diff --git a/.changeset/empty-ravens-refuse.md b/.changeset/empty-ravens-refuse.md new file mode 100644 index 00000000..59a2e504 --- /dev/null +++ b/.changeset/empty-ravens-refuse.md @@ -0,0 +1,9 @@ +--- +"@getodk/xforms-engine": patch +"@getodk/web-forms": patch +"@getodk/scenario": patch +"@getodk/ui-solid": patch +"@getodk/common": patch +--- + +Add initial engine support for appearances diff --git a/packages/common/src/lib/type-assertions/assertUnknownObject.ts b/packages/common/src/lib/type-assertions/assertUnknownObject.ts new file mode 100644 index 00000000..24692e6d --- /dev/null +++ b/packages/common/src/lib/type-assertions/assertUnknownObject.ts @@ -0,0 +1,9 @@ +type UnknownObject = Record; + +type AssertUnknownObject = (value: unknown) => asserts value is UnknownObject; + +export const assertUnknownObject: AssertUnknownObject = (value) => { + if (typeof value !== 'object' || value == null) { + throw new Error('Not an object'); + } +}; diff --git a/packages/common/src/test/assertions/arrayOfAssertion.ts b/packages/common/src/test/assertions/arrayOfAssertion.ts new file mode 100644 index 00000000..16de17cd --- /dev/null +++ b/packages/common/src/test/assertions/arrayOfAssertion.ts @@ -0,0 +1,24 @@ +import type { AssertIs } from '../../../types/assertions/AssertIs.ts'; + +type ArrayItemAssertion = (item: unknown) => asserts item is T; + +export const arrayOfAssertion = ( + assertItem: ArrayItemAssertion, + itemTypeDescription: string +): AssertIs => { + return (value) => { + if (!Array.isArray(value)) { + throw new Error(`Not an array of ${itemTypeDescription}: value itself is not an array`); + } + + for (const [index, item] of value.entries()) { + try { + assertItem(item); + } catch { + throw new Error( + `Not an array of ${itemTypeDescription}: item at index ${index} not an instance` + ); + } + } + }; +}; diff --git a/packages/common/src/test/assertions/instanceArrayAssertion.ts b/packages/common/src/test/assertions/instanceArrayAssertion.ts index 6d7fe3e5..094a68d6 100644 --- a/packages/common/src/test/assertions/instanceArrayAssertion.ts +++ b/packages/common/src/test/assertions/instanceArrayAssertion.ts @@ -1,5 +1,6 @@ import type { AssertIs } from '../../../types/assertions/AssertIs.ts'; import type { ConstructorOf } from '../../../types/helpers'; +import { arrayOfAssertion } from './arrayOfAssertion.ts'; import { instanceAssertion } from './instanceAssertion.ts'; /** @@ -12,19 +13,5 @@ export const instanceArrayAssertion = ( ): AssertIs => { const assertInstance: AssertIs = instanceAssertion(Constructor); - return (value) => { - if (!Array.isArray(value)) { - throw new Error(`Not an array of ${Constructor.name}: value itself is not an array`); - } - - for (const [index, item] of value.entries()) { - try { - assertInstance(item); - } catch { - throw new Error( - `Not an array of ${Constructor.name}: item at index ${index} not an instance` - ); - } - } - }; + return arrayOfAssertion(assertInstance, Constructor.name); }; diff --git a/packages/common/types/string/PartiallyKnownString.ts b/packages/common/types/string/PartiallyKnownString.ts new file mode 100644 index 00000000..25b0019d --- /dev/null +++ b/packages/common/types/string/PartiallyKnownString.ts @@ -0,0 +1,27 @@ +/** + * Produces a `string` type while preserving autocomplete/autosuggest + * functionality for a known string (union). + * + * @see {@link https://www.totaltypescript.com/tips/create-autocomplete-helper-which-allows-for-arbitrary-values} + * + * @example + * ```ts + * let foo: PartiallyKnownString<'a' | 'b' | 'zed'>; + * + * // Each of these will be suggested by a TypeScript-supporting editor: + * foo = 'a'; + * foo = 'b'; + * foo = 'zed'; + * + * // ... but any string is valid: + * foo = 'lmnop'; + * ``` + */ +// prettier-ignore +export type PartiallyKnownString = + [string] extends [Known] + ? string + : ( + | Known + | (string & { /* Type hack! */ }) + ); diff --git a/packages/scenario/src/assertion/extensions/answers.ts b/packages/scenario/src/assertion/extensions/answers.ts index bde975d1..92fd049a 100644 --- a/packages/scenario/src/assertion/extensions/answers.ts +++ b/packages/scenario/src/assertion/extensions/answers.ts @@ -5,20 +5,18 @@ import { SymmetricTypedExpectExtension, extendExpect, instanceAssertion, - typeofAssertion, } from '@getodk/common/test/assertions/helpers.ts'; import { expect } from 'vitest'; import { ComparableAnswer } from '../../answer/ComparableAnswer.ts'; import { ExpectedApproximateUOMAnswer } from '../../answer/ExpectedApproximateUOMAnswer.ts'; import { AnswerResult } from '../../jr/Scenario.ts'; import { ValidationImplementationPendingError } from '../../jr/validation/ValidationImplementationPendingError.ts'; +import { assertString } from './shared-type-assertions.ts'; const assertComparableAnswer = instanceAssertion(ComparableAnswer); const assertExpectedApproximateUOMAnswer = instanceAssertion(ExpectedApproximateUOMAnswer); -const assertString = typeofAssertion('string'); - type AssertAnswerResult = (value: unknown) => asserts value is AnswerResult; const answerResults = new Set(Object.values(AnswerResult)); diff --git a/packages/scenario/src/assertion/extensions/appearances.ts b/packages/scenario/src/assertion/extensions/appearances.ts new file mode 100644 index 00000000..f6a1b1e1 --- /dev/null +++ b/packages/scenario/src/assertion/extensions/appearances.ts @@ -0,0 +1,75 @@ +import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts'; +import { + AsymmetricTypedExpectExtension, + extendExpect, +} from '@getodk/common/test/assertions/helpers.ts'; +import type { AnyNode } from '@getodk/xforms-engine'; +import { expect } from 'vitest'; +import { assertArrayOfStrings, assertEngineNode, assertString } from './shared-type-assertions.ts'; + +const hasAppearance = (node: AnyNode, appearance: string): boolean => { + return node.appearances?.[appearance] === true; +}; + +const appearanceExtensions = extendExpect(expect, { + toHaveAppearance: new AsymmetricTypedExpectExtension( + assertEngineNode, + assertString, + (actual, expected) => { + if (hasAppearance(actual, expected)) { + return true; + } + + return new Error( + `Node ${actual.currentState.reference} does not have appearance "${expected}"` + ); + } + ), + + notToHaveAppearance: new AsymmetricTypedExpectExtension( + assertEngineNode, + assertString, + (actual, expected) => { + if (hasAppearance(actual, expected)) { + return new Error( + `Node ${actual.currentState.reference} has appearance "${expected}", which was not expected` + ); + } + + return true; + } + ), + + toYieldAppearances: new AsymmetricTypedExpectExtension( + assertEngineNode, + assertArrayOfStrings, + (actual, expected) => { + const yielded = new Set(); + + for (const appearance of actual.appearances ?? []) { + yielded.add(appearance); + } + + const notYielded = expected.filter((item) => { + return !yielded.has(item); + }); + + if (notYielded.length === 0) { + return true; + } + + return new Error( + `Node ${actual.currentState.reference} did not yield expected appearances ${notYielded.join(', ')}` + ); + } + ), +}); + +type AppearanceExtensions = typeof appearanceExtensions; + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + interface Assertion extends DeriveStaticVitestExpectExtension {} + interface AsymmetricMatchersContaining + extends DeriveStaticVitestExpectExtension {} +} diff --git a/packages/scenario/src/assertion/extensions/body-classes.ts b/packages/scenario/src/assertion/extensions/body-classes.ts new file mode 100644 index 00000000..03bd2f8d --- /dev/null +++ b/packages/scenario/src/assertion/extensions/body-classes.ts @@ -0,0 +1,75 @@ +import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts'; +import { + AsymmetricTypedExpectExtension, + extendExpect, +} from '@getodk/common/test/assertions/helpers.ts'; +import type { RootNode } from '@getodk/xforms-engine'; +import { expect } from 'vitest'; +import { assertArrayOfStrings, assertRootNode, assertString } from './shared-type-assertions.ts'; + +const hasClass = (node: RootNode, className: string): boolean => { + return node.classes?.[className] === true; +}; + +const bodyClassesExtensions = extendExpect(expect, { + toHaveClass: new AsymmetricTypedExpectExtension( + assertRootNode, + assertString, + (actual, expected) => { + if (hasClass(actual, expected)) { + return true; + } + + return new Error( + `RootNode ${actual.currentState.reference} does not have class "${expected}"` + ); + } + ), + + notToHaveClass: new AsymmetricTypedExpectExtension( + assertRootNode, + assertString, + (actual, expected) => { + if (hasClass(actual, expected)) { + return new Error( + `RootNode ${actual.currentState.reference} has class "${expected}", which was not expected` + ); + } + + return true; + } + ), + + toYieldClasses: new AsymmetricTypedExpectExtension( + assertRootNode, + assertArrayOfStrings, + (actual, expected) => { + const yielded = new Set(); + + for (const className of actual.classes) { + yielded.add(className); + } + + const notYielded = expected.filter((item) => { + return !yielded.has(item); + }); + + if (notYielded.length === 0) { + return true; + } + + return new Error( + `RootNode ${actual.currentState.reference} did not yield expected classes ${notYielded.join(', ')}` + ); + } + ), +}); + +type BodyClassExtensions = typeof bodyClassesExtensions; + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + interface Assertion extends DeriveStaticVitestExpectExtension {} + interface AsymmetricMatchersContaining + extends DeriveStaticVitestExpectExtension {} +} diff --git a/packages/scenario/src/assertion/extensions/shared-type-assertions.ts b/packages/scenario/src/assertion/extensions/shared-type-assertions.ts new file mode 100644 index 00000000..a0fa872e --- /dev/null +++ b/packages/scenario/src/assertion/extensions/shared-type-assertions.ts @@ -0,0 +1,55 @@ +import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts'; +import { arrayOfAssertion } from '@getodk/common/test/assertions/arrayOfAssertion.ts'; +import { typeofAssertion } from '@getodk/common/test/assertions/typeofAssertion.ts'; +import type { AnyNode, RootNode } from '@getodk/xforms-engine'; + +type AssertRootNode = (node: unknown) => asserts node is RootNode; + +export const assertRootNode: AssertRootNode = (node) => { + assertUnknownObject(node); + + const maybeRootNode = node as Partial; + + if ( + maybeRootNode.nodeType !== 'root' || + typeof maybeRootNode.setLanguage !== 'function' || + typeof maybeRootNode.currentState !== 'object' || + maybeRootNode.currentState == null + ) { + throw new Error('Node is not a `RootNode`'); + } +}; + +type AssertEngineNode = (node: unknown) => asserts node is AnyNode; + +type AnyNodeType = AnyNode['nodeType']; +type NonRootNodeType = Exclude; + +const nonRootNodeTypes = new Set([ + 'string', + 'select', + 'subtree', + 'group', + 'repeat-range', + 'repeat-instance', +]); + +export const assertEngineNode: AssertEngineNode = (node) => { + assertUnknownObject(node); + + const maybeNode = node as Partial; + + assertRootNode(maybeNode.root); + + if (maybeNode === maybeNode.root) { + return; + } + + if (!nonRootNodeTypes.has(maybeNode.nodeType as NonRootNodeType)) { + throw new Error('Not an engine node'); + } +}; + +export const assertString = typeofAssertion('string'); + +export const assertArrayOfStrings = arrayOfAssertion(assertString, 'string'); diff --git a/packages/scenario/src/assertion/setup.ts b/packages/scenario/src/assertion/setup.ts index 1fdaf74f..2c4e83d6 100644 --- a/packages/scenario/src/assertion/setup.ts +++ b/packages/scenario/src/assertion/setup.ts @@ -1,4 +1,6 @@ import './extensions/answers.ts'; +import './extensions/appearances.ts'; +import './extensions/body-classes.ts'; import './extensions/choices.ts'; import './extensions/form-state.ts'; import './extensions/node-state.ts'; diff --git a/packages/scenario/test/appearance-body-class.test.ts b/packages/scenario/test/appearance-body-class.test.ts new file mode 100644 index 00000000..d1331961 --- /dev/null +++ b/packages/scenario/test/appearance-body-class.test.ts @@ -0,0 +1,443 @@ +import { BodyXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/BodyXFormsElement.ts'; +import { TagXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/TagXFormsElement.ts'; +import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; +import { + body, + head, + html, + input, + mainInstance, + model, + t, + title, +} from '@getodk/common/test/fixtures/xform-dsl/index.ts'; +import { describe, expect, it } from 'vitest'; +import { Scenario } from '../src/jr/Scenario.ts'; + +const element = ( + name: string, + ref: string, + appearances: readonly string[], + ...children: XFormsElement[] +): XFormsElement => { + return new TagXFormsElement( + name, + new Map([ + ['ref', ref], + ['appearance', appearances.join(' ')], + ]), + children + ); +}; + +describe('Appearances', () => { + // eslint-disable-next-line @typescript-eslint/sort-type-constituents + type AppearanceCategory = 'documented' | 'arbitrary' | 'mixed'; + + interface AppearanceTestCase { + readonly category: AppearanceCategory; + readonly appearances: readonly string[]; + } + + describe('', () => { + const inputCases: readonly AppearanceTestCase[] = [ + { + category: 'documented', + appearances: ['multiline'], + }, + { + category: 'documented', + appearances: ['numbers', 'masked'], + }, + { + category: 'arbitrary', + appearances: ['bold', 'round'], + }, + { + category: 'mixed', + appearances: ['hidden', 'url'], + }, + ]; + + const inputFixture = (appearances: readonly string[]): XFormsElement => { + // prettier-ignore + return html( + head( + title('Input appearances'), + model( + mainInstance( + t('data id="appearances-fixture"', + t('inp'))))), + body( + element('input', '/data/inp', appearances) + ), + ); + }; + + it.each(inputCases)( + 'gets the $category appearances $appearances defined on an input', + async ({ appearances }) => { + const scenario = await Scenario.init('Input appearances', inputFixture(appearances)); + + const node = scenario.getInstanceNode('/data/inp'); + + appearances.forEach((appearance) => { + expect(node).toHaveAppearance(appearance); + }); + } + ); + + it.each(inputCases)( + 'iterates the $category appearances $appearances defined on an input', + async ({ appearances }) => { + const scenario = await Scenario.init('Input appearances', inputFixture(appearances)); + + const node = scenario.getInstanceNode('/data/inp'); + + expect(node).toYieldAppearances(appearances); + } + ); + + it('does not get appearances not present in the form definition', async () => { + const scenario = await Scenario.init('Input appearances', inputFixture(['no-calendar'])); + + const node = scenario.getInstanceNode('/data/inp'); + + expect(node).notToHaveAppearance('month-year'); + }); + }); + + interface SelectSuiteOptions { + readonly selectTag: 'select' | 'select1'; + } + + describe.each([{ selectTag: 'select' }, { selectTag: 'select1' }])( + '<$selectTag>', + ({ selectTag }) => { + const selectCases: readonly AppearanceTestCase[] = [ + { + category: 'documented', + appearances: ['compact'], + }, + { + category: 'documented', + appearances: ['columns', 'autocomplete'], + }, + { + category: 'arbitrary', + appearances: ['bold', 'round'], + }, + { + category: 'mixed', + appearances: ['hidden', 'url'], + }, + ]; + + const selectFixture = (appearances: readonly string[]): XFormsElement => { + // prettier-ignore + return html( + head( + title('Select appearances'), + model( + mainInstance( + t('data id="appearances-fixture"', + t('sel'))))), + body( + element(selectTag, '/data/sel', appearances) + ), + ); + }; + + it.each(selectCases)( + `gets the $category appearances $appearances defined on a ${selectTag}`, + async ({ appearances }) => { + const scenario = await Scenario.init('Select appearances', selectFixture(appearances)); + + const node = scenario.getInstanceNode('/data/sel'); + + appearances.forEach((appearance) => { + expect(node).toHaveAppearance(appearance); + }); + } + ); + + it.each(selectCases)( + `iterates the $category appearances $appearances defined on a ${selectTag}`, + async ({ appearances }) => { + const scenario = await Scenario.init('Select appearances', selectFixture(appearances)); + + const node = scenario.getInstanceNode('/data/sel'); + + expect(node).toYieldAppearances(appearances); + } + ); + + it('does not get appearances not present in the form definition', async () => { + const scenario = await Scenario.init('Select appearances', selectFixture(['columns-1'])); + + const node = scenario.getInstanceNode('/data/sel'); + + expect(node).notToHaveAppearance('columns-2'); + }); + + describe('aliases', () => { + it('gets the "autocomplete" appearance alias when the "search" appearance is defined', async () => { + const scenario = await Scenario.init('Select appearances', selectFixture(['search'])); + + const node = scenario.getInstanceNode('/data/sel'); + + expect(node).toHaveAppearance('autocomplete'); + }); + + it('gets the deprecated "search" appearance when the "search" appearance is defined', async () => { + const scenario = await Scenario.init('Select appearances', selectFixture(['search'])); + + const node = scenario.getInstanceNode('/data/sel'); + + expect(node).toHaveAppearance('search'); + }); + + it('yields both the deprecated "search" and aliased "autocomplete" appearances when the "search" appearance is defined', async () => { + const scenario = await Scenario.init('Select appearances', selectFixture(['search'])); + + const node = scenario.getInstanceNode('/data/sel'); + + expect(node).toYieldAppearances(['autocomplete', 'search']); + }); + }); + } + ); + + describe('', () => { + const groupCases: readonly AppearanceTestCase[] = [ + { + category: 'documented', + appearances: ['field-list'], + }, + { + category: 'documented', + appearances: ['table-list'], + }, + { + category: 'arbitrary', + appearances: ['task-list', 'shopping-list'], + }, + { + category: 'mixed', + appearances: ['field-list', 'shopping-list'], + }, + ]; + + const groupFixture = (appearances: readonly string[]): XFormsElement => { + // prettier-ignore + return html( + head( + title('Group appearances'), + model( + mainInstance( + t('data id="appearances-fixture"', + t('grp', + t('inp')))))), + body( + element('group', '/data/grp', appearances, + input('/data/grp/inp') + ) + ), + ); + }; + + it.each(groupCases)( + 'looks up the $category appearances $appearances by name, when defined on a group', + async ({ appearances }) => { + const scenario = await Scenario.init('Group appearances', groupFixture(appearances)); + + const node = scenario.getInstanceNode('/data/grp'); + + appearances.forEach((appearance) => { + expect(node).toHaveAppearance(appearance); + }); + } + ); + + it.each(groupCases)( + 'iterates the $category appearances $appearances defined on a group', + async ({ appearances }) => { + const scenario = await Scenario.init('Group appearances', groupFixture(appearances)); + + const node = scenario.getInstanceNode('/data/grp'); + + expect(node).toYieldAppearances(appearances); + } + ); + + it('does not get appearances not present in the form definition', async () => { + const scenario = await Scenario.init('Group appearances', groupFixture(['field-list'])); + + const node = scenario.getInstanceNode('/data/grp'); + + expect(node).notToHaveAppearance('something-else'); + }); + }); + + describe('', () => { + const repeatCases: readonly AppearanceTestCase[] = [ + { + category: 'documented', + appearances: ['field-list'], + }, + { + category: 'documented', + appearances: ['table-list'], + }, + { + category: 'arbitrary', + appearances: ['task-list', 'shopping-list'], + }, + { + category: 'mixed', + appearances: ['field-list', 'shopping-list'], + }, + ]; + + const repeatFixture = (appearances: readonly string[]): XFormsElement => { + // prettier-ignore + return html( + head( + title('Repeat appearances'), + model( + mainInstance( + t('data id="appearances-fixture"', + t('rep jr:template=""', + t('inp')), + t('rep', + t('inp')), + t('rep', + t('inp')))))), + body( + element('repeat', '/data/rep', appearances, + input('/data/rep/inp') + ) + ), + ); + }; + + it.each(repeatCases)( + 'gets the $category appearances $appearances defined on a repeat range', + async ({ appearances }) => { + const scenario = await Scenario.init('Repeat appearances', repeatFixture(appearances)); + + const node = scenario.getInstanceNode('/data/rep'); + + appearances.forEach((appearance) => { + expect(node).toHaveAppearance(appearance); + }); + } + ); + + it.each(repeatCases)( + 'gets the $category appearances $appearances defined on a repeat, for each individual repeat instance', + async ({ appearances }) => { + const scenario = await Scenario.init('Repeat appearances', repeatFixture(appearances)); + + const nodes = [ + scenario.getInstanceNode('/data/rep[1]'), + scenario.getInstanceNode('/data/rep[2]'), + ]; + + nodes.forEach((node) => { + appearances.forEach((appearance) => { + expect(node).toHaveAppearance(appearance); + }); + }); + } + ); + + it.each(repeatCases)( + 'iterates the $category appearances $appearances defined on a repeat range', + async ({ appearances }) => { + const scenario = await Scenario.init('Repeat appearances', repeatFixture(appearances)); + + const node = scenario.getInstanceNode('/data/rep'); + + expect(node).toYieldAppearances(appearances); + } + ); + + it.each(repeatCases)( + 'iterates the $category appearances $appearances defined on a repeat, for each individual repeat instance', + async ({ appearances }) => { + const scenario = await Scenario.init('Repeat appearances', repeatFixture(appearances)); + + const nodes = [ + scenario.getInstanceNode('/data/rep[1]'), + scenario.getInstanceNode('/data/rep[2]'), + ]; + + nodes.forEach((node) => { + expect(node).toYieldAppearances(appearances); + }); + } + ); + + it('does not get appearances not present in the form definition', async () => { + const scenario = await Scenario.init('Repeat appearances', repeatFixture(['field-list'])); + + const nodes = [ + scenario.getInstanceNode('/data/rep'), + scenario.getInstanceNode('/data/rep[1]'), + scenario.getInstanceNode('/data/rep[2]'), + ]; + + nodes.forEach((node) => { + expect(node).notToHaveAppearance('something-else'); + }); + }); + }); +}); + +describe(' classes', () => { + class BodyXFormsElementWithAttributes extends TagXFormsElement implements BodyXFormsElement { + override readonly name = 'h:body'; + + constructor(attributes: Record, children: XFormsElement[]) { + super('h:body', new Map(Object.entries(attributes)), children); + } + } + + const bodyWithAttributes = ( + attributes: Record, + ...children: XFormsElement[] + ): BodyXFormsElement => { + return new BodyXFormsElementWithAttributes(attributes, children); + }; + + // prettier-ignore + const bodyClassesFixture = html( + head( + title('Body classes'), + model( + mainInstance( + t('data id="appearances-fixture"', + t('inp'))))), + bodyWithAttributes( + { class: 'pages theme-grid' }, + input('/data/inp') + ), + ); + + it('gets the classes defined on the form body from the instance root node', async () => { + const scenario = await Scenario.init('Body classes', bodyClassesFixture); + const rootNode = scenario.getInstanceNode('/data'); + + expect(rootNode).toHaveClass('pages'); + expect(rootNode).toHaveClass('theme-grid'); + expect(rootNode).notToHaveClass('something-else'); + }); + + it('iterates the classes defined on the form body from the instance root node', async () => { + const scenario = await Scenario.init('Body classes', bodyClassesFixture); + const rootNode = scenario.getInstanceNode('/data'); + + expect(rootNode).toYieldClasses(['pages', 'theme-grid']); + }); +}); diff --git a/packages/ui-solid/src/components/XForm/containers/XFormGroup.tsx b/packages/ui-solid/src/components/XForm/containers/XFormGroup.tsx index 0f380324..c7f65201 100644 --- a/packages/ui-solid/src/components/XForm/containers/XFormGroup.tsx +++ b/packages/ui-solid/src/components/XForm/containers/XFormGroup.tsx @@ -28,7 +28,7 @@ const groupNode = (node: GroupNode | RepeatRangeNode): GroupNode | null => { export const XFormGroup = (props: XFormGroupProps) => { const groupLabel = () => { - if (props.node.definition.type === 'repeat-sequence') { + if (props.node.nodeType === 'repeat-range') { return null; } diff --git a/packages/web-forms/src/components/QuestionList.vue b/packages/web-forms/src/components/QuestionList.vue index 16cf60bc..5883f7cd 100644 --- a/packages/web-forms/src/components/QuestionList.vue +++ b/packages/web-forms/src/components/QuestionList.vue @@ -1,16 +1,28 @@