From 4988981eda132f748a5946e2fdcc5db5dd5eb47b Mon Sep 17 00:00:00 2001 From: XingY Date: Sat, 18 Jul 2026 15:07:49 -0700 Subject: [PATCH 01/14] Sample type color field and color configuration --- packages/components/package-lock.json | 4 +- packages/components/package.json | 2 +- packages/components/src/index.ts | 6 + .../samples/SampleTypeDesigner.tsx | 2 + .../samples/SampleTypePropertiesPanel.tsx | 13 +- .../domainproperties/samples/models.ts | 2 + .../forms/input/InputRenderFactory.ts | 2 + .../forms/input/SampleColorInput.test.tsx | 66 ++++ .../forms/input/SampleColorInput.tsx | 88 +++++ .../internal/components/samples/APIWrapper.ts | 7 +- .../samples/ManageSampleColorsPanel.test.tsx | 111 ++++++ .../samples/ManageSampleColorsPanel.tsx | 362 ++++++++++++++++++ .../samples/SampleColorsSetting.test.tsx | 117 ++++++ .../samples/SampleColorsSetting.tsx | 133 +++++++ .../internal/components/samples/actions.ts | 26 +- .../internal/components/samples/constants.ts | 2 + .../src/internal/components/samples/models.ts | 7 + .../renderers/SampleColorRenderer.tsx | 34 ++ packages/components/src/internal/schemas.ts | 1 + packages/components/src/theme/choiceList.scss | 9 + packages/components/src/theme/form.scss | 22 ++ 21 files changed, 1008 insertions(+), 8 deletions(-) create mode 100644 packages/components/src/internal/components/forms/input/SampleColorInput.test.tsx create mode 100644 packages/components/src/internal/components/forms/input/SampleColorInput.tsx create mode 100644 packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx create mode 100644 packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx create mode 100644 packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx create mode 100644 packages/components/src/internal/components/samples/SampleColorsSetting.tsx create mode 100644 packages/components/src/internal/renderers/SampleColorRenderer.tsx diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index b7505e5613..7c08f7d525 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.46.3", + "version": "7.46.4-fb-sampleColors.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.46.3", + "version": "7.46.4-fb-sampleColors.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 22591678e3..9e90d9cc92 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.46.3", + "version": "7.46.4-fb-sampleColors.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index d555cce754..681ef00aa9 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -270,6 +270,7 @@ import { AliasRenderer } from './internal/renderers/AliasRenderer'; import { ANCESTOR_LOOKUP_CONCEPT_URI, AncestorRenderer } from './internal/renderers/AncestorRenderer'; import { StorageStatusRenderer } from './internal/renderers/StorageStatusRenderer'; import { SampleStatusRenderer } from './internal/renderers/SampleStatusRenderer'; +import { SampleColorRenderer } from './internal/renderers/SampleColorRenderer'; import { TransactionAuditIdRenderer } from './internal/renderers/TransactionAuditIdRenderer'; import { ExpirationDateColumnRenderer } from './internal/renderers/ExpirationDateColumnRenderer'; import { FolderColumnRenderer } from './internal/renderers/FolderColumnRenderer'; @@ -639,6 +640,7 @@ import { import { DisableableMenuItem } from './internal/components/samples/DisableableMenuItem'; import { SampleStatusTag } from './internal/components/samples/SampleStatusTag'; import { ManageSampleStatusesPanel } from './internal/components/samples/ManageSampleStatusesPanel'; +import { ManageSampleColorsPanel } from './internal/components/samples/ManageSampleColorsPanel'; import { SampleStatusLegend } from './internal/components/samples/SampleStatusLegend'; import { ALIQUOT_FILTER_MODE, @@ -690,6 +692,7 @@ import { isBiologicsEnabled, isFreezerManagementEnabled, isPremiumApplication, + isSampleManagerEnabled, limsIsPrimaryApp, } from './internal/app/products'; import { @@ -945,6 +948,7 @@ const App = { isDataChangeCommentRequirementFeatureEnabled, isSharedContainer, freezerManagerIsCurrentApp, + isSampleManagerEnabled, isSampleStatusEnabled, isProductFoldersEnabled, isAllProductFoldersFilteringEnabled, @@ -1546,6 +1550,7 @@ export { makeTestQueryModel, ManageDropdownButton, ManageSampleStatusesPanel, + ManageSampleColorsPanel, MAX_EDITABLE_GRID_ROWS, MAX_SELECTION_ACTION_ROWS, MEASUREMENT_UNITS, @@ -1669,6 +1674,7 @@ export { SampleStateType, SampleStatusLegend, SampleStatusRenderer, + SampleColorRenderer, SampleStatusTag, SampleTypeDataType, SampleTypeDesigner, diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx index 1b89924854..702d504c0a 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx @@ -579,6 +579,7 @@ export class SampleTypeDesignerImpl extends React.PureComponent { + this.onFieldChange('disabledSampleColorRowIds', disabledRowIds); + }; + onMetricUnitKindChange = (key: string, value: any): void => { const { originalUnit } = this.state; const unitKind = value ? UnitKinds[value] : null; @@ -597,8 +602,8 @@ class SampleTypePropertiesPanelImpl extends PureComponent
@@ -610,6 +615,10 @@ class SampleTypePropertiesPanelImpl extends PureComponent
+ {includeMetricUnitProperty && ( <>
diff --git a/packages/components/src/internal/components/domainproperties/samples/models.ts b/packages/components/src/internal/components/domainproperties/samples/models.ts index f53aa4f8eb..df01d78ce8 100644 --- a/packages/components/src/internal/components/domainproperties/samples/models.ts +++ b/packages/components/src/internal/components/domainproperties/samples/models.ts @@ -27,6 +27,7 @@ export class SampleTypeModel extends Record({ autoLinkCategory: undefined, excludedContainerIds: undefined, excludedDashboardContainerIds: undefined, + disabledSampleColorRowIds: undefined, exception: undefined, }) { declare rowId: number; @@ -45,6 +46,7 @@ export class SampleTypeModel extends Record({ declare autoLinkCategory: string; declare excludedContainerIds?: string[]; declare excludedDashboardContainerIds?: string[]; + declare disabledSampleColorRowIds?: number[]; declare exception: string; static create(raw?: DomainDetails, name?: string): SampleTypeModel { diff --git a/packages/components/src/internal/components/forms/input/InputRenderFactory.ts b/packages/components/src/internal/components/forms/input/InputRenderFactory.ts index 3905d95125..ddae6a078e 100644 --- a/packages/components/src/internal/components/forms/input/InputRenderFactory.ts +++ b/packages/components/src/internal/components/forms/input/InputRenderFactory.ts @@ -10,6 +10,7 @@ import { InputRendererProps } from './types'; import { AliasGridInput, AliasInput } from './AliasInput'; import { AppendUnitsInput } from './AppendUnitsInput'; import { SampleStatusInputRenderer } from './SampleStatusInput'; +import { SampleColorInputRenderer } from './SampleColorInput'; import { AmountUnitInput } from './AmountUnitInput'; export type InputRendererComponent = ComponentType; @@ -53,5 +54,6 @@ export function registerInputRenderers(): void { registerInputRenderer('ExperimentAlias', AliasGridInput, InputRenderContext.Grid); registerInputRenderer('ExperimentAlias', AliasInput, InputRenderContext.Form); registerInputRenderer('SampleStatusInput', SampleStatusInputRenderer); + registerInputRenderer('SampleColorInput', SampleColorInputRenderer); registerInputRenderer('AmountUnitInput', AmountUnitInput, InputRenderContext.Form); } diff --git a/packages/components/src/internal/components/forms/input/SampleColorInput.test.tsx b/packages/components/src/internal/components/forms/input/SampleColorInput.test.tsx new file mode 100644 index 0000000000..8234482034 --- /dev/null +++ b/packages/components/src/internal/components/forms/input/SampleColorInput.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { QueryColumn, QueryLookup } from '../../../../public/QueryColumn'; + +import { QuerySelect } from '../QuerySelect'; + +import { SampleColorInput, SampleColorInputRenderer } from './SampleColorInput'; + +jest.mock('../QuerySelect', () => ({ + QuerySelect: jest.fn(() =>
), +})); + +const COL = new QueryColumn({ + caption: 'Sample Color', + fieldKey: 'sampleColor', + name: 'SampleColor', + lookup: new QueryLookup({ + containerPath: '/LookupHome', + displayColumn: 'Label', + keyColumn: 'RowId', + queryName: 'DataColors', + schemaName: 'exp', + }), +}); + +const lastQuerySelectProps = (): any => (QuerySelect as jest.Mock).mock.calls.at(-1)[0]; + +beforeEach(() => { + (QuerySelect as jest.Mock).mockClear(); +}); + +describe('SampleColorInput', () => { + test('forces the color lookup query/value column and excludes archived colors', () => { + render(); + const props = lastQuerySelectProps(); + expect(props.schemaQuery).toBe(COL.lookup.schemaQuery); + expect(props.valueColumn).toBe('RowId'); + expect(props.displayColumn).toBe('Label'); + expect(props.name).toBe('sampleColor'); + // archived colors are filtered out of the selectable options + expect(props.queryFilters.size).toBe(1); + expect(props.queryFilters.get(0).getColumnName()).toBe('Archived'); + }); + + test('defaults containerPath to the lookup container', () => { + render(); + expect(lastQuerySelectProps().containerPath).toBe('/LookupHome'); + }); + + test('lets a caller override the lookup containerPath', () => { + render(); + expect(lastQuerySelectProps().containerPath).toBe('/EditHere'); + }); + + test('renders the provided label field', () => { + const renderLabelField = jest.fn(() =>
); + render(); + expect(renderLabelField).toHaveBeenCalledWith(COL); + expect(screen.getByTestId('label-field')).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/internal/components/forms/input/SampleColorInput.tsx b/packages/components/src/internal/components/forms/input/SampleColorInput.tsx new file mode 100644 index 0000000000..64668d049d --- /dev/null +++ b/packages/components/src/internal/components/forms/input/SampleColorInput.tsx @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +import React, { FC, memo, ReactNode } from 'react'; +import { List } from 'immutable'; +import { Filter } from '@labkey/api'; + +import { QueryColumn } from '../../../../public/QueryColumn'; +import { QuerySelect, QuerySelectOwnProps } from '../QuerySelect'; +import { LOOKUP_DEFAULT_SIZE } from '../../../constants'; +import { NON_ARCHIVED_COLOR_FILTER } from '../../samples/constants'; + +import { InputRendererProps } from './types'; + +const COLOR_QUERY_FILTERS = List([NON_ARCHIVED_COLOR_FILTER]); + +interface SampleColorInputProps extends Omit { + col: QueryColumn; + renderLabelField?: (col: QueryColumn) => ReactNode; +} + +export const SampleColorInput: FC = memo(props => { + const { col, renderLabelField, ...querySelectProps } = props; + + return ( + <> + {renderLabelField?.(col)} + + + ); +}); + +SampleColorInput.displayName = 'SampleColorInput'; + +export const SampleColorInputRenderer: FC = memo(props => { + const { + allowFieldDisable, + col, + containerPath, + formsy, + initiallyDisabled, + onSelectChange, + onToggleDisable, + renderLabelField, + selectInputProps, + showAsteriskSymbol, + value, + fieldWithMixedValues, + } = props; + + const hasMixedValue = fieldWithMixedValues?.includes(col.name.toLowerCase()); + + return ( + + ); +}); + +SampleColorInputRenderer.displayName = 'SampleColorInputRenderer'; diff --git a/packages/components/src/internal/components/samples/APIWrapper.ts b/packages/components/src/internal/components/samples/APIWrapper.ts index 7b66277fb1..69d9f81b88 100644 --- a/packages/components/src/internal/components/samples/APIWrapper.ts +++ b/packages/components/src/internal/components/samples/APIWrapper.ts @@ -22,6 +22,7 @@ import { getLookupRowIdsFromSelection, getSampleAliquotRows, getSampleAssayResultViewConfigs, + getSampleColors, getSampleCounter, getSampleStatuses, getSampleStorageId, @@ -32,7 +33,7 @@ import { SampleAssayResultViewConfig, saveSampleCounter, } from './actions'; -import { GroupedSampleFields, SampleState } from './models'; +import { GroupedSampleFields, SampleColorModel, SampleState } from './models'; import { SampleOperation } from './constants'; import { ExecuteSqlResponseWithSession } from '../../query/executeSql'; import { Row } from '../../query/selectRows'; @@ -58,6 +59,8 @@ export interface SamplesAPIWrapper { getSampleAssayResultViewConfigs: () => Promise; + getSampleColors: (includeArchive?: boolean, containerPath?: string) => Promise; + getSampleCounter: (seqType: 'rootSampleCount' | 'sampleCount', containerPath?: string) => Promise; getSampleOperationConfirmationData: ( @@ -108,6 +111,7 @@ export class SamplesServerAPIWrapper implements SamplesAPIWrapper { getSampleAliquotRows = getSampleAliquotRows; getSampleAssayResultViewConfigs = getSampleAssayResultViewConfigs; getSelectionLineageData = getSelectionLineageData; + getSampleColors = getSampleColors; getSampleStatuses = getSampleStatuses; getDefaultDiscardStatus = getDefaultDiscardStatus; getSampleOperationConfirmationData = getSampleOperationConfirmationData; @@ -134,6 +138,7 @@ export function getSamplesTestAPIWrapper( getSampleAliquotRows: mockFn(), getSampleAssayResultViewConfigs: mockFn(), getSelectionLineageData: mockFn(), + getSampleColors: mockFn(), getSampleStatuses: mockFn(), getDefaultDiscardStatus: mockFn(), getSampleOperationConfirmationData: mockFn(), diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx new file mode 100644 index 0000000000..d4d1365cee --- /dev/null +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { getTestAPIWrapper } from '../../APIWrapper'; +import { getQueryTestAPIWrapper, QueryAPIWrapper } from '../../query/APIWrapper'; +import { renderWithAppContext } from '../../test/reactTestLibraryHelpers'; + +import { getSamplesTestAPIWrapper, SamplesAPIWrapper } from './APIWrapper'; +import { SampleColorModel } from './models'; +import { ManageSampleColorsPanel } from './ManageSampleColorsPanel'; + +const makeColor = (rowId: number, label: string, color: string, archived = false): SampleColorModel => ({ + rowId, + label, + color, + archived, +}); + +const renderPanel = ( + colors: SampleColorModel[], + samplesOverrides: Partial = {}, + queryOverrides: Partial = {} +) => { + const query = getQueryTestAPIWrapper(jest.fn, { + insertRows: jest.fn().mockResolvedValue({}), + updateRows: jest.fn().mockResolvedValue({}), + deleteRows: jest.fn().mockResolvedValue({}), + ...queryOverrides, + }); + const samples = getSamplesTestAPIWrapper(jest.fn, { + getSampleColors: jest.fn().mockResolvedValue(colors), + ...samplesOverrides, + }); + const result = renderWithAppContext( + , + { appContext: { api: getTestAPIWrapper(jest.fn, { query, samples }) } } + ); + return { ...result, query }; +}; + +describe('ManageSampleColorsPanel', () => { + test('splits colors into active and an expandable archived section', async () => { + renderPanel([makeColor(1, 'Red', '#ff0000'), makeColor(2, 'Gray', '#888888', true)]); + + // active color shows immediately; archived color is hidden until the section is expanded + await screen.findByRole('button', { name: 'Red' }); + expect(screen.queryByRole('button', { name: 'Gray' })).not.toBeInTheDocument(); + + const toggle = screen.getByRole('button', { name: /Archived Colors/ }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(toggle); + + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('button', { name: 'Gray' })).toBeInTheDocument(); + }); + + test('disables adding a color once the max limit is reached', async () => { + const colors = Array.from({ length: 200 }, (_, i) => makeColor(i + 1, `Color ${i + 1}`, '#111111')); + renderPanel(colors); + const addButton = await screen.findByRole('button', { name: /Add Color/i }); + // ActionButton applies a `disabled` class (not the DOM attribute) and drops its onClick when disabled. + expect(addButton.className).toContain('disabled'); + }); + + test('adding a color below the limit opens an empty detail form with Save disabled until valid', async () => { + renderPanel([makeColor(1, 'Red', '#ff0000')]); + await screen.findByRole('button', { name: 'Red' }); + + const addButton = screen.getByRole('button', { name: /Add Color/i }); + expect(addButton.className).not.toContain('disabled'); + await userEvent.click(addButton); + + expect(screen.getByPlaceholderText('Enter color label')).toHaveValue(''); + // canSave requires both a label and a color, so a brand-new color starts non-savable. + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + test('archiving a color updates it with the archived flag flipped', async () => { + const { query } = renderPanel([makeColor(1, 'Red', '#ff0000')]); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + await userEvent.click(screen.getByRole('button', { name: 'Archive' })); + + await waitFor(() => expect(query.updateRows).toHaveBeenCalledTimes(1)); + const options = (query.updateRows as jest.Mock).mock.calls[0][0]; + expect(options.rows[0]).toMatchObject({ rowId: 1, label: 'Red', archived: true }); + }); + + test('deleting a color calls deleteRows after confirmation', async () => { + const { query } = renderPanel([makeColor(1, 'Red', '#ff0000')]); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + await userEvent.click(screen.getByRole('button', { name: 'Delete' })); + await userEvent.click(screen.getByRole('button', { name: 'Yes, Delete' })); + + await waitFor(() => expect(query.deleteRows).toHaveBeenCalledTimes(1)); + const options = (query.deleteRows as jest.Mock).mock.calls[0][0]; + expect(options.rows[0]).toMatchObject({ rowId: 1 }); + }); + + test('shows an error when the colors query fails', async () => { + renderPanel([], { getSampleColors: jest.fn().mockRejectedValue(new Error('boom')) }); + expect(await screen.findByText('Error: Unable to load sample colors.')).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx new file mode 100644 index 0000000000..b7877101dc --- /dev/null +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { List } from 'immutable'; +import classNames from 'classnames'; + +import { LoadingSpinner } from '../base/LoadingSpinner'; +import { Alert } from '../base/Alert'; +import { ColorIcon } from '../base/ColorIcon'; +import { LabelHelpTip } from '../base/LabelHelpTip'; +import { Modal } from '../../Modal'; +import { ChoicesListItem } from '../base/ChoicesListItem'; +import { AddEntityButton } from '../buttons/AddEntityButton'; +import { DomainFieldLabel } from '../domainproperties/DomainFieldLabel'; +import { ColorPickerInput } from '../forms/input/ColorPickerInput'; +import { SCHEMAS } from '../../schemas'; +import { resolveErrorMessage } from '../../util/messaging'; +import { InjectedRouteLeaveProps } from '../../util/RouteLeave'; +import { useAppContext } from '../../AppContext'; +import { Container } from '../base/models/Container'; + +import { SampleColorModel } from './models'; + +const TITLE = 'Manage Sample Colors'; +const NEW_COLOR_INDEX = -1; +const MAX_DATA_COLORS = 200; +const AT_LIMIT_HELP = + 'The maximum of ' + MAX_DATA_COLORS + ' sample colors (active and archived) has been reached. Delete a color before adding a new one.'; +const ARCHIVED_HELP = "Archived colors can't be applied to future samples, but may still be applied to existing samples."; + +function newColor(): SampleColorModel { + return { archived: false, color: undefined, label: undefined }; +} + +interface SampleColorDetailProps { + color: SampleColorModel; + container?: Container; + isNew: boolean; + onActionComplete: (newColorLabel?: string, isDelete?: boolean) => void; + onChange: () => void; +} + +// exported for jest testing +export const SampleColorDetail: FC = memo(props => { + const { color, isNew, onActionComplete, onChange, container } = props; + const [updated, setUpdated] = useState(); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const { api } = useAppContext(); + + useEffect(() => { + setUpdated(isNew ? newColor() : color); + setDirty(isNew); + setSaving(false); + setShowDeleteConfirm(false); + setError(undefined); + if (isNew) onChange(); + }, [color, isNew, onChange]); + + const onLabelChange = useCallback( + (evt): void => { + setUpdated(prev => ({ ...prev, label: evt.target.value })); + setDirty(true); + onChange(); + }, + [onChange] + ); + + const onColorChange = useCallback( + (name: string, value: string): void => { + setUpdated(prev => ({ ...prev, color: value })); + setDirty(true); + onChange(); + }, + [onChange] + ); + + const saveRows = useCallback( + (rows: SampleColorModel[], isInsert: boolean, successLabel?: string, isDelete = false) => { + setError(undefined); + setSaving(true); + const schemaQuery = SCHEMAS.EXP_TABLES.DATA_COLORS; + const opts = { schemaQuery, containerPath: container?.path }; + let promise: Promise; + if (isDelete) promise = api.query.deleteRows({ ...opts, rows }); + else if (isInsert) promise = api.query.insertRows({ ...opts, rows: List(rows) }); + else promise = api.query.updateRows({ ...opts, rows }); + + promise + .then(() => onActionComplete(successLabel, isDelete)) + .catch(reason => { + setError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', isDelete ? 'delete' : 'save')); + setSaving(false); + }); + }, + [api, container?.path, onActionComplete] + ); + + const onSave = useCallback(() => { + const toSave = { ...updated, label: updated.label?.trim() }; + saveRows([toSave], !toSave.rowId, toSave.label); + }, [updated, saveRows]); + + const onToggleArchive = useCallback(() => { + const toSave = { ...updated, archived: !updated.archived }; + saveRows([toSave], false, toSave.label); + }, [updated, saveRows]); + + const onToggleDeleteConfirm = useCallback(() => setShowDeleteConfirm(s => !s), []); + const onDeleteConfirm = useCallback(() => { + if (updated.rowId) saveRows([updated], false, undefined, true); + else setShowDeleteConfirm(false); + }, [updated, saveRows]); + + if (!updated && color !== null) { + return

Select a sample color to view details.

; + } + if (!updated) return null; + + const canSave = dirty && !saving && !!updated.color && !!updated.label?.trim(); + + return ( + <> +
+ {error && {error}} +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ {!isNew && ( + <> + + + + )} + +
+
+ {showDeleteConfirm && ( + + + The {updated.label} color will be permanently deleted. +

+ Deletion cannot be undone. Colors that are in use by samples cannot be + deleted. Do you want to proceed? +

+
+
+ )} + + ); +}); +SampleColorDetail.displayName = 'SampleColorDetail'; + +interface SampleColorsListProps { + archivedColors: SampleColorModel[]; + activeColors: SampleColorModel[]; + onSelect: (rowId: number) => void; + selectedRowId: number; +} + +// exported for jest testing +export const SampleColorsList: FC = memo(props => { + const { activeColors, archivedColors, onSelect, selectedRowId } = props; + const [showArchived, setShowArchived] = useState(false); + const toggleArchived = useCallback(() => setShowArchived(s => !s), []); + + const renderItem = useCallback( + (c: SampleColorModel) => ( + } + onSelect={onSelect} + /> + ), + [onSelect, selectedRowId] + ); + + return ( + <> +
+

Set up colors that can be applied to individual samples, overriding the sample type color.

+ {activeColors.map(renderItem)} +
+ {archivedColors.length > 0 && ( + <> +
+ + {ARCHIVED_HELP} +
+ {showArchived &&
{archivedColors.map(renderItem)}
} + + )} + + ); +}); +SampleColorsList.displayName = 'SampleColorsList'; + +interface ManageSampleColorsPanelProps extends InjectedRouteLeaveProps { + homeContainer?: Container; +} + +export const ManageSampleColorsPanel: FC = memo(props => { + const { setIsDirty, homeContainer } = props; + const [colors, setColors] = useState(); + const [error, setError] = useState(); + const [selectedRowId, setSelectedRowId] = useState(); + const { api } = useAppContext(); + const isNew = selectedRowId === NEW_COLOR_INDEX; + + const loadColors = useCallback( + (selectLabel?: string) => { + setError(undefined); + api.samples + .getSampleColors(true, homeContainer?.path) + .then(loaded => { + setColors(loaded); + if (selectLabel) setSelectedRowId(loaded.find(c => c.label === selectLabel)?.rowId); + }) + .catch(() => { + setColors([]); + setError('Error: Unable to load sample colors.'); + }); + }, + [api, homeContainer?.path] + ); + + useEffect(() => { + loadColors(); + }, [loadColors]); + + const onSelect = useCallback((rowId: number) => setSelectedRowId(rowId), []); + const onAdd = useCallback(() => setSelectedRowId(NEW_COLOR_INDEX), []); + const onChange = useCallback(() => setIsDirty(true), [setIsDirty]); + + const onActionComplete = useCallback( + (newColorLabel?: string, isDelete = false) => { + loadColors(newColorLabel); + if (isDelete) setSelectedRowId(undefined); + setIsDirty(false); + }, + [loadColors, setIsDirty] + ); + + const { activeColors, archivedColors, selectedColor } = useMemo(() => { + const active = (colors ?? []).filter(c => !c.archived); + const archived = (colors ?? []).filter(c => c.archived); + const selected = isNew ? undefined : (colors ?? []).find(c => c.rowId === selectedRowId); + return { activeColors: active, archivedColors: archived, selectedColor: selected }; + }, [colors, isNew, selectedRowId]); + + const atLimit = (colors?.length ?? 0) >= MAX_DATA_COLORS; + + return ( +
+

{TITLE}

+
+ {error && {error}} + {!colors && } + {colors && !error && ( +
+
+ + +
+
+ +
+
+ )} +
+
+ ); +}); + +ManageSampleColorsPanel.displayName = 'ManageSampleColorsPanel'; diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx new file mode 100644 index 0000000000..c0032f288f --- /dev/null +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { getTestAPIWrapper } from '../../APIWrapper'; +import { renderWithAppContext } from '../../test/reactTestLibraryHelpers'; +import { TEST_USER_APP_ADMIN, TEST_USER_EDITOR } from '../../userFixtures'; + +import { getSamplesTestAPIWrapper } from './APIWrapper'; +import { SampleColorModel } from './models'; +import { SampleColorsSetting } from './SampleColorsSetting'; + +const makeColor = (rowId: number): SampleColorModel => ({ + rowId, + label: `Color ${rowId}`, + color: '#123456', + archived: false, +}); + +const renderSetting = ( + colors: SampleColorModel[], + props: Partial> = {}, + user = TEST_USER_APP_ADMIN +) => { + const appContext = { + api: getTestAPIWrapper(jest.fn, { + samples: getSamplesTestAPIWrapper(jest.fn, { + getSampleColors: jest.fn().mockResolvedValue(colors), + }), + }), + }; + return renderWithAppContext( + , + { appContext, serverContext: { user } } + ); +}; + +describe('SampleColorsSetting', () => { + test('shows a loading spinner until colors resolve', async () => { + const { container } = renderSetting([]); + expect(container.querySelectorAll('.fa-spinner')).toHaveLength(1); + await waitFor(() => expect(container.querySelectorAll('.fa-spinner')).toHaveLength(0)); + }); + + test('empty state shows the "Add Colors" link only for an app admin', async () => { + renderSetting([], {}, TEST_USER_APP_ADMIN); + await waitFor(() => expect(screen.getByText('No colors are set up yet.')).toBeInTheDocument()); + expect(screen.getByRole('link', { name: 'Add Colors' })).toBeInTheDocument(); + }); + + test('empty state hides the "Add Colors" link for a non-app-admin', async () => { + renderSetting([], {}, TEST_USER_EDITOR); + await waitFor(() => expect(screen.getByText('No colors are set up yet.')).toBeInTheDocument()); + expect(screen.queryByRole('link', { name: 'Add Colors' })).not.toBeInTheDocument(); + }); + + test('renders a dot per enabled color with an accurate count', async () => { + const { container } = renderSetting([makeColor(1), makeColor(2), makeColor(3)]); + await waitFor(() => expect(screen.getByText('3 colors enabled.')).toBeInTheDocument()); + expect(container.querySelectorAll('.sample-colors-setting__dot')).toHaveLength(3); + expect(container.querySelectorAll('.sample-colors-setting__dot-more')).toHaveLength(0); + }); + + test('disabledRowIds are excluded from the enabled count and dots', async () => { + const { container } = renderSetting([makeColor(1), makeColor(2), makeColor(3)], { + disabledRowIds: [2, 3], + }); + await waitFor(() => expect(screen.getByText('1 colors enabled.')).toBeInTheDocument()); + expect(container.querySelectorAll('.sample-colors-setting__dot')).toHaveLength(1); + }); + + test('caps the preview at MAX_DOTS and shows the "+" more indicator when over the cap', async () => { + const colors = Array.from({ length: 25 }, (_, i) => makeColor(i + 1)); + const { container } = renderSetting(colors); + await waitFor(() => expect(screen.getByText('25 colors enabled.')).toBeInTheDocument()); + // 25 > MAX_DOTS (20): exactly 20 dots render, and the last one is the "+" more indicator. + expect(container.querySelectorAll('.sample-colors-setting__dot')).toHaveLength(20); + const more = container.querySelectorAll('.sample-colors-setting__dot-more'); + expect(more).toHaveLength(1); + expect(more.item(0).textContent).toBe('+'); + }); + + test('editing colors and applying reports the new disabled set via onChange', async () => { + const onChange = jest.fn(); + renderSetting([makeColor(1), makeColor(2)], { onChange }); + await waitFor(() => expect(screen.getByText('2 colors enabled.')).toBeInTheDocument()); + + await userEvent.click(screen.getByRole('button', { name: 'Edit' })); + + // All colors start enabled (checked); unchecking the first disables rowId 1. + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(2); + await userEvent.click(checkboxes[0]); + + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onChange).toHaveBeenCalledWith([1]); + }); + + test('shows an error when the colors query fails', async () => { + const appContext = { + api: getTestAPIWrapper(jest.fn, { + samples: getSamplesTestAPIWrapper(jest.fn, { + getSampleColors: jest.fn().mockRejectedValue(new Error('boom')), + }), + }), + }; + renderWithAppContext(, { + appContext, + serverContext: { user: TEST_USER_APP_ADMIN }, + }); + await waitFor(() => expect(screen.getByText('Unable to load sample colors.')).toBeInTheDocument()); + }); +}); diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx new file mode 100644 index 0000000000..ec8df724b1 --- /dev/null +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; + +import { Modal } from '../../Modal'; +import { Alert } from '../base/Alert'; +import { ColorIcon } from '../base/ColorIcon'; +import { LoadingSpinner } from '../base/LoadingSpinner'; +import { useAppContext } from '../../AppContext'; +import { useServerContext } from '../base/ServerContext'; +import { AppURL } from '../../url/AppURL'; +import { AppLink } from '../../url/AppLink'; +import { ADMIN_KEY } from '../../app/constants'; +import { DomainFieldLabel } from '../domainproperties/DomainFieldLabel'; + +import { SampleColorModel } from './models'; + +const MAX_DOTS = 20; +const HELP_TIP = 'Set up colors that can be applied to individual samples, overriding the sample type color.'; + +interface Props { + disabledRowIds: number[]; + onChange: (disabledRowIds: number[]) => void; +} + +export const SampleColorsSetting: FC = memo(({ disabledRowIds, onChange }) => { + const { api } = useAppContext(); + const { user } = useServerContext(); + const [colors, setColors] = useState(); + const [error, setError] = useState(); + const [showModal, setShowModal] = useState(false); + const [draftDisabled, setDraftDisabled] = useState>(new Set()); + + useEffect(() => { + (async () => { + try { + setColors(await api.samples.getSampleColors()); + } catch (e) { + setColors([]); + setError('Unable to load sample colors.'); + } + })(); + }, [api]); + + const disabledSet = useMemo(() => new Set(disabledRowIds ?? []), [disabledRowIds]); + const enabledColors = useMemo(() => (colors ?? []).filter(c => !disabledSet.has(c.rowId)), [colors, disabledSet]); + + const openModal = useCallback(() => { + setDraftDisabled(new Set(disabledRowIds ?? [])); + setShowModal(true); + }, [disabledRowIds]); + + const closeModal = useCallback(() => setShowModal(false), []); + + const toggleColor = useCallback((rowId: number) => { + setDraftDisabled(prev => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); + }, []); + + const onConfirm = useCallback(() => { + onChange(Array.from(draftDisabled)); + setShowModal(false); + }, [draftDisabled, onChange]); + + return ( +
+
+ +
+
+ {error && {error}} + {!colors && } + {colors && colors.length === 0 && ( + + No colors are set up yet. + {user?.isAppAdmin() && ( + <> + {' '} + Add Colors + + )} + + )} + {colors && colors.length > 0 && ( + <> + + {enabledColors.slice(0, MAX_DOTS).map((c, i, shown) => { + const showPlus = enabledColors.length > MAX_DOTS && i === shown.length - 1; + return ( + + + {showPlus && +} + + ); + })} + + {enabledColors.length} colors enabled. + + + )} +
+ {showModal && ( + + {colors?.length === 0 &&

No colors are set up yet.

} +
+ {(colors ?? []).map(c => ( +
+ +
+ ))} +
+
+ )} +
+ ); +}); + +SampleColorsSetting.displayName = 'SampleColorsSetting'; diff --git a/packages/components/src/internal/components/samples/actions.ts b/packages/components/src/internal/components/samples/actions.ts index ebe3935024..d3a2d6d6d6 100644 --- a/packages/components/src/internal/components/samples/actions.ts +++ b/packages/components/src/internal/components/samples/actions.ts @@ -40,8 +40,13 @@ import { Row, selectRows } from '../../query/selectRows'; import { QueryInfo } from '../../../public/QueryInfo'; -import { ALL_AMOUNT_AND_UNITS_COLUMNS_LC, SAMPLE_STORAGE_COLUMNS_LC, STORED_AMOUNT_FIELDS } from './constants'; -import { FindField, GroupedSampleFields, SampleState, SampleStateType } from './models'; +import { + ALL_AMOUNT_AND_UNITS_COLUMNS_LC, + NON_ARCHIVED_COLOR_FILTER, + SAMPLE_STORAGE_COLUMNS_LC, + STORED_AMOUNT_FIELDS, +} from './constants'; +import { FindField, GroupedSampleFields, SampleColorModel, SampleState, SampleStateType } from './models'; import { executeSql, ExecuteSqlResponseWithSession } from '../../query/executeSql'; import { EDIT_METHOD } from '../../constants'; @@ -180,6 +185,23 @@ export async function getSampleStorageId(sampleRowId: number): Promise { return caseInsensitive(result.rows[0], 'RowId').value; } +export function getSampleColors(includeArchive = false, containerPath?: string): Promise { + return selectRows({ + columns: 'RowId,Label,Color,Archived', + containerPath, + filterArray: includeArchive ? undefined : [NON_ARCHIVED_COLOR_FILTER], + schemaQuery: SCHEMAS.EXP_TABLES.DATA_COLORS, + sort: 'Label', + }).then(response => + response.rows.map(row => ({ + rowId: caseInsensitive(row, 'RowId').value, + label: caseInsensitive(row, 'Label').value, + color: caseInsensitive(row, 'Color').value, + archived: !!caseInsensitive(row, 'Archived').value, + })) + ); +} + // Used for samples and dataclasses export function getSelectionLineageData( selections: Set, diff --git a/packages/components/src/internal/components/samples/constants.ts b/packages/components/src/internal/components/samples/constants.ts index 3eb01c1aec..5a896fa348 100644 --- a/packages/components/src/internal/components/samples/constants.ts +++ b/packages/components/src/internal/components/samples/constants.ts @@ -225,6 +225,8 @@ export const SAMPLE_DATA_EXPORT_CONFIG = { // Issue 46037: Some plate-based assays (e.g., NAB) create samples with a bogus 'Material' sample type, which should get excluded everywhere in the application export const SAMPLES_WITH_TYPES_FILTER = Filter.create('SampleSet', 'Material', Filter.Types.NEQ); +export const NON_ARCHIVED_COLOR_FILTER = Filter.create('Archived', true, Filter.Types.NEQ_OR_NULL); + export const SAMPLE_DOMAIN_DEFAULT_SYSTEM_FIELDS = [ { Name: 'Name', diff --git a/packages/components/src/internal/components/samples/models.ts b/packages/components/src/internal/components/samples/models.ts index 06db383707..08b16007f9 100644 --- a/packages/components/src/internal/components/samples/models.ts +++ b/packages/components/src/internal/components/samples/models.ts @@ -4,6 +4,13 @@ */ import { immerable, produce } from 'immer'; +export interface SampleColorModel { + archived: boolean; + color: string; + label: string; + rowId?: number; +} + export enum EntityCreationType { Aliquots = 'Aliquot', Derivatives = 'Derive', diff --git a/packages/components/src/internal/renderers/SampleColorRenderer.tsx b/packages/components/src/internal/renderers/SampleColorRenderer.tsx new file mode 100644 index 0000000000..1d41ab1d95 --- /dev/null +++ b/packages/components/src/internal/renderers/SampleColorRenderer.tsx @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +import React, { FC, memo } from 'react'; +import { Map } from 'immutable'; + +import { ColorIcon } from '../components/base/ColorIcon'; +import { caseInsensitive } from '../util/utils'; + +export const SAMPLE_COLOR_COLUMN_NAME = 'SampleColor'; +export const SAMPLE_COLOR_COLOR_COLUMN_NAME = 'SampleColor/Color'; + +interface Props { + data?: Map; + row?: Map; +} + +export const SampleColorRenderer: FC = memo(({ data, row }) => { + const label = data?.get('displayValue') ?? data?.get('value'); + if (label === undefined || label === null || label === '') return null; + + let color: string; + if (row) { + const rowJS = row.toJS(); + color = + caseInsensitive(rowJS, SAMPLE_COLOR_COLOR_COLUMN_NAME)?.value ?? + caseInsensitive(rowJS, 'SampleID/' + SAMPLE_COLOR_COLOR_COLUMN_NAME)?.value; + } + + return ; +}); + +SampleColorRenderer.displayName = 'SampleColorRenderer'; diff --git a/packages/components/src/internal/schemas.ts b/packages/components/src/internal/schemas.ts index 21e33edf3c..4f44b987d6 100644 --- a/packages/components/src/internal/schemas.ts +++ b/packages/components/src/internal/schemas.ts @@ -37,6 +37,7 @@ export const EXP_TABLES = { SAMPLE_SETS: new SchemaQuery(EXP_SCHEMA, 'SampleSets'), SAMPLE_SETS_DETAILS: new SchemaQuery(EXP_SCHEMA, 'SampleSets', ViewInfo.DETAIL_NAME), SAMPLE_STATUS: new SchemaQuery(EXP_SCHEMA, 'SampleStatus'), + DATA_COLORS: new SchemaQuery(EXP_SCHEMA, 'DataColors'), }; // CORE diff --git a/packages/components/src/theme/choiceList.scss b/packages/components/src/theme/choiceList.scss index c01bd2bf2d..f38343a5d2 100644 --- a/packages/components/src/theme/choiceList.scss +++ b/packages/components/src/theme/choiceList.scss @@ -10,6 +10,15 @@ margin-bottom: 8px; } +.choice-section-header__toggle { + padding: 0; + border: none; + background: none; + font: inherit; + color: inherit; + cursor: pointer; +} + .choice-details-section { margin-top: 16px; } diff --git a/packages/components/src/theme/form.scss b/packages/components/src/theme/form.scss index a0afd598e6..186a5e5c53 100644 --- a/packages/components/src/theme/form.scss +++ b/packages/components/src/theme/form.scss @@ -488,3 +488,25 @@ textarea.form-control { .select-options-divider { margin: 0 2px 0 2px; } + +.sample-colors-setting__dot { + display: inline-block; + position: relative; + margin-left: -6px; + + &:first-child { + margin-left: 0; + } +} + +.sample-colors-setting__dot-more { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: #000; +} From 9477482b372c2951e1cee7b05ab2f1afa5824371 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 20 Jul 2026 10:01:25 -0700 Subject: [PATCH 02/14] Sample type color field and color configuration - handle exclusion --- packages/components/package-lock.json | 4 +- packages/components/package.json | 2 +- .../internal/components/samples/APIWrapper.ts | 15 ++ .../samples/ManageSampleColorsPanel.test.tsx | 108 +++++++++++++- .../samples/ManageSampleColorsPanel.tsx | 134 ++++++++++++++---- .../internal/components/samples/actions.ts | 46 +++++- packages/components/src/internal/schemas.ts | 1 + 7 files changed, 276 insertions(+), 34 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 7c08f7d525..b69896e4c1 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.46.4-fb-sampleColors.0", + "version": "7.46.4-fb-sampleColors.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.46.4-fb-sampleColors.0", + "version": "7.46.4-fb-sampleColors.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 9e90d9cc92..6dd716abaf 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.46.4-fb-sampleColors.0", + "version": "7.46.4-fb-sampleColors.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/components/samples/APIWrapper.ts b/packages/components/src/internal/components/samples/APIWrapper.ts index 69d9f81b88..595194eb67 100644 --- a/packages/components/src/internal/components/samples/APIWrapper.ts +++ b/packages/components/src/internal/components/samples/APIWrapper.ts @@ -20,6 +20,7 @@ import { getDistinctAssaysPerSample, getGroupedSampleDomainFields, getLookupRowIdsFromSelection, + getColorSampleTypeExclusions, getSampleAliquotRows, getSampleAssayResultViewConfigs, getSampleColors, @@ -32,6 +33,7 @@ import { hasExistingSamples, SampleAssayResultViewConfig, saveSampleCounter, + updateColorSettings, } from './actions'; import { GroupedSampleFields, SampleColorModel, SampleState } from './models'; import { SampleOperation } from './constants'; @@ -61,6 +63,15 @@ export interface SamplesAPIWrapper { getSampleColors: (includeArchive?: boolean, containerPath?: string) => Promise; + getColorSampleTypeExclusions: (colorRowId: number, containerPath?: string) => Promise; + + updateColorSettings: ( + color: SampleColorModel, + newlyDisabledTypeIds: number[], + newlyEnabledTypeIds: number[], + containerPath?: string + ) => Promise; + getSampleCounter: (seqType: 'rootSampleCount' | 'sampleCount', containerPath?: string) => Promise; getSampleOperationConfirmationData: ( @@ -112,6 +123,8 @@ export class SamplesServerAPIWrapper implements SamplesAPIWrapper { getSampleAssayResultViewConfigs = getSampleAssayResultViewConfigs; getSelectionLineageData = getSelectionLineageData; getSampleColors = getSampleColors; + getColorSampleTypeExclusions = getColorSampleTypeExclusions; + updateColorSettings = updateColorSettings; getSampleStatuses = getSampleStatuses; getDefaultDiscardStatus = getDefaultDiscardStatus; getSampleOperationConfirmationData = getSampleOperationConfirmationData; @@ -139,6 +152,8 @@ export function getSamplesTestAPIWrapper( getSampleAssayResultViewConfigs: mockFn(), getSelectionLineageData: mockFn(), getSampleColors: mockFn(), + getColorSampleTypeExclusions: mockFn(), + updateColorSettings: mockFn(), getSampleStatuses: mockFn(), getDefaultDiscardStatus: mockFn(), getSampleOperationConfirmationData: mockFn(), diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx index d4d1365cee..1ac1f1e629 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx @@ -30,19 +30,27 @@ const renderPanel = ( insertRows: jest.fn().mockResolvedValue({}), updateRows: jest.fn().mockResolvedValue({}), deleteRows: jest.fn().mockResolvedValue({}), + // DataTypeSelector (the "Sample Types" picker) loads the type list through this. + getFolderConfigurableEntityTypeOptions: jest.fn().mockResolvedValue([]), + getFolderDataTypeDataCount: jest.fn().mockResolvedValue({}), ...queryOverrides, }); const samples = getSamplesTestAPIWrapper(jest.fn, { getSampleColors: jest.fn().mockResolvedValue(colors), + getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]), + updateColorSettings: jest.fn().mockResolvedValue(1), ...samplesOverrides, }); const result = renderWithAppContext( , { appContext: { api: getTestAPIWrapper(jest.fn, { query, samples }) } } ); - return { ...result, query }; + return { ...result, query, samples }; }; +const TYPE_A = { rowId: 10, label: 'Sample Type A' } as any; +const TYPE_B = { rowId: 11, label: 'Sample Type B' } as any; + describe('ManageSampleColorsPanel', () => { test('splits colors into active and an expandable archived section', async () => { renderPanel([makeColor(1, 'Red', '#ff0000'), makeColor(2, 'Gray', '#888888', true)]); @@ -81,15 +89,19 @@ describe('ManageSampleColorsPanel', () => { expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); }); - test('archiving a color updates it with the archived flag flipped', async () => { - const { query } = renderPanel([makeColor(1, 'Red', '#ff0000')]); + test('archiving a color saves it (via the settings action) with the archived flag flipped', async () => { + const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')]); await userEvent.click(await screen.findByRole('button', { name: 'Red' })); await userEvent.click(screen.getByRole('button', { name: 'Archive' })); - await waitFor(() => expect(query.updateRows).toHaveBeenCalledTimes(1)); - const options = (query.updateRows as jest.Mock).mock.calls[0][0]; - expect(options.rows[0]).toMatchObject({ rowId: 1, label: 'Red', archived: true }); + await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); + expect(samples.updateColorSettings).toHaveBeenCalledWith( + expect.objectContaining({ rowId: 1, label: 'Red', archived: true }), + [], + [], + undefined + ); }); test('deleting a color calls deleteRows after confirmation', async () => { @@ -108,4 +120,88 @@ describe('ManageSampleColorsPanel', () => { renderPanel([], { getSampleColors: jest.fn().mockRejectedValue(new Error('boom')) }); expect(await screen.findByText('Error: Unable to load sample colors.')).toBeInTheDocument(); }); + + const withTypes = (...types: any[]) => ({ getFolderConfigurableEntityTypeOptions: jest.fn().mockResolvedValue(types) }); + + test('selecting a color loads its sample types with all enabled (checked) by default', async () => { + renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + expect(await screen.findByRole('checkbox', { name: 'Sample Type A' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Sample Type B' })).toBeChecked(); + }); + + test('an existing exclusion shows that sample type unchecked', async () => { + renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_B.rowId]) }, withTypes(TYPE_A, TYPE_B)); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + expect(await screen.findByRole('checkbox', { name: 'Sample Type A' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Sample Type B' })).not.toBeChecked(); + }); + + test('unchecking a sample type and saving folds the exclusion into the color save', async () => { + const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + await userEvent.click(await screen.findByRole('checkbox', { name: 'Sample Type A' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); + // TYPE_A was enabled and is now unchecked -> newly disabled; nothing newly enabled. + expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [TYPE_A.rowId], [], undefined); + }); + + test('re-enabling a previously excluded sample type sends it as newly enabled on save', async () => { + const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_B.rowId]) }, withTypes(TYPE_A, TYPE_B)); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + // TYPE_B starts excluded (unchecked); checking it re-enables it. + await userEvent.click(await screen.findByRole('checkbox', { name: 'Sample Type B' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); + expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [], [TYPE_B.rowId], undefined); + }); + + test('"Deselect All" excludes every sample type on save', async () => { + const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + await userEvent.click(await screen.findByRole('button', { name: 'Deselect All' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); + expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [TYPE_A.rowId, TYPE_B.rowId], [], undefined); + }); + + test('"Select All" re-enables every excluded sample type on save', async () => { + const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_A.rowId, TYPE_B.rowId]) }, withTypes(TYPE_A, TYPE_B)); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + await userEvent.click(await screen.findByRole('button', { name: 'Select All' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); + expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [], [TYPE_A.rowId, TYPE_B.rowId], undefined); + }); + + test('a new color shows the sample-type section with everything enabled', async () => { + renderPanel([makeColor(1, 'Red', '#ff0000')], {}, withTypes(TYPE_A, TYPE_B)); + await userEvent.click(await screen.findByRole('button', { name: /Add Color/i })); + + // The section is available while creating a color, so it can be created with exclusions. + expect(await screen.findByRole('checkbox', { name: 'Sample Type A' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Sample Type B' })).toBeChecked(); + }); + + test('sample-type checkboxes are read-only (and no Select All) for an archived color', async () => { + renderPanel([makeColor(1, 'Gray', '#888888', true)], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + // archived colors live behind the collapsible Archived Colors section + await userEvent.click(await screen.findByRole('button', { name: /Archived Colors/ })); + await userEvent.click(screen.getByRole('button', { name: 'Gray' })); + + expect(await screen.findByRole('checkbox', { name: 'Sample Type A' })).toBeDisabled(); + expect(screen.getByRole('checkbox', { name: 'Sample Type B' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: /Select All|Deselect All/ })).not.toBeInTheDocument(); + }); }); diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx index b7877101dc..6a1594226a 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -3,7 +3,6 @@ * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { List } from 'immutable'; import classNames from 'classnames'; import { LoadingSpinner } from '../base/LoadingSpinner'; @@ -21,6 +20,9 @@ import { InjectedRouteLeaveProps } from '../../util/RouteLeave'; import { useAppContext } from '../../AppContext'; import { Container } from '../base/models/Container'; +import { DataTypeSelector } from '../entities/DataTypeSelector'; +import { SampleTypeDataType } from '../entities/constants'; + import { SampleColorModel } from './models'; const TITLE = 'Manage Sample Colors'; @@ -29,6 +31,8 @@ const MAX_DATA_COLORS = 200; const AT_LIMIT_HELP = 'The maximum of ' + MAX_DATA_COLORS + ' sample colors (active and archived) has been reached. Delete a color before adding a new one.'; const ARCHIVED_HELP = "Archived colors can't be applied to future samples, but may still be applied to existing samples."; +const APPLIES_TO_HELP = + 'Choose which sample types this color can be applied to. All sample types are enabled by default; unchecking one excludes this color from that sample type.'; function newColor(): SampleColorModel { return { archived: false, color: undefined, label: undefined }; @@ -50,6 +54,11 @@ export const SampleColorDetail: FC = memo(props => { const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [excludedTypes, setExcludedTypes] = useState>(new Set()); + // The exclusions as loaded, so Save can send only the delta (newly disabled / newly enabled) rather than the full set. + const [initialExcludedTypes, setInitialExcludedTypes] = useState>(new Set()); + const [exclusionsLoaded, setExclusionsLoaded] = useState(false); + const [typesError, setTypesError] = useState(); const { api } = useAppContext(); useEffect(() => { @@ -61,6 +70,49 @@ export const SampleColorDetail: FC = memo(props => { if (isNew) onChange(); }, [color, isNew, onChange]); + // Load this color's current sample-type exclusions (DataTypeSelector loads the sample-type list itself). A new + // color has none yet, so it starts with everything enabled and can be created with exclusions. + useEffect(() => { + setTypesError(undefined); + if (isNew) { + setExcludedTypes(new Set()); + setInitialExcludedTypes(new Set()); + setExclusionsLoaded(true); + return; + } + setExclusionsLoaded(false); + if (!color?.rowId) { + setExcludedTypes(new Set()); + setInitialExcludedTypes(new Set()); + return; + } + let cancelled = false; + (async () => { + try { + const excluded = await api.samples.getColorSampleTypeExclusions(color.rowId, container?.path); + if (cancelled) return; + setExcludedTypes(new Set(excluded)); + setInitialExcludedTypes(new Set(excluded)); + setExclusionsLoaded(true); + } catch (e) { + if (!cancelled) setTypesError('Unable to load sample type exclusions.'); + } + })(); + return () => { + cancelled = true; + }; + }, [api, color?.rowId, container?.path, isNew]); + + // DataTypeSelector reports the full set of unchecked (excluded) sample-type rowIds on every change / select-all. + const onExcludedTypesChange = useCallback( + (_dataType: string, unchecked: number[]): void => { + setExcludedTypes(new Set(unchecked)); + setDirty(true); + onChange(); + }, + [onChange] + ); + const onLabelChange = useCallback( (evt): void => { setUpdated(prev => ({ ...prev, label: evt.target.value })); @@ -79,21 +131,20 @@ export const SampleColorDetail: FC = memo(props => { [onChange] ); - const saveRows = useCallback( - (rows: SampleColorModel[], isInsert: boolean, successLabel?: string, isDelete = false) => { + const saveColor = useCallback( + (toSave: SampleColorModel, exclusionDelta?: { newlyDisabled: number[]; newlyEnabled: number[] }) => { setError(undefined); setSaving(true); - const schemaQuery = SCHEMAS.EXP_TABLES.DATA_COLORS; - const opts = { schemaQuery, containerPath: container?.path }; - let promise: Promise; - if (isDelete) promise = api.query.deleteRows({ ...opts, rows }); - else if (isInsert) promise = api.query.insertRows({ ...opts, rows: List(rows) }); - else promise = api.query.updateRows({ ...opts, rows }); - - promise - .then(() => onActionComplete(successLabel, isDelete)) + api.samples + .updateColorSettings( + toSave, + exclusionDelta?.newlyDisabled ?? [], + exclusionDelta?.newlyEnabled ?? [], + container?.path + ) + .then(() => onActionComplete(toSave.label)) .catch(reason => { - setError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', isDelete ? 'delete' : 'save')); + setError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', 'save')); setSaving(false); }); }, @@ -102,19 +153,33 @@ export const SampleColorDetail: FC = memo(props => { const onSave = useCallback(() => { const toSave = { ...updated, label: updated.label?.trim() }; - saveRows([toSave], !toSave.rowId, toSave.label); - }, [updated, saveRows]); + const exclusionDelta = { + newlyDisabled: Array.from(excludedTypes).filter(id => !initialExcludedTypes.has(id)), + newlyEnabled: Array.from(initialExcludedTypes).filter(id => !excludedTypes.has(id)), + }; + saveColor(toSave, exclusionDelta); + }, [updated, excludedTypes, initialExcludedTypes, saveColor]); const onToggleArchive = useCallback(() => { - const toSave = { ...updated, archived: !updated.archived }; - saveRows([toSave], false, toSave.label); - }, [updated, saveRows]); + saveColor({ ...updated, archived: !updated.archived }); + }, [updated, saveColor]); const onToggleDeleteConfirm = useCallback(() => setShowDeleteConfirm(s => !s), []); const onDeleteConfirm = useCallback(() => { - if (updated.rowId) saveRows([updated], false, undefined, true); - else setShowDeleteConfirm(false); - }, [updated, saveRows]); + if (!updated.rowId) { + setShowDeleteConfirm(false); + return; + } + setError(undefined); + setSaving(true); + api.query + .deleteRows({ schemaQuery: SCHEMAS.EXP_TABLES.DATA_COLORS, containerPath: container?.path, rows: [updated] }) + .then(() => onActionComplete(undefined, true)) + .catch(reason => { + setError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', 'delete')); + setSaving(false); + }); + }, [api, container?.path, onActionComplete, updated]); if (!updated && color !== null) { return

Select a sample color to view details.

; @@ -158,12 +223,30 @@ export const SampleColorDetail: FC = memo(props => { />
+
+
+ +
+
+ {typesError && {typesError}} + {!exclusionsLoaded && !typesError && } + {exclusionsLoaded && ( + + )} +
+
{!isNew && ( <> - + )}
{includeMetricUnitProperty && ( diff --git a/packages/components/src/internal/components/editable/Cell.tsx b/packages/components/src/internal/components/editable/Cell.tsx index 3bbf9cf6f4..9c85320201 100644 --- a/packages/components/src/internal/components/editable/Cell.tsx +++ b/packages/components/src/internal/components/editable/Cell.tsx @@ -576,6 +576,11 @@ export class Cell extends React.PureComponent { showLabel={false} value={values?.get(0)?.raw} values={values} + queryFilters={ + columnMetadata?.lookupValueFilters + ? { [col.name]: List(columnMetadata.lookupValueFilters) } + : undefined + } /> ); } diff --git a/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx b/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx index b493016cf2..6a342d08a8 100644 --- a/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx +++ b/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx @@ -5,7 +5,7 @@ import React, { FC, memo, ReactNode, useMemo } from 'react'; import { List, OrderedMap } from 'immutable'; import numeral from 'numeral'; -import { Query } from '@labkey/api'; +import { Filter, Query } from '@labkey/api'; import classNames from 'classnames'; @@ -57,6 +57,8 @@ export interface RenderOptions { containerPath?: string; hideLabel?: boolean; includeSpacesWarning?: boolean; + /** Per-column lookup filters (keyed by column name) applied to query-based input renderers. */ + queryFilters?: Record>; } export interface EditRendererOptions extends RenderOptions { @@ -176,6 +178,7 @@ export const DetailDisplay: FC = memo(props => { fileInputRenderer, fieldHelpTexts, onAdditionalFormDataChange, + queryFilters, tableCls, internalSpacesWarningFieldKeys, } = props; @@ -196,7 +199,7 @@ export const DetailDisplay: FC = memo(props => { if (data.size === 0) { body =
No data available.
; } else { - const options = { containerFilter, containerPath } as RenderOptions; + const options = { containerFilter, containerPath, queryFilters } as RenderOptions; if (editingMode) options.hideLabel = true; const fields = processFields( @@ -315,6 +318,7 @@ export function resolveDetailEditRenderer( key={col.name} onAdditionalFormDataChange={onAdditionalFormDataChange} onSelectChange={options?.onSelectChange} + queryFilters={options?.queryFilters} selectInputProps={{ inputClass: DETAIL_INPUT_WRAPPER_CLASS_NAME, showLabel }} showLabel={showLabel} value={value} diff --git a/packages/components/src/internal/components/forms/input/SampleColorInput.tsx b/packages/components/src/internal/components/forms/input/SampleColorInput.tsx index 64668d049d..68c8ac3017 100644 --- a/packages/components/src/internal/components/forms/input/SampleColorInput.tsx +++ b/packages/components/src/internal/components/forms/input/SampleColorInput.tsx @@ -2,7 +2,7 @@ * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React, { FC, memo, ReactNode } from 'react'; +import React, { FC, memo, ReactNode, useMemo } from 'react'; import { List } from 'immutable'; import { Filter } from '@labkey/api'; @@ -12,16 +12,24 @@ import { LOOKUP_DEFAULT_SIZE } from '../../../constants'; import { NON_ARCHIVED_COLOR_FILTER } from '../../samples/constants'; import { InputRendererProps } from './types'; - -const COLOR_QUERY_FILTERS = List([NON_ARCHIVED_COLOR_FILTER]); +import { caseInsensitive } from '../../../util/utils'; interface SampleColorInputProps extends Omit { col: QueryColumn; + queryFilters?: List; renderLabelField?: (col: QueryColumn) => ReactNode; } export const SampleColorInput: FC = memo(props => { - const { col, renderLabelField, ...querySelectProps } = props; + const { col, renderLabelField, queryFilters, ...querySelectProps } = props; + + const filters = useMemo(() => { + let result = List([NON_ARCHIVED_COLOR_FILTER]); + if (queryFilters && !queryFilters.isEmpty()) { + result = result.concat(queryFilters).toList(); + } + return result; + }, [queryFilters]); return ( <> @@ -39,7 +47,7 @@ export const SampleColorInput: FC = memo(props => { required={col.required} showLoading={false} {...querySelectProps} - queryFilters={COLOR_QUERY_FILTERS} + queryFilters={filters} schemaQuery={col.lookup.schemaQuery} valueColumn={col.lookup.keyColumn} /> @@ -63,6 +71,7 @@ export const SampleColorInputRenderer: FC = memo(props => { showAsteriskSymbol, value, fieldWithMixedValues, + queryFilters, } = props; const hasMixedValue = fieldWithMixedValues?.includes(col.name.toLowerCase()); @@ -79,6 +88,7 @@ export const SampleColorInputRenderer: FC = memo(props => { initiallyDisabled={initiallyDisabled} onQSChange={onSelectChange} onToggleDisable={onToggleDisable} + queryFilters={caseInsensitive(queryFilters, col.name)} renderLabelField={renderLabelField} value={value} /> diff --git a/packages/components/src/internal/components/samples/APIWrapper.ts b/packages/components/src/internal/components/samples/APIWrapper.ts index 595194eb67..8237320bef 100644 --- a/packages/components/src/internal/components/samples/APIWrapper.ts +++ b/packages/components/src/internal/components/samples/APIWrapper.ts @@ -21,6 +21,7 @@ import { getGroupedSampleDomainFields, getLookupRowIdsFromSelection, getColorSampleTypeExclusions, + getSampleTypeColorExclusions, getSampleAliquotRows, getSampleAssayResultViewConfigs, getSampleColors, @@ -28,6 +29,8 @@ import { getSampleStatuses, getSampleStorageId, getSampleTypeDetails, + getSampleTypeLabelColor, + getSampleTypeRowId, getSelectionLineageData, getTimelineEvents, hasExistingSamples, @@ -65,6 +68,12 @@ export interface SamplesAPIWrapper { getColorSampleTypeExclusions: (colorRowId: number, containerPath?: string) => Promise; + getSampleTypeColorExclusions: ( + sampleTypeRowId?: number, + sampleTypeName?: string, + containerPath?: string + ) => Promise; + updateColorSettings: ( color: SampleColorModel, newlyDisabledTypeIds: number[], @@ -92,6 +101,10 @@ export interface SamplesAPIWrapper { includeNamePreview?: boolean ) => Promise; + getSampleTypeLabelColor: (name: string) => Promise; + + getSampleTypeRowId: (name: string) => Promise; + getSelectionLineageData: ( selection: Set, schema: string, @@ -124,11 +137,14 @@ export class SamplesServerAPIWrapper implements SamplesAPIWrapper { getSelectionLineageData = getSelectionLineageData; getSampleColors = getSampleColors; getColorSampleTypeExclusions = getColorSampleTypeExclusions; + getSampleTypeColorExclusions = getSampleTypeColorExclusions; updateColorSettings = updateColorSettings; getSampleStatuses = getSampleStatuses; getDefaultDiscardStatus = getDefaultDiscardStatus; getSampleOperationConfirmationData = getSampleOperationConfirmationData; getSampleStorageId = getSampleStorageId; + getSampleTypeLabelColor = getSampleTypeLabelColor; + getSampleTypeRowId = getSampleTypeRowId; getLookupRowIdsFromSelection = getLookupRowIdsFromSelection; getTimelineEvents = getTimelineEvents; getSampleTypeDetails = getSampleTypeDetails; @@ -153,11 +169,14 @@ export function getSamplesTestAPIWrapper( getSelectionLineageData: mockFn(), getSampleColors: mockFn(), getColorSampleTypeExclusions: mockFn(), + getSampleTypeColorExclusions: mockFn(), updateColorSettings: mockFn(), getSampleStatuses: mockFn(), getDefaultDiscardStatus: mockFn(), getSampleOperationConfirmationData: mockFn(), getSampleStorageId: mockFn(), + getSampleTypeLabelColor: mockFn(), + getSampleTypeRowId: mockFn(), getLookupRowIdsFromSelection: mockFn(), getTimelineEvents: mockFn(), getSampleTypeDetails: mockFn(), diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx index c0032f288f..fedff55a52 100644 --- a/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx @@ -23,6 +23,7 @@ const makeColor = (rowId: number): SampleColorModel => ({ const renderSetting = ( colors: SampleColorModel[], + excluded: number[] = [], props: Partial> = {}, user = TEST_USER_APP_ADMIN ) => { @@ -30,11 +31,12 @@ const renderSetting = ( api: getTestAPIWrapper(jest.fn, { samples: getSamplesTestAPIWrapper(jest.fn, { getSampleColors: jest.fn().mockResolvedValue(colors), + getSampleTypeColorExclusions: jest.fn().mockResolvedValue(excluded), }), }), }; return renderWithAppContext( - , + , { appContext, serverContext: { user } } ); }; @@ -47,13 +49,13 @@ describe('SampleColorsSetting', () => { }); test('empty state shows the "Add Colors" link only for an app admin', async () => { - renderSetting([], {}, TEST_USER_APP_ADMIN); + renderSetting([], [], {}, TEST_USER_APP_ADMIN); await waitFor(() => expect(screen.getByText('No colors are set up yet.')).toBeInTheDocument()); expect(screen.getByRole('link', { name: 'Add Colors' })).toBeInTheDocument(); }); test('empty state hides the "Add Colors" link for a non-app-admin', async () => { - renderSetting([], {}, TEST_USER_EDITOR); + renderSetting([], [], {}, TEST_USER_EDITOR); await waitFor(() => expect(screen.getByText('No colors are set up yet.')).toBeInTheDocument()); expect(screen.queryByRole('link', { name: 'Add Colors' })).not.toBeInTheDocument(); }); @@ -65,10 +67,8 @@ describe('SampleColorsSetting', () => { expect(container.querySelectorAll('.sample-colors-setting__dot-more')).toHaveLength(0); }); - test('disabledRowIds are excluded from the enabled count and dots', async () => { - const { container } = renderSetting([makeColor(1), makeColor(2), makeColor(3)], { - disabledRowIds: [2, 3], - }); + test('saved exclusions are excluded from the enabled count and dots', async () => { + const { container } = renderSetting([makeColor(1), makeColor(2), makeColor(3)], [2, 3]); await waitFor(() => expect(screen.getByText('1 colors enabled.')).toBeInTheDocument()); expect(container.querySelectorAll('.sample-colors-setting__dot')).toHaveLength(1); }); @@ -86,7 +86,7 @@ describe('SampleColorsSetting', () => { test('editing colors and applying reports the new disabled set via onChange', async () => { const onChange = jest.fn(); - renderSetting([makeColor(1), makeColor(2)], { onChange }); + renderSetting([makeColor(1), makeColor(2)], [], { onChange }); await waitFor(() => expect(screen.getByText('2 colors enabled.')).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Edit' })); @@ -105,10 +105,11 @@ describe('SampleColorsSetting', () => { api: getTestAPIWrapper(jest.fn, { samples: getSamplesTestAPIWrapper(jest.fn, { getSampleColors: jest.fn().mockRejectedValue(new Error('boom')), + getSampleTypeColorExclusions: jest.fn().mockResolvedValue([]), }), }), }; - renderWithAppContext(, { + renderWithAppContext(, { appContext, serverContext: { user: TEST_USER_APP_ADMIN }, }); diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx index ec8df724b1..e65b0a8081 100644 --- a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx @@ -21,14 +21,15 @@ const MAX_DOTS = 20; const HELP_TIP = 'Set up colors that can be applied to individual samples, overriding the sample type color.'; interface Props { - disabledRowIds: number[]; + sampleTypeRowId?: number; onChange: (disabledRowIds: number[]) => void; } -export const SampleColorsSetting: FC = memo(({ disabledRowIds, onChange }) => { +export const SampleColorsSetting: FC = memo(({ sampleTypeRowId, onChange }) => { const { api } = useAppContext(); const { user } = useServerContext(); const [colors, setColors] = useState(); + const [disabledSet, setDisabledSet] = useState>(new Set()); const [error, setError] = useState(); const [showModal, setShowModal] = useState(false); const [draftDisabled, setDraftDisabled] = useState>(new Set()); @@ -36,21 +37,25 @@ export const SampleColorsSetting: FC = memo(({ disabledRowIds, onChange } useEffect(() => { (async () => { try { - setColors(await api.samples.getSampleColors()); + const [allColors, excluded] = await Promise.all([ + api.samples.getSampleColors(), + sampleTypeRowId ? api.samples.getSampleTypeColorExclusions(sampleTypeRowId) : Promise.resolve([]), + ]); + setColors(allColors); + setDisabledSet(new Set(excluded)); } catch (e) { setColors([]); setError('Unable to load sample colors.'); } })(); - }, [api]); + }, [api, sampleTypeRowId]); - const disabledSet = useMemo(() => new Set(disabledRowIds ?? []), [disabledRowIds]); const enabledColors = useMemo(() => (colors ?? []).filter(c => !disabledSet.has(c.rowId)), [colors, disabledSet]); const openModal = useCallback(() => { - setDraftDisabled(new Set(disabledRowIds ?? [])); + setDraftDisabled(new Set(disabledSet)); setShowModal(true); - }, [disabledRowIds]); + }, [disabledSet]); const closeModal = useCallback(() => setShowModal(false), []); @@ -64,6 +69,7 @@ export const SampleColorsSetting: FC = memo(({ disabledRowIds, onChange } }, []); const onConfirm = useCallback(() => { + setDisabledSet(new Set(draftDisabled)); onChange(Array.from(draftDisabled)); setShowModal(false); }, [draftDisabled, onChange]); diff --git a/packages/components/src/internal/components/samples/actions.ts b/packages/components/src/internal/components/samples/actions.ts index b160f2c2df..01bc34ffc9 100644 --- a/packages/components/src/internal/components/samples/actions.ts +++ b/packages/components/src/internal/components/samples/actions.ts @@ -185,6 +185,43 @@ export async function getSampleStorageId(sampleRowId: number): Promise { return caseInsensitive(result.rows[0], 'RowId').value; } +function getSampleTypeRow(name: string, fieldKey: string): Promise { + return new Promise((resolve, reject) => { + selectRows({ + schemaQuery: SCHEMAS.EXP_TABLES.SAMPLE_SETS, + columns: 'Name,' + fieldKey, + }) + .then(response => { + const { rows } = response; + let rowFound; + rows.forEach(row => { + if (name.toLowerCase() === caseInsensitive(row, 'Name').value.toLowerCase()) { + rowFound = row; + return; + } + }); + + if (!rowFound) { + reject(`Sample Type with name '${name}' not found.`); + return; + } + resolve(caseInsensitive(rowFound, fieldKey).value); + }) + .catch(reason => { + console.error(reason); + reject(resolveErrorMessage(reason)); + }); + }); +} + +export function getSampleTypeRowId(name: string): Promise { + return getSampleTypeRow(name, 'RowId'); +} + +export function getSampleTypeLabelColor(name: string): Promise { + return getSampleTypeRow(name, 'LabelColor'); +} + export function getSampleColors(includeArchive = false, containerPath?: string): Promise { return selectRows({ columns: 'RowId,Label,Color,Archived', @@ -202,7 +239,6 @@ export function getSampleColors(includeArchive = false, containerPath?: string): ); } -// Returns the sample type RowIds that currently exclude the given color, via the GetColorDataTypeExclusion action. export function getColorSampleTypeExclusions(colorRowId: number, containerPath?: string): Promise { return new Promise((resolve, reject) => { Ajax.request({ @@ -210,7 +246,7 @@ export function getColorSampleTypeExclusions(colorRowId: number, containerPath?: SAMPLE_MANAGER_APP_PROPERTIES.controllerName, 'getColorDataTypeExclusion.api', containerPath, - { colorRowId } + { rowId: colorRowId } ), success: Utils.getCallbackWrapper(response => resolve(response?.excludedSampleTypes ?? [])), failure: Utils.getCallbackWrapper(response => { @@ -221,6 +257,29 @@ export function getColorSampleTypeExclusions(colorRowId: number, containerPath?: }); } +export async function getSampleTypeColorExclusions( + sampleTypeRowId?: number, + sampleTypeName?: string, + containerPath?: string +): Promise { + const rowId = sampleTypeRowId ?? (await getSampleTypeRowId(sampleTypeName)); + return new Promise((resolve, reject) => { + Ajax.request({ + url: ActionURL.buildURL( + SAMPLE_MANAGER_APP_PROPERTIES.controllerName, + 'getSampleTypeColorExclusion.api', + containerPath, + { rowId } + ), + success: Utils.getCallbackWrapper(response => resolve(response?.excludedColors ?? [])), + failure: Utils.getCallbackWrapper(response => { + console.error(response); + reject(response); + }), + }); + }); +} + // Single write path for a sample color: creates (no rowId) or updates the color (label/color/archived) and, in the // same server transaction, applies the sample-type exclusion delta (only the changed types, so the request scales with // the edit, not the total number of sample types). The server (UpdateColorSettingsAction) audits each affected type diff --git a/packages/components/src/internal/components/samples/constants.ts b/packages/components/src/internal/components/samples/constants.ts index 712ece076b..197d1d5ddf 100644 --- a/packages/components/src/internal/components/samples/constants.ts +++ b/packages/components/src/internal/components/samples/constants.ts @@ -55,8 +55,8 @@ export const SAMPLE_STATUS_REQUIRED_COLUMNS = [ SAMPLE_STATE_COLOR_COLUMN_NAME, ]; -export const SAMPLE_COLOR_COLUMN_NAME = 'SampleColor'; -export const SAMPLE_COLOR_COLOR_COLUMN_NAME = 'SampleColor/Color'; +export const SAMPLE_COLOR_COLUMN_NAME = 'ExpMaterialColor'; +export const SAMPLE_COLOR_COLOR_COLUMN_NAME = 'ExpMaterialColor/Color'; // TODO, color fields not wired up yet export const SAMPLE_COLOR_REQUIRED_COLUMNS = [SAMPLE_COLOR_COLUMN_NAME, SAMPLE_COLOR_COLOR_COLUMN_NAME]; diff --git a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx index 518cfed85a..a0a70adcdc 100644 --- a/packages/components/src/public/QueryModel/EditableDetailPanel.tsx +++ b/packages/components/src/public/QueryModel/EditableDetailPanel.tsx @@ -3,8 +3,8 @@ * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import React, { FC, ReactNode, useCallback, useMemo, useState } from 'react'; -import { fromJS } from 'immutable'; -import { Query } from '@labkey/api'; +import { fromJS, List } from 'immutable'; +import { Filter, Query } from '@labkey/api'; import { Formsy } from '../../internal/components/forms/formsy'; import { DetailPanelHeader } from '../../internal/components/forms/detail/DetailPanelHeader'; @@ -48,6 +48,7 @@ export interface EditableDetailPanelProps { onEditToggle?: (editing: boolean) => void; onUpdate: () => void; queryColumns?: QueryColumn[]; + queryFilters?: Record>; submitText?: string; title?: string; } @@ -73,6 +74,7 @@ const EditingFormImpl: FC = props => { onCommentChange, onEditToggle, onUpdate, + queryFilters, queryModels, submitText = 'Save', title, @@ -189,6 +191,7 @@ const EditingFormImpl: FC = props => { internalSpacesWarningFieldKeys={internalSpacesWarningFieldKeys} model={editModel} onAdditionalFormDataChange={onAdditionalFormDataChange} + queryFilters={queryFilters} />
From 0de814fe9594fd935a5bfa36ffe0676a8bc96e0e Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 22 Jul 2026 09:52:45 -0700 Subject: [PATCH 05/14] add experimental flag --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- packages/components/src/index.ts | 2 ++ packages/components/src/internal/app/utils.ts | 4 ++++ .../samples/SampleTypePropertiesPanel.tsx | 11 +++++++---- .../samples/ManageSampleColorsPanel.tsx | 15 ++++++++------- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index c829a5d98c..549107b9f7 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.47.1-fb-sampleColors.0", + "version": "7.47.1-fb-sampleColors.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.47.1-fb-sampleColors.0", + "version": "7.47.1-fb-sampleColors.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 9761316cae..18c7916635 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.47.1-fb-sampleColors.0", + "version": "7.47.1-fb-sampleColors.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 187840948e..dc5ba41b3e 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -738,6 +738,7 @@ import { isProjectContainer, isProtectedDataEnabled, isRegistryEnabled, + isSampleColorsEnabled, isSampleStatusEnabled, isSharedContainer, isSourceTypeEnabled, @@ -951,6 +952,7 @@ const App = { isSharedContainer, freezerManagerIsCurrentApp, isSampleManagerEnabled, + isSampleColorsEnabled, isSampleStatusEnabled, isProductFoldersEnabled, isAllProductFoldersFilteringEnabled, diff --git a/packages/components/src/internal/app/utils.ts b/packages/components/src/internal/app/utils.ts index c019d385f0..716e2ed344 100644 --- a/packages/components/src/internal/app/utils.ts +++ b/packages/components/src/internal/app/utils.ts @@ -204,6 +204,10 @@ export function isSampleStatusEnabled(moduleContext?: ModuleContext): boolean { return hasSampleManagementModule(moduleContext); } +export function isSampleColorsEnabled(moduleContext?: ModuleContext): boolean { + return resolveModuleContext(moduleContext)?.experiment?.SampleColors === true; +} + export function isQueryMetadataEditor(): boolean { const action = ActionURL.getAction()?.toLowerCase() || ''; return action === 'metadataquery' || action.startsWith('querymetadataeditor'); diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index d87437cfa6..259fb0f042 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -32,6 +32,7 @@ import { ENTITY_FORM_IDS } from '../entities/constants'; import { AutoLinkToStudyDropdown } from '../AutoLinkToStudyDropdown'; import { isSampleManagerEnabled } from '../../../app/products'; +import { isSampleColorsEnabled } from '../../../app/utils'; import { getCurrentProductName, isCommunityDistribution } from '../../../app/utils'; import { PREFIX_SUBSTITUTION_EXPRESSION, PROPERTIES_PANEL_NAMING_PATTERN_WARNING_MSG } from '../constants'; @@ -615,10 +616,12 @@ class SampleTypePropertiesPanelImpl extends PureComponent - + {isSampleColorsEnabled() && ( + + )} {includeMetricUnitProperty && ( <>
diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx index 6a1594226a..e09419c365 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -53,6 +53,7 @@ export const SampleColorDetail: FC = memo(props => { const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(); + const [deleteError, setDeleteError] = useState(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [excludedTypes, setExcludedTypes] = useState>(new Set()); // The exclusions as loaded, so Save can send only the delta (newly disabled / newly enabled) rather than the full set. @@ -164,19 +165,18 @@ export const SampleColorDetail: FC = memo(props => { saveColor({ ...updated, archived: !updated.archived }); }, [updated, saveColor]); - const onToggleDeleteConfirm = useCallback(() => setShowDeleteConfirm(s => !s), []); + const onToggleDeleteConfirm = useCallback(() => { + setDeleteError(undefined); + setShowDeleteConfirm(s => !s); + }, []); const onDeleteConfirm = useCallback(() => { - if (!updated.rowId) { - setShowDeleteConfirm(false); - return; - } - setError(undefined); + setDeleteError(undefined); setSaving(true); api.query .deleteRows({ schemaQuery: SCHEMAS.EXP_TABLES.DATA_COLORS, containerPath: container?.path, rows: [updated] }) .then(() => onActionComplete(undefined, true)) .catch(reason => { - setError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', 'delete')); + setDeleteError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', 'delete')); setSaving(false); }); }, [api, container?.path, onActionComplete, updated]); @@ -279,6 +279,7 @@ export const SampleColorDetail: FC = memo(props => { onConfirm={onDeleteConfirm} title="Permanently Delete Color?" > + {deleteError && {deleteError}} The {updated.label} color will be permanently deleted.

From 49e5284e19492b6fd4bed3619bfa3fd63169ebfb Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 22 Jul 2026 14:15:12 -0700 Subject: [PATCH 06/14] 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 0f7997c61d..512c6bd5ba 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-sampleColors.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-sampleColors.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 49fcfc1c63..84e78b09b3 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.48.1-fb-sampleColors.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 8a57b9f5babca5f45a23c1d73848bc206f393c95 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 22 Jul 2026 15:13:59 -0700 Subject: [PATCH 07/14] lint --- packages/components/src/index.ts | 4 +- .../samples/SampleTypePropertiesPanel.tsx | 17 ++--- .../SampleTypePropertiesPanel.test.tsx.snap | 4 +- .../src/internal/components/editable/Cell.tsx | 15 ++-- .../forms/input/SampleColorInput.tsx | 2 +- .../internal/components/samples/APIWrapper.ts | 34 ++++----- .../samples/ManageSampleColorsPanel.test.tsx | 74 ++++++++++++++++--- .../samples/ManageSampleColorsPanel.tsx | 33 ++++++--- .../samples/SampleColorsSetting.test.tsx | 10 +-- .../samples/SampleColorsSetting.tsx | 2 +- .../internal/components/samples/actions.ts | 6 +- .../renderers/SampleColorRenderer.tsx | 2 +- 12 files changed, 134 insertions(+), 69 deletions(-) diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index e2fdc7a534..59ae7e9d89 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1554,8 +1554,8 @@ export { makeTestISelectRowsResult, makeTestQueryModel, ManageDropdownButton, - ManageSampleStatusesPanel, ManageSampleColorsPanel, + ManageSampleStatusesPanel, MAX_EDITABLE_GRID_ROWS, MAX_SELECTION_ACTION_ROWS, MEASUREMENT_UNITS, @@ -1672,6 +1672,7 @@ export { SAMPLE_TYPE_CONCEPT_URI, SAMPLE_TYPE_DESIGNER_ROLE, SampleAmountEditModal, + SampleColorRenderer, sampleDeleteDependencyText, SampleOperation, SampleParentDataType, @@ -1681,7 +1682,6 @@ export { SampleStateType, SampleStatusLegend, SampleStatusRenderer, - SampleColorRenderer, SampleStatusTag, SampleTypeDataType, SampleTypeDesigner, diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index 259fb0f042..e06920d390 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -32,8 +32,7 @@ import { ENTITY_FORM_IDS } from '../entities/constants'; import { AutoLinkToStudyDropdown } from '../AutoLinkToStudyDropdown'; import { isSampleManagerEnabled } from '../../../app/products'; -import { isSampleColorsEnabled } from '../../../app/utils'; -import { getCurrentProductName, isCommunityDistribution } from '../../../app/utils'; +import { getCurrentProductName, isCommunityDistribution, isSampleColorsEnabled } from '../../../app/utils'; import { PREFIX_SUBSTITUTION_EXPRESSION, PROPERTIES_PANEL_NAMING_PATTERN_WARNING_MSG } from '../constants'; @@ -102,7 +101,10 @@ export const UnitKinds: Record = { value: UNITS_KIND.COUNT, label: 'Other', hideSubSelect: true, - msg: "Amounts can be entered as " + makeCommaSeparatedString(MEASUREMENT_UNITS.unit.altLabels, ', or ') + " and won't be converted", + msg: + 'Amounts can be entered as ' + + makeCommaSeparatedString(MEASUREMENT_UNITS.unit.altLabels, ', or ') + + " and won't be converted", }, }; @@ -557,8 +559,8 @@ class SampleTypePropertiesPanelImpl extends PureComponent

} + id="linked-study-label" label="Auto-Link Data to Study" />
@@ -575,8 +577,8 @@ class SampleTypePropertiesPanelImpl extends PureComponent
} + id="linked-dataset-category-label" label="Linked Dataset Category" />
@@ -617,10 +619,7 @@ class SampleTypePropertiesPanelImpl extends PureComponent
{isSampleColorsEnabled() && ( - + )} {includeMetricUnitProperty && ( <> diff --git a/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap b/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap index a9d9871649..d577011f50 100644 --- a/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap +++ b/packages/components/src/internal/components/domainproperties/samples/__snapshots__/SampleTypePropertiesPanel.test.tsx.snap @@ -214,7 +214,7 @@ exports[`SampleTypePropertiesPanel appPropertiesOnly 1`] = ` class="col-xs-2" > - Label + Sample Type @@ -499,7 +499,7 @@ exports[`SampleTypePropertiesPanel appPropertiesOnly 1`] = ` class="col-xs-2" > - Label + Sample Type diff --git a/packages/components/src/internal/components/editable/Cell.tsx b/packages/components/src/internal/components/editable/Cell.tsx index 9c85320201..45ee586efb 100644 --- a/packages/components/src/internal/components/editable/Cell.tsx +++ b/packages/components/src/internal/components/editable/Cell.tsx @@ -527,7 +527,10 @@ export class Cell extends React.PureComponent { .filter(vd => vd && vd.display !== undefined) .reduce((v, vd, i) => v + (i > 0 ? ', ' : '') + vd.display, ''); - const showMenu = (showLookup || !!col.inputRenderer) && (NON_NEGATIVE_NUMBER_CONCEPT_URI !== col?.conceptURI /*storedamount has inputRenderer but shouldn't show menu*/); + const showMenu = + (showLookup || !!col.inputRenderer) && + NON_NEGATIVE_NUMBER_CONCEPT_URI !== + col?.conceptURI; /*storedamount has inputRenderer but shouldn't show menu*/ return ( <> @@ -566,6 +569,11 @@ export class Cell extends React.PureComponent { data={row} formsy={false} onSelectChange={this.onSelectChange} + queryFilters={ + columnMetadata?.lookupValueFilters + ? { [col.name]: List(columnMetadata.lookupValueFilters) } + : undefined + } selectInputProps={{ ...gridCellSelectInputProps, defaultInputValue: this.recordedKeys, @@ -576,11 +584,6 @@ export class Cell extends React.PureComponent { showLabel={false} value={values?.get(0)?.raw} values={values} - queryFilters={ - columnMetadata?.lookupValueFilters - ? { [col.name]: List(columnMetadata.lookupValueFilters) } - : undefined - } /> ); } diff --git a/packages/components/src/internal/components/forms/input/SampleColorInput.tsx b/packages/components/src/internal/components/forms/input/SampleColorInput.tsx index 68c8ac3017..6d9e3a8274 100644 --- a/packages/components/src/internal/components/forms/input/SampleColorInput.tsx +++ b/packages/components/src/internal/components/forms/input/SampleColorInput.tsx @@ -23,7 +23,7 @@ interface SampleColorInputProps extends Omit = memo(props => { const { col, renderLabelField, queryFilters, ...querySelectProps } = props; - const filters = useMemo(() => { + const filters = useMemo(() => { let result = List([NON_ARCHIVED_COLOR_FILTER]); if (queryFilters && !queryFilters.isEmpty()) { result = result.concat(queryFilters).toList(); diff --git a/packages/components/src/internal/components/samples/APIWrapper.ts b/packages/components/src/internal/components/samples/APIWrapper.ts index 8237320bef..b403b0fd78 100644 --- a/packages/components/src/internal/components/samples/APIWrapper.ts +++ b/packages/components/src/internal/components/samples/APIWrapper.ts @@ -16,18 +16,18 @@ import { DomainDetails } from '../domainproperties/models'; import { createSessionAssayRunSummaryQuery, + getColorSampleTypeExclusions, getDefaultDiscardStatus, getDistinctAssaysPerSample, getGroupedSampleDomainFields, getLookupRowIdsFromSelection, - getColorSampleTypeExclusions, - getSampleTypeColorExclusions, getSampleAliquotRows, getSampleAssayResultViewConfigs, getSampleColors, getSampleCounter, getSampleStatuses, getSampleStorageId, + getSampleTypeColorExclusions, getSampleTypeDetails, getSampleTypeLabelColor, getSampleTypeRowId, @@ -46,6 +46,8 @@ import { Row } from '../../query/selectRows'; export interface SamplesAPIWrapper { createSessionAssayRunSummaryQuery: (sampleIds: number[]) => Promise; + getColorSampleTypeExclusions: (colorRowId: number, containerPath?: string) => Promise; + getDefaultDiscardStatus: (containerPath?: string) => Promise; getDistinctAssaysPerSample: (sampleIds: number[]) => Promise; @@ -66,21 +68,6 @@ export interface SamplesAPIWrapper { getSampleColors: (includeArchive?: boolean, containerPath?: string) => Promise; - getColorSampleTypeExclusions: (colorRowId: number, containerPath?: string) => Promise; - - getSampleTypeColorExclusions: ( - sampleTypeRowId?: number, - sampleTypeName?: string, - containerPath?: string - ) => Promise; - - updateColorSettings: ( - color: SampleColorModel, - newlyDisabledTypeIds: number[], - newlyEnabledTypeIds: number[], - containerPath?: string - ) => Promise; - getSampleCounter: (seqType: 'rootSampleCount' | 'sampleCount', containerPath?: string) => Promise; getSampleOperationConfirmationData: ( @@ -94,6 +81,12 @@ export interface SamplesAPIWrapper { getSampleStorageId: (sampleRowId: number) => Promise; + getSampleTypeColorExclusions: ( + sampleTypeRowId?: number, + sampleTypeName?: string, + containerPath?: string + ) => Promise; + getSampleTypeDetails: ( query?: SchemaQuery, domainId?: number, @@ -127,6 +120,13 @@ export interface SamplesAPIWrapper { seqType: 'rootSampleCount' | 'sampleCount', containerPath?: string ) => Promise; + + updateColorSettings: ( + color: SampleColorModel, + newlyDisabledTypeIds: number[], + newlyEnabledTypeIds: number[], + containerPath?: string + ) => Promise; } export class SamplesServerAPIWrapper implements SamplesAPIWrapper { diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx index 1ac1f1e629..bb67b6c0ad 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx @@ -121,10 +121,16 @@ describe('ManageSampleColorsPanel', () => { expect(await screen.findByText('Error: Unable to load sample colors.')).toBeInTheDocument(); }); - const withTypes = (...types: any[]) => ({ getFolderConfigurableEntityTypeOptions: jest.fn().mockResolvedValue(types) }); + const withTypes = (...types: any[]) => ({ + getFolderConfigurableEntityTypeOptions: jest.fn().mockResolvedValue(types), + }); test('selecting a color loads its sample types with all enabled (checked) by default', async () => { - renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + renderPanel( + [makeColor(1, 'Red', '#ff0000')], + { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, + withTypes(TYPE_A, TYPE_B) + ); await userEvent.click(await screen.findByRole('button', { name: 'Red' })); expect(await screen.findByRole('checkbox', { name: 'Sample Type A' })).toBeChecked(); @@ -132,7 +138,11 @@ describe('ManageSampleColorsPanel', () => { }); test('an existing exclusion shows that sample type unchecked', async () => { - renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_B.rowId]) }, withTypes(TYPE_A, TYPE_B)); + renderPanel( + [makeColor(1, 'Red', '#ff0000')], + { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_B.rowId]) }, + withTypes(TYPE_A, TYPE_B) + ); await userEvent.click(await screen.findByRole('button', { name: 'Red' })); expect(await screen.findByRole('checkbox', { name: 'Sample Type A' })).toBeChecked(); @@ -140,7 +150,11 @@ describe('ManageSampleColorsPanel', () => { }); test('unchecking a sample type and saving folds the exclusion into the color save', async () => { - const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + const { samples } = renderPanel( + [makeColor(1, 'Red', '#ff0000')], + { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, + withTypes(TYPE_A, TYPE_B) + ); await userEvent.click(await screen.findByRole('button', { name: 'Red' })); await userEvent.click(await screen.findByRole('checkbox', { name: 'Sample Type A' })); @@ -148,11 +162,20 @@ describe('ManageSampleColorsPanel', () => { await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); // TYPE_A was enabled and is now unchecked -> newly disabled; nothing newly enabled. - expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [TYPE_A.rowId], [], undefined); + expect(samples.updateColorSettings).toHaveBeenCalledWith( + expect.objectContaining({ rowId: 1 }), + [TYPE_A.rowId], + [], + undefined + ); }); test('re-enabling a previously excluded sample type sends it as newly enabled on save', async () => { - const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_B.rowId]) }, withTypes(TYPE_A, TYPE_B)); + const { samples } = renderPanel( + [makeColor(1, 'Red', '#ff0000')], + { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_B.rowId]) }, + withTypes(TYPE_A, TYPE_B) + ); await userEvent.click(await screen.findByRole('button', { name: 'Red' })); // TYPE_B starts excluded (unchecked); checking it re-enables it. @@ -160,29 +183,52 @@ describe('ManageSampleColorsPanel', () => { await userEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); - expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [], [TYPE_B.rowId], undefined); + expect(samples.updateColorSettings).toHaveBeenCalledWith( + expect.objectContaining({ rowId: 1 }), + [], + [TYPE_B.rowId], + undefined + ); }); test('"Deselect All" excludes every sample type on save', async () => { - const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + const { samples } = renderPanel( + [makeColor(1, 'Red', '#ff0000')], + { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, + withTypes(TYPE_A, TYPE_B) + ); await userEvent.click(await screen.findByRole('button', { name: 'Red' })); await userEvent.click(await screen.findByRole('button', { name: 'Deselect All' })); await userEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); - expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [TYPE_A.rowId, TYPE_B.rowId], [], undefined); + expect(samples.updateColorSettings).toHaveBeenCalledWith( + expect.objectContaining({ rowId: 1 }), + [TYPE_A.rowId, TYPE_B.rowId], + [], + undefined + ); }); test('"Select All" re-enables every excluded sample type on save', async () => { - const { samples } = renderPanel([makeColor(1, 'Red', '#ff0000')], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_A.rowId, TYPE_B.rowId]) }, withTypes(TYPE_A, TYPE_B)); + const { samples } = renderPanel( + [makeColor(1, 'Red', '#ff0000')], + { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([TYPE_A.rowId, TYPE_B.rowId]) }, + withTypes(TYPE_A, TYPE_B) + ); await userEvent.click(await screen.findByRole('button', { name: 'Red' })); await userEvent.click(await screen.findByRole('button', { name: 'Select All' })); await userEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(samples.updateColorSettings).toHaveBeenCalledTimes(1)); - expect(samples.updateColorSettings).toHaveBeenCalledWith(expect.objectContaining({ rowId: 1 }), [], [TYPE_A.rowId, TYPE_B.rowId], undefined); + expect(samples.updateColorSettings).toHaveBeenCalledWith( + expect.objectContaining({ rowId: 1 }), + [], + [TYPE_A.rowId, TYPE_B.rowId], + undefined + ); }); test('a new color shows the sample-type section with everything enabled', async () => { @@ -195,7 +241,11 @@ describe('ManageSampleColorsPanel', () => { }); test('sample-type checkboxes are read-only (and no Select All) for an archived color', async () => { - renderPanel([makeColor(1, 'Gray', '#888888', true)], { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, withTypes(TYPE_A, TYPE_B)); + renderPanel( + [makeColor(1, 'Gray', '#888888', true)], + { getColorSampleTypeExclusions: jest.fn().mockResolvedValue([]) }, + withTypes(TYPE_A, TYPE_B) + ); // archived colors live behind the collapsible Archived Colors section await userEvent.click(await screen.findByRole('button', { name: /Archived Colors/ })); await userEvent.click(screen.getByRole('button', { name: 'Gray' })); diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx index e09419c365..d022153af3 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -29,8 +29,11 @@ const TITLE = 'Manage Sample Colors'; const NEW_COLOR_INDEX = -1; const MAX_DATA_COLORS = 200; const AT_LIMIT_HELP = - 'The maximum of ' + MAX_DATA_COLORS + ' sample colors (active and archived) has been reached. Delete a color before adding a new one.'; -const ARCHIVED_HELP = "Archived colors can't be applied to future samples, but may still be applied to existing samples."; + 'The maximum of ' + + MAX_DATA_COLORS + + ' sample colors (active and archived) has been reached. Delete a color before adding a new one.'; +const ARCHIVED_HELP = + "Archived colors can't be applied to future samples, but may still be applied to existing samples."; const APPLIES_TO_HELP = 'Choose which sample types this color can be applied to. All sample types are enabled by default; unchecking one excludes this color from that sample type.'; @@ -173,7 +176,11 @@ export const SampleColorDetail: FC = memo(props => { setDeleteError(undefined); setSaving(true); api.query - .deleteRows({ schemaQuery: SCHEMAS.EXP_TABLES.DATA_COLORS, containerPath: container?.path, rows: [updated] }) + .deleteRows({ + schemaQuery: SCHEMAS.EXP_TABLES.DATA_COLORS, + containerPath: container?.path, + rows: [updated], + }) .then(() => onActionComplete(undefined, true)) .catch(reason => { setDeleteError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', 'delete')); @@ -256,17 +263,17 @@ export const SampleColorDetail: FC = memo(props => {  Delete - )} - @@ -295,8 +302,8 @@ export const SampleColorDetail: FC = memo(props => { SampleColorDetail.displayName = 'SampleColorDetail'; interface SampleColorsListProps { - archivedColors: SampleColorModel[]; activeColors: SampleColorModel[]; + archivedColors: SampleColorModel[]; onSelect: (rowId: number) => void; selectedRowId: number; } @@ -323,7 +330,9 @@ export const SampleColorsList: FC = memo(props => { return ( <>
-

Set up colors that can be applied to individual samples, overriding the sample type color.

+

+ Set up colors that can be applied to individual samples, overriding the sample type color. +

{activeColors.map(renderItem)}
{archivedColors.length > 0 && ( diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx index fedff55a52..0f3700a0f7 100644 --- a/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx @@ -35,10 +35,10 @@ const renderSetting = ( }), }), }; - return renderWithAppContext( - , - { appContext, serverContext: { user } } - ); + return renderWithAppContext(, { + appContext, + serverContext: { user }, + }); }; describe('SampleColorsSetting', () => { @@ -109,7 +109,7 @@ describe('SampleColorsSetting', () => { }), }), }; - renderWithAppContext(, { + renderWithAppContext(, { appContext, serverContext: { user: TEST_USER_APP_ADMIN }, }); diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx index e65b0a8081..212c3654b2 100644 --- a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx @@ -21,8 +21,8 @@ const MAX_DOTS = 20; const HELP_TIP = 'Set up colors that can be applied to individual samples, overriding the sample type color.'; interface Props { - sampleTypeRowId?: number; onChange: (disabledRowIds: number[]) => void; + sampleTypeRowId?: number; } export const SampleColorsSetting: FC = memo(({ sampleTypeRowId, onChange }) => { diff --git a/packages/components/src/internal/components/samples/actions.ts b/packages/components/src/internal/components/samples/actions.ts index 01bc34ffc9..06663aa15b 100644 --- a/packages/components/src/internal/components/samples/actions.ts +++ b/packages/components/src/internal/components/samples/actions.ts @@ -292,7 +292,11 @@ export function updateColorSettings( ): Promise { return new Promise((resolve, reject) => { Ajax.request({ - url: ActionURL.buildURL(SAMPLE_MANAGER_APP_PROPERTIES.controllerName, 'updateColorSettings.api', containerPath), + url: ActionURL.buildURL( + SAMPLE_MANAGER_APP_PROPERTIES.controllerName, + 'updateColorSettings.api', + containerPath + ), method: 'POST', jsonData: { rowId: color.rowId, diff --git a/packages/components/src/internal/renderers/SampleColorRenderer.tsx b/packages/components/src/internal/renderers/SampleColorRenderer.tsx index 4d53d24d1f..b37460b51f 100644 --- a/packages/components/src/internal/renderers/SampleColorRenderer.tsx +++ b/packages/components/src/internal/renderers/SampleColorRenderer.tsx @@ -26,7 +26,7 @@ export const SampleColorRenderer: FC = memo(({ data, row }) => { caseInsensitive(rowJS, 'SampleID/' + SAMPLE_COLOR_COLOR_COLUMN_NAME)?.value; } - return ; + return ; }); SampleColorRenderer.displayName = 'SampleColorRenderer'; From 6ba8dfddc008465079cca7cdffb22c0effaf762a Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 22 Jul 2026 15:29:15 -0700 Subject: [PATCH 08/14] lint --- 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 512c6bd5ba..e487f988a5 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.0", + "version": "7.48.1-fb-sampleColors.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.0", + "version": "7.48.1-fb-sampleColors.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 84e78b09b3..b416ac3364 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.0", + "version": "7.48.1-fb-sampleColors.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 8f5d77f0d5fadad2a2a080eef7b371f5b78bca16 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 23 Jul 2026 19:57:37 -0700 Subject: [PATCH 09/14] code review changes, bug fixes, add junit tests --- packages/components/package-lock.json | 4 +- packages/components/package.json | 2 +- .../internal/components/samples/APIWrapper.ts | 2 +- .../samples/ManageSampleColorsPanel.test.tsx | 37 ++++++ .../samples/ManageSampleColorsPanel.tsx | 44 ++++--- .../samples/SampleColorsSetting.test.tsx | 74 +++++++++++- .../samples/SampleColorsSetting.tsx | 112 +++++++++++------- .../internal/components/samples/actions.ts | 42 +++++-- .../internal/components/samples/constants.ts | 1 + .../src/internal/components/samples/models.ts | 1 + packages/components/src/theme/form.scss | 2 +- 11 files changed, 245 insertions(+), 76 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index e487f988a5..e29a91bd35 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.1", + "version": "7.48.1-fb-sampleColors.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.1", + "version": "7.48.1-fb-sampleColors.2", "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 b416ac3364..465c43888d 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.1", + "version": "7.48.1-fb-sampleColors.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/components/samples/APIWrapper.ts b/packages/components/src/internal/components/samples/APIWrapper.ts index b403b0fd78..7489661c73 100644 --- a/packages/components/src/internal/components/samples/APIWrapper.ts +++ b/packages/components/src/internal/components/samples/APIWrapper.ts @@ -66,7 +66,7 @@ export interface SamplesAPIWrapper { getSampleAssayResultViewConfigs: () => Promise; - getSampleColors: (includeArchive?: boolean, containerPath?: string) => Promise; + getSampleColors: (includeArchive?: boolean, checkInUse?: boolean, containerPath?: string) => Promise; getSampleCounter: (seqType: 'rootSampleCount' | 'sampleCount', containerPath?: string) => Promise; diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx index bb67b6c0ad..8c83bab5ee 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx @@ -116,6 +116,43 @@ describe('ManageSampleColorsPanel', () => { expect(options.rows[0]).toMatchObject({ rowId: 1 }); }); + test('blocks navigating to other colors when there are unsaved edits', async () => { + renderPanel([makeColor(1, 'Red', '#ff0000'), makeColor(2, 'Blue', '#0000ff')]); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + // both list items are clickable before any edit + expect(screen.getByRole('button', { name: 'Blue' })).toBeEnabled(); + + // editing the label marks the panel dirty + await userEvent.type(screen.getByPlaceholderText('Enter color label'), 'X'); + + // the other color becomes unclickable; the color being edited stays selectable + expect(screen.getByRole('button', { name: 'Blue' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Red' })).toBeEnabled(); + // and the "New Color" button is disabled too (AddEntityButton uses a disabled class rather than the attribute) + expect(screen.getByRole('button', { name: /Color/ })).toHaveClass('disabled'); + }); + + test('limits the label input to the max length (64)', async () => { + renderPanel([makeColor(1, 'Red', '#ff0000')]); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + expect(screen.getByPlaceholderText('Enter color label')).toHaveAttribute('maxlength', '64'); + }); + + test('disables Delete for a color that is in use', async () => { + const { query } = renderPanel([{ ...makeColor(1, 'Red', '#ff0000'), inUse: true }]); + await userEvent.click(await screen.findByRole('button', { name: 'Red' })); + + const deleteButton = screen.getByRole('button', { name: 'Delete' }); + expect(deleteButton).toBeDisabled(); + + // clicking a disabled button is a no-op: no confirmation modal is shown and deleteRows is never called + await userEvent.click(deleteButton); + expect(screen.queryByRole('button', { name: 'Yes, Delete' })).not.toBeInTheDocument(); + expect(query.deleteRows).not.toHaveBeenCalled(); + }); + test('shows an error when the colors query fails', async () => { renderPanel([], { getSampleColors: jest.fn().mockRejectedValue(new Error('boom')) }); expect(await screen.findByText('Error: Unable to load sample colors.')).toBeInTheDocument(); diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx index d022153af3..82b99499a0 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -12,6 +12,7 @@ import { LabelHelpTip } from '../base/LabelHelpTip'; import { Modal } from '../../Modal'; import { ChoicesListItem } from '../base/ChoicesListItem'; import { AddEntityButton } from '../buttons/AddEntityButton'; +import { DisableableButton } from '../buttons/DisableableButton'; import { DomainFieldLabel } from '../domainproperties/DomainFieldLabel'; import { ColorPickerInput } from '../forms/input/ColorPickerInput'; import { SCHEMAS } from '../../schemas'; @@ -28,6 +29,7 @@ import { SampleColorModel } from './models'; const TITLE = 'Manage Sample Colors'; const NEW_COLOR_INDEX = -1; const MAX_DATA_COLORS = 200; +const MAX_LABEL_LENGTH = 64; // matches exp.DataColors.Label VARCHAR(64) and the server-side validation const AT_LIMIT_HELP = 'The maximum of ' + MAX_DATA_COLORS + @@ -78,18 +80,13 @@ export const SampleColorDetail: FC = memo(props => { // color has none yet, so it starts with everything enabled and can be created with exclusions. useEffect(() => { setTypesError(undefined); - if (isNew) { + if (isNew || !color?.rowId) { setExcludedTypes(new Set()); setInitialExcludedTypes(new Set()); setExclusionsLoaded(true); return; } setExclusionsLoaded(false); - if (!color?.rowId) { - setExcludedTypes(new Set()); - setInitialExcludedTypes(new Set()); - return; - } let cancelled = false; (async () => { try { @@ -194,6 +191,11 @@ export const SampleColorDetail: FC = memo(props => { if (!updated) return null; const canSave = dirty && !saving && !!updated.color && !!updated.label?.trim(); + const deleteDisabledMsg = saving + ? 'Please wait for the current operation to finish.' + : updated.inUse + ? 'This color is in use by one or more samples and cannot be deleted.' + : undefined; return ( <> @@ -208,6 +210,7 @@ export const SampleColorDetail: FC = memo(props => { aria-labelledby="color-label-label" className="form-control" disabled={saving} + maxLength={MAX_LABEL_LENGTH} name="label" onChange={onLabelChange} placeholder="Enter color label" @@ -254,17 +257,12 @@ export const SampleColorDetail: FC = memo(props => {
{!isNew && ( <> - +
); diff --git a/packages/components/src/internal/components/samples/actions.ts b/packages/components/src/internal/components/samples/actions.ts index 06663aa15b..e0fbfaafcf 100644 --- a/packages/components/src/internal/components/samples/actions.ts +++ b/packages/components/src/internal/components/samples/actions.ts @@ -43,6 +43,7 @@ import { QueryInfo } from '../../../public/QueryInfo'; import { ALL_AMOUNT_AND_UNITS_COLUMNS_LC, NON_ARCHIVED_COLOR_FILTER, + SAMPLE_COLOR_COLUMN_NAME, SAMPLE_STORAGE_COLUMNS_LC, STORED_AMOUNT_FIELDS, } from './constants'; @@ -222,21 +223,41 @@ export function getSampleTypeLabelColor(name: string): Promise { return getSampleTypeRow(name, 'LabelColor'); } -export function getSampleColors(includeArchive = false, containerPath?: string): Promise { - return selectRows({ +export async function getSampleColors( + includeArchive = false, + checkInUse = false, + containerPath?: string +): Promise { + const response = await selectRows({ columns: 'RowId,Label,Color,Archived', containerPath, filterArray: includeArchive ? undefined : [NON_ARCHIVED_COLOR_FILTER], schemaQuery: SCHEMAS.EXP_TABLES.DATA_COLORS, sort: 'Label', - }).then(response => - response.rows.map(row => ({ - rowId: caseInsensitive(row, 'RowId').value, - label: caseInsensitive(row, 'Label').value, - color: caseInsensitive(row, 'Color').value, - archived: !!caseInsensitive(row, 'Archived').value, - })) + }); + + const colors: SampleColorModel[] = response.rows.map(row => ({ + rowId: caseInsensitive(row, 'RowId').value, + label: caseInsensitive(row, 'Label').value, + color: caseInsensitive(row, 'Color').value, + archived: !!caseInsensitive(row, 'Archived').value, + })); + + if (!checkInUse || colors.length === 0) { + return colors; + } + + const inUseResponse = await selectDistinctRows({ + column: SAMPLE_COLOR_COLUMN_NAME, + containerPath, + schemaName: SCHEMAS.EXP_TABLES.MATERIALS.schemaName, + queryName: SCHEMAS.EXP_TABLES.MATERIALS.queryName, + }); + const inUseRowIds = new Set( + (inUseResponse.values ?? []).filter(value => value !== null && value !== undefined).map(value => Number(value)) ); + + return colors.map(color => ({ ...color, inUse: inUseRowIds.has(color.rowId) })); } export function getColorSampleTypeExclusions(colorRowId: number, containerPath?: string): Promise { @@ -262,6 +283,9 @@ export async function getSampleTypeColorExclusions( sampleTypeName?: string, containerPath?: string ): Promise { + if (!sampleTypeRowId && !sampleTypeName) { + throw new Error('Either sampleTypeRowId or sampleTypeName is required.'); + } const rowId = sampleTypeRowId ?? (await getSampleTypeRowId(sampleTypeName)); return new Promise((resolve, reject) => { Ajax.request({ diff --git a/packages/components/src/internal/components/samples/constants.ts b/packages/components/src/internal/components/samples/constants.ts index 197d1d5ddf..066f4eb859 100644 --- a/packages/components/src/internal/components/samples/constants.ts +++ b/packages/components/src/internal/components/samples/constants.ts @@ -217,6 +217,7 @@ export const SAMPLE_IMPORT_EXTRA_ALLOWED_COLUMNS = [ 'EnteredStorage', 'ExpirationDate', 'StorageUnitBarcode', + 'SampleColor', ]; export const SAMPLE_DATA_EXPORT_CONFIG = { diff --git a/packages/components/src/internal/components/samples/models.ts b/packages/components/src/internal/components/samples/models.ts index 08b16007f9..07cb595291 100644 --- a/packages/components/src/internal/components/samples/models.ts +++ b/packages/components/src/internal/components/samples/models.ts @@ -7,6 +7,7 @@ import { immerable, produce } from 'immer'; export interface SampleColorModel { archived: boolean; color: string; + inUse?: boolean; label: string; rowId?: number; } diff --git a/packages/components/src/theme/form.scss b/packages/components/src/theme/form.scss index 186a5e5c53..0ca4b69358 100644 --- a/packages/components/src/theme/form.scss +++ b/packages/components/src/theme/form.scss @@ -508,5 +508,5 @@ textarea.form-control { align-items: center; justify-content: center; font-size: 16px; - color: #000; + color: $gray-base; } From 0898f07fe456b9d590d8de07efc24c4f3e7e5dab Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 23 Jul 2026 20:04:00 -0700 Subject: [PATCH 10/14] 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 e29a91bd35..383e62745b 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.2", + "version": "7.48.1-fb-sampleColors.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.2", + "version": "7.48.1-fb-sampleColors.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 465c43888d..b76418e3ea 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.2", + "version": "7.48.1-fb-sampleColors.3", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From aa56c84c51ae50fe13f7db693dc4fea80e19f01d Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 27 Jul 2026 11:53:22 -0700 Subject: [PATCH 11/14] bug fixes, fix tests --- .../internal/components/samples/ManageSampleColorsPanel.tsx | 3 ++- .../src/internal/components/samples/SampleColorsSetting.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx index 82b99499a0..7c6871dae4 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -25,6 +25,7 @@ import { DataTypeSelector } from '../entities/DataTypeSelector'; import { SampleTypeDataType } from '../entities/constants'; import { SampleColorModel } from './models'; +import { invalidateFullQueryDetailsCache } from '../../query/api'; const TITLE = 'Manage Sample Colors'; const NEW_COLOR_INDEX = -1; @@ -409,7 +410,7 @@ export const ManageSampleColorsPanel: FC = memo(pr if (isDelete) setSelectedRowId(undefined); setIsDirty(false); setDirty(false); - }, + invalidateFullQueryDetailsCache(); [loadColors, setIsDirty] ); diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx index 10ac3410f9..8d1b335d0d 100644 --- a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx @@ -145,7 +145,7 @@ export const SampleColorsSetting: FC = memo(({ sampleTypeRowId, onChange ); })}
- {enabledColors.length} colors enabled. + {enabledColors.length} color{enabledColors.length > 1 ? 's' : ''} enabled. From 48a055b7d649dfdc80854eeed5a37532fc1ea012 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 27 Jul 2026 12:05:40 -0700 Subject: [PATCH 12/14] lint --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- .../src/internal/components/samples/APIWrapper.ts | 6 +++++- .../internal/components/samples/ManageSampleColorsPanel.tsx | 1 + .../src/internal/components/samples/SampleColorsSetting.tsx | 4 +++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 383e62745b..f0f8ce6bc6 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.3", + "version": "7.48.1-fb-sampleColors.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.3", + "version": "7.48.1-fb-sampleColors.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 b76418e3ea..0281a202d9 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.3", + "version": "7.48.1-fb-sampleColors.5", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/components/samples/APIWrapper.ts b/packages/components/src/internal/components/samples/APIWrapper.ts index 7489661c73..7125ba2ba9 100644 --- a/packages/components/src/internal/components/samples/APIWrapper.ts +++ b/packages/components/src/internal/components/samples/APIWrapper.ts @@ -66,7 +66,11 @@ export interface SamplesAPIWrapper { getSampleAssayResultViewConfigs: () => Promise; - getSampleColors: (includeArchive?: boolean, checkInUse?: boolean, containerPath?: string) => Promise; + getSampleColors: ( + includeArchive?: boolean, + checkInUse?: boolean, + containerPath?: string + ) => Promise; getSampleCounter: (seqType: 'rootSampleCount' | 'sampleCount', containerPath?: string) => Promise; diff --git a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx index 7c6871dae4..71fbfc92f9 100644 --- a/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -411,6 +411,7 @@ export const ManageSampleColorsPanel: FC = memo(pr setIsDirty(false); setDirty(false); invalidateFullQueryDetailsCache(); + }, [loadColors, setIsDirty] ); diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx index 8d1b335d0d..03fd41a0d1 100644 --- a/packages/components/src/internal/components/samples/SampleColorsSetting.tsx +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx @@ -145,7 +145,9 @@ export const SampleColorsSetting: FC = memo(({ sampleTypeRowId, onChange ); })}
- {enabledColors.length} color{enabledColors.length > 1 ? 's' : ''} enabled. + + {enabledColors.length} color{enabledColors.length > 1 ? 's' : ''} enabled. + From aa134c25417b60b4ca7c67f85ade804bb93cb2f4 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 27 Jul 2026 15:29:36 -0700 Subject: [PATCH 13/14] update jest --- .../internal/components/samples/SampleColorsSetting.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx index 6b7a797407..19c829a609 100644 --- a/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx @@ -69,7 +69,7 @@ describe('SampleColorsSetting', () => { test('saved exclusions are excluded from the enabled count and dots', async () => { const { container } = renderSetting([makeColor(1), makeColor(2), makeColor(3)], [2, 3]); - await waitFor(() => expect(screen.getByText('1 colors enabled.')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('1 color enabled.')).toBeInTheDocument()); expect(container.querySelectorAll('.sample-colors-setting__dot')).toHaveLength(1); }); From 1ea71991cfc977a1489a35af1fc2b788ab41386e Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 27 Jul 2026 19:15:55 -0700 Subject: [PATCH 14/14] publish --- 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 f0f8ce6bc6..f9f1bdd1a4 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.5", + "version": "7.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.5", + "version": "7.49.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 0281a202d9..1ac0a04509 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.1-fb-sampleColors.5", + "version": "7.49.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [