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: add search to Team list #4174

Merged
merged 18 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { ALL_FACETS, SearchFacets } from "./facets";
import { SearchHeader } from "./SearchHeader";
import { SearchResultCard } from "./SearchResultCard";

const DEBOUNCE_MS = 500;
export const DEBOUNCE_MS = 500;
RODO94 marked this conversation as resolved.
Show resolved Hide resolved

interface SearchNodes {
pattern: string;
Expand Down
69 changes: 44 additions & 25 deletions editor.planx.uk/src/pages/Team.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { gql } from "@apollo/client";
import Edit from "@mui/icons-material/Edit";
import Visibility from "@mui/icons-material/Visibility";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Container from "@mui/material/Container";
Expand All @@ -12,12 +10,14 @@ import DialogTitle from "@mui/material/DialogTitle";
import { styled } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import { hasFeatureFlag } from "lib/featureFlags";
import { isEmpty } from "lodash";
import React, { useCallback, useEffect, useState } from "react";
import { Link, useNavigation } from "react-navi";
import { FONT_WEIGHT_SEMI_BOLD } from "theme";
import { borderedFocusStyle } from "theme";
import { AddButton } from "ui/editor/AddButton";
import { SortableFields, SortControl } from "ui/editor/SortControl";
import { SearchBox } from "ui/shared/SearchBox/SearchBox";
import { slugify } from "utils";

import { client } from "../lib/graphql";
Expand Down Expand Up @@ -258,7 +258,7 @@ const FlowItem: React.FC<FlowItemProps> = ({
);
};

const GetStarted: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => (
const GetStarted: React.FC<{ flows: FlowSummary[] | null }> = ({ flows }) => (
<Box
sx={(theme) => ({
mt: 4,
Expand All @@ -277,7 +277,9 @@ const GetStarted: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => (
</Box>
);

const AddFlowButton: React.FC<{ flows: FlowSummary[] }> = ({ flows }) => {
const AddFlowButton: React.FC<{ flows: FlowSummary[] | null }> = ({
flows,
}) => {
const { navigate } = useNavigation();
const { teamId, createFlow, teamSlug } = useStore();

Expand Down Expand Up @@ -307,6 +309,9 @@ const Team: React.FC = () => {
(state) => [state.getTeam(), state.canUserEditTeam, state.getFlows],
);
const [flows, setFlows] = useState<FlowSummary[] | null>(null);
const [filteredFlows, setFilteredFlows] = useState<FlowSummary[] | null>(
null,
);
RODO94 marked this conversation as resolved.
Show resolved Hide resolved

const sortOptions: SortableFields<FlowSummary>[] = [
{
Expand All @@ -326,6 +331,7 @@ const Team: React.FC = () => {
},
];
const fetchFlows = useCallback(() => {
console.log("fetch flows");
getFlows(teamId).then((flows) => {
// Copy the array and sort by most recently edited desc using last associated operation.createdAt, not flow.updatedAt
const sortedFlows = flows.toSorted((a, b) =>
Expand All @@ -334,40 +340,53 @@ const Team: React.FC = () => {
),
);
setFlows(sortedFlows);
setFilteredFlows(sortedFlows);
});
}, [teamId, setFlows, getFlows]);

useEffect(() => {
fetchFlows();
}, [fetchFlows]);

const teamHasFlows = flows && Boolean(flows.length);
const teamHasFlows = !isEmpty(filteredFlows) && !isEmpty(flows);
const showAddFlowButton = teamHasFlows && canUserEditTeam(slug);

console.log(flows);

return (
<Container maxWidth="formWrap">
<Box
pb={1}
sx={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Container maxWidth="lg">
<Box bgcolor={"background.paper"} flexGrow={1}>
<Box
pb={1}
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
flexDirection: { xs: "column", contentWrap: "row" },
justifyContent: "space-between",
alignItems: { xs: "flex-start", contentWrap: "center" },
gap: 2,
}}
>
<Typography variant="h2" component="h1" pr={1}>
Services
</Typography>
{canUserEditTeam(slug) ? <Edit /> : <Visibility />}
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 2,
}}
>
<Typography variant="h2" component="h1" pr={1}>
Services
</Typography>
{showAddFlowButton && flows && <AddFlowButton flows={flows} />}
</Box>
{hasFeatureFlag("SORT_FLOWS") && flows && (
<SearchBox<FlowSummary>
records={filteredFlows}
setRecords={setFilteredFlows}
searchKey={["name", "slug"]}
/>
)}
</Box>
{showAddFlowButton && <AddFlowButton flows={flows} />}
</Box>
{hasFeatureFlag("SORT_FLOWS") && flows && (
<SortControl<FlowSummary>
Expand All @@ -376,9 +395,9 @@ const Team: React.FC = () => {
sortOptions={sortOptions}
/>
)}
{teamHasFlows && (
{teamHasFlows && flows && (
<DashboardList>
{flows.map((flow) => (
{filteredFlows?.map((flow) => (
<FlowItem
flow={flow}
flows={flows}
Expand All @@ -392,7 +411,7 @@ const Team: React.FC = () => {
))}
</DashboardList>
)}
{flows && !flows.length && <GetStarted flows={flows} />}
{!flows && <GetStarted flows={flows} />}
</Container>
);
};
Expand Down
130 changes: 130 additions & 0 deletions editor.planx.uk/src/ui/shared/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import ClearIcon from "@mui/icons-material/Clear";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import { useFormik } from "formik";
import { FuseOptionKey } from "fuse.js";
import { useSearch } from "hooks/useSearch";
import { debounce } from "lodash";
import { DEBOUNCE_MS } from "pages/FlowEditor/components/Sidebar/Search";
import React, { useEffect, useMemo, useState } from "react";

import Input from "../Input/Input";
import InputRow from "../InputRow";
import InputRowItem from "../InputRowItem";
import InputRowLabel from "../InputRowLabel";

interface SearchBoxProps<T> {
records: T[] | null;
setRecords: React.Dispatch<React.SetStateAction<T[] | null>>;
searchKey: FuseOptionKey<T>[];
}

export const SearchBox = <T extends object>({
records,
setRecords,
searchKey,
}: SearchBoxProps<T>) => {
const [isSearching, setIsSearching] = useState(false);
const [searchedTerm, setSearchedTerm] = useState<string>();
const [originalRecords] = useState(records);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This originalRecords variable is kept in place to handle behaviour for when we run setRecords(newRecords) for a searched term - this updates records in the parent component and triggers a re-render, but originalRecords is only set on mount and not reset when the components re-render, so it acts as a proper original version of the list.

Records as an immutable prop works differently since it will be updated when the Parent component is re-rendered, like when setRecords(newRecords) is triggered


const formik = useFormik({
initialValues: { pattern: "", keys: searchKey },
onSubmit: ({ pattern }) => {
setIsSearching(true);
debouncedSearch(pattern);
},
});
const { results, search } = useSearch({
list: originalRecords || [],
keys: formik.values.keys,
});

const debouncedSearch = useMemo(
() =>
debounce((pattern: string) => {
search(pattern);
setSearchedTerm(pattern);
setIsSearching(false);
}, DEBOUNCE_MS),
[search],
);

useEffect(() => {
if (results && searchedTerm) {
const mappedResults = results.map((result) => result.item);
setRecords(mappedResults);
}
if (results && !searchedTerm) {
originalRecords && setRecords(originalRecords);
}
}, [results, setRecords, searchedTerm, originalRecords]);

return (
<Box maxWidth={360}>
<InputRow>
<InputRowLabel>
<strong>Search</strong>
</InputRowLabel>
<InputRowItem>
<Box sx={{ position: "relative" }}>
<Input
sx={{
borderColor: (theme) => theme.palette.border.input,
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: This search box doesn't match the style of the flow search box.

Copy link
Contributor

Choose a reason for hiding this comment

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

It would also be nice to implement the same CircularProgress icon as SearchHeader.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry - should have been clearer here - I was specifically referring to the border colour that doesn't match.

image image

This is actually inconsistent in a few places in the Editor atm and I'm keen not to introduce any more (cc @ianjon3s)

image

Copy link
Contributor

Choose a reason for hiding this comment

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

Spinner is great btw 👌

pr: 5,
}}
name="search"
id="search"
value={formik.values.pattern}
onChange={(e) => {
formik.setFieldValue("pattern", e.target.value);
formik.submitForm();
}}
/>
{searchedTerm && !isSearching && (
<IconButton
aria-label="clear search"
onClick={() => {
formik.setFieldValue("pattern", "");
formik.submitForm();
}}
size="small"
sx={{
position: "absolute",
right: (theme) => theme.spacing(1),
top: "50%",
transform: "translateY(-50%)",
padding: 0.5,
zIndex: 1,
}}
>
<ClearIcon fontSize="small" />
</IconButton>
)}
{isSearching && (
<IconButton
aria-label="clear search"
onClick={() => {
formik.setFieldValue("pattern", "");
formik.submitForm();
}}
size="small"
sx={{
position: "absolute",
right: (theme) => theme.spacing(1),
top: "50%",
transform: "translateY(-50%)",
padding: 0.5,
zIndex: 1,
}}
>
<CircularProgress size={"1.5rem"} />
</IconButton>
)}
</Box>
</InputRowItem>
</InputRow>
</Box>
);
};