diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index d1a361d5e3..fa084bd0ce 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,14 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.?.? +*Released*: ???? +- Remove styles for `.app-page` + - Component was moved to ui-premium a while ago +- Add `ta-right`, `ta-left` util styles +- Add UnidentifiedPill +- Add EMPTY_SEQUENCE_WARNING constant + ### version 7.21.0 *Released*: 26 February 2026 - Package updates diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 65b3fab597..55247ab938 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -59,6 +59,7 @@ import { getValueFromRow, getValuesSummary, handleFileInputChange, + hasIdentifiedCol, isImage, isInteger, isIntegerInRange, @@ -247,6 +248,7 @@ import { AssayUploadTabs, DataViewInfoTypes, EDIT_METHOD, + EMPTY_SEQUENCE_WARNING, EXPORT_TYPES, GRID_CHECKBOX_OPTIONS, IMPORT_DATA_FORM_TYPES, @@ -893,6 +895,7 @@ import { ArchivedFolderTag } from './internal/components/folder/ArchivedFolderTa import { FilterCriteriaRenderer } from './internal/FilterCriteriaRenderer'; import { getQueryTestAPIWrapper } from './internal/query/APIWrapper'; import { useLoadableState } from './internal/useLoadableState'; +import { UnidentifiedPill } from './internal/UnidentifiedPill'; // See Immer docs for why we do this: https://immerjs.github.io/immer/docs/installation#pick-your-immer-version enableMapSet(); @@ -1263,6 +1266,7 @@ export { EditInlineField, EditorMode, EditorModel, + EMPTY_SEQUENCE_WARNING, encodeFormDataQuote, encodePart, ensureAllFieldsInAllRows, @@ -1358,6 +1362,7 @@ export { getHelpLink, getInactiveUsers, getInitialParentChoices, + getIntegerSearchParam, getJsonDateFormatString, getJsonDateTimeFormatString, getJsonFormatString, @@ -1369,7 +1374,6 @@ export { getMetricUnitOptions, getModuleCustomLabels, getNonStandardFormatWarning, - getIntegerSearchParam, getOmittedSampleTypeColumns, getOperationNotAllowedMessage, getOperationNotAllowedMessageFromCounts, @@ -1413,6 +1417,7 @@ export { getSelectedRows, getSourceDomainDefaultSystemFields, getTestAPIWrapper, + getTextAlignClassName, getTimelineEntityUrl, getUniqueIdColumnMetadata, getUsersWithPermissions, @@ -1424,7 +1429,6 @@ export { GlobalStateContextProvider, Grid, GRID_CHECKBOX_OPTIONS, - getTextAlignClassName, GridColumn, GridPanel, GridPanelWithModel, @@ -1438,6 +1442,7 @@ export { hasAnyPermissions, hasParameter, hasPermissions, + hasIdentifiedCol, Help, HELP_LINK_REFERRER, HelpIcon, @@ -1713,6 +1718,7 @@ export { Tooltip, TransactionAuditIdRenderer, uncapitalizeFirstChar, + UnidentifiedPill, UNIQUE_ID_FIND_FIELD, UnitModel, updateCellKeySampleIdMap, diff --git a/packages/components/src/internal/Popover.tsx b/packages/components/src/internal/Popover.tsx index 88dfecb242..8952cd7577 100644 --- a/packages/components/src/internal/Popover.tsx +++ b/packages/components/src/internal/Popover.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import { useOverlayPositioning } from './useOverlayPositioning'; import { TooltipProps } from './Tooltip'; -interface PopoverProps extends TooltipProps, PropsWithChildren { +interface PopoverProps extends PropsWithChildren, TooltipProps { className?: string; isFlexPlacement?: boolean; title?: string; @@ -30,7 +30,7 @@ export const Popover: FC = props => { }); return ( -
+
{title &&

{title}

}
{children}
diff --git a/packages/components/src/internal/UnidentifiedPill.tsx b/packages/components/src/internal/UnidentifiedPill.tsx new file mode 100644 index 0000000000..4ba3c8f45d --- /dev/null +++ b/packages/components/src/internal/UnidentifiedPill.tsx @@ -0,0 +1,35 @@ +import React, { FC, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { generateId } from './util/utils'; +import { useOverlayTriggerState } from './OverlayTrigger'; +import { Popover } from './Popover'; +import { EMPTY_SEQUENCE_WARNING } from './constants'; + +export const UnidentifiedPill: FC = () => { + const id = useMemo(() => generateId('unidentified-sequence-overlay-trigger'), []); + // Note: we use useOverlayTriggerState instead of OverlayTrigger because the wrapping div from OverlayTrigger + // causes layout problems. + const { onMouseEnter, onMouseLeave, portalEl, show, targetRef } = useOverlayTriggerState(id, true, false); + + const popover = useMemo( + () => ( + +
{EMPTY_SEQUENCE_WARNING}
+
+ ), + [targetRef] + ); + + return ( +
+ Unidentified + + {show && createPortal(popover, portalEl)} +
+ ); +}; diff --git a/packages/components/src/internal/components/forms/PageDetailHeader.tsx b/packages/components/src/internal/components/forms/PageDetailHeader.tsx index 3a819196a9..3d000a13c8 100644 --- a/packages/components/src/internal/components/forms/PageDetailHeader.tsx +++ b/packages/components/src/internal/components/forms/PageDetailHeader.tsx @@ -28,6 +28,9 @@ interface PageDetailHeaderProps extends PropsWithChildren { title: ReactNode; } +/** + * @deprecated use AppPageHeader in ui-premium instead + */ export class PageDetailHeader extends PureComponent { static defaultProps = { leftColumns: 6, diff --git a/packages/components/src/internal/components/lineage/constants.tsx b/packages/components/src/internal/components/lineage/constants.tsx index 7f9598d92d..10622ea946 100644 --- a/packages/components/src/internal/components/lineage/constants.tsx +++ b/packages/components/src/internal/components/lineage/constants.tsx @@ -16,6 +16,7 @@ import { LineageOptions, LineageURLResolvers, } from './types'; +import { SAMPLE_STATE_COLOR_COLUMN_NAME, SAMPLE_STATE_TYPE_COLUMN_NAME } from '../samples/constants'; // Default depth to fetch with the lineage API export const DEFAULT_LINEAGE_DISTANCE = 5; @@ -159,3 +160,13 @@ export const LINEAGE_GRID_COLUMNS = List([ title: 'Alias', }), ]); + +export const IDENTIFIED_COLUMN_NAME = 'identified'; + +// Must specify '*' columns be requested to resolve "properties" columns +export const LINEAGE_DETAIL_REQUIRED_COLS = [ + '*', + SAMPLE_STATE_COLOR_COLUMN_NAME, + SAMPLE_STATE_TYPE_COLUMN_NAME, + IDENTIFIED_COLUMN_NAME, +]; diff --git a/packages/components/src/internal/components/lineage/models.ts b/packages/components/src/internal/components/lineage/models.ts index 46936ac84f..459a422538 100644 --- a/packages/components/src/internal/components/lineage/models.ts +++ b/packages/components/src/internal/components/lineage/models.ts @@ -104,7 +104,7 @@ export class LineageNodeMetadata extends ImmutableRecord({ let aliases; if (selectRowsMetadata.has('Alias')) { - aliases = selectRowsMetadata.get('Alias').map(alias => alias.get('displayValue')); + aliases = selectRowsMetadata.get('Alias').map(alias => alias.get('value')); } return new LineageNodeMetadata({ diff --git a/packages/components/src/internal/components/lineage/node/LineageDetail.tsx b/packages/components/src/internal/components/lineage/node/LineageDetail.tsx index a1ff6a7255..9ec01ae4ec 100644 --- a/packages/components/src/internal/components/lineage/node/LineageDetail.tsx +++ b/packages/components/src/internal/components/lineage/node/LineageDetail.tsx @@ -1,72 +1,45 @@ -import React, { FC, memo, useMemo } from 'react'; -import { Experiment, Filter } from '@labkey/api'; +import React, { FC, memo } from 'react'; +import { Experiment } from '@labkey/api'; import { List, Map } from 'immutable'; import { Renderer, resolveDetailRenderer } from '../../forms/detail/DetailDisplay'; import { LoadingSpinner } from '../../base/LoadingSpinner'; import { Alert } from '../../base/Alert'; import { DetailPanel } from '../../../../public/QueryModel/DetailPanel'; -import { SchemaQuery } from '../../../../public/SchemaQuery'; -import { ViewInfo } from '../../../ViewInfo'; import { QueryColumn } from '../../../../public/QueryColumn'; -import { InjectedQueryModels, QueryConfigMap, withQueryModels } from '../../../../public/QueryModel/withQueryModels'; -import { SAMPLE_STATE_COLOR_COLUMN_NAME, SAMPLE_STATE_TYPE_COLUMN_NAME } from '../../samples/constants'; +import { QueryModel } from '../../../../public/QueryModel/QueryModel'; const ADDITIONAL_DETAIL_FIELDS = ['properties']; export interface LineageDetailProps { item: Experiment.LineageItemBase; + model: QueryModel; } -const LineageDetailImpl: FC = memo(props => { - const { queryModels } = props; - if (queryModels.model.isLoading) return ; - if (queryModels.model.hasLoadErrors) return {queryModels.model.loadErrors[0]}; +export const LineageDetail: FC = memo(({ item, model }) => { + if (item.restricted) { + return This {item.name} cannot be viewed.; + } - // Issue 50537: only show the "Properties" column in the detail view for the exp schema - const isExpSchema = queryModels.model.schemaName === 'exp'; + if (model.isLoading) return ; + if (model.hasLoadErrors) return {model.loadErrors[0]}; + // Issue 50537: only show the "Properties" column in the detail view for the exp schema + const isExpSchema = model.schemaName === 'exp'; const additionalCols = isExpSchema - ? queryModels.model.allColumns.filter(col => ADDITIONAL_DETAIL_FIELDS.indexOf(col.fieldKey?.toLowerCase()) > -1) + ? model.allColumns.filter(col => ADDITIONAL_DETAIL_FIELDS.indexOf(col.fieldKey?.toLowerCase()) > -1) : []; - const detailColumns = [...queryModels.model.detailColumns, ...additionalCols]; + const detailColumns = [...model.detailColumns, ...additionalCols]; return ( ); }); -LineageDetailImpl.displayName = 'LineageDetailImpl'; - -const LineageDetailWithQueryModels = withQueryModels(LineageDetailImpl); - -export const LineageDetail: FC = memo(({ item }) => { - const queryConfigs = useMemo(() => { - if (item.restricted) return {}; - - return { - model: { - baseFilters: item.pkFilters.map(pkFilter => Filter.create(pkFilter.fieldKey, pkFilter.value)), - containerPath: item.containerPath, - // Issue 45028: Display details view columns in lineage - schemaQuery: new SchemaQuery(item.schemaName, item.queryName, ViewInfo.DETAIL_NAME), - // Must specify '*' columns be requested to resolve "properties" columns - requiredColumns: ['*', SAMPLE_STATE_COLOR_COLUMN_NAME, SAMPLE_STATE_TYPE_COLUMN_NAME], - }, - }; - }, [item]); - - if (item.restricted) { - return This {item.name} cannot be viewed.; - } - - // providing "key" to allow for reload on lsid change - return ; -}); LineageDetail.displayName = 'LineageDetail'; interface RendererProps { diff --git a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx index be76843b96..9151396cfb 100644 --- a/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx +++ b/packages/components/src/internal/components/lineage/node/LineageNodeDetail.tsx @@ -25,6 +25,13 @@ import { hasModule } from '../../../app/utils'; import { LineageDetail } from './LineageDetail'; import { DetailHeader, NodeDetailHeader } from './NodeDetailHeader'; import { DetailsListLineageIO, DetailsListNodes, DetailsListSteps } from './DetailsList'; +import { InjectedQueryModels, QueryConfigMap, withQueryModels } from '../../../../public/QueryModel/withQueryModels'; +import { Filter } from '@labkey/api'; +import { SchemaQuery } from '../../../../public/SchemaQuery'; +import { ViewInfo } from '../../../ViewInfo'; +import { QueryModel } from '../../../../public/QueryModel/QueryModel'; + +import { LINEAGE_DETAIL_REQUIRED_COLS } from '../constants'; interface LineageNodeDetailProps { highlightNode?: string; @@ -33,27 +40,28 @@ interface LineageNodeDetailProps { seed: string; } -export const LineageNodeDetail: FC = memo(props => { - const { seed, node, highlightNode, lineageOptions } = props; - const { isRun, restricted } = node; +const LineageNodeDetailImpl: FC = memo(props => { + const { seed, node, highlightNode, lineageOptions, queryModels } = props; + const { containerPath, isRun, lsid, restricted } = node; + const model = queryModels.model; const [stepIdx, setStepIdx] = useState(undefined); const [tabKey, setTabKey] = useState('details'); const onBack = useCallback(() => setStepIdx(undefined), []); if (isRun && stepIdx !== undefined) { - return ; + return ; } const nodeDetails = ( <> - + {!restricted && ( )} @@ -62,7 +70,7 @@ export const LineageNodeDetail: FC = memo(props => { return (
- + {isRun && !restricted ? ( @@ -79,6 +87,37 @@ export const LineageNodeDetail: FC = memo(props => {
); }); +LineageNodeDetailImpl.displayName = 'LineageNodeDetailImpl'; + +const LineageNodeDetailWithModels = withQueryModels(LineageNodeDetailImpl); + +export const LineageNodeDetail: FC = memo(props => { + const { highlightNode, lineageOptions, node, seed } = props; + const queryConfigs = useMemo(() => { + if (node.restricted) return {}; + + return { + model: { + baseFilters: node.pkFilters.map(pkFilter => Filter.create(pkFilter.fieldKey, pkFilter.value)), + containerPath: node.containerPath, + // Issue 45028: Display details view columns in lineage + schemaQuery: new SchemaQuery(node.schemaName, node.queryName, ViewInfo.DETAIL_NAME), + requiredColumns: LINEAGE_DETAIL_REQUIRED_COLS, + }, + }; + }, [node]); + + return ( + + ); +}); LineageNodeDetail.displayName = 'LineageNodeDetail'; interface ClusterNodeDetailProps { @@ -128,13 +167,14 @@ export const ClusterNodeDetail: FC = memo(props => { ClusterNodeDetail.displayName = 'ClusterNodeDetail'; interface RunStepNodeDetailProps { + model: QueryModel; node: LineageNode; onBack: () => void; stepIdx: number; } const RunStepNodeDetail: FC = memo(props => { - const { node, onBack, stepIdx } = props; + const { model, node, onBack, stepIdx } = props; const [tabKey, setTabKey] = useState('details'); const step = node.steps.get(stepIdx); const stepName = step.protocol?.name || step.name; @@ -155,7 +195,7 @@ const RunStepNodeDetail: FC = memo(props => { - + {hasProvenanceModule && ( diff --git a/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx b/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx index 5fcf1b0684..51ee2dd1bd 100644 --- a/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx +++ b/packages/components/src/internal/components/lineage/node/NodeDetailHeader.tsx @@ -3,6 +3,10 @@ import React, { FC, memo, PropsWithChildren, ReactNode } from 'react'; import { LineageNode } from '../models'; import { LineageDataLink } from '../LineageDataLink'; import { SVGIcon, Theme } from '../../base/SVGIcon'; +import { QueryModel } from '../../../../public/QueryModel/QueryModel'; +import { caseInsensitive, hasIdentifiedCol } from '../../../util/utils'; +import { UnidentifiedPill } from '../../../UnidentifiedPill'; +import { IDENTIFIED_COLUMN_NAME } from '../constants'; export interface DetailHeaderProps extends PropsWithChildren { header: ReactNode; @@ -25,18 +29,22 @@ export const DetailHeader: FC = memo(({ children, header, ico DetailHeader.displayName = 'DetailHeader'; export interface NodeDetailHeaderProps { + model: QueryModel; node: LineageNode; seed?: string; } -export const NodeDetailHeader: FC = memo(({ node, seed }) => { +export const NodeDetailHeader: FC = memo(({ model, node, seed }) => { const { links, meta, name } = node; const lineageUrl = links.lineage; const isSeed = seed === node.lsid; - const aliases = meta?.aliases; - const description = meta?.description; const displayType = meta?.displayType; + let identified: boolean; + + if (model && !model.isLoading && !model.hasLoadErrors && hasIdentifiedCol(model.schemaQuery)) { + identified = caseInsensitive(model.getRow(), IDENTIFIED_COLUMN_NAME)?.value; + } const header = ( <> @@ -50,9 +58,9 @@ export const NodeDetailHeader: FC = memo(({ node, seed }) return ( + {/* Triple eq is important here; we only want false, not falsey values */} + {identified === false && } {displayType &&
{displayType}
} - {aliases &&
{aliases.join(', ')}
} - {description &&
{description}
}
); }); diff --git a/packages/components/src/internal/constants.ts b/packages/components/src/internal/constants.ts index 7a0cbf2acc..ef5683e637 100644 --- a/packages/components/src/internal/constants.ts +++ b/packages/components/src/internal/constants.ts @@ -260,3 +260,6 @@ export const VIEW_NOT_FOUND_EXCEPTION_CLASS = 'org.labkey.api.view.NotFoundExcep export const APP_FIELD_CANNOT_BE_REMOVED_MESSAGE = 'This application field cannot be removed.'; export const CELL_SELECTION_HANDLE_CLASSNAME = 'cell-selection-handle'; + +export const EMPTY_SEQUENCE_WARNING = + "Without a sequence, Protein sequence translations cannot be done automatically, and the system can't prevent duplicates."; diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index 378b2fb605..f1efe6d3fd 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -20,6 +20,8 @@ import { ChangeEvent, CSSProperties } from 'react'; import { hasParameter, toggleParameter } from '../url/ActionURL'; import { QueryInfo } from '../../public/QueryInfo'; import { STORED_AMOUNT_FIELDS } from '../components/samples/constants'; +import { SchemaQuery } from '../../public/SchemaQuery'; +import { SCHEMAS } from '../schemas'; // Case-insensitive Object reference. Returns undefined if either object or prop does not resolve. // If both casings exist (e.g. 'x' and 'X' are props) then either value may be returned. @@ -900,3 +902,12 @@ export const setIsTestEnv = (isTestEnv: boolean): void => { }; export const isTestEnv = (): boolean => IS_NODE_TEST_ENV || IS_TEST_ENV; + +export function hasIdentifiedCol(schemaQuery: SchemaQuery): boolean { + // Drop the viewName from the schemaQuery so we can properly compare + const sqNoView = new SchemaQuery(schemaQuery.schemaName, schemaQuery.queryName); + const isNucSeq = sqNoView.isEqual(SCHEMAS.DATA_CLASSES.NUC_SEQUENCE); + const isProtSeq = sqNoView.isEqual(SCHEMAS.DATA_CLASSES.PROTEIN_SEQUENCE); + const isMolecule = sqNoView.isEqual(SCHEMAS.DATA_CLASSES.MOLECULE); + return isNucSeq || isProtSeq || isMolecule; +} diff --git a/packages/components/src/theme/app/app.scss b/packages/components/src/theme/app/app.scss index 592b29aca3..f7bf6e1fd6 100644 --- a/packages/components/src/theme/app/app.scss +++ b/packages/components/src/theme/app/app.scss @@ -19,18 +19,6 @@ scroll-margin: 97px; } -.app-page { - padding-top: 20px; - - // GitHub Issue #734: inline-comment footer can take up to 125px on small screens - @media (max-width: 774px) { - padding-bottom: 130px; - } - @media (min-width: 775px) { - padding-bottom: 80px; - } -} - .page-header { margin: 0 0 15px 0; padding: 0; diff --git a/packages/components/src/theme/lineage.scss b/packages/components/src/theme/lineage.scss index 3ebbaf4982..d3f8928967 100644 --- a/packages/components/src/theme/lineage.scss +++ b/packages/components/src/theme/lineage.scss @@ -149,6 +149,10 @@ width: 100%; } +.lineage-detail-header .unidentified-sequence-pill { + margin: 4px 0; +} + .lineage-sm-icon { height: 1.2em; margin: 0.1em; diff --git a/packages/components/src/theme/pills.scss b/packages/components/src/theme/pills.scss index 706cc059f9..12969c7365 100644 --- a/packages/components/src/theme/pills.scss +++ b/packages/components/src/theme/pills.scss @@ -6,7 +6,11 @@ white-space: break-spaces; border-style: solid; border-width: 1px; + cursor: default; + // It's important to set font-size and weight because we often use status-pill components in headers, titles, or + // other areas where the font-size and weight are set to something large and bold. font-size: 12px; + font-weight: normal; &.disabled { background-color: $gray-lighter; @@ -65,3 +69,12 @@ text-align:center; margin-left: 0.2em; } + +// The styles below are in the pills.scss file because they're part of the UnidentifiedPill component +.unidentified-sequence-pill > .fa { + margin-left: 4px; +} + +.unidentified-sequence-popover { + max-width: 250px; +} diff --git a/packages/components/src/theme/utils.scss b/packages/components/src/theme/utils.scss index 910bf343b1..b320d93e85 100644 --- a/packages/components/src/theme/utils.scss +++ b/packages/components/src/theme/utils.scss @@ -184,6 +184,14 @@ font-size: $font-size-large; } +.ta-left { + text-align: left; +} + +.ta-right { + text-align: right; +} + /** Other **/ .pointer {