UNPKG

@jsonforms/vue-vuetify

Version:

Vue Vuetify renderers for JSON Forms

535 lines (470 loc) 13.3 kB
import { aliases as faIcons } from '@/icons/fa'; import type { IconAliases } from '@/icons/icons'; import { aliases as mdiIcons } from '@/icons/mdi'; import { Resolve, arrayDefaultTranslations, combinatorDefaultTranslations, composePaths, computeLabel, defaultJsonFormsI18nState, getArrayTranslations, getCombinatorTranslations, getCombinedErrorMessage, getControlPath, getErrorTranslator, getFirstPrimitiveProp, getTranslator, isDescriptionHidden, type ControlElement, type DispatchPropsOfControl, type DispatchPropsOfMultiEnumControl, type JsonSchema, type UISchemaElement, } from '@jsonforms/core'; import { useJsonForms } from '@jsonforms/vue'; import type { ErrorObject } from 'ajv'; import cloneDeep from 'lodash/cloneDeep'; import debounce from 'lodash/debounce'; import get from 'lodash/get'; import isPlainObject from 'lodash/isPlainObject'; import merge from 'lodash/merge'; import { computed, inject, provide, ref, type ComputedRef, type InjectionKey, } from 'vue'; import type { IconOptions } from 'vuetify'; import { useStyles } from '../styles'; import { IsDynamicPropertyContext } from './inject'; export const IconSymbol: InjectionKey<Required<IconOptions>> = Symbol.for('vuetify:icons'); export const useControlAppliedOptions = < T extends { config: any; uischema: UISchemaElement }, I extends { control: ComputedRef<T>; }, >( input: I, ) => { return computed(() => merge( {}, cloneDeep(input.control.value.config), cloneDeep(input.control.value.uischema.options), ), ); }; export const useLayoutAppliedOptions = < T extends { config: any; uischema: UISchemaElement }, I extends { layout: ComputedRef<T>; }, >( input: I, ) => { return computed(() => merge( {}, cloneDeep(input.layout.value.config), cloneDeep(input.layout.value.uischema.options), ), ); }; export const useComputedLabel = < T extends { label: string; required: boolean }, I extends { control: ComputedRef<T> }, >( input: I, appliedOptions: ReturnType<typeof useControlAppliedOptions>, ) => { return computed((): string => { return computeLabel( input.control.value.label, input.control.value.required, !!appliedOptions.value?.hideRequiredAsterisk, ); }); }; export const isControlEditable = (control: { enabled: boolean; readonly: boolean; }) => control.enabled && !control.readonly; /** * Adds styles, appliedOptions and vuetifyProps */ export const useVuetifyLabel = < T extends { uischema: UISchemaElement; config: any; }, I extends { label: ComputedRef<T>; }, >( input: I, ) => { const styles = useStyles(input.label.value.uischema); const appliedOptions = computed(() => merge( {}, cloneDeep(input.label.value.config), cloneDeep(input.label.value.uischema.options), ), ); const vuetifyProps = (path: string) => { const props = get(appliedOptions.value?.vuetify, path); return props && isPlainObject(props) ? props : {}; }; return { ...input, appliedOptions, vuetifyProps, styles, }; }; /** * Adds styles, isFocused, appliedOptions and onChange */ export const useVuetifyControl = < T extends { schema: JsonSchema; uischema: ControlElement; path: string; config: any; label: string; description: string; required: boolean; errors: string; id: string; visible: boolean; enabled: boolean; readonly: boolean; }, I extends { control: ComputedRef<T>; } & (DispatchPropsOfControl | DispatchPropsOfMultiEnumControl), >( input: I, adaptValue: (target: any) => any = (v) => v, debounceWait?: number, ) => { const touched = ref(false); const changeEmitter = typeof debounceWait === 'number' && (input as DispatchPropsOfControl).handleChange ? debounce((input as DispatchPropsOfControl).handleChange, debounceWait) : (input as DispatchPropsOfControl).handleChange; const onChange = (value: any) => { if (changeEmitter) { changeEmitter(input.control.value.path, adaptValue(value)); } }; const appliedOptions = useControlAppliedOptions(input); const isFocused = ref(false); const handleFocus = () => { isFocused.value = true; }; const handleBlur = () => { touched.value = true; isFocused.value = false; if (changeEmitter && (changeEmitter as any).flush) { (changeEmitter as any).flush(); } }; const jsonforms = useJsonForms(); const filteredErrors = computed(() => { // Always show errors if touched, no errors exist, or filtering is not enabled if ( touched.value || !input.control.value.errors || !appliedOptions.value.enableFilterErrorsBeforeTouch ) { return input.control.value.errors; } const filterKeywords = appliedOptions.value.filterErrorKeywordsBeforeTouch; // Filtering is enabled - check if specific keywords are configured if (Array.isArray(filterKeywords) && filterKeywords.length > 0) { // Granular filtering: only hide specific error keywords const errorsAtControl = jsonforms.core?.errors?.filter( (error) => input.control.value.path === getControlPath(error), ) ?? []; // Filter out errors that match the filterKeywords, keep the rest const errorsToShow = errorsAtControl.filter( (error) => !error.keyword || !filterKeywords.includes(error.keyword), ); // If no errors were filtered out (all errors remain), return original errors string if (errorsToShow.length === errorsAtControl.length) { return input.control.value.errors; } const t = getTranslator()({ jsonforms }); const te = getErrorTranslator()({ jsonforms }); return getCombinedErrorMessage( errorsToShow, te, t, input.control.value.schema, input.control.value.uischema, input.control.value.path, ); } // default, all errors are filtered return ''; }); const persistentHint = (): boolean => { return !isDescriptionHidden( input.control.value.visible, input.control.value.description, isFocused.value, !!appliedOptions.value?.showUnfocusedDescription, ); }; const computedLabel = useComputedLabel(input, appliedOptions); const styles = useStyles(input.control.value.uischema); const vuetifyProps = (path: string) => { const props = get(appliedOptions.value?.vuetify, path); return props && isPlainObject(props) ? props : {}; }; const overwrittenControl = computed(() => { return { ...input.control.value, errors: filteredErrors.value, }; }); const controlWrapper = computed(() => { const { id, description, errors, label, visible, required } = overwrittenControl.value; return { id, description, errors, label, visible, required }; }); const rawErrors = computed(() => input.control.value.errors); const clearable = computed(() => { return appliedOptions.value.clearable !== undefined ? appliedOptions.value.clearable && input.control.value.enabled : isControlEditable(input.control.value); }); return { ...input, control: overwrittenControl, styles, isFocused, appliedOptions, controlWrapper, onChange, vuetifyProps, persistentHint, computedLabel, clearable, touched, handleBlur, handleFocus, rawErrors, }; }; export const useCombinatorTranslations = < T extends { i18nKeyPrefix: string; label: string; }, I extends { control: ComputedRef<T>; }, >( input: I, ) => { const jsonforms = useJsonForms(); const translations = getCombinatorTranslations( jsonforms?.i18n?.translate ?? defaultJsonFormsI18nState.translate, combinatorDefaultTranslations, input.control.value.i18nKeyPrefix, input.control.value.label, ); const overwrittenControl = computed(() => { return { ...input.control.value, translations, }; }); return { ...input, control: overwrittenControl, }; }; /** * Adds styles and appliedOptions */ export const useVuetifyLayout = < T extends { config: any; uischema: UISchemaElement }, I extends { layout: ComputedRef<T> }, >( input: I, ) => { const appliedOptions = useLayoutAppliedOptions(input); const vuetifyProps = (path: string) => { const props = get(appliedOptions.value?.vuetify, path); return props && isPlainObject(props) ? props : {}; }; return { ...input, styles: useStyles(input.layout.value.uischema), appliedOptions, vuetifyProps, }; }; /** * Adds styles, appliedOptions and childUiSchema */ export const useVuetifyArrayControl = < T extends { label: string; required: boolean; config: any; uischema: UISchemaElement; schema: JsonSchema; data: any; childErrors: ErrorObject[]; i18nKeyPrefix: string; }, I extends { control: ComputedRef<T>; }, >( input: I, ) => { const appliedOptions = useControlAppliedOptions(input); const computedLabel = useComputedLabel(input, appliedOptions); const vuetifyProps = (path: string) => { const props = get(appliedOptions.value?.vuetify, path); return props && isPlainObject(props) ? props : {}; }; const childLabelForIndex = (index: number | null) => { if (index === null) { return ''; } const childLabelProp = input.control.value.uischema.options?.childLabelProp ?? getFirstPrimitiveProp(input.control.value.schema); if (!childLabelProp) { return `${index}`; } const labelValue = Resolve.data( input.control.value.data, composePaths(`${index}`, childLabelProp), ); if ( labelValue === undefined || labelValue === null || Number.isNaN(labelValue) ) { return ''; } return `${labelValue}`; }; const filteredChildErrors = computed(() => { if ( !input.control.value.childErrors || input.control.value.childErrors.length === 0 || !appliedOptions.value.enableFilterErrorsBeforeTouch ) { return input.control.value.childErrors; } // supress childErrors unless touch filtering is disabled // otherwise all child errors will show, irrespective of their control touch state const filterKeywords = appliedOptions.value.filterErrorKeywordsBeforeTouch; // Filtering is enabled - check if specific keywords are configured if (Array.isArray(filterKeywords) && filterKeywords.length > 0) { // Granular filtering: only hide specific error keywords const errorsToShow = input.control.value.childErrors.filter( (error) => !error.keyword || !filterKeywords.includes(error.keyword), ); return errorsToShow; } // default, all child errors are filtered return []; }); const jsonforms = useJsonForms(); const translations = getArrayTranslations( jsonforms?.i18n?.translate ?? defaultJsonFormsI18nState.translate, arrayDefaultTranslations, input.control.value.i18nKeyPrefix, input.control.value.label, ); const overwrittenControl = computed(() => { return { ...input.control.value, childErrors: filteredChildErrors.value, translations, }; }); const rawChildErrors = computed(() => input.control.value.childErrors); return { ...input, control: overwrittenControl, styles: useStyles(input.control.value.uischema), appliedOptions, childLabelForIndex, computedLabel, vuetifyProps, rawChildErrors, }; }; /** * Adds styles and appliedOptions */ export const useVuetifyBasicControl = < T extends { config: any; uischema: UISchemaElement }, I extends { control: ComputedRef<T>; }, >( input: I, ) => { const appliedOptions = useControlAppliedOptions(input); const vuetifyProps = (path: string) => { const props = get(appliedOptions.value?.vuetify, path); return props && isPlainObject(props) ? props : {}; }; return { ...input, styles: useStyles(input.control.value.uischema), appliedOptions, vuetifyProps, }; }; export interface NestedInfo { level: number; parentElement?: 'array' | 'object'; } export const useNested = (element: false | 'array' | 'object'): NestedInfo => { const nestedInfo = inject<NestedInfo>('jsonforms.nestedInfo', { level: 0 }); if (element) { provide('jsonforms.nestedInfo', { level: nestedInfo.level + 1, parentElement: element, }); } return nestedInfo; }; export const useIcons = () => { const iconSet = computed<IconAliases>(() => { const icons = inject(IconSymbol); if (!icons) throw new Error('Missing Vuetify Icons provide!'); let result = mdiIcons; // default const overrides = icons.aliases; if (icons.defaultSet === 'fa') { result = faIcons; } return overrides ? { ...result, ...overrides } : result; }); return { current: iconSet, }; }; export const determineClearValue = (defaultValue: any) => { const useDefaultValue = inject<boolean>(IsDynamicPropertyContext, false); // undefined will clear the property from the object return useDefaultValue ? defaultValue : undefined; };