Skip to content

Commit

Permalink
feat: Data value search (#3646)
Browse files Browse the repository at this point in the history
  • Loading branch information
DafyddLlyr authored Sep 11, 2024
1 parent b6b4220 commit 80b88d3
Show file tree
Hide file tree
Showing 7 changed files with 962 additions and 76 deletions.
22 changes: 14 additions & 8 deletions editor.planx.uk/src/hooks/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ interface UseSearchProps<T extends object> {
export interface SearchResult<T extends object> {
item: T;
key: string;
matchIndices?: [number, number][];
matchIndices: [number, number][];
refIndex: number;
}

export type SearchResults<T extends object> = SearchResult<T>[];
Expand Down Expand Up @@ -39,13 +40,18 @@ export const useSearch = <T extends object>({
useEffect(() => {
const fuseResults = fuse.search(pattern);
setResults(
fuseResults.map((result) => ({
item: result.item,
key: result.matches?.[0].key || "",
// We only display the first match
matchIndices:
(result.matches?.[0].indices as [number, number][]) || undefined,
})),
fuseResults.map((result) => {
// Required type narrowing for FuseResult
if (!result.matches) throw Error("Matches missing from FuseResults");

return {
item: result.item,
key: result.matches?.[0].key || "",
// We only display the first match
matchIndices: result.matches[0].indices as [number, number][],
refIndex: result.matches[0]?.refIndex || 0,
};
}),
);
}, [pattern, fuse]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import { styled } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import { ComponentType, IndexedNode } from "@opensystemslab/planx-core/types";
import { IndexedNode } from "@opensystemslab/planx-core/types";
import type { SearchResults } from "hooks/useSearch";
import React from "react";

Expand All @@ -17,32 +17,20 @@ export const Root = styled(List)(({ theme }) => ({

export const NodeSearchResults: React.FC<{
results: SearchResults<IndexedNode>;
}> = ({ results }) => {
/** Temporary guard function to filter out component types not yet supported by SearchResultCard */
const isSupportedNodeType = (
result: SearchResults<IndexedNode>[number],
): boolean =>
![
ComponentType.FileUploadAndLabel,
ComponentType.Calculate,
ComponentType.List,
].includes(result.item.type);
}> = ({ results }) => (
<>
<Typography variant="h3" mb={1}>
{!results.length && "No matches found"}
{results.length === 1 && "1 result:"}
{results.length > 1 && `${results.length} results:`}
</Typography>

return (
<>
<Typography variant="h3" mb={1}>
{!results.length && "No matches found"}
{results.length === 1 && "1 result:"}
{results.length > 1 && `${results.length} results:`}
</Typography>

<Root>
{results.filter(isSupportedNodeType).map((result) => (
<ListItem key={result.item.id} disablePadding>
<SearchResultCard result={result} />
</ListItem>
))}
</Root>
</>
);
};
<Root>
{results.map((result) => (
<ListItem key={result.item.id} disablePadding>
<SearchResultCard result={result} />
</ListItem>
))}
</Root>
</>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { ComponentType } from "@opensystemslab/planx-core/types";
import { useStore } from "pages/FlowEditor/lib/store";

import {
mockAnswerResult,
mockCalculateFormulaResult,
mockCalculateRootResult,
mockFileUploadAndLabelResult,
mockFlow,
mockListAnswerResult,
mockListDataResult,
mockListRootResult,
mockQuestionResult,
} from "../mocks/DataDisplayMap";
import { getDisplayDetailsForResult } from "./DataDisplayMap";

type Output = ReturnType<typeof getDisplayDetailsForResult>;

// Setup flow so that it can be referenced by SearchResults (e.g. getting parent nodes)
beforeAll(() => useStore.setState({ flow: mockFlow }));

describe("Question component", () => {
it("returns the expected display values", () => {
const output = getDisplayDetailsForResult(mockQuestionResult);

expect(output).toStrictEqual<Output>({
key: "Data",
iconKey: ComponentType.Question,
componentType: "Question",
title: "This is a question component",
headline: "colour",
});
});
});

describe("Answer component", () => {
it("returns the expected display values", () => {
const output = getDisplayDetailsForResult(mockAnswerResult);

expect(output).toStrictEqual<Output>({
key: "Option (data)",
iconKey: ComponentType.Question,
componentType: "Question",
title: "This is a question component",
headline: "red",
});
});
});

describe("List component", () => {
it("handles the root data value", () => {
const output = getDisplayDetailsForResult(mockListRootResult);

expect(output).toStrictEqual<Output>({
componentType: "List",
headline: "listRoot",
iconKey: ComponentType.List,
key: "Data",
title: "This is a list component",
});
});

it("handles nested data variables", () => {
const output = getDisplayDetailsForResult(mockListDataResult);

expect(output).toStrictEqual<Output>({
componentType: "List",
headline: "tenure",
iconKey: ComponentType.List,
key: "Data",
title: "This is a list component",
});
});

it("handles nested data variables in Answers", () => {
const output = getDisplayDetailsForResult(mockListAnswerResult);

expect(output).toStrictEqual<Output>({
componentType: "List",
headline: "selfCustomBuild",
iconKey: ComponentType.List,
key: "Option (data)",
title: "This is a list component",
});
});
});

describe("Calculate component", () => {
it("handles the output data variables", () => {
const output = getDisplayDetailsForResult(mockCalculateRootResult);

expect(output).toStrictEqual<Output>({
componentType: "Calculate",
headline: "calculateOutput",
iconKey: ComponentType.Calculate,
key: "Output (data)",
title: "This is a calculate component",
});
});

it("handles the formula data variables", () => {
const output = getDisplayDetailsForResult(mockCalculateFormulaResult);

expect(output).toStrictEqual<Output>({
componentType: "Calculate",
headline: "formulaOne + formulaTwo",
iconKey: ComponentType.Calculate,
key: "Formula",
title: "This is a calculate component",
});
});
});

describe("FileUploadAndLabel component", () => {
it("handles the data variables nested in FileTypes", () => {
const output = getDisplayDetailsForResult(mockFileUploadAndLabelResult);

expect(output).toStrictEqual<Output>({
componentType: "File upload and label",
headline: "floorplan",
iconKey: ComponentType.FileUploadAndLabel,
key: "File type (data)",
title: "This is a FileUploadAndLabel component",
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { ComponentType, IndexedNode } from "@opensystemslab/planx-core/types";
import { Calculate } from "@planx/components/Calculate/model";
import { FileUploadAndLabel } from "@planx/components/FileUploadAndLabel/model";
import { List } from "@planx/components/List/model";
import { SearchResult } from "hooks/useSearch";
import { capitalize, get } from "lodash";
import { SLUGS } from "pages/FlowEditor/data/types";
import { useStore } from "pages/FlowEditor/lib/store";

interface DataDisplayValues {
displayKey: string;
getIconKey: (result: SearchResult<IndexedNode>) => ComponentType;
getTitle: (result: SearchResult<IndexedNode>) => string;
getHeadline: (result: SearchResult<IndexedNode>) => string;
getComponentType: (result: SearchResult<IndexedNode>) => string;
}

/**
* Map of data keys to their associated display values
* Uses Partial<DataDisplayValues> as not all values are unique, we later apply defaults
*/
type DataKeyMap = Record<string, Partial<DataDisplayValues>>;

/**
* Map of ComponentTypes to their associated data keys
*/
type ComponentMap = Record<ComponentType, DataKeyMap>;

/**
* Map of ComponentTypes which need specific overrides in order to display their data values
*/
const DISPLAY_DATA: Partial<ComponentMap> = {
// Answers are mapped to their parent questions
[ComponentType.Answer]: {
default: {
getIconKey: () => ComponentType.Question,
displayKey: "Option (data)",
getTitle: ({ item }) => {
const parentNode = useStore.getState().flow[item.parentId];
return parentNode.data.text;
},
getHeadline: ({ item, key }) => get(item, key)?.toString(),
},
},
// FileUploadAndLabel has data values nested in FileTypes
[ComponentType.FileUploadAndLabel]: {
default: {
displayKey: "File type (data)",
getHeadline: ({ item, refIndex }) =>
(item["data"] as unknown as FileUploadAndLabel)["fileTypes"][refIndex][
"fn"
],
},
},
// Calculate contains both input and output data values
[ComponentType.Calculate]: {
formula: {
displayKey: "Formula",
getHeadline: ({ item }) => (item.data as unknown as Calculate).formula,
},
"data.output": {
displayKey: "Output (data)",
getHeadline: ({ item }) => (item.data as unknown as Calculate).output,
},
},
// List contains data variables nested within its schema
[ComponentType.List]: {
"data.schema.fields.data.fn": {
getHeadline: ({ item, refIndex }) =>
(item.data as unknown as List).schema.fields[refIndex].data.fn,
},
"data.schema.fields.data.options.data.val": {
displayKey: "Option (data)",
getHeadline: ({ item, refIndex }) => {
// Fuse.js flattens deeply nested arrays when using refIndex
const options = (item.data as unknown as List).schema.fields
.filter((field) => field.type === "question")
.flatMap((field) => field.data.options);
return options[refIndex].data.val || "";
},
},
},
};

/**
* Default values for all ComponentTypes not listed in DISPLAY_DATA
*/
const DEFAULT_DISPLAY_DATA: DataDisplayValues = {
displayKey: "Data",
getIconKey: ({ item }) => item.type,
getTitle: ({ item }) =>
(item.data?.title as string) || (item.data?.text as string) || "",
getHeadline: ({ item, key }) => get(item, key)?.toString() || "",
getComponentType: ({ item }) =>
capitalize(SLUGS[item.type].replaceAll("-", " ")),
};

export const getDisplayDetailsForResult = (
result: SearchResult<IndexedNode>,
) => {
const componentMap = DISPLAY_DATA[result.item.type];
const keyMap = componentMap?.[result.key] || componentMap?.default || {};

const data: DataDisplayValues = {
...DEFAULT_DISPLAY_DATA,
...keyMap,
};

return {
iconKey: data.getIconKey(result),
componentType: data.getComponentType(result),
title: data.getTitle(result),
key: data.displayKey,
headline: data.getHeadline(result),
};
};
Loading

0 comments on commit 80b88d3

Please sign in to comment.