@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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +11 -0
- package/lib/components/MarkdownRenderer/markdownRenderer.d.ts +1 -1
- package/lib/components/MarkdownRenderer/markdownRenderer.js +6 -3
- package/lib/components/MarkdownRenderer/rehypeHighlight.d.ts +10 -0
- package/lib/components/MarkdownRenderer/rehypeHighlight.js +49 -0
- package/lib/components/MarkdownRenderer/stories/args.d.ts +3 -0
- package/lib/components/MarkdownRenderer/stories/args.js +4 -0
- package/lib/components/MarkdownRenderer/stories/markdownRenderer.stories.d.ts +6 -0
- package/lib/components/MarkdownRenderer/stories/markdownRenderer.stories.js +22 -0
- package/lib/components/MarkdownRenderer/types.d.ts +1 -0
- package/lib/components/Table/columnDef/globalFilter/utils.js +5 -5
- package/lib/components/Table/components/TableCell/components/MarkdownCell/markdownCell.d.ts +1 -2
- package/lib/components/Table/components/TableCell/components/MarkdownCell/markdownCell.js +10 -5
- package/lib/components/Table/components/TableCell/components/MarkdownCell/stories/args.js +8 -6
- package/lib/components/Table/components/TableCell/components/RankedCell/utils.d.ts +0 -9
- package/lib/components/Table/components/TableCell/components/RankedCell/utils.js +0 -27
- package/package.json +1 -1
- package/src/components/MarkdownRenderer/markdownRenderer.tsx +12 -2
- package/src/components/MarkdownRenderer/rehypeHighlight.ts +54 -0
- package/src/components/MarkdownRenderer/stories/args.ts +8 -0
- package/src/components/MarkdownRenderer/stories/markdownRenderer.stories.tsx +33 -0
- package/src/components/MarkdownRenderer/types.ts +1 -0
- package/src/components/Table/columnDef/globalFilter/utils.ts +5 -5
- package/src/components/Table/components/TableCell/components/MarkdownCell/markdownCell.tsx +16 -6
- package/src/components/Table/components/TableCell/components/MarkdownCell/stories/args.ts +14 -13
- package/src/components/Table/components/TableCell/components/RankedCell/utils.ts +0 -37
- package/lib/components/Table/components/TableCell/components/MarkdownCell/stories/types.d.ts +0 -3
- package/lib/components/Table/components/TableCell/components/MarkdownCell/stories/types.js +0 -1
- package/lib/components/Table/components/TableCell/components/MarkdownCell/types.d.ts +0 -3
- package/lib/components/Table/components/TableCell/components/MarkdownCell/types.js +0 -1
- package/src/components/Table/components/TableCell/components/MarkdownCell/stories/types.ts +0 -4
- package/src/components/Table/components/TableCell/components/MarkdownCell/types.ts +0 -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,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 <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)\\" <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)\\" <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,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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
if (!
|
|
5
|
+
export const MarkdownCell = ({ className, column, getValue, row, table, }) => {
|
|
6
|
+
const value = getValue();
|
|
7
|
+
if (!value)
|
|
7
8
|
return null;
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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 <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)\\" <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)\\" <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
|
-
|
|
8
|
-
|
|
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,5 +1,11 @@
|
|
|
1
1
|
import { Typography } from "@mui/material";
|
|
2
|
-
import 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 <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)\\" <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)\\" <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
|
+
};
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
17
|
-
if (!
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
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 <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)\\" <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)\\" <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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
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 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|