Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/components/data-display/LinkPopoverCell/LinkPopoverCell.css
Original file line number Diff line number Diff line change
@@ -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;
}
93 changes: 93 additions & 0 deletions src/components/data-display/LinkPopoverCell/LinkPopoverCell.tsx
Original file line number Diff line number Diff line change
@@ -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<LinkPopoverCellProps> = ({ selector, value, row }) => {
const { state } = useSearchUIContext();
const actions = useSearchUIContextActions();
const wrapperRef = useRef<HTMLSpanElement>(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 (
<span ref={wrapperRef} className="mpc-link-popover-cell">
<a href="#" className="mpc-link-popover-cell-link" onClick={handleClick}>
{value}
</a>
{isOpen && (
<span className="mpc-link-popover-cell-popover" role="dialog">
{state.popoverContent != null ? (
state.popoverContent
) : (
<span className="mpc-link-popover-cell-loading">Loading…</span>
)}
</span>
)}
</span>
);
};
1 change: 1 addition & 0 deletions src/components/data-display/LinkPopoverCell/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LinkPopoverCell } from './LinkPopoverCell';
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,19 @@ export const SearchUIContextProvider: React.FC<SearchState> = ({
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;
Expand Down Expand Up @@ -345,9 +358,22 @@ export const SearchUIContextProvider: React.FC<SearchState> = ({
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 (
<SearchUIContext.Provider value={{ state, query }}>
Expand Down
28 changes: 27 additions & 1 deletion src/components/data-display/SearchUI/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/utils/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <LinkPopoverCell selector={c.selector} value={rowValue} row={row} />;
};
return c;
case ColumnFormat.BOOLEAN:
var truthyLabel =
hasFormatOptions && c.formatOptions.truthyLabel ? c.formatOptions.truthyLabel : 'true';
Expand Down