@databiosphere/findable-ui 38.3.0 → 39.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +11 -0
  3. package/lib/components/MarkdownRenderer/markdownRenderer.d.ts +1 -1
  4. package/lib/components/MarkdownRenderer/markdownRenderer.js +6 -3
  5. package/lib/components/MarkdownRenderer/rehypeHighlight.d.ts +10 -0
  6. package/lib/components/MarkdownRenderer/rehypeHighlight.js +49 -0
  7. package/lib/components/MarkdownRenderer/stories/args.d.ts +3 -0
  8. package/lib/components/MarkdownRenderer/stories/args.js +4 -0
  9. package/lib/components/MarkdownRenderer/stories/markdownRenderer.stories.d.ts +6 -0
  10. package/lib/components/MarkdownRenderer/stories/markdownRenderer.stories.js +22 -0
  11. package/lib/components/MarkdownRenderer/types.d.ts +1 -0
  12. package/lib/components/Table/columnDef/globalFilter/utils.js +5 -5
  13. package/lib/components/Table/components/TableCell/components/MarkdownCell/markdownCell.d.ts +1 -2
  14. package/lib/components/Table/components/TableCell/components/MarkdownCell/markdownCell.js +10 -5
  15. package/lib/components/Table/components/TableCell/components/MarkdownCell/stories/args.js +8 -6
  16. package/lib/components/Table/components/TableCell/components/RankedCell/utils.d.ts +0 -9
  17. package/lib/components/Table/components/TableCell/components/RankedCell/utils.js +0 -27
  18. package/package.json +1 -1
  19. package/src/components/MarkdownRenderer/markdownRenderer.tsx +12 -2
  20. package/src/components/MarkdownRenderer/rehypeHighlight.ts +54 -0
  21. package/src/components/MarkdownRenderer/stories/args.ts +8 -0
  22. package/src/components/MarkdownRenderer/stories/markdownRenderer.stories.tsx +33 -0
  23. package/src/components/MarkdownRenderer/types.ts +1 -0
  24. package/src/components/Table/columnDef/globalFilter/utils.ts +5 -5
  25. package/src/components/Table/components/TableCell/components/MarkdownCell/markdownCell.tsx +16 -6
  26. package/src/components/Table/components/TableCell/components/MarkdownCell/stories/args.ts +14 -13
  27. package/src/components/Table/components/TableCell/components/RankedCell/utils.ts +0 -37
  28. package/lib/components/Table/components/TableCell/components/MarkdownCell/stories/types.d.ts +0 -3
  29. package/lib/components/Table/components/TableCell/components/MarkdownCell/stories/types.js +0 -1
  30. package/lib/components/Table/components/TableCell/components/MarkdownCell/types.d.ts +0 -3
  31. package/lib/components/Table/components/TableCell/components/MarkdownCell/types.js +0 -1
  32. package/src/components/Table/components/TableCell/components/MarkdownCell/stories/types.ts +0 -4
  33. package/src/components/Table/components/TableCell/components/MarkdownCell/types.ts +0 -3
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "38.3.0"
2
+ ".": "39.0.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [39.0.0](https://github.com/DataBiosphere/findable-ui/compare/v38.3.0...v39.0.0) (2025-07-23)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * fix global search when a marked value is inside another HTML tag ([#539](https://github.com/DataBiosphere/findable-ui/issues/539)) (#573)
9
+
10
+ ### Features
11
+
12
+ * fix global search when a marked value is inside another HTML tag ([#539](https://github.com/DataBiosphere/findable-ui/issues/539)) ([#573](https://github.com/DataBiosphere/findable-ui/issues/573)) ([7b58c24](https://github.com/DataBiosphere/findable-ui/commit/7b58c2429ba947e752358a012a6a7d545f0ffc28))
13
+
3
14
  ## [38.3.0](https://github.com/DataBiosphere/findable-ui/compare/v38.2.0...v38.3.0) (2025-07-22)
4
15
 
5
16
 
@@ -11,4 +11,4 @@ import { MarkdownRendererProps } from "./types";
11
11
  * rehype plug-ins are chosen from versions that still target Unified 10:
12
12
  * rehype-raw 7.x, rehype-sanitize 5.x and rehype-react 7.x.
13
13
  */
14
- export declare const MarkdownRenderer: ({ className, components: componentOptions, value, }: MarkdownRendererProps) => JSX.Element;
14
+ export declare const MarkdownRenderer: ({ className, components: componentOptions, regex: markdownRegex, value, }: MarkdownRendererProps) => JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { Typography } from "@mui/material";
2
- import React, { Fragment, createElement, useEffect, useState } from "react";
2
+ import React, { Fragment, createElement, useEffect, useMemo, useState, } from "react";
3
3
  import rehypeRaw from "rehype-raw";
4
4
  import rehypeReact from "rehype-react";
5
5
  import rehypeSanitize from "rehype-sanitize";
@@ -10,6 +10,7 @@ import { unified } from "unified";
10
10
  import { TYPOGRAPHY_PROPS } from "../../styles/common/mui/typography";
11
11
  import { COMPONENTS } from "./constants";
12
12
  import { StyledContainer } from "./markdownRenderer.styles";
13
+ import { rehypeHighlight } from "./rehypeHighlight";
13
14
  /**
14
15
  * Markdown Rendering - Pipeline Version Constraints
15
16
  *
@@ -22,10 +23,11 @@ import { StyledContainer } from "./markdownRenderer.styles";
22
23
  * rehype plug-ins are chosen from versions that still target Unified 10:
23
24
  * rehype-raw 7.x, rehype-sanitize 5.x and rehype-react 7.x.
24
25
  */
25
- export const MarkdownRenderer = ({ className, components: componentOptions = COMPONENTS, value, }) => {
26
+ export const MarkdownRenderer = ({ className, components: componentOptions = COMPONENTS, regex: markdownRegex, value, }) => {
26
27
  const [element, setElement] = useState(null);
27
28
  const [error, setError] = useState(null);
28
29
  const [components] = useState(componentOptions);
30
+ const regex = useMemo(() => markdownRegex, [markdownRegex]);
29
31
  useEffect(() => {
30
32
  let cancelled = false;
31
33
  setError(null);
@@ -35,6 +37,7 @@ export const MarkdownRenderer = ({ className, components: componentOptions = COM
35
37
  .use(remarkRehype, { allowDangerousHtml: true })
36
38
  .use(rehypeRaw)
37
39
  .use(rehypeSanitize)
40
+ .use(rehypeHighlight, { regex })
38
41
  .use(rehypeReact, { Fragment, components, createElement });
39
42
  processor
40
43
  .process(value)
@@ -49,7 +52,7 @@ export const MarkdownRenderer = ({ className, components: componentOptions = COM
49
52
  return () => {
50
53
  cancelled = true;
51
54
  };
52
- }, [components, value]);
55
+ }, [components, regex, value]);
53
56
  if (error)
54
57
  return (React.createElement(Typography, { color: TYPOGRAPHY_PROPS.COLOR.ERROR, variant: TYPOGRAPHY_PROPS.VARIANT.TEXT_BODY_SMALL_400 }, error));
55
58
  return React.createElement(StyledContainer, { className: className }, element);
@@ -0,0 +1,10 @@
1
+ import { Root } from "hast";
2
+ /**
3
+ * Custom rehype plugin to highlight markdown from given regex.
4
+ * @param options - Options.
5
+ * @param options.regex - Regex to match.
6
+ * @returns A rehype plugin.
7
+ */
8
+ export declare function rehypeHighlight(options: {
9
+ regex: RegExp | undefined;
10
+ }): (tree: Root) => void;
@@ -0,0 +1,49 @@
1
+ import { visit } from "unist-util-visit";
2
+ /**
3
+ * Custom rehype plugin to highlight markdown from given regex.
4
+ * @param options - Options.
5
+ * @param options.regex - Regex to match.
6
+ * @returns A rehype plugin.
7
+ */
8
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- ignoring for readability
9
+ export function rehypeHighlight(options) {
10
+ const { regex } = options;
11
+ if (!regex)
12
+ return () => { };
13
+ return (tree) => {
14
+ visit(tree, "text", (node, index, parent) => {
15
+ if (!parent)
16
+ return;
17
+ if (typeof index !== "number")
18
+ return;
19
+ // Avoid double marking, breaking scripts, and breaking styles.
20
+ if ("tagName" in parent &&
21
+ ["mark", "script", "style"].includes(parent.tagName)) {
22
+ return;
23
+ }
24
+ const parts = node.value.split(regex);
25
+ if (parts.length === 1)
26
+ return; // No text to highlight.
27
+ const newNodes = [];
28
+ parts.forEach((part, i) => {
29
+ if (!part)
30
+ return; // Skip empties.
31
+ if (i % 2) {
32
+ // Captured text.
33
+ newNodes.push({
34
+ children: [{ type: "text", value: part }],
35
+ properties: {},
36
+ tagName: "mark",
37
+ type: "element",
38
+ });
39
+ }
40
+ else {
41
+ // Normal text.
42
+ newNodes.push({ type: "text", value: part });
43
+ }
44
+ });
45
+ // Replace the original text node with the new nodes.
46
+ parent.children.splice(index, 1, ...newNodes);
47
+ });
48
+ };
49
+ }
@@ -0,0 +1,3 @@
1
+ import { ComponentProps } from "react";
2
+ import { MarkdownRenderer } from "../markdownRenderer";
3
+ export declare const DEFAULT_ARGS: ComponentProps<typeof MarkdownRenderer>;
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_ARGS = {
2
+ regex: /(UBERON|955)/gi,
3
+ value: '| Key | Annotator | Value |\n| --- | --- | --- |\n| `tissue_ontology_term_id` | Curator | categorical with `str` categories. This **MUST** be the UBERON or CL term that best describes the tissue the cell was derived from, depending on the sample type. |\n\n**Mapping guidance**\n\n| For | Use |\n| --- | --- |\n| Tissue | STRONGLY RECOMMENDED to be an UBERON term&nbsp;<br />(e.g. [`UBERON:0008930`](http://purl.obolibrary.org/obo/UBERON_0008930) for a *somatosensory cortex* tissue sample) |\n| Cell culture | MUST be a CL term appended with \\" (cell culture)\\"&nbsp;<br />(e.g. [`CL:0000057`](http://purl.obolibrary.org/obo/CL_0000057) **(cell culture)** for the *WTC-11* cell line) |\n| Organoid | MUST be an UBERON term appended with \\" (organoid)\\"&nbsp;<br />(e.g. [`UBERON:0000955`](http://purl.obolibrary.org/obo/UBERON_0000955) **(organoid)** for a *brain organoid*) |\n| Enriched / sorted / isolated cells from a tissue | MUST be an UBERON or CL term and **SHOULD NOT** use terms that do not capture the tissue of origin.<br /><br />• *CD3+ kidney cells* → [`UBERON:0002113`](https://www.ebi.ac.uk/ols/ontologies/uberon/terms?iri=http://purl.obolibrary.org/obo/UBERON_0002113) (*kidney*) instead of [`CL:000084`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000084) (*T cell*).<br />• *EPCAM+ cervical cells* → [`CL:0000066`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000066) (*epithelial cell* of the cervix). |\n\n---\n\nWhen a dataset is uploaded, the **cellxgene Data Portal** MUST automatically add the matching human-readable name for the corresponding ontology term to the `obs` dataframe. Curators **MUST NOT** annotate the following columns.\n\n### `assay`\n\n| Key | Annotator | Value |\n| --- | --- | --- |\n| `assay` | Data Portal | categorical with `str` categories. This **MUST** be the human-readable name assigned to the value of `assay_ontology_term_id`. |',
4
+ };
@@ -0,0 +1,6 @@
1
+ import { Meta, StoryObj } from "@storybook/react";
2
+ import { MarkdownRenderer } from "../markdownRenderer";
3
+ declare const meta: Meta<typeof MarkdownRenderer>;
4
+ export default meta;
5
+ type Story = StoryObj<typeof meta>;
6
+ export declare const Default: Story;
@@ -0,0 +1,22 @@
1
+ import { Box } from "@mui/material";
2
+ import React from "react";
3
+ import { PALETTE } from "../../../styles/common/constants/palette";
4
+ import { MarkdownRenderer } from "../markdownRenderer";
5
+ import { DEFAULT_ARGS } from "./args";
6
+ const meta = {
7
+ component: MarkdownRenderer,
8
+ decorators: [
9
+ (Story) => (React.createElement(Box, { sx: {
10
+ backgroundColor: PALETTE.COMMON_WHITE,
11
+ fontSize: "14px",
12
+ lineHeight: "20px",
13
+ padding: 3,
14
+ width: 480,
15
+ } },
16
+ React.createElement(Story, null))),
17
+ ],
18
+ };
19
+ export default meta;
20
+ export const Default = {
21
+ args: DEFAULT_ARGS,
22
+ };
@@ -3,5 +3,6 @@ import { BaseComponentProps } from "../types";
3
3
  export type MarkdownRendererComponents = Record<string, ComponentType<any>>;
4
4
  export interface MarkdownRendererProps extends BaseComponentProps {
5
5
  components?: MarkdownRendererComponents;
6
+ regex?: RegExp;
6
7
  value: string;
7
8
  }
@@ -6,11 +6,14 @@ import { RANK_ITEM_OPTIONS } from "./constants";
6
6
  * @returns Array of terms.
7
7
  */
8
8
  export function parseSearchTerms(value) {
9
- return String(value ?? "")
9
+ const terms = String(value ?? "")
10
10
  .toLowerCase()
11
11
  .trim()
12
12
  .split(/\s+/)
13
- .filter(Boolean);
13
+ .filter(Boolean)
14
+ .sort((a, b) => b.length - a.length);
15
+ const termsSet = new Set(terms);
16
+ return [...termsSet];
14
17
  }
15
18
  /**
16
19
  * Ranks a column's value against search terms.
@@ -38,7 +41,6 @@ export function rankColumnValue(row, columnId, terms) {
38
41
  * @param terms - Search terms to match against.
39
42
  */
40
43
  export function rankRowColumns(row, columnId, terms) {
41
- const columnFiltersMeta = row.columnFiltersMeta;
42
44
  // Process other columns.
43
45
  for (const { column } of row.getAllCells()) {
44
46
  // Column is not searchable.
@@ -47,8 +49,6 @@ export function rankRowColumns(row, columnId, terms) {
47
49
  // Column has already been processed.
48
50
  if (column.id === columnId)
49
51
  continue;
50
- if (column.id in columnFiltersMeta)
51
- continue;
52
52
  // Rank the column value.
53
53
  const passed = rankColumnValue(row, column.id, terms);
54
54
  // Add the filter metadata.
@@ -1,4 +1,3 @@
1
1
  import { CellContext, RowData } from "@tanstack/react-table";
2
2
  import { BaseComponentProps } from "components/types";
3
- import { MarkdownCellProps } from "./types";
4
- export declare const MarkdownCell: <T extends RowData, TValue extends MarkdownCellProps = MarkdownCellProps>({ className, column, getValue, }: BaseComponentProps & CellContext<T, TValue>) => JSX.Element | null;
3
+ export declare const MarkdownCell: <T extends RowData, TValue extends string = string>({ className, column, getValue, row, table, }: BaseComponentProps & CellContext<T, TValue>) => JSX.Element | null;
@@ -1,13 +1,18 @@
1
1
  import React from "react";
2
2
  import { COMPONENTS } from "../../../../../MarkdownRenderer/constants";
3
+ import { getTokens, getTokensRegex, isRankedCell } from "../RankedCell/utils";
3
4
  import { StyledMarkdownRenderer } from "./markdownCell.styles";
4
- export const MarkdownCell = ({ className, column, getValue, }) => {
5
- const props = getValue();
6
- if (!props)
5
+ export const MarkdownCell = ({ className, column, getValue, row, table, }) => {
6
+ const value = getValue();
7
+ if (!value)
7
8
  return null;
8
- const { values } = props;
9
+ // Get column metadata (components to be rendered in MarkdownRenderer).
9
10
  const columnDef = column?.columnDef;
10
11
  const columnMeta = columnDef?.meta;
11
12
  const components = columnMeta?.components;
12
- return (React.createElement(StyledMarkdownRenderer, { className: className, components: { ...COMPONENTS, ...components }, value: values }));
13
+ // Determine if the cell is ranked.
14
+ const isRanked = isRankedCell(table, row, column.id);
15
+ // Build regex for markdown highlighting.
16
+ const regex = isRanked ? getTokensRegex(getTokens(table)) : undefined;
17
+ return (React.createElement(StyledMarkdownRenderer, { className: className, components: { ...COMPONENTS, ...components }, regex: regex, value: value }));
13
18
  };
@@ -1,10 +1,12 @@
1
1
  export const DEFAULT_ARGS = {
2
- getValue: (() => ({
3
- values: '| Key | Annotator | Value |\n| --- | --- | --- |\n| `tissue_ontology_term_id` | Curator | categorical with `str` categories. This **MUST** be the UBERON or CL term that best describes the tissue the cell was derived from, depending on the sample type. |\n\n**Mapping guidance**\n\n| For | Use |\n| --- | --- |\n| Tissue | STRONGLY RECOMMENDED to be an UBERON term&nbsp;<br />(e.g. [`UBERON:0008930`](http://purl.obolibrary.org/obo/UBERON_0008930) for a *somatosensory cortex* tissue sample) |\n| Cell culture | MUST be a CL term appended with \\" (cell culture)\\"&nbsp;<br />(e.g. [`CL:0000057`](http://purl.obolibrary.org/obo/CL_0000057) **(cell culture)** for the *WTC-11* cell line) |\n| Organoid | MUST be an UBERON term appended with \\" (organoid)\\"&nbsp;<br />(e.g. [`UBERON:0000955`](http://purl.obolibrary.org/obo/UBERON_0000955) **(organoid)** for a *brain organoid*) |\n| Enriched / sorted / isolated cells from a tissue | MUST be an UBERON or CL term and **SHOULD NOT** use terms that do not capture the tissue of origin.<br /><br />• *CD3+ kidney cells* → [`UBERON:0002113`](https://www.ebi.ac.uk/ols/ontologies/uberon/terms?iri=http://purl.obolibrary.org/obo/UBERON_0002113) (*kidney*) instead of [`CL:000084`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000084) (*T cell*).<br />• *EPCAM+ cervical cells* → [`CL:0000066`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000066) (*epithelial cell* of the cervix). |\n\n---\n\nWhen a dataset is uploaded, the **cellxgene Data Portal** MUST automatically add the matching human-readable name for the corresponding ontology term to the `obs` dataframe. Curators **MUST NOT** annotate the following columns.\n\n### `assay`\n\n| Key | Annotator | Value |\n| --- | --- | --- |\n| `assay` | Data Portal | categorical with `str` categories. This **MUST** be the human-readable name assigned to the value of `assay_ontology_term_id`. |',
4
- })),
2
+ column: { id: "description" },
3
+ getValue: () => '| Key | Annotator | Value |\n| --- | --- | --- |\n| `tissue_ontology_term_id` | Curator | categorical with `str` categories. This **MUST** be the UBERON or CL term that best describes the tissue the cell was derived from, depending on the sample type. |\n\n**Mapping guidance**\n\n| For | Use |\n| --- | --- |\n| Tissue | STRONGLY RECOMMENDED to be an UBERON term&nbsp;<br />(e.g. [`UBERON:0008930`](http://purl.obolibrary.org/obo/UBERON_0008930) for a *somatosensory cortex* tissue sample) |\n| Cell culture | MUST be a CL term appended with \\" (cell culture)\\"&nbsp;<br />(e.g. [`CL:0000057`](http://purl.obolibrary.org/obo/CL_0000057) **(cell culture)** for the *WTC-11* cell line) |\n| Organoid | MUST be an UBERON term appended with \\" (organoid)\\"&nbsp;<br />(e.g. [`UBERON:0000955`](http://purl.obolibrary.org/obo/UBERON_0000955) **(organoid)** for a *brain organoid*) |\n| Enriched / sorted / isolated cells from a tissue | MUST be an UBERON or CL term and **SHOULD NOT** use terms that do not capture the tissue of origin.<br /><br />• *CD3+ kidney cells* → [`UBERON:0002113`](https://www.ebi.ac.uk/ols/ontologies/uberon/terms?iri=http://purl.obolibrary.org/obo/UBERON_0002113) (*kidney*) instead of [`CL:000084`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000084) (*T cell*).<br />• *EPCAM+ cervical cells* → [`CL:0000066`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000066) (*epithelial cell* of the cervix). |\n\n---\n\nWhen a dataset is uploaded, the **cellxgene Data Portal** MUST automatically add the matching human-readable name for the corresponding ontology term to the `obs` dataframe. Curators **MUST NOT** annotate the following columns.\n\n### `assay`\n\n| Key | Annotator | Value |\n| --- | --- | --- |\n| `assay` | Data Portal | categorical with `str` categories. This **MUST** be the human-readable name assigned to the value of `assay_ontology_term_id`. |',
4
+ row: { columnFiltersMeta: { description: { passed: false } } },
5
+ table: { getState: () => ({ globalFilter: "" }) },
5
6
  };
6
7
  export const WITH_HTML_ARGS = {
7
- getValue: (() => ({
8
- values: "Hello <br />World <a href='https://www.example.com'>example link</a>",
9
- })),
8
+ column: { id: "description" },
9
+ getValue: () => "Hello <br />World <a href='https://www.example.com'>example link</a>",
10
+ row: { columnFiltersMeta: { description: { passed: false } } },
11
+ table: { getState: () => ({ globalFilter: "" }) },
10
12
  };
@@ -1,13 +1,4 @@
1
1
  import { Row, RowData, Table } from "@tanstack/react-table";
2
- /**
3
- * Renders a cell value with highlighting if it matches item rank filter criteria.
4
- * @param table - Table.
5
- * @param row - Row.
6
- * @param columnId - Column identifier.
7
- * @param value - Cell value.
8
- * @returns Rendered cell value with highlighting.
9
- */
10
- export declare function renderRankedCell<T extends RowData>(table: Table<T>, row: Row<T>, columnId: string, value: string | undefined | null): string;
11
2
  /**
12
3
  * Returns the current global filter tokens from the table.
13
4
  * @param table - Table.
@@ -1,32 +1,5 @@
1
1
  import { escapeRegExp } from "../../../../../../common/utils";
2
2
  import { parseSearchTerms } from "../../../../columnDef/globalFilter/utils";
3
- /**
4
- * Renders a cell value with highlighting if it matches item rank filter criteria.
5
- * @param table - Table.
6
- * @param row - Row.
7
- * @param columnId - Column identifier.
8
- * @param value - Cell value.
9
- * @returns Rendered cell value with highlighting.
10
- */
11
- export function renderRankedCell(table, row, columnId, value) {
12
- // If the cell value is undefined or null, return an empty string.
13
- if (value === undefined || value === null)
14
- return "";
15
- const stringValue = String(value);
16
- // Check if the cell is ranked.
17
- const isRanked = isRankedCell(table, row, columnId);
18
- // Return the unranked cell, as-is, in string form.
19
- if (!isRanked)
20
- return stringValue;
21
- // Tokenise the current global filter.
22
- const tokens = getTokens(table);
23
- // If there are no tokens, return the value as-is, in string form.
24
- if (tokens.length === 0)
25
- return stringValue;
26
- // Create regex pattern.
27
- const regex = getTokensRegex(tokens);
28
- return stringValue.replace(regex, "<mark>$1</mark>");
29
- }
30
3
  /**
31
4
  * Returns the current global filter tokens from the table.
32
5
  * @param table - Table.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databiosphere/findable-ui",
3
- "version": "38.3.0",
3
+ "version": "39.0.0",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
@@ -1,5 +1,11 @@
1
1
  import { Typography } from "@mui/material";
2
- import React, { Fragment, createElement, useEffect, useState } from "react";
2
+ import React, {
3
+ Fragment,
4
+ createElement,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
3
9
  import rehypeRaw from "rehype-raw";
4
10
  import rehypeReact from "rehype-react";
5
11
  import rehypeSanitize from "rehype-sanitize";
@@ -10,6 +16,7 @@ import { unified } from "unified";
10
16
  import { TYPOGRAPHY_PROPS } from "../../styles/common/mui/typography";
11
17
  import { COMPONENTS } from "./constants";
12
18
  import { StyledContainer } from "./markdownRenderer.styles";
19
+ import { rehypeHighlight } from "./rehypeHighlight";
13
20
  import { MarkdownRendererComponents, MarkdownRendererProps } from "./types";
14
21
 
15
22
  /**
@@ -28,11 +35,13 @@ import { MarkdownRendererComponents, MarkdownRendererProps } from "./types";
28
35
  export const MarkdownRenderer = ({
29
36
  className,
30
37
  components: componentOptions = COMPONENTS,
38
+ regex: markdownRegex,
31
39
  value,
32
40
  }: MarkdownRendererProps): JSX.Element => {
33
41
  const [element, setElement] = useState<JSX.Element | null>(null);
34
42
  const [error, setError] = useState<string | null>(null);
35
43
  const [components] = useState<MarkdownRendererComponents>(componentOptions);
44
+ const regex = useMemo(() => markdownRegex, [markdownRegex]);
36
45
 
37
46
  useEffect(() => {
38
47
  let cancelled = false;
@@ -44,6 +53,7 @@ export const MarkdownRenderer = ({
44
53
  .use(remarkRehype, { allowDangerousHtml: true })
45
54
  .use(rehypeRaw)
46
55
  .use(rehypeSanitize)
56
+ .use(rehypeHighlight, { regex })
47
57
  .use(rehypeReact, { Fragment, components, createElement });
48
58
 
49
59
  processor
@@ -58,7 +68,7 @@ export const MarkdownRenderer = ({
58
68
  return (): void => {
59
69
  cancelled = true;
60
70
  };
61
- }, [components, value]);
71
+ }, [components, regex, value]);
62
72
 
63
73
  if (error)
64
74
  return (
@@ -0,0 +1,54 @@
1
+ import { Element, Root, Text } from "hast";
2
+ import { visit } from "unist-util-visit";
3
+
4
+ /**
5
+ * Custom rehype plugin to highlight markdown from given regex.
6
+ * @param options - Options.
7
+ * @param options.regex - Regex to match.
8
+ * @returns A rehype plugin.
9
+ */
10
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- ignoring for readability
11
+ export function rehypeHighlight(options: { regex: RegExp | undefined }) {
12
+ const { regex } = options;
13
+
14
+ if (!regex) return (): void => {};
15
+
16
+ return (tree: Root): void => {
17
+ visit(tree, "text", (node, index, parent) => {
18
+ if (!parent) return;
19
+ if (typeof index !== "number") return;
20
+
21
+ // Avoid double marking, breaking scripts, and breaking styles.
22
+ if (
23
+ "tagName" in parent &&
24
+ ["mark", "script", "style"].includes(parent.tagName)
25
+ ) {
26
+ return;
27
+ }
28
+
29
+ const parts = node.value.split(regex);
30
+ if (parts.length === 1) return; // No text to highlight.
31
+
32
+ const newNodes: (Element | Text)[] = [];
33
+
34
+ parts.forEach((part, i) => {
35
+ if (!part) return; // Skip empties.
36
+ if (i % 2) {
37
+ // Captured text.
38
+ newNodes.push({
39
+ children: [{ type: "text", value: part }],
40
+ properties: {},
41
+ tagName: "mark",
42
+ type: "element",
43
+ });
44
+ } else {
45
+ // Normal text.
46
+ newNodes.push({ type: "text", value: part });
47
+ }
48
+ });
49
+
50
+ // Replace the original text node with the new nodes.
51
+ parent.children.splice(index, 1, ...newNodes);
52
+ });
53
+ };
54
+ }
@@ -0,0 +1,8 @@
1
+ import { ComponentProps } from "react";
2
+ import { MarkdownRenderer } from "../markdownRenderer";
3
+
4
+ export const DEFAULT_ARGS: ComponentProps<typeof MarkdownRenderer> = {
5
+ regex: /(UBERON|955)/gi,
6
+ value:
7
+ '| Key | Annotator | Value |\n| --- | --- | --- |\n| `tissue_ontology_term_id` | Curator | categorical with `str` categories. This **MUST** be the UBERON or CL term that best describes the tissue the cell was derived from, depending on the sample type. |\n\n**Mapping guidance**\n\n| For | Use |\n| --- | --- |\n| Tissue | STRONGLY RECOMMENDED to be an UBERON term&nbsp;<br />(e.g. [`UBERON:0008930`](http://purl.obolibrary.org/obo/UBERON_0008930) for a *somatosensory cortex* tissue sample) |\n| Cell culture | MUST be a CL term appended with \\" (cell culture)\\"&nbsp;<br />(e.g. [`CL:0000057`](http://purl.obolibrary.org/obo/CL_0000057) **(cell culture)** for the *WTC-11* cell line) |\n| Organoid | MUST be an UBERON term appended with \\" (organoid)\\"&nbsp;<br />(e.g. [`UBERON:0000955`](http://purl.obolibrary.org/obo/UBERON_0000955) **(organoid)** for a *brain organoid*) |\n| Enriched / sorted / isolated cells from a tissue | MUST be an UBERON or CL term and **SHOULD NOT** use terms that do not capture the tissue of origin.<br /><br />• *CD3+ kidney cells* → [`UBERON:0002113`](https://www.ebi.ac.uk/ols/ontologies/uberon/terms?iri=http://purl.obolibrary.org/obo/UBERON_0002113) (*kidney*) instead of [`CL:000084`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000084) (*T cell*).<br />• *EPCAM+ cervical cells* → [`CL:0000066`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000066) (*epithelial cell* of the cervix). |\n\n---\n\nWhen a dataset is uploaded, the **cellxgene Data Portal** MUST automatically add the matching human-readable name for the corresponding ontology term to the `obs` dataframe. Curators **MUST NOT** annotate the following columns.\n\n### `assay`\n\n| Key | Annotator | Value |\n| --- | --- | --- |\n| `assay` | Data Portal | categorical with `str` categories. This **MUST** be the human-readable name assigned to the value of `assay_ontology_term_id`. |',
8
+ };
@@ -0,0 +1,33 @@
1
+ import { Box } from "@mui/material";
2
+ import { Meta, StoryObj } from "@storybook/react";
3
+ import React from "react";
4
+ import { PALETTE } from "../../../styles/common/constants/palette";
5
+ import { MarkdownRenderer } from "../markdownRenderer";
6
+ import { DEFAULT_ARGS } from "./args";
7
+
8
+ const meta: Meta<typeof MarkdownRenderer> = {
9
+ component: MarkdownRenderer,
10
+ decorators: [
11
+ (Story): JSX.Element => (
12
+ <Box
13
+ sx={{
14
+ backgroundColor: PALETTE.COMMON_WHITE,
15
+ fontSize: "14px",
16
+ lineHeight: "20px",
17
+ padding: 3,
18
+ width: 480,
19
+ }}
20
+ >
21
+ <Story />
22
+ </Box>
23
+ ),
24
+ ],
25
+ };
26
+
27
+ export default meta;
28
+
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ export const Default: Story = {
32
+ args: DEFAULT_ARGS,
33
+ };
@@ -6,5 +6,6 @@ export type MarkdownRendererComponents = Record<string, ComponentType<any>>;
6
6
 
7
7
  export interface MarkdownRendererProps extends BaseComponentProps {
8
8
  components?: MarkdownRendererComponents;
9
+ regex?: RegExp;
9
10
  value: string;
10
11
  }
@@ -8,11 +8,14 @@ import { RANK_ITEM_OPTIONS } from "./constants";
8
8
  * @returns Array of terms.
9
9
  */
10
10
  export function parseSearchTerms(value: unknown): string[] {
11
- return String(value ?? "")
11
+ const terms = String(value ?? "")
12
12
  .toLowerCase()
13
13
  .trim()
14
14
  .split(/\s+/)
15
- .filter(Boolean);
15
+ .filter(Boolean)
16
+ .sort((a, b) => b.length - a.length);
17
+ const termsSet = new Set(terms);
18
+ return [...termsSet];
16
19
  }
17
20
 
18
21
  /**
@@ -54,8 +57,6 @@ export function rankRowColumns<T extends RowData>(
54
57
  columnId: string,
55
58
  terms: string[]
56
59
  ): void {
57
- const columnFiltersMeta = row.columnFiltersMeta;
58
-
59
60
  // Process other columns.
60
61
  for (const { column } of row.getAllCells()) {
61
62
  // Column is not searchable.
@@ -63,7 +64,6 @@ export function rankRowColumns<T extends RowData>(
63
64
 
64
65
  // Column has already been processed.
65
66
  if (column.id === columnId) continue;
66
- if (column.id in columnFiltersMeta) continue;
67
67
 
68
68
  // Rank the column value.
69
69
  const passed = rankColumnValue(row, column.id, terms);
@@ -2,28 +2,38 @@ import { CellContext, RowData } from "@tanstack/react-table";
2
2
  import { BaseComponentProps } from "components/types";
3
3
  import React from "react";
4
4
  import { COMPONENTS } from "../../../../../MarkdownRenderer/constants";
5
+ import { getTokens, getTokensRegex, isRankedCell } from "../RankedCell/utils";
5
6
  import { StyledMarkdownRenderer } from "./markdownCell.styles";
6
- import { MarkdownCellProps } from "./types";
7
7
 
8
8
  export const MarkdownCell = <
9
9
  T extends RowData,
10
- TValue extends MarkdownCellProps = MarkdownCellProps
10
+ TValue extends string = string
11
11
  >({
12
12
  className,
13
13
  column,
14
14
  getValue,
15
+ row,
16
+ table,
15
17
  }: BaseComponentProps & CellContext<T, TValue>): JSX.Element | null => {
16
- const props = getValue();
17
- if (!props) return null;
18
- const { values } = props;
18
+ const value = getValue();
19
+ if (!value) return null;
20
+
21
+ // Get column metadata (components to be rendered in MarkdownRenderer).
19
22
  const columnDef = column?.columnDef;
20
23
  const columnMeta = columnDef?.meta;
21
24
  const components = columnMeta?.components;
25
+
26
+ // Determine if the cell is ranked.
27
+ const isRanked = isRankedCell(table, row, column.id);
28
+ // Build regex for markdown highlighting.
29
+ const regex = isRanked ? getTokensRegex(getTokens(table)) : undefined;
30
+
22
31
  return (
23
32
  <StyledMarkdownRenderer
24
33
  className={className}
25
34
  components={{ ...COMPONENTS, ...components }}
26
- value={values}
35
+ regex={regex}
36
+ value={value}
27
37
  />
28
38
  );
29
39
  };
@@ -1,17 +1,18 @@
1
1
  import { ComponentProps } from "react";
2
2
  import { MarkdownCell } from "../markdownCell";
3
- import { GetValue } from "./types";
4
3
 
5
- export const DEFAULT_ARGS: Partial<ComponentProps<typeof MarkdownCell>> = {
6
- getValue: (() => ({
7
- values:
8
- '| Key | Annotator | Value |\n| --- | --- | --- |\n| `tissue_ontology_term_id` | Curator | categorical with `str` categories. This **MUST** be the UBERON or CL term that best describes the tissue the cell was derived from, depending on the sample type. |\n\n**Mapping guidance**\n\n| For | Use |\n| --- | --- |\n| Tissue | STRONGLY RECOMMENDED to be an UBERON term&nbsp;<br />(e.g. [`UBERON:0008930`](http://purl.obolibrary.org/obo/UBERON_0008930) for a *somatosensory cortex* tissue sample) |\n| Cell culture | MUST be a CL term appended with \\" (cell culture)\\"&nbsp;<br />(e.g. [`CL:0000057`](http://purl.obolibrary.org/obo/CL_0000057) **(cell culture)** for the *WTC-11* cell line) |\n| Organoid | MUST be an UBERON term appended with \\" (organoid)\\"&nbsp;<br />(e.g. [`UBERON:0000955`](http://purl.obolibrary.org/obo/UBERON_0000955) **(organoid)** for a *brain organoid*) |\n| Enriched / sorted / isolated cells from a tissue | MUST be an UBERON or CL term and **SHOULD NOT** use terms that do not capture the tissue of origin.<br /><br />• *CD3+ kidney cells* → [`UBERON:0002113`](https://www.ebi.ac.uk/ols/ontologies/uberon/terms?iri=http://purl.obolibrary.org/obo/UBERON_0002113) (*kidney*) instead of [`CL:000084`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000084) (*T cell*).<br />• *EPCAM+ cervical cells* → [`CL:0000066`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000066) (*epithelial cell* of the cervix). |\n\n---\n\nWhen a dataset is uploaded, the **cellxgene Data Portal** MUST automatically add the matching human-readable name for the corresponding ontology term to the `obs` dataframe. Curators **MUST NOT** annotate the following columns.\n\n### `assay`\n\n| Key | Annotator | Value |\n| --- | --- | --- |\n| `assay` | Data Portal | categorical with `str` categories. This **MUST** be the human-readable name assigned to the value of `assay_ontology_term_id`. |',
9
- })) as GetValue,
10
- };
4
+ export const DEFAULT_ARGS = {
5
+ column: { id: "description" },
6
+ getValue: () =>
7
+ '| Key | Annotator | Value |\n| --- | --- | --- |\n| `tissue_ontology_term_id` | Curator | categorical with `str` categories. This **MUST** be the UBERON or CL term that best describes the tissue the cell was derived from, depending on the sample type. |\n\n**Mapping guidance**\n\n| For | Use |\n| --- | --- |\n| Tissue | STRONGLY RECOMMENDED to be an UBERON term&nbsp;<br />(e.g. [`UBERON:0008930`](http://purl.obolibrary.org/obo/UBERON_0008930) for a *somatosensory cortex* tissue sample) |\n| Cell culture | MUST be a CL term appended with \\" (cell culture)\\"&nbsp;<br />(e.g. [`CL:0000057`](http://purl.obolibrary.org/obo/CL_0000057) **(cell culture)** for the *WTC-11* cell line) |\n| Organoid | MUST be an UBERON term appended with \\" (organoid)\\"&nbsp;<br />(e.g. [`UBERON:0000955`](http://purl.obolibrary.org/obo/UBERON_0000955) **(organoid)** for a *brain organoid*) |\n| Enriched / sorted / isolated cells from a tissue | MUST be an UBERON or CL term and **SHOULD NOT** use terms that do not capture the tissue of origin.<br /><br />• *CD3+ kidney cells* → [`UBERON:0002113`](https://www.ebi.ac.uk/ols/ontologies/uberon/terms?iri=http://purl.obolibrary.org/obo/UBERON_0002113) (*kidney*) instead of [`CL:000084`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000084) (*T cell*).<br />• *EPCAM+ cervical cells* → [`CL:0000066`](https://www.ebi.ac.uk/ols/ontologies/cl/terms?iri=http://purl.obolibrary.org/obo/CL_0000066) (*epithelial cell* of the cervix). |\n\n---\n\nWhen a dataset is uploaded, the **cellxgene Data Portal** MUST automatically add the matching human-readable name for the corresponding ontology term to the `obs` dataframe. Curators **MUST NOT** annotate the following columns.\n\n### `assay`\n\n| Key | Annotator | Value |\n| --- | --- | --- |\n| `assay` | Data Portal | categorical with `str` categories. This **MUST** be the human-readable name assigned to the value of `assay_ontology_term_id`. |',
8
+ row: { columnFiltersMeta: { description: { passed: false } } },
9
+ table: { getState: () => ({ globalFilter: "" }) },
10
+ } as unknown as Partial<ComponentProps<typeof MarkdownCell>>;
11
11
 
12
- export const WITH_HTML_ARGS: Partial<ComponentProps<typeof MarkdownCell>> = {
13
- getValue: (() => ({
14
- values:
15
- "Hello <br />World <a href='https://www.example.com'>example link</a>",
16
- })) as GetValue,
17
- };
12
+ export const WITH_HTML_ARGS = {
13
+ column: { id: "description" },
14
+ getValue: () =>
15
+ "Hello <br />World <a href='https://www.example.com'>example link</a>",
16
+ row: { columnFiltersMeta: { description: { passed: false } } },
17
+ table: { getState: () => ({ globalFilter: "" }) },
18
+ } as unknown as Partial<ComponentProps<typeof MarkdownCell>>;
@@ -3,43 +3,6 @@ import { escapeRegExp } from "../../../../../../common/utils";
3
3
  import { ColumnFilterMeta } from "../../../../columnDef/globalFilter/types";
4
4
  import { parseSearchTerms } from "../../../../columnDef/globalFilter/utils";
5
5
 
6
- /**
7
- * Renders a cell value with highlighting if it matches item rank filter criteria.
8
- * @param table - Table.
9
- * @param row - Row.
10
- * @param columnId - Column identifier.
11
- * @param value - Cell value.
12
- * @returns Rendered cell value with highlighting.
13
- */
14
- export function renderRankedCell<T extends RowData>(
15
- table: Table<T>,
16
- row: Row<T>,
17
- columnId: string,
18
- value: string | undefined | null
19
- ): string {
20
- // If the cell value is undefined or null, return an empty string.
21
- if (value === undefined || value === null) return "";
22
-
23
- const stringValue = String(value);
24
-
25
- // Check if the cell is ranked.
26
- const isRanked = isRankedCell(table, row, columnId);
27
-
28
- // Return the unranked cell, as-is, in string form.
29
- if (!isRanked) return stringValue;
30
-
31
- // Tokenise the current global filter.
32
- const tokens = getTokens(table);
33
-
34
- // If there are no tokens, return the value as-is, in string form.
35
- if (tokens.length === 0) return stringValue;
36
-
37
- // Create regex pattern.
38
- const regex = getTokensRegex(tokens);
39
-
40
- return stringValue.replace(regex, "<mark>$1</mark>");
41
- }
42
-
43
6
  /**
44
7
  * Returns the current global filter tokens from the table.
45
8
  * @param table - Table.
@@ -1,3 +0,0 @@
1
- import { CellContext, RowData } from "@tanstack/react-table";
2
- import { MarkdownCellProps } from "../types";
3
- export type GetValue = CellContext<RowData, MarkdownCellProps>["getValue"];
@@ -1,3 +0,0 @@
1
- export type MarkdownCellProps = {
2
- values: string;
3
- };
@@ -1,4 +0,0 @@
1
- import { CellContext, RowData } from "@tanstack/react-table";
2
- import { MarkdownCellProps } from "../types";
3
-
4
- export type GetValue = CellContext<RowData, MarkdownCellProps>["getValue"];
@@ -1,3 +0,0 @@
1
- export type MarkdownCellProps = {
2
- values: string;
3
- };