From 1209c637f40bc128f51a841871277196bc7b121c Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 20 Jan 2026 16:31:45 -0800 Subject: [PATCH 01/19] Text Choice and multi choice column conversion --- .../ConfirmDataTypeChangeModal.test.tsx | 113 ++++++- .../ConfirmDataTypeChangeModal.tsx | 40 ++- .../domainproperties/DomainRow.test.tsx | 41 ++- .../components/domainproperties/DomainRow.tsx | 34 +- .../DomainRowExpandedOptions.tsx | 3 + .../domainproperties/PropDescType.ts | 11 +- .../TextChoiceOptions.spec.tsx | 283 ---------------- .../TextChoiceOptions.test.tsx | 306 ++++++++++++++++++ .../domainproperties/TextChoiceOptions.tsx | 57 +++- .../domainproperties/actions.test.ts | 93 ++++++ .../components/domainproperties/actions.ts | 132 ++++++-- .../components/domainproperties/constants.ts | 1 + .../domainproperties/models.test.ts | 2 + .../components/domainproperties/models.tsx | 2 + 14 files changed, 760 insertions(+), 358 deletions(-) delete mode 100644 packages/components/src/internal/components/domainproperties/TextChoiceOptions.spec.tsx create mode 100644 packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx diff --git a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx index f649a982e5..bcc2cf0fb9 100644 --- a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx +++ b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx @@ -2,19 +2,77 @@ import React from 'react'; import { render } from '@testing-library/react'; import { ConfirmDataTypeChangeModal, getDataTypeConfirmDisplayText } from './ConfirmDataTypeChangeModal'; -import { DATE_TYPE, DATETIME_TYPE, PROP_DESC_TYPES, TIME_TYPE } from './PropDescType'; +import { + BOOLEAN_TYPE, + DATE_TYPE, + DATETIME_TYPE, FILE_TYPE, + INTEGER_TYPE, MULTI_CHOICE_TYPE, + MULTILINE_TYPE, + PROP_DESC_TYPES, TEXT_CHOICE_TYPE, + TEXT_TYPE, + TIME_TYPE +} from './PropDescType'; import { BOOLEAN_RANGE_URI, DATETIME_RANGE_URI, FILELINK_RANGE_URI, - INT_RANGE_URI, - MULTILINE_RANGE_URI, + INT_RANGE_URI, MULTI_CHOICE_RANGE_URI, + MULTILINE_RANGE_URI, TEXT_CHOICE_CONCEPT_URI, TIME_RANGE_URI, } from './constants'; describe('ConfirmDataTypeChangeModal', () => { + + const stringType = { + rangeURI: 'http://www.w3.org/2001/XMLSchema#boolean', + dataType: TEXT_TYPE, + } + + const intType ={ + rangeURI: 'http://www.w3.org/2001/XMLSchema#int', + dataType: INTEGER_TYPE, + } + + const multiLineType ={ + rangeURI: MULTILINE_RANGE_URI, + dataType: MULTILINE_TYPE, + } + + const fileLinkType ={ + rangeURI: FILELINK_RANGE_URI, + dataType: FILE_TYPE, + } + + const booleanType ={ + rangeURI: BOOLEAN_RANGE_URI, + dataType: BOOLEAN_TYPE, + } + + const dateTimeType ={ + rangeURI: DATETIME_RANGE_URI, + dataType: DATETIME_TYPE, + } + + const timeType ={ + rangeURI: TIME_RANGE_URI, + dataType: TIME_TYPE, + } + + const multiChoiceType ={ + rangeURI: MULTI_CHOICE_RANGE_URI, + dataType: MULTI_CHOICE_TYPE, + } + + const textChoiceType = { + conceptURI: TEXT_CHOICE_CONCEPT_URI, + dataType: TEXT_CHOICE_TYPE, + } + const DEFAULT_PROPS = { - originalRangeURI: 'http://www.w3.org/2001/XMLSchema#boolean', + original: { + rangeURI: 'http://www.w3.org/2001/XMLSchema#boolean', + dataType: BOOLEAN_TYPE, + }, newDataType: PROP_DESC_TYPES.get(0), onConfirm: jest.fn, onCancel: jest.fn, @@ -29,18 +87,21 @@ describe('ConfirmDataTypeChangeModal', () => { }); test('getDataTypeConfirmDisplayText', () => { - expect(getDataTypeConfirmDisplayText(INT_RANGE_URI)).toBe('integer'); - expect(getDataTypeConfirmDisplayText(MULTILINE_RANGE_URI)).toBe('string'); - expect(getDataTypeConfirmDisplayText(FILELINK_RANGE_URI)).toBe('file'); - expect(getDataTypeConfirmDisplayText(BOOLEAN_RANGE_URI)).toBe('boolean'); - expect(getDataTypeConfirmDisplayText(DATETIME_RANGE_URI)).toBe('dateTime'); + expect(getDataTypeConfirmDisplayText(intType.dataType)).toBe('integer'); + expect(getDataTypeConfirmDisplayText(multiLineType.dataType)).toBe('string'); + expect(getDataTypeConfirmDisplayText(fileLinkType.dataType)).toBe('file'); + expect(getDataTypeConfirmDisplayText(booleanType.dataType)).toBe('boolean'); + expect(getDataTypeConfirmDisplayText(dateTimeType.dataType)).toBe('dateTime'); + expect(getDataTypeConfirmDisplayText(multiChoiceType.dataType)).toBe('Text Choice (multiple select)'); + expect(getDataTypeConfirmDisplayText(textChoiceType.dataType)).toBe('Text Choice (single select)'); + }); test('from datetime to time', () => { render( ); @@ -53,7 +114,7 @@ describe('ConfirmDataTypeChangeModal', () => { render( ); @@ -66,7 +127,7 @@ describe('ConfirmDataTypeChangeModal', () => { render( ); @@ -74,4 +135,32 @@ describe('ConfirmDataTypeChangeModal', () => { 'This change will convert the values in the field from time to dateTime. Once you save your changes, you will not be able to change it back to time.' ); }); + + test('from text choice to mvtc', () => { + render( + + ); + expect(document.body).toHaveTextContent( + 'Confirm Data Type ChangeThis change will convert the values in the field from Text Choice (single select) to Text Choice (multiple select). Filters in saved views might not function as expected and any conditional formatting configured for this field will be removed.' + ); + }); + + test('from mvtc to tc', () => { + render( + + ); + expect(document.body).toHaveTextContent( + 'This change will convert the values in the field from Text Choice (multiple select) to Text Choice (single select). Filters in saved views might not function as expected' + ); + }); + + }); diff --git a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx index 9357af84c1..dd88cde35c 100644 --- a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx +++ b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx @@ -11,25 +11,44 @@ import { MULTILINE_RANGE_URI, TIME_RANGE_URI, } from './constants'; +import {IDomainField} from "./models"; interface Props { - originalRangeURI: string; + original: Partial; newDataType: PropDescType; onConfirm: () => void; onCancel: () => void; } export const ConfirmDataTypeChangeModal: FC = memo(props => { - const { originalRangeURI, newDataType, onConfirm, onCancel } = props; - const origTypeLabel = getDataTypeConfirmDisplayText(originalRangeURI); - const newTypeLabel = getDataTypeConfirmDisplayText(newDataType.rangeURI); + const { original, newDataType, onConfirm, onCancel } = props; + const originalRangeURI = original.rangeURI || ''; + const origTypeLabel = getDataTypeConfirmDisplayText(original.dataType); + const newTypeLabel = getDataTypeConfirmDisplayText(newDataType); + const newMultiChoice = PropDescType.isMultiChoice(newDataType.rangeURI); + const oldMultiChoice = PropDescType.isMultiChoice(original.dataType.rangeURI); + const newTextChoice = PropDescType.isTextChoice(newDataType.conceptURI); const reversible = (PropDescType.isDate(originalRangeURI) && PropDescType.isDateTime(newDataType.rangeURI)) || - (PropDescType.isDateTime(originalRangeURI) && PropDescType.isDate(newDataType.rangeURI)); + (PropDescType.isDateTime(originalRangeURI) && PropDescType.isDate(newDataType.rangeURI)) || + newMultiChoice; let dataLossWarning = null; - if ( + if (newMultiChoice) { + dataLossWarning = ( + <> + Filters in saved views might not function as expected and any conditional formatting configured for this field will be removed.{' '} + ) + ; + } + else if (oldMultiChoice && newTextChoice) { + dataLossWarning = ( + <> + Filters in saved views might not function as expected.{' '} + ) + ; + } else if ( originalRangeURI === DATETIME_RANGE_URI && (newDataType.rangeURI === DATE_RANGE_URI || newDataType.rangeURI === TIME_RANGE_URI) ) { @@ -41,7 +60,7 @@ export const ConfirmDataTypeChangeModal: FC = memo(props => { ); } - return ( + return ( = memo(props => { ConfirmDataTypeChangeModal.displayName = 'ConfirmDataTypeChangeModal'; // exported for jest testing -export const getDataTypeConfirmDisplayText = (rangeURI: string): string => { + +export const getDataTypeConfirmDisplayText = (dataType: PropDescType): string => { + if (dataType?.longDisplay) { + return dataType.longDisplay; + } + const rangeURI = dataType?.rangeURI || ''; if (rangeURI === INT_RANGE_URI) return 'integer'; if (rangeURI === MULTILINE_RANGE_URI) return 'string'; if (rangeURI === FILELINK_RANGE_URI) return 'file'; diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx index c09709183e..b498fa52ef 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx @@ -31,9 +31,10 @@ import { TEXT_TYPE, } from './PropDescType'; -import { DomainRow, DomainRowProps } from './DomainRow'; +import { DomainRow, DomainRowProps, shouldShowConfirmDataTypeChange } from './DomainRow'; import { ATTACHMENT_RANGE_URI, + DATETIME_RANGE_URI, DOMAIN_EDITABLE_DEFAULT, DOMAIN_FIELD_ADV, DOMAIN_FIELD_DELETE, @@ -44,12 +45,15 @@ import { DOMAIN_FIELD_TYPE, DOMAIN_LAST_ENTERED_DEFAULT, DOMAIN_NON_EDITABLE_DEFAULT, + DOUBLE_RANGE_URI, FIELD_NAME_CHAR_WARNING_INFO, FIELD_NAME_CHAR_WARNING_MSG, INT_RANGE_URI, + MULTI_CHOICE_RANGE_URI, PHILEVEL_RESTRICTED_PHI, SEVERITY_LEVEL_ERROR, SEVERITY_LEVEL_WARN, + STRING_RANGE_URI, TEXT_CHOICE_CONCEPT_URI, } from './constants'; import { createFormInputId } from './utils'; @@ -405,3 +409,38 @@ describe('DomainRow', () => { expect(rowDetails[0].textContent).toContain(expected); }); }); + +describe('shouldShowConfirmDataTypeChange', () => { + test('should return false for same type', () => { + expect(shouldShowConfirmDataTypeChange(STRING_RANGE_URI, STRING_RANGE_URI)).toBe(false); + }); + + test('should return true for converting to number', () => { + expect(shouldShowConfirmDataTypeChange(STRING_RANGE_URI, DOUBLE_RANGE_URI)).toBe(true); + expect(shouldShowConfirmDataTypeChange(INT_RANGE_URI, DOUBLE_RANGE_URI)).toBe(true); + }); + + test('should return true for converting to date', () => { + expect(shouldShowConfirmDataTypeChange(STRING_RANGE_URI, DATETIME_RANGE_URI)).toBe(true); + }); + + test('should return true for converting to multi-choice', () => { + expect(shouldShowConfirmDataTypeChange(STRING_RANGE_URI, MULTI_CHOICE_RANGE_URI)).toBe(true); + }); + + test('should return true for converting non-string/non-multichoice to string', () => { + expect(shouldShowConfirmDataTypeChange(INT_RANGE_URI, STRING_RANGE_URI)).toBe(true); + }); + + test('should return false for converting multichoice to string', () => { + expect(shouldShowConfirmDataTypeChange(MULTI_CHOICE_RANGE_URI, STRING_RANGE_URI)).toBe(false); + }); + + test('should return false for converting multichoice to textChoice', () => { + expect(shouldShowConfirmDataTypeChange(MULTI_CHOICE_RANGE_URI, TEXT_CHOICE_CONCEPT_URI)).toBe(true); + }); + + test('should return false for converting textChoice to multiChoice', () => { + expect(shouldShowConfirmDataTypeChange(TEXT_CHOICE_CONCEPT_URI, MULTI_CHOICE_RANGE_URI)).toBe(true); + }); +}); diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.tsx index 662c3e1202..0a3291f8e4 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.tsx @@ -263,26 +263,28 @@ export class DomainRow extends React.PureComponent { - const { field } = this.props; - const { value } = evt.target; + handleDataTypeChange = (targetId: string, value: any): void => { + const { field, index } = this.props; // warn for a saved field changing from any non-string -> string OR int/long -> double/float/decimal if (field.isSaved()) { const typeConvertingTo = PropDescType.fromName(value); - if (shouldShowConfirmDataTypeChange(field.original.rangeURI, typeConvertingTo.rangeURI)) { + if (shouldShowConfirmDataTypeChange(field.original.rangeURI ?? field.original.conceptURI, typeConvertingTo.rangeURI ?? typeConvertingTo.conceptURI)) { this.onShowConfirmTypeChange(value); return; } } - this.onFieldChange( - evt, - PropDescType.isLookup(value) || - PropDescType.isTextChoice(value) || - PropDescType.isUser(value) || - PropDescType.isCalculation(value) - ); + const expand = PropDescType.isLookup(value) || + PropDescType.isTextChoice(value) || + PropDescType.isUser(value) || + PropDescType.isCalculation(value); + + this.onSingleFieldChange(targetId, value, index, expand); + }; + + onDataTypeChange = (evt: any): void => { + this.handleDataTypeChange(evt.target.id, evt.target.value); }; onShowConfirmTypeChange = (dataTypeChangeToConfirm: string): void => { @@ -567,6 +569,7 @@ export class DomainRow extends React.PureComponent @@ -575,7 +578,7 @@ export class DomainRow extends React.PureComponent )} @@ -585,13 +588,16 @@ export class DomainRow extends React.PureComponent { +export const shouldShowConfirmDataTypeChange = (originalRangeURI: string, newRangeURI: string): boolean => { if (newRangeURI && originalRangeURI !== newRangeURI) { const wasString = STRING_CONVERT_URIS.indexOf(originalRangeURI) > -1; const toString = STRING_CONVERT_URIS.indexOf(newRangeURI) > -1; const toNumber = NUMBER_CONVERT_URIS.indexOf(newRangeURI) > -1; const toDate = DATETIME_CONVERT_URIS.indexOf(newRangeURI) > -1; - return toNumber || (toString && !wasString) || toDate; + const wasMultiChoice = PropDescType.isMultiChoice(originalRangeURI); + const newTextChoice = PropDescType.isTextChoice(newRangeURI); + const toMultiChoice = PropDescType.isMultiChoice(newRangeURI); + return toNumber || (toString && !wasString && !wasMultiChoice) || toDate || toMultiChoice || (wasMultiChoice && newTextChoice); } return false; }; diff --git a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx index 8c91100903..4fcdce2e7d 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx @@ -49,6 +49,7 @@ interface Props { queryName?: string; schemaName?: string; showingModal: (boolean) => void; + handleDataTypeChange: (targetId: string, value: any) => void; } export class DomainRowExpandedOptions extends React.Component { @@ -65,6 +66,7 @@ export class DomainRowExpandedOptions extends React.Component { domainContainerPath, schemaName, queryName, + handleDataTypeChange, } = this.props; // In most cases we will use the selected data type to determine which field options to show, @@ -239,6 +241,7 @@ export class DomainRowExpandedOptions extends React.Component { schemaName={schemaName} lockedForDomain={domainFormDisplayOptions.textChoiceLockedForDomain} lockedSqlFragment={domainFormDisplayOptions.textChoiceLockedSqlFragment} + handleDataTypeChange={handleDataTypeChange} /> ); case 'fileLink': diff --git a/packages/components/src/internal/components/domainproperties/PropDescType.ts b/packages/components/src/internal/components/domainproperties/PropDescType.ts index f0aba0c5a8..40a9e9a03c 100644 --- a/packages/components/src/internal/components/domainproperties/PropDescType.ts +++ b/packages/components/src/internal/components/domainproperties/PropDescType.ts @@ -36,34 +36,40 @@ export type JsonType = 'array' | 'boolean' | 'date' | 'float' | 'int' | 'string' interface IPropDescType { conceptURI: string; display: string; + longDisplay?: string; lookupQuery?: string; lookupSchema?: string; name: string; rangeURI: string; shortDisplay?: string; + hideFromDomainRow?: boolean; } export class PropDescType extends Record({ conceptURI: undefined, display: undefined, + longDisplay: undefined, name: undefined, rangeURI: undefined, alternateRangeURI: undefined, shortDisplay: undefined, lookupSchema: undefined, lookupQuery: undefined, + hideFromDomainRow: false, }) implements IPropDescType { declare conceptURI: string; declare display: string; + declare longDisplay?: string; declare name: string; declare rangeURI: string; declare alternateRangeURI: string; declare shortDisplay: string; declare lookupSchema?: string; declare lookupQuery?: string; + declare hideFromDomainRow?: boolean; static fromName(name: string): PropDescType { return PROP_DESC_TYPES.find(type => type.name === name); @@ -360,14 +366,17 @@ export const UNIQUE_ID_TYPE = new PropDescType({ export const TEXT_CHOICE_TYPE = new PropDescType({ name: 'textChoice', display: 'Text Choice', + longDisplay: 'Text Choice (single select)', rangeURI: STRING_RANGE_URI, conceptURI: TEXT_CHOICE_CONCEPT_URI, }); export const MULTI_CHOICE_TYPE = new PropDescType({ name: 'multiChoice', - display: 'Multi Choice', + display: 'Text Choice', + longDisplay: 'Text Choice (multiple select)', rangeURI: MULTI_CHOICE_RANGE_URI, + hideFromDomainRow: true, }); export const SMILES_TYPE = new PropDescType({ diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.spec.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.spec.tsx deleted file mode 100644 index ab9065d776..0000000000 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.spec.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; - -import { ChoicesListItem } from '../base/ChoicesListItem'; - -import { waitForLifecycle } from '../../test/enzymeTestHelpers'; - -import { LoadingSpinner } from '../base/LoadingSpinner'; - -import { AddEntityButton } from '../buttons/AddEntityButton'; - -import { TextChoiceOptionsImpl } from './TextChoiceOptions'; -import { DomainField } from './models'; -import { SectionHeading } from './SectionHeading'; -import { DomainFieldLabel } from './DomainFieldLabel'; - -describe('TextChoiceOptions', () => { - const DEFAULT_PROPS = { - label: 'Test Label', - field: DomainField.create({}), - fieldValues: {}, - loading: false, - replaceValues: jest.fn, - validValues: [], - index: 0, - domainIndex: 0, - onChange: jest.fn, - lockType: undefined, - }; - - function validate( - wrapper: ReactWrapper, - isLoading = false, - validValues = 0, - inUse = 0, - hasSelection = false, - hasValueUpdate = false, - hasValueError = false - ): void { - expect(wrapper.find(SectionHeading)).toHaveLength(1); - expect(wrapper.find(SectionHeading).prop('title')).toBe('Test Label'); - expect(wrapper.find(DomainFieldLabel)).toHaveLength(hasSelection ? 2 : 1); - expect(wrapper.find(LoadingSpinner)).toHaveLength(isLoading ? 1 : 0); - - expect(wrapper.find('.domain-text-choices-list')).toHaveLength(!isLoading ? 1 : 0); - - if (!isLoading) { - expect(wrapper.find('.domain-text-choices-left-panel')).toHaveLength(validValues > 0 ? 1 : 0); - expect(wrapper.find(ChoicesListItem)).toHaveLength(validValues); - expect(wrapper.find('.choices-list__locked')).toHaveLength(inUse); - expect(wrapper.find(AddEntityButton)).toHaveLength(1); - expect(wrapper.find('.choices-detail__empty-message')).toHaveLength( - validValues > 0 && !hasSelection ? 1 : 0 - ); - expect(wrapper.find('input.full-width')).toHaveLength(hasSelection ? 1 : 0); - expect(wrapper.find('button')).toHaveLength(validValues + (hasSelection ? 2 : 0)); - expect(wrapper.find('.domain-text-choices-info').hostNodes()).toHaveLength(hasValueUpdate ? 1 : 0); - expect(wrapper.find('.alert-danger')).toHaveLength(hasValueError ? 1 : 0); - expect(wrapper.find('input.domain-text-choices-search')).toHaveLength(validValues > 2 ? 1 : 0); - } - } - - test('default props', () => { - const wrapper = mount(); - validate(wrapper); - expect(wrapper.find(AddEntityButton).prop('disabled')).toBeFalsy(); - wrapper.unmount(); - }); - - test('loading', () => { - const wrapper = mount(); - validate(wrapper, true); - wrapper.unmount(); - }); - - test('with validValues, no selection', () => { - const wrapper = mount(); - validate(wrapper, false, 2); - expect(wrapper.find(ChoicesListItem).first().prop('active')).toBeFalsy(); - wrapper.unmount(); - }); - - test('with validValues, with selection', async () => { - const wrapper = mount(); - wrapper.find(ChoicesListItem).first().simulate('click'); - await waitForLifecycle(wrapper); - validate(wrapper, false, 2, 0, true); - expect(wrapper.find(ChoicesListItem).first().prop('active')).toBeTruthy(); - wrapper.unmount(); - }); - - test('apply button disabled', async () => { - const wrapper = mount(); - wrapper.find(ChoicesListItem).first().simulate('click'); - await waitForLifecycle(wrapper); - - expect(wrapper.find('input.full-width').prop('value')).toBe('a'); - expect(wrapper.find('input.full-width').prop('disabled')).toBeFalsy(); - expect(wrapper.find('.btn-success').prop('disabled')).toBeTruthy(); - - wrapper.find('input.full-width').simulate('change', { target: { name: 'value', value: 'aa' } }); - await waitForLifecycle(wrapper); - expect(wrapper.find('input.full-width').prop('value')).toBe('aa'); - expect(wrapper.find('.btn-success').prop('disabled')).toBeFalsy(); - - wrapper.unmount(); - }); - - test('choice item empty', async () => { - const wrapper = mount(); - expect(wrapper.find(ChoicesListItem).first().prop('label')).toBe('a'); - expect(wrapper.find(ChoicesListItem).first().prop('subLabel')).toBe(undefined); - expect(wrapper.find(ChoicesListItem).last().prop('label')).toBe('b'); - expect(wrapper.find(ChoicesListItem).last().prop('subLabel')).toBe(undefined); - - wrapper.setProps({ - validValues: ['', 'b'], - }); - await waitForLifecycle(wrapper); - - expect(wrapper.find(ChoicesListItem).first().prop('label')).toBe(''); - expect(wrapper.find(ChoicesListItem).first().prop('subLabel')).toBe('Empty Value'); - expect(wrapper.find(ChoicesListItem).last().prop('label')).toBe('b'); - expect(wrapper.find(ChoicesListItem).last().prop('subLabel')).toBe(undefined); - - wrapper.unmount(); - }); - - test('with inUse values', async () => { - const wrapper = mount( - - ); - validate(wrapper, false, 2, 1); - - // select the in-use value and check right hand items - wrapper.find(ChoicesListItem).last().simulate('click'); - await waitForLifecycle(wrapper); - validate(wrapper, false, 2, 1, true); - expect(wrapper.find('input.full-width').prop('disabled')).toBeFalsy(); - - wrapper.unmount(); - }); - - test('with inUse value update info', async () => { - const wrapper = mount( - - ); - validate(wrapper, false, 2, 1); - - // select the in-use value, change it, and apply - wrapper.find(ChoicesListItem).last().simulate('click'); - await waitForLifecycle(wrapper); - wrapper.find('input.full-width').simulate('change', { target: { name: 'value', value: 'bb' } }); - await waitForLifecycle(wrapper); - wrapper.find('.btn-success').simulate('click'); - await waitForLifecycle(wrapper); - wrapper.setProps({ validValues: ['a', 'bb'] }); - await waitForLifecycle(wrapper); - - validate(wrapper, false, 2, 1, true, true); - expect(wrapper.find('.domain-text-choices-info').hostNodes().text()).toBe( - '1 row with value b will be updated to bb on save.' - ); - - wrapper.unmount(); - }); - - test('with locked values', async () => { - const wrapper = mount( - - ); - validate(wrapper, false, 2, 1); - - // select the locked value and check right hand items - wrapper.find(ChoicesListItem).last().simulate('click'); - await waitForLifecycle(wrapper); - validate(wrapper, false, 2, 1, true); - expect(wrapper.find('input.full-width').prop('disabled')).toBeTruthy(); - - wrapper.unmount(); - }); - - test('value update error checks', async () => { - const wrapper = mount(); - - wrapper.find(ChoicesListItem).last().simulate('click'); - await waitForLifecycle(wrapper); - validate(wrapper, false, 2, 0, true); - - // don't allow empty string - wrapper.find('input.full-width').simulate('change', { target: { name: 'value', value: 'bb' } }); - await waitForLifecycle(wrapper); - expect(wrapper.find('.btn-success').prop('disabled')).toBeFalsy(); - wrapper.find('input.full-width').simulate('change', { target: { name: 'value', value: ' ' } }); - await waitForLifecycle(wrapper); - expect(wrapper.find('.btn-success').prop('disabled')).toBeTruthy(); - - // don't allow duplicates - wrapper.find('input.full-width').simulate('change', { target: { name: 'value', value: ' a ' } }); - await waitForLifecycle(wrapper); - expect(wrapper.find('.btn-success').prop('disabled')).toBeTruthy(); - validate(wrapper, false, 2, 0, true, false, true); - expect(wrapper.find('.alert-danger').text()).toBe('"a" already exists in the list of values.'); - - wrapper.unmount(); - }); - - test('delete button disabled', async () => { - const wrapper = mount( - - ); - validate(wrapper, false, 2, 1); - - // first value, not in use - wrapper.find(ChoicesListItem).first().simulate('click'); - await waitForLifecycle(wrapper); - expect(wrapper.find('.btn-default').last().prop('disabled')).toBeFalsy(); - - // second value, in use - wrapper.find(ChoicesListItem).last().simulate('click'); - await waitForLifecycle(wrapper); - expect(wrapper.find('.btn-default').last().prop('disabled')).toBeTruthy(); - - wrapper.unmount(); - }); - - test('AddEntityButton disabled if max reached', () => { - const wrapper = mount(); - validate(wrapper, false, 2); - expect(wrapper.find(AddEntityButton).prop('disabled')).toBeTruthy(); - wrapper.unmount(); - }); - - test('search', async () => { - const wrapper = mount(); - validate(wrapper, false, 4); - wrapper - .find('input.domain-text-choices-search') - .simulate('change', { target: { name: 'value', value: ' a ' } }); - await waitForLifecycle(wrapper); - let values = wrapper.find(ChoicesListItem); - expect(values).toHaveLength(3); - expect(values.at(0).text()).toBe('a'); - expect(values.at(1).text()).toBe('aa'); - expect(values.at(2).text()).toBe('aaa'); - wrapper.find('input.domain-text-choices-search').simulate('change', { target: { name: 'value', value: 'b' } }); - await waitForLifecycle(wrapper); - values = wrapper.find(ChoicesListItem); - expect(values).toHaveLength(1); - expect(values.at(0).text()).toBe('b'); - wrapper.find('input.domain-text-choices-search').simulate('change', { target: { name: 'value', value: 'AA' } }); - await waitForLifecycle(wrapper); - values = wrapper.find(ChoicesListItem); - expect(values).toHaveLength(2); - expect(values.at(0).text()).toBe('aa'); - expect(values.at(1).text()).toBe('aaa'); - wrapper.find('input.domain-text-choices-search').simulate('change', { target: { name: 'value', value: '' } }); - await waitForLifecycle(wrapper); - values = wrapper.find(ChoicesListItem); - expect(values).toHaveLength(4); - expect(values.at(0).text()).toBe('a'); - expect(values.at(1).text()).toBe('aa'); - expect(values.at(2).text()).toBe('aaa'); - expect(values.at(3).text()).toBe('b'); - wrapper.unmount(); - }); -}); diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx new file mode 100644 index 0000000000..c01146c7f8 --- /dev/null +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { TextChoiceOptionsImpl } from './TextChoiceOptions'; +import { DomainField } from './models'; + +describe('TextChoiceOptions', () => { + const DEFAULT_PROPS = { + label: 'Test Label', + field: DomainField.create({}), + fieldValues: {}, + loading: false, + replaceValues: jest.fn(), + validValues: [], + index: 0, + domainIndex: 0, + onChange: jest.fn(), + handleDataTypeChange: jest.fn(), + lockType: undefined, + }; + + function validate( + isLoading = false, + validValuesCount = 0, + inUse = 0, + hasSelection = false, + hasValueUpdate = false, + hasValueError = false + ): void { + expect(screen.getByText('Test Label')).toBeInTheDocument(); + + if (isLoading) { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(document.querySelector('.domain-text-choices-list')).not.toBeInTheDocument(); + } else { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + + const list = document.querySelector('.domain-text-choices-list'); + if (validValuesCount > 0 || hasSelection) { + expect(list).toBeInTheDocument(); + } + + const items = list?.querySelectorAll('.list-group-item'); + if (items) { + expect(items.length).toBe(validValuesCount); + } else { + expect(validValuesCount).toBe(0); + } + + expect(document.querySelectorAll('.choices-list__locked').length).toBe(inUse); + const addBtn = document.querySelector('span.container--action-button'); + expect(addBtn.textContent).toBe(' Add Values'); + + if (validValuesCount > 0 && !hasSelection) { + expect( + screen.getByText('Select a value from the list on the left to view details.') + ).toBeInTheDocument(); + } + + const inputs = screen.queryAllByPlaceholderText('Enter a text choice value'); + expect(inputs).toHaveLength(hasSelection ? 1 : 0); + + const updateInfos = document.querySelectorAll('.domain-text-choices-info'); + expect(updateInfos).toHaveLength(hasValueUpdate ? 1 : 0); + + const errors = document.querySelectorAll('.alert-danger'); + expect(errors).toHaveLength(hasValueError ? 1 : 0); + + const searchInputs = screen.queryAllByPlaceholderText('Find a value'); + expect(searchInputs).toHaveLength(validValuesCount > 2 ? 1 : 0); + } + } + + test('default props', () => { + render(); + validate(); + const addBtn = document.querySelector('span.container--action-button'); + expect(addBtn.textContent).toBe(' Add Values'); + expect(addBtn.getAttribute('class').indexOf('disabled')).toBe(-1); + }); + + test('loading', () => { + render(); + validate(true); + }); + + test('with validValues, no selection', () => { + render(); + validate(false, 2); + const items = document.querySelectorAll('.list-group-item'); + expect(items[0]).not.toHaveClass('active'); + }); + + test('with validValues, with selection', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'a' })); + await waitFor(() => { + validate(false, 2, 0, true); + }); + const items = document.querySelectorAll('.list-group-item'); + expect(items[0]).toHaveClass('active'); + }); + + test('apply button disabled', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'a' })); + + const input = screen.getByPlaceholderText('Enter a text choice value'); + expect(input).toHaveValue('a'); + expect(input).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'aa' } }); + await waitFor(() => { + expect(input).toHaveValue('aa'); + }); + expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled(); + }); + + test('choice item empty', async () => { + const { rerender } = render(); + + let items = screen.getAllByRole('button').filter(b => b.classList.contains('list-group-item')); + expect(items[0]).toHaveTextContent('a'); + expect(items[1]).toHaveTextContent('b'); + + rerender(); + + items = screen.getAllByRole('button').filter(b => b.classList.contains('list-group-item')); + expect(items[0]).toHaveTextContent('Empty Value'); + expect(items[1]).toHaveTextContent('b'); + }); + + test('with inUse values', async () => { + render( + + ); + validate(false, 2, 1); + + // select the in-use value and check right hand items + // 'b' is the label, but it also has the lock icon. The button text content includes 'b'. + // Because of the icon, the accessible name might be tricky. + // We can query by text content. + const bButton = screen.getAllByRole('button').find(b => b.textContent?.includes('b')); + fireEvent.click(bButton); + + await waitFor(() => { + validate(false, 2, 1, true); + }); + expect(screen.getByPlaceholderText('Enter a text choice value')).toBeEnabled(); + }); + + test('with inUse value update info', async () => { + const replaceValues = jest.fn(); + const { rerender } = render( + + ); + validate(false, 2, 1); + + // select the in-use value, change it, and apply + const bButton = screen.getAllByRole('button').find(b => b.textContent?.includes('b')); + fireEvent.click(bButton); + + const input = screen.getByPlaceholderText('Enter a text choice value'); + fireEvent.change(input, { target: { value: 'bb' } }); + + const applyBtn = screen.getByRole('button', { name: 'Apply' }); + await waitFor(() => expect(applyBtn).toBeEnabled()); + fireEvent.click(applyBtn); + + expect(replaceValues).toHaveBeenCalled(); + }); + + test('with locked values', async () => { + render( + + ); + validate(false, 2, 1); + + // select the locked value and check right hand items + const bButton = screen.getAllByRole('button').find(b => b.textContent?.includes('b')); + fireEvent.click(bButton); + + await waitFor(() => { + validate(false, 2, 1, true); + }); + expect(screen.getByPlaceholderText('Enter a text choice value')).toBeDisabled(); + }); + + test('value update error checks', async () => { + render(); + + const bButton = screen.getByRole('button', { name: 'b' }); + fireEvent.click(bButton); + validate(false, 2, 0, true); + + // don't allow empty string + const input = screen.getByPlaceholderText('Enter a text choice value'); + fireEvent.change(input, { target: { value: 'bb' } }); + const applyBtn = screen.getByRole('button', { name: 'Apply' }); + expect(applyBtn).toBeEnabled(); + + fireEvent.change(input, { target: { value: ' ' } }); + expect(applyBtn).toBeDisabled(); + + // don't allow duplicates + fireEvent.change(input, { target: { value: ' a ' } }); + expect(applyBtn).toBeDisabled(); + + validate(false, 2, 0, true, false, true); + const alert = document.querySelector('.alert-danger'); + expect(alert).toHaveTextContent('"a" already exists in the list of values.'); + }); + + test('delete button disabled', async () => { + render( + + ); + validate(false, 2, 1); + + // first value, not in use + const aButton = screen.getByRole('button', { name: 'a' }); + fireEvent.click(aButton); + + // Delete button is the one with trash icon. "Delete" text is in span after icon. + // DisableableButton renders children. + const deleteBtn = screen.getByRole('button', { name: /Delete/ }); + expect(deleteBtn).toBeEnabled(); + + // second value, in use + const bButton = screen.getAllByRole('button').find(b => b.textContent?.includes('b')); + fireEvent.click(bButton); + + const deleteBtn2 = screen.getByRole('button', { name: /Delete/ }); + expect(deleteBtn2).toBeDisabled(); + }); + + test('AddEntityButton disabled if max reached', () => { + render(); + validate(false, 2); + const addBtn = document.querySelector('span.container--action-button'); + expect(addBtn.textContent).toBe(' Add Values'); + expect(addBtn.getAttribute('class')).toContain(' disabled'); + }); + + test('search', async () => { + render(); + validate(false, 4); + + const searchInput = screen.getByPlaceholderText('Find a value'); + + fireEvent.change(searchInput, { target: { value: ' a ' } }); + let items = document.querySelectorAll('.list-group-item'); + expect(items).toHaveLength(3); + expect(items[0]).toHaveTextContent('a'); + expect(items[1]).toHaveTextContent('aa'); + expect(items[2]).toHaveTextContent('aaa'); + + fireEvent.change(searchInput, { target: { value: 'b' } }); + items = document.querySelectorAll('.list-group-item'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('b'); + + fireEvent.change(searchInput, { target: { value: 'AA' } }); + items = document.querySelectorAll('.list-group-item'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('aa'); + expect(items[1]).toHaveTextContent('aaa'); + + fireEvent.change(searchInput, { target: { value: '' } }); + items = document.querySelectorAll('.list-group-item'); + expect(items).toHaveLength(4); + }); +}); diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index a831e63a9b..ed96174dc7 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -12,14 +12,19 @@ import { DisableableButton } from '../buttons/DisableableButton'; import { DisableableInput } from '../forms/DisableableInput'; -import { DOMAIN_VALIDATOR_TEXTCHOICE, MAX_VALID_TEXT_CHOICES } from './constants'; +import { + DOMAIN_FIELD_SCANNABLE_OPTION, + DOMAIN_FIELD_TEXTCHOICE_MULTI, DOMAIN_FIELD_TYPE, DOMAIN_VALIDATOR_TEXTCHOICE, MAX_VALID_TEXT_CHOICES +} from './constants'; import { DEFAULT_TEXT_CHOICE_VALIDATOR, DomainField, ITypeDependentProps, PropertyValidator } from './models'; import { SectionHeading } from './SectionHeading'; import { DomainFieldLabel } from './DomainFieldLabel'; import { TextChoiceAddValuesModal } from './TextChoiceAddValuesModal'; -import { getTextChoiceInUseValues } from './actions'; -import { createFormInputId } from './utils'; +import {getTextChoiceInUseValues, TextChoiceInUseValues} from './actions'; +import {createFormInputId, createFormInputName} from './utils'; +import {isFieldFullyLocked} from "./propertiesUtil"; +import {MULTI_CHOICE_TYPE, PropDescType, TEXT_CHOICE_TYPE} from "./PropDescType"; const MIN_VALUES_FOR_SEARCH_COUNT = 2; const HELP_TIP_BODY =

The set of values to be used as drop-down options to restrict data entry into this field.

; @@ -55,6 +60,7 @@ interface Props extends ITypeDependentProps { lockedSqlFragment?: string; queryName?: string; schemaName?: string; + handleDataTypeChange: (targetId: string, value: any) => void; } interface ImplProps extends Props { @@ -65,6 +71,7 @@ interface ImplProps extends Props { maxValueCount?: number; replaceValues: (newValues: string[], valueUpdates?: Record) => void; validValues: string[]; + hasMultiValueInUse?: boolean; } // exported for jest testing @@ -78,12 +85,18 @@ export const TextChoiceOptionsImpl: FC = memo(props => { replaceValues, maxValueCount = MAX_VALID_TEXT_CHOICES, lockedForDomain, + domainIndex, + index, + handleDataTypeChange, + hasMultiValueInUse } = props; const [selectedIndex, setSelectedIndex] = useState(); const [currentValue, setCurrentValue] = useState(); const [currentError, setCurrentError] = useState(); const [showAddValuesModal, setShowAddValuesModal] = useState(); const [search, setSearch] = useState(''); + const fieldTypeId = createFormInputId(DOMAIN_FIELD_TYPE, domainIndex, index); + const isMultiChoiceField = field.dataType.name === MULTI_CHOICE_TYPE.name; // keep a map from the updated values for the in-use field values to their original values const [fieldValueUpdates, setFieldValueUpdates] = useState>({}); @@ -188,6 +201,14 @@ export const TextChoiceOptionsImpl: FC = memo(props => { setSearch(event.target.value); }, []); + const onAllowMultiChange = useCallback( + (event: any): void => { + const { checked } = event.target; + handleDataTypeChange?.(fieldTypeId, checked ? MULTI_CHOICE_TYPE.name : TEXT_CHOICE_TYPE.name); + }, + [handleDataTypeChange, fieldTypeId] + ); + return (
@@ -254,6 +275,15 @@ export const TextChoiceOptionsImpl: FC = memo(props => { onClick={toggleAddValues} title={`Add Values (max ${maxValueCount})`} /> + + Allow multiple selections
{validValues.length > 0 && selectedIndex === undefined && ( @@ -261,7 +291,12 @@ export const TextChoiceOptionsImpl: FC = memo(props => { Select a value from the list on the left to view details.

)} - {selectedIndex !== undefined && ( + {selectedIndex !== undefined && (currentInUse && isMultiChoiceField) && ( +

+ Value is currently in use and cannot be updated. +

+ )} + {selectedIndex !== undefined && (!currentInUse || !isMultiChoiceField) && ( <>
@@ -333,9 +368,9 @@ TextChoiceOptionsImpl.displayName = 'TextChoiceOptionsImpl'; export const TextChoiceOptions: FC = memo(props => { const { field, onChange, domainIndex, index, schemaName, queryName, lockedSqlFragment = 'FALSE' } = props; const [loading, setLoading] = useState(true); - const [fieldValues, setFieldValues] = useState>>({}); + const [fieldValues, setFieldValues] = useState(null); const [validValues, setValidValues] = useState(field.textChoiceValidator?.properties.validValues ?? []); - const fieldId = createFormInputId(DOMAIN_VALIDATOR_TEXTCHOICE, domainIndex, index); + const fieldValidatorId = createFormInputId(DOMAIN_VALIDATOR_TEXTCHOICE, domainIndex, index); const replaceValues = useCallback( (newValues: string[], newValueUpdates?: Record) => { @@ -349,7 +384,7 @@ export const TextChoiceOptions: FC = memo(props => { }, {}); onChange( - fieldId, + fieldValidatorId, new PropertyValidator({ // keep the existing validator Id/props, if present, and override the expression / properties ...field.textChoiceValidator, @@ -361,7 +396,7 @@ export const TextChoiceOptions: FC = memo(props => { }) ); }, - [field.textChoiceValidator, fieldId, onChange] + [field.textChoiceValidator, fieldValidatorId, onChange] ); useEffect( @@ -369,7 +404,8 @@ export const TextChoiceOptions: FC = memo(props => { // for an existing field, we query for the distinct set of values in the Text column to be used for // the initial set of values and/or setting fields as locked (i.e. in use) if (!field.isNew() && schemaName && queryName) { - getTextChoiceInUseValues(field, schemaName, queryName, lockedSqlFragment) + const isMulti = field.dataType.name === MULTI_CHOICE_TYPE.name; + getTextChoiceInUseValues(field, schemaName, queryName, lockedSqlFragment, isMulti) .then(values => { setFieldValues(values); @@ -397,7 +433,8 @@ export const TextChoiceOptions: FC = memo(props => { return ( { expect(field.textChoiceValidator).toBe(DEFAULT_TEXT_CHOICE_VALIDATOR); }); + test('updateDataType clear properties when changing to MultiChoice field - from TextChoice', () => { + let field = DomainField.create({ + propertyId: 1, + propertyValidators: [ + { type: 'Range', name: 'Range Validator', expression: '' }, + { type: 'RegEx', name: 'RegEx Validator', expression: '' }, + { type: 'Lookup', name: 'Lookup Validator', expression: '' }, + ], + measure: true, + dimension: true, + mvEnabled: true, + recommendedVariable: true, + uniqueConstraint: true, + nonUniqueConstraint: true, + }); + + // Change to Text Choice to ensure textChoiceValidator exists + field = updateDataType(field, 'textChoice'); + expect(field.dataType).toBe(TEXT_CHOICE_TYPE); + expect(field.textChoiceValidator).toBe(DEFAULT_TEXT_CHOICE_VALIDATOR); + expect(field.measure).toBe(true); + expect(field.dimension).toBe(true); + expect(field.mvEnabled).toBe(true); + expect(field.recommendedVariable).toBe(true); + expect(field.uniqueConstraint).toBe(true); + expect(field.nonUniqueConstraint).toBe(true); + + // Change to MultiChoice + field = updateDataType(field, 'multiChoice'); + expect(field.dataType).toBe(MULTI_CHOICE_TYPE); + // validators and expressions cleared for multiChoice + expect(field.lookupValidator).toBeUndefined(); + expect(field.rangeValidators?.size).toBe(0); + expect(field.regexValidators?.size).toBe(0); + expect(field.valueExpression).toBeUndefined(); + // flags cleared for multiChoice + expect(field.measure).toBe(false); + expect(field.dimension).toBe(false); + expect(field.mvEnabled).toBe(false); + expect(field.recommendedVariable).toBe(false); + expect(field.uniqueConstraint).toBe(false); + expect(field.nonUniqueConstraint).toBe(false); + // textChoiceValidator should still be present for text/multi choice types + expect(field.textChoiceValidator).toBeDefined(); + }); + + function convertToMultiChoiceFromDataType(isNewField: boolean) { + let field = DomainField.create({ + propertyId: isNewField ? 0 : 1, + propertyValidators: [ + { type: 'Range', name: 'Range Validator', expression: '' }, + { type: 'RegEx', name: 'RegEx Validator', expression: '' }, + { type: 'Lookup', name: 'Lookup Validator', expression: '' }, + ], + scale: 10, + measure: true, + dimension: true, + mvEnabled: true, + recommendedVariable: true, + uniqueConstraint: true, + nonUniqueConstraint: true, + rangeURI: STRING_RANGE_URI + }); + expect(field.dataType).toBe(TEXT_TYPE); + expect(field.scale).toBe(10); + expect(field.lookupValidator).toBeDefined(); + expect(field.rangeValidators.size).toBe(1); + expect(field.regexValidators.size).toBe(1); + + field = updateDataType(field, 'multiChoice'); + expect(field.dataType).toBe(MULTI_CHOICE_TYPE); + // default textChoiceValidator added when transitioning from non-textChoice + expect(field.textChoiceValidator).toBe(DEFAULT_TEXT_CHOICE_VALIDATOR); + // validators cleared + expect(field.lookupValidator).toBeUndefined(); + expect(field.rangeValidators.size).toBe(0); + expect(field.regexValidators.size).toBe(0); + // scale set to MAX for choice types + expect(field.scale).toBe(MAX_TEXT_LENGTH); + // flags cleared for multiChoice + expect(field.measure).toBe(false); + expect(field.dimension).toBe(false); + expect(field.mvEnabled).toBe(false); + expect(field.recommendedVariable).toBe(false); + expect(field.uniqueConstraint).toBe(false); + expect(field.nonUniqueConstraint).toBe(false); + expect(field.valueExpression).toBeUndefined(); + } + test('updateDataType clear properties when changing to MultiChoice field - from String', () => { + convertToMultiChoiceFromDataType(true); + convertToMultiChoiceFromDataType(false); + }); + test('updateDataType isLookup', () => { let field = DomainField.create({}); expect(field.dataType).toBe(TEXT_TYPE); diff --git a/packages/components/src/internal/components/domainproperties/actions.ts b/packages/components/src/internal/components/domainproperties/actions.ts index a1fd4210ab..5d3042cc64 100644 --- a/packages/components/src/internal/components/domainproperties/actions.ts +++ b/packages/components/src/internal/components/domainproperties/actions.ts @@ -86,6 +86,7 @@ import { VISIT_LABEL_TYPE, } from './PropDescType'; import { + ConditionalFormat, decodeLookup, DEFAULT_TEXT_CHOICE_VALIDATOR, DomainDesign, @@ -891,17 +892,36 @@ export function updateDataType(field: DomainField, value: any): DomainField { field = field.merge(DomainField.resolveLookupConfig(field, dataType)) as DomainField; } - if ((field.isTextChoiceField() || field.isMultiChoiceField()) && !wasTextChoice) { + if ((field.isTextChoiceField() || field.isMultiChoiceField())) { // when changing a field to a Text Choice, add the default textChoiceValidator and // remove/reset all other propertyValidators and other text option settings - field = field.merge({ - textChoiceValidator: DEFAULT_TEXT_CHOICE_VALIDATOR, - lookupValidator: undefined, - rangeValidators: [], - regexValidators: [], - scale: MAX_TEXT_LENGTH, - valueExpression: undefined, - }) as DomainField; + if (!wasTextChoice) { + field = field.merge({ + textChoiceValidator: DEFAULT_TEXT_CHOICE_VALIDATOR, + lookupValidator: undefined, + rangeValidators: [], + regexValidators: [], + scale: MAX_TEXT_LENGTH, + valueExpression: undefined, + }) as DomainField; + } + + if (field.isMultiChoiceField()) { + field = field.merge({ + lookupValidator: undefined, + rangeValidators: [], + regexValidators: [], + valueExpression: undefined, + dimension: false, + measure: false, + mvEnabled: false, + recommendedVariable: false, + uniqueConstraint: false, + nonUniqueConstraint: false, + conditionalFormats: List() + }) as DomainField; + } + } else if (field.isCalculatedField()) { field = field.merge({ importAliases: undefined, @@ -1365,17 +1385,23 @@ export function getDomainNamePreviews( }); } -type TextChoiceInUseValues = Record; +export type TextChoiceInUseValues = { + useCount: Record, + hasMultiValue: boolean, +}; export async function getTextChoiceInUseValues( field: DomainField, schemaName: string, queryName: string, - lockedSqlFragment: string + lockedSqlFragment: string, + isMultiField: boolean, ): Promise { const containerFilter = Query.ContainerFilter.allInProjectPlusShared; // to account for a shared domain at project or /Shared const fieldName = field.original?.name ?? field.name; + const useCount: Record = {}; + let hasMultiValue = false; // If the field is set as PHI, we need the query to include the RowId for logging, so we have to do the aggregate client side if (field.isPHI()) { const result = await selectRows({ @@ -1386,19 +1412,35 @@ export async function getTextChoiceInUseValues( schemaQuery: new SchemaQuery(schemaName, queryName), }); - const values: TextChoiceInUseValues = {}; result.rows.forEach(row => { const value = row[fieldName]?.value; - if (isValidTextChoiceValue(value)) { - if (!values[value]) { - values[value] = { count: 0, locked: false }; - } - values[value].count++; - values[value].locked = - values[value].locked || caseInsensitive(row, 'SampleState/StatusType').value === 'Locked'; + if (!isMultiField && !isValidTextChoiceValue(value)) + return; + + const values : string[] = []; + if (isMultiField && Array.isArray(value)) { + values.push(...value); + hasMultiValue = hasMultiValue || value.length > 1; + } + else { + values.push(value); } + + const rowLocked = caseInsensitive(row, 'SampleState/StatusType').value === 'Locked'; + values.forEach(val => { + if (!useCount[val]) { + useCount[val] = { count: 0, locked: false }; + } + useCount[val].count++; + useCount[val].locked = + useCount[val].locked || rowLocked; + }) + }); - return values; + return { + useCount, + hasMultiValue, + }; } const response = await executeSql({ @@ -1407,16 +1449,48 @@ export async function getTextChoiceInUseValues( sql: `SELECT "${fieldName}", ${lockedSqlFragment} AS IsLocked, COUNT(*) AS RowCount FROM "${queryName}" WHERE "${fieldName}" IS NOT NULL GROUP BY "${fieldName}"`, }); - return response.rows - .filter(row => isValidTextChoiceValue(row[fieldName].value)) - .reduce((prev, row) => { + response.rows + .forEach((row) => { + if (!isMultiField && !isValidTextChoiceValue(row[fieldName].value)) + return; + const value = row[fieldName].value; - prev[value] = { - count: row.RowCount.value, - locked: row.IsLocked.value === 1, - }; - return prev; - }, {}); + const values : string[] = []; + if (isMultiField && Array.isArray(value)) { + values.push(...value); + hasMultiValue = hasMultiValue || value.length > 1; + } + else { + values.push(value); + } + + const rowLocked = row.IsLocked.value === 1; + const rowCount = row.RowCount.value; + + values.forEach(val => { + if (!useCount[val]) { + useCount[val] = { count: 0, locked: false }; + } + useCount[val].count++; + useCount[val].locked = + useCount[val].locked || rowLocked; + }) + + values.forEach(val => { + if (!useCount[val]) { + useCount[val] = { count: 0, locked: false }; + } + useCount[val].count += rowCount; + useCount[val].locked = + useCount[val].locked || rowLocked; + }) + }); + + return { + useCount, + hasMultiValue, + }; + } export function getGenId(rowId: number, kindName: 'DataClass' | 'SampleSet', containerPath?: string): Promise { diff --git a/packages/components/src/internal/components/domainproperties/constants.ts b/packages/components/src/internal/components/domainproperties/constants.ts index c39a9237a4..51d3c6f790 100644 --- a/packages/components/src/internal/components/domainproperties/constants.ts +++ b/packages/components/src/internal/components/domainproperties/constants.ts @@ -82,6 +82,7 @@ export const DOMAIN_VALIDATOR_NAME = 'name'; export const DOMAIN_VALIDATOR_REMOVE = 'removeValidator'; export const DOMAIN_VALIDATOR_LOOKUP = 'lookupValidator'; export const DOMAIN_VALIDATOR_TEXTCHOICE = 'textChoiceValidator'; +export const DOMAIN_FIELD_TEXTCHOICE_MULTI = 'textChoiceAllowMulti'; export const DOMAIN_VALIDATOR_BOLD = 'bold'; export const DOMAIN_VALIDATOR_ITALIC = 'italic'; diff --git a/packages/components/src/internal/components/domainproperties/models.test.ts b/packages/components/src/internal/components/domainproperties/models.test.ts index e2b16df8e7..7004e96303 100644 --- a/packages/components/src/internal/components/domainproperties/models.test.ts +++ b/packages/components/src/internal/components/domainproperties/models.test.ts @@ -504,6 +504,8 @@ describe('PropDescType', () => { expect(isPropertyTypeAllowed(true, VISIT_DATE_TYPE, true, true)).toBeTruthy(); expect(isPropertyTypeAllowed(true, VISIT_ID_TYPE, true, false)).toBeFalsy(); expect(isPropertyTypeAllowed(true, VISIT_ID_TYPE, true, true)).toBeTruthy(); + expect(isPropertyTypeAllowed(true, MULTI_CHOICE_TYPE, true, true)).toBeFalsy(); + expect(isPropertyTypeAllowed(false, MULTI_CHOICE_TYPE, true, true)).toBeFalsy(); expect(isPropertyTypeAllowed(false, VISIT_ID_TYPE, true, true)).toBeTruthy(); expect(isPropertyTypeAllowed(false, VISIT_ID_TYPE, true, false)).toBeFalsy(); expect(isPropertyTypeAllowed(false, FILE_TYPE, false, false)).toBeFalsy(); diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx index 91a6070049..25fee984bb 100644 --- a/packages/components/src/internal/components/domainproperties/models.tsx +++ b/packages/components/src/internal/components/domainproperties/models.tsx @@ -1740,6 +1740,8 @@ export function isPropertyTypeAllowed( showFilePropertyType: boolean, showStudyPropertyTypes: boolean ): boolean { + if (type.hideFromDomainRow) return false; + if (type === FILE_TYPE) return showFilePropertyType; if (STUDY_PROPERTY_TYPES.includes(type)) return showStudyPropertyTypes; From 086831af9e98f425aa07aa344d55f9f338b662a8 Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 20 Jan 2026 16:34:19 -0800 Subject: [PATCH 02/19] lint --- .../ConfirmDataTypeChangeModal.test.tsx | 80 +++++++----------- .../ConfirmDataTypeChangeModal.tsx | 30 +++---- .../domainproperties/DomainRow.test.tsx | 17 ++-- .../components/domainproperties/DomainRow.tsx | 20 ++++- .../domainproperties/PropDescType.ts | 2 +- .../TextChoiceOptions.test.tsx | 8 +- .../domainproperties/TextChoiceOptions.tsx | 52 +++++++----- .../domainproperties/actions.test.ts | 2 +- .../components/domainproperties/actions.ts | 83 ++++++++----------- 9 files changed, 141 insertions(+), 153 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx index bcc2cf0fb9..8e0fa9ad45 100644 --- a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx +++ b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx @@ -5,68 +5,71 @@ import { ConfirmDataTypeChangeModal, getDataTypeConfirmDisplayText } from './Con import { BOOLEAN_TYPE, DATE_TYPE, - DATETIME_TYPE, FILE_TYPE, - INTEGER_TYPE, MULTI_CHOICE_TYPE, + DATETIME_TYPE, + FILE_TYPE, + INTEGER_TYPE, + MULTI_CHOICE_TYPE, MULTILINE_TYPE, - PROP_DESC_TYPES, TEXT_CHOICE_TYPE, + PROP_DESC_TYPES, + TEXT_CHOICE_TYPE, TEXT_TYPE, - TIME_TYPE + TIME_TYPE, } from './PropDescType'; import { BOOLEAN_RANGE_URI, DATETIME_RANGE_URI, FILELINK_RANGE_URI, - INT_RANGE_URI, MULTI_CHOICE_RANGE_URI, - MULTILINE_RANGE_URI, TEXT_CHOICE_CONCEPT_URI, + MULTI_CHOICE_RANGE_URI, + MULTILINE_RANGE_URI, + TEXT_CHOICE_CONCEPT_URI, TIME_RANGE_URI, } from './constants'; describe('ConfirmDataTypeChangeModal', () => { - const stringType = { rangeURI: 'http://www.w3.org/2001/XMLSchema#boolean', dataType: TEXT_TYPE, - } + }; - const intType ={ + const intType = { rangeURI: 'http://www.w3.org/2001/XMLSchema#int', dataType: INTEGER_TYPE, - } + }; - const multiLineType ={ + const multiLineType = { rangeURI: MULTILINE_RANGE_URI, dataType: MULTILINE_TYPE, - } + }; - const fileLinkType ={ + const fileLinkType = { rangeURI: FILELINK_RANGE_URI, dataType: FILE_TYPE, - } + }; - const booleanType ={ + const booleanType = { rangeURI: BOOLEAN_RANGE_URI, dataType: BOOLEAN_TYPE, - } + }; - const dateTimeType ={ + const dateTimeType = { rangeURI: DATETIME_RANGE_URI, dataType: DATETIME_TYPE, - } + }; - const timeType ={ + const timeType = { rangeURI: TIME_RANGE_URI, dataType: TIME_TYPE, - } + }; - const multiChoiceType ={ + const multiChoiceType = { rangeURI: MULTI_CHOICE_RANGE_URI, dataType: MULTI_CHOICE_TYPE, - } + }; const textChoiceType = { conceptURI: TEXT_CHOICE_CONCEPT_URI, dataType: TEXT_CHOICE_TYPE, - } + }; const DEFAULT_PROPS = { original: { @@ -94,43 +97,24 @@ describe('ConfirmDataTypeChangeModal', () => { expect(getDataTypeConfirmDisplayText(dateTimeType.dataType)).toBe('dateTime'); expect(getDataTypeConfirmDisplayText(multiChoiceType.dataType)).toBe('Text Choice (multiple select)'); expect(getDataTypeConfirmDisplayText(textChoiceType.dataType)).toBe('Text Choice (single select)'); - }); test('from datetime to time', () => { - render( - - ); + render(); expect(document.body).toHaveTextContent( 'This change will convert the values in the field from dateTime to time. This will cause the Date portion of the value to be removed. Once you save your changes, you will not be able to change it back to dateTime.' ); }); test('from datetime to date', () => { - render( - - ); + render(); expect(document.body).toHaveTextContent( 'This change will convert the values in the field from dateTime to date. This will cause the Time portion of the value to be removed.' ); }); test('from date to datetime', () => { - render( - - ); + render(); expect(document.body).toHaveTextContent( 'This change will convert the values in the field from time to dateTime. Once you save your changes, you will not be able to change it back to time.' ); @@ -140,8 +124,8 @@ describe('ConfirmDataTypeChangeModal', () => { render( ); expect(document.body).toHaveTextContent( @@ -153,14 +137,12 @@ describe('ConfirmDataTypeChangeModal', () => { render( ); expect(document.body).toHaveTextContent( 'This change will convert the values in the field from Text Choice (multiple select) to Text Choice (single select). Filters in saved views might not function as expected' ); }); - - }); diff --git a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx index dd88cde35c..dd5386258d 100644 --- a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx +++ b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.tsx @@ -11,13 +11,13 @@ import { MULTILINE_RANGE_URI, TIME_RANGE_URI, } from './constants'; -import {IDomainField} from "./models"; +import { IDomainField } from './models'; interface Props { - original: Partial; newDataType: PropDescType; - onConfirm: () => void; onCancel: () => void; + onConfirm: () => void; + original: Partial; } export const ConfirmDataTypeChangeModal: FC = memo(props => { @@ -38,16 +38,12 @@ export const ConfirmDataTypeChangeModal: FC = memo(props => { if (newMultiChoice) { dataLossWarning = ( <> - Filters in saved views might not function as expected and any conditional formatting configured for this field will be removed.{' '} - ) - ; - } - else if (oldMultiChoice && newTextChoice) { - dataLossWarning = ( - <> - Filters in saved views might not function as expected.{' '} - ) - ; + Filters in saved views might not function as expected and any conditional formatting configured for this + field will be removed.{' '} + + ); + } else if (oldMultiChoice && newTextChoice) { + dataLossWarning = <>Filters in saved views might not function as expected. ; } else if ( originalRangeURI === DATETIME_RANGE_URI && (newDataType.rangeURI === DATE_RANGE_URI || newDataType.rangeURI === TIME_RANGE_URI) @@ -60,13 +56,13 @@ export const ConfirmDataTypeChangeModal: FC = memo(props => { ); } - return ( + return (
This change will convert the values in the field from{' '} diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx index b498fa52ef..89541f6e5c 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx @@ -53,7 +53,8 @@ import { PHILEVEL_RESTRICTED_PHI, SEVERITY_LEVEL_ERROR, SEVERITY_LEVEL_WARN, - STRING_RANGE_URI, TEXT_CHOICE_CONCEPT_URI, + STRING_RANGE_URI, + TEXT_CHOICE_CONCEPT_URI, } from './constants'; import { createFormInputId } from './utils'; @@ -121,7 +122,7 @@ describe('DomainRow', () => { await act(async () => { renderWithAppContext( wrapDraggable( - + ) ); }); @@ -153,7 +154,7 @@ describe('DomainRow', () => { await act(async () => { renderWithAppContext( wrapDraggable( - + ) ); }); @@ -185,7 +186,7 @@ describe('DomainRow', () => { await act(async () => { renderWithAppContext( wrapDraggable( - + ) ); }); @@ -217,7 +218,7 @@ describe('DomainRow', () => { await act(async () => { renderWithAppContext( wrapDraggable( - + ) ); }); @@ -267,10 +268,10 @@ describe('DomainRow', () => { wrapDraggable( ) ); @@ -321,7 +322,7 @@ describe('DomainRow', () => { await act(async () => { renderWithAppContext( wrapDraggable( - + ) ); }); diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.tsx index 0a3291f8e4..6bfdeab03d 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.tsx @@ -269,13 +269,19 @@ export class DomainRow extends React.PureComponent string OR int/long -> double/float/decimal if (field.isSaved()) { const typeConvertingTo = PropDescType.fromName(value); - if (shouldShowConfirmDataTypeChange(field.original.rangeURI ?? field.original.conceptURI, typeConvertingTo.rangeURI ?? typeConvertingTo.conceptURI)) { + if ( + shouldShowConfirmDataTypeChange( + field.original.rangeURI ?? field.original.conceptURI, + typeConvertingTo.rangeURI ?? typeConvertingTo.conceptURI + ) + ) { this.onShowConfirmTypeChange(value); return; } } - const expand = PropDescType.isLookup(value) || + const expand = + PropDescType.isLookup(value) || PropDescType.isTextChoice(value) || PropDescType.isUser(value) || PropDescType.isCalculation(value); @@ -563,13 +569,13 @@ export class DomainRow extends React.PureComponent
@@ -597,7 +603,13 @@ export const shouldShowConfirmDataTypeChange = (originalRangeURI: string, newRan const wasMultiChoice = PropDescType.isMultiChoice(originalRangeURI); const newTextChoice = PropDescType.isTextChoice(newRangeURI); const toMultiChoice = PropDescType.isMultiChoice(newRangeURI); - return toNumber || (toString && !wasString && !wasMultiChoice) || toDate || toMultiChoice || (wasMultiChoice && newTextChoice); + return ( + toNumber || + (toString && !wasString && !wasMultiChoice) || + toDate || + toMultiChoice || + (wasMultiChoice && newTextChoice) + ); } return false; }; diff --git a/packages/components/src/internal/components/domainproperties/PropDescType.ts b/packages/components/src/internal/components/domainproperties/PropDescType.ts index 40a9e9a03c..14836b2752 100644 --- a/packages/components/src/internal/components/domainproperties/PropDescType.ts +++ b/packages/components/src/internal/components/domainproperties/PropDescType.ts @@ -36,13 +36,13 @@ export type JsonType = 'array' | 'boolean' | 'date' | 'float' | 'int' | 'string' interface IPropDescType { conceptURI: string; display: string; + hideFromDomainRow?: boolean; longDisplay?: string; lookupQuery?: string; lookupSchema?: string; name: string; rangeURI: string; shortDisplay?: string; - hideFromDomainRow?: boolean; } export class PropDescType diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx index c01146c7f8..002ee0e729 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx @@ -150,8 +150,8 @@ describe('TextChoiceOptions', () => { render( ); validate(false, 2, 1); @@ -174,9 +174,9 @@ describe('TextChoiceOptions', () => { const { rerender } = render( ); validate(false, 2, 1); @@ -199,8 +199,8 @@ describe('TextChoiceOptions', () => { render( ); validate(false, 2, 1); @@ -244,8 +244,8 @@ describe('TextChoiceOptions', () => { render( ); validate(false, 2, 1); diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index ed96174dc7..e3cf906f9a 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -13,18 +13,20 @@ import { DisableableButton } from '../buttons/DisableableButton'; import { DisableableInput } from '../forms/DisableableInput'; import { - DOMAIN_FIELD_SCANNABLE_OPTION, - DOMAIN_FIELD_TEXTCHOICE_MULTI, DOMAIN_FIELD_TYPE, DOMAIN_VALIDATOR_TEXTCHOICE, MAX_VALID_TEXT_CHOICES + DOMAIN_FIELD_TEXTCHOICE_MULTI, + DOMAIN_FIELD_TYPE, + DOMAIN_VALIDATOR_TEXTCHOICE, + MAX_VALID_TEXT_CHOICES, } from './constants'; import { DEFAULT_TEXT_CHOICE_VALIDATOR, DomainField, ITypeDependentProps, PropertyValidator } from './models'; import { SectionHeading } from './SectionHeading'; import { DomainFieldLabel } from './DomainFieldLabel'; import { TextChoiceAddValuesModal } from './TextChoiceAddValuesModal'; -import {getTextChoiceInUseValues, TextChoiceInUseValues} from './actions'; -import {createFormInputId, createFormInputName} from './utils'; -import {isFieldFullyLocked} from "./propertiesUtil"; -import {MULTI_CHOICE_TYPE, PropDescType, TEXT_CHOICE_TYPE} from "./PropDescType"; +import { getTextChoiceInUseValues, TextChoiceInUseValues } from './actions'; +import { createFormInputId, createFormInputName } from './utils'; +import { isFieldFullyLocked } from './propertiesUtil'; +import { MULTI_CHOICE_TYPE, PropDescType, TEXT_CHOICE_TYPE } from './PropDescType'; const MIN_VALUES_FOR_SEARCH_COUNT = 2; const HELP_TIP_BODY =

The set of values to be used as drop-down options to restrict data entry into this field.

; @@ -33,9 +35,9 @@ const IN_USE_TITLE = 'Text Choice In Use'; const IN_USE_TIP = 'This text choice value cannot be deleted because it is in use.'; const VALUE_IN_USE = ( @@ -46,9 +48,9 @@ const LOCKED_TIP = 'This text choice value cannot be deleted because it is in use and cannot be edited because one or more usages are for read-only items.'; const VALUE_LOCKED = ( @@ -56,22 +58,22 @@ const VALUE_LOCKED = ( interface Props extends ITypeDependentProps { field: DomainField; + handleDataTypeChange: (targetId: string, value: any) => void; lockedForDomain?: boolean; lockedSqlFragment?: string; queryName?: string; schemaName?: string; - handleDataTypeChange: (targetId: string, value: any) => void; } interface ImplProps extends Props { // mapping existing field values (existence in this object signals "in use") to locked status (only applicable // to some domain types) and row count for the given value fieldValues: Record>; + hasMultiValueInUse?: boolean; loading: boolean; maxValueCount?: number; replaceValues: (newValues: string[], valueUpdates?: Record) => void; validValues: string[]; - hasMultiValueInUse?: boolean; } // exported for jest testing @@ -88,7 +90,7 @@ export const TextChoiceOptionsImpl: FC = memo(props => { domainIndex, index, handleDataTypeChange, - hasMultiValueInUse + hasMultiValueInUse, } = props; const [selectedIndex, setSelectedIndex] = useState(); const [currentValue, setCurrentValue] = useState(); @@ -219,7 +221,7 @@ export const TextChoiceOptionsImpl: FC = memo(props => {
- +
@@ -259,12 +261,12 @@ export const TextChoiceOptionsImpl: FC = memo(props => { return ( ); })} @@ -276,14 +278,20 @@ export const TextChoiceOptionsImpl: FC = memo(props => { title={`Add Values (max ${maxValueCount})`} /> - Allow multiple selections + + Allow multiple selections +
{validValues.length > 0 && selectedIndex === undefined && ( @@ -291,7 +299,7 @@ export const TextChoiceOptionsImpl: FC = memo(props => { Select a value from the list on the left to view details.

)} - {selectedIndex !== undefined && (currentInUse && isMultiChoiceField) && ( + {selectedIndex !== undefined && currentInUse && isMultiChoiceField && (

Value is currently in use and cannot be updated.

@@ -355,9 +363,9 @@ export const TextChoiceOptionsImpl: FC = memo(props => { {showAddValuesModal && ( )}
diff --git a/packages/components/src/internal/components/domainproperties/actions.test.ts b/packages/components/src/internal/components/domainproperties/actions.test.ts index db4f2897ce..097f4c29ea 100644 --- a/packages/components/src/internal/components/domainproperties/actions.test.ts +++ b/packages/components/src/internal/components/domainproperties/actions.test.ts @@ -857,7 +857,7 @@ describe('domain properties actions', () => { recommendedVariable: true, uniqueConstraint: true, nonUniqueConstraint: true, - rangeURI: STRING_RANGE_URI + rangeURI: STRING_RANGE_URI, }); expect(field.dataType).toBe(TEXT_TYPE); expect(field.scale).toBe(10); diff --git a/packages/components/src/internal/components/domainproperties/actions.ts b/packages/components/src/internal/components/domainproperties/actions.ts index 5d3042cc64..c5ed66cf71 100644 --- a/packages/components/src/internal/components/domainproperties/actions.ts +++ b/packages/components/src/internal/components/domainproperties/actions.ts @@ -892,7 +892,7 @@ export function updateDataType(field: DomainField, value: any): DomainField { field = field.merge(DomainField.resolveLookupConfig(field, dataType)) as DomainField; } - if ((field.isTextChoiceField() || field.isMultiChoiceField())) { + if (field.isTextChoiceField() || field.isMultiChoiceField()) { // when changing a field to a Text Choice, add the default textChoiceValidator and // remove/reset all other propertyValidators and other text option settings if (!wasTextChoice) { @@ -918,10 +918,9 @@ export function updateDataType(field: DomainField, value: any): DomainField { recommendedVariable: false, uniqueConstraint: false, nonUniqueConstraint: false, - conditionalFormats: List() + conditionalFormats: List(), }) as DomainField; } - } else if (field.isCalculatedField()) { field = field.merge({ importAliases: undefined, @@ -1386,8 +1385,8 @@ export function getDomainNamePreviews( } export type TextChoiceInUseValues = { - useCount: Record, - hasMultiValue: boolean, + hasMultiValue: boolean; + useCount: Record; }; export async function getTextChoiceInUseValues( @@ -1395,7 +1394,7 @@ export async function getTextChoiceInUseValues( schemaName: string, queryName: string, lockedSqlFragment: string, - isMultiField: boolean, + isMultiField: boolean ): Promise { const containerFilter = Query.ContainerFilter.allInProjectPlusShared; // to account for a shared domain at project or /Shared const fieldName = field.original?.name ?? field.name; @@ -1414,15 +1413,13 @@ export async function getTextChoiceInUseValues( result.rows.forEach(row => { const value = row[fieldName]?.value; - if (!isMultiField && !isValidTextChoiceValue(value)) - return; + if (!isMultiField && !isValidTextChoiceValue(value)) return; - const values : string[] = []; + const values: string[] = []; if (isMultiField && Array.isArray(value)) { values.push(...value); hasMultiValue = hasMultiValue || value.length > 1; - } - else { + } else { values.push(value); } @@ -1432,10 +1429,8 @@ export async function getTextChoiceInUseValues( useCount[val] = { count: 0, locked: false }; } useCount[val].count++; - useCount[val].locked = - useCount[val].locked || rowLocked; - }) - + useCount[val].locked = useCount[val].locked || rowLocked; + }); }); return { useCount, @@ -1449,48 +1444,42 @@ export async function getTextChoiceInUseValues( sql: `SELECT "${fieldName}", ${lockedSqlFragment} AS IsLocked, COUNT(*) AS RowCount FROM "${queryName}" WHERE "${fieldName}" IS NOT NULL GROUP BY "${fieldName}"`, }); - response.rows - .forEach((row) => { - if (!isMultiField && !isValidTextChoiceValue(row[fieldName].value)) - return; + response.rows.forEach(row => { + if (!isMultiField && !isValidTextChoiceValue(row[fieldName].value)) return; - const value = row[fieldName].value; - const values : string[] = []; - if (isMultiField && Array.isArray(value)) { - values.push(...value); - hasMultiValue = hasMultiValue || value.length > 1; - } - else { - values.push(value); - } + const value = row[fieldName].value; + const values: string[] = []; + if (isMultiField && Array.isArray(value)) { + values.push(...value); + hasMultiValue = hasMultiValue || value.length > 1; + } else { + values.push(value); + } - const rowLocked = row.IsLocked.value === 1; - const rowCount = row.RowCount.value; + const rowLocked = row.IsLocked.value === 1; + const rowCount = row.RowCount.value; - values.forEach(val => { - if (!useCount[val]) { - useCount[val] = { count: 0, locked: false }; - } - useCount[val].count++; - useCount[val].locked = - useCount[val].locked || rowLocked; - }) + values.forEach(val => { + if (!useCount[val]) { + useCount[val] = { count: 0, locked: false }; + } + useCount[val].count++; + useCount[val].locked = useCount[val].locked || rowLocked; + }); - values.forEach(val => { - if (!useCount[val]) { - useCount[val] = { count: 0, locked: false }; - } - useCount[val].count += rowCount; - useCount[val].locked = - useCount[val].locked || rowLocked; - }) + values.forEach(val => { + if (!useCount[val]) { + useCount[val] = { count: 0, locked: false }; + } + useCount[val].count += rowCount; + useCount[val].locked = useCount[val].locked || rowLocked; }); + }); return { useCount, hasMultiValue, }; - } export function getGenId(rowId: number, kindName: 'DataClass' | 'SampleSet', containerPath?: string): Promise { From c3eab136f255c11e6682a63f3fbf1ffa1f9b9be6 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 21 Jan 2026 11:09:04 -0800 Subject: [PATCH 03/19] update tests --- .../TextChoiceOptions.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx index 002ee0e729..910b72b1e9 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx @@ -18,6 +18,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { TextChoiceOptionsImpl } from './TextChoiceOptions'; import { DomainField } from './models'; +import { MULTI_CHOICE_RANGE_URI } from './constants'; describe('TextChoiceOptions', () => { const DEFAULT_PROPS = { @@ -92,6 +93,13 @@ describe('TextChoiceOptions', () => { const addBtn = document.querySelector('span.container--action-button'); expect(addBtn.textContent).toBe(' Add Values'); expect(addBtn.getAttribute('class').indexOf('disabled')).toBe(-1); + + // verify multi-choice checkbox exists, is unchecked, and enabled by default + const multiCheckbox = document.querySelector('input.domain-text-choice-multi') as HTMLInputElement; + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox.checked).toBe(false); + expect(multiCheckbox).toBeEnabled(); + expect(screen.getByText('Allow multiple selections')).toBeInTheDocument(); }); test('loading', () => { @@ -99,6 +107,33 @@ describe('TextChoiceOptions', () => { validate(true); }); + test('multi-choice checkbox checked when field is multi-choice', () => { + render( + + ); + const multiCheckbox = document.querySelector('input.domain-text-choice-multi') as HTMLInputElement; + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).toBeEnabled(); + expect(multiCheckbox.checked).toBe(true); + }); + + test('multi-choice checkbox disabled when multi values are in use', () => { + render( + + ); + const multiCheckbox = document.querySelector('input.domain-text-choice-multi') as HTMLInputElement; + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).toBeDisabled(); + const labelSpan = screen.getByText('Allow multiple selections'); + expect(labelSpan.getAttribute('title')).toBe('Multiple values are currently used by at least one data row.'); + }); + test('with validValues, no selection', () => { render(); validate(false, 2); From 4ddcf19dfe9286183974ba031b56702b9d81a5f3 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 22 Jan 2026 15:26:51 -0800 Subject: [PATCH 04/19] Fix --- .../components/domainproperties/TextChoiceOptions.tsx | 2 +- .../src/internal/components/domainproperties/actions.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index e3cf906f9a..30286d3406 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -421,7 +421,7 @@ export const TextChoiceOptions: FC = memo(props => { // that is being changed to data type = Text Choice (that "is new field" check is above), // then we will use the existing distinct values for that field as the initial options if (!field.textChoiceValidator?.rowId) { - replaceValues(Object.keys(values).sort()); + replaceValues(Object.keys(values.useCount).sort()); } setLoading(false); diff --git a/packages/components/src/internal/components/domainproperties/actions.ts b/packages/components/src/internal/components/domainproperties/actions.ts index c5ed66cf71..c0bb0acc6d 100644 --- a/packages/components/src/internal/components/domainproperties/actions.ts +++ b/packages/components/src/internal/components/domainproperties/actions.ts @@ -1412,7 +1412,7 @@ export async function getTextChoiceInUseValues( }); result.rows.forEach(row => { - const value = row[fieldName]?.value; + const value = caseInsensitive(row, fieldName)?.value; if (!isMultiField && !isValidTextChoiceValue(value)) return; const values: string[] = []; @@ -1445,9 +1445,10 @@ export async function getTextChoiceInUseValues( }); response.rows.forEach(row => { - if (!isMultiField && !isValidTextChoiceValue(row[fieldName].value)) return; + const value = caseInsensitive(row, fieldName)?.value; + + if (!isMultiField && !isValidTextChoiceValue(value)) return; - const value = row[fieldName].value; const values: string[] = []; if (isMultiField && Array.isArray(value)) { values.push(...value); From dd9bab9ee73e1ab5aa61bd777127ca68521f1671 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 22 Jan 2026 15:43:29 -0800 Subject: [PATCH 05/19] Support field type conversion for Multi Choice fields --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- packages/components/releaseNotes/components.md | 5 +++++ .../components/domainproperties/TextChoiceOptions.test.tsx | 7 +------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index a0066cbefd..22f032e692 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.13.0", + "version": "7.14.0-fb-mvtc-convert.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.13.0", + "version": "7.14.0-fb-mvtc-convert.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index e58f02190d..026e84e7cd 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.13.0", + "version": "7.14.0-fb-mvtc-convert.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 881e48c847..1c778f3c2c 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,11 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.X +*Released*: X January 2026 +- Multi value text choices: field type conversion + - TODO + ### version 7.13.0 *Released*: 20 January 2026 - Multi value text choices diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx index 910b72b1e9..114fc966d9 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx @@ -121,12 +121,7 @@ describe('TextChoiceOptions', () => { }); test('multi-choice checkbox disabled when multi values are in use', () => { - render( - - ); + render(); const multiCheckbox = document.querySelector('input.domain-text-choice-multi') as HTMLInputElement; expect(multiCheckbox).toBeInTheDocument(); expect(multiCheckbox).toBeDisabled(); From 475fa24fb13666340bb2372b45187298b48a8e02 Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 23 Jan 2026 16:20:34 -0800 Subject: [PATCH 06/19] fix display --- .../internal/components/domainproperties/DomainRow.tsx | 6 +++--- .../internal/components/domainproperties/PropDescType.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.tsx index 6bfdeab03d..3dcf36e149 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.tsx @@ -483,10 +483,10 @@ export class DomainRow extends React.PureComponent {isPrimaryKeyFieldLocked(field.lockType) ? ( - ) : ( @@ -499,7 +499,7 @@ export class DomainRow extends React.PureComponent ( - )) diff --git a/packages/components/src/internal/components/domainproperties/PropDescType.ts b/packages/components/src/internal/components/domainproperties/PropDescType.ts index 14836b2752..6b4636f65f 100644 --- a/packages/components/src/internal/components/domainproperties/PropDescType.ts +++ b/packages/components/src/internal/components/domainproperties/PropDescType.ts @@ -41,6 +41,7 @@ interface IPropDescType { lookupQuery?: string; lookupSchema?: string; name: string; + altName?: string; rangeURI: string; shortDisplay?: string; } @@ -56,6 +57,7 @@ export class PropDescType shortDisplay: undefined, lookupSchema: undefined, lookupQuery: undefined, + altName: undefined, hideFromDomainRow: false, }) implements IPropDescType @@ -64,6 +66,7 @@ export class PropDescType declare display: string; declare longDisplay?: string; declare name: string; + declare altName?: string; declare rangeURI: string; declare alternateRangeURI: string; declare shortDisplay: string; @@ -242,6 +245,10 @@ export class PropDescType isDateTime(): boolean { return PropDescType.isDateTime(this.rangeURI); } + + get selectName(): string { + return this.altName ?? this.name; + } } export const TEXT_TYPE = new PropDescType({ @@ -373,6 +380,7 @@ export const TEXT_CHOICE_TYPE = new PropDescType({ export const MULTI_CHOICE_TYPE = new PropDescType({ name: 'multiChoice', + altName: 'textChoice', display: 'Text Choice', longDisplay: 'Text Choice (multiple select)', rangeURI: MULTI_CHOICE_RANGE_URI, From c6ddd9f6fef8110b0ef70bdee4f29dff1e648b4e Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 23 Jan 2026 16:29:43 -0800 Subject: [PATCH 07/19] fix display --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 22f032e692..0ced0e330a 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.1", + "version": "7.14.0-fb-mvtc-convert.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.1", + "version": "7.14.0-fb-mvtc-convert.3", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 026e84e7cd..8166c7438a 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.1", + "version": "7.14.0-fb-mvtc-convert.3", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 3dff74edcaf66b925e7e7868b689cae577067a03 Mon Sep 17 00:00:00 2001 From: XingY Date: Sun, 25 Jan 2026 15:18:57 -0800 Subject: [PATCH 08/19] fix assay run domain --- .../domainproperties/DomainForm.tsx | 1 + .../components/domainproperties/DomainRow.tsx | 3 ++ .../DomainRowExpandedOptions.tsx | 3 ++ .../domainproperties/TextChoiceOptions.tsx | 37 +++++++++++-------- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/DomainForm.tsx b/packages/components/src/internal/components/domainproperties/DomainForm.tsx index 182c5756c7..ce99b047ab 100644 --- a/packages/components/src/internal/components/domainproperties/DomainForm.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainForm.tsx @@ -1267,6 +1267,7 @@ export class DomainFormImpl extends React.PureComponent appPropertiesOnly={appPropertiesOnly} availableTypes={availableTypes} defaultDefaultValueType={domain.defaultDefaultValueType} + allowMultiChoiceField={domain.allowMultiChoiceProperties} defaultValueOptions={domain.defaultValueOptions} domainContainerPath={domain.container} domainFormDisplayOptions={domainFormDisplayOptions} diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.tsx index 3dcf36e149..15b5c96526 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.tsx @@ -99,6 +99,7 @@ export interface DomainRowProps { queryName?: string; schemaName?: string; showDefaultValueSettings: boolean; + allowMultiChoiceField: boolean; } interface DomainRowState { @@ -384,6 +385,7 @@ export class DomainRow extends React.PureComponent
diff --git a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx index 4fcdce2e7d..7641f1f4c6 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx @@ -50,6 +50,7 @@ interface Props { schemaName?: string; showingModal: (boolean) => void; handleDataTypeChange: (targetId: string, value: any) => void; + allowMultiChoiceField: boolean; } export class DomainRowExpandedOptions extends React.Component { @@ -67,6 +68,7 @@ export class DomainRowExpandedOptions extends React.Component { schemaName, queryName, handleDataTypeChange, + allowMultiChoiceField } = this.props; // In most cases we will use the selected data type to determine which field options to show, @@ -242,6 +244,7 @@ export class DomainRowExpandedOptions extends React.Component { lockedForDomain={domainFormDisplayOptions.textChoiceLockedForDomain} lockedSqlFragment={domainFormDisplayOptions.textChoiceLockedSqlFragment} handleDataTypeChange={handleDataTypeChange} + allowMultiChoice={allowMultiChoiceField} /> ); case 'fileLink': diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index 30286d3406..4da816fb4c 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -63,6 +63,7 @@ interface Props extends ITypeDependentProps { lockedSqlFragment?: string; queryName?: string; schemaName?: string; + allowMultiChoice: boolean; } interface ImplProps extends Props { @@ -91,6 +92,7 @@ export const TextChoiceOptionsImpl: FC = memo(props => { index, handleDataTypeChange, hasMultiValueInUse, + allowMultiChoice, } = props; const [selectedIndex, setSelectedIndex] = useState(); const [currentValue, setCurrentValue] = useState(); @@ -277,21 +279,26 @@ export const TextChoiceOptionsImpl: FC = memo(props => { onClick={toggleAddValues} title={`Add Values (max ${maxValueCount})`} /> - - - Allow multiple selections - + {allowMultiChoice && ( + <> + + + Allow multiple selections + + + )} +
{validValues.length > 0 && selectedIndex === undefined && ( From 7263cf50712d47623942d26a2037de9c8ee57575 Mon Sep 17 00:00:00 2001 From: XingY Date: Sun, 25 Jan 2026 15:23:30 -0800 Subject: [PATCH 09/19] fix assay run domain --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 0ced0e330a..8e5d4fd0d6 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.3", + "version": "7.14.0-fb-mvtc-convert.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.3", + "version": "7.14.0-fb-mvtc-convert.4", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 8166c7438a..b74687bd28 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.3", + "version": "7.14.0-fb-mvtc-convert.4", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From bcbf4b21198fcf11c6ca15bf50527c2ff4b65ed6 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 26 Jan 2026 14:49:00 -0800 Subject: [PATCH 10/19] clean --- packages/components/releaseNotes/components.md | 4 +++- .../domainproperties/ConfirmDataTypeChangeModal.test.tsx | 5 ----- .../components/domainproperties/DomainRow.test.tsx | 4 ++-- .../domainproperties/TextChoiceOptions.test.tsx | 6 ++++++ .../components/domainproperties/TextChoiceOptions.tsx | 4 ++-- .../src/internal/components/domainproperties/actions.ts | 8 -------- 6 files changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 1c778f3c2c..de9ccda567 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -4,7 +4,9 @@ Components, models, actions, and utility functions for LabKey applications and p ### version 7.X *Released*: X January 2026 - Multi value text choices: field type conversion - - TODO + - Updates the Text Choice options UI to add an “Allow multiple selections” toggle, multi-choice-specific edit restrictions, and improved confirmation messaging/tests for data-type changes involving text/multi-choice. + - Make multi-choice behaves as an internal variant of Text Choice rather than a separate visible type in data type dropdown + - Modified updateDataType and text choice usage counting to correctly handle conversions between string, Text Choice, and Multi Choice fields, clearing validators/flags and tracking multi-value usage where appropriate. ### version 7.13.0 *Released*: 20 January 2026 diff --git a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx index 8e0fa9ad45..75f0e7520d 100644 --- a/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx +++ b/packages/components/src/internal/components/domainproperties/ConfirmDataTypeChangeModal.test.tsx @@ -26,11 +26,6 @@ import { } from './constants'; describe('ConfirmDataTypeChangeModal', () => { - const stringType = { - rangeURI: 'http://www.w3.org/2001/XMLSchema#boolean', - dataType: TEXT_TYPE, - }; - const intType = { rangeURI: 'http://www.w3.org/2001/XMLSchema#int', dataType: INTEGER_TYPE, diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx index 89541f6e5c..f54e50ba83 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.test.tsx @@ -437,11 +437,11 @@ describe('shouldShowConfirmDataTypeChange', () => { expect(shouldShowConfirmDataTypeChange(MULTI_CHOICE_RANGE_URI, STRING_RANGE_URI)).toBe(false); }); - test('should return false for converting multichoice to textChoice', () => { + test('should return true for converting multichoice to textChoice', () => { expect(shouldShowConfirmDataTypeChange(MULTI_CHOICE_RANGE_URI, TEXT_CHOICE_CONCEPT_URI)).toBe(true); }); - test('should return false for converting textChoice to multiChoice', () => { + test('should return true for converting textChoice to multiChoice', () => { expect(shouldShowConfirmDataTypeChange(TEXT_CHOICE_CONCEPT_URI, MULTI_CHOICE_RANGE_URI)).toBe(true); }); }); diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx index 114fc966d9..b9a3f3451c 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.test.tsx @@ -33,6 +33,7 @@ describe('TextChoiceOptions', () => { onChange: jest.fn(), handleDataTypeChange: jest.fn(), lockType: undefined, + allowMultiChoice: true, }; function validate( @@ -129,6 +130,11 @@ describe('TextChoiceOptions', () => { expect(labelSpan.getAttribute('title')).toBe('Multiple values are currently used by at least one data row.'); }); + test('multi-choice checkbox not present', () => { + render(); + expect(document.querySelectorAll('input.domain-text-choice-multi')).toHaveLength(0); + }); + test('with validValues, no selection', () => { render(); validate(false, 2); diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index 4da816fb4c..dca5191697 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -24,9 +24,9 @@ import { DomainFieldLabel } from './DomainFieldLabel'; import { TextChoiceAddValuesModal } from './TextChoiceAddValuesModal'; import { getTextChoiceInUseValues, TextChoiceInUseValues } from './actions'; -import { createFormInputId, createFormInputName } from './utils'; +import { createFormInputId } from './utils'; import { isFieldFullyLocked } from './propertiesUtil'; -import { MULTI_CHOICE_TYPE, PropDescType, TEXT_CHOICE_TYPE } from './PropDescType'; +import { MULTI_CHOICE_TYPE, TEXT_CHOICE_TYPE } from './PropDescType'; const MIN_VALUES_FOR_SEARCH_COUNT = 2; const HELP_TIP_BODY =

The set of values to be used as drop-down options to restrict data entry into this field.

; diff --git a/packages/components/src/internal/components/domainproperties/actions.ts b/packages/components/src/internal/components/domainproperties/actions.ts index c0bb0acc6d..d09d9cd07a 100644 --- a/packages/components/src/internal/components/domainproperties/actions.ts +++ b/packages/components/src/internal/components/domainproperties/actions.ts @@ -1460,14 +1460,6 @@ export async function getTextChoiceInUseValues( const rowLocked = row.IsLocked.value === 1; const rowCount = row.RowCount.value; - values.forEach(val => { - if (!useCount[val]) { - useCount[val] = { count: 0, locked: false }; - } - useCount[val].count++; - useCount[val].locked = useCount[val].locked || rowLocked; - }); - values.forEach(val => { if (!useCount[val]) { useCount[val] = { count: 0, locked: false }; From 86e4e973edd7f93e18da2d83d111726c4cab1b3c Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 26 Jan 2026 14:49:44 -0800 Subject: [PATCH 11/19] clean --- .../internal/components/domainproperties/DomainForm.tsx | 2 +- .../src/internal/components/domainproperties/DomainRow.tsx | 4 ++-- .../internal/components/domainproperties/PropDescType.ts | 2 +- .../components/domainproperties/TextChoiceOptions.tsx | 7 ++++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/DomainForm.tsx b/packages/components/src/internal/components/domainproperties/DomainForm.tsx index ce99b047ab..7ee58ffd56 100644 --- a/packages/components/src/internal/components/domainproperties/DomainForm.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainForm.tsx @@ -1263,11 +1263,11 @@ export class DomainFormImpl extends React.PureComponent return ( ; @@ -99,7 +100,6 @@ export interface DomainRowProps { queryName?: string; schemaName?: string; showDefaultValueSettings: boolean; - allowMultiChoiceField: boolean; } interface DomainRowState { @@ -565,6 +565,7 @@ export class DomainRow extends React.PureComponent
diff --git a/packages/components/src/internal/components/domainproperties/PropDescType.ts b/packages/components/src/internal/components/domainproperties/PropDescType.ts index 6b4636f65f..f0d74b45bb 100644 --- a/packages/components/src/internal/components/domainproperties/PropDescType.ts +++ b/packages/components/src/internal/components/domainproperties/PropDescType.ts @@ -34,6 +34,7 @@ import { export type JsonType = 'array' | 'boolean' | 'date' | 'float' | 'int' | 'string' | 'time'; interface IPropDescType { + altName?: string; conceptURI: string; display: string; hideFromDomainRow?: boolean; @@ -41,7 +42,6 @@ interface IPropDescType { lookupQuery?: string; lookupSchema?: string; name: string; - altName?: string; rangeURI: string; shortDisplay?: string; } diff --git a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx index dca5191697..9bbb249a38 100644 --- a/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx +++ b/packages/components/src/internal/components/domainproperties/TextChoiceOptions.tsx @@ -57,13 +57,13 @@ const VALUE_LOCKED = ( ); interface Props extends ITypeDependentProps { + allowMultiChoice: boolean; field: DomainField; handleDataTypeChange: (targetId: string, value: any) => void; lockedForDomain?: boolean; lockedSqlFragment?: string; queryName?: string; schemaName?: string; - allowMultiChoice: boolean; } interface ImplProps extends Props { @@ -291,14 +291,15 @@ export const TextChoiceOptionsImpl: FC = memo(props => { /> Allow multiple selections )} -
{validValues.length > 0 && selectedIndex === undefined && ( From 0f700309c3c41c0228f8cc629ef0ccd044c3b4a2 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 26 Jan 2026 14:51:47 -0800 Subject: [PATCH 12/19] clean --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 8e5d4fd0d6..08099ed714 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.4", + "version": "7.14.0-fb-mvtc-convert.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.4", + "version": "7.14.0-fb-mvtc-convert.5", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index b74687bd28..709fd25644 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.4", + "version": "7.14.0-fb-mvtc-convert.5", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 204c0b300587f3340a11e6fd65248e8bddebbbaf Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 26 Jan 2026 14:56:32 -0800 Subject: [PATCH 13/19] merge from develop --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 88d5fa2172..9d520ea587 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.13.1", + "version": "7.14.0-fb-mvtc-convert.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.13.1", + "version": "7.14.0-fb-mvtc-convert.6", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 709fd25644..43e9e9d7c6 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.5", + "version": "7.14.0-fb-mvtc-convert.6", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From b94afdd7165aee05071fce07ceca8e12b16fea62 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 26 Jan 2026 16:35:32 -0800 Subject: [PATCH 14/19] clean --- packages/components/package-lock.json | 12 ++++++------ packages/components/package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 9d520ea587..53e6dcf202 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,16 +1,16 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.6", + "version": "7.14.0-fb-mvtc-convert.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.6", + "version": "7.14.0-fb-mvtc-convert.7", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.45.0", + "@labkey/api": "1.45.1-fb-mvtc-convert.1", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.0", @@ -3535,9 +3535,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.45.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.0.tgz", - "integrity": "sha512-7KN2SvmcY46OtRBtlsUxlmGaE5LN/cg6OfPyc837pSGl+cIndPxOJMqFCvxO26h7c7Fd7cAK1/oOuAzAbvKHUw==", + "version": "1.45.1-fb-mvtc-convert.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.1-fb-mvtc-convert.1.tgz", + "integrity": "sha512-IlQwnZzi9whzKTBdAur3La0wJmIpFhMHpoQDIdKuo2NByygI+920EBGBiqjrSpTZYQbM0TnJjAZvIk0+5TtsWg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { diff --git a/packages/components/package.json b/packages/components/package.json index 43e9e9d7c6..c76195f7a1 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.6", + "version": "7.14.0-fb-mvtc-convert.7", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ @@ -50,7 +50,7 @@ "homepage": "https://github.com/LabKey/labkey-ui-components#readme", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.45.0", + "@labkey/api": "1.45.1-fb-mvtc-convert.1", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.0", From 5cf39c71f370171a270690e7aa8683dbd7105eb6 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 26 Jan 2026 17:49:43 -0800 Subject: [PATCH 15/19] fix check all values --- packages/components/src/internal/components/search/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/components/src/internal/components/search/utils.ts b/packages/components/src/internal/components/search/utils.ts index 9ae44bd1b8..19a0fad48c 100644 --- a/packages/components/src/internal/components/search/utils.ts +++ b/packages/components/src/internal/components/search/utils.ts @@ -412,6 +412,9 @@ export function getUpdatedChooseValuesFilter( if (isArrayFilter) { if ((newValue === ALL_VALUE_DISPLAY && !check) || newCheckedValues.length === 0) return Filter.create(fieldKey, [], oldFilter.getFilterType()); + if (allValues && newCheckedValues.length >= allValues.length) { + return Filter.create(fieldKey, allValues.filter(v => v !== ALL_VALUE_DISPLAY), oldFilter.getFilterType()); + } return Filter.create(fieldKey, newCheckedValues, oldFilter.getFilterType()); } From 61a6075894633fdded72f0c513a8a90afb41af33 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 26 Jan 2026 17:52:03 -0800 Subject: [PATCH 16/19] fix check all values --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 53e6dcf202..8441e1edef 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.7", + "version": "7.14.0-fb-mvtc-convert.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.7", + "version": "7.14.0-fb-mvtc-convert.8", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index c76195f7a1..d7ec1bc189 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.7", + "version": "7.14.0-fb-mvtc-convert.8", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 19249d2eee1b6e9cb8ed50a235b65d75b77f5e22 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 28 Jan 2026 12:38:26 -0800 Subject: [PATCH 17/19] code review --- .../components/domainproperties/DomainRow.tsx | 4 +- .../domainproperties/PropDescType.ts | 4 -- .../components/domainproperties/models.tsx | 2 +- .../internal/components/search/utils.test.ts | 42 +++++++++++++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.tsx index d0ea1a6666..7cad7ef059 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.tsx @@ -272,8 +272,8 @@ export class DomainRow extends React.PureComponent type.name === name); @@ -384,7 +381,6 @@ export const MULTI_CHOICE_TYPE = new PropDescType({ display: 'Text Choice', longDisplay: 'Text Choice (multiple select)', rangeURI: MULTI_CHOICE_RANGE_URI, - hideFromDomainRow: true, }); export const SMILES_TYPE = new PropDescType({ diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx index 25fee984bb..4f2fcc6d20 100644 --- a/packages/components/src/internal/components/domainproperties/models.tsx +++ b/packages/components/src/internal/components/domainproperties/models.tsx @@ -1740,7 +1740,7 @@ export function isPropertyTypeAllowed( showFilePropertyType: boolean, showStudyPropertyTypes: boolean ): boolean { - if (type.hideFromDomainRow) return false; + if (type === MULTI_CHOICE_TYPE) return false; if (type === FILE_TYPE) return showFilePropertyType; diff --git a/packages/components/src/internal/components/search/utils.test.ts b/packages/components/src/internal/components/search/utils.test.ts index 52be535f1f..a34124894d 100644 --- a/packages/components/src/internal/components/search/utils.test.ts +++ b/packages/components/src/internal/components/search/utils.test.ts @@ -575,6 +575,7 @@ describe('isEmptyValue', () => { const distinctValues = ['[All]', '[blank]', 'ed', 'ned', 'ted', 'red', 'bed']; const distinctValuesNoBlank = ['[All]', 'ed', 'ned', 'ted', 'red', 'bed']; const distinctValuesExcludeAll = ['[blank]', 'ed', 'ned', 'ted', 'red', 'bed']; +const distinctValuesExcludeBlankAll = ['ed', 'ned', 'ted', 'red', 'bed']; const fieldKey = 'thing'; const checkedOne = Filter.create(fieldKey, 'ed'); @@ -901,6 +902,47 @@ describe('getUpdatedChooseValuesFilter', () => { 'isnonblank' ); }); + + test('check all, array', () => { + validate( + getUpdatedChooseValuesFilter(distinctValuesNoBlank, fieldKey, ALL_VALUE_DISPLAY, true, arrayContainsAll), + 'arraycontainsall', + distinctValuesExcludeBlankAll + ); + }); + + test('check some value, array', () => { + validate( + getUpdatedChooseValuesFilter(distinctValuesNoBlank, fieldKey, 'ed', true, arrayContainsAll), + 'arraycontainsall', + ['red', 'ted', 'ned', 'ed'] + ); + }); + + test('check some value, array', () => { + validate( + getUpdatedChooseValuesFilter(distinctValuesNoBlank, fieldKey, 'ted', false, arrayContainsAll), + 'arraycontainsall', + ['red', 'ned'] + ); + }); + + test('check all values one by one, array', () => { + const updated = getUpdatedChooseValuesFilter(distinctValuesNoBlank, fieldKey, 'ed', true, arrayContainsAll); + const allChecked = getUpdatedChooseValuesFilter(distinctValuesNoBlank, fieldKey, 'bed', true, updated); + validate( + allChecked, + 'arraycontainsall', + distinctValuesExcludeBlankAll + ); + const allCheckedThenCheckAll = getUpdatedChooseValuesFilter(distinctValuesNoBlank, fieldKey, ALL_VALUE_DISPLAY, true, allChecked); + validate( + allCheckedThenCheckAll, + 'arraycontainsall', + distinctValuesExcludeBlankAll + ); + }); + }); describe('isValidFilterField', () => { From efc568738c1461efd70b458b68feeff2e027972f Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 28 Jan 2026 12:52:11 -0800 Subject: [PATCH 18/19] code review --- .../components/domainproperties/actions.ts | 86 ++++++++----------- 1 file changed, 35 insertions(+), 51 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/actions.ts b/packages/components/src/internal/components/domainproperties/actions.ts index d09d9cd07a..afccab199a 100644 --- a/packages/components/src/internal/components/domainproperties/actions.ts +++ b/packages/components/src/internal/components/domainproperties/actions.ts @@ -1389,6 +1389,28 @@ export type TextChoiceInUseValues = { useCount: Record; }; +function processTextChoiceRow( value: any, isMultiField: boolean, isLocked: boolean, rowCount: number, useCount: Record, hasMultiValue: boolean ): boolean { + if (!isMultiField && !isValidTextChoiceValue(value)) return hasMultiValue; + + const values: string[] = []; + if (isMultiField && Array.isArray(value)) { + values.push(...value); + hasMultiValue = hasMultiValue || value.length > 1; + } else { + values.push(value); + } + + values.forEach(val => { + if (!useCount[val]) { + useCount[val] = { count: 0, locked: false }; + } + useCount[val].count += rowCount; + useCount[val].locked = useCount[val].locked || isLocked; + }); + + return hasMultiValue; +} + export async function getTextChoiceInUseValues( field: DomainField, schemaName: string, @@ -1413,61 +1435,23 @@ export async function getTextChoiceInUseValues( result.rows.forEach(row => { const value = caseInsensitive(row, fieldName)?.value; - if (!isMultiField && !isValidTextChoiceValue(value)) return; - - const values: string[] = []; - if (isMultiField && Array.isArray(value)) { - values.push(...value); - hasMultiValue = hasMultiValue || value.length > 1; - } else { - values.push(value); - } - const rowLocked = caseInsensitive(row, 'SampleState/StatusType').value === 'Locked'; - values.forEach(val => { - if (!useCount[val]) { - useCount[val] = { count: 0, locked: false }; - } - useCount[val].count++; - useCount[val].locked = useCount[val].locked || rowLocked; - }); + hasMultiValue = processTextChoiceRow(value, isMultiField, rowLocked, 1, useCount, hasMultiValue); + }); + } else { + const response = await executeSql({ + containerFilter, + schemaName, + sql: `SELECT "${fieldName}", ${lockedSqlFragment} AS IsLocked, COUNT(*) AS RowCount FROM "${queryName}" WHERE "${fieldName}" IS NOT NULL GROUP BY "${fieldName}"`, }); - return { - useCount, - hasMultiValue, - }; - } - - const response = await executeSql({ - containerFilter, - schemaName, - sql: `SELECT "${fieldName}", ${lockedSqlFragment} AS IsLocked, COUNT(*) AS RowCount FROM "${queryName}" WHERE "${fieldName}" IS NOT NULL GROUP BY "${fieldName}"`, - }); - - response.rows.forEach(row => { - const value = caseInsensitive(row, fieldName)?.value; - - if (!isMultiField && !isValidTextChoiceValue(value)) return; - - const values: string[] = []; - if (isMultiField && Array.isArray(value)) { - values.push(...value); - hasMultiValue = hasMultiValue || value.length > 1; - } else { - values.push(value); - } - - const rowLocked = row.IsLocked.value === 1; - const rowCount = row.RowCount.value; - values.forEach(val => { - if (!useCount[val]) { - useCount[val] = { count: 0, locked: false }; - } - useCount[val].count += rowCount; - useCount[val].locked = useCount[val].locked || rowLocked; + response.rows.forEach(row => { + const value = caseInsensitive(row, fieldName)?.value; + const rowLocked = row.IsLocked.value === 1; + const rowCount = row.RowCount.value; + hasMultiValue = processTextChoiceRow(value, isMultiField, rowLocked, rowCount, useCount, hasMultiValue); }); - }); + } return { useCount, From 9748ff97eea798fc6df72b724b1d0012af4f4811 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 28 Jan 2026 12:57:49 -0800 Subject: [PATCH 19/19] merge from develop --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 8441e1edef..247bed0d4f 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.8", + "version": "7.14.0-fb-mvtc-convert.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.8", + "version": "7.14.0-fb-mvtc-convert.9", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index d7ec1bc189..6dd61c4cff 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.14.0-fb-mvtc-convert.8", + "version": "7.14.0-fb-mvtc-convert.9", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [