diff --git a/packages/example/package.json b/packages/example/package.json index ace5b63..76ac86f 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -34,6 +34,7 @@ "json-refs": "^3.0.15", "lodash": "^4.17.15", "monaco-editor": "^0.49.0", + "ractive": "^1.4.4", "splitpanes": "^3.1.5", "vue": "^3.5.0", "vuetify": "^3.11.0" diff --git a/packages/example/src/core/jsonschema/specification/uischema.json b/packages/example/src/core/jsonschema/specification/uischema.json index 5093b6d..3527e93 100644 --- a/packages/example/src/core/jsonschema/specification/uischema.json +++ b/packages/example/src/core/jsonschema/specification/uischema.json @@ -364,6 +364,9 @@ "template": { "type": "string" }, + "lang": { + "type": "string" + }, "elements": { "$ref": "#/definitions/elements" }, diff --git a/packages/example/src/examples/button/uischema.json b/packages/example/src/examples/button/uischema.json index 95c3dcc..d51e670 100644 --- a/packages/example/src/examples/button/uischema.json +++ b/packages/example/src/examples/button/uischema.json @@ -44,6 +44,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Button", diff --git a/packages/example/src/examples/index.ts b/packages/example/src/examples/index.ts index 022637b..7a876d7 100644 --- a/packages/example/src/examples/index.ts +++ b/packages/example/src/examples/index.ts @@ -7,6 +7,7 @@ import { input as anyOfSimple } from './anyOf-simple'; import { input as anyOfWithProps } from './anyOf-with-props'; import { input as array } from './array'; import { input as arrayRestrict } from './array-restrict'; +import { input as arrayType } from './array-type'; import { input as arrayWithReorder } from './array-with-reorder'; import { input as basic } from './basic'; import { input as basicTypes } from './basic-types'; @@ -64,6 +65,7 @@ import { input as templateSlot } from './template-slot'; import { input as time } from './time'; import { input as verticalLayout } from './vertical-layout'; import { input as verticalSplitLayout } from './vertical-split-layout'; +import { input as vueTemplateLayout } from './vue-template-layout'; export * from './register'; @@ -77,6 +79,7 @@ export { anyOfWithProps, array, arrayRestrict, + arrayType, arrayWithReorder, basic, basicTypes, @@ -134,4 +137,5 @@ export { time, verticalLayout, verticalSplitLayout, + vueTemplateLayout, }; diff --git a/packages/example/src/examples/job/uischema.json b/packages/example/src/examples/job/uischema.json index 8bca81a..20d69af 100644 --- a/packages/example/src/examples/job/uischema.json +++ b/packages/example/src/examples/job/uischema.json @@ -1,19 +1,23 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "TemplateLayout", "template": "{{translate('Job Application', 'Job Application')}}
", + "lang": "vue", "name": "toolbar", "elements": [ { "type": "TemplateLayout", - "template": "" + "template": "", + "lang": "vue" }, { "type": "TemplateLayout", - "template": "{{ context.vuetify.dark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}" + "template": "{{ context.vuetify.dark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}", + "lang": "vue" } ] }, @@ -27,6 +31,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Template", @@ -43,6 +48,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Template", @@ -59,6 +65,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Control", @@ -75,6 +82,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "ListWithDetail", diff --git a/packages/example/src/examples/job/uischemas.json b/packages/example/src/examples/job/uischemas.json index 5365ec2..ea4d1ee 100644 --- a/packages/example/src/examples/job/uischemas.json +++ b/packages/example/src/examples/job/uischemas.json @@ -3,6 +3,7 @@ "tester": "(jsonSchema, schemaPath, path) => { return NOT_APPLICABLE; }", "uischema": { "type": "TemplateLayout", + "lang": "vue", "name": "applicant", "template": "", "elements": [ @@ -12,6 +13,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Control", @@ -154,6 +156,7 @@ "uischema": { "type": "TemplateLayout", "name": "jobDetails", + "lang": "vue", "template": "", "elements": [ { diff --git a/packages/example/src/examples/template-layout/uischema.json b/packages/example/src/examples/template-layout/uischema.json index 2feee25..70b2bb3 100644 --- a/packages/example/src/examples/template-layout/uischema.json +++ b/packages/example/src/examples/template-layout/uischema.json @@ -1,49 +1,31 @@ { "type": "TemplateLayout", - "template": "
", + "template": "
{{> header}}{{> title}}
{{#each elements:i}}{{#if name !== 'header' && name !== 'title'}}{{> name}}{{/if}}{{/each}}
", "elements": [ { "type": "TemplateLayout", - "template": "{{translate('Custom TemplateLayout with JSONForms and Vuetify', 'Custom TemplateLayout with JSONForms and Vuetify')}}", + "template": "
{{translate('Custom TemplateLayout with JSONForms and Vuetify','Custom TemplateLayout with JSONForms and Vuetify')}}
{{#each elements:i}}{{> name}}{{/each}}
", "name": "title", "elements": [ { "type": "Button", "label": "EN", "action": "changeLang", - "params": { - "lang": "en" - }, - "options": { - "vuetify": { - "v-btn": { - "small": true, - "fab": true - } - } - } + "params": { "lang": "en" }, + "options": { "vuetify": { "v-btn": { "small": true, "fab": true } } } }, { "type": "Button", "label": "BG", "action": "changeLang", - "params": { - "lang": "bg" - }, - "options": { - "vuetify": { - "v-btn": { - "small": true, - "fab": true - } - } - } + "params": { "lang": "bg" }, + "options": { "vuetify": { "v-btn": { "small": true, "fab": true } } } } ] }, { "type": "TemplateLayout", - "template": "", + "template": "
", "name": "header" }, { @@ -52,23 +34,17 @@ { "type": "HorizontalLayout", "elements": [ - { - "type": "Control", - "scope": "#/properties/name" - }, + { "type": "Control", "scope": "#/properties/name" }, { "type": "Control", "scope": "#/properties/personalData/properties/age" }, - { - "type": "Control", - "scope": "#/properties/birthDate" - } + { "type": "Control", "scope": "#/properties/birthDate" } ] }, { "type": "TemplateLayout", - "template": "
{{translate('Additional Information For', 'Additional Information For')}} {{ data.name }}
" + "template": "
{{translate('Additional Information For','Additional Information For')}} {{data.name}}
" }, { "type": "HorizontalLayout", @@ -77,10 +53,7 @@ "type": "Control", "scope": "#/properties/personalData/properties/height" }, - { - "type": "Control", - "scope": "#/properties/nationality" - }, + { "type": "Control", "scope": "#/properties/nationality" }, { "type": "Control", "scope": "#/properties/occupation", @@ -103,11 +76,25 @@ }, { "type": "TemplateLayout", - "template": "
JSON Data
", + "template": "
JSON Data{{#if errors}}{{/if}}
{{> jsoneditor}}
", "elements": [ { - "type": "TemplateLayout", - "template": "" + "type": "Control", + "scope": "#", + "options": { + "format": "code", + "language": "json", + "convertJson": true, + "vuetify": { + "v-monaco-editor": { + "initActions": ["editor.action.formatDocument"], + "readonly": false, + "autoGrow": true, + "maxRows": 15 + } + } + }, + "name": "jsoneditor" } ] } diff --git a/packages/example/src/examples/vue-template-layout/actions.ts b/packages/example/src/examples/vue-template-layout/actions.ts new file mode 100644 index 0000000..a0c88c7 --- /dev/null +++ b/packages/example/src/examples/vue-template-layout/actions.ts @@ -0,0 +1,20 @@ +import type { ActionEvent } from '@chobantonov/jsonforms-vuetify-renderers'; + +const changeLang = (event: ActionEvent) => { + if (event.context.appStore) { + // demo app + event.context.appStore.jsonforms.locale = event.params.lang; + } else if (event.$el.getRootNode() instanceof ShadowRoot) { + // web component + const form = (event.$el.getRootNode() as ShadowRoot).host; + if (form) { + form.setAttribute('locale', event.params.lang); + } + } +}; + +export const onHandleAction = (event: ActionEvent) => { + if (event.action === 'changeLang') { + event.callback = changeLang; + } +}; diff --git a/packages/example/src/examples/vue-template-layout/config.json b/packages/example/src/examples/vue-template-layout/config.json new file mode 100644 index 0000000..d264bd8 --- /dev/null +++ b/packages/example/src/examples/vue-template-layout/config.json @@ -0,0 +1,3 @@ +{ + "defaultTemplateLang": "vue" +} diff --git a/packages/example/src/examples/vue-template-layout/data.json b/packages/example/src/examples/vue-template-layout/data.json new file mode 100644 index 0000000..44a8183 --- /dev/null +++ b/packages/example/src/examples/vue-template-layout/data.json @@ -0,0 +1,10 @@ +{ + "name": "John Doe", + "vegetarian": false, + "birthDate": "1985-06-02", + "personalData": { + "age": 34, + "drivingSkill": 7 + }, + "postalCode": "12345" +} diff --git a/packages/example/src/examples/vue-template-layout/i18n.json b/packages/example/src/examples/vue-template-layout/i18n.json new file mode 100644 index 0000000..e67a3ac --- /dev/null +++ b/packages/example/src/examples/vue-template-layout/i18n.json @@ -0,0 +1,86 @@ +{ + "en": { + "name": { + "label": "Name", + "description": "The name of the person" + }, + "vegetarian": { + "label": "Vegetarian", + "description": "Whether the person is a vegetarian" + }, + "birth": { + "label": "Birth Date", + "description": "" + }, + "nationality": { + "label": "Nationality", + "description": "" + }, + "personal-data": { + "age": { + "label": "Age" + }, + "driving": { + "label": "Driving Skill", + "description": "Indicating experience level" + } + }, + "height": { + "label": "Height" + }, + "occupation": { + "label": "Occupation", + "description": "" + }, + "postal-code": { + "label": "Postal Code" + }, + "error": { + "required": "field is required" + } + }, + "bg": { + "name": { + "label": "Име", + "description": "Името на лицето" + }, + "vegetarian": { + "label": "Вегетарианец", + "description": "Дали човекът е вегетарианец" + }, + "birth": { + "label": "Рождена дата", + "description": "" + }, + "nationality": { + "label": "Националност", + "description": "" + }, + "personal-data": { + "age": { + "label": "Възраст", + "description": "Моля, въведете вашата възраст." + }, + "driving": { + "label": "Шофьорски умения", + "description": "Показва ниво на опит" + } + }, + "height": { + "label": "Височина" + }, + "occupation": { + "label": "Професия", + "description": "" + }, + "postal-code": { + "label": "Пощенски код" + }, + "error": { + "required": "полето е задължително" + }, + "Additional Information": "Допълнителна информация", + "Custom TemplateLayout with JSONForms and Vuetify": "Персонализиран TemplateLayout с JSONForms и Vuetify", + "Additional Information For": "Допълнителна информация за" + } +} diff --git a/packages/example/src/examples/vue-template-layout/index.ts b/packages/example/src/examples/vue-template-layout/index.ts new file mode 100644 index 0000000..5f846ea --- /dev/null +++ b/packages/example/src/examples/vue-template-layout/index.ts @@ -0,0 +1,25 @@ +import type { ExampleInputDescription } from '@/core/types'; +import { registerExamples } from '../register'; +import { onHandleAction } from './actions'; +import data from './data.json'; +import i18n from './i18n.json'; +import schema from './schema.json'; +import uischema from './uischema.json'; +import config from './config.json'; + +export const input: ExampleInputDescription = { + schema, + uischema, + data, + i18n, + onHandleAction, + config, +}; + +registerExamples([ + { + name: 'vue-template-layout', + label: 'Vue Template Layout', + input, + }, +]); diff --git a/packages/example/src/examples/vue-template-layout/schema.json b/packages/example/src/examples/vue-template-layout/schema.json new file mode 100644 index 0000000..65f55ed --- /dev/null +++ b/packages/example/src/examples/vue-template-layout/schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "description": "Please enter your name", + "i18n": "name" + }, + "vegetarian": { + "type": "boolean", + "i18n": "vegetarian" + }, + "birthDate": { + "type": "string", + "format": "date", + "i18n": "birth" + }, + "nationality": { + "type": "string", + "enum": ["US", "BG", "DE", "JP", "RU", "Other"], + "i18n": "nationality" + }, + "personalData": { + "type": "object", + "properties": { + "age": { + "type": "integer", + "description": "Please enter your age.", + "i18n": "personal-data.age" + }, + "height": { + "type": "number", + "i18n": "height" + }, + "drivingSkill": { + "type": "number", + "maximum": 10, + "minimum": 1, + "default": 7, + "i18n": "personal-data.driving" + } + }, + "required": ["age", "height"] + }, + "occupation": { + "type": "string", + "i18n": "occupation" + }, + "postalCode": { + "type": "string", + "maxLength": 5, + "i18n": "postal-code" + } + }, + "required": ["occupation", "nationality"] +} diff --git a/packages/example/src/examples/vue-template-layout/uischema.json b/packages/example/src/examples/vue-template-layout/uischema.json new file mode 100644 index 0000000..959a86a --- /dev/null +++ b/packages/example/src/examples/vue-template-layout/uischema.json @@ -0,0 +1,121 @@ +{ + "type": "TemplateLayout", + "template": "
", + "lang": "vue", + "elements": [ + { + "type": "TemplateLayout", + "template": "{{translate('Custom TemplateLayout with JSONForms and Vuetify', 'Custom TemplateLayout with JSONForms and Vuetify')}}", + "lang": "vue", + "name": "title", + "elements": [ + { + "type": "Button", + "label": "EN", + "action": "changeLang", + "params": { + "lang": "en" + }, + "options": { + "vuetify": { + "v-btn": { + "small": true, + "fab": true + } + } + } + }, + { + "type": "Button", + "label": "BG", + "action": "changeLang", + "params": { + "lang": "bg" + }, + "options": { + "vuetify": { + "v-btn": { + "small": true, + "fab": true + } + } + } + } + ] + }, + { + "type": "TemplateLayout", + "template": "", + "lang": "vue", + "name": "header" + }, + { + "type": "VerticalLayout", + "elements": [ + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/name" + }, + { + "type": "Control", + "scope": "#/properties/personalData/properties/age" + }, + { + "type": "Control", + "scope": "#/properties/birthDate" + } + ] + }, + { + "type": "TemplateLayout", + "template": "
{{translate('Additional Information For', 'Additional Information For')}} {{ data.name }}
", + "lang": "ractive" + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/personalData/properties/height" + }, + { + "type": "Control", + "scope": "#/properties/nationality" + }, + { + "type": "Control", + "scope": "#/properties/occupation", + "options": { + "suggestion": [ + "Accountant", + "Engineer", + "Freelancer", + "Journalism", + "Physician", + "Student", + "Teacher", + "Other" + ] + } + } + ] + } + ] + }, + { + "type": "TemplateLayout", + "template": "
JSON Data
", + "lang": "vue", + "elements": [ + { + "type": "TemplateLayout", + "template": "", + "lang": "vue" + } + ] + } + ] +} diff --git a/packages/jsonforms-vuetify-renderers/package.json b/packages/jsonforms-vuetify-renderers/package.json index 245bc08..31dfbce 100644 --- a/packages/jsonforms-vuetify-renderers/package.json +++ b/packages/jsonforms-vuetify-renderers/package.json @@ -62,6 +62,7 @@ "lodash": "^4.17.21", "maska": "^2.1.11", "monaco-editor": "^0.49.0", + "ractive": "^1.4.4", "splitpanes": "^3.2.0", "vue": "^3.5.0", "vuetify": "^3.11.0" @@ -87,6 +88,7 @@ "lodash": "^4.17.21", "maska": "^2.1.11", "monaco-editor": "^0.49.0", + "ractive": "^1.4.4", "resize-observer-polyfill": "^1.5.1", "splitpanes": "^3.2.0", "vue": "^3.5.0", diff --git a/packages/jsonforms-vuetify-renderers/src/components/ResolvedJsonForms.vue b/packages/jsonforms-vuetify-renderers/src/components/ResolvedJsonForms.vue index 900d19d..bf1883c 100644 --- a/packages/jsonforms-vuetify-renderers/src/components/ResolvedJsonForms.vue +++ b/packages/jsonforms-vuetify-renderers/src/components/ResolvedJsonForms.vue @@ -279,10 +279,13 @@ const createMiddlewareWrapper = (wrappedFunction: Middleware): Middleware => { }; }; -const errorMessage = computed(() => { +const errorTitle = computed(() => { const message = 'Error resolving schema'; if (props.state.i18n?.translate) { - return props.state.i18n.translate(message, message); + return props.state.i18n.translate( + 'ResolvedJsonForms.resolve_schema_error_title', + message, + ); } return message; }); @@ -479,8 +482,11 @@ if (!handleActionEmitter) { justify="center" > - - {{ errorMessage }}: {{ resolvedSchema.error }} + diff --git a/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.entry.ts b/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.entry.ts index 1b769ba..e7da8d3 100644 --- a/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.entry.ts +++ b/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.entry.ts @@ -4,6 +4,7 @@ import { optionIs, or, rankWith, + uiTypeIs, type JsonFormsRendererRegistryEntry, type Tester, type UISchemaElement, @@ -30,11 +31,24 @@ const hasStringValueOption = export const entry: JsonFormsRendererRegistryEntry = { renderer: monacoRenderer, tester: rankWith( - 2, + 3, and( - isStringControl, optionIs('format', 'code'), - or(hasStringValueOption('language'), hasStringValueOption(':language')), + or( + and( + isStringControl, + or( + hasStringValueOption('language'), + hasStringValueOption(':language'), + ), + ), + // if the language is set to 'json' we can use the monaco editor for any type + and( + and(uiTypeIs('Control')), + optionIs('language', 'json'), + optionIs('convertJson', true), + ), + ), ), ), }; diff --git a/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.vue b/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.vue index 888aed0..654efff 100644 --- a/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.vue +++ b/packages/jsonforms-vuetify-renderers/src/renderers/MonacoRenderer.vue @@ -20,7 +20,7 @@ :persistent-hint="persistentHint()" :required="control.required" :error-messages="control.errors" - :model-value="control.data" + :model-value="adaptModelToEditorValue(control.data)" v-bind="vuetifyProps('v-monaco-editor')" :language="language" @update:model-value="onChange" @@ -55,7 +55,7 @@ :placeholder="appliedOptions.placeholder" :required="control.required" :error-messages="control.errors" - :model-value="control.data" + :model-value="adaptModelToEditorValue(control.data)" v-bind="vuetifyProps('v-monaco-editor')" :language="language" @update:model-value="onChange" @@ -87,6 +87,7 @@ import { useVuetifyControl, } from '@jsonforms/vue-vuetify'; import { defineComponent, ref, nextTick, watch } from 'vue'; +import { computed } from 'vue'; import { defineAsyncComponent } from 'vue'; import { VBtn, @@ -123,14 +124,50 @@ const controlRenderer = defineComponent({ const editorRef = ref(null); const clearValue = determineClearValue(''); - const adaptValue = (value: any) => value || clearValue; - const vuetifyControl = useVuetifyControl( - useJsonFormsControl(props), - adaptValue, - 300, + const control = useJsonFormsControl(props); + const language = computed(() => { + const langOption = control.control.value.uischema.options?.[':language']; + if (langOption) { + const rootData = jsonforms.core?.data; + return Resolve.data(rootData, langOption); + } + return control.control.value.uischema.options?.language; + }); + + const convertJson = computed( + () => control.control.value.uischema.options?.convertJson ?? false, ); + const adaptModelToEditorValue = (value: any): string => { + if (language.value === 'json' && convertJson.value) { + return value != undefined ? JSON.stringify(value, null, 2) : ''; + } + + if (value === undefined || value === null) { + return ''; + } + + return typeof value === 'string' ? value : String(value); + }; + + const vuetifyControl = useVuetifyControl(control, (v) => v, 300); + + const onChange = (value: string) => { + let model = value || clearValue; + + if (language.value === 'json' && convertJson.value) { + try { + model = JSON.parse(value); + } catch { + // do not update the model if JSON is invalid + return; + } + } + + vuetifyControl.onChange(model); + }; + const toggleMaximize = () => { isMaximized.value = !isMaximized.value; nextTick(() => { @@ -148,23 +185,16 @@ const controlRenderer = defineComponent({ return { ...vuetifyControl, + onChange, jsonforms, icons, isMaximized, editorRef, toggleMaximize, + language, + adaptModelToEditorValue, }; }, - computed: { - language(): string | undefined { - const language = this.control.uischema.options?.[':language']; - if (language) { - const rootData = this.jsonforms.core?.data; - return Resolve.data(rootData, language); - } - return this.control.uischema.options?.language; - }, - }, }); export default controlRenderer; diff --git a/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.entry.ts b/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.entry.ts index 3566c29..0817646 100644 --- a/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.entry.ts +++ b/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.entry.ts @@ -1,11 +1,32 @@ import { + and, rankWith, uiTypeIs, type JsonFormsRendererRegistryEntry, + type JsonSchema, + type Tester, + type TesterContext, + type UISchemaElement, } from '@jsonforms/core'; import templateLayoutRenderer from './TemplateLayoutRenderer.vue'; +export const langIs = + (expected: string): Tester => + ( + uischema: UISchemaElement & { lang?: string }, + schema: JsonSchema, + context: TesterContext, + ): boolean => { + return ( + uischema.lang === expected || + (uischema.lang === undefined && + (!context.config || + context.config.defaultTemplateLang === expected || + context.config.defaultTemplateLang === undefined)) + ); + }; + export const entry: JsonFormsRendererRegistryEntry = { renderer: templateLayoutRenderer, - tester: rankWith(2, uiTypeIs('TemplateLayout')), + tester: rankWith(2, and(uiTypeIs('TemplateLayout'), langIs('ractive'))), }; diff --git a/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.vue b/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.vue index 4925aa1..113c5ad 100644 --- a/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.vue +++ b/packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.vue @@ -3,33 +3,37 @@ :disabled="!vuetifyProps('defaults')" :defaults="vuetifyProps('defaults')" > -
- Template Error: {{ templateError }} -
- - + +
+ + -
+ + diff --git a/packages/jsonforms-vuetify-renderers/src/renderers/VueTemplateLayoutRenderer.entry.ts b/packages/jsonforms-vuetify-renderers/src/renderers/VueTemplateLayoutRenderer.entry.ts new file mode 100644 index 0000000..ff71b7f --- /dev/null +++ b/packages/jsonforms-vuetify-renderers/src/renderers/VueTemplateLayoutRenderer.entry.ts @@ -0,0 +1,31 @@ +import { + and, + rankWith, + uiTypeIs, + type JsonFormsRendererRegistryEntry, + type JsonSchema, + type Tester, + type TesterContext, + type UISchemaElement, +} from '@jsonforms/core'; + +import vueTemplateLayoutRenderer from './VueTemplateLayoutRenderer.vue'; +export const langIs = + (expected: string): Tester => + ( + uischema: UISchemaElement & { lang?: string }, + schema: JsonSchema, + context: TesterContext, + ): boolean => { + return ( + uischema.lang === expected || + (uischema.lang === undefined && + context.config && + context.config.defaultTemplateLang === expected) + ); + }; + +export const entry: JsonFormsRendererRegistryEntry = { + renderer: vueTemplateLayoutRenderer, + tester: rankWith(2, and(uiTypeIs('TemplateLayout'), langIs('vue'))), +}; diff --git a/packages/jsonforms-vuetify-renderers/src/renderers/VueTemplateLayoutRenderer.vue b/packages/jsonforms-vuetify-renderers/src/renderers/VueTemplateLayoutRenderer.vue new file mode 100644 index 0000000..373e764 --- /dev/null +++ b/packages/jsonforms-vuetify-renderers/src/renderers/VueTemplateLayoutRenderer.vue @@ -0,0 +1,227 @@ + + + diff --git a/packages/jsonforms-vuetify-renderers/src/renderers/index.ts b/packages/jsonforms-vuetify-renderers/src/renderers/index.ts index bb0cca3..a58a360 100644 --- a/packages/jsonforms-vuetify-renderers/src/renderers/index.ts +++ b/packages/jsonforms-vuetify-renderers/src/renderers/index.ts @@ -7,6 +7,7 @@ import { default as MonacoRenderer } from './MonacoRenderer.vue'; import { default as NullControlRenderer } from './NullControlRenderer.vue'; import { default as SlotRenderer } from './SlotRenderer.vue'; import { default as TemplateLabelRenderer } from './TemplateLabelRenderer.vue'; +import { default as VueTemplateLayoutRenderer } from './VueTemplateLayoutRenderer.vue'; import { default as TemplateLayoutRenderer } from './TemplateLayoutRenderer.vue'; import { default as TemplateRenderer } from './TemplateRenderer.vue'; import { default as SplitLayoutRenderer } from './SplitLayoutRenderer.vue'; @@ -21,6 +22,7 @@ import { entry as monacoRendererEntry } from './MonacoRenderer.entry'; import { entry as nullControlRendererEntry } from './NullControlRenderer.entry'; import { entry as slotRendererEntry } from './SlotRenderer.entry'; import { entry as templateLabelRendererEntry } from './TemplateLabelRenderer.entry'; +import { entry as vueTemplateLayoutRendererEntry } from './VueTemplateLayoutRenderer.entry'; import { entry as templateLayoutRendererEntry } from './TemplateLayoutRenderer.entry'; import { entry as templateRendererEntry } from './TemplateRenderer.entry'; import { entry as splitLayoutRendererEntry } from './SplitLayoutRenderer.entry'; @@ -36,6 +38,7 @@ export const extraVuetifyRenderers = [ nullControlRendererEntry, slotRendererEntry, templateLabelRendererEntry, + vueTemplateLayoutRendererEntry, templateLayoutRendererEntry, templateRendererEntry, splitLayoutRendererEntry, @@ -52,6 +55,7 @@ export { NullControlRenderer, SlotRenderer, TemplateLabelRenderer, + VueTemplateLayoutRenderer, TemplateLayoutRenderer, TemplateRenderer, SplitLayoutRenderer, diff --git a/packages/jsonforms-vuetify-renderers/src/util/ractive-template-controller.ts b/packages/jsonforms-vuetify-renderers/src/util/ractive-template-controller.ts new file mode 100644 index 0000000..b5336f3 --- /dev/null +++ b/packages/jsonforms-vuetify-renderers/src/util/ractive-template-controller.ts @@ -0,0 +1,99 @@ +import Ractive, { + type Data, + type DataFn, + type ParsedTemplate, + type Partial, + type Registry, +} from 'ractive'; + +export interface SlotPlaceholder { + name: string; + el: HTMLElement; +} + +export class RactiveTemplateController = Ractive> { + private ractive: Ractive | null = null; + private onMountSlots: (slots: SlotPlaceholder[]) => void; + private container: HTMLElement | null = null; + private template: ParsedTemplate | null = null; + private data: any = null; + + constructor(onMountSlots: (slots: SlotPlaceholder[]) => void) { + this.onMountSlots = onMountSlots; + } + + async destroy() { + if (this.ractive) { + this.ractive.off(); + await this.ractive.teardown().catch(() => {}); + this.ractive = null; + } + } + + updateData(keyPath: string, value: any) { + if (this.ractive) { + this.ractive.set(keyPath, value); + } + } + + async setup( + container: HTMLElement, + template: string, + data: (Data | DataFn) & { elements?: { name: string }[] }, + visible: boolean, + ) { + this.container = container; + this.template = Ractive.parse(template); + this.data = data; + + if (!visible) { + await this.destroy(); + return; + } + + await this.render(); + } + + async updateVisibility(visible: boolean) { + if (visible && !this.ractive) { + await this.render(); + } else if (!visible && this.ractive) { + await this.destroy(); + } + } + + private async render() { + if (!this.container) return; + + await this.destroy(); + this.container.innerHTML = ''; + + const partials: Registry = {}; + + this.data.elements?.forEach((el: any) => { + partials[el.name] = `
`; + }); + + this.ractive = new Ractive({ + el: this.container, + template: this.template!, + partials, + data: this.data, + twoway: true, + lazy: false, + }); + + const runMount = () => { + const els = this.container!.querySelectorAll('[data-slot]'); + const slots = Array.from(els).map((el) => ({ + name: el.getAttribute('data-slot')!, + el: el as HTMLElement, + })); + this.onMountSlots(slots); + }; + + this.ractive.on('render', runMount); + this.ractive.on('update', runMount); + runMount(); + } +} diff --git a/packages/jsonforms-vuetify-renderers/src/util/util.ts b/packages/jsonforms-vuetify-renderers/src/util/util.ts index f2110ea..05b9618 100644 --- a/packages/jsonforms-vuetify-renderers/src/util/util.ts +++ b/packages/jsonforms-vuetify-renderers/src/util/util.ts @@ -1,5 +1,6 @@ import { type JsonFormsUISchemaRegistryEntry, + type Layout, type UISchemaElement, type UISchemaTester, type ValidateFunctionContext, @@ -176,3 +177,16 @@ export const getLightDarkTheme = ( return newTheme; }; + +export interface TemplateLayout extends Layout { + type: 'TemplateLayout'; + /** + * The template string. + */ + template: string; + + /** + * The template language. + */ + lang: string; +} diff --git a/packages/jsonforms-vuetify-tooling/schemas/uischema.json b/packages/jsonforms-vuetify-tooling/schemas/uischema.json index 533ff9e..777bf47 100644 --- a/packages/jsonforms-vuetify-tooling/schemas/uischema.json +++ b/packages/jsonforms-vuetify-tooling/schemas/uischema.json @@ -364,6 +364,9 @@ "template": { "type": "string" }, + "lang": { + "type": "string" + }, "elements": { "$ref": "#/definitions/elements" }, diff --git a/packages/jsonforms-vuetify-webcomponent/package.json b/packages/jsonforms-vuetify-webcomponent/package.json index ba374e1..0d5893e 100644 --- a/packages/jsonforms-vuetify-webcomponent/package.json +++ b/packages/jsonforms-vuetify-webcomponent/package.json @@ -62,6 +62,7 @@ "json-refs": "^3.0.15", "lodash": "^4.17.15", "monaco-editor": "^0.49.0", + "ractive": "^1.4.4", "shady-css-parser": "^0.1.0", "vue": "^3.5.0", "vue-plugin-load-script": "^2.1.1", diff --git a/packages/jsonforms-vuetify-webcomponent/src/examples/basic/uischema.json b/packages/jsonforms-vuetify-webcomponent/src/examples/basic/uischema.json index 2c8c644..5ae279e 100644 --- a/packages/jsonforms-vuetify-webcomponent/src/examples/basic/uischema.json +++ b/packages/jsonforms-vuetify-webcomponent/src/examples/basic/uischema.json @@ -1,24 +1,29 @@ { "type": "TemplateLayout", "template": "{{translate('Vuetify JSON Forms Demo', 'Vuetify JSON Forms Demo')}}", + "lang": "vue", "elements": [ { "type": "TemplateLayout", "template": "
", + "lang": "vue", "elements": [ { "type": "TemplateLayout", - "template": "" + "template": "", + "lang": "vue" }, { "type": "TemplateLayout", - "template": "{{ context.vuetify.dark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}" + "template": "{{ context.vuetify.dark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}", + "lang": "vue" } ] }, { "type": "TemplateLayout", "template": "Example", + "lang": "vue", "elements": [ { "type": "VerticalLayout", @@ -139,7 +144,8 @@ "elements": [ { "type": "TemplateLayout", - "template": "" + "template": "", + "lang": "vue" } ] }, @@ -148,7 +154,8 @@ "elements": [ { "type": "TemplateLayout", - "template": "{{ context.uidata.errorDialog.title }}{{ context.uidata.errorDialog.text }} OK " + "template": "{{ context.uidata.errorDialog.title }}{{ context.uidata.errorDialog.text }} OK ", + "lang": "vue" } ] } diff --git a/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischema.json b/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischema.json index 01cb231..903547d 100644 --- a/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischema.json +++ b/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischema.json @@ -1,19 +1,23 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "TemplateLayout", "template": "{{translate('Job Application', 'Job Application')}}
", + "lang": "vue", "name": "toolbar", "elements": [ { "type": "TemplateLayout", - "template": "" + "template": "", + "lang": "vue" }, { "type": "TemplateLayout", - "template": "{{ context.vuetify.dark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}" + "template": "{{ context.vuetify.dark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}", + "lang": "vue" } ] }, @@ -27,6 +31,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Template", @@ -43,6 +48,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Template", @@ -59,6 +65,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Control", @@ -75,6 +82,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "ListWithDetail", diff --git a/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischemas.json b/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischemas.json index 5365ec2..1962b6a 100644 --- a/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischemas.json +++ b/packages/jsonforms-vuetify-webcomponent/src/examples/job/uischemas.json @@ -5,6 +5,7 @@ "type": "TemplateLayout", "name": "applicant", "template": "", + "lang": "vue", "elements": [ { "type": "HorizontalLayout", @@ -12,6 +13,7 @@ { "type": "TemplateLayout", "template": "", + "lang": "vue", "elements": [ { "type": "Control", @@ -155,6 +157,7 @@ "type": "TemplateLayout", "name": "jobDetails", "template": "", + "lang": "vue", "elements": [ { "type": "Control", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a523f65..f5fb287 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: monaco-editor: specifier: ^0.49.0 version: 0.49.0 + ractive: + specifier: ^1.4.4 + version: 1.4.4 splitpanes: specifier: ^3.1.5 version: 3.2.0(vue@3.5.13(typescript@5.4.5)) @@ -271,6 +274,9 @@ importers: monaco-editor: specifier: ^0.49.0 version: 0.49.0 + ractive: + specifier: ^1.4.4 + version: 1.4.4 resize-observer-polyfill: specifier: ^1.5.1 version: 1.5.1 @@ -392,6 +398,9 @@ importers: monaco-editor: specifier: ^0.49.0 version: 0.49.0 + ractive: + specifier: ^1.4.4 + version: 1.4.4 shady-css-parser: specifier: ^0.1.0 version: 0.1.0 @@ -4452,6 +4461,11 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + ractive@1.4.4: + resolution: {integrity: sha512-FFKmPox5nJQeBNPWLl2dsVC47z44O0NNx2vtGwjWYTtICIyVDyoKex8AAV99Cv07j6njFm3O70Pi96QxVu5rzA==} + engines: {node: '>=4.0.0', npm: '>=2.14.2'} + hasBin: true + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -10160,6 +10174,8 @@ snapshots: queue-microtask@1.2.3: {} + ractive@1.4.4: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1