Skip to content

Commit

Permalink
Refactor: Grep codes form (#2539)
Browse files Browse the repository at this point in the history
Refactor GrepCode-components to new components + panda
  • Loading branch information
katrinewi authored Oct 30, 2024
1 parent b67c319 commit 8a0ac97
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 262 deletions.
7 changes: 2 additions & 5 deletions src/components/Form/GenericSearchCombobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@ndla/primitives";
import { styled } from "@ndla/styled-system/jsx";
import { useComboboxTranslations } from "@ndla/ui";
import { scrollToIndexFn } from "./utils";
import Pagination from "../abstractions/Pagination";

interface PaginationData {
Expand Down Expand Up @@ -91,12 +92,8 @@ export const GenericSearchCombobox = <T extends CollectionItem>({
selectionBehavior={selectionBehavior}
variant={variant}
context={context}
// keyboard scrolling does not work properly when items are not nested directly within
// ComboboxContent, so we need to provide a custom scroll function
// TODO: Check if ark provides a better fix for this.
scrollToIndexFn={(details) => {
const el = contentRef.current?.querySelectorAll(`[role='option']`)[details.index];
el?.scrollIntoView({ behavior: "auto", block: "nearest" });
scrollToIndexFn(contentRef, details.index);
}}
{...props}
>
Expand Down
17 changes: 17 additions & 0 deletions src/components/Form/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2024-present, NDLA.
*
* This source code is licensed under the GPLv3 license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import { RefObject } from "react";

// keyboard scrolling does not work properly when items are not nested directly within
// ComboboxContent, so we need to provide a custom scroll function
// TODO: Check if ark provides a better fix for this.
export const scrollToIndexFn = (contentRef: RefObject<HTMLDivElement>, index: number) => {
const el = contentRef.current?.querySelectorAll(`[role='option']`)[index];
el?.scrollIntoView({ behavior: "auto", block: "nearest" });
};
201 changes: 192 additions & 9 deletions src/containers/FormikForm/GrepCodesField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,203 @@
*
*/

import { FieldProps, FormikValues } from "formik";
import { memo } from "react";
import { useField } from "formik";
import difference from "lodash/difference";
import { memo, useState, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import GrepCodesFieldContent from "./GrepCodesFieldContent";
import FormikField from "../../components/FormikField";
import { createListCollection } from "@ark-ui/react";
import { DeleteForever } from "@ndla/icons/editor";
import {
ComboboxContent,
ComboboxItem,
ComboboxList,
ComboboxPositioner,
ComboboxRoot,
FieldHelper,
FieldLabel,
FieldRoot,
IconButton,
ListItemContent,
ListItemRoot,
Text,
} from "@ndla/primitives";
import { styled } from "@ndla/styled-system/jsx";
import { useComboboxTranslations } from "@ndla/ui";
import { GenericComboboxInput, GenericComboboxItemContent } from "../../components/abstractions/Combobox";
import { scrollToIndexFn } from "../../components/Form/utils";
import { FormField } from "../../components/FormField";
import { useGrepCodesSearch } from "../../modules/draft/draftQueries";
import { fetchGrepCodeTitle } from "../../modules/grep/grepApi";
import { GrepCode } from "../../modules/grep/grepApiInterfaces";
import { isGrepCodeValid } from "../../util/articleUtil";
import { usePaginatedQuery } from "../../util/usePaginatedQuery";

const StyledList = styled("ul", {
base: { listStyle: "none" },
});

const StyledComboboxList = styled(ComboboxList, {
base: {
overflowY: "auto",
},
});

export const convertGrepCodesToObject = async (grepCodes: string[]): Promise<Record<string, string>> => {
const grepCodesWithTitle = await Promise.all(
grepCodes.map(async (c) => {
const grepCodeTitle = await fetchGrepCodeTitle(c);
return {
[c]: grepCodeTitle ? `${c} - ${grepCodeTitle}` : c,
};
}),
);
return Object.assign({}, ...grepCodesWithTitle);
};

const GrepCodesField = () => {
const { t } = useTranslation();
const translations = useComboboxTranslations();
const [field, _, helpers] = useField<string[]>("grepCodes");
const [grepCodes, setGrepCodes] = useState<Record<string, string>>({});

const { query, setQuery } = usePaginatedQuery();
const searchQuery = useGrepCodesSearch({ input: query });
const contentRef = useRef<HTMLDivElement>(null);

useEffect(() => {
(async () => {
const grepCodesObject = await convertGrepCodesToObject(field.value);
setGrepCodes(grepCodesObject);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const fetchGrepCodeTitles = async (newGrepCodes: string[]): Promise<GrepCode[]> => {
const newGrepCodeNames: GrepCode[] = [];
for (const grepCode of newGrepCodes) {
const grepCodeTitle = await fetchGrepCodeTitle(grepCode);
const isGrepCodeSaved = grepCodes[grepCode];

if (grepCodeTitle && !isGrepCodeSaved && isGrepCodeValid(grepCode)) {
newGrepCodeNames.push({
code: grepCode,
title: `${grepCode} - ${grepCodeTitle}`,
});
} else if (!isGrepCodeSaved) {
setTimeout(() => {
helpers.setError(`${t("errorMessage.grepCodes")}${grepCode}`);
}, 0);
}
}
return newGrepCodeNames;
};

const updateGrepCodes = async (values: string[]) => {
helpers.setError(undefined);
const trimmedValues = Array.from(new Set(values.map((v) => v.toUpperCase().trim())));
// Grep code is added
if (trimmedValues.length > field.value.length) {
const addedGrepCodes = difference(trimmedValues, field.value);
const grepCodesWithNames = await fetchGrepCodeTitles(addedGrepCodes);
const grepCodesObject = grepCodesWithNames.reduce((acc, c) => ({ ...acc, [c.code]: c.title }), grepCodes);
setGrepCodes(grepCodesObject);
helpers.setValue(Object.keys(grepCodesObject));
return;
}
// Grep code is removed
if (trimmedValues.length < field.value.length) {
const filteredGrepCodes = trimmedValues.reduce(
(acc, c) => (values.includes(c) ? { ...acc, [c]: grepCodes[c] } : acc),
{},
);
setGrepCodes(filteredGrepCodes);
helpers.setValue(values);
return;
}
};

const collection = useMemo(() => {
return createListCollection({
items: searchQuery.data?.results ?? [],
itemToString: (item) => item.title,
itemToValue: (item) => item.code,
isItemDisabled: (item) => !!grepCodes[item.code],
});
}, [grepCodes, searchQuery.data?.results]);

return (
<>
<FormikField name="grepCodes" label={t("form.grepCodes.label")}>
{({ field, form }: FieldProps<string[], FormikValues>) => <GrepCodesFieldContent field={field} form={form} />}
</FormikField>
</>
<FormField name="grepCodes">
{({ field, meta }) => (
<FieldRoot>
<FieldLabel>{t("form.grepCodes.label")}</FieldLabel>
<FieldHelper>{t("form.grepCodes.description")}</FieldHelper>
<Text color="text.error" aria-live="polite">
{meta.error}
</Text>
<ComboboxRoot
collection={collection}
translations={translations}
onInputValueChange={(details) => setQuery(details.inputValue)}
inputValue={query}
onValueChange={(details) => updateGrepCodes(details.value)}
value={field.value}
multiple
positioning={{ strategy: "fixed" }}
variant="complex"
context="standalone"
scrollToIndexFn={(details) => {
scrollToIndexFn(contentRef, details.index);
}}
>
<GenericComboboxInput
placeholder={t("form.grepCodes.placeholder")}
isFetching={searchQuery.isFetching}
onKeyDown={(event) => {
if (event.key === "Enter" && !!query.trim()) {
updateGrepCodes([...field.value, query]);
}
}}
triggerable
/>
<ComboboxPositioner>
<ComboboxContent ref={contentRef}>
<StyledComboboxList>
{collection.items.map((item) => (
<ComboboxItem key={item.code} item={item} asChild>
<GenericComboboxItemContent title={item.title} />
</ComboboxItem>
))}
</StyledComboboxList>
{searchQuery.isSuccess && (
<Text>{t("dropdown.numberHits", { hits: searchQuery.data?.totalCount ?? 0 })}</Text>
)}
</ComboboxContent>
</ComboboxPositioner>
</ComboboxRoot>
<StyledList>
{Object.entries(grepCodes).map(([code, title]) => (
<ListItemRoot key={code} context="list" variant="subtle" asChild consumeCss>
<li>
<ListItemContent>{title}</ListItemContent>
<IconButton
variant="danger"
size="small"
aria-label={t("delete")}
title={t("delete")}
onClick={() => {
const filtered = field.value.filter((el: string) => el !== code);
updateGrepCodes(filtered);
}}
>
<DeleteForever />
</IconButton>
</li>
</ListItemRoot>
))}
</StyledList>
</FieldRoot>
)}
</FormField>
);
};

Expand Down
Loading

0 comments on commit 8a0ac97

Please sign in to comment.