Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Data value search #3646

Merged
merged 8 commits into from
Sep 11, 2024
Merged
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
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"
],
Comment on lines +50 to +52
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a fair amount of type casting in this data structure as IndexedNode (and our flow as a whole) are not strongly typed.

We can't use a type guard as the types of IndexedNode["data"] and various components have no overlap - we hit the "A type predicate's type must be assignable to its parameter's type" error.

This means that casting is unfortunately necessary, but it's better than using any or just optional chaining the whole way down.

},
},
// 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
Loading