diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index de99c7c40f..7610e79326 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.10.0", + "version": "7.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.10.0", + "version": "7.11.0", "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 59a07417f8..84725424be 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.10.0", + "version": "7.11.0", "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 7b4add178e..cedc332aa0 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,12 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.11.0 +*Released*: 6 January 2026 +- GitHub Issue 73: Field editor Advanced Settings to allow for non-unique constraint / index + - update UI to allow for single field non-unique index and unique constraint via select dropdown + - update DomainField model to support nonUniqueConstraint in addition to uniqueConstraint + ### version 7.10.0 *Released*: 6 January 2026 - GridColumn: remove width, fixedWidth properties diff --git a/packages/components/src/internal/components/domainproperties/AdvancedSettings.test.tsx b/packages/components/src/internal/components/domainproperties/AdvancedSettings.test.tsx index e5b576fe62..a32c20ab08 100644 --- a/packages/components/src/internal/components/domainproperties/AdvancedSettings.test.tsx +++ b/packages/components/src/internal/components/domainproperties/AdvancedSettings.test.tsx @@ -7,6 +7,7 @@ import { createFormInputId } from './utils'; import { CALCULATED_CONCEPT_URI, DOMAIN_EDITABLE_DEFAULT, + DOMAIN_FIELD_CONSTRAINT, DOMAIN_FIELD_DEFAULT_VALUE_TYPE, DOMAIN_FIELD_DIMENSION, DOMAIN_FIELD_HIDDEN, @@ -128,9 +129,14 @@ describe('AdvancedSettings', () => { expect(recommendedVariable.getAttribute('checked')).toEqual(''); // Verify uniqueConstraint - id = createFormInputId(DOMAIN_FIELD_UNIQUECONSTRAINT, _domainIndex, _index); - const uniqueConstraint = document.querySelector('#' + id); - expect(uniqueConstraint.getAttribute('checked')).toEqual(''); + id = createFormInputId(DOMAIN_FIELD_CONSTRAINT, _domainIndex, _index); + const singleFieldIndex = document.querySelector('#' + id); + expect(singleFieldIndex.getAttribute('disabled')).toBeNull(); + let options = singleFieldIndex.querySelectorAll('option'); + expect(options).toHaveLength(3); + expect(options[0].textContent).toBe('No Index'); + expect(options[1].textContent).toBe('Index'); + expect(options[2].textContent).toBe('Index and require unique values'); // Verify default type id = createFormInputId(DOMAIN_FIELD_DEFAULT_VALUE_TYPE, _domainIndex, _index); @@ -148,8 +154,7 @@ describe('AdvancedSettings', () => { id = createFormInputId(DOMAIN_FIELD_PHI, _domainIndex, _index); const phi = document.querySelector('#' + id); expect(phi.getAttribute('disabled')).toBeNull(); - - const options = phi.querySelectorAll('option'); + options = phi.querySelectorAll('option'); expect(options).toHaveLength(3); expect(options[0].textContent).toBe('Not PHI'); expect(options[1].textContent).toBe('Limited PHI'); diff --git a/packages/components/src/internal/components/domainproperties/AdvancedSettings.tsx b/packages/components/src/internal/components/domainproperties/AdvancedSettings.tsx index 48ca9f30f7..b5c667c71d 100644 --- a/packages/components/src/internal/components/domainproperties/AdvancedSettings.tsx +++ b/packages/components/src/internal/components/domainproperties/AdvancedSettings.tsx @@ -25,12 +25,14 @@ import { DEFAULT_DOMAIN_FORM_DISPLAY_OPTIONS, DOMAIN_DEFAULT_TYPES, DOMAIN_EDITABLE_DEFAULT, + DOMAIN_FIELD_CONSTRAINT, DOMAIN_FIELD_DEFAULT_VALUE_TYPE, DOMAIN_FIELD_DIMENSION, DOMAIN_FIELD_EXCLUDE_FROM_SHIFTING, DOMAIN_FIELD_HIDDEN, DOMAIN_FIELD_MEASURE, DOMAIN_FIELD_MVENABLED, + DOMAIN_FIELD_NONUNIQUECONSTRAINT, DOMAIN_FIELD_PHI, DOMAIN_FIELD_RECOMMENDEDVARIABLE, DOMAIN_FIELD_SHOWNINDETAILSVIEW, @@ -67,6 +69,7 @@ interface AdvancedSettingsState { hidden?: boolean; measure?: boolean; mvEnabled?: boolean; + nonUniqueConstraint?: boolean; PHI?: string; phiLevels?: { label: string; value: string }[]; recommendedVariable?: boolean; @@ -117,6 +120,7 @@ export class AdvancedSettings extends React.PureComponent { + // only one of uniqueConstraint or nonUniqueConstraint can be true at a time + const value = evt.target.value; + if (value === DOMAIN_FIELD_UNIQUECONSTRAINT) { + this.setState({ + uniqueConstraint: true, + nonUniqueConstraint: false, + }); + } else if (value === DOMAIN_FIELD_NONUNIQUECONSTRAINT) { + this.setState({ + uniqueConstraint: false, + nonUniqueConstraint: true, + }); + } else { + this.setState({ + uniqueConstraint: false, + nonUniqueConstraint: false, + }); + } + }; + hasValidDomainId(): boolean { const { domainId } = this.props; return !(domainId === undefined || domainId === null || domainId === 0); @@ -217,6 +242,15 @@ export class AdvancedSettings extends React.PureComponent { + return ( +
+

Add a single-field database index for this field.

+

Optionally, also require all values to be unique for this field.

+
+ ); + }; + getDefaultTypeHelpText = () => { return (
@@ -364,10 +398,17 @@ export class AdvancedSettings extends React.PureComponent level.value === PHI) !== undefined; const disablePhiSelect = domainFormDisplayOptions.phiLevelDisabled || @@ -378,8 +419,8 @@ export class AdvancedSettings extends React.PureComponent
Miscellaneous Options
{!field.isCalculatedField() && ( -
-
+
+
@@ -403,7 +444,39 @@ export class AdvancedSettings extends React.PureComponent
-
+
+
+ )} + + {allowUniqueConstraintProperties && !field.isCalculatedField() && ( +
+
+ +
+
+ +
+
)} {field.dataType === DATETIME_TYPE && ( @@ -508,20 +581,6 @@ export class AdvancedSettings extends React.PureComponent )} - {allowUniqueConstraintProperties && !field.isCalculatedField() && ( - - Require all values to be unique - -
Add a unique constraint via a database-level index for this field.
-
-
- )} ); }; diff --git a/packages/components/src/internal/components/domainproperties/constants.ts b/packages/components/src/internal/components/domainproperties/constants.ts index c4f60ed49f..a4031d19b4 100644 --- a/packages/components/src/internal/components/domainproperties/constants.ts +++ b/packages/components/src/internal/components/domainproperties/constants.ts @@ -40,7 +40,9 @@ export const DOMAIN_FIELD_DIMENSION = 'dimension'; export const DOMAIN_FIELD_HIDDEN = 'hidden'; export const DOMAIN_FIELD_MVENABLED = 'mvEnabled'; export const DOMAIN_FIELD_PHI = 'PHI'; +export const DOMAIN_FIELD_CONSTRAINT = 'singleFieldConstraint'; export const DOMAIN_FIELD_UNIQUECONSTRAINT = 'uniqueConstraint'; +export const DOMAIN_FIELD_NONUNIQUECONSTRAINT = 'nonUniqueConstraint'; export const DOMAIN_FIELD_RECOMMENDEDVARIABLE = 'recommendedVariable'; export const DOMAIN_FIELD_SHOWNINDETAILSVIEW = 'shownInDetailsView'; export const DOMAIN_FIELD_SHOWNININSERTVIEW = 'shownInInsertView'; diff --git a/packages/components/src/internal/components/domainproperties/models.test.ts b/packages/components/src/internal/components/domainproperties/models.test.ts index aca1a45c00..7d2427bcd6 100644 --- a/packages/components/src/internal/components/domainproperties/models.test.ts +++ b/packages/components/src/internal/components/domainproperties/models.test.ts @@ -827,17 +827,17 @@ describe('DomainDesign', () => { { columnNames: ['a', 'b', 'c'], unique: true }, { columnNames: ['a'], unique: true }, { columnNames: ['b'], unique: false }, - { columnNames: ['c'], unique: true }, + { columnNames: ['c'], unique: true }, // should be omitted since 'c' is not a field ], }); const ddJson = DomainDesign.serialize(dd); expect(ddJson.indices.length).toBe(3); expect(ddJson.indices[0].columnNames).toStrictEqual(['a', 'b', 'c']); expect(ddJson.indices[0].unique).toBe(true); - expect(ddJson.indices[1].columnNames).toStrictEqual(['b']); - expect(ddJson.indices[1].unique).toBe(false); - expect(ddJson.indices[2].columnNames).toStrictEqual(['a']); - expect(ddJson.indices[2].unique).toBe(true); + expect(ddJson.indices[1].columnNames).toStrictEqual(['a']); + expect(ddJson.indices[1].unique).toBe(true); + expect(ddJson.indices[2].columnNames).toStrictEqual(['b']); + expect(ddJson.indices[2].unique).toBe(false); }); }); @@ -1211,8 +1211,26 @@ describe('DomainField', () => { List.of('A', 'b', 'd') ); expect(fields.get(0).uniqueConstraint).toBe(true); // field a + expect(fields.get(0).nonUniqueConstraint).toBe(false); // field a + expect(fields.get(1).uniqueConstraint).toBe(true); // field b + expect(fields.get(1).nonUniqueConstraint).toBe(false); // field b + expect(fields.get(2).uniqueConstraint).toBe(false); // field c + expect(fields.get(2).nonUniqueConstraint).toBe(false); // field c + }); + + test('nonUniqueConstraintFieldNames in fromJS', () => { + const fields = DomainField.fromJS( + [{ name: 'a' } as IDomainField, { name: 'b' } as IDomainField, { name: 'c' } as IDomainField], + undefined, + List.of('A', 'b', 'd'), + List.of('c', 'd') + ); + expect(fields.get(0).uniqueConstraint).toBe(true); // field a + expect(fields.get(0).nonUniqueConstraint).toBe(false); // field a expect(fields.get(1).uniqueConstraint).toBe(true); // field b + expect(fields.get(1).nonUniqueConstraint).toBe(false); // field b expect(fields.get(2).uniqueConstraint).toBe(false); // field c + expect(fields.get(2).nonUniqueConstraint).toBe(true); // field c }); // TODO add other test cases for DomainField.serialize code @@ -1228,17 +1246,13 @@ describe('DomainIndex', () => { expect(index.isSingleFieldUniqueConstraint()).toBe(false); }); - test('isMSSQLHashedSingleFieldUniqueConstraint', () => { - let index = DomainIndex.fromJS([{ columnNames: ['a'], unique: true } as IDomainIndex]).get(0); - expect(index.isMSSQLHashedSingleFieldUniqueConstraint()).toBe(false); - index = DomainIndex.fromJS([{ columnNames: ['a'], unique: false } as IDomainIndex]).get(0); - expect(index.isMSSQLHashedSingleFieldUniqueConstraint()).toBe(false); - index = DomainIndex.fromJS([{ columnNames: ['_hashed_a'], unique: true } as IDomainIndex]).get(0); - expect(index.isMSSQLHashedSingleFieldUniqueConstraint()).toBe(false); - index = DomainIndex.fromJS([{ columnNames: ['_hashed_a'], unique: false } as IDomainIndex]).get(0); - expect(index.isMSSQLHashedSingleFieldUniqueConstraint()).toBe(true); - index = DomainIndex.fromJS([{ columnNames: ['_hashed_a', 'b'], unique: false } as IDomainIndex]).get(0); - expect(index.isMSSQLHashedSingleFieldUniqueConstraint()).toBe(false); + test('isSingleFieldNonUniqueConstraint', () => { + let index = DomainIndex.fromJS([{ columnNames: ['a'], unique: false } as IDomainIndex]).get(0); + expect(index.isSingleFieldNonUniqueConstraint()).toBe(true); + index = DomainIndex.fromJS([{ columnNames: ['a'], unique: true } as IDomainIndex]).get(0); + expect(index.isSingleFieldNonUniqueConstraint()).toBe(false); + index = DomainIndex.fromJS([{ columnNames: ['a', 'b'], unique: false } as IDomainIndex]).get(0); + expect(index.isSingleFieldNonUniqueConstraint()).toBe(false); }); }); diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx index d3ba52477b..2085714ca2 100644 --- a/packages/components/src/internal/components/domainproperties/models.tsx +++ b/packages/components/src/internal/components/domainproperties/models.tsx @@ -238,6 +238,7 @@ export class DomainDesign let defaultValueOptions = List(); let mandatoryFieldNames = List(); let uniqueConstraintFieldNames = List(); + let nonUniqueConstraintFieldNames = List(); const domainException = DomainException.create(exception, exception ? exception.severity : undefined); @@ -253,19 +254,28 @@ export class DomainDesign .map(index => index.columns.get(0)) .toList(); - // Hack: SQL server uses a hashed field for unique constraints on text columns, see - // BaseMicrosoftSqlServerDialect.addCreateIndexStatements (where it talks about HASHBYTES) indices - .filter(index => index.isMSSQLHashedSingleFieldUniqueConstraint()) + .filter(index => index.isSingleFieldNonUniqueConstraint()) .forEach(index => { - uniqueConstraintFieldNames = uniqueConstraintFieldNames.push( - index.columns.get(0).replace('_hashed_', '') - ); + // Hack: SQL server uses a hashed field for unique constraints on text columns, see + // BaseMicrosoftSqlServerDialect.addCreateIndexStatements (where it talks about HASHBYTES) + if (index.columns.get(0).startsWith('_hashed_')) { + uniqueConstraintFieldNames = uniqueConstraintFieldNames.push( + index.columns.get(0).replace('_hashed_', '') + ); + } else { + nonUniqueConstraintFieldNames = nonUniqueConstraintFieldNames.push(index.columns.get(0)); + } }); } if (rawModel.fields) { - fields = DomainField.fromJS(rawModel.fields, mandatoryFieldNames, uniqueConstraintFieldNames); + fields = DomainField.fromJS( + rawModel.fields, + mandatoryFieldNames, + uniqueConstraintFieldNames, + nonUniqueConstraintFieldNames + ); } // allow calculated fields if the feature is enabled and the domain kind allows it, @@ -276,7 +286,8 @@ export class DomainDesign const calcFields = DomainField.fromJS( rawModel.calculatedFields, mandatoryFieldNames, - uniqueConstraintFieldNames + uniqueConstraintFieldNames, + nonUniqueConstraintFieldNames ); fields = fields.push(...calcFields.toArray()); } @@ -301,11 +312,10 @@ export class DomainDesign const json = dd.toJS(); // Issue 41677: allow for per-field unique constraints to be added via the field editor UI + // GitHub Issue 73: allow for per-field non-unique constraints to be added via the field editor UI json.indices = dd.indices - // filter out the single field unique indices, and keep the others - .filter( - index => !index.isSingleFieldUniqueConstraint() && !index.isMSSQLHashedSingleFieldUniqueConstraint() - ) + // filter out the single field indices, and keep the others + .filter(index => !index.isSingleFieldUniqueConstraint() && !index.isSingleFieldNonUniqueConstraint()) .map(index => DomainIndex.serialize(index)) .toArray(); // add in the new set of single field unique indices @@ -314,6 +324,10 @@ export class DomainDesign json.indices.push( DomainIndex.serialize(new DomainIndex({ columns: List.of(field.name?.trim()), type: 'unique' })) ); + } else if (field.nonUniqueConstraint) { + json.indices.push( + DomainIndex.serialize(new DomainIndex({ columns: List.of(field.name?.trim()), type: 'nonunique' })) + ); } }); @@ -606,8 +620,8 @@ export class DomainIndex return this.type === 'unique' && this.columns.size === 1; } - isMSSQLHashedSingleFieldUniqueConstraint(): boolean { - return this.type === 'nonunique' && this.columns.size === 1 && this.columns.get(0).startsWith('_hashed_'); + isSingleFieldNonUniqueConstraint(): boolean { + return this.type === 'nonunique' && this.columns.size === 1; } } @@ -899,6 +913,7 @@ export interface IDomainField { measure?: boolean; mvEnabled?: boolean; name: string; + nonUniqueConstraint?: boolean; original: Partial; PHI?: string; primaryKey?: boolean; @@ -989,6 +1004,7 @@ export class DomainField textChoiceValidator: undefined, recommendedVariable: false, uniqueConstraint: false, + nonUniqueConstraint: false, required: false, scale: MAX_TEXT_LENGTH, URL: undefined, @@ -1051,6 +1067,7 @@ export class DomainField declare textChoiceValidator?: PropertyValidator; declare recommendedVariable: boolean; declare uniqueConstraint: boolean; + declare nonUniqueConstraint: boolean; declare required?: boolean; declare scale?: number; declare scannable?: boolean; @@ -1154,14 +1171,18 @@ export class DomainField static fromJS( rawFields: IDomainField[], mandatoryFieldNames?: List, - uniqueConstraintFieldNames?: List + uniqueConstraintFieldNames?: List, + nonUniqueConstraintFieldNames?: List ): List { let fields = List(); const lowerUniqueConstraintFieldNames = uniqueConstraintFieldNames?.map(f => f.toLowerCase()).toArray(); + const lowerNonUniqueConstraintFieldNames = nonUniqueConstraintFieldNames?.map(f => f.toLowerCase()).toArray(); for (let i = 0; i < rawFields.length; i++) { const rawField = rawFields[i]; rawField.uniqueConstraint = lowerUniqueConstraintFieldNames?.indexOf(rawField.name?.toLowerCase()) > -1; + rawField.nonUniqueConstraint = + lowerNonUniqueConstraintFieldNames?.indexOf(rawField.name?.toLowerCase()) > -1; fields = fields.push(DomainField.create(rawField, undefined, mandatoryFieldNames)); } @@ -1288,6 +1309,7 @@ export class DomainField delete json.selected; delete json.lookupIsValid; delete json.uniqueConstraint; + delete json.nonUniqueConstraint; return json; }