diff --git a/src/components/data-display/LinkPopoverCell/LinkPopoverCell.css b/src/components/data-display/LinkPopoverCell/LinkPopoverCell.css new file mode 100644 index 00000000..2126dbab --- /dev/null +++ b/src/components/data-display/LinkPopoverCell/LinkPopoverCell.css @@ -0,0 +1,40 @@ +/** + * Cell renderer for ColumnFormat.LINK_POPOVER. + * + * The wrapper is positioned relative so the popover can be absolutely + * positioned to its right. `white-space: nowrap` prevents the popover from + * wrapping inside narrow columns. + * + * Final visual styling (colors, link decoration, etc.) is intentionally minimal + * here so consuming apps (e.g. mp-web) can override via their own SCSS. + */ +.mpc-link-popover-cell { + position: relative; + display: inline-block; + white-space: nowrap; +} + +.mpc-link-popover-cell-link { + cursor: pointer; + text-decoration: underline; +} + +.mpc-link-popover-cell-popover { + position: absolute; + top: 50%; + left: 100%; + transform: translateY(-50%); + margin-left: 0.5rem; + z-index: 50; + background: #fff; + border: 1px solid #dbdbdb; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 0.5rem 0.75rem; + min-width: 180px; +} + +.mpc-link-popover-cell-loading { + font-style: italic; + color: #7a7a7a; +} diff --git a/src/components/data-display/LinkPopoverCell/LinkPopoverCell.tsx b/src/components/data-display/LinkPopoverCell/LinkPopoverCell.tsx new file mode 100644 index 00000000..9edffd11 --- /dev/null +++ b/src/components/data-display/LinkPopoverCell/LinkPopoverCell.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useRef } from 'react'; +import { + useSearchUIContext, + useSearchUIContextActions +} from '../SearchUI/SearchUIContextProvider'; +import './LinkPopoverCell.css'; + +const emptyCellPlaceholder = '-'; + +export interface LinkPopoverCellProps { + /** + * The column selector this cell belongs to. Used (together with `value`) by + * the SearchUI context to identify which cell is currently active. + */ + selector: string; + /** + * The cell value (also used as the link label). If falsy, an empty + * placeholder is rendered instead. + */ + value: any; + /** + * The full row object. Forwarded to Dash via `lastClickedCell` so callbacks + * have access to sibling fields (e.g. `material_id`) without an extra lookup. + */ + row: any; +} + +/** + * Renderer for `ColumnFormat.LINK_POPOVER` cells. Renders the cell value as a + * link; clicking the link records the cell in the SearchUI context (which + * surfaces it to Dash via `lastClickedCell`) and opens an inline popover + * anchored to the right of the link. The popover body is sourced from + * `state.popoverContent`, which Dash callbacks populate via the container's + * `popoverContent` prop. While `popoverContent` is null, a loading message is + * shown. + * + * Cells deliberately do NOT navigate on click — the popover supplies the + * navigation choices instead. + */ +export const LinkPopoverCell: React.FC = ({ selector, value, row }) => { + const { state } = useSearchUIContext(); + const actions = useSearchUIContextActions(); + const wrapperRef = useRef(null); + + const isEmpty = value === undefined || value === null || value === ''; + const isOpen = + !isEmpty && + state.lastClickedCell && + state.lastClickedCell.selector === selector && + state.lastClickedCell.value === value; + + /** + * Close the popover when the user clicks anywhere outside the wrapper. + * Registered only while the popover is open to avoid leaking listeners. + */ + useEffect(() => { + if (!isOpen) return; + const handleDocClick = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + actions.setLastClickedCell(null, null, null); + } + }; + document.addEventListener('mousedown', handleDocClick); + return () => document.removeEventListener('mousedown', handleDocClick); + }, [isOpen, actions]); + + if (isEmpty) { + return <>{emptyCellPlaceholder}; + } + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + actions.setLastClickedCell(selector, value, row); + }; + + return ( + + + {value} + + {isOpen && ( + + {state.popoverContent != null ? ( + state.popoverContent + ) : ( + Loading… + )} + + )} + + ); +}; diff --git a/src/components/data-display/LinkPopoverCell/index.tsx b/src/components/data-display/LinkPopoverCell/index.tsx new file mode 100644 index 00000000..1bf1d6b5 --- /dev/null +++ b/src/components/data-display/LinkPopoverCell/index.tsx @@ -0,0 +1 @@ +export { LinkPopoverCell } from './LinkPopoverCell'; diff --git a/src/components/data-display/SearchUI/SearchUIContextProvider/SearchUIContextProvider.tsx b/src/components/data-display/SearchUI/SearchUIContextProvider/SearchUIContextProvider.tsx index 56ce991d..b31d5bba 100644 --- a/src/components/data-display/SearchUI/SearchUIContextProvider/SearchUIContextProvider.tsx +++ b/src/components/data-display/SearchUI/SearchUIContextProvider/SearchUIContextProvider.tsx @@ -237,6 +237,19 @@ export const SearchUIContextProvider: React.FC = ({ setSelectedRows: (selectedRows: any[]) => { setState((currentState) => ({ ...currentState, selectedRows })); }, + /** + * Record the most recently clicked LINK_POPOVER cell. The `ts` field + * forces a fresh value (and therefore a fresh Dash callback) even when + * the same cell is clicked twice in a row. + */ + setLastClickedCell: (selector: string, value: any, row: any) => { + setState((currentState) => ({ + ...currentState, + lastClickedCell: { selector, value, row, ts: Date.now() }, + // Reset prior popover content so the next callback shows a loading state. + popoverContent: null + })); + }, getData: () => { /** Only show the loading icon if this is a filter change not on simple page change */ const showLoading = state.activeFilters !== prevActiveFilters ? true : false; @@ -345,9 +358,22 @@ export const SearchUIContextProvider: React.FC = ({ useEffect(() => { props.setProps({ results: state.results, - selectedRows: state.selectedRows + selectedRows: state.selectedRows, + lastClickedCell: state.lastClickedCell }); - }, [state.results, state.selectedRows]); + }, [state.results, state.selectedRows, state.lastClickedCell]); + + /** + * Allow Dash callbacks to push popover content back into state by setting + * the `popoverContent` prop on the container. Without this, the callback's + * output would never reach the renderer. + */ + useEffect(() => { + setState((currentState) => ({ + ...currentState, + popoverContent: props.popoverContent + })); + }, [props.popoverContent]); return ( diff --git a/src/components/data-display/SearchUI/types.tsx b/src/components/data-display/SearchUI/types.tsx index 4624a603..b10e703e 100644 --- a/src/components/data-display/SearchUI/types.tsx +++ b/src/components/data-display/SearchUI/types.tsx @@ -140,7 +140,17 @@ export enum ColumnFormat { TAG = 'TAG', DICT = 'DICT', CONTRIBS_FILES_DOWNLOAD = 'CONTRIBS_FILES_DOWNLOAD', - PUBLICATION = 'PUBLICATION' + PUBLICATION = 'PUBLICATION', + /** + * Renders a hyperlink that, when clicked, opens an inline popover anchored to + * the right of the link. The clicked cell is bubbled up to the parent + * `SearchUIContainer` via the `lastClickedCell` prop so a Dash callback can + * resolve any data needed to populate the popover. The popover body is then + * driven by the parent's `popoverContent` prop. + * + * Cells render the empty placeholder if the row value is missing. + */ + LINK_POPOVER = 'LINK_POPOVER' } /** @@ -398,6 +408,22 @@ export interface SearchUIContainerProps { * EXPERIMENTAL */ cardOptions?: any; + /** + * This prop is set automatically. + * Identifies the most recently clicked cell rendered with a `LINK_POPOVER` + * column format. Shape: `{ selector: string, value: any, row: any, ts: number }`. + * The `ts` (timestamp) ensures that consecutive clicks on the same cell still + * fire Dash callbacks. Reset by the parent (e.g. set to `null`) once the + * popover content has been resolved. + */ + lastClickedCell?: any; + /** + * Optional renderable content that is displayed inside the popover for the + * cell identified by `lastClickedCell`. Typically populated by a Dash + * callback after `lastClickedCell` fires. Set to `null`/`undefined` to show + * the default loading placeholder. + */ + popoverContent?: any; } export interface SearchState extends SearchUIContainerProps { diff --git a/src/utils/table.tsx b/src/utils/table.tsx index 48e8c4ac..5146e13a 100644 --- a/src/utils/table.tsx +++ b/src/utils/table.tsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import React from 'react'; import { ArrayChips } from '../components/data-display/ArrayChips'; import { Formula } from '../components/data-display/Formula'; +import { LinkPopoverCell } from '../components/data-display/LinkPopoverCell'; import { Column, ColumnFormat } from '../components/data-display/SearchUI/types'; import { Tooltip } from '../components/data-display/Tooltip'; import { formatPointGroup } from '../components/data-entry/utils'; @@ -147,6 +148,12 @@ export const initColumns = (columns: Column[], disableRichColumnHeaders?: boolea ); }; return c; + case ColumnFormat.LINK_POPOVER: + c.cell = (row: any) => { + const rowValue = getRowValueFromSelectorString(c.selector, row); + return ; + }; + return c; case ColumnFormat.BOOLEAN: var truthyLabel = hasFormatOptions && c.formatOptions.truthyLabel ? c.formatOptions.truthyLabel : 'true';