diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 0f7997c61d..f9f1bdd1a4 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.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.48.0", + "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 49fcfc1c63..1ac0a04509 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.48.0", + "version": "7.49.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 bd738fbf34..59ae7e9d89 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'; @@ -640,6 +641,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, @@ -647,6 +649,8 @@ import { DEFAULT_SAMPLE_FIELD_CONFIG, FIND_BY_IDS_QUERY_PARAM, IS_ALIQUOT_COL, + SAMPLE_COLOR_COLUMN_NAME, + SAMPLE_COLOR_REQUIRED_COLUMNS, SAMPLE_DATA_EXPORT_CONFIG, SAMPLE_ID_FIND_FIELD, SAMPLE_IMPORT_EXTRA_ALLOWED_COLUMNS, @@ -691,6 +695,7 @@ import { isBiologicsEnabled, isFreezerManagementEnabled, isPremiumApplication, + isSampleManagerEnabled, limsIsPrimaryApp, } from './internal/app/products'; import { @@ -734,6 +739,7 @@ import { isProjectContainer, isProtectedDataEnabled, isRegistryEnabled, + isSampleColorsEnabled, isSampleStatusEnabled, isSharedContainer, isSourceTypeEnabled, @@ -946,6 +952,8 @@ const App = { isDataChangeCommentRequirementFeatureEnabled, isSharedContainer, freezerManagerIsCurrentApp, + isSampleManagerEnabled, + isSampleColorsEnabled, isSampleStatusEnabled, isProductFoldersEnabled, isAllProductFoldersFilteringEnabled, @@ -1546,6 +1554,7 @@ export { makeTestISelectRowsResult, makeTestQueryModel, ManageDropdownButton, + ManageSampleColorsPanel, ManageSampleStatusesPanel, MAX_EDITABLE_GRID_ROWS, MAX_SELECTION_ACTION_ROWS, @@ -1645,6 +1654,8 @@ export { ResponsiveMenuButton, ResponsiveMenuButtonGroup, runDetailsColumnsForQueryModel, + SAMPLE_COLOR_COLUMN_NAME, + SAMPLE_COLOR_REQUIRED_COLUMNS, SAMPLE_DATA_EXPORT_CONFIG, SAMPLE_FINDER_SESSION_PREFIX, SAMPLE_ID_FIELD_KEY, @@ -1661,6 +1672,7 @@ export { SAMPLE_TYPE_CONCEPT_URI, SAMPLE_TYPE_DESIGNER_ROLE, SampleAmountEditModal, + SampleColorRenderer, sampleDeleteDependencyText, SampleOperation, SampleParentDataType, 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/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 = { 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", }, }; @@ -320,6 +324,10 @@ class SampleTypePropertiesPanelImpl extends PureComponent { + this.onFieldChange('disabledSampleColorRowIds', disabledRowIds); + }; + onMetricUnitKindChange = (key: string, value: any): void => { const { originalUnit } = this.state; const unitKind = value ? UnitKinds[value] : null; @@ -551,8 +559,8 @@ class SampleTypePropertiesPanelImpl extends PureComponent
} + id="linked-study-label" label="Auto-Link Data to Study" />
@@ -569,8 +577,8 @@ class SampleTypePropertiesPanelImpl extends PureComponent
} + id="linked-dataset-category-label" label="Linked Dataset Category" />
@@ -597,8 +605,8 @@ class SampleTypePropertiesPanelImpl extends PureComponent
@@ -610,6 +618,9 @@ 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/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/editable/Cell.tsx b/packages/components/src/internal/components/editable/Cell.tsx index 3bbf9cf6f4..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, diff --git a/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx b/packages/components/src/internal/components/forms/detail/DetailDisplay.tsx index f26b144d5c..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'; @@ -26,6 +26,7 @@ import { resolveDetailFieldValue } from '../utils'; import { AssayRunReferenceRenderer } from '../../../renderers/AssayRunReferenceRenderer'; import { SampleStatusRenderer } from '../../../renderers/SampleStatusRenderer'; +import { SampleColorRenderer } from '../../../renderers/SampleColorRenderer'; import { TextChoiceInput } from '../input/TextChoiceInput'; import { DatePickerInput } from '../input/DatePickerInput'; @@ -56,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 { @@ -175,6 +178,7 @@ export const DetailDisplay: FC = memo(props => { fileInputRenderer, fieldHelpTexts, onAdditionalFormDataChange, + queryFilters, tableCls, internalSpacesWarningFieldKeys, } = props; @@ -195,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( @@ -314,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} @@ -485,6 +490,9 @@ export function resolveDetailRenderer(column: QueryColumn): Renderer { case 'nolinkrenderer': renderer = d => ; break; + case 'samplecolorrenderer': + renderer = (d, r) => ; + break; case 'samplestatusrenderer': renderer = (d, r) => ; break; 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..6d9e3a8274 --- /dev/null +++ b/packages/components/src/internal/components/forms/input/SampleColorInput.tsx @@ -0,0 +1,98 @@ +/* + * 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, useMemo } 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'; +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, 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 ( + <> + {renderLabelField?.(col)} + + + ); +}); + +SampleColorInput.displayName = 'SampleColorInput'; + +export const SampleColorInputRenderer: FC = memo(props => { + const { + allowFieldDisable, + col, + containerPath, + formsy, + initiallyDisabled, + onSelectChange, + onToggleDisable, + renderLabelField, + selectInputProps, + showAsteriskSymbol, + value, + fieldWithMixedValues, + queryFilters, + } = 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..7125ba2ba9 100644 --- a/packages/components/src/internal/components/samples/APIWrapper.ts +++ b/packages/components/src/internal/components/samples/APIWrapper.ts @@ -16,23 +16,29 @@ import { DomainDetails } from '../domainproperties/models'; import { createSessionAssayRunSummaryQuery, + getColorSampleTypeExclusions, getDefaultDiscardStatus, getDistinctAssaysPerSample, getGroupedSampleDomainFields, getLookupRowIdsFromSelection, getSampleAliquotRows, getSampleAssayResultViewConfigs, + getSampleColors, getSampleCounter, getSampleStatuses, getSampleStorageId, + getSampleTypeColorExclusions, getSampleTypeDetails, + getSampleTypeLabelColor, + getSampleTypeRowId, getSelectionLineageData, getTimelineEvents, hasExistingSamples, SampleAssayResultViewConfig, saveSampleCounter, + updateColorSettings, } 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'; @@ -40,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; @@ -58,6 +66,12 @@ export interface SamplesAPIWrapper { getSampleAssayResultViewConfigs: () => Promise; + getSampleColors: ( + includeArchive?: boolean, + checkInUse?: boolean, + containerPath?: string + ) => Promise; + getSampleCounter: (seqType: 'rootSampleCount' | 'sampleCount', containerPath?: string) => Promise; getSampleOperationConfirmationData: ( @@ -71,6 +85,12 @@ export interface SamplesAPIWrapper { getSampleStorageId: (sampleRowId: number) => Promise; + getSampleTypeColorExclusions: ( + sampleTypeRowId?: number, + sampleTypeName?: string, + containerPath?: string + ) => Promise; + getSampleTypeDetails: ( query?: SchemaQuery, domainId?: number, @@ -78,6 +98,10 @@ export interface SamplesAPIWrapper { includeNamePreview?: boolean ) => Promise; + getSampleTypeLabelColor: (name: string) => Promise; + + getSampleTypeRowId: (name: string) => Promise; + getSelectionLineageData: ( selection: Set, schema: string, @@ -100,6 +124,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 { @@ -108,10 +139,16 @@ export class SamplesServerAPIWrapper implements SamplesAPIWrapper { getSampleAliquotRows = getSampleAliquotRows; getSampleAssayResultViewConfigs = getSampleAssayResultViewConfigs; 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; @@ -134,10 +171,16 @@ export function getSamplesTestAPIWrapper( getSampleAliquotRows: mockFn(), getSampleAssayResultViewConfigs: mockFn(), 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/ManageSampleColorsPanel.test.tsx b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx new file mode 100644 index 0000000000..8c83bab5ee --- /dev/null +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.test.tsx @@ -0,0 +1,294 @@ +/* + * 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({}), + // 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, 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)]); + + // 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 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(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 () => { + 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('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(); + }); + + 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 new file mode 100644 index 0000000000..71fbfc92f9 --- /dev/null +++ b/packages/components/src/internal/components/samples/ManageSampleColorsPanel.tsx @@ -0,0 +1,466 @@ +/* + * 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 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 { DisableableButton } from '../buttons/DisableableButton'; +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 { 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; +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 + + ' 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 }; +} + +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 [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. + const [initialExcludedTypes, setInitialExcludedTypes] = useState>(new Set()); + const [exclusionsLoaded, setExclusionsLoaded] = useState(false); + const [typesError, setTypesError] = useState(); + const { api } = useAppContext(); + + useEffect(() => { + setUpdated(isNew ? newColor() : color); + setDirty(isNew); + setSaving(false); + setShowDeleteConfirm(false); + setError(undefined); + 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 || !color?.rowId) { + setExcludedTypes(new Set()); + setInitialExcludedTypes(new Set()); + setExclusionsLoaded(true); + return; + } + setExclusionsLoaded(false); + 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 })); + setDirty(true); + onChange(); + }, + [onChange] + ); + + const onColorChange = useCallback( + (name: string, value: string): void => { + setUpdated(prev => ({ ...prev, color: value })); + setDirty(true); + onChange(); + }, + [onChange] + ); + + const saveColor = useCallback( + (toSave: SampleColorModel, exclusionDelta?: { newlyDisabled: number[]; newlyEnabled: number[] }) => { + setError(undefined); + setSaving(true); + api.samples + .updateColorSettings( + toSave, + exclusionDelta?.newlyDisabled ?? [], + exclusionDelta?.newlyEnabled ?? [], + container?.path + ) + .then(() => onActionComplete(toSave.label)) + .catch(reason => { + setError(resolveErrorMessage(reason?.error ?? reason, 'color', 'colors', 'save')); + setSaving(false); + }); + }, + [api, container?.path, onActionComplete] + ); + + const onSave = useCallback(() => { + const toSave = { ...updated, label: updated.label?.trim() }; + 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(() => { + saveColor({ ...updated, archived: !updated.archived }); + }, [updated, saveColor]); + + const onToggleDeleteConfirm = useCallback(() => { + setDeleteError(undefined); + setShowDeleteConfirm(s => !s); + }, []); + const onDeleteConfirm = useCallback(() => { + setDeleteError(undefined); + setSaving(true); + api.query + .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')); + setSaving(false); + }); + }, [api, container?.path, onActionComplete, updated]); + + 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(); + 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 ( + <> +
+ {error && {error}} +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ {typesError && {typesError}} + {!exclusionsLoaded && !typesError && } + {exclusionsLoaded && ( + + )} +
+
+
+ {!isNew && ( + <> + + +  Delete + + + + )} + +
+
+ {showDeleteConfirm && ( + + {deleteError && {deleteError}} + + 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 { + activeColors: SampleColorModel[]; + archivedColors: SampleColorModel[]; + disabled?: boolean; + onSelect: (rowId: number) => void; + selectedRowId: number; +} + +// exported for jest testing +export const SampleColorsList: FC = memo(props => { + const { activeColors, archivedColors, disabled, onSelect, selectedRowId } = props; + const [showArchived, setShowArchived] = useState(false); + const toggleArchived = useCallback(() => setShowArchived(s => !s), []); + + const renderItem = useCallback( + (c: SampleColorModel) => ( + } + onSelect={onSelect} + /> + ), + [disabled, 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 [dirty, setDirty] = useState(false); + const { api } = useAppContext(); + const isNew = selectedRowId === NEW_COLOR_INDEX; + + const loadColors = useCallback( + (selectLabel?: string) => { + setError(undefined); + api.samples + .getSampleColors(true, 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); + setDirty(true); + }, [setIsDirty]); + + const onActionComplete = useCallback( + (newColorLabel?: string, isDelete = false) => { + loadColors(newColorLabel); + if (isDelete) setSelectedRowId(undefined); + setIsDirty(false); + setDirty(false); + invalidateFullQueryDetailsCache(); + }, + [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..19c829a609 --- /dev/null +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.test.tsx @@ -0,0 +1,190 @@ +/* + * 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 { SampleColorsSelectorModal, SampleColorsSetting } from './SampleColorsSetting'; + +const makeColor = (rowId: number): SampleColorModel => ({ + rowId, + label: `Color ${rowId}`, + color: '#123456', + archived: false, +}); + +const renderSetting = ( + colors: SampleColorModel[], + excluded: number[] = [], + props: Partial> = {}, + user = TEST_USER_APP_ADMIN +) => { + const appContext = { + api: getTestAPIWrapper(jest.fn, { + samples: getSamplesTestAPIWrapper(jest.fn, { + getSampleColors: jest.fn().mockResolvedValue(colors), + getSampleTypeColorExclusions: jest.fn().mockResolvedValue(excluded), + }), + }), + }; + 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('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 color 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')), + getSampleTypeColorExclusions: jest.fn().mockResolvedValue([]), + }), + }), + }; + renderWithAppContext(, { + appContext, + serverContext: { user: TEST_USER_APP_ADMIN }, + }); + await waitFor(() => expect(screen.getByText('Unable to load sample colors.')).toBeInTheDocument()); + }); +}); + +describe('SampleColorsSelectorModal', () => { + // rowIds 1, 2, 3 in order + const colors = [makeColor(1), makeColor(2), makeColor(3)]; + + const renderModal = (props: Partial> = {}) => + renderWithAppContext( + + ); + + test('renders a checkbox per color, all checked when nothing is disabled', () => { + renderModal(); + const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[]; + expect(checkboxes).toHaveLength(3); + expect(checkboxes.every(cb => cb.checked)).toBe(true); + }); + + test('reflects the initial disabled set as unchecked', () => { + renderModal({ initialDisabled: new Set([2]) }); + const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[]; + expect(checkboxes[0].checked).toBe(true); // rowId 1 + expect(checkboxes[1].checked).toBe(false); // rowId 2 (disabled) + expect(checkboxes[2].checked).toBe(true); // rowId 3 + }); + + test('unchecking a color and applying reports it as disabled', async () => { + const onConfirm = jest.fn(); + renderModal({ onConfirm }); + await userEvent.click(screen.getAllByRole('checkbox')[0]); // disable rowId 1 + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onConfirm).toHaveBeenCalledWith([1]); + }); + + test('re-checking a previously disabled color removes it from the disabled set', async () => { + const onConfirm = jest.fn(); + renderModal({ initialDisabled: new Set([2]), onConfirm }); + await userEvent.click(screen.getAllByRole('checkbox')[1]); // re-enable rowId 2 + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onConfirm).toHaveBeenCalledWith([]); + }); + + test('toggling a color off then on nets no change', async () => { + const onConfirm = jest.fn(); + renderModal({ onConfirm }); + const first = screen.getAllByRole('checkbox')[0]; + await userEvent.click(first); + await userEvent.click(first); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onConfirm).toHaveBeenCalledWith([]); + }); + + test('Cancel invokes onCancel and does not confirm', async () => { + const onCancel = jest.fn(); + const onConfirm = jest.fn(); + renderModal({ onCancel, onConfirm }); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + test('shows an empty message and no checkboxes when there are no colors', () => { + renderModal({ colors: [] }); + expect(screen.getByText('No colors are set up yet.')).toBeInTheDocument(); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + }); +}); 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..03fd41a0d1 --- /dev/null +++ b/packages/components/src/internal/components/samples/SampleColorsSetting.tsx @@ -0,0 +1,169 @@ +/* + * 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, { ChangeEvent, 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 SampleColorsSelectorModalProps { + colors: SampleColorModel[]; + initialDisabled: Set; + onCancel: () => void; + onConfirm: (disabledRowIds: number[]) => void; +} + +// exported for jest testing +export const SampleColorsSelectorModal: FC = memo( + ({ colors, initialDisabled, onCancel, onConfirm }) => { + const [draftDisabled, setDraftDisabled] = useState>(() => new Set(initialDisabled)); + + const onToggle = useCallback((evt: ChangeEvent) => { + const rowId = parseInt(evt.currentTarget.value, 10); + setDraftDisabled(prev => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); + }, []); + + const onApply = useCallback(() => { + onConfirm(Array.from(draftDisabled)); + }, [draftDisabled, onConfirm]); + + return ( + + {colors.length === 0 &&

No colors are set up yet.

} +
+ {colors.map(c => ( +
+ +
+ ))} +
+
+ ); + } +); + +SampleColorsSelectorModal.displayName = 'SampleColorsSelectorModal'; + +interface Props { + onChange: (disabledRowIds: number[]) => void; + sampleTypeRowId?: number; +} + +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); + + useEffect(() => { + (async () => { + try { + const allColors = await api.samples.getSampleColors(); + const excluded = sampleTypeRowId ? await api.samples.getSampleTypeColorExclusions(sampleTypeRowId) : []; + setColors(allColors); + setDisabledSet(new Set(excluded)); + } catch (e) { + setColors([]); + setError('Unable to load sample colors.'); + } + })(); + }, [api, sampleTypeRowId]); + + const enabledColors = useMemo(() => (colors ?? []).filter(c => !disabledSet.has(c.rowId)), [colors, disabledSet]); + + const openModal = useCallback(() => setShowModal(true), []); + + const closeModal = useCallback(() => setShowModal(false), []); + + const onConfirm = useCallback( + (disabledRowIds: number[]) => { + setDisabledSet(new Set(disabledRowIds)); + onChange(disabledRowIds); + setShowModal(false); + }, + [onChange] + ); + + return ( +
+
+ +
+
+ {error && {error}} + {!colors && } + {colors && colors.length === 0 && ( + + No colors are set up yet. + {user.isAdmin && ( + <> + {' '} + 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} color{enabledColors.length > 1 ? 's' : ''} enabled. + + + + )} +
+ {showModal && ( + + )} +
+ ); +}); + +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..e0fbfaafcf 100644 --- a/packages/components/src/internal/components/samples/actions.ts +++ b/packages/components/src/internal/components/samples/actions.ts @@ -40,8 +40,14 @@ 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_COLOR_COLUMN_NAME, + 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 +186,159 @@ 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 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', + }); + + 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 { + return new Promise((resolve, reject) => { + Ajax.request({ + url: ActionURL.buildURL( + SAMPLE_MANAGER_APP_PROPERTIES.controllerName, + 'getColorDataTypeExclusion.api', + containerPath, + { rowId: colorRowId } + ), + success: Utils.getCallbackWrapper(response => resolve(response?.excludedSampleTypes ?? [])), + failure: Utils.getCallbackWrapper(response => { + console.error(response); + reject(response); + }), + }); + }); +} + +export async function getSampleTypeColorExclusions( + sampleTypeRowId?: number, + 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({ + 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 +// and returns the color's rowId. +export function updateColorSettings( + color: SampleColorModel, + newlyDisabledTypeIds: number[], + newlyEnabledTypeIds: number[], + containerPath?: string +): Promise { + return new Promise((resolve, reject) => { + Ajax.request({ + url: ActionURL.buildURL( + SAMPLE_MANAGER_APP_PROPERTIES.controllerName, + 'updateColorSettings.api', + containerPath + ), + method: 'POST', + jsonData: { + rowId: color.rowId, + label: color.label, + color: color.color, + archived: color.archived, + newlyDisabledTypes: newlyDisabledTypeIds, + newlyEnabledTypes: newlyEnabledTypeIds, + }, + success: Utils.getCallbackWrapper(response => resolve(response?.rowId)), + failure: Utils.getCallbackWrapper(response => { + console.error(response); + reject(response); + }), + }); + }); +} + // 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..066f4eb859 100644 --- a/packages/components/src/internal/components/samples/constants.ts +++ b/packages/components/src/internal/components/samples/constants.ts @@ -55,6 +55,12 @@ export const SAMPLE_STATUS_REQUIRED_COLUMNS = [ SAMPLE_STATE_COLOR_COLUMN_NAME, ]; +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]; + export enum SampleOperation { EditMetadata, EditLineage, @@ -211,6 +217,7 @@ export const SAMPLE_IMPORT_EXTRA_ALLOWED_COLUMNS = [ 'EnteredStorage', 'ExpirationDate', 'StorageUnitBarcode', + 'SampleColor', ]; export const SAMPLE_DATA_EXPORT_CONFIG = { @@ -225,6 +232,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..07cb595291 100644 --- a/packages/components/src/internal/components/samples/models.ts +++ b/packages/components/src/internal/components/samples/models.ts @@ -4,6 +4,14 @@ */ import { immerable, produce } from 'immer'; +export interface SampleColorModel { + archived: boolean; + color: string; + inUse?: boolean; + 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..b37460b51f --- /dev/null +++ b/packages/components/src/internal/renderers/SampleColorRenderer.tsx @@ -0,0 +1,32 @@ +/* + * 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'; +import { SAMPLE_COLOR_COLOR_COLUMN_NAME } from '../components/samples/constants'; + +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 83fdd0f3e7..fe8613ba42 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/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} />
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..0ca4b69358 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: $gray-base; +}