diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 7c223a9bf4..a1ce5b503a 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.45.2", + "version": "7.45.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.45.2", + "version": "7.45.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 61c93c2765..219099fd91 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.45.2", + "version": "7.45.5", "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 a441459736..f10bc994a9 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,21 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.45.5 +*Released*: 7 July 2026 +- Export DetailDisplay +- ExpandableContainer: Fix style issues +- Remove .component-detail and .detail-components styles + - These have been moved to Biologics where they are used exclusively + +### version 7.45.4 +*Released*: 7 July 2026 +- GitHub Issue #1846: Add role-restriction option to API key dialog + +### version 7.45.3 +*Released*: 4 July 2026 +- GitHub Issue #1134: Support plating from find by samples + ### version 7.45.2 *Released*: 29 June 2026 - GitHub Issue #1023: Add redirect() helper that uses core-safeRedirect when necessary to check url before redirecting diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 399bfac781..d555cce754 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -309,7 +309,7 @@ import { ColorIcon } from './internal/components/base/ColorIcon'; import { QuerySelect } from './internal/components/forms/QuerySelect'; import { PageDetailHeader } from './internal/components/forms/PageDetailHeader'; import { DetailPanelHeader } from './internal/components/forms/detail/DetailPanelHeader'; -import { resolveDetailRenderer } from './internal/components/forms/detail/DetailDisplay'; +import { DetailDisplay, resolveDetailRenderer } from './internal/components/forms/detail/DetailDisplay'; import { useDataChangeCommentsRequired } from './internal/components/forms/input/useDataChangeCommentsRequired'; import { InputRenderContext, @@ -398,6 +398,7 @@ import { getSampleStatusFromSampleRow, getSampleStatusType, isAllSamplesSchema, + isFindBySampleSchema, isSampleOperationPermitted, isSamplesSchema, SamplesEditButtonSections, @@ -1237,6 +1238,7 @@ export { DERIVATION_DATA_SCOPES, DERIVATIVE_CREATION, DesignerDetailTooltip, + DetailDisplay, DetailPanel, DetailPanelHeader, DetailPanelWithModel, @@ -1488,6 +1490,7 @@ export { isCtrlOrMetaKey, isDateBetween, isDateTimeInPast, + isFindBySampleSchema, isImage, isInteger, isIntegerInRange, diff --git a/packages/components/src/internal/UnidentifiedPill.tsx b/packages/components/src/internal/UnidentifiedPill.tsx index e634b8c0e8..87ba8a6de9 100644 --- a/packages/components/src/internal/UnidentifiedPill.tsx +++ b/packages/components/src/internal/UnidentifiedPill.tsx @@ -7,7 +7,12 @@ import { createPortal } from 'react-dom'; import { generateId } from './util/utils'; import { useOverlayTriggerState } from './OverlayTrigger'; import { Popover } from './Popover'; -import { EMPTY_COMPOUND_WARNING, EMPTY_NS_SEQUENCE_WARNING, EMPTY_PS_SEQUENCE_WARNING } from './constants'; +import { + EMPTY_COMPOUND_WARNING, + EMPTY_NS_SEQUENCE_WARNING, + EMPTY_PS_SEQUENCE_WARNING, + UNIDENTIFIED_MOLECULE_WARNING, +} from './constants'; import { SchemaQuery } from '../public/SchemaQuery'; import { SCHEMAS } from './schemas'; @@ -18,6 +23,8 @@ function getPopoverMessage(schemaQuery: SchemaQuery): string | undefined { return EMPTY_NS_SEQUENCE_WARNING; } else if (schemaQuery.isEqual(SCHEMAS.DATA_CLASSES.COMPOUND, false)) { return EMPTY_COMPOUND_WARNING; + } else if (schemaQuery.isEqual(SCHEMAS.DATA_CLASSES.MOLECULE, false)) { + return UNIDENTIFIED_MOLECULE_WARNING; } return undefined; diff --git a/packages/components/src/internal/components/chart/TrendlineOption.tsx b/packages/components/src/internal/components/chart/TrendlineOption.tsx index deeabbb6f2..79d4d3ef85 100644 --- a/packages/components/src/internal/components/chart/TrendlineOption.tsx +++ b/packages/components/src/internal/components/chart/TrendlineOption.tsx @@ -113,10 +113,10 @@ export const TrendlineOption: FC = memo(props => { ); const trendlineOptions = useMemo(() => { - return TRENDLINE_OPTIONS.filter(option => { - return !option.schemaPrefix || schemaQuery.schemaName.startsWith(option.schemaPrefix); - }); - }, [TRENDLINE_OPTIONS, schemaQuery.schemaName]); + return TRENDLINE_OPTIONS.filter( + option => !option.schemaPrefix || schemaQuery.schemaStartsWith(option.schemaPrefix) + ); + }, [TRENDLINE_OPTIONS, schemaQuery]); const onAsymptoteTypeChange = useCallback( (selected: string) => { diff --git a/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx b/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx index 680f27b481..f26b144d5c 100644 --- a/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx +++ b/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx @@ -214,7 +214,7 @@ export const DetailDisplay: FC = memo(props => { // key safety const newRow = row.reduce((newRow, value, key) => { return newRow.set(key.toLowerCase(), value); - }, OrderedMap()); + }, OrderedMap()); return ( diff --git a/packages/components/src/internal/components/forms/detail/DetailPanelHeader.tsx b/packages/components/src/internal/components/forms/detail/DetailPanelHeader.tsx index 26962e6e3d..06174d06aa 100644 --- a/packages/components/src/internal/components/forms/detail/DetailPanelHeader.tsx +++ b/packages/components/src/internal/components/forms/detail/DetailPanelHeader.tsx @@ -40,12 +40,9 @@ export const DetailPanelHeader: FC = memo(props => { {title} {isEditable && ( - <> - -
- + )} diff --git a/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx b/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx index 74be0efa82..406cd9ec6b 100644 --- a/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx +++ b/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx @@ -19,7 +19,7 @@ export interface DetailHeaderProps extends PropsWithChildren { export const DetailHeader: FC = memo(({ children, header, iconSrc }) => (
- +
diff --git a/packages/components/src/internal/components/samples/utils.test.ts b/packages/components/src/internal/components/samples/utils.test.ts index 5f4d56cb8f..5cc4d792a0 100644 --- a/packages/components/src/internal/components/samples/utils.test.ts +++ b/packages/components/src/internal/components/samples/utils.test.ts @@ -31,10 +31,15 @@ import { getSampleStatusFromSampleRow, getSampleStatusLockedMessage, getSampleStatusType, + isAllSamplesSchema, + isFindBySampleSchema, isSampleOperationPermitted, isSamplesSchema, + isWorkflowInputSamplesSchema, } from './utils'; import { SampleState, SampleStateType } from './models'; +import { TEST_LK_LIMS_MODULE_CONTEXT } from '../../productFixtures'; +import { ViewInfo } from '../../ViewInfo'; const CHECKED_OUT_BY_FIELD = SCHEMAS.INVENTORY.CHECKED_OUT_BY_FIELD; const INVENTORY_COLS = SCHEMAS.INVENTORY.INVENTORY_COLS; @@ -69,13 +74,13 @@ describe('isSampleOperationPermitted', () => { }); test('enabled, no status provided', () => { - const moduleContext = { api: { moduleNames: ['samplemanagement'] } }; + const moduleContext = TEST_LK_LIMS_MODULE_CONTEXT; expect(isSampleOperationPermitted(undefined, SampleOperation.EditMetadata, moduleContext)).toBeTruthy(); expect(isSampleOperationPermitted(null, SampleOperation.EditLineage, moduleContext)).toBeTruthy(); }); test('enabled, with status type provided', () => { - const moduleContext = { api: { moduleNames: ['samplemanagement'] } }; + const moduleContext = TEST_LK_LIMS_MODULE_CONTEXT; expect( isSampleOperationPermitted(SampleStateType.Locked, SampleOperation.EditMetadata, moduleContext) ).toBeFalsy(); @@ -96,16 +101,16 @@ describe('isSampleOperationPermitted', () => { describe('getFilterForSampleOperation', () => { test('status not enabled', () => { - expect(getFilterForSampleOperation(SampleOperation.EditMetadata, true, {})).toBeNull(); + expect(getFilterForSampleOperation(SampleOperation.EditMetadata, true, {})).toBeUndefined(); }); test('enabled, all allowed', () => { - const moduleContext = { api: { moduleNames: ['samplemanagement'] } }; - expect(getFilterForSampleOperation(SampleOperation.AddToPicklist, true, moduleContext)).toBeNull(); + const moduleContext = TEST_LK_LIMS_MODULE_CONTEXT; + expect(getFilterForSampleOperation(SampleOperation.AddToPicklist, true, moduleContext)).toBeUndefined(); }); test('enabled, some status does not allow', () => { - const moduleContext = { api: { moduleNames: ['samplemanagement'] } }; + const moduleContext = TEST_LK_LIMS_MODULE_CONTEXT; expect(getFilterForSampleOperation(SampleOperation.EditLineage, true, moduleContext)).toStrictEqual( Filter.create(SAMPLE_STATE_TYPE_COLUMN_NAME, [SampleStateType.Locked], Filter.Types.NOT_IN) ); @@ -308,6 +313,52 @@ describe('getOperationNotPermittedMessage', () => { }); }); +describe('isAllSamplesSchema', () => { + test('invalid values', () => { + expect(isAllSamplesSchema(undefined)).toBeFalsy(); + expect(isAllSamplesSchema(null)).toBeFalsy(); + expect(isAllSamplesSchema(SCHEMAS.EXP_TABLES.DATA)).toBeFalsy(); + }); + test('exp.materials query', () => { + const sq = SCHEMAS.EXP_TABLES.MATERIALS; + const details = new SchemaQuery(sq.schemaName, sq.queryName, ViewInfo.DETAIL_NAME); + const otherView = new SchemaQuery(sq.schemaName.toUpperCase(), sq.queryName.toUpperCase(), 'otherView'); + expect(isAllSamplesSchema(sq)).toBeTruthy(); + expect(isAllSamplesSchema(details)).toBeTruthy(); + expect(isAllSamplesSchema(otherView)).toBeTruthy(); + }); + test('other supported schemas', () => { + expect(isAllSamplesSchema(SCHEMAS.SAMPLE_MANAGEMENT.SOURCE_SAMPLES)).toBeTruthy(); + expect(isAllSamplesSchema(SCHEMAS.SAMPLE_MANAGEMENT.SAMPLE_STATUS_COUNTS)).toBeFalsy(); + expect(new SchemaQuery(SCHEMAS.EXP_TABLES.SCHEMA, 'exp_temp_123')).toBeTruthy(); + expect(isAllSamplesSchema(SCHEMAS.WORKFLOW.JOB_INPUT_SAMPLES)).toBeTruthy(); + }); +}); + +describe('isFindBySampleSchema', () => { + const expSQ = (queryName: string, viewName?: string): SchemaQuery => + new SchemaQuery(SCHEMAS.EXP_TABLES.SCHEMA, queryName, viewName); + + test('invalid values', () => { + expect(isFindBySampleSchema(undefined)).toBeFalsy(); + expect(isFindBySampleSchema(null)).toBeFalsy(); + }); + test('other experiment queries', () => { + expect(isWorkflowInputSamplesSchema(SCHEMAS.EXP_TABLES.DATA)).toBeFalsy(); + expect(isWorkflowInputSamplesSchema(SCHEMAS.EXP_TABLES.MATERIALS)).toBeFalsy(); + }); + test('expected query prefix', () => { + const prefix = 'exp_temp_'; + expect(isFindBySampleSchema(expSQ(prefix))).toBeTruthy(); + expect(isFindBySampleSchema(expSQ(prefix.toUpperCase()))).toBeTruthy(); + expect(isFindBySampleSchema(expSQ(`${prefix}Hello`))).toBeTruthy(); + expect(isFindBySampleSchema(expSQ(`${prefix}${prefix}`))).toBeTruthy(); + expect(isFindBySampleSchema(expSQ(`${prefix}${prefix}`))).toBeTruthy(); + expect(isFindBySampleSchema(expSQ(`${prefix}`, ViewInfo.DETAIL_NAME))).toBeTruthy(); + expect(isFindBySampleSchema(expSQ(`_${prefix}`))).toBeFalsy(); + }); +}); + describe('isSamplesSchema', () => { test('not sample schema', () => { expect(isSamplesSchema(SCHEMAS.EXP_TABLES.DATA)).toBeFalsy(); @@ -331,6 +382,25 @@ describe('isSamplesSchema', () => { }); }); +describe('isWorkflowInputSamplesSchema', () => { + test('invalid values', () => { + expect(isWorkflowInputSamplesSchema(undefined)).toBeFalsy(); + expect(isWorkflowInputSamplesSchema(null)).toBeFalsy(); + }); + test('other workflow queries', () => { + expect(isWorkflowInputSamplesSchema(SCHEMAS.WORKFLOW.JOB)).toBeFalsy(); + expect(isWorkflowInputSamplesSchema(SCHEMAS.WORKFLOW.JOB_PRIORITY)).toBeFalsy(); + }); + test('job input samples', () => { + const sq = SCHEMAS.WORKFLOW.JOB_INPUT_SAMPLES; + const details = new SchemaQuery(sq.schemaName, sq.queryName, ViewInfo.DETAIL_NAME); + const otherView = new SchemaQuery(sq.schemaName.toUpperCase(), sq.queryName.toUpperCase(), 'otherView'); + expect(isWorkflowInputSamplesSchema(sq)).toBeTruthy(); + expect(isWorkflowInputSamplesSchema(details)).toBeTruthy(); + expect(isWorkflowInputSamplesSchema(otherView)).toBeTruthy(); + }); +}); + describe('getSampleStatusType', () => { test('no data', () => { expect(getSampleStatusType(undefined)).toBeUndefined(); @@ -356,14 +426,19 @@ describe('getSampleStatusType', () => { describe('getSampleStatusFromSampleRow', () => { test('label', () => { expect(getSampleStatusFromSampleRow({}).label).toBeUndefined(); - expect(getSampleStatusFromSampleRow({ SampleState: { displayValue: undefined } }).label).toBeUndefined(); - expect(getSampleStatusFromSampleRow({ SampleState: { displayValue: 'Label1' } }).label).toBe('Label1'); expect( - getSampleStatusFromSampleRow({ 'SampleID/SampleState': { displayValue: undefined } }).label + getSampleStatusFromSampleRow({ SampleState: { displayValue: undefined, value: undefined } }).label ).toBeUndefined(); - expect(getSampleStatusFromSampleRow({ 'SampleID/SampleState': { displayValue: 'Label2' } }).label).toBe( - 'Label2' + expect(getSampleStatusFromSampleRow({ SampleState: { displayValue: 'Label1', value: undefined } }).label).toBe( + 'Label1' ); + expect( + getSampleStatusFromSampleRow({ 'SampleID/SampleState': { displayValue: undefined, value: undefined } }) + .label + ).toBeUndefined(); + expect( + getSampleStatusFromSampleRow({ 'SampleID/SampleState': { displayValue: 'Label2', value: undefined } }).label + ).toBe('Label2'); }); test('description', () => { @@ -371,7 +446,9 @@ describe('getSampleStatusFromSampleRow', () => { expect( getSampleStatusFromSampleRow({ 'SampleState/Description': { value: undefined } }).description ).toBeUndefined(); - expect(getSampleStatusFromSampleRow({ 'SampleState/Description': { value: 'Desc1' } }).description).toBe('Desc1'); + expect(getSampleStatusFromSampleRow({ 'SampleState/Description': { value: 'Desc1' } }).description).toBe( + 'Desc1' + ); expect( getSampleStatusFromSampleRow({ 'SampleID/SampleState/Description': { value: undefined } }).description ).toBeUndefined(); @@ -384,8 +461,8 @@ describe('getSampleStatusFromSampleRow', () => { describe('getSampleStatus', () => { test('label', () => { expect(getSampleStatus({}).label).toBeUndefined(); - expect(getSampleStatus({ Label: { displayValue: undefined } }).label).toBeUndefined(); - expect(getSampleStatus({ Label: { displayValue: 'Label3' } }).label).toBeUndefined(); + expect(getSampleStatus({ Label: { displayValue: undefined, value: undefined } }).label).toBeUndefined(); + expect(getSampleStatus({ Label: { displayValue: 'Label3', value: undefined } }).label).toBeUndefined(); expect(getSampleStatus({ Label: { value: 'Label3' } }).label).toBe('Label3'); }); diff --git a/packages/components/src/internal/components/samples/utils.tsx b/packages/components/src/internal/components/samples/utils.tsx index 04e7811526..504468f28d 100644 --- a/packages/components/src/internal/components/samples/utils.tsx +++ b/packages/components/src/internal/components/samples/utils.tsx @@ -35,6 +35,7 @@ import { } from './constants'; import { SampleState, SampleStateType, SampleStatus } from './models'; +import { Row } from '../../query/selectRows'; export function getOmittedSampleTypeColumns(user: User, moduleContext?: ModuleContext): string[] { let cols: string[] = []; @@ -63,7 +64,7 @@ export function isSampleOperationPermitted( return permittedOps[sampleStatusType].has(operation); } -export function getSampleStatusType(row: any): SampleStateType { +export function getSampleStatusType(row: Row): SampleStateType { return ( caseInsensitive(row, SAMPLE_STATE_TYPE_COLUMN_NAME)?.value || caseInsensitive(row, 'SampleID/' + SAMPLE_STATE_TYPE_COLUMN_NAME)?.value || @@ -88,9 +89,9 @@ export function getSampleStatusColor(color: string, stateType: SampleStateType | } } -export function getSampleStatusFromSampleRow(row: any): SampleStatus { - let label; - let rowId; +export function getSampleStatusFromSampleRow(row: Row): SampleStatus { + let label: string; + let rowId: number; // Issue 45269. If the state columns are present, don't look at a column named 'Label' let field = caseInsensitive(row, SAMPLE_STATE_COLUMN_NAME); if (field) { @@ -103,7 +104,7 @@ export function getSampleStatusFromSampleRow(row: any): SampleStatus { label = field.displayValue; } } - let color; + let color: string; let col = caseInsensitive(row, SAMPLE_STATE_COLOR_COLUMN_NAME); if (col) { color = col.value; @@ -113,7 +114,7 @@ export function getSampleStatusFromSampleRow(row: any): SampleStatus { color = col.value; } } - let description; + let description: string; col = caseInsensitive(row, SAMPLE_STATE_DESCRIPTION_COLUMN_NAME); if (col) { description = col.value; @@ -132,7 +133,7 @@ export function getSampleStatusFromSampleRow(row: any): SampleStatus { }; } -export function getSampleStatus(row: any): SampleStatus { +export function getSampleStatus(row: Row): SampleStatus { return { label: caseInsensitive(row, 'Label')?.value, rowId: caseInsensitive(row, 'RowId')?.value, @@ -146,20 +147,16 @@ export function getFilterForSampleOperation( operation: SampleOperation, allowed = true, moduleContext?: ModuleContext -): Filter.IFilter { - if (!isSampleStatusEnabled(moduleContext)) { - return null; - } +): Filter.IFilter | undefined { + if (!isSampleStatusEnabled(moduleContext)) return undefined; - const typesNotAllowed = []; + const typesNotAllowed: string[] = []; for (const stateType in SampleStateType) { if (!permittedOps[stateType].has(operation)) { typesNotAllowed.push(stateType); } } - if (typesNotAllowed.length === 0) { - return null; - } + if (typesNotAllowed.length === 0) return undefined; const filterType = allowed ? Filter.Types.NOT_IN : Filter.Types.IN; return Filter.create(SAMPLE_STATE_TYPE_COLUMN_NAME, typesNotAllowed, filterType); @@ -170,7 +167,7 @@ function getOperationMessageAndRecommendation(operation: SampleOperation, numSam return operationRestrictionMessage[operation].all; } else { const messageInfo = operationRestrictionMessage[operation]; - let message; + let message: string; if (numSamples === 1) { message = operationRestrictionMessage[operation].singular; } else { @@ -265,52 +262,38 @@ export enum SamplesEditButtonSections { MOVE_TO_FOLDER = 'movetofolder', } -export function isSamplesSchema(schemaQuery: SchemaQuery): boolean { - const lcSchemaName = schemaQuery?.schemaName?.toLowerCase(); - if (lcSchemaName === SCHEMAS.SAMPLE_SETS.SCHEMA) return true; +export function isFindBySampleSchema(schemaQuery: SchemaQuery): boolean { + return schemaQuery?.hasSchema(SCHEMAS.EXP_TABLES.SCHEMA) && schemaQuery.queryStartsWith('exp_temp_'); +} - return isAllSamplesSchema(schemaQuery); +export function isSamplesSchema(schemaQuery: SchemaQuery): boolean { + return schemaQuery?.hasSchema(SCHEMAS.SAMPLE_SETS.SCHEMA) || isAllSamplesSchema(schemaQuery); } export function isWorkflowInputSamplesSchema(schemaQuery: SchemaQuery): boolean { - const lcSchemaName = schemaQuery?.schemaName?.toLowerCase(); - const lcQueryName = schemaQuery?.queryName?.toLowerCase(); - return ( - lcSchemaName === SCHEMAS.WORKFLOW.SCHEMA && - lcQueryName === SCHEMAS.WORKFLOW.JOB_INPUT_SAMPLES.queryName.toLowerCase() - ); + return SCHEMAS.WORKFLOW.JOB_INPUT_SAMPLES.isEqual(schemaQuery, false); } export function isAllSamplesSchema(schemaQuery: SchemaQuery): boolean { - const lcSchemaName = schemaQuery?.schemaName?.toLowerCase(); - const lcQueryName = schemaQuery?.queryName?.toLowerCase(); - if ( - lcSchemaName === SCHEMAS.EXP_TABLES.SCHEMA && - lcQueryName === SCHEMAS.EXP_TABLES.MATERIALS.queryName.toLowerCase() - ) - return true; - - if ( - lcSchemaName === SCHEMAS.EXP_TABLES.SCHEMA && - lcQueryName.startsWith('exp_temp_') // Find Sample by ID/Barcode - ) - return true; - - if (lcSchemaName === SCHEMAS.SAMPLE_MANAGEMENT.SCHEMA) { - return lcQueryName === SCHEMAS.SAMPLE_MANAGEMENT.SOURCE_SAMPLES.queryName.toLowerCase(); + if (!schemaQuery) return false; + if (SCHEMAS.EXP_TABLES.MATERIALS.isEqual(schemaQuery, false)) return true; + if (isFindBySampleSchema(schemaQuery)) return true; + + if (schemaQuery.hasSchema(SCHEMAS.SAMPLE_MANAGEMENT.SCHEMA)) { + return SCHEMAS.SAMPLE_MANAGEMENT.SOURCE_SAMPLES.isEqual(schemaQuery, false); } + return isWorkflowInputSamplesSchema(schemaQuery); } - export function getSampleDomainDefaultSystemFields(moduleContext?: ModuleContext): SystemField[] { return isFreezerManagementEnabled(moduleContext) ? SAMPLE_DOMAIN_DEFAULT_SYSTEM_FIELDS.concat(SAMPLE_DOMAIN_INVENTORY_SYSTEM_FIELDS) : SAMPLE_DOMAIN_DEFAULT_SYSTEM_FIELDS; } -export function getSampleStatusLockedMessage(state: SampleState, saving: boolean): string { - const msgs = []; +export function getSampleStatusLockedMessage(state: SampleState, saving: boolean): string | undefined { + const msgs: string[] = []; if (state?.inUse || saving) msgs.push('cannot change status type or be deleted because it is in use'); if (state && !state.isLocal) msgs.push('can be changed only in the ' + state.containerPath.substring(1) + ' folder'); @@ -333,7 +316,7 @@ export function getSampleStatusContainerFilter( return Query.ContainerFilter.currentAndSubfoldersPlusShared; } - // When requesting data from a sub-folder context the ContainerFilter filters + // When requesting data from a subfolder context, the ContainerFilter filters // "up" the folder hierarchy for data. return Query.ContainerFilter.currentPlusProjectAndShared; } diff --git a/packages/components/src/internal/components/security/APIWrapper.ts b/packages/components/src/internal/components/security/APIWrapper.ts index da3acbc47a..f75757f191 100644 --- a/packages/components/src/internal/components/security/APIWrapper.ts +++ b/packages/components/src/internal/components/security/APIWrapper.ts @@ -56,6 +56,11 @@ export interface RemoveGroupMembersResponse { removed: number[]; } +export interface ApiKeyRole { + displayName: string; + uniqueName: string; +} + export interface AuthenticationConfiguration { description: string; reauthUrl: string; @@ -68,7 +73,8 @@ interface AuthenticationConfigurationResponse { export interface SecurityAPIWrapper { addGroupMembers: (groupId: number, principalIds: number[], projectPath: string) => Promise; - createApiKey: (type?: string, description?: string) => Promise; + createApiKey: (type?: string, description?: string, role?: string) => Promise; + getApiKeyRoles: () => Promise; createGroup: (groupName: string, projectPath: string) => Promise; deleteApiKeys: (selections: Set) => Promise; deleteContainer: (options: DeleteContainerOptions) => Promise>; @@ -133,17 +139,24 @@ export class ServerSecurityAPIWrapper implements SecurityAPIWrapper { }); }; - createApiKey = async (type = 'apikey', description?: string): Promise => { + createApiKey = async (type = 'apikey', description?: string, role?: string): Promise => { const response = await request<{ apikey: string }>({ url: ActionURL.buildURL('security', 'createApiKey.api'), method: 'POST', - jsonData: { type, description }, + jsonData: { type, description, role }, errorLogMsg: 'Problem generating the apiKey for this user.', }); return response.apikey; }; + getApiKeyRoles = (): Promise => { + return request({ + url: ActionURL.buildURL('security', 'getApiKeyRoles.api'), + errorLogMsg: 'Failed to load API key roles.', + }); + }; + deleteApiKeys(selections: Set): Promise { const rows = []; selections.forEach(selection => { @@ -453,6 +466,7 @@ export function getSecurityTestAPIWrapper( return { addGroupMembers: mockFn(), createApiKey: mockFn(), + getApiKeyRoles: mockFn(), deleteApiKeys: mockFn(), createGroup: mockFn(), deleteContainer: mockFn(), diff --git a/packages/components/src/internal/components/user/APIKeysPanel.test.tsx b/packages/components/src/internal/components/user/APIKeysPanel.test.tsx index 6f354d851d..58709d20fc 100644 --- a/packages/components/src/internal/components/user/APIKeysPanel.test.tsx +++ b/packages/components/src/internal/components/user/APIKeysPanel.test.tsx @@ -80,6 +80,7 @@ describe('KeyGeneratorModal', () => { api: { security: { createApiKey: apiKeyFn, + getApiKeyRoles: jest.fn().mockResolvedValue([]), }, }, }, diff --git a/packages/components/src/internal/components/user/APIKeysPanel.tsx b/packages/components/src/internal/components/user/APIKeysPanel.tsx index 776d129c64..67469a4582 100644 --- a/packages/components/src/internal/components/user/APIKeysPanel.tsx +++ b/packages/components/src/internal/components/user/APIKeysPanel.tsx @@ -31,6 +31,7 @@ import { GridPanel } from '../../../public/QueryModel/GridPanel'; import { Modal } from '../../Modal'; import { getHelpLink, HelpLink } from '../../util/helpLinks'; import { biologicsIsPrimaryApp } from '../../app/products'; +import { ApiKeyRole } from '../security/APIWrapper'; const API_KEYS_QUERY_HREF = ActionURL.buildURL('query', 'executeQuery.view', '/', { schemaName: 'core', @@ -101,19 +102,21 @@ interface ModalProps extends KeyGeneratorProps { export const KeyGeneratorModal: FC = props => { const { type, afterCreate, noun, onClose } = props; const [description, setDescription] = useState(); + const [role, setRole] = useState(); + const [roles, setRoles] = useState([]); const { api } = useAppContext(); const [error, setError] = useState(false); const [keyValue, setKeyValue] = useState(undefined); const onGenerateKey = useCallback(async () => { try { - const key = await api.security.createApiKey(type, description); + const key = await api.security.createApiKey(type, description, role); setKeyValue(key); afterCreate?.(); } catch (e) { setError(true); } - }, [api.security, type, afterCreate, description]); + }, [api.security, type, afterCreate, description, role]); useEffect(() => { (async () => { @@ -123,6 +126,17 @@ export const KeyGeneratorModal: FC = props => { })(); }, [type, onGenerateKey]); + useEffect(() => { + if (type !== 'apikey') return; + (async () => { + try { + setRoles(await api.security.getApiKeyRoles()); + } catch (e) { + console.error(e); + } + })(); + }, [api.security, type]); + const onCopyKey = useCallback(() => { const handleCopy = (event: ClipboardEvent): void => { setCopyValue(event, keyValue); @@ -137,6 +151,10 @@ export const KeyGeneratorModal: FC = props => { setDescription(event.target.value); }, []); + const changeRole = useCallback((event: ChangeEvent) => { + setRole(event.target.value || undefined); + }, []); + return ( = props => { autoFocus placeholder="Enter description of key usage (optional)" /> + {roles.length > 0 && ( +
+ + +
+ )}
)} {!!keyValue && ( @@ -342,11 +378,12 @@ export const APIKeysPanel: FC = props => {
API Keys

- API keys are used to authorize client code using one of the{' '} + API keys are used to authorize desktop AI agents using LabKey's MCP and client code using one of the{' '} LabKey Client APIs. API keys are appropriate for authenticating ad hoc interactions within statistical tools (e.g., R, RStudio, SAS) or programming languages (e.g., Java, Python), as well as authenticating API use from automated scripts. A valid API key provides - complete access to your data and actions, so it should be kept secret. + access to your data and actions, so it should be kept secret. We recommend restricting API keys to a + specific security role at creation time, imposing appropriate limits on AI agents and scripts.

{apiEnabled && ( diff --git a/packages/components/src/internal/constants.ts b/packages/components/src/internal/constants.ts index ab9816c41a..f9f20a4306 100644 --- a/packages/components/src/internal/constants.ts +++ b/packages/components/src/internal/constants.ts @@ -256,3 +256,5 @@ export const EMPTY_PS_SEQUENCE_WARNING = 'No sequence added. The structure format and physical properties of molecules using this sequence cannot be calculated.'; export const EMPTY_COMPOUND_WARNING = 'Without SMILES, Molecule component translation cannot be done automatically, and the system cannot prevent duplicates.'; +export const UNIDENTIFIED_MOLECULE_WARNING = + 'Components not added or are unidentified. The structure format and physical properties cannot be calculated.'; diff --git a/packages/components/src/internal/productFixtures.ts b/packages/components/src/internal/productFixtures.ts index 0b13f59cf8..5f6adecb83 100644 --- a/packages/components/src/internal/productFixtures.ts +++ b/packages/components/src/internal/productFixtures.ts @@ -33,7 +33,7 @@ export const TEST_LK_LIMS_MODULE_CONTEXT = { ProductFeature.AdvancedWorkflow, ], primaryApplicationId: LIMS_PRODUCT_ID, - } + }, }; export const TEST_LKSM_PROFESSIONAL_MODULE_CONTEXT = { @@ -57,7 +57,7 @@ export const TEST_LKSM_PROFESSIONAL_MODULE_CONTEXT = { ProductFeature.CustomImportTemplates, ], primaryApplicationId: SAMPLE_MANAGER_PRODUCT_ID, - } + }, }; export const TEST_LKSM_STARTER_MODULE_CONTEXT = { @@ -109,6 +109,9 @@ export const TEST_LKS_STARTER_MODULE_CONTEXT = { }; export const TEST_BIO_LIMS_STARTER_MODULE_CONTEXT = { + api: { + moduleNames: ['assay', 'biologics', 'inventory', 'premium', 'samplemanagement'], + }, biologics: { productId: BIOLOGICS_APP_PROPERTIES.productId, }, @@ -137,6 +140,9 @@ export const TEST_BIO_LIMS_STARTER_MODULE_CONTEXT = { }; export const TEST_BIO_LIMS_ENTERPRISE_MODULE_CONTEXT = { + api: { + moduleNames: ['assay', 'biologics', 'inventory', 'premium', 'samplemanagement'], + }, biologics: { productId: BIOLOGICS_APP_PROPERTIES.productId, }, diff --git a/packages/components/src/internal/query/api.ts b/packages/components/src/internal/query/api.ts index d325671163..69195d5302 100644 --- a/packages/components/src/internal/query/api.ts +++ b/packages/components/src/internal/query/api.ts @@ -18,7 +18,7 @@ import { isProjectContainer, } from '../app/utils'; -import { caseInsensitive, quoteValueWithDelimiters } from '../util/utils'; +import { caseInsensitive } from '../util/utils'; import { QueryInfo, QueryInfoStatus } from '../../public/QueryInfo'; import { QueryColumn, QueryLookup } from '../../public/QueryColumn'; import { ViewInfo, ViewInfoJson } from '../ViewInfo'; diff --git a/packages/components/src/public/QueryModel/DetailPanel.tsx b/packages/components/src/public/QueryModel/DetailPanel.tsx index a6bbb284f5..dd65711dab 100644 --- a/packages/components/src/public/QueryModel/DetailPanel.tsx +++ b/packages/components/src/public/QueryModel/DetailPanel.tsx @@ -46,10 +46,12 @@ export const DetailPanel: FC = memo(props => { return ; }); +DetailPanel.displayName = 'DetailPanel'; const DetailPanelWithModelBodyImpl: FC = memo(({ queryModels, ...rest }) => { return ; }); +DetailPanelWithModelBodyImpl.displayName = 'DetailPanelWithModelBodyImpl'; const DetailPanelWithModelBody = withQueryModels(DetailPanelWithModelBodyImpl); @@ -63,7 +65,8 @@ export const DetailPanelWithModel: FC = memo(props => const { keyValue, schemaQuery } = queryConfig; const { schemaName, queryName } = schemaQuery; // Key is used here to ensure we re-mount the DetailPanel when the queryConfig changes - const key = useMemo(() => `${schemaName}.${queryName}.${keyValue}`, [schemaQuery, keyValue]); + const key = useMemo(() => `${schemaName}.${queryName}.${keyValue}`, [schemaName, queryName, keyValue]); return ; }); +DetailPanelWithModel.displayName = 'DetailPanelWithModel'; diff --git a/packages/components/src/public/SchemaQuery.test.ts b/packages/components/src/public/SchemaQuery.test.ts index 4674353b31..0c04a0ccce 100644 --- a/packages/components/src/public/SchemaQuery.test.ts +++ b/packages/components/src/public/SchemaQuery.test.ts @@ -4,93 +4,329 @@ */ import { getSchemaQuery, resolveKey, resolveKeyFromJson, SchemaQuery } from './SchemaQuery'; -describe('getSchemaQuery', () => { - test('no decoding required, no view', () => { - expect(getSchemaQuery('name/query')).toEqual(new SchemaQuery('name', 'query')); - expect(getSchemaQuery('name/query/view')).toEqual(new SchemaQuery('name', 'query', 'view')); - }); +describe('SchemaQuery', () => { + const UNDEF_SQ = new SchemaQuery(undefined, undefined); + const SQ = new SchemaQuery('schema.subschema', 'query'); - test('no decoding required, with view', () => {}); + describe('getSchemaQuery', () => { + test('no decoding required, no view', () => { + expect(getSchemaQuery('name/query')).toEqual(new SchemaQuery('name', 'query')); + expect(getSchemaQuery('name/query/view')).toEqual(new SchemaQuery('name', 'query', 'view')); + }); - test('decoding required', () => { - expect(getSchemaQuery('my$Sname/just$pask')).toEqual(new SchemaQuery('my/name', 'just.ask')); - expect(getSchemaQuery('one$ptwo$pthree$d/q1')).toEqual(new SchemaQuery('one.two.three$', 'q1')); - expect(getSchemaQuery('one$ptwo$pthree$d/q1/view$s2$d')).toEqual( - new SchemaQuery('one.two.three$', 'q1', 'view/2$') - ); - }); -}); + test('no decoding required, with view', () => {}); -describe('resolveKey', () => { - test('no encodings', () => { - expect(resolveKey('schema', 'query')).toBe('schema/query'); - expect(resolveKey('Schema', 'Query')).toBe('schema/query'); - expect(resolveKey('ScheMa', 'QueRy')).toBe('schema/query'); + test('decoding required', () => { + expect(getSchemaQuery('my$Sname/just$pask')).toEqual(new SchemaQuery('my/name', 'just.ask')); + expect(getSchemaQuery('one$ptwo$pthree$d/q1')).toEqual(new SchemaQuery('one.two.three$', 'q1')); + expect(getSchemaQuery('one$ptwo$pthree$d/q1/view$s2$d')).toEqual( + new SchemaQuery('one.two.three$', 'q1', 'view/2$') + ); + }); }); - test('with encodings', () => { - expect(resolveKey('$chem&', '{query,/.more~less}')).toBe('$dchem$a/{query$c$s$pmore$tless$b'); - expect(resolveKey('$,hema$', 'q&x&&&d')).toBe('$d$chema$d/q$ax$a$a$ad'); - }); -}); + describe('resolveKey', () => { + test('no encodings', () => { + expect(resolveKey('schema', 'query')).toBe('schema/query'); + expect(resolveKey('Schema', 'Query')).toBe('schema/query'); + expect(resolveKey('ScheMa', 'QueRy')).toBe('schema/query'); + }); -describe('resolveKeyFromJson', () => { - test('schema name with one part', () => { - expect(resolveKeyFromJson({ schemaName: ['partOne'], queryName: 'q/Name' })).toBe('partone/q$sname'); - expect(resolveKeyFromJson({ schemaName: ['p&rtOne'], queryName: '//$Name' })).toBe('p$dartone/$s$s$dname'); - expect(resolveKeyFromJson({ schemaName: ['p&rtOne'], queryName: '//$Name', viewName: 'view' })).toBe( - 'p$dartone/$s$s$dname/view' - ); - expect(resolveKeyFromJson({ schemaName: ['p&rtOne'], queryName: '//$Name', viewName: 'new/view$' })).toBe( - 'p$dartone/$s$s$dname/new$sview$d' - ); + test('with encodings', () => { + expect(resolveKey('$chem&', '{query,/.more~less}')).toBe('$dchem$a/{query$c$s$pmore$tless$b'); + expect(resolveKey('$,hema$', 'q&x&&&d')).toBe('$d$chema$d/q$ax$a$a$ad'); + }); }); - test('schema name with multiple parts', () => { - expect(resolveKeyFromJson({ schemaName: ['one', 'Two', 'thrEE$'], queryName: 'four' })).toBe( - 'one$ptwo$pthree$dd/four' - ); + describe('resolveKeyFromJson', () => { + test('schema name with one part', () => { + expect(resolveKeyFromJson({ schemaName: ['partOne'], queryName: 'q/Name' })).toBe('partone/q$sname'); + expect(resolveKeyFromJson({ schemaName: ['p&rtOne'], queryName: '//$Name' })).toBe('p$dartone/$s$s$dname'); + expect(resolveKeyFromJson({ schemaName: ['p&rtOne'], queryName: '//$Name', viewName: 'view' })).toBe( + 'p$dartone/$s$s$dname/view' + ); + expect(resolveKeyFromJson({ schemaName: ['p&rtOne'], queryName: '//$Name', viewName: 'new/view$' })).toBe( + 'p$dartone/$s$s$dname/new$sview$d' + ); + }); + + test('schema name with multiple parts', () => { + expect(resolveKeyFromJson({ schemaName: ['one', 'Two', 'thrEE$'], queryName: 'four' })).toBe( + 'one$ptwo$pthree$dd/four' + ); + }); }); -}); -describe('parseSelectionKey', () => { - test('selectionKey with Schema, Query, and View', () => { - expect(SchemaQuery.parseSelectionKey('some-app-key|MySchema/MyQuery/MyView')).toEqual({ - keys: undefined, - schemaQuery: new SchemaQuery('MySchema', 'MyQuery', 'MyView'), + describe('parseSelectionKey', () => { + test('selectionKey with Schema, Query, and View', () => { + expect(SchemaQuery.parseSelectionKey('some-app-key|MySchema/MyQuery/MyView')).toEqual({ + keys: undefined, + schemaQuery: new SchemaQuery('MySchema', 'MyQuery', 'MyView'), + }); + }); + test('selectionKey with only Schema and Query', () => { + expect(SchemaQuery.parseSelectionKey('some-app-key|MySchema/MyQuery')).toEqual({ + keys: undefined, + schemaQuery: new SchemaQuery('MySchema', 'MyQuery'), + }); + }); + test('selectionKey with keys', () => { + expect(SchemaQuery.parseSelectionKey('some-app-key|MySchema/MyQuery/MyView|one;two;three')).toEqual({ + keys: 'one;two;three', + schemaQuery: new SchemaQuery('MySchema', 'MyQuery', 'MyView'), + }); + }); + test('selectionKey with encoded params', () => { + expect(SchemaQuery.parseSelectionKey('some-app-key|My$PSchema/My$SQuery/My$BView|one;two;three')).toEqual({ + keys: 'one;two;three', + schemaQuery: new SchemaQuery('My.Schema', 'My/Query', 'My}View'), + }); + }); + test('selectionKey with snapshot ID', () => { + const sk = + 'some-app-key|My$PSchema/My$SQuery/My$BView|one;two;three__snapshot__e4bdc808-f624-103d-8ba4-b314ec40030b'; + expect(SchemaQuery.parseSelectionKey(sk)).toEqual({ + keys: 'one;two;three', + schemaQuery: new SchemaQuery('My.Schema', 'My/Query', 'My}View'), + }); + }); + test('selectionKey with view name', () => { + const sk = 'sample-detail|samples/biotest/$t$tdetails$t$t|~~details~~|3933964'; + expect(SchemaQuery.parseSelectionKey(sk)).toEqual({ + keys: '3933964', + schemaQuery: new SchemaQuery('samples', 'biotest', '~~details~~'), + }); }); }); - test('selectionKey with only Schema and Query', () => { - expect(SchemaQuery.parseSelectionKey('some-app-key|MySchema/MyQuery')).toEqual({ - keys: undefined, - schemaQuery: new SchemaQuery('MySchema', 'MyQuery'), + + describe('queryStartsWith', () => { + test('invalid and blank values', () => { + expect(UNDEF_SQ.queryStartsWith(undefined)).toEqual(false); + expect(UNDEF_SQ.queryStartsWith(null)).toEqual(false); + expect(UNDEF_SQ.queryStartsWith('')).toEqual(false); + expect(UNDEF_SQ.queryStartsWith(' ')).toEqual(false); + + expect(SQ.queryStartsWith(undefined)).toEqual(false); + expect(SQ.queryStartsWith(null)).toEqual(false); + expect(SQ.queryStartsWith('')).toEqual(false); + expect(SQ.queryStartsWith(' ')).toEqual(false); + }); + + test('case-insensitive match', () => { + expect(SQ.queryStartsWith('Q')).toEqual(true); + expect(new SchemaQuery('Samples', 'MixTures').queryStartsWith('mIx')).toEqual(true); + }); + + test('full-string prefix', () => { + expect(SQ.queryStartsWith('query')).toEqual(true); + }); + + test('multi-char partial prefix', () => { + expect(SQ.queryStartsWith('que')).toEqual(true); + }); + + test('non-match', () => { + expect(SQ.queryStartsWith('x')).toEqual(false); + expect(SQ.queryStartsWith('uery')).toEqual(false); + }); + + test('prefix longer than value', () => { + expect(SQ.queryStartsWith('queryextra')).toEqual(false); + }); + + test('nullish prefix never matches regardless of value', () => { + expect(new SchemaQuery('s', 'undefinedQuery').queryStartsWith(undefined)).toEqual(false); + expect(new SchemaQuery('s', 'nullQuery').queryStartsWith(null)).toEqual(false); + expect(new SchemaQuery('s', 'undefinedQuery').queryStartsWith(null)).toEqual(false); + }); + + test('trailing space in prefix', () => { + expect(new SchemaQuery('s', 'my query').queryStartsWith('my ')).toEqual(true); + expect(SQ.queryStartsWith('query ')).toEqual(false); + }); + + test('does not inspect view name', () => { + expect(new SchemaQuery('s', 'q', 'viewX').queryStartsWith('view')).toEqual(false); }); }); - test('selectionKey with keys', () => { - expect(SchemaQuery.parseSelectionKey('some-app-key|MySchema/MyQuery/MyView|one;two;three')).toEqual({ - keys: 'one;two;three', - schemaQuery: new SchemaQuery('MySchema', 'MyQuery', 'MyView'), + + describe('schemaStartsWith', () => { + test('invalid and blank values', () => { + expect(UNDEF_SQ.schemaStartsWith(undefined)).toEqual(false); + expect(UNDEF_SQ.schemaStartsWith(null)).toEqual(false); + expect(UNDEF_SQ.schemaStartsWith('')).toEqual(false); + expect(UNDEF_SQ.schemaStartsWith(' ')).toEqual(false); + + expect(SQ.schemaStartsWith(undefined)).toEqual(false); + expect(SQ.schemaStartsWith(null)).toEqual(false); + expect(SQ.schemaStartsWith('')).toEqual(false); + expect(SQ.schemaStartsWith(' ')).toEqual(false); + }); + + test('case-insensitive match', () => { + expect(SQ.schemaStartsWith('S')).toEqual(true); + expect(new SchemaQuery('Samples', 'MixTures').schemaStartsWith('SAMP')).toEqual(true); + }); + + test('full-string prefix', () => { + expect(SQ.schemaStartsWith('schema.subschema')).toEqual(true); + }); + + test('multi-char partial prefix', () => { + expect(SQ.schemaStartsWith('schema.')).toEqual(true); + expect(SQ.schemaStartsWith('schema.sub')).toEqual(true); + }); + + test('non-match', () => { + expect(SQ.schemaStartsWith('sub')).toEqual(false); + expect(SQ.schemaStartsWith('x')).toEqual(false); + }); + + test('prefix longer than value', () => { + expect(SQ.schemaStartsWith('schema.subschema.more')).toEqual(false); + }); + + test('nullish prefix never matches regardless of value', () => { + expect(new SchemaQuery('undefinedSchema', 'q').schemaStartsWith(undefined)).toEqual(false); + expect(new SchemaQuery('nullSchema', 'q').schemaStartsWith(null)).toEqual(false); + expect(new SchemaQuery('undefinedSchema', 'q').schemaStartsWith(null)).toEqual(false); + }); + + test('trailing space in prefix', () => { + expect(new SchemaQuery('my schema', 'q').schemaStartsWith('my ')).toEqual(true); + expect(SQ.schemaStartsWith('schema.subschema ')).toEqual(false); + }); + + test('does not inspect view name', () => { + expect(new SchemaQuery('s', 'q', 'viewX').schemaStartsWith('view')).toEqual(false); }); }); - test('selectionKey with encoded params', () => { - expect(SchemaQuery.parseSelectionKey('some-app-key|My$PSchema/My$SQuery/My$BView|one;two;three')).toEqual({ - keys: 'one;two;three', - schemaQuery: new SchemaQuery('My.Schema', 'My/Query', 'My}View'), + + describe('isEqual', () => { + test('null or undefined argument', () => { + expect(SQ.isEqual(undefined)).toEqual(false); + expect(SQ.isEqual(null)).toEqual(false); + }); + + test('identical schema query', () => { + expect(SQ.isEqual(SQ)).toEqual(true); + expect(SQ.isEqual(new SchemaQuery('schema.subschema', 'query'))).toEqual(true); + }); + + test('both schema and query undefined', () => { + expect(UNDEF_SQ.isEqual(new SchemaQuery(undefined, undefined))).toEqual(true); + }); + + test('case-insensitive match', () => { + expect(new SchemaQuery('Schema', 'Query').isEqual(new SchemaQuery('schema', 'query'))).toEqual(true); + expect( + new SchemaQuery('Schema', 'Query', 'View').isEqual(new SchemaQuery('schema', 'query', 'view')) + ).toEqual(true); + }); + + test('different schema', () => { + expect(SQ.isEqual(new SchemaQuery('other', 'query'))).toEqual(false); + }); + + test('different query', () => { + expect(SQ.isEqual(new SchemaQuery('schema.subschema', 'other'))).toEqual(false); + }); + + test('different view name', () => { + const a = new SchemaQuery('s', 'q', 'viewA'); + const b = new SchemaQuery('s', 'q', 'viewB'); + expect(a.isEqual(b)).toEqual(false); + expect(a.isEqual(b, false)).toEqual(true); + }); + + test('one with view name, one without', () => { + const withView = new SchemaQuery('s', 'q', 'view'); + const withoutView = new SchemaQuery('s', 'q'); + expect(withView.isEqual(withoutView)).toEqual(false); + expect(withView.isEqual(withoutView, false)).toEqual(true); + }); + + test('nullish and empty view names are equivalent', () => { + expect(new SchemaQuery('s', 'q').isEqual(new SchemaQuery('s', 'q', ''))).toEqual(true); + expect(new SchemaQuery('s', 'q', null).isEqual(new SchemaQuery('s', 'q', ''))).toEqual(true); + expect(new SchemaQuery('s', 'q', undefined).isEqual(new SchemaQuery('s', 'q', null))).toEqual(true); + }); + + test('nullish and empty schema and query are equivalent', () => { + expect(new SchemaQuery(undefined, undefined).isEqual(new SchemaQuery('', ''))).toEqual(true); + expect(new SchemaQuery(null, null).isEqual(new SchemaQuery('', ''))).toEqual(true); + }); + + test('whitespace view name is not equivalent to empty', () => { + expect(new SchemaQuery('s', 'q', ' ').isEqual(new SchemaQuery('s', 'q', ''))).toEqual(false); + }); + + test('field boundary collision', () => { + expect(new SchemaQuery('a', 'b|c').isEqual(new SchemaQuery('a|b', 'c'))).toEqual(false); }); }); - test('selectionKey with snapshot ID', () => { - const sk = - 'some-app-key|My$PSchema/My$SQuery/My$BView|one;two;three__snapshot__e4bdc808-f624-103d-8ba4-b314ec40030b'; - expect(SchemaQuery.parseSelectionKey(sk)).toEqual({ - keys: 'one;two;three', - schemaQuery: new SchemaQuery('My.Schema', 'My/Query', 'My}View'), + + describe('hasSchema', () => { + test('invalid and blank values', () => { + expect(SQ.hasSchema(undefined)).toEqual(false); + expect(SQ.hasSchema(null)).toEqual(false); + expect(SQ.hasSchema('')).toEqual(false); + expect(SQ.hasSchema(' ')).toEqual(false); + + expect(UNDEF_SQ.hasSchema(undefined)).toEqual(false); + expect(UNDEF_SQ.hasSchema(null)).toEqual(false); + expect(UNDEF_SQ.hasSchema('')).toEqual(false); + }); + + test('undefined schema name never matches a non-blank argument', () => { + expect(UNDEF_SQ.hasSchema('schema.subschema')).toEqual(false); + }); + + test('exact match', () => { + expect(SQ.hasSchema('schema.subschema')).toEqual(true); + }); + + test('case-insensitive match', () => { + expect(SQ.hasSchema('Schema.SubSchema')).toEqual(true); + expect(new SchemaQuery('Samples', 'q').hasSchema('samples')).toEqual(true); + }); + + test('partial schema name is not a match', () => { + expect(SQ.hasSchema('schema')).toEqual(false); + expect(SQ.hasSchema('subschema')).toEqual(false); + }); + + test('does not inspect query or view name', () => { + expect(new SchemaQuery('s', 'q', 'view').hasSchema('q')).toEqual(false); + expect(new SchemaQuery('s', 'q', 'view').hasSchema('view')).toEqual(false); }); }); - test('selectionKey with view name', () => { - const sk = 'sample-detail|samples/biotest/$t$tdetails$t$t|~~details~~|3933964'; - expect(SchemaQuery.parseSelectionKey(sk)).toEqual({ - keys: '3933964', - schemaQuery: new SchemaQuery('samples', 'biotest', '~~details~~'), + + describe('toString', () => { + test('schema and query, no view name', () => { + expect(new SchemaQuery('s', 'q').toString()).toEqual('s|q|'); + }); + + test('schema, query, and view name', () => { + expect(new SchemaQuery('s', 'q', 'view').toString()).toEqual('s|q|view'); + }); + + test('excludes view name when includeViewName is false', () => { + expect(new SchemaQuery('s', 'q', 'view').toString(false)).toEqual('s|q'); + expect(new SchemaQuery('s', 'q').toString(false)).toEqual('s|q'); + }); + + test('preserves case', () => { + expect(new SchemaQuery('Schema', 'Query', 'View').toString()).toEqual('Schema|Query|View'); + }); + + test('undefined schema and query', () => { + expect(UNDEF_SQ.toString()).toEqual('||'); + expect(UNDEF_SQ.toString(false)).toEqual('|'); + }); + + test('empty-string view name matches undefined view name', () => { + expect(new SchemaQuery('s', 'q', '').toString()).toEqual(new SchemaQuery('s', 'q').toString()); }); }); }); diff --git a/packages/components/src/public/SchemaQuery.ts b/packages/components/src/public/SchemaQuery.ts index 64ff81cb49..4cbf503641 100644 --- a/packages/components/src/public/SchemaQuery.ts +++ b/packages/components/src/public/SchemaQuery.ts @@ -77,6 +77,11 @@ export interface IParsedSelectionKey { schemaQuery: SchemaQuery; } +function equalsIgnoreCase(a?: string, b?: string): boolean { + // Treat undefined/null/'' as equivalent + return (a ?? '').toLowerCase() === (b ?? '').toLowerCase(); +} + export class SchemaQuery { schemaName: string; queryName: string; @@ -90,20 +95,16 @@ export class SchemaQuery { isEqual(sq: SchemaQuery, includeViewName = true): boolean { if (!sq) return false; - return this.toString(includeViewName).toLowerCase() === sq.toString(includeViewName).toLowerCase(); - } - - hasSchema(schemaName: string): boolean { - if (schemaName) { - return this.schemaName?.toLowerCase() === schemaName.toLowerCase(); - } - return false; + return ( + equalsIgnoreCase(this.schemaName, sq.schemaName) && + equalsIgnoreCase(this.queryName, sq.queryName) && + (!includeViewName || equalsIgnoreCase(this.viewName, sq.viewName)) + ); } - hasSchemaQuery(sq: SchemaQuery): boolean { - if (!sq) return false; - return this.toString(false).toLowerCase() === sq.toString(false).toLowerCase(); + hasSchema(schemaName: string): boolean { + return !!schemaName && equalsIgnoreCase(this.schemaName, schemaName); } getKey(includeViewName = true): string { @@ -128,6 +129,16 @@ export class SchemaQuery { return [APP_SELECTION_PREFIX, targetSQ.getKey(), keys.join(';')].join('|'); } + queryStartsWith(prefix: string): boolean { + if (!prefix) return false; + return !!this.queryName?.toLowerCase().startsWith(prefix.toLowerCase()); + } + + schemaStartsWith(prefix: string): boolean { + if (!prefix) return false; + return !!this.schemaName?.toLowerCase().startsWith(prefix.toLowerCase()); + } + toString(includeViewName = true): string { const parts = [this.schemaName, this.queryName]; if (includeViewName) { diff --git a/packages/components/src/theme/container.scss b/packages/components/src/theme/container.scss index c514093a78..20c90e503a 100644 --- a/packages/components/src/theme/container.scss +++ b/packages/components/src/theme/container.scss @@ -1,6 +1,6 @@ .container-expandable { - border: 1px solid lightgray; + border-top: 1px solid lightgray; &:before, &:after { content: " "; @@ -31,7 +31,10 @@ background: $brand-primary; color: $white; - h4, a { + a, + h4, + .clickable-text, + .text-muted { color: $white; } } diff --git a/packages/components/src/theme/detail.scss b/packages/components/src/theme/detail.scss index 8ae2c413b3..02fcf5d819 100644 --- a/packages/components/src/theme/detail.scss +++ b/packages/components/src/theme/detail.scss @@ -1,4 +1,3 @@ - .detail-component--table__fixed { table-layout: fixed; border: unset; @@ -35,23 +34,6 @@ right: 0; } -.detail-component__expanded-container { - width: 100%; - height: 100%; - padding: 0 10px 10px; -} - -.detail-component { - padding: 15px 20px 15px 5px; - max-width: 100%; - position: relative; - - h4 { - display: inline; - color: $brand-primary; - } -} - .app-page-header__subtitle, .detail-subtitle { font-weight: 500; @@ -61,29 +43,6 @@ margin-bottom: 10px; } -.detail-component__active { - background: $brand-primary; - color: $white; - position: relative; - - a, h4 { - color: $white; - } -} - -.detail-component__inactive { - background: $white; - color: inherit; -} - -.detail-component__w-child { - padding-left: 5%; -} - -.detail-component__w-out-child { - padding-left: 5px; -} - .ws-pre-wrap { white-space: pre-wrap; } @@ -92,46 +51,6 @@ white-space: nowrap; } -.component-detail--child { - position: absolute; - width: 5%; - font-size: 20px; - cursor: pointer; - left: 0; - top: 0; - line-height: 80px; - padding-left: 1.5%; - background: white; - color: black !important; // override button.clickable-text - border-right: 1px solid lightgray !important; // override button.clickable-text -} - -.component-detail--child--inactive { - border-bottom: 1px solid $brand-primary; -} - -.component-detail--child--img { - float: left; -} - -.component-detail--child--title { - max-width: calc(100% - 100px); - margin-left: 5px; - float: left; -} - -.component-detail--child--chevron--container { - margin-top: 17px; - - & i { - width: 14px; - } -} - -.component-rmat-details { - margin-bottom: 3px; -} - // Editing styles .field-edit__icon { diff --git a/packages/components/src/theme/form.scss b/packages/components/src/theme/form.scss index 9ee2e8f473..a0afd598e6 100644 --- a/packages/components/src/theme/form.scss +++ b/packages/components/src/theme/form.scss @@ -170,10 +170,6 @@ color: #3495D2; } -.container--action-button { - cursor: pointer; -} - .container--toolbar-button { display: inline-block; padding-right: 5px; diff --git a/packages/components/src/theme/user.scss b/packages/components/src/theme/user.scss index 7766b43647..f6add16279 100644 --- a/packages/components/src/theme/user.scss +++ b/packages/components/src/theme/user.scss @@ -30,6 +30,5 @@ div:has(> .user-detail-modal) > .modal-backdrop { } .api-key__input { - width: 90%; display: inline-block; }