hotfix: enhance radio button and file upload components

- Improved the styling and functionality of the radio button component, including a new radio button style.
- Added a loading spinner to the file upload component to indicate when large files are being processed.
- Added the ability to sort files in the file upload component.
- Fixed an issue where the radio button component was not correctly updating the model value.
- Updated the dataset creation and edit forms to use the new radio button component.
- Added a global declaration for the `sort_order` property on the `File` interface.
- Updated the API to filter authors by first and last name.
- Removed the import of `_checkbox-radio-switch.css` as the radio button styling is now handled within the component.
This commit is contained in:
Kaimbacher 2025-03-27 16:04:23 +01:00
parent b93e46207f
commit 9823364670
11 changed files with 272 additions and 181 deletions

View file

@ -1,7 +1,7 @@
/* @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap'); */
/* @import url('https://fonts.googleapis.com/css?family=Roboto:400,400i,600,700'); */
@import '_checkbox-radio-switch.css';
/* @import '_checkbox-radio-switch.css'; */
@import '_progress.css';
@import '_scrollbars.css';
@import '_table.css';

View file

@ -17,6 +17,15 @@
<p class="text-lg text-blue-700">Drop files to upload</p>
</div>
<!-- Loading Spinner when processing big files -->
<div v-if="isLoading" class="absolute inset-0 z-60 flex items-center justify-center bg-gray-500 bg-opacity-50">
<svg class="animate-spin h-12 w-12 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M12 2a10 10 0 0110 10h-4a6 6 0 00-6-6V2z"></path>
</svg>
</div>
<!-- scroll area -->
<div class="h-full p-8 w-full h-full flex flex-col">
<header class="flex items-center justify-center w-full">
@ -32,9 +41,8 @@
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">
<span class="font-semibold">Click to upload</span> or drag and drop
</p>
<!-- <p class="text-xs text-gray-500 dark:text-gray-400">SVG, PNG, JPG or GIF (MAX. 800x400px)</p> -->
</div>
<input id="dropzone-file" type="file" class="hidden" @change="onChangeFile" multiple="true" />
<input id="dropzone-file" type="file" class="hidden" @click="showSpinner" @change="onChangeFile" @cancel="cancelSpinner" multiple="true" />
</label>
</header>
@ -241,6 +249,8 @@ class FileUploadComponent extends Vue {
@Ref('overlay') overlay: HTMLDivElement;
public isLoading: boolean = false;
private counter: number = 0;
// @Prop() files: Array<TestFile>;
@ -264,7 +274,7 @@ class FileUploadComponent extends Vue {
set deletetFiles(values: Array<TethysFile>) {
// this.modelValue = value;
this.filesToDelete.length = 0;
this.filesToDelete.push(...values);
this.filesToDelete.push(...values);
}
get items(): Array<TethysFile | File> {
@ -342,10 +352,10 @@ class FileUploadComponent extends Vue {
}
// reset counter and append file to gallery when file is dropped
public dropHandler(event: DragEvent): void {
event.preventDefault();
const dataTransfer = event.dataTransfer;
// let bigFileFound = false;
if (dataTransfer) {
for (const file of event.dataTransfer?.files) {
// let fileName = String(file.name.replace(/\.[^/.]+$/, ''));
@ -353,28 +363,73 @@ class FileUploadComponent extends Vue {
// if (file.type.match('image.*')) {
// this.generateURL(file);
// }
// if (file.size > 62914560) { // 60 MB in bytes
// bigFileFound = true;
// }
this._addFile(file);
}
this.overlay.classList.remove('draggedover');
this.counter = 0;
}
// if (bigFileFound) {
// this.isLoading = true;
// // Assume file processing delay; adjust timeout as needed or rely on async processing completion.
// setTimeout(() => {
// this.isLoading = false;
// }, 1500);
// }
}
public showSpinner() {
// event.preventDefault();
this.isLoading = true;
}
public cancelSpinner() {
// const target = event.target as HTMLInputElement;
// // If no files were selected, remove spinner
// if (!target.files || target.files.length === 0) {
// this.isLoading = false;
// }
this.isLoading = false;
}
public onChangeFile(event: Event) {
event.preventDefault();
let target = event.target as HTMLInputElement;
// let uploadedFile = event.target.files[0];
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
for (const file of event.target.files) {
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
// file.label = fileName;
// if (file.type.match('image.*')) {
// this.generateURL(file);
// }
this._addFile(file);
if (target && target.files) {
for (const file of event.target.files) {
// let fileName = String(event.target.files[0].name.replace(/\.[^/.]+$/, ''));
// file.label = fileName;
// if (file.type.match('image.*')) {
// this.generateURL(file);
// }
// Immediately set spinner if any file is large (over 100 MB)
// for (const file of target.files) {
// if (file.size > 62914560) { // 100 MB
// bigFileFound = true;
// break;
// }
// }
// if (bigFileFound) {
// this.isLoading = true;
// }
this._addFile(file);
}
}
// if (bigFileFound) {
// this.isLoading = true;
// setTimeout(() => {
// this.isLoading = false;
// }, 1500);
// }
// this.overlay.classList.remove('draggedover');
this.counter = 0;
this.isLoading = false;
}
get errors(): IDictionary {
@ -445,7 +500,7 @@ class FileUploadComponent extends Vue {
let localUrl: string = '';
if (file instanceof File) {
localUrl = URL.createObjectURL(file as Blob);
}
}
// else if (file.fileData) {
// // const blob = new Blob([file.fileData]);
// // localUrl = URL.createObjectURL(blob);
@ -465,17 +520,6 @@ class FileUploadComponent extends Vue {
return localUrl;
}
// private async downloadFile(id: number): Promise<string> {
// const response = await axios.get<Blob>(`/api/download/${id}`, {
// responseType: 'blob',
// });
// const url = URL.createObjectURL(response.data);
// setTimeout(() => {
// URL.revokeObjectURL(url);
// }, 1000);
// return url;
// }
public getFileSize(file: File) {
if (file.size > 1024) {
if (file.size > 1048576) {
@ -488,17 +532,6 @@ class FileUploadComponent extends Vue {
}
}
// private _addFile(file) {
// // const isImage = file.type.match('image.*');
// // const objectURL = URL.createObjectURL(file);
// // this.files[objectURL] = file;
// // let test: TethysFile = { upload: file, label: "dfdsfs", sorting: 0 };
// // file.sorting = this.files.length;
// file.sort_order = (this.items.length + 1),
// this.files.push(file);
// }
private _addFile(file: File) {
// const reader = new FileReader();
// reader.onload = (event) => {
@ -530,14 +563,11 @@ class FileUploadComponent extends Vue {
// this.items.push(test);
this.items[this.items.length] = test;
} else {
file.sort_order = this.items.length + 1;
this.items.push(file);
}
}
// use to check if a file is being dragged
// private _hasFiles({ types = [] as Array<string> }) {
// return types.indexOf('Files') > -1;
// }
private _hasFiles(dataTransfer: DataTransfer | null): boolean {
return dataTransfer ? dataTransfer.items.length > 0 : false;
}

View file

@ -1,6 +1,15 @@
<script setup>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
name: string;
type?: 'checkbox' | 'radio' | 'switch';
label?: string | null;
modelValue: Array<any> | string | number | boolean | null;
inputValue: string | number | boolean;
}
const props = defineProps({
name: {
type: String,
@ -9,7 +18,7 @@ const props = defineProps({
type: {
type: String,
default: 'checkbox',
validator: (value) => ['checkbox', 'radio', 'switch'].includes(value),
validator: (value: string) => ['checkbox', 'radio', 'switch'].includes(value),
},
label: {
type: String,
@ -23,19 +32,65 @@ const props = defineProps({
type: [String, Number, Boolean],
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
});// const props = defineProps<Props>();
// const emit = defineEmits(['update:modelValue']);
const emit = defineEmits<{ (e: 'update:modelValue', value: Props['modelValue']): void }>();
const computedValue = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value);
// If type is radio, wrap the new value inside an array.
if (props.type === 'radio') {
emit('update:modelValue', [value]);
} else {
emit('update:modelValue', value);
}
},
});
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'));
// Define isChecked for radio inputs: it's true when the current modelValue equals the inputValue
const isChecked = computed(() => {
if (props.type === 'radio') {
return Array.isArray(computedValue.value) &&
computedValue.value.length > 0 &&
computedValue.value[0] === props.inputValue;
}
return computedValue.value === props.inputValue;
});
</script>
<!-- <template>
<label v-if="type == 'radio'" :class="[type, 'mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative']">
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue" class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0" />
<span
class="check border-gray-700 border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full"
:class="{ 'bg-no-repeat bg-center bg-blue-600 border-blue-600 border-4': isChecked }"/>
<span class="pl-2">{{ label }}</span>
</label>
<label v-else :class="[type, 'mr-6 mb-3 last:mr-0']">
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue" />
<span class="check" />
<span class="pl-2">{{ label }}</span>
</label>
</template> -->
<template>
<label :class="type" class="mr-6 mb-3 last:mr-0">
<label v-if="type === 'radio'" :class="[type]"
class="mr-6 mb-3 last:mr-0 inline-flex items-center cursor-pointer relative">
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue"
class="absolute left-0 opacity-0 -z-1 focus:outline-none focus:ring-0"
:checked="isChecked" />
<span class="check border transition-colors duration-200 dark:bg-slate-800 block w-5 h-5 rounded-full" :class="{
'border-gray-700': !isChecked,
'bg-radio-checked bg-no-repeat bg-center bg-blue-600 border-blue-600 border-4': isChecked
}" />
<span class="pl-2">{{ label }}</span>
</label>
<label v-else class="mr-6 mb-3 last:mr-0" :class="[type]">
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue" />
<span class="check" />
<span class="pl-2">{{ label }}</span>

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, ref, PropType } from 'vue';
import FormCheckRadio from '@/Components/FormCheckRadio.vue';
import BaseButton from '@/Components/BaseButton.vue';
import FormControl from '@/Components/FormControl.vue';
import { mdiPlusCircle } from '@mdi/js';
// import BaseButton from '@/Components/BaseButton.vue';
// import FormControl from '@/Components/FormControl.vue';
const props = defineProps({
options: {
type: Object,
@ -23,7 +23,7 @@ const props = defineProps({
required: true,
},
type: {
type: String,
type: String as PropType<'checkbox' | 'radio' | 'switch'>,
default: 'checkbox',
validator: (value: string) => ['checkbox', 'radio', 'switch'].includes(value),
},
@ -78,11 +78,11 @@ const addOption = () => {
const inputElClass = computed(() => {
const base = [
'px-3 py-2 max-w-full focus:ring focus:outline-none border-gray-700 rounded w-full',
'px-3 py-2 max-w-full border-gray-700 rounded w-full',
'dark:placeholder-gray-400',
'h-12',
'border',
'bg-transparent'
'bg-transparent'
// props.isReadOnly ? 'bg-gray-50 dark:bg-slate-600' : 'bg-white dark:bg-slate-800',
];
// if (props.icon) {
@ -108,7 +108,7 @@ const inputElClass = computed(() => {
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-6H5v-2h6V5h2v6h6v2h-6v6z" />
</svg>
</div>
<FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type" :name="name"
:input-value="key" :label="value" :class="componentClass" />
<FormCheckRadio v-for="(value, key) in options" :key="key" v-model="computedValue" :type="type"
:name="name" :input-value="key" :label="value" :class="componentClass" />
</div>
</template>

View file

@ -320,12 +320,17 @@ const nextStep = async () => {
} else if (formStep.value == 3) {
route = stardust.route('dataset.third.step');
}
// formStep.value++;
// When posting in steps 1-3, remove any file uploads from the data.
await form
.transform((data) => ({
...data,
rights: form.rights && form.rights == true ? 'true' : 'false',
}))
.transform((data: Dataset) => {
// Create payload and set rights (transforming to a string if needed)
const payload: any = { ...data, rights: data.rights ? 'true' : 'false' };
// Remove the files property so that the partial update is done without files
if (payload.files) {
delete payload.files;
}
return payload;
})
.post(route, {
onSuccess: () => {
// console.log(form.data());
@ -334,7 +339,6 @@ const nextStep = async () => {
},
});
};
const prevStep = () => {
formStep.value--;
};
@ -343,7 +347,7 @@ const submit = async () => {
let route = stardust.route('dataset.submit');
const files = form.files.map((obj) => {
return new File([obj.blob], obj.label, { type: obj.type, lastModified: obj.lastModified });
return new File([obj.blob], obj.label, { type: obj.type, lastModified: obj.lastModified, sort_order: obj.sort_order });
});
// formStep.value++;
@ -576,7 +580,7 @@ Removes a selected keyword
<FormField label="Licenses" wrap-body :class="{ 'text-red-400': form.errors.licenses }"
class="mt-8 w-full mx-2 flex-1">
<FormCheckRadioGroup v-model="form.licenses" name="roles" is-column
<FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column
:options="props.licenses" />
</FormField>

View file

@ -42,7 +42,7 @@
<!-- (2) licenses -->
<FormField label="Licenses" wrap-body :class="{ 'text-red-400': form.errors.licenses }"
class="mt-8 w-full mx-2 flex-1">
<FormCheckRadioGroup v-model="form.licenses" name="licenses" is-column :options="licenses" />
<FormCheckRadioGroup type="radio" v-model="form.licenses" name="licenses" is-column :options="licenses" />
</FormField>
<div class="flex flex-col md:flex-row">