diff --git a/frontend/components/Account/EditForm.vue b/frontend/components/Account/EditForm.vue index 2fe5dcbdbf..c613dabe43 100644 --- a/frontend/components/Account/EditForm.vue +++ b/frontend/components/Account/EditForm.vue @@ -44,11 +44,11 @@ diff --git a/frontend/components/Admin/Users/Form.vue b/frontend/components/Admin/Users/Form.vue index 419af39360..1a61563d45 100644 --- a/frontend/components/Admin/Users/Form.vue +++ b/frontend/components/Admin/Users/Form.vue @@ -34,7 +34,7 @@ id="edit_form_roles" class="col-md-12" :field="v$.roles" - :options="roleOptions" + :options="roles" :label="$gettext('Roles')" /> @@ -42,15 +42,14 @@ diff --git a/frontend/components/Common/BitrateOptions.vue b/frontend/components/Common/BitrateOptions.vue index b87a51be41..1847fdc077 100644 --- a/frontend/components/Common/BitrateOptions.vue +++ b/frontend/components/Common/BitrateOptions.vue @@ -72,10 +72,10 @@ diff --git a/frontend/components/Form/FormGroupSelect.vue b/frontend/components/Form/FormGroupSelect.vue index e88a5078be..3f99e81ae2 100644 --- a/frontend/components/Form/FormGroupSelect.vue +++ b/frontend/components/Form/FormGroupSelect.vue @@ -64,14 +64,14 @@ import FormGroup from "~/components/Form/FormGroup.vue"; import {FormFieldProps, useFormField} from "~/components/Form/useFormField"; import SelectOptions from "~/components/Form/SelectOptions.vue"; import {useSlots} from "vue"; -import {FormOptionInput} from "~/functions/objectToFormOptions.ts"; +import {NestedFormOptionInput} from "~/functions/objectToNestedFormOptions.ts"; interface FormGroupSelectProps extends FormFieldProps, FormLabelParentProps { id: string, name?: string, label?: string, description?: string, - options: FormOptionInput, + options: NestedFormOptionInput, multiple?: boolean, } diff --git a/frontend/components/Form/FormMultiCheck.vue b/frontend/components/Form/FormMultiCheck.vue index 85e48f878a..ef256078e8 100644 --- a/frontend/components/Form/FormMultiCheck.vue +++ b/frontend/components/Form/FormMultiCheck.vue @@ -1,7 +1,7 @@ import {useVModel} from "@vueuse/core"; -import {FormOption} from "~/functions/objectToFormOptions.ts"; +import {objectToSimpleFormOptions, SimpleFormOptionInput} from "~/functions/objectToFormOptions.ts"; +import {ModelFormField} from "~/components/Form/useFormField.ts"; +import {toRef} from "vue"; const props = withDefaults( defineProps<{ - modelValue: string | number | boolean | Array, + modelValue?: ModelFormField, id: string, name: string, fieldClass?: string, - options: FormOption[], + options: SimpleFormOptionInput, radio?: boolean, stacked?: boolean }>(), { + modelValue: null, name: (props) => props.id, fieldClass: null, radio: false, @@ -59,4 +62,6 @@ const props = withDefaults( const emit = defineEmits(['update:modelValue']); const value = useVModel(props, 'modelValue', emit); + +const parsedOptions = objectToSimpleFormOptions(toRef(props, 'options')); diff --git a/frontend/components/Form/SelectOptions.vue b/frontend/components/Form/SelectOptions.vue index 6b66d99f38..30b3f5b608 100644 --- a/frontend/components/Form/SelectOptions.vue +++ b/frontend/components/Form/SelectOptions.vue @@ -4,7 +4,7 @@ :key="index" > @@ -20,11 +20,11 @@ diff --git a/frontend/components/Form/useFormField.ts b/frontend/components/Form/useFormField.ts index ae1defdc3f..62964b3f3c 100644 --- a/frontend/components/Form/useFormField.ts +++ b/frontend/components/Form/useFormField.ts @@ -1,36 +1,46 @@ -import {computed} from "vue"; +import {computed, ComputedRef, WritableComputedRef} from "vue"; import {has} from "lodash"; +import {VuelidateObject} from "~/functions/useVuelidateOnForm.ts"; -type ValidFormField = string | number | boolean | Array +export type ModelFormField = string | number | boolean | Array | null export interface FormFieldProps { - field?: object, - modelValue?: ValidFormField, + field?: VuelidateObject, + modelValue?: ModelFormField, required?: boolean } export interface FormFieldEmits { - (e: 'update:modelValue', value: ValidFormField): void + (e: 'update:modelValue', value: ModelFormField): void } -export function useFormField(initialProps: FormFieldProps, emit: FormFieldEmits) { - const props = { +export function useFormField( + initialProps: FormFieldProps, + emit: FormFieldEmits +): { + isVuelidateField: ComputedRef, + model: WritableComputedRef, + fieldClass: ComputedRef, + isRequired: ComputedRef +} { + const props: FormFieldProps = { required: false, ...initialProps }; - const isVuelidateField = computed(() => { - return props.field !== undefined; - }); + const isVuelidateField = computed( + () => props.field !== undefined + ); - const model = computed({ + const model: WritableComputedRef = computed({ get() { return (isVuelidateField.value) - ? props.field.$model + ? props.field.$model as ModelFormField : props.modelValue; }, - set(newValue) { + set(newValue: ModelFormField) { if (isVuelidateField.value) { + // @ts-expect-error Vuelidate mistypes this. props.field.$model = newValue; } else { emit('update:modelValue', newValue); diff --git a/frontend/components/Stations/Podcasts/PodcastForm/Source.vue b/frontend/components/Stations/Podcasts/PodcastForm/Source.vue index c8e4552b5c..084c1a82e8 100644 --- a/frontend/components/Stations/Podcasts/PodcastForm/Source.vue +++ b/frontend/components/Stations/Podcasts/PodcastForm/Source.vue @@ -66,7 +66,6 @@ import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue"; import {useTranslate} from "~/vendor/gettext.ts"; import {onMounted, ref, shallowRef} from "vue"; import {useAxios} from "~/vendor/axios.ts"; -import objectToFormOptions from "~/functions/objectToFormOptions.ts"; import {getStationApiUrl} from "~/router.ts"; import Loading from "~/components/Common/Loading.vue"; @@ -111,7 +110,7 @@ const playlistsApiUrl = getStationApiUrl('/podcasts/playlists'); const loadPlaylists = () => { axios.get(playlistsApiUrl.value).then((resp) => { - playlistOptions.value = objectToFormOptions(resp.data).value; + playlistOptions.value = resp.data; }).finally(() => { playlistsLoading.value = false; }); diff --git a/frontend/components/Stations/menu.ts b/frontend/components/Stations/menu.ts index bedaf40950..754b0f60cb 100644 --- a/frontend/components/Stations/menu.ts +++ b/frontend/components/Stations/menu.ts @@ -1,5 +1,5 @@ import {useTranslate} from "~/vendor/gettext.ts"; -import filterMenu, {MenuCategory, ReactiveMenu} from "~/functions/filterMenu.ts"; +import filterMenu, {ReactiveMenu} from "~/functions/filterMenu.ts"; import {StationPermission, userAllowedForStation} from "~/acl.ts"; import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts"; import {computed, reactive} from "vue"; @@ -25,7 +25,7 @@ export function useStationsMenu(): ReactiveMenu { // Reuse this variable to avoid multiple calls. const userCanManageMedia = userAllowedForStation(StationPermission.Media); - const menu: ReactiveMenu = reactive>([ + const menu: ReactiveMenu = reactive([ { key: 'profile', label: computed(() => $gettext('Profile')), diff --git a/frontend/functions/filterMenu.ts b/frontend/functions/filterMenu.ts index 3f08ced0d5..c529cc3f2b 100644 --- a/frontend/functions/filterMenu.ts +++ b/frontend/functions/filterMenu.ts @@ -1,9 +1,10 @@ -import {filter, get, map} from "lodash"; -import { ComputedRef, UnwrapNestedRefs } from "vue"; -import { Icon } from "../components/Common/icons"; -import { RouteLocationRaw } from "vue-router"; +import {cloneDeep, filter, get, map} from "lodash"; +import {ComputedRef, Reactive} from "vue"; +import {Icon} from "../components/Common/icons"; +import {RouteLocationRaw} from "vue-router"; +import {reactiveComputed} from "@vueuse/core"; -export type ReactiveMenu = UnwrapNestedRefs>; +export type ReactiveMenu = Reactive>; export interface MenuSubCategory { key: string, @@ -12,6 +13,8 @@ export interface MenuSubCategory { icon?: Icon | null, visible?: boolean | null, external?: boolean | null, + title?: string, + class?: string, } export interface MenuCategory extends MenuSubCategory { @@ -19,25 +22,30 @@ export interface MenuCategory extends MenuSubCategory { } export default function filterMenu(menuItems: ReactiveMenu): ReactiveMenu { - return filter(map( - menuItems, - (menuRow: MenuCategory) => { - const itemIsVisible: boolean = get(menuRow, 'visible', true); - if (!itemIsVisible) { - return null; - } + return reactiveComputed( + () => filter( + map( + cloneDeep(menuItems), + (menuRow: MenuCategory): MenuCategory | null => { + const itemIsVisible: boolean = get(menuRow, 'visible', true); + if (!itemIsVisible) { + return null; + } - if ('items' in menuRow) { - menuRow.items = filter(menuRow.items, (item) => { - return get(item, 'visible', true); - }); + if ('items' in menuRow) { + menuRow.items = filter(menuRow.items, (item) => { + return get(item, 'visible', true); + }); - if (menuRow.items.length === 0) { - return null; - } - } + if (menuRow.items.length === 0) { + return null; + } + } - return menuRow; - } - )); + return menuRow; + } + ), + (row: MenuCategory | null) => null !== row + ) + ); } diff --git a/frontend/functions/mergeExisting.ts b/frontend/functions/mergeExisting.ts index a1b2074b3d..8025fe119d 100644 --- a/frontend/functions/mergeExisting.ts +++ b/frontend/functions/mergeExisting.ts @@ -5,7 +5,10 @@ import {toRaw} from "vue"; * A "deep" merge that only merges items from the source into the destination that already exist in the destination. * Useful for merging in form values with API returns. */ -export default function mergeExisting(destRaw, sourceRaw) { +export default function mergeExisting( + destRaw: T, + sourceRaw: Partial +): T { const dest = toRaw(destRaw); const source = toRaw(sourceRaw); diff --git a/frontend/functions/objectToFormOptions.ts b/frontend/functions/objectToFormOptions.ts index 600c274d32..c2c76deb00 100644 --- a/frontend/functions/objectToFormOptions.ts +++ b/frontend/functions/objectToFormOptions.ts @@ -1,25 +1,48 @@ import {map} from 'lodash'; -import {MaybeRefOrGetter, computed, ComputedRef, toValue} from "vue"; +import {computed, ComputedRef, MaybeRefOrGetter, toValue} from "vue"; export interface FormOption { - value: any, + value: string | number, text: string, description?: string } +type SimpleFormOptionObject = Record<(string | number), string> + +export type SimpleFormOptionInput = FormOption[] | SimpleFormOptionObject + +export function objectToSimpleFormOptions( + initial: MaybeRefOrGetter, +): ComputedRef { + return computed(() => { + const array = toValue(initial); + + if (Array.isArray(array)) { + return array; + } + + return map(array, (outerValue, outerKey) => ({ + text: outerValue, + value: outerKey + })); + }); +} + export interface FormOptionGroup { options: FormOption[], label: string, } -export type FormOptionInput = (FormOption | FormOptionGroup)[] | Record; -export type FormOptionOutput = (FormOption | FormOptionGroup)[]; +type NestedFormOptionObject = SimpleFormOptionObject | Record<(string | number), SimpleFormOptionObject> + +export type NestedFormOptionInput = (FormOption | FormOptionGroup)[] | NestedFormOptionObject; +export type NestedFormOptionOutput = (FormOption | FormOptionGroup)[]; -export default function objectToFormOptions( - initial: MaybeRefOrGetter -): ComputedRef { +export function objectToNestedFormOptions( + initial: MaybeRefOrGetter +): ComputedRef { return computed(() => { - const array: FormOptionInput = toValue(initial); + const array = toValue(initial); if (Array.isArray(array)) { return array; diff --git a/frontend/functions/useBaseEditModal.ts b/frontend/functions/useBaseEditModal.ts index 12e8ac419e..0bb3125cc3 100644 --- a/frontend/functions/useBaseEditModal.ts +++ b/frontend/functions/useBaseEditModal.ts @@ -18,14 +18,14 @@ export interface BaseEditModalEmits { (e: 'relist'): void } -export interface BaseEditModalOptions extends GlobalConfig { +export interface BaseEditModalOptions extends GlobalConfig { resetForm?(originalResetForm: () => void): void, clearContents?(resetForm: () => void): void, - populateForm?(data: Record, form: Ref): void, + populateForm?(data: Partial, form: Ref): void, - getSubmittableFormData?(form: Ref, isEditMode: ComputedRef): Record, + getSubmittableFormData?(form: Ref, isEditMode: ComputedRef): Record, buildSubmitRequest?(): AxiosRequestConfig, @@ -38,15 +38,15 @@ export function useBaseEditModal( props: BaseEditModalProps, emit: BaseEditModalEmits, $modal: Ref, - validations: VuelidateValidations = {}, - blankForm: T = {}, - options: BaseEditModalOptions = {} + validations: VuelidateValidations, + blankForm: T, + options: BaseEditModalOptions = {} ): { loading: Ref, error: Ref, editUrl: Ref, isEditMode: ComputedRef, - form: Form, + form: Ref, v$: VuelidateRef, resetForm(): void, clearContents(): void, @@ -107,7 +107,7 @@ export function useBaseEditModal( }); }; - const populateForm = (data: Record): void => { + const populateForm = (data: Partial): void => { if (typeof options.populateForm === 'function') { options.populateForm(data, form); return; diff --git a/frontend/functions/useChart.ts b/frontend/functions/useChart.ts index bc8c81a3cc..0b70049c1d 100644 --- a/frontend/functions/useChart.ts +++ b/frontend/functions/useChart.ts @@ -1,4 +1,7 @@ -import {Chart, registerables} from "chart.js"; +import {Chart, registerables, ChartConfiguration, + ChartConfigurationCustomTypesPerDataset, + ChartType, + DefaultDataPoint} from "chart.js"; import {defaultsDeep} from "lodash"; import {computed, isRef, MaybeRef, onMounted, onUnmounted, Ref, toRef, toValue, watch} from "vue"; import zoomPlugin from 'chartjs-plugin-zoom'; @@ -6,12 +9,6 @@ import chartjsColorSchemes from "~/vendor/chartjs_colorschemes.ts"; import 'chartjs-adapter-luxon'; import '~/vendor/luxon'; -import { - ChartConfiguration, - ChartConfigurationCustomTypesPerDataset, - ChartType, - DefaultDataPoint -} from "chart.js/dist/types"; Chart.register(...registerables); @@ -36,16 +33,13 @@ export interface ChartProps< TData = DefaultDataPoint, TLabel = unknown > { - options?: ChartConfiguration | ChartConfigurationCustomTypesPerDataset, + options?: Partial | ChartConfigurationCustomTypesPerDataset>, data?: any[], aspectRatio?: number, alt?: ChartAltData[], labels?: Array } -export const chartProps = { -}; - export type ChartTemplateRef = HTMLCanvasElement | null; export default function useChart< @@ -56,7 +50,7 @@ export default function useChart< initialProps: ChartProps, $canvas: Ref, defaultOptions: MaybeRef< - ChartConfiguration | ChartConfigurationCustomTypesPerDataset + Partial | ChartConfigurationCustomTypesPerDataset> > ): { $chart: Chart | null diff --git a/frontend/functions/useVuelidateOnFormTab.ts b/frontend/functions/useVuelidateOnFormTab.ts index a5807acbcf..a797fa80d9 100644 --- a/frontend/functions/useVuelidateOnFormTab.ts +++ b/frontend/functions/useVuelidateOnFormTab.ts @@ -44,7 +44,7 @@ export function useVuelidateOnFormTab< // Register event listener for blankForm building. const formEventBus = useEventBus('form_tabs'); - formEventBus.on((addToForm) => { + formEventBus.on((addToForm: (blankForm: Partial) => void) => { addToForm(blankForm); });