diff --git a/packages/core/src/mappers/renderer.ts b/packages/core/src/mappers/renderer.ts index d80392c85..f417062f5 100644 --- a/packages/core/src/mappers/renderer.ts +++ b/packages/core/src/mappers/renderer.ts @@ -992,11 +992,13 @@ export const mapDispatchToArrayControlProps = ( export interface DispatchPropsOfMultiEnumControl { addItem: (path: string, value: any) => void; removeItem?: (path: string, toDelete: any) => void; + handleChange?: (path: string, value: any) => void; } export const mapDispatchToMultiEnumProps = ( dispatch: Dispatch ): DispatchPropsOfMultiEnumControl => ({ + ...mapDispatchToControlProps(dispatch), addItem: (path: string, value: any) => { dispatch( update(path, (data) => { diff --git a/packages/core/test/mappers/renderer.test.ts b/packages/core/test/mappers/renderer.test.ts index 9d0dd516c..36cddea5b 100644 --- a/packages/core/test/mappers/renderer.test.ts +++ b/packages/core/test/mappers/renderer.test.ts @@ -1551,6 +1551,43 @@ test('mapDispatchToMultiEnumProps - oneOf schema - removeItem', (t) => { t.is(getCore().data.colors.length, 0); }); +test('mapDispatchToMultiEnumProps - handleChange', (t) => { + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/colors', + }; + const schema = { + type: 'object', + properties: { + colors: { + type: 'array', + items: { + type: 'string', + enum: ['red', 'green', 'pink'], + }, + uniqueItems: true, + }, + }, + }; + const data = { colors: ['green'] }; + const initCore: JsonFormsCore = { + uischema, + schema, + data, + errors: [] as ErrorObject[], + }; + const [getCore, dispatch] = mockDispatch(initCore); + dispatch(init(data, schema, uischema, createAjv({ useDefaults: true }))); + const props = mapDispatchToMultiEnumProps(dispatch); + + if (props.handleChange) { + props.handleChange('colors', ['green', 'red']); + } + + t.is(getCore().data.colors.length, 2); + t.deepEqual(getCore().data.colors, ['green', 'red']); +}); + test('should assign defaults to enum', (t) => { const schema: JsonSchema = { type: 'object', diff --git a/packages/examples/src/examples/enum-multi.ts b/packages/examples/src/examples/enum-multi.ts index 5a7b235b4..0765ff7ae 100644 --- a/packages/examples/src/examples/enum-multi.ts +++ b/packages/examples/src/examples/enum-multi.ts @@ -46,6 +46,15 @@ export const schema = { enum: ['foo', 'bar', 'foobar'], }, }, + multiSelect: { + type: 'array', + title: 'Select Fruits', + uniqueItems: true, + items: { + type: 'string', + enum: ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'], + }, + }, }, }; @@ -60,10 +69,21 @@ export const uischema = { type: 'Control', scope: '#/properties/multiEnum', }, + { + type: 'Control', + scope: '#/properties/multiSelect', + options: { + format: 'multiselect', + }, + }, ], }; -export const data = { oneOfMultiEnum: ['foo'], multiEnum: ['bar'] }; +export const data = { + oneOfMultiEnum: ['foo'], + multiEnum: ['bar'], + multiSelect: ['Banana', 'Cherry'], +}; registerExamples([ { diff --git a/packages/vue-vuetify/src/complex/EnumArrayRenderer.entry.ts b/packages/vue-vuetify/src/complex/EnumArrayRenderer.entry.ts index 721b04017..891688e12 100644 --- a/packages/vue-vuetify/src/complex/EnumArrayRenderer.entry.ts +++ b/packages/vue-vuetify/src/complex/EnumArrayRenderer.entry.ts @@ -1,42 +1,8 @@ -import { - and, - hasType, - rankWith, - schemaMatches, - schemaSubPathMatches, - uiTypeIs, - type JsonFormsRendererRegistryEntry, - type JsonSchema, -} from '@jsonforms/core'; +import { rankWith, type JsonFormsRendererRegistryEntry } from '@jsonforms/core'; import controlRenderer from './EnumArrayRenderer.vue'; +import { isMultiEnumControl } from '../util/tester'; export const entry: JsonFormsRendererRegistryEntry = { renderer: controlRenderer, - tester: rankWith( - 5, - and( - uiTypeIs('Control'), - and( - schemaMatches( - (schema) => - hasType(schema, 'array') && - !Array.isArray(schema.items) && - schema.uniqueItems === true, - ), - schemaSubPathMatches('items', (schema) => { - return hasOneOfItems(schema) || hasEnumItems(schema); - }), - ), - ), - ), + tester: rankWith(5, isMultiEnumControl), }; - -const hasOneOfItems = (schema: JsonSchema): boolean => - schema.oneOf !== undefined && - schema.oneOf.length > 0 && - (schema.oneOf as JsonSchema[]).every((entry: JsonSchema) => { - return entry.const !== undefined; - }); - -const hasEnumItems = (schema: JsonSchema): boolean => - schema.type === 'string' && schema.enum !== undefined; diff --git a/packages/vue-vuetify/src/complex/MultiEnumSelectRenderer.entry.ts b/packages/vue-vuetify/src/complex/MultiEnumSelectRenderer.entry.ts new file mode 100644 index 000000000..bd3d58939 --- /dev/null +++ b/packages/vue-vuetify/src/complex/MultiEnumSelectRenderer.entry.ts @@ -0,0 +1,16 @@ +import { + and, + optionIs, + rankWith, + type JsonFormsRendererRegistryEntry, +} from '@jsonforms/core'; +import controlRenderer from './MultiEnumSelectRenderer.vue'; +import { isMultiEnumControl } from '../util/tester'; + +export const entry: JsonFormsRendererRegistryEntry = { + renderer: controlRenderer, + tester: rankWith( + 10, + and(optionIs('format', 'multiselect'), isMultiEnumControl), + ), +}; diff --git a/packages/vue-vuetify/src/complex/MultiEnumSelectRenderer.vue b/packages/vue-vuetify/src/complex/MultiEnumSelectRenderer.vue new file mode 100644 index 000000000..4a10cabe5 --- /dev/null +++ b/packages/vue-vuetify/src/complex/MultiEnumSelectRenderer.vue @@ -0,0 +1,78 @@ + + + diff --git a/packages/vue-vuetify/src/complex/index.ts b/packages/vue-vuetify/src/complex/index.ts index adeb84282..f186ec1b0 100644 --- a/packages/vue-vuetify/src/complex/index.ts +++ b/packages/vue-vuetify/src/complex/index.ts @@ -2,6 +2,7 @@ export { default as AllOfRenderer } from './AllOfRenderer.vue'; export { default as AnyOfRenderer } from './AnyOfRenderer.vue'; export { default as ArrayControlRenderer } from './ArrayControlRenderer.vue'; export { default as EnumArrayRenderer } from './EnumArrayRenderer.vue'; +export { default as MultiEnumSelectRenderer } from './MultiEnumSelectRenderer.vue'; export { default as ObjectRenderer } from './ObjectRenderer.vue'; export { default as OneOfRenderer } from './OneOfRenderer.vue'; export { default as OneOfTabRenderer } from './OneOfTabRenderer.vue'; @@ -11,6 +12,7 @@ import { entry as allOfRendererEntry } from './AllOfRenderer.entry'; import { entry as anyOfRendererEntry } from './AnyOfRenderer.entry'; import { entry as arrayControlRendererEntry } from './ArrayControlRenderer.entry'; import { entry as enumArrayRendererEntry } from './EnumArrayRenderer.entry'; +import { entry as multiEnumSelectRendererEntry } from './MultiEnumSelectRenderer.entry'; import { entry as objectRendererEntry } from './ObjectRenderer.entry'; import { entry as oneOfRendererEntry } from './OneOfRenderer.entry'; import { entry as oneOfTabRendererEntry } from './OneOfTabRenderer.entry'; @@ -21,6 +23,7 @@ export const complexRenderers = [ anyOfRendererEntry, arrayControlRendererEntry, enumArrayRendererEntry, + multiEnumSelectRendererEntry, objectRendererEntry, oneOfRendererEntry, oneOfTabRendererEntry, @@ -32,6 +35,7 @@ export { anyOfRendererEntry, arrayControlRendererEntry, enumArrayRendererEntry, + multiEnumSelectRendererEntry, objectRendererEntry, oneOfRendererEntry, oneOfTabRendererEntry, diff --git a/packages/vue-vuetify/src/util/index.ts b/packages/vue-vuetify/src/util/index.ts index e3c6d25f7..665196676 100644 --- a/packages/vue-vuetify/src/util/index.ts +++ b/packages/vue-vuetify/src/util/index.ts @@ -3,3 +3,4 @@ export * from './datejs'; export * from './inject'; export * from './options'; export * from './validator'; +export * from './tester'; diff --git a/packages/vue-vuetify/src/util/tester.ts b/packages/vue-vuetify/src/util/tester.ts new file mode 100644 index 000000000..2beaa056b --- /dev/null +++ b/packages/vue-vuetify/src/util/tester.ts @@ -0,0 +1,27 @@ +import { + and, + hasType, + isOneOfEnumSchema, + schemaMatches, + schemaSubPathMatches, + uiTypeIs, + type JsonSchema, +} from '@jsonforms/core'; + +export const isMultiEnumControl = and( + uiTypeIs('Control'), + schemaMatches( + (schema) => + hasType(schema, 'array') && + !Array.isArray(schema.items) && + schema.uniqueItems === true, + ), + schemaSubPathMatches('items', (schema) => { + return ( + (isOneOfEnumSchema(schema) && + schema.oneOf !== undefined && + (schema.oneOf as JsonSchema[]).length > 0) || + (schema.type === 'string' && schema.enum !== undefined) + ); + }), +); diff --git a/packages/vue-vuetify/tests/unit/complex/MultiEnumSelectRenderer.spec.ts b/packages/vue-vuetify/tests/unit/complex/MultiEnumSelectRenderer.spec.ts new file mode 100644 index 000000000..401550f31 --- /dev/null +++ b/packages/vue-vuetify/tests/unit/complex/MultiEnumSelectRenderer.spec.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { clearAllIds } from '@jsonforms/core'; +import MultiEnumSelectRenderer from '../../../src/complex/MultiEnumSelectRenderer.vue'; +import { entry as multiEnumSelectRendererEntry } from '../../../src/complex/MultiEnumSelectRenderer.entry'; +import { mountJsonForms } from '../util'; + +describe('MultiEnumSelectRenderer.vue', () => { + const renderers = [multiEnumSelectRendererEntry]; + + describe('with enum schema', () => { + const schema = { + type: 'array', + title: 'My Multi Select', + uniqueItems: true, + items: { + type: 'string', + enum: ['a', 'b', 'c'], + }, + }; + const uischema = { + type: 'Control', + scope: '#', + options: { + format: 'multiselect', + }, + }; + + let wrapper: ReturnType; + + beforeEach(() => { + clearAllIds(); + wrapper = mountJsonForms(['a'], schema, renderers, uischema); + }); + + it('check if child MultiEnumSelectRenderer exists', () => { + expect(wrapper.getComponent(MultiEnumSelectRenderer)).toBeTruthy(); + }); + + it('renders a select input', () => { + expect(wrapper.find('input[type="text"]').exists()).toBe(true); + }); + + it('renders title as label', () => { + expect(wrapper.find('label').text()).toEqual('My Multi Select'); + }); + + it('emits change when an item is selected', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', ['a', 'b']); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toEqual(['a', 'b']); + }); + + it('emits change when an item is deselected', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', ['a']); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toEqual(['a']); + }); + + it('emits change when cleared via empty array', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', []); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toBeUndefined(); + }); + + it('emits change when cleared', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', null); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toEqual(undefined); + }); + + it('should render component and match snapshot', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with oneOf schema', () => { + const schema = { + type: 'array', + title: 'My OneOf Multi Select', + uniqueItems: true, + items: { + oneOf: [ + { const: 'a', title: 'Option A' }, + { const: 'b', title: 'Option B' }, + { const: 'c', title: 'Option C' }, + ], + }, + }; + const uischema = { + type: 'Control', + scope: '#', + options: { + format: 'multiselect', + }, + }; + + let wrapper: ReturnType; + + beforeEach(() => { + clearAllIds(); + wrapper = mountJsonForms(['b'], schema, renderers, uischema); + }); + + it('check if child MultiEnumSelectRenderer exists', () => { + expect(wrapper.getComponent(MultiEnumSelectRenderer)).toBeTruthy(); + }); + + it('emits change when an item is selected', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', ['b', 'c']); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toEqual(['b', 'c']); + }); + + it('emits change when an item is deselected', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', ['a']); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toEqual(['a']); + }); + + it('emits change when cleared via empty array', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', []); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toBeUndefined(); + }); + + it('emits change when cleared', async () => { + const select = wrapper.findComponent({ name: 'VSelect' }); + await select.vm.$emit('update:modelValue', null); + + const component = wrapper.getComponent(MultiEnumSelectRenderer); + expect(component.vm.control.data).toBeUndefined(); + }); + + it('should render component and match snapshot', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/vue-vuetify/tests/unit/complex/__snapshots__/MultiEnumSelectRenderer.spec.ts.snap b/packages/vue-vuetify/tests/unit/complex/__snapshots__/MultiEnumSelectRenderer.spec.ts.snap new file mode 100644 index 000000000..100b2404c --- /dev/null +++ b/packages/vue-vuetify/tests/unit/complex/__snapshots__/MultiEnumSelectRenderer.spec.ts.snap @@ -0,0 +1,145 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MultiEnumSelectRenderer.vue > with enum schema > should render component and match snapshot 1`] = ` +"
+
+
+
+ +
+ +
+ + +
+
+
+
" +`; + +exports[`MultiEnumSelectRenderer.vue > with oneOf schema > should render component and match snapshot 1`] = ` +"
+
+
+
+ +
+ +
+ + +
+
+
+
" +`; diff --git a/packages/vue-vuetify/tests/unit/util/tester.spec.ts b/packages/vue-vuetify/tests/unit/util/tester.spec.ts new file mode 100644 index 000000000..e370a1105 --- /dev/null +++ b/packages/vue-vuetify/tests/unit/util/tester.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { isMultiEnumControl } from '../../../src/util/tester'; +import type { ControlElement } from '@jsonforms/core'; + +describe('tester', () => { + describe('isMultiEnumControl', () => { + it('should return false for const-only string array schemas', () => { + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/myArray', + }; + + const schema = { + type: 'object', + properties: { + myArray: { + type: 'array', + uniqueItems: true, + items: { + type: 'string', + const: 'x', + }, + }, + }, + }; + + const result = isMultiEnumControl(uischema, schema, { + rootSchema: schema, + config: {}, + }); + + expect(result).toBe(false); // isMultiEnumControl returns false when it doesn't match + }); + + it('should return true for enum string array schemas', () => { + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/myArray', + }; + + const schema = { + type: 'object', + properties: { + myArray: { + type: 'array', + uniqueItems: true, + items: { + type: 'string', + enum: ['x', 'y'], + }, + }, + }, + }; + + const result = isMultiEnumControl(uischema, schema, { + rootSchema: schema, + config: {}, + }); + + expect(result).toBe(true); + }); + }); +});