@jsonforms/vue-vuetify
Version:
Vue Vuetify renderers for JSON Forms
535 lines (470 loc) • 13.3 kB
text/typescript
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;
};