From d8c1c38123d83784005e9b8d9b40005e8a408081 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 11 Jul 2025 16:49:41 +0200 Subject: [PATCH] refactor: fixes to custom field --- .../__tests__/viewParams.utils.test.ts | 4 +- .../composite/CustomFieldEntry.tsx | 2 +- .../composite/CustomFieldForm.tsx | 4 +- .../composite/EventEditorCustomFields.tsx | 2 +- .../cuesheetColsFactory.tsx | 2 +- .../__tests__/customFields.parser.test.ts | 50 +++++++++---------- .../custom-fields/customFields.parser.ts | 2 +- .../custom-fields/customFields.validation.ts | 4 +- .../excel/__tests__/excel.parser.test.ts | 44 ++++++++-------- .../server/src/api-data/excel/excel.parser.ts | 2 +- .../rundown/__mocks__/rundown.mocks.ts | 2 +- .../rundown/__tests__/rundown.dao.test.ts | 6 +-- .../rundown/__tests__/rundown.parser.test.ts | 10 ++-- .../src/api-data/rundown/rundown.dao.ts | 40 ++++++--------- .../src/api-data/rundown/rundown.service.ts | 11 ++-- .../__tests__/DataProvider.utils.test.ts | 12 ++--- apps/server/src/models/demoProject.ts | 4 +- apps/server/test-db/db.json | 4 +- e2e/tests/fixtures/e2e-test-db.json | 4 +- .../src/definitions/core/CustomFields.type.ts | 2 +- 20 files changed, 103 insertions(+), 108 deletions(-) diff --git a/apps/client/src/common/components/view-params-editor/__tests__/viewParams.utils.test.ts b/apps/client/src/common/components/view-params-editor/__tests__/viewParams.utils.test.ts index 9092d3329..647aad619 100644 --- a/apps/client/src/common/components/view-params-editor/__tests__/viewParams.utils.test.ts +++ b/apps/client/src/common/components/view-params-editor/__tests__/viewParams.utils.test.ts @@ -7,8 +7,8 @@ import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewP describe('makeOptionsFromCustomFields()', () => { const testCustomFields: CustomFields = { - field1: { label: 'Field 1', colour: 'red', type: 'string' }, - field2: { label: 'Field 2', colour: 'blue', type: 'string' }, + field1: { label: 'Field 1', colour: 'red', type: 'text' }, + field2: { label: 'Field 2', colour: 'blue', type: 'text' }, }; it('creates an array of options to use in a select', () => { diff --git a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx index 96c1bc5e7..1b2857186 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldEntry.tsx @@ -16,7 +16,7 @@ interface CustomFieldEntryProps { colour: string; label: string; fieldKey: string; - type: 'string' | 'image'; + type: CustomField['type']; onEdit: (key: CustomFieldKey, patch: CustomField) => Promise; onDelete: (key: CustomFieldKey) => Promise; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx index 451c15a91..d9310aa9f 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/composite/CustomFieldForm.tsx @@ -42,7 +42,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { watch, formState: { errors, isSubmitting, isValid, isDirty }, } = useForm({ - defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' }, + defaultValues: { type: 'text', label: initialLabel || '', colour: initialColour || '' }, resetOptions: { keepDirtyValues: true, }, @@ -96,7 +96,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) { onValueChange={(value) => setValue('type', value, { shouldDirty: true })} value={watch('type')} items={[ - { value: 'string', label: 'Text' }, + { value: 'text', label: 'Text' }, { value: 'image', label: 'Image' }, ]} /> diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx index 2357e2b75..be6b965c8 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorCustomFields.tsx @@ -30,7 +30,7 @@ export default function EntryEditorCustomFields({ const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour); const labelText = customFields[fieldKey].label; - if (customFields[fieldKey].type === 'string') { + if (customFields[fieldKey].type === 'text') { return ( { const errorEmitter = vi.fn(); // @ts-expect-error -- data is external, we check bad types const customFields = { - 1: { label: 'test', type: 'string', colour: 'red' }, // ok - 2: { label: 'test', type: 'string' }, // duplicate label - 3: { label: '', type: 'string' }, // missing colour - 4: { type: 'string', colour: '' }, // missing label + 1: { label: 'test', type: 'text', colour: 'red' }, // ok + 2: { label: 'test', type: 'text' }, // duplicate label + 3: { label: '', type: 'text' }, // missing colour + 4: { type: 'text', colour: '' }, // missing label } as CustomFields; const result = parseCustomFields({ customFields }, errorEmitter); expect(result).toMatchObject({ test: { label: 'test', - type: 'string', + type: 'text', colour: 'red', }, }); @@ -39,9 +39,9 @@ describe('sanitiseCustomFields()', () => { it('returns an object of valid entries', () => { const customFields: CustomFields = { - test: { label: 'test', type: 'string', colour: 'red' }, - test2: { label: 'test2', type: 'string', colour: 'green' }, - Test3: { label: 'Test3', type: 'string', colour: '' }, + test: { label: 'test', type: 'text', colour: 'red' }, + test2: { label: 'test2', type: 'text', colour: 'green' }, + Test3: { label: 'Test3', type: 'text', colour: '' }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual(customFields); @@ -51,18 +51,18 @@ describe('sanitiseCustomFields()', () => { const testTypes = sanitiseCustomFields({ test1: { label: 'test1', type: 'another', colour: 'red' }, test2: { label: 'test2', type: 'image', colour: 'red' }, - test3: { label: 'test3', type: 'string', colour: 'red' }, + test3: { label: 'test3', type: 'text', colour: 'red' }, }); expect(testTypes).toMatchObject({ test2: { label: 'test2', type: 'image', colour: 'red' }, - test3: { label: 'test3', type: 'string', colour: 'red' }, + test3: { label: 'test3', type: 'text', colour: 'red' }, }); }); it('colour must be a string', () => { const customFields: CustomFields = { // @ts-expect-error intentional bad data - test: { label: 'test', type: 'string', colour: 5 }, + test: { label: 'test', type: 'text', colour: 5 }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual({}); @@ -70,7 +70,7 @@ describe('sanitiseCustomFields()', () => { it('label can not be empty', () => { const customFields: CustomFields = { - '': { label: '', type: 'string', colour: 'red' }, + '': { label: '', type: 'text', colour: 'red' }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual({}); @@ -79,10 +79,10 @@ describe('sanitiseCustomFields()', () => { it('remove extra stuff', () => { const customFields: CustomFields = { // @ts-expect-error intentional bad data - test: { label: 'test', type: 'string', colour: 'red', extra: 'should be removed' }, + test: { label: 'test', type: 'text', colour: 'red', extra: 'should be removed' }, }; const expectedCustomFields: CustomFields = { - test: { label: 'test', type: 'string', colour: 'red' }, + test: { label: 'test', type: 'text', colour: 'red' }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual(expectedCustomFields); @@ -90,10 +90,10 @@ describe('sanitiseCustomFields()', () => { it('enforce name cohesion', () => { const customFields: CustomFields = { - test: { label: 'NewName', type: 'string', colour: 'red' }, + test: { label: 'NewName', type: 'text', colour: 'red' }, }; const expectedCustomFields: CustomFields = { - NewName: { label: 'NewName', type: 'string', colour: 'red' }, + NewName: { label: 'NewName', type: 'text', colour: 'red' }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual(expectedCustomFields); @@ -101,10 +101,10 @@ describe('sanitiseCustomFields()', () => { it('labels with space', () => { const customFields: CustomFields = { - Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' }, + Test_with_Space: { label: 'Test with Space', type: 'text', colour: 'red' }, }; const expectedCustomFields: CustomFields = { - Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' }, + Test_with_Space: { label: 'Test with Space', type: 'text', colour: 'red' }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual(expectedCustomFields); @@ -112,15 +112,15 @@ describe('sanitiseCustomFields()', () => { it('filters invalid entries', () => { const customFields: CustomFields = { - test: { label: 'test', type: 'string', colour: 'red' }, - test2: { label: 'test2', type: 'string', colour: 'green' }, - bad: { label: '', type: 'string', colour: '' }, - Test3: { label: 'Test3', type: 'string', colour: '' }, + test: { label: 'test', type: 'text', colour: 'red' }, + test2: { label: 'test2', type: 'text', colour: 'green' }, + bad: { label: '', type: 'text', colour: '' }, + Test3: { label: 'Test3', type: 'text', colour: '' }, }; const expectedCustomFields: CustomFields = { - test: { label: 'test', type: 'string', colour: 'red' }, - test2: { label: 'test2', type: 'string', colour: 'green' }, - Test3: { label: 'Test3', type: 'string', colour: '' }, + test: { label: 'test', type: 'text', colour: 'red' }, + test2: { label: 'test2', type: 'text', colour: 'green' }, + Test3: { label: 'Test3', type: 'text', colour: '' }, }; const sanitationResult = sanitiseCustomFields(customFields); expect(sanitationResult).toStrictEqual(expectedCustomFields); diff --git a/apps/server/src/api-data/custom-fields/customFields.parser.ts b/apps/server/src/api-data/custom-fields/customFields.parser.ts index 6f9d568ba..b8a873033 100644 --- a/apps/server/src/api-data/custom-fields/customFields.parser.ts +++ b/apps/server/src/api-data/custom-fields/customFields.parser.ts @@ -56,7 +56,7 @@ export function sanitiseCustomFields(data: object): CustomFields { 'colour' in data && typeof data.colour === 'string' && 'type' in data && - (data.type === 'string' || data.type === 'image') + (data.type === 'text' || data.type === 'image') ); } diff --git a/apps/server/src/api-data/custom-fields/customFields.validation.ts b/apps/server/src/api-data/custom-fields/customFields.validation.ts index f63250919..c4f775ffb 100644 --- a/apps/server/src/api-data/custom-fields/customFields.validation.ts +++ b/apps/server/src/api-data/custom-fields/customFields.validation.ts @@ -11,7 +11,7 @@ export const validateCustomField = [ .custom((value) => { return isAlphanumericWithSpace(value); }), - body('type').isIn(['string', 'image']), + body('type').isIn(['text', 'image']), body('colour').isString().trim(), requestValidationFunction, @@ -26,7 +26,7 @@ export const validateEditCustomField = [ .custom((value) => { return isAlphanumericWithSpace(value); }), - body('type').isIn(['string', 'image']), + body('type').isIn(['text', 'image']), body('colour').isString().trim(), requestValidationFunction, diff --git a/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts b/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts index ad261d308..c9278182d 100644 --- a/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts +++ b/apps/server/src/api-data/excel/__tests__/excel.parser.test.ts @@ -18,30 +18,30 @@ describe('parseExcel()', () => { }; const existingCustomFields: CustomFields = { - user0: { type: 'string', colour: 'red', label: 'user0' }, - user1: { type: 'string', colour: 'green', label: 'user1' }, - user2: { type: 'string', colour: 'blue', label: 'user2' }, + user0: { type: 'text', colour: 'red', label: 'user0' }, + user1: { type: 'text', colour: 'green', label: 'user1' }, + user2: { type: 'text', colour: 'blue', label: 'user2' }, }; const parsedData = parseExcel(dataFromExcelTemplate, existingCustomFields, 'testSheet', importMap); expect(parsedData.customFields).toStrictEqual({ user0: { - type: 'string', + type: 'text', colour: 'red', label: 'user0', }, user1: { - type: 'string', + type: 'text', colour: 'green', label: 'user1', }, user2: { - type: 'string', + type: 'text', colour: 'blue', label: 'user2', }, user3: { - type: 'string', + type: 'text', colour: '', label: 'user3', }, @@ -97,12 +97,12 @@ describe('parseExcel()', () => { const parsedData = parseExcel(dataFromExcelTemplate, {}, 'testSheet', importMap); expect(parsedData.customFields).toStrictEqual({ niu1: { - type: 'string', + type: 'text', colour: '', label: 'niu1', }, niu2: { - type: 'string', + type: 'text', colour: '', label: 'niu2', }, @@ -448,17 +448,17 @@ describe('getCustomFieldData()', () => { const result = getCustomFieldData(importMap, {}); expect(result.mergedCustomFields).toStrictEqual({ lighting: { - type: 'string', + type: 'text', colour: '', label: 'lighting', }, sound: { - type: 'string', + type: 'text', colour: '', label: 'sound', }, video: { - type: 'string', + type: 'text', colour: '', label: 'video', }, @@ -499,30 +499,30 @@ describe('getCustomFieldData()', () => { } as ImportMap; const existingCustomFields: CustomFields = { - lighting: { label: 'lighting', type: 'string', colour: 'red' }, - sound: { label: 'sound', type: 'string', colour: 'green' }, - ontime_key: { label: 'ontime key', type: 'string', colour: 'blue' }, + lighting: { label: 'lighting', type: 'text', colour: 'red' }, + sound: { label: 'sound', type: 'text', colour: 'green' }, + ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' }, }; const result = getCustomFieldData(importMap, existingCustomFields); expect(result.mergedCustomFields).toStrictEqual({ lighting: { - type: 'string', + type: 'text', colour: 'red', label: 'lighting', }, sound: { - type: 'string', + type: 'text', colour: 'green', label: 'sound', }, video: { - type: 'string', + type: 'text', colour: '', label: 'video', }, ontime_key: { - type: 'string', + type: 'text', colour: 'blue', label: 'ontime key', }, @@ -550,17 +550,17 @@ describe('getCustomFieldData()', () => { const result = getCustomFieldData(importMap, {}); expect(result.mergedCustomFields).toStrictEqual({ Lighting: { - type: 'string', + type: 'text', colour: '', label: 'Lighting', }, Sound: { - type: 'string', + type: 'text', colour: '', label: 'Sound', }, video: { - type: 'string', + type: 'text', colour: '', label: 'video', }, diff --git a/apps/server/src/api-data/excel/excel.parser.ts b/apps/server/src/api-data/excel/excel.parser.ts index 64ee6fe54..461edc70a 100644 --- a/apps/server/src/api-data/excel/excel.parser.ts +++ b/apps/server/src/api-data/excel/excel.parser.ts @@ -330,7 +330,7 @@ export function getCustomFieldData( // 1. add the custom field to the merged custom fields mergedCustomFields[keyInCustomFields] = { - type: 'string', // we currently only support string custom fields + type: 'text', // we currently only support text custom fields colour: maybeExistingColour, label: ontimeLabel, }; diff --git a/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts b/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts index b90557773..7d40d25d9 100644 --- a/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts +++ b/apps/server/src/api-data/rundown/__mocks__/rundown.mocks.ts @@ -49,7 +49,7 @@ export function makeRundown(patch: Partial): Rundown { export function makeCustomField(patch: Partial): CustomField { return { - type: 'string', + type: 'text', colour: '#000000', label: 'Custom Field', ...patch, diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts index 99a9c737b..ee0864f42 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts @@ -62,7 +62,7 @@ describe('createTransaction', () => { rundown.title = 'Another Title'; customFields['newField'] = { label: 'New Field', - type: 'string', + type: 'text', colour: 'blue', }; @@ -524,12 +524,12 @@ describe('processRundown()', () => { const customProperties: CustomFields = { lighting: { label: 'lighting', - type: 'string', + type: 'text', colour: 'red', }, sound: { label: 'sound', - type: 'string', + type: 'text', colour: 'red', }, }; diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts index 6885fcd03..93cd12807 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts @@ -197,12 +197,12 @@ describe('parseRundown()', () => { const customFields: CustomFields = { lighting: { - type: 'string', + type: 'text', colour: 'red', label: 'lighting', }, sound: { - type: 'string', + type: 'text', colour: 'red', label: 'sound', }, @@ -229,7 +229,7 @@ describe('parseRundown()', () => { const customFields: CustomFields = { lighting: { - type: 'string', + type: 'text', colour: 'red', label: 'lighting', }, @@ -308,12 +308,12 @@ describe('handleCustomField()', () => { it('creates a map of where custom fields are used', () => { const customFields = { lighting: { - type: 'string', + type: 'text', colour: 'red', label: 'lighting', }, sound: { - type: 'string', + type: 'text', colour: 'red', label: 'sound', }, diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 1f691fc96..62bdcfe0b 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -121,38 +121,28 @@ export function createTransaction(options: TransactionOptions): Transaction { await getDataProvider().setRundown(cachedRundown.id, cachedRundown); }); - // increment the revision number + // update fields which are agnostic of whether the rundown is processed cachedRundown.revision = cachedRundown.revision + 1; + cachedRundown.title = rundown.title; - /** - * Some mutations do not require processing the rundown - * We simply increment the revision and return the rundown - */ if (!shouldProcess) { - cachedRundown.title = rundown.title; + // if we dont need to process, we just reassign the commit data to the cache cachedRundown.entries = rundown.entries; cachedRundown.order = rundown.order; cachedRundown.flatOrder = rundown.flatOrder; - return { - rundown: cachedRundown, - rundownMetadata, // metadata doesnt change as long as we dont process the rundown - customFields: projectCustomFields, - revision: cachedRundown.revision, - }; + } else { + const processedData = processRundown(rundown, projectCustomFields); + // update the cache values + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data + const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } = + processedData; + + cachedRundown.entries = entries; + cachedRundown.order = order; + cachedRundown.flatOrder = metadata.flatEntryOrder; + customFieldsMetadata.assigned = assignedCustomFields; + rundownMetadata = metadata; } - - const processedData = processRundown(rundown, projectCustomFields); - // update the cache values - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data - const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } = - processedData; - - cachedRundown.title = rundown.title; - cachedRundown.entries = entries; - cachedRundown.order = order; - cachedRundown.flatOrder = metadata.flatEntryOrder; - customFieldsMetadata.assigned = assignedCustomFields; - rundownMetadata = metadata; } // if the customFields are mutable we persist the changes diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 3872a8b2b..61f8b234a 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -464,9 +464,14 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial { it('merges customFields into existing object', () => { const existing = { customFields: { - lighting: { type: 'string', label: 'lighting' }, - sound: { type: 'string', label: 'sound' }, + lighting: { type: 'text', label: 'lighting' }, + sound: { type: 'text', label: 'sound' }, }, }; const newData = { customFields: { - switcher: { type: 'string', label: 'switcher' }, - vfx: { type: 'string', label: 'vfx' }, + switcher: { type: 'text', label: 'switcher' }, + vfx: { type: 'text', label: 'vfx' }, }, }; const expected = { - switcher: { type: 'string', label: 'switcher' }, - vfx: { type: 'string', label: 'vfx' }, + switcher: { type: 'text', label: 'switcher' }, + vfx: { type: 'text', label: 'vfx' }, }; //@ts-expect-error -- testing partial merge diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 09724139a..a51e21362 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -528,12 +528,12 @@ export const demoDb: DatabaseModel = { customFields: { Song: { label: 'Song', - type: 'string', + type: 'text', colour: '#339E4E', }, Artist: { label: 'Artist', - type: 'string', + type: 'text', colour: '#3E75E8', }, }, diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index 3f870d018..964cda172 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -477,12 +477,12 @@ "customFields": { "song": { "label": "Song", - "type": "string", + "type": "text", "colour": "#339E4E" }, "artist": { "label": "Artist", - "type": "string", + "type": "text", "colour": "#3E75E8" } }, diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json index 103dc35e9..328cda61a 100644 --- a/e2e/tests/fixtures/e2e-test-db.json +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -494,12 +494,12 @@ "customFields": { "Song": { "label": "Song", - "type": "string", + "type": "text", "colour": "#339E4E" }, "Artist": { "label": "Artist", - "type": "string", + "type": "text", "colour": "#3E75E8" } }, diff --git a/packages/types/src/definitions/core/CustomFields.type.ts b/packages/types/src/definitions/core/CustomFields.type.ts index 3f8333fb4..1a20f3cdb 100644 --- a/packages/types/src/definitions/core/CustomFields.type.ts +++ b/packages/types/src/definitions/core/CustomFields.type.ts @@ -1,7 +1,7 @@ export type CustomFieldKey = string; export type CustomField = { - type: 'string' | 'image'; + type: 'text' | 'image'; colour: string; label: string; };