diff options
Diffstat (limited to 'frontend/src/components')
51 files changed, 1121 insertions, 1034 deletions
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index a29200e47..4e39dd9dc 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,5 +1,5 @@ -import UIError from "@/pages/errors/UIError"; import { Component, PropsWithChildren } from "react"; +import UIError from "@/pages/errors/UIError"; interface State { error: Error | null; diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx index bc4a9f8d3..b506afee3 100644 --- a/frontend/src/components/Search.tsx +++ b/frontend/src/components/Search.tsx @@ -1,15 +1,10 @@ -import { useServerSearch } from "@/apis/hooks"; -import { useDebouncedValue } from "@/utilities"; +import { FunctionComponent, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Autocomplete, ComboboxItem, OptionsFilter, Text } from "@mantine/core"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - Anchor, - Autocomplete, - createStyles, - SelectItemProps, -} from "@mantine/core"; -import { forwardRef, FunctionComponent, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { useServerSearch } from "@/apis/hooks"; +import { useDebouncedValue } from "@/utilities"; type SearchResultItem = { value: string; @@ -18,7 +13,7 @@ type SearchResultItem = { function useSearch(query: string) { const debouncedQuery = useDebouncedValue(query, 500); - const { data } = useServerSearch(debouncedQuery, debouncedQuery.length > 0); + const { data } = useServerSearch(debouncedQuery, debouncedQuery.length >= 0); return useMemo<SearchResultItem[]>( () => @@ -31,7 +26,6 @@ function useSearch(query: string) { } else { throw new Error("Unknown search result"); } - return { value: `${v.title} (${v.year})`, link, @@ -41,59 +35,43 @@ function useSearch(query: string) { ); } -const useStyles = createStyles((theme) => { - return { - result: { - color: - theme.colorScheme === "light" - ? theme.colors.dark[8] - : theme.colors.gray[1], - }, - }; -}); - -type ResultCompProps = SelectItemProps & SearchResultItem; - -const ResultComponent = forwardRef<HTMLDivElement, ResultCompProps>( - ({ link, value }, ref) => { - const styles = useStyles(); +const optionsFilter: OptionsFilter = ({ options, search }) => { + const lowercaseSearch = search.toLowerCase(); + const trimmedSearch = search.trim(); + return (options as ComboboxItem[]).filter((option) => { return ( - <Anchor - component={Link} - to={link} - underline={false} - className={styles.classes.result} - p="sm" - > - {value} - </Anchor> + option.value.toLowerCase().includes(lowercaseSearch) || + option.value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .includes(trimmedSearch) ); - }, -); + }); +}; const Search: FunctionComponent = () => { + const navigate = useNavigate(); const [query, setQuery] = useState(""); const results = useSearch(query); return ( <Autocomplete - icon={<FontAwesomeIcon icon={faSearch} />} - itemComponent={ResultComponent} + leftSection={<FontAwesomeIcon icon={faSearch} />} + renderOption={(input) => <Text p="xs">{input.option.value}</Text>} placeholder="Search" size="sm" data={results} value={query} + scrollAreaProps={{ type: "auto" }} + maxDropdownHeight={400} onChange={setQuery} onBlur={() => setQuery("")} - filter={(value, item) => - item.value.toLowerCase().includes(value.toLowerCase().trim()) || - item.value - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .includes(value.trim()) + filter={optionsFilter} + onOptionSubmit={(option) => + navigate(results.find((a) => a.value === option)?.link || "/") } ></Autocomplete> ); diff --git a/frontend/src/components/StateIcon.tsx b/frontend/src/components/StateIcon.tsx index f9683f63a..31e0b5243 100644 --- a/frontend/src/components/StateIcon.tsx +++ b/frontend/src/components/StateIcon.tsx @@ -1,4 +1,6 @@ -import { BuildKey } from "@/utilities"; +import { FunctionComponent } from "react"; +import { Group, List, Popover, Stack, Text } from "@mantine/core"; +import { useHover } from "@mantine/hooks"; import { faCheck, faCheckCircle, @@ -7,9 +9,7 @@ import { faTimes, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Group, List, Popover, Stack, Text } from "@mantine/core"; -import { useHover } from "@mantine/hooks"; -import { FunctionComponent } from "react"; +import { BuildKey } from "@/utilities"; interface StateIconProps { matches: string[]; @@ -31,7 +31,7 @@ const StateIcon: FunctionComponent<StateIconProps> = ({ return <FontAwesomeIcon icon={faListCheck} />; } else { return ( - <Text color={hasIssues ? "yellow" : "green"}> + <Text c={hasIssues ? "yellow" : "green"} span> <FontAwesomeIcon icon={hasIssues ? faExclamationCircle : faCheckCircle} /> @@ -48,9 +48,9 @@ const StateIcon: FunctionComponent<StateIconProps> = ({ </Text> </Popover.Target> <Popover.Dropdown> - <Group position="left" spacing="xl" noWrap grow> - <Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto"> - <Text color="green"> + <Group justify="left" gap="xl" wrap="nowrap" grow> + <Stack align="flex-start" justify="flex-start" gap="xs" mb="auto"> + <Text c="green"> <FontAwesomeIcon icon={faCheck}></FontAwesomeIcon> </Text> <List> @@ -59,8 +59,8 @@ const StateIcon: FunctionComponent<StateIconProps> = ({ ))} </List> </Stack> - <Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto"> - <Text color="yellow"> + <Stack align="flex-start" justify="flex-start" gap="xs" mb="auto"> + <Text c="yellow"> <FontAwesomeIcon icon={faTimes}></FontAwesomeIcon> </Text> <List> diff --git a/frontend/src/components/SubtitleToolsMenu.tsx b/frontend/src/components/SubtitleToolsMenu.tsx index e36a1e9e1..545c87478 100644 --- a/frontend/src/components/SubtitleToolsMenu.tsx +++ b/frontend/src/components/SubtitleToolsMenu.tsx @@ -1,11 +1,5 @@ -import { useSubtitleAction } from "@/apis/hooks"; -import { ColorToolModal } from "@/components/forms/ColorToolForm"; -import { FrameRateModal } from "@/components/forms/FrameRateForm"; -import { TimeOffsetModal } from "@/components/forms/TimeOffsetForm"; -import { TranslationModal } from "@/components/forms/TranslationForm"; -import { useModals } from "@/modules/modals"; -import { ModalComponent } from "@/modules/modals/WithModal"; -import { task } from "@/modules/task"; +import { FunctionComponent, ReactElement, useCallback, useMemo } from "react"; +import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core"; import { faClock, faCode, @@ -23,8 +17,14 @@ import { IconDefinition, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core"; -import { FunctionComponent, ReactElement, useCallback, useMemo } from "react"; +import { useSubtitleAction } from "@/apis/hooks"; +import { ColorToolModal } from "@/components/forms/ColorToolForm"; +import { FrameRateModal } from "@/components/forms/FrameRateForm"; +import { TimeOffsetModal } from "@/components/forms/TimeOffsetForm"; +import { TranslationModal } from "@/components/forms/TranslationForm"; +import { useModals } from "@/modules/modals"; +import { ModalComponent } from "@/modules/modals/WithModal"; +import { task } from "@/modules/task"; import { SyncSubtitleModal } from "./forms/SyncSubtitleForm"; export interface ToolOptions { @@ -127,6 +127,8 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({ type: s.type, language: s.language, path: s.path, + hi: s.hi, + forced: s.forced, }; task.create(s.path, name, mutateAsync, { action, form }); }); @@ -148,7 +150,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({ <Menu.Item key={tool.key} disabled={disabledTools} - icon={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>} + leftSection={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>} onClick={() => { if (tool.modal) { modals.openContextModal(tool.modal, { selections }); @@ -164,7 +166,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({ <Menu.Label>Actions</Menu.Label> <Menu.Item disabled={selections.length !== 0 || onAction === undefined} - icon={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>} + leftSection={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>} onClick={() => { onAction?.("search"); }} @@ -174,7 +176,7 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({ <Menu.Item disabled={selections.length === 0 || onAction === undefined} color="red" - icon={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>} + leftSection={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>} onClick={() => { modals.openConfirmModal({ title: "The following subtitles will be deleted", diff --git a/frontend/src/components/TextPopover.tsx b/frontend/src/components/TextPopover.tsx index 8fda5913e..03dd58700 100644 --- a/frontend/src/components/TextPopover.tsx +++ b/frontend/src/components/TextPopover.tsx @@ -1,7 +1,7 @@ +import { FunctionComponent, ReactElement } from "react"; import { Tooltip, TooltipProps } from "@mantine/core"; import { useHover } from "@mantine/hooks"; import { isNull, isUndefined } from "lodash"; -import { FunctionComponent, ReactElement } from "react"; interface TextPopoverProps { children: ReactElement; @@ -21,7 +21,12 @@ const TextPopover: FunctionComponent<TextPopoverProps> = ({ } return ( - <Tooltip opened={hovered} label={text} {...tooltip}> + <Tooltip + opened={hovered} + label={text} + {...tooltip} + style={{ textWrap: "wrap" }} + > <div ref={ref}>{children}</div> </Tooltip> ); diff --git a/frontend/src/components/async/Lazy.tsx b/frontend/src/components/async/Lazy.tsx index 2a0496223..317c0feb3 100644 --- a/frontend/src/components/async/Lazy.tsx +++ b/frontend/src/components/async/Lazy.tsx @@ -1,5 +1,5 @@ -import { LoadingOverlay } from "@mantine/core"; import { FunctionComponent, PropsWithChildren, Suspense } from "react"; +import { LoadingOverlay } from "@mantine/core"; const Lazy: FunctionComponent<PropsWithChildren> = ({ children }) => { return <Suspense fallback={<LoadingOverlay visible />}>{children}</Suspense>; diff --git a/frontend/src/components/async/MutateAction.tsx b/frontend/src/components/async/MutateAction.tsx index 920fe4ff3..92c102ea9 100644 --- a/frontend/src/components/async/MutateAction.tsx +++ b/frontend/src/components/async/MutateAction.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from "react"; -import { UseMutationResult } from "react-query"; -import { Action } from "../inputs"; -import { ActionProps } from "../inputs/Action"; +import { UseMutationResult } from "@tanstack/react-query"; +import { Action } from "@/components/inputs"; +import { ActionProps } from "@/components/inputs/Action"; type MutateActionProps<DATA, VAR> = Omit< ActionProps, @@ -16,7 +16,6 @@ type MutateActionProps<DATA, VAR> = Omit< function MutateAction<DATA, VAR>({ mutation, - noReset, onSuccess, onError, args, diff --git a/frontend/src/components/async/MutateButton.tsx b/frontend/src/components/async/MutateButton.tsx index 8d0f68541..908c2dfda 100644 --- a/frontend/src/components/async/MutateButton.tsx +++ b/frontend/src/components/async/MutateButton.tsx @@ -1,6 +1,6 @@ -import { Button, ButtonProps } from "@mantine/core"; import { useCallback, useState } from "react"; -import { UseMutationResult } from "react-query"; +import { Button, ButtonProps } from "@mantine/core"; +import { UseMutationResult } from "@tanstack/react-query"; type MutateButtonProps<DATA, VAR> = Omit< ButtonProps, @@ -15,7 +15,6 @@ type MutateButtonProps<DATA, VAR> = Omit< function MutateButton<DATA, VAR>({ mutation, - noReset, onSuccess, onError, args, diff --git a/frontend/src/components/async/QueryOverlay.tsx b/frontend/src/components/async/QueryOverlay.tsx index 24b95ab18..1672989ff 100644 --- a/frontend/src/components/async/QueryOverlay.tsx +++ b/frontend/src/components/async/QueryOverlay.tsx @@ -1,7 +1,7 @@ -import { LoadingProvider } from "@/contexts"; -import { LoadingOverlay } from "@mantine/core"; import { FunctionComponent, ReactNode } from "react"; -import { UseQueryResult } from "react-query"; +import { LoadingOverlay } from "@mantine/core"; +import { UseQueryResult } from "@tanstack/react-query"; +import { LoadingProvider } from "@/contexts"; interface QueryOverlayProps { result: UseQueryResult<unknown, unknown>; @@ -12,7 +12,7 @@ interface QueryOverlayProps { const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({ children, global = false, - result: { isLoading, isError, error }, + result: { isLoading }, }) => { return ( <LoadingProvider value={isLoading}> diff --git a/frontend/src/components/bazarr/AudioList.tsx b/frontend/src/components/bazarr/AudioList.tsx index ac9cce743..f0dc07c0d 100644 --- a/frontend/src/components/bazarr/AudioList.tsx +++ b/frontend/src/components/bazarr/AudioList.tsx @@ -1,6 +1,7 @@ -import { BuildKey } from "@/utilities"; -import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core"; import { FunctionComponent } from "react"; +import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core"; +import { BuildKey } from "@/utilities"; +import { normalizeAudioLanguage } from "@/utilities/languages"; export type AudioListProps = GroupProps & { audios: Language.Info[]; @@ -13,10 +14,10 @@ const AudioList: FunctionComponent<AudioListProps> = ({ ...group }) => { return ( - <Group spacing="xs" {...group}> + <Group gap="xs" {...group}> {audios.map((audio, idx) => ( <Badge color="blue" key={BuildKey(idx, audio.code2)} {...badgeProps}> - {audio.name} + {normalizeAudioLanguage(audio.name)} </Badge> ))} </Group> diff --git a/frontend/src/components/bazarr/HistoryIcon.tsx b/frontend/src/components/bazarr/HistoryIcon.tsx index e6c0f2411..add0cd1fd 100644 --- a/frontend/src/components/bazarr/HistoryIcon.tsx +++ b/frontend/src/components/bazarr/HistoryIcon.tsx @@ -1,3 +1,5 @@ +import { FunctionComponent } from "react"; +import { Tooltip } from "@mantine/core"; import { faClock, faClosedCaptioning, @@ -9,8 +11,6 @@ import { faUser, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Tooltip } from "@mantine/core"; -import { FunctionComponent } from "react"; enum HistoryAction { Delete = 0, diff --git a/frontend/src/components/bazarr/Language.test.tsx b/frontend/src/components/bazarr/Language.test.tsx index 9e0e0fab8..2cad5d4c8 100644 --- a/frontend/src/components/bazarr/Language.test.tsx +++ b/frontend/src/components/bazarr/Language.test.tsx @@ -1,5 +1,5 @@ -import { rawRender, screen } from "@/tests"; import { describe, it } from "vitest"; +import { render, screen } from "@/tests"; import { Language } from "."; describe("Language text", () => { @@ -9,13 +9,13 @@ describe("Language text", () => { }; it("should show short text", () => { - rawRender(<Language.Text value={testLanguage}></Language.Text>); + render(<Language.Text value={testLanguage}></Language.Text>); expect(screen.getByText(testLanguage.code2)).toBeDefined(); }); it("should show long text", () => { - rawRender(<Language.Text value={testLanguage} long></Language.Text>); + render(<Language.Text value={testLanguage} long></Language.Text>); expect(screen.getByText(testLanguage.name)).toBeDefined(); }); @@ -23,7 +23,7 @@ describe("Language text", () => { const testLanguageWithHi: Language.Info = { ...testLanguage, hi: true }; it("should show short text with HI", () => { - rawRender(<Language.Text value={testLanguageWithHi}></Language.Text>); + render(<Language.Text value={testLanguageWithHi}></Language.Text>); const expectedText = `${testLanguageWithHi.code2}:HI`; @@ -31,7 +31,7 @@ describe("Language text", () => { }); it("should show long text with HI", () => { - rawRender(<Language.Text value={testLanguageWithHi} long></Language.Text>); + render(<Language.Text value={testLanguageWithHi} long></Language.Text>); const expectedText = `${testLanguageWithHi.name} HI`; @@ -44,7 +44,7 @@ describe("Language text", () => { }; it("should show short text with Forced", () => { - rawRender(<Language.Text value={testLanguageWithForced}></Language.Text>); + render(<Language.Text value={testLanguageWithForced}></Language.Text>); const expectedText = `${testLanguageWithHi.code2}:Forced`; @@ -52,9 +52,7 @@ describe("Language text", () => { }); it("should show long text with Forced", () => { - rawRender( - <Language.Text value={testLanguageWithForced} long></Language.Text>, - ); + render(<Language.Text value={testLanguageWithForced} long></Language.Text>); const expectedText = `${testLanguageWithHi.name} Forced`; @@ -75,7 +73,7 @@ describe("Language list", () => { ]; it("should show all languages", () => { - rawRender(<Language.List value={elements}></Language.List>); + render(<Language.List value={elements}></Language.List>); elements.forEach((value) => { expect(screen.getByText(value.name)).toBeDefined(); diff --git a/frontend/src/components/bazarr/Language.tsx b/frontend/src/components/bazarr/Language.tsx index e5627c82e..6315d9102 100644 --- a/frontend/src/components/bazarr/Language.tsx +++ b/frontend/src/components/bazarr/Language.tsx @@ -1,6 +1,6 @@ -import { BuildKey } from "@/utilities"; -import { Badge, Group, Text, TextProps } from "@mantine/core"; import { FunctionComponent, useMemo } from "react"; +import { Badge, Group, Text, TextProps } from "@mantine/core"; +import { BuildKey } from "@/utilities"; type LanguageTextProps = TextProps & { value: Language.Info; @@ -49,7 +49,7 @@ type LanguageListProps = { const LanguageList: FunctionComponent<LanguageListProps> = ({ value }) => { return ( - <Group spacing="xs"> + <Group gap="xs"> {value.map((v) => ( <Badge key={BuildKey(v.code2, v.code2, v.hi)}>{v.name}</Badge> ))} diff --git a/frontend/src/components/bazarr/LanguageProfile.tsx b/frontend/src/components/bazarr/LanguageProfile.tsx index 75b7b73ca..a234268c3 100644 --- a/frontend/src/components/bazarr/LanguageProfile.tsx +++ b/frontend/src/components/bazarr/LanguageProfile.tsx @@ -1,5 +1,5 @@ -import { useLanguageProfiles } from "@/apis/hooks"; import { FunctionComponent, useMemo } from "react"; +import { useLanguageProfiles } from "@/apis/hooks"; interface Props { index: number | null; diff --git a/frontend/src/components/bazarr/LanguageSelector.tsx b/frontend/src/components/bazarr/LanguageSelector.tsx index c2219ca7c..8954403bd 100644 --- a/frontend/src/components/bazarr/LanguageSelector.tsx +++ b/frontend/src/components/bazarr/LanguageSelector.tsx @@ -1,7 +1,7 @@ +import { FunctionComponent, useMemo } from "react"; import { useLanguages } from "@/apis/hooks"; import { Selector, SelectorProps } from "@/components/inputs"; import { useSelectorOptions } from "@/utilities"; -import { FunctionComponent, useMemo } from "react"; interface LanguageSelectorProps extends Omit<SelectorProps<Language.Server>, "options" | "getkey"> { diff --git a/frontend/src/components/forms/ColorToolForm.tsx b/frontend/src/components/forms/ColorToolForm.tsx index a37819bee..9deac9bf4 100644 --- a/frontend/src/components/forms/ColorToolForm.tsx +++ b/frontend/src/components/forms/ColorToolForm.tsx @@ -1,11 +1,11 @@ +import { FunctionComponent } from "react"; +import { Button, Divider, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; import { useSubtitleAction } from "@/apis/hooks"; import { Selector, SelectorOption } from "@/components"; import { useModals, withModal } from "@/modules/modals"; import { task } from "@/modules/task"; import FormUtils from "@/utilities/form"; -import { Button, Divider, Stack } from "@mantine/core"; -import { useForm } from "@mantine/form"; -import { FunctionComponent } from "react"; const TaskName = "Changing Color"; diff --git a/frontend/src/components/forms/FrameRateForm.tsx b/frontend/src/components/forms/FrameRateForm.tsx index 7e7eca24c..7c57daf28 100644 --- a/frontend/src/components/forms/FrameRateForm.tsx +++ b/frontend/src/components/forms/FrameRateForm.tsx @@ -1,10 +1,10 @@ +import { FunctionComponent } from "react"; +import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; import { useSubtitleAction } from "@/apis/hooks"; import { useModals, withModal } from "@/modules/modals"; import { task } from "@/modules/task"; import FormUtils from "@/utilities/form"; -import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core"; -import { useForm } from "@mantine/form"; -import { FunctionComponent } from "react"; const TaskName = "Changing Frame Rate"; @@ -55,15 +55,17 @@ const FrameRateForm: FunctionComponent<Props> = ({ selections, onSubmit }) => { })} > <Stack> - <Group spacing="xs" grow> + <Group gap="xs" grow> <NumberInput placeholder="From" - precision={2} + decimalScale={2} + fixedDecimalScale {...form.getInputProps("from")} ></NumberInput> <NumberInput placeholder="To" - precision={2} + decimalScale={2} + fixedDecimalScale {...form.getInputProps("to")} ></NumberInput> </Group> diff --git a/frontend/src/components/forms/ItemEditForm.tsx b/frontend/src/components/forms/ItemEditForm.tsx index 9f3856d54..392338500 100644 --- a/frontend/src/components/forms/ItemEditForm.tsx +++ b/frontend/src/components/forms/ItemEditForm.tsx @@ -1,11 +1,11 @@ +import { FunctionComponent, useMemo } from "react"; +import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { UseMutationResult } from "@tanstack/react-query"; import { useLanguageProfiles } from "@/apis/hooks"; import { MultiSelector, Selector } from "@/components/inputs"; import { useModals, withModal } from "@/modules/modals"; import { GetItemId, useSelectorOptions } from "@/utilities"; -import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core"; -import { useForm } from "@mantine/form"; -import { FunctionComponent, useMemo } from "react"; -import { UseMutationResult } from "react-query"; interface Props { mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>; @@ -21,7 +21,7 @@ const ItemEditForm: FunctionComponent<Props> = ({ onCancel, }) => { const { data, isFetching } = useLanguageProfiles(); - const { isLoading, mutate } = mutation; + const { isPending, mutate } = mutation; const modals = useModals(); const profileOptions = useSelectorOptions( @@ -47,7 +47,7 @@ const ItemEditForm: FunctionComponent<Props> = ({ (v) => v.code2, ); - const isOverlayVisible = isLoading || isFetching || item === null; + const isOverlayVisible = isPending || isFetching || item === null; return ( <form @@ -80,7 +80,7 @@ const ItemEditForm: FunctionComponent<Props> = ({ label="Languages Profile" ></Selector> <Divider></Divider> - <Group position="right"> + <Group justify="right"> <Button disabled={isOverlayVisible} onClick={() => { diff --git a/frontend/src/components/forms/MovieUploadForm.tsx b/frontend/src/components/forms/MovieUploadForm.tsx index b51614770..8e318d7ad 100644 --- a/frontend/src/components/forms/MovieUploadForm.tsx +++ b/frontend/src/components/forms/MovieUploadForm.tsx @@ -1,37 +1,35 @@ -import { useMovieSubtitleModification } from "@/apis/hooks"; -import { useModals, withModal } from "@/modules/modals"; -import { TaskGroup, task } from "@/modules/task"; -import { useTableStyles } from "@/styles"; -import { useArrayAction, useSelectorOptions } from "@/utilities"; -import FormUtils from "@/utilities/form"; -import { - useLanguageProfileBy, - useProfileItemsToLanguages, -} from "@/utilities/languages"; -import { - faCheck, - faCircleNotch, - faInfoCircle, - faTimes, - faTrash, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { FunctionComponent, useEffect, useMemo } from "react"; import { Button, Checkbox, - createStyles, Divider, MantineColor, Stack, Text, } from "@mantine/core"; import { useForm } from "@mantine/form"; +import { + faCheck, + faCircleNotch, + faInfoCircle, + faTimes, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { isString } from "lodash"; -import { FunctionComponent, useEffect, useMemo } from "react"; -import { Column } from "react-table"; -import TextPopover from "../TextPopover"; -import { Action, Selector } from "../inputs"; -import { SimpleTable } from "../tables"; +import { useMovieSubtitleModification } from "@/apis/hooks"; +import { Action, Selector } from "@/components/inputs"; +import SimpleTable from "@/components/tables/SimpleTable"; +import TextPopover from "@/components/TextPopover"; +import { useModals, withModal } from "@/modules/modals"; +import { task, TaskGroup } from "@/modules/task"; +import { useArrayAction, useSelectorOptions } from "@/utilities"; +import FormUtils from "@/utilities/form"; +import { + useLanguageProfileBy, + useProfileItemsToLanguages, +} from "@/utilities/languages"; type SubtitleFile = { file: File; @@ -79,21 +77,12 @@ interface Props { onComplete?: () => void; } -const useStyles = createStyles((theme) => { - return { - wrapper: { - overflowWrap: "anywhere", - }, - }; -}); - const MovieUploadForm: FunctionComponent<Props> = ({ files, movie, onComplete, }) => { const modals = useModals(); - const { classes } = useStyles(); const profile = useLanguageProfileBy(movie.profileId); @@ -154,63 +143,77 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }); }); - const columns = useMemo<Column<SubtitleFile>[]>( - () => [ - { - accessor: "validateResult", - Cell: ({ cell: { value } }) => { - const icon = useMemo(() => { - switch (value?.state) { - case "valid": - return faCheck; - case "warning": - return faInfoCircle; - case "error": - return faTimes; - default: - return faCircleNotch; - } - }, [value?.state]); + const ValidateResultCell = ({ + validateResult, + }: { + validateResult: SubtitleValidateResult | undefined; + }) => { + const icon = useMemo(() => { + switch (validateResult?.state) { + case "valid": + return faCheck; + case "warning": + return faInfoCircle; + case "error": + return faTimes; + default: + return faCircleNotch; + } + }, [validateResult?.state]); - const color = useMemo<MantineColor | undefined>(() => { - switch (value?.state) { - case "valid": - return "green"; - case "warning": - return "yellow"; - case "error": - return "red"; - default: - return undefined; - } - }, [value?.state]); + const color = useMemo<MantineColor | undefined>(() => { + switch (validateResult?.state) { + case "valid": + return "green"; + case "warning": + return "yellow"; + case "error": + return "red"; + default: + return undefined; + } + }, [validateResult?.state]); - return ( - <TextPopover text={value?.messages}> - <Text color={color} inline> - <FontAwesomeIcon icon={icon}></FontAwesomeIcon> - </Text> - </TextPopover> - ); + return ( + <TextPopover text={validateResult?.messages}> + <Text c={color} inline> + <FontAwesomeIcon icon={icon} /> + </Text> + </TextPopover> + ); + }; + + const columns = useMemo<ColumnDef<SubtitleFile>[]>( + () => [ + { + id: "validateResult", + cell: ({ + row: { + original: { validateResult }, + }, + }) => { + return <ValidateResultCell validateResult={validateResult} />; }, }, { - Header: "File", + header: "File", id: "filename", - accessor: "file", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - - return <Text className={classes.primary}>{value.name}</Text>; + accessorKey: "file", + cell: ({ + row: { + original: { file }, + }, + }) => { + return <Text className="table-primary">{file.name}</Text>; }, }, { - Header: "Forced", - accessor: "forced", - Cell: ({ row: { original, index }, value }) => { + header: "Forced", + accessorKey: "forced", + cell: ({ row: { original, index } }) => { return ( <Checkbox - checked={value} + checked={original.forced} onChange={({ currentTarget: { checked } }) => { action.mutate(index, { ...original, forced: checked }); }} @@ -219,12 +222,12 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }, }, { - Header: "HI", - accessor: "hi", - Cell: ({ row: { original, index }, value }) => { + header: "HI", + accessorKey: "hi", + cell: ({ row: { original, index } }) => { return ( <Checkbox - checked={value} + checked={original.hi} onChange={({ currentTarget: { checked } }) => { action.mutate(index, { ...original, hi: checked }); }} @@ -233,15 +236,14 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }, }, { - Header: "Language", - accessor: "language", - Cell: ({ row: { original, index }, value }) => { - const { classes } = useTableStyles(); + header: "Language", + accessorKey: "language", + cell: ({ row: { original, index } }) => { return ( <Selector {...languageOptions} - className={classes.select} - value={value} + className="table-long-break" + value={original.language} onChange={(item) => { action.mutate(index, { ...original, language: item }); }} @@ -251,13 +253,12 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }, { id: "action", - accessor: "file", - Cell: ({ row: { index } }) => { + cell: ({ row: { index } }) => { return ( <Action label="Remove" icon={faTrash} - color="red" + c="red" onClick={() => action.remove(index)} ></Action> ); @@ -289,7 +290,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({ modals.closeSelf(); })} > - <Stack className={classes.wrapper}> + <Stack className="table-long-break"> <SimpleTable columns={columns} data={form.values.files}></SimpleTable> <Divider></Divider> <Button type="submit">Upload</Button> diff --git a/frontend/src/components/forms/ProfileEditForm.module.scss b/frontend/src/components/forms/ProfileEditForm.module.scss new file mode 100644 index 000000000..3d4a8e177 --- /dev/null +++ b/frontend/src/components/forms/ProfileEditForm.module.scss @@ -0,0 +1,13 @@ +.content { + @include smaller-than($mantine-breakpoint-md) { + padding: 0; + } +} + +.evenly { + flex-wrap: wrap; + + & > div { + flex: 1; + } +} diff --git a/frontend/src/components/forms/ProfileEditForm.tsx b/frontend/src/components/forms/ProfileEditForm.tsx index eecacd73e..267951fcb 100644 --- a/frontend/src/components/forms/ProfileEditForm.tsx +++ b/frontend/src/components/forms/ProfileEditForm.tsx @@ -1,14 +1,9 @@ -import { Action, Selector, SelectorOption, SimpleTable } from "@/components"; -import { useModals, withModal } from "@/modules/modals"; -import { useTableStyles } from "@/styles"; -import { useArrayAction, useSelectorOptions } from "@/utilities"; -import { LOG } from "@/utilities/console"; -import FormUtils from "@/utilities/form"; -import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; import { Accordion, Button, Checkbox, + Flex, Select, Stack, Switch, @@ -16,9 +11,16 @@ import { TextInput, } from "@mantine/core"; import { useForm } from "@mantine/form"; -import { FunctionComponent, useCallback, useMemo } from "react"; -import { Column } from "react-table"; -import ChipInput from "../inputs/ChipInput"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; +import { Action, Selector, SelectorOption } from "@/components"; +import ChipInput from "@/components/inputs/ChipInput"; +import SimpleTable from "@/components/tables/SimpleTable"; +import { useModals, withModal } from "@/modules/modals"; +import { useArrayAction, useSelectorOptions } from "@/utilities"; +import { LOG } from "@/utilities/console"; +import FormUtils from "@/utilities/form"; +import styles from "./ProfileEditForm.module.scss"; export const anyCutoff = 65535; @@ -71,9 +73,16 @@ const ProfileEditForm: FunctionComponent<Props> = ({ (value) => value.length > 0, "Must have a name", ), + tag: FormUtils.validation((value) => { + if (!value) { + return true; + } + + return /^[a-z_0-9-]+$/.test(value); + }, "Only lowercase alphanumeric characters, underscores (_) and hyphens (-) are allowed"), items: FormUtils.validation( (value) => value.length > 0, - "Must contain at lease 1 language", + "Must contain at least 1 language", ), }, }); @@ -145,78 +154,88 @@ const ProfileEditForm: FunctionComponent<Props> = ({ } }, [form, languages]); - const columns = useMemo<Column<Language.ProfileItem>[]>( + const LanguageCell = React.memo( + ({ item, index }: { item: Language.ProfileItem; index: number }) => { + const code = useMemo( + () => + languageOptions.options.find((l) => l.value.code2 === item.language) + ?.value ?? null, + [item.language], + ); + + return ( + <Selector + {...languageOptions} + className="table-select" + value={code} + onChange={(value) => { + if (value) { + item.language = value.code2; + action.mutate(index, { ...item, language: value.code2 }); + } + }} + ></Selector> + ); + }, + ); + + const SubtitleTypeCell = React.memo( + ({ item, index }: { item: Language.ProfileItem; index: number }) => { + const selectValue = useMemo(() => { + if (item.forced === "True") { + return "forced"; + } else if (item.hi === "True") { + return "hi"; + } else { + return "normal"; + } + }, [item.forced, item.hi]); + + return ( + <Select + value={selectValue} + data={subtitlesTypeOptions} + onChange={(value) => { + if (value) { + action.mutate(index, { + ...item, + hi: value === "hi" ? "True" : "False", + forced: value === "forced" ? "True" : "False", + }); + } + }} + ></Select> + ); + }, + ); + + const columns = useMemo<ColumnDef<Language.ProfileItem>[]>( () => [ { - Header: "ID", - accessor: "id", + header: "ID", + accessorKey: "id", }, { - Header: "Language", - accessor: "language", - Cell: ({ value: code, row: { original: item, index } }) => { - const language = useMemo( - () => - languageOptions.options.find((l) => l.value.code2 === code) - ?.value ?? null, - [code], - ); - - const { classes } = useTableStyles(); - - return ( - <Selector - {...languageOptions} - className={classes.select} - value={language} - onChange={(value) => { - if (value) { - item.language = value.code2; - action.mutate(index, { ...item, language: value.code2 }); - } - }} - ></Selector> - ); + header: "Language", + accessorKey: "language", + cell: ({ row: { original: item, index } }) => { + return <LanguageCell item={item} index={index} />; }, }, { - Header: "Subtitles Type", - accessor: "forced", - Cell: ({ row: { original: item, index }, value }) => { - const selectValue = useMemo(() => { - if (item.forced === "True") { - return "forced"; - } else if (item.hi === "True") { - return "hi"; - } else { - return "normal"; - } - }, [item.forced, item.hi]); - - return ( - <Select - value={selectValue} - data={subtitlesTypeOptions} - onChange={(value) => { - if (value) { - action.mutate(index, { - ...item, - hi: value === "hi" ? "True" : "False", - forced: value === "forced" ? "True" : "False", - }); - } - }} - ></Select> - ); + header: "Subtitles Type", + accessorKey: "forced", + cell: ({ row: { original: item, index } }) => { + return <SubtitleTypeCell item={item} index={index} />; }, }, { - Header: "Exclude If Matching Audio", - accessor: "audio_exclude", - Cell: ({ row: { original: item, index }, value }) => { + header: "Exclude If Matching Audio", + accessorKey: "audio_exclude", + cell: ({ row: { original: item, index } }) => { return ( <Checkbox - checked={value === "True"} + checked={item.audio_exclude === "True"} onChange={({ currentTarget: { checked } }) => { action.mutate(index, { ...item, @@ -230,20 +249,19 @@ const ProfileEditForm: FunctionComponent<Props> = ({ }, { id: "action", - accessor: "id", - Cell: ({ row }) => { + cell: ({ row }) => { return ( <Action label="Remove" icon={faTrash} - color="red" + c="red" onClick={() => action.remove(row.index)} ></Action> ); }, }, ], - [action, languageOptions], + [action, LanguageCell, SubtitleTypeCell], ); return ( @@ -255,29 +273,40 @@ const ProfileEditForm: FunctionComponent<Props> = ({ })} > <Stack> - <TextInput label="Name" {...form.getInputProps("name")}></TextInput> + <Flex + direction={{ base: "column", sm: "row" }} + gap="sm" + className={styles.evenly} + > + <TextInput label="Name" {...form.getInputProps("name")}></TextInput> + <TextInput + label="Tag" + {...form.getInputProps("tag")} + onBlur={() => + form.setFieldValue( + "tag", + (prev) => + prev?.toLowerCase().trim().replace(/\s+/g, "_") ?? undefined, + ) + } + ></TextInput> + </Flex> <Accordion multiple chevronPosition="right" defaultValue={["Languages"]} - styles={(theme) => ({ - content: { - [theme.fn.smallerThan("md")]: { - padding: 0, - }, - }, - })} + className={styles.content} > <Accordion.Item value="Languages"> <Stack> - {form.errors.items} <SimpleTable columns={columns} data={form.values.items} ></SimpleTable> - <Button fullWidth color="light" onClick={addItem}> + <Button fullWidth onClick={addItem}> Add Language </Button> + <Text c="var(--mantine-color-error)">{form.errors.items}</Text> <Selector clearable label="Cutoff" diff --git a/frontend/src/components/forms/SeriesUploadForm.tsx b/frontend/src/components/forms/SeriesUploadForm.tsx index 5ce9c821a..e4482cab4 100644 --- a/frontend/src/components/forms/SeriesUploadForm.tsx +++ b/frontend/src/components/forms/SeriesUploadForm.tsx @@ -1,41 +1,39 @@ +import { FunctionComponent, useEffect, useMemo } from "react"; +import { + Button, + Checkbox, + Divider, + MantineColor, + Stack, + Text, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { + faCheck, + faCircleNotch, + faInfoCircle, + faTimes, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; +import { isString } from "lodash"; import { useEpisodesBySeriesId, useEpisodeSubtitleModification, useSubtitleInfos, } from "@/apis/hooks"; +import { Action, Selector } from "@/components/inputs"; +import SimpleTable from "@/components/tables/SimpleTable"; +import TextPopover from "@/components/TextPopover"; import { useModals, withModal } from "@/modules/modals"; import { task, TaskGroup } from "@/modules/task"; -import { useTableStyles } from "@/styles"; import { useArrayAction, useSelectorOptions } from "@/utilities"; import FormUtils from "@/utilities/form"; import { useLanguageProfileBy, useProfileItemsToLanguages, } from "@/utilities/languages"; -import { - faCheck, - faCircleNotch, - faInfoCircle, - faTimes, - faTrash, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - Button, - Checkbox, - createStyles, - Divider, - MantineColor, - Stack, - Text, -} from "@mantine/core"; -import { useForm } from "@mantine/form"; -import { isString } from "lodash"; -import { FunctionComponent, useEffect, useMemo } from "react"; -import { Column } from "react-table"; -import { Action, Selector } from "../inputs"; -import { SimpleTable } from "../tables"; -import TextPopover from "../TextPopover"; type SubtitleFile = { file: File; @@ -86,21 +84,12 @@ interface Props { onComplete?: VoidFunction; } -const useStyles = createStyles((theme) => { - return { - wrapper: { - overflowWrap: "anywhere", - }, - }; -}); - const SeriesUploadForm: FunctionComponent<Props> = ({ series, files, onComplete, }) => { const modals = useModals(); - const { classes } = useStyles(); const episodes = useEpisodesBySeriesId(series.sonarrSeriesId); const episodeOptions = useSelectorOptions( episodes.data ?? [], @@ -180,62 +169,79 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ } }, [action, episodes.data, infos.data]); - const columns = useMemo<Column<SubtitleFile>[]>( - () => [ - { - accessor: "validateResult", - Cell: ({ cell: { value } }) => { - const icon = useMemo(() => { - switch (value?.state) { - case "valid": - return faCheck; - case "warning": - return faInfoCircle; - case "error": - return faTimes; - default: - return faCircleNotch; - } - }, [value?.state]); + const ValidateResultCell = ({ + validateResult, + }: { + validateResult: SubtitleValidateResult | undefined; + }) => { + const icon = useMemo(() => { + switch (validateResult?.state) { + case "valid": + return faCheck; + case "warning": + return faInfoCircle; + case "error": + return faTimes; + default: + return faCircleNotch; + } + }, [validateResult?.state]); - const color = useMemo<MantineColor | undefined>(() => { - switch (value?.state) { - case "valid": - return "green"; - case "warning": - return "yellow"; - case "error": - return "red"; - default: - return undefined; - } - }, [value?.state]); + const color = useMemo<MantineColor | undefined>(() => { + switch (validateResult?.state) { + case "valid": + return "green"; + case "warning": + return "yellow"; + case "error": + return "red"; + default: + return undefined; + } + }, [validateResult?.state]); - return ( - <TextPopover text={value?.messages}> - <Text color={color} inline> - <FontAwesomeIcon icon={icon}></FontAwesomeIcon> - </Text> - </TextPopover> - ); + return ( + <TextPopover text={validateResult?.messages}> + <Text c={color} inline> + <FontAwesomeIcon icon={icon}></FontAwesomeIcon> + </Text> + </TextPopover> + ); + }; + + const columns = useMemo<ColumnDef<SubtitleFile>[]>( + () => [ + { + id: "validateResult", + cell: ({ + row: { + original: { validateResult }, + }, + }) => { + return <ValidateResultCell validateResult={validateResult} />; }, }, { - Header: "File", + header: "File", id: "filename", - accessor: "file", - Cell: ({ value: { name } }) => { - const { classes } = useTableStyles(); - return <Text className={classes.primary}>{name}</Text>; + accessorKey: "file", + cell: ({ + row: { + original: { + file: { name }, + }, + }, + }) => { + return <Text className="table-primary">{name}</Text>; }, }, { - Header: "Forced", - accessor: "forced", - Cell: ({ row: { original, index }, value }) => { + header: "Forced", + accessorKey: "forced", + cell: ({ row: { original, index } }) => { return ( <Checkbox - checked={value} + checked={original.forced} onChange={({ currentTarget: { checked } }) => { action.mutate(index, { ...original, @@ -248,12 +254,12 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, }, { - Header: "HI", - accessor: "hi", - Cell: ({ row: { original, index }, value }) => { + header: "HI", + accessorKey: "hi", + cell: ({ row: { original, index } }) => { return ( <Checkbox - checked={value} + checked={original.hi} onChange={({ currentTarget: { checked } }) => { action.mutate(index, { ...original, @@ -266,7 +272,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, }, { - Header: ( + header: () => ( <Selector {...languageOptions} value={null} @@ -281,14 +287,13 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }} ></Selector> ), - accessor: "language", - Cell: ({ row: { original, index }, value }) => { - const { classes } = useTableStyles(); + accessorKey: "language", + cell: ({ row: { original, index } }) => { return ( <Selector {...languageOptions} - className={classes.select} - value={value} + className="table-select" + value={original.language} onChange={(item) => { action.mutate(index, { ...original, language: item }); }} @@ -298,18 +303,17 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, { id: "episode", - Header: "Episode", - accessor: "episode", - Cell: ({ value, row }) => { - const { classes } = useTableStyles(); + header: "Episode", + accessorKey: "episode", + cell: ({ row: { original, index } }) => { return ( <Selector {...episodeOptions} searchable - className={classes.select} - value={value} + className="table-select" + value={original.episode} onChange={(item) => { - action.mutate(row.index, { ...row.original, episode: item }); + action.mutate(index, { ...original, episode: item }); }} ></Selector> ); @@ -317,13 +321,12 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, { id: "action", - accessor: "file", - Cell: ({ row: { index } }) => { + cell: ({ row: { index } }) => { return ( <Action label="Remove" icon={faTrash} - color="red" + c="red" onClick={() => action.remove(index)} ></Action> ); @@ -368,7 +371,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ modals.closeSelf(); })} > - <Stack className={classes.wrapper}> + <Stack className="table-long-break"> <SimpleTable columns={columns} data={form.values.files}></SimpleTable> <Divider></Divider> <Button type="submit">Upload</Button> diff --git a/frontend/src/components/forms/SyncSubtitleForm.tsx b/frontend/src/components/forms/SyncSubtitleForm.tsx index b5136fc85..63953fb2d 100644 --- a/frontend/src/components/forms/SyncSubtitleForm.tsx +++ b/frontend/src/components/forms/SyncSubtitleForm.tsx @@ -1,20 +1,23 @@ /* eslint-disable camelcase */ - +import { FunctionComponent } from "react"; +import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRefTracksByEpisodeId, useRefTracksByMovieId, useSubtitleAction, } from "@/apis/hooks"; +import { + GroupedSelector, + GroupedSelectorOptions, + Selector, +} from "@/components/inputs"; import { useModals, withModal } from "@/modules/modals"; import { task } from "@/modules/task"; import { syncMaxOffsetSecondsOptions } from "@/pages/Settings/Subtitles/options"; -import { toPython } from "@/utilities"; -import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Alert, Button, Checkbox, Divider, Stack, Text } from "@mantine/core"; -import { useForm } from "@mantine/form"; -import { FunctionComponent } from "react"; -import { Selector, SelectorOption } from "../inputs"; +import { fromPython, toPython } from "@/utilities"; const TaskName = "Syncing Subtitle"; @@ -37,15 +40,21 @@ function useReferencedSubtitles( const mediaData = mediaType === "episode" ? episodeData : movieData; - const subtitles: { group: string; value: string; label: string }[] = []; + const subtitles: GroupedSelectorOptions<string>[] = []; if (!mediaData.data) { return []; } else { if (mediaData.data.audio_tracks.length > 0) { + const embeddedAudioGroup: GroupedSelectorOptions<string> = { + group: "Embedded audio tracks", + items: [], + }; + + subtitles.push(embeddedAudioGroup); + mediaData.data.audio_tracks.forEach((item) => { - subtitles.push({ - group: "Embedded audio tracks", + embeddedAudioGroup.items.push({ value: item.stream, label: `${item.name || item.language} (${item.stream})`, }); @@ -53,9 +62,15 @@ function useReferencedSubtitles( } if (mediaData.data.embedded_subtitles_tracks.length > 0) { + const embeddedSubtitlesTrackGroup: GroupedSelectorOptions<string> = { + group: "Embedded subtitles tracks", + items: [], + }; + + subtitles.push(embeddedSubtitlesTrackGroup); + mediaData.data.embedded_subtitles_tracks.forEach((item) => { - subtitles.push({ - group: "Embedded subtitles tracks", + embeddedSubtitlesTrackGroup.items.push({ value: item.stream, label: `${item.name || item.language} (${item.stream})`, }); @@ -63,10 +78,16 @@ function useReferencedSubtitles( } if (mediaData.data.external_subtitles_tracks.length > 0) { + const externalSubtitlesFilesGroup: GroupedSelectorOptions<string> = { + group: "External Subtitles files", + items: [], + }; + + subtitles.push(externalSubtitlesFilesGroup); + mediaData.data.external_subtitles_tracks.forEach((item) => { if (item) { - subtitles.push({ - group: "External Subtitles files", + externalSubtitlesFilesGroup.items.push({ value: item.path, label: item.name, }); @@ -88,6 +109,8 @@ interface FormValues { maxOffsetSeconds?: string; noFixFramerate: boolean; gss: boolean; + hi?: boolean; + forced?: boolean; } const SyncSubtitleForm: FunctionComponent<Props> = ({ @@ -101,20 +124,20 @@ const SyncSubtitleForm: FunctionComponent<Props> = ({ const { mutateAsync } = useSubtitleAction(); const modals = useModals(); - const mediaType = selections[0].type; - const mediaId = selections[0].id; - const subtitlesPath = selections[0].path; + const subtitle = selections[0]; - const subtitles: SelectorOption<string>[] = useReferencedSubtitles( - mediaType, - mediaId, - subtitlesPath, - ); + const mediaType = subtitle.type; + const mediaId = subtitle.id; + const subtitlesPath = subtitle.path; + + const subtitles = useReferencedSubtitles(mediaType, mediaId, subtitlesPath); const form = useForm<FormValues>({ initialValues: { noFixFramerate: false, gss: false, + hi: fromPython(subtitle.hi), + forced: fromPython(subtitle.forced), }, }); @@ -145,14 +168,14 @@ const SyncSubtitleForm: FunctionComponent<Props> = ({ > <Text size="sm">{selections.length} subtitles selected</Text> </Alert> - <Selector + <GroupedSelector clearable disabled={subtitles.length === 0 || selections.length !== 1} label="Reference" placeholder="Default: choose automatically within video file" options={subtitles} {...form.getInputProps("reference")} - ></Selector> + ></GroupedSelector> <Selector clearable label="Max Offset Seconds" diff --git a/frontend/src/components/forms/TimeOffsetForm.tsx b/frontend/src/components/forms/TimeOffsetForm.tsx index 2792d64d8..1a7739dd9 100644 --- a/frontend/src/components/forms/TimeOffsetForm.tsx +++ b/frontend/src/components/forms/TimeOffsetForm.tsx @@ -1,12 +1,12 @@ +import { FunctionComponent } from "react"; +import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useSubtitleAction } from "@/apis/hooks"; import { useModals, withModal } from "@/modules/modals"; import { task } from "@/modules/task"; import FormUtils from "@/utilities/form"; -import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core"; -import { useForm } from "@mantine/form"; -import { FunctionComponent } from "react"; const TaskName = "Changing Time"; @@ -70,10 +70,11 @@ const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => { })} > <Stack> - <Group align="end" spacing="xs" noWrap> + <Group align="end" gap="xs" wrap="nowrap"> <Button color="gray" variant="filled" + style={{ overflow: "visible" }} onClick={() => form.setValues((f) => ({ ...f, positive: !f.positive })) } diff --git a/frontend/src/components/forms/TranslationForm.tsx b/frontend/src/components/forms/TranslationForm.tsx index 976b2f72f..20aa08478 100644 --- a/frontend/src/components/forms/TranslationForm.tsx +++ b/frontend/src/components/forms/TranslationForm.tsx @@ -1,14 +1,14 @@ +import { FunctionComponent, useMemo } from "react"; +import { Alert, Button, Divider, Stack } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { isObject } from "lodash"; import { useSubtitleAction } from "@/apis/hooks"; +import { Selector } from "@/components/inputs"; import { useModals, withModal } from "@/modules/modals"; import { task } from "@/modules/task"; import { useSelectorOptions } from "@/utilities"; import FormUtils from "@/utilities/form"; import { useEnabledLanguages } from "@/utilities/languages"; -import { Alert, Button, Divider, Stack } from "@mantine/core"; -import { useForm } from "@mantine/form"; -import { isObject } from "lodash"; -import { FunctionComponent, useMemo } from "react"; -import { Selector } from "../inputs"; const TaskName = "Translating Subtitles"; diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx index c3d7b4763..5ea97cf04 100644 --- a/frontend/src/components/index.tsx +++ b/frontend/src/components/index.tsx @@ -1,4 +1,4 @@ export { default as Search } from "./Search"; export * from "./inputs"; export * from "./tables"; -export { default as Toolbox } from "./toolbox"; +export { default as Toolbox } from "./toolbox/Toolbox"; diff --git a/frontend/src/components/inputs/Action.test.tsx b/frontend/src/components/inputs/Action.test.tsx index 189aca076..dc8972630 100644 --- a/frontend/src/components/inputs/Action.test.tsx +++ b/frontend/src/components/inputs/Action.test.tsx @@ -1,7 +1,7 @@ -import { rawRender, screen } from "@/tests"; import { faStickyNote } from "@fortawesome/free-regular-svg-icons"; import userEvent from "@testing-library/user-event"; import { describe, it, vitest } from "vitest"; +import { render, screen } from "@/tests"; import Action from "./Action"; const testLabel = "Test Label"; @@ -9,7 +9,7 @@ const testIcon = faStickyNote; describe("Action button", () => { it("should be a button", () => { - rawRender(<Action icon={testIcon} label={testLabel}></Action>); + render(<Action icon={testIcon} label={testLabel}></Action>); const element = screen.getByRole("button", { name: testLabel }); expect(element.getAttribute("type")).toEqual("button"); @@ -17,7 +17,7 @@ describe("Action button", () => { }); it("should show icon", () => { - rawRender(<Action icon={testIcon} label={testLabel}></Action>); + render(<Action icon={testIcon} label={testLabel}></Action>); // TODO: use getBy... const element = screen.getByRole("img", { hidden: true }); @@ -27,7 +27,7 @@ describe("Action button", () => { it("should call on-click event when clicked", async () => { const onClickFn = vitest.fn(); - rawRender( + render( <Action icon={testIcon} label={testLabel} onClick={onClickFn}></Action>, ); diff --git a/frontend/src/components/inputs/Action.tsx b/frontend/src/components/inputs/Action.tsx index 236baf112..95477f090 100644 --- a/frontend/src/components/inputs/Action.tsx +++ b/frontend/src/components/inputs/Action.tsx @@ -1,15 +1,15 @@ -import { IconDefinition } from "@fortawesome/fontawesome-common-types"; -import { - FontAwesomeIcon, - FontAwesomeIconProps, -} from "@fortawesome/react-fontawesome"; +import { forwardRef } from "react"; import { ActionIcon, ActionIconProps, Tooltip, TooltipProps, } from "@mantine/core"; -import { forwardRef } from "react"; +import { IconDefinition } from "@fortawesome/fontawesome-common-types"; +import { + FontAwesomeIcon, + FontAwesomeIconProps, +} from "@fortawesome/react-fontawesome"; export type ActionProps = MantineComp<ActionIconProps, "button"> & { icon: IconDefinition; diff --git a/frontend/src/components/inputs/ChipInput.test.tsx b/frontend/src/components/inputs/ChipInput.test.tsx index cb52ee30c..4035966fc 100644 --- a/frontend/src/components/inputs/ChipInput.test.tsx +++ b/frontend/src/components/inputs/ChipInput.test.tsx @@ -1,6 +1,6 @@ -import { rawRender, screen } from "@/tests"; import userEvent from "@testing-library/user-event"; import { describe, it, vitest } from "vitest"; +import { render, screen } from "@/tests"; import ChipInput from "./ChipInput"; describe("ChipInput", () => { @@ -8,7 +8,7 @@ describe("ChipInput", () => { // TODO: Support default value it.skip("should works with default value", () => { - rawRender(<ChipInput defaultValue={existedValues}></ChipInput>); + render(<ChipInput defaultValue={existedValues}></ChipInput>); existedValues.forEach((value) => { expect(screen.getByText(value)).toBeDefined(); @@ -16,7 +16,7 @@ describe("ChipInput", () => { }); it("should works with value", () => { - rawRender(<ChipInput value={existedValues}></ChipInput>); + render(<ChipInput value={existedValues}></ChipInput>); existedValues.forEach((value) => { expect(screen.getByText(value)).toBeDefined(); @@ -29,9 +29,7 @@ describe("ChipInput", () => { expect(values).toContain(typedValue); }); - rawRender( - <ChipInput value={existedValues} onChange={mockedFn}></ChipInput>, - ); + render(<ChipInput value={existedValues} onChange={mockedFn}></ChipInput>); const element = screen.getByRole("searchbox"); diff --git a/frontend/src/components/inputs/ChipInput.tsx b/frontend/src/components/inputs/ChipInput.tsx index 4308f7189..1fa57084c 100644 --- a/frontend/src/components/inputs/ChipInput.tsx +++ b/frontend/src/components/inputs/ChipInput.tsx @@ -1,35 +1,29 @@ -import { useSelectorOptions } from "@/utilities"; import { FunctionComponent } from "react"; -import { MultiSelector, MultiSelectorProps } from "./Selector"; +import { TagsInput } from "@mantine/core"; -export type ChipInputProps = Omit< - MultiSelectorProps<string>, - | "searchable" - | "creatable" - | "getCreateLabel" - | "onCreate" - | "options" - | "getkey" ->; - -const ChipInput: FunctionComponent<ChipInputProps> = ({ ...props }) => { - const { value, onChange } = props; - - const options = useSelectorOptions(value ?? [], (v) => v); +export interface ChipInputProps { + defaultValue?: string[] | undefined; + value?: readonly string[] | null; + label?: string; + onChange?: (value: string[]) => void; +} +const ChipInput: FunctionComponent<ChipInputProps> = ({ + defaultValue, + value, + label, + onChange, +}: ChipInputProps) => { + // TODO: Replace with our own custom implementation instead of just using the + // built-in TagsInput. https://mantine.dev/combobox/?e=MultiSelectCreatable return ( - <MultiSelector - {...props} - {...options} - creatable - searchable - getCreateLabel={(query) => `Add "${query}"`} - onCreate={(query) => { - onChange?.([...(value ?? []), query]); - return query; - }} - buildOption={(value) => value} - ></MultiSelector> + <TagsInput + defaultValue={defaultValue} + label={label} + value={value ? value?.map((v) => v) : []} + onChange={onChange} + clearable + ></TagsInput> ); }; diff --git a/frontend/src/components/inputs/DropContent.module.scss b/frontend/src/components/inputs/DropContent.module.scss new file mode 100644 index 000000000..c6c0f848a --- /dev/null +++ b/frontend/src/components/inputs/DropContent.module.scss @@ -0,0 +1,4 @@ +.container { + pointer-events: none; + min-height: 220px; +} diff --git a/frontend/src/components/inputs/DropContent.tsx b/frontend/src/components/inputs/DropContent.tsx index 38556220d..c4bcf2877 100644 --- a/frontend/src/components/inputs/DropContent.tsx +++ b/frontend/src/components/inputs/DropContent.tsx @@ -1,27 +1,17 @@ +import { FunctionComponent } from "react"; +import { Group, Stack, Text } from "@mantine/core"; +import { Dropzone } from "@mantine/dropzone"; import { faArrowUp, faFileCirclePlus, faXmark, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Group, Stack, Text, createStyles } from "@mantine/core"; -import { Dropzone } from "@mantine/dropzone"; -import { FunctionComponent } from "react"; - -const useStyle = createStyles((theme) => { - return { - container: { - pointerEvents: "none", - minHeight: 220, - }, - }; -}); +import styles from "./DropContent.module.scss"; export const DropContent: FunctionComponent = () => { - const { classes } = useStyle(); - return ( - <Group position="center" spacing="xl" className={classes.container}> + <Group justify="center" gap="xl" className={styles.container}> <Dropzone.Idle> <FontAwesomeIcon icon={faFileCirclePlus} size="2x" /> </Dropzone.Idle> @@ -31,9 +21,9 @@ export const DropContent: FunctionComponent = () => { <Dropzone.Reject> <FontAwesomeIcon icon={faXmark} size="2x" /> </Dropzone.Reject> - <Stack spacing={0}> + <Stack gap={0}> <Text size="lg">Upload Subtitles</Text> - <Text color="dimmed" size="sm"> + <Text c="dimmed" size="sm"> Attach as many files as you like, you will need to select file metadata before uploading </Text> diff --git a/frontend/src/components/inputs/FileBrowser.tsx b/frontend/src/components/inputs/FileBrowser.tsx index ce57a4938..bba66a66e 100644 --- a/frontend/src/components/inputs/FileBrowser.tsx +++ b/frontend/src/components/inputs/FileBrowser.tsx @@ -1,8 +1,13 @@ -import { useFileSystem } from "@/apis/hooks"; +import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react"; +import { + Autocomplete, + AutocompleteProps, + ComboboxItem, + OptionsFilter, +} from "@mantine/core"; import { faFolder } from "@fortawesome/free-regular-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Autocomplete, AutocompleteProps } from "@mantine/core"; -import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react"; +import { useFileSystem } from "@/apis/hooks"; // TODO: use fortawesome icons const backKey = "⏎ Back"; @@ -75,24 +80,28 @@ export const FileBrowser: FunctionComponent<FileBrowserProps> = ({ const ref = useRef<HTMLInputElement>(null); + const optionsFilter: OptionsFilter = ({ options, search }) => { + return (options as ComboboxItem[]).filter((option) => { + if (search === backKey) { + return true; + } + + return option.value.includes(search); + }); + }; + return ( <Autocomplete {...props} ref={ref} - icon={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>} + leftSection={<FontAwesomeIcon icon={faFolder}></FontAwesomeIcon>} placeholder="Click to start" data={data} value={value} // Temporary solution of infinite dropdown items, fix later limit={NaN} maxDropdownHeight={240} - filter={(value, item) => { - if (item.value === backKey) { - return true; - } else { - return item.value.includes(value); - } - }} + filter={optionsFilter} onChange={(val) => { if (val !== backKey) { setValue(val); diff --git a/frontend/src/components/inputs/Selector.test.tsx b/frontend/src/components/inputs/Selector.test.tsx index a7b6cfb85..a28772a2d 100644 --- a/frontend/src/components/inputs/Selector.test.tsx +++ b/frontend/src/components/inputs/Selector.test.tsx @@ -1,6 +1,6 @@ -import { rawRender, screen } from "@/tests"; import userEvent from "@testing-library/user-event"; import { describe, it, vitest } from "vitest"; +import { render, screen } from "@/tests"; import { Selector, SelectorOption } from "./Selector"; const selectorName = "Test Selections"; @@ -18,20 +18,17 @@ const testOptions: SelectorOption<string>[] = [ describe("Selector", () => { describe("options", () => { it("should work with the SelectorOption", () => { - rawRender( - <Selector name={selectorName} options={testOptions}></Selector>, - ); + render(<Selector name={selectorName} options={testOptions}></Selector>); - // TODO: selectorName - expect(screen.getByRole("searchbox")).toBeDefined(); + testOptions.forEach((o) => { + expect(screen.getByText(o.label)).toBeDefined(); + }); }); it("should display when clicked", async () => { - rawRender( - <Selector name={selectorName} options={testOptions}></Selector>, - ); + render(<Selector name={selectorName} options={testOptions}></Selector>); - const element = screen.getByRole("searchbox"); + const element = screen.getByTestId("input-selector"); await userEvent.click(element); @@ -44,7 +41,7 @@ describe("Selector", () => { it("shouldn't show default value", async () => { const option = testOptions[0]; - rawRender( + render( <Selector name={selectorName} options={testOptions} @@ -57,7 +54,7 @@ describe("Selector", () => { it("shouldn't show value", async () => { const option = testOptions[0]; - rawRender( + render( <Selector name={selectorName} options={testOptions} @@ -75,7 +72,7 @@ describe("Selector", () => { const mockedFn = vitest.fn((value: string | null) => { expect(value).toEqual(clickedOption.value); }); - rawRender( + render( <Selector name={selectorName} options={testOptions} @@ -83,13 +80,13 @@ describe("Selector", () => { ></Selector>, ); - const element = screen.getByRole("searchbox"); + const element = screen.getByTestId("input-selector"); await userEvent.click(element); await userEvent.click(screen.getByText(clickedOption.label)); - expect(mockedFn).toBeCalled(); + expect(mockedFn).toHaveBeenCalled(); }); }); @@ -115,7 +112,7 @@ describe("Selector", () => { const mockedFn = vitest.fn((value: { name: string } | null) => { expect(value).toEqual(clickedOption.value); }); - rawRender( + render( <Selector name={selectorName} options={objectOptions} @@ -124,20 +121,20 @@ describe("Selector", () => { ></Selector>, ); - const element = screen.getByRole("searchbox"); + const element = screen.getByTestId("input-selector"); await userEvent.click(element); await userEvent.click(screen.getByText(clickedOption.label)); - expect(mockedFn).toBeCalled(); + expect(mockedFn).toHaveBeenCalled(); }); }); describe("placeholder", () => { it("should show when no selection", () => { const placeholder = "Empty Selection"; - rawRender( + render( <Selector name={selectorName} options={testOptions} diff --git a/frontend/src/components/inputs/Selector.tsx b/frontend/src/components/inputs/Selector.tsx index 0af276fc4..092fd24e7 100644 --- a/frontend/src/components/inputs/Selector.tsx +++ b/frontend/src/components/inputs/Selector.tsx @@ -1,23 +1,24 @@ -import { LOG } from "@/utilities/console"; +import { useCallback, useMemo, useRef } from "react"; import { + ComboboxItem, + ComboboxItemGroup, MultiSelect, MultiSelectProps, Select, - SelectItem, SelectProps, } from "@mantine/core"; import { isNull, isUndefined } from "lodash"; -import { useCallback, useMemo, useRef } from "react"; +import { LOG } from "@/utilities/console"; export type SelectorOption<T> = Override< { value: T; label: string; }, - SelectItem + ComboboxItem >; -type SelectItemWithPayload<T> = SelectItem & { +type SelectItemWithPayload<T> = ComboboxItem & { payload: T; }; @@ -34,6 +35,33 @@ function DefaultKeyBuilder<T>(value: T) { } } +export interface GroupedSelectorOptions<T> { + group: string; + items: SelectorOption<T>[]; +} + +export type GroupedSelectorProps<T> = Override< + { + options: ComboboxItemGroup[]; + getkey?: (value: T) => string; + }, + Omit<SelectProps, "data"> +>; + +export function GroupedSelector<T>({ + options, + ...select +}: GroupedSelectorProps<T>) { + return ( + <Select + data-testid="input-selector" + comboboxProps={{ withinPortal: true }} + data={options} + {...select} + ></Select> + ); +} + export type SelectorProps<T> = Override< { value?: T | null; @@ -84,7 +112,7 @@ export function Selector<T>({ }, [defaultValue, keyRef]); const wrappedOnChange = useCallback( - (value: string) => { + (value: string | null) => { const payload = data.find((v) => v.value === value)?.payload ?? null; onChange?.(payload); }, @@ -93,7 +121,8 @@ export function Selector<T>({ return ( <Select - withinPortal={true} + data-testid="input-selector" + comboboxProps={{ withinPortal: true }} data={data} defaultValue={wrappedDefaultValue} value={wrappedValue} @@ -144,6 +173,7 @@ export function MultiSelector<T>({ () => value && value.map(labelRef.current), [value], ); + const wrappedDefaultValue = useMemo( () => defaultValue && defaultValue.map(labelRef.current), [defaultValue], @@ -168,6 +198,7 @@ export function MultiSelector<T>({ return ( <MultiSelect {...select} + hidePickedOptions value={wrappedValue} defaultValue={wrappedDefaultValue} onChange={wrappedOnChange} diff --git a/frontend/src/components/modals/HistoryModal.tsx b/frontend/src/components/modals/HistoryModal.tsx index cc4197c44..88d57ac65 100644 --- a/frontend/src/components/modals/HistoryModal.tsx +++ b/frontend/src/components/modals/HistoryModal.tsx @@ -1,23 +1,23 @@ /* eslint-disable camelcase */ +import { FunctionComponent, useMemo } from "react"; +import { Badge, Center, Text } from "@mantine/core"; +import { faFileExcel, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useEpisodeAddBlacklist, useEpisodeHistory, useMovieAddBlacklist, useMovieHistory, } from "@/apis/hooks"; +import MutateAction from "@/components/async/MutateAction"; +import QueryOverlay from "@/components/async/QueryOverlay"; +import { HistoryIcon } from "@/components/bazarr"; +import Language from "@/components/bazarr/Language"; import StateIcon from "@/components/StateIcon"; +import PageTable from "@/components/tables/PageTable"; +import TextPopover from "@/components/TextPopover"; import { withModal } from "@/modules/modals"; -import { faFileExcel, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Badge, Center, Text } from "@mantine/core"; -import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; -import { PageTable } from ".."; -import TextPopover from "../TextPopover"; -import MutateAction from "../async/MutateAction"; -import QueryOverlay from "../async/QueryOverlay"; -import { HistoryIcon } from "../bazarr"; -import Language from "../bazarr/Language"; interface MovieHistoryViewProps { movie: Item.Movie; @@ -30,24 +30,34 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({ const { data } = history; - const columns = useMemo<Column<History.Movie>[]>( + const addMovieToBlacklist = useMovieAddBlacklist(); + + const columns = useMemo<ColumnDef<History.Movie>[]>( () => [ { - accessor: "action", - Cell: (row) => ( + id: "action", + cell: ({ + row: { + original: { action }, + }, + }) => ( <Center> - <HistoryIcon action={row.value}></HistoryIcon> + <HistoryIcon action={action}></HistoryIcon> </Center> ), }, { - Header: "Language", - accessor: "language", - Cell: ({ value }) => { - if (value) { + header: "Language", + accessorKey: "language", + cell: ({ + row: { + original: { language }, + }, + }) => { + if (language) { return ( <Badge> - <Language.Text value={value} long></Language.Text> + <Language.Text value={language} long></Language.Text> </Badge> ); } else { @@ -56,17 +66,20 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({ }, }, { - Header: "Provider", - accessor: "provider", + header: "Provider", + accessorKey: "provider", }, { - Header: "Score", - accessor: "score", + header: "Score", + accessorKey: "score", }, { - accessor: "matches", - Cell: (row) => { - const { matches, dont_matches: dont } = row.row.original; + id: "matches", + cell: ({ + row: { + original: { matches, dont_matches: dont }, + }, + }) => { if (matches.length || dont.length) { return ( <StateIcon @@ -81,31 +94,42 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({ }, }, { - Header: "Date", - accessor: "timestamp", - Cell: ({ value, row }) => { + header: "Date", + accessorKey: "timestamp", + cell: ({ + row: { + original: { timestamp, parsed_timestamp: parsedTimestamp }, + }, + }) => { return ( - <TextPopover text={row.original.parsed_timestamp}> - <Text>{value}</Text> + <TextPopover text={parsedTimestamp}> + <Text>{timestamp}</Text> </TextPopover> ); }, }, { // Actions - accessor: "blacklisted", - Cell: ({ row, value }) => { - const add = useMovieAddBlacklist(); - const { radarrId, provider, subs_id, language, subtitles_path } = - row.original; - + id: "blacklisted", + cell: ({ + row: { + original: { + blacklisted, + radarrId, + provider, + subs_id, + language, + subtitles_path, + }, + }, + }) => { if (subs_id && provider && language) { return ( <MutateAction label="Add to Blacklist" - disabled={value} + disabled={blacklisted} icon={faFileExcel} - mutation={add} + mutation={addMovieToBlacklist} args={() => ({ id: radarrId, form: { @@ -123,7 +147,7 @@ const MovieHistoryView: FunctionComponent<MovieHistoryViewProps> = ({ }, }, ], - [], + [addMovieToBlacklist], ); return ( @@ -153,24 +177,34 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({ const { data } = history; - const columns = useMemo<Column<History.Episode>[]>( + const addEpisodeToBlacklist = useEpisodeAddBlacklist(); + + const columns = useMemo<ColumnDef<History.Episode>[]>( () => [ { - accessor: "action", - Cell: (row) => ( + id: "action", + cell: ({ + row: { + original: { action }, + }, + }) => ( <Center> - <HistoryIcon action={row.value}></HistoryIcon> + <HistoryIcon action={action}></HistoryIcon> </Center> ), }, { - Header: "Language", - accessor: "language", - Cell: ({ value }) => { - if (value) { + header: "Language", + accessorKey: "language", + cell: ({ + row: { + original: { language }, + }, + }) => { + if (language) { return ( <Badge> - <Language.Text value={value} long></Language.Text> + <Language.Text value={language} long></Language.Text> </Badge> ); } else { @@ -179,16 +213,16 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({ }, }, { - Header: "Provider", - accessor: "provider", + header: "Provider", + accessorKey: "provider", }, { - Header: "Score", - accessor: "score", + header: "Score", + accessorKey: "score", }, { - accessor: "matches", - Cell: (row) => { + id: "matches", + cell: (row) => { const { matches, dont_matches: dont } = row.row.original; if (matches.length || dont.length) { return ( @@ -204,21 +238,29 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({ }, }, { - Header: "Date", - accessor: "timestamp", - Cell: ({ row, value }) => { + header: "Date", + accessorKey: "timestamp", + cell: ({ + row: { + original: { timestamp, parsed_timestamp: parsedTimestamp }, + }, + }) => { return ( - <TextPopover text={row.original.parsed_timestamp}> - <Text>{value}</Text> + <TextPopover text={parsedTimestamp}> + <Text>{timestamp}</Text> </TextPopover> ); }, }, { - accessor: "description", - Cell: ({ value }) => { + id: "description", + cell: ({ + row: { + original: { description }, + }, + }) => { return ( - <TextPopover text={value}> + <TextPopover text={description}> <FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon> </TextPopover> ); @@ -226,25 +268,27 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({ }, { // Actions - accessor: "blacklisted", - Cell: ({ row, value }) => { - const { - sonarrEpisodeId, - sonarrSeriesId, - provider, - subs_id, - language, - subtitles_path, - } = row.original; - const add = useEpisodeAddBlacklist(); - + id: "blacklisted", + cell: ({ + row: { + original: { + blacklisted, + sonarrEpisodeId, + sonarrSeriesId, + provider, + subs_id, + language, + subtitles_path, + }, + }, + }) => { if (subs_id && provider && language) { return ( <MutateAction label="Add to Blacklist" - disabled={value} + disabled={blacklisted} icon={faFileExcel} - mutation={add} + mutation={addEpisodeToBlacklist} args={() => ({ seriesId: sonarrSeriesId, episodeId: sonarrEpisodeId, @@ -263,12 +307,13 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({ }, }, ], - [], + [addEpisodeToBlacklist], ); return ( <QueryOverlay result={history}> <PageTable + autoScroll={false} tableStyles={{ emptyText: "No history found", placeholder: 5 }} columns={columns} data={data ?? []} diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx index 24799130d..81a49f0f3 100644 --- a/frontend/src/components/modals/ManualSearchModal.tsx +++ b/frontend/src/components/modals/ManualSearchModal.tsx @@ -1,13 +1,4 @@ -import { withModal } from "@/modules/modals"; -import { task, TaskGroup } from "@/modules/task"; -import { useTableStyles } from "@/styles"; -import { GetItemId } from "@/utilities"; -import { - faCaretDown, - faDownload, - faInfoCircle, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { useCallback, useMemo, useState } from "react"; import { Alert, Anchor, @@ -19,21 +10,28 @@ import { Stack, Text, } from "@mantine/core"; +import { + faCaretDown, + faDownload, + faInfoCircle, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UseQueryResult } from "@tanstack/react-query"; +import { ColumnDef } from "@tanstack/react-table"; import { isString } from "lodash"; -import { useCallback, useMemo, useState } from "react"; -import { UseQueryResult } from "react-query"; -import { Column } from "react-table"; -import { Action, PageTable } from ".."; -import Language from "../bazarr/Language"; -import StateIcon from "../StateIcon"; +import { Action } from "@/components"; +import Language from "@/components/bazarr/Language"; +import StateIcon from "@/components/StateIcon"; +import PageTable from "@/components/tables/PageTable"; +import { withModal } from "@/modules/modals"; +import { task, TaskGroup } from "@/modules/task"; +import { GetItemId } from "@/utilities"; type SupportType = Item.Movie | Item.Episode; interface Props<T extends SupportType> { download: (item: T, result: SearchResultType) => Promise<void>; - query: ( - id?: number, - ) => UseQueryResult<SearchResultType[] | undefined, unknown>; + query: (id?: number) => UseQueryResult<SearchResultType[] | undefined>; item: T; } @@ -50,27 +48,67 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { const search = useCallback(() => { setSearchStarted(true); - results.refetch(); + + void results.refetch(); }, [results]); - const columns = useMemo<Column<SearchResultType>[]>( + const ReleaseInfoCell = React.memo( + ({ releaseInfo }: { releaseInfo: string[] }) => { + const [open, setOpen] = useState(false); + + const items = useMemo( + () => releaseInfo.slice(1).map((v, idx) => <Text key={idx}>{v}</Text>), + [releaseInfo], + ); + + if (releaseInfo.length === 0) { + return <Text c="dimmed">Cannot get release info</Text>; + } + + return ( + <Stack gap={0} onClick={() => setOpen((o) => !o)}> + <Text className="table-primary" span> + {releaseInfo[0]} + {releaseInfo.length > 1 && ( + <FontAwesomeIcon + icon={faCaretDown} + rotation={open ? 180 : undefined} + ></FontAwesomeIcon> + )} + </Text> + <Collapse in={open}> + <>{items}</> + </Collapse> + </Stack> + ); + }, + ); + + const columns = useMemo<ColumnDef<SearchResultType>[]>( () => [ { - Header: "Score", - accessor: "score", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - return <Text className={classes.noWrap}>{value}%</Text>; + header: "Score", + accessorKey: "score", + cell: ({ + row: { + original: { score }, + }, + }) => { + return <Text className="table-no-wrap">{score}%</Text>; }, }, { - Header: "Language", - accessor: "language", - Cell: ({ row: { original }, value }) => { + header: "Language", + accessorKey: "language", + cell: ({ + row: { + original: { language, hearing_impaired: hi, forced }, + }, + }) => { const lang: Language.Info = { - code2: value, - hi: original.hearing_impaired === "True", - forced: original.forced === "True", + code2: language, + hi: hi === "True", + forced: forced === "True", name: "", }; return ( @@ -81,16 +119,19 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { }, }, { - Header: "Provider", - accessor: "provider", - Cell: (row) => { - const { classes } = useTableStyles(); - const value = row.value; - const { url } = row.row.original; + header: "Provider", + accessorKey: "provider", + cell: ({ + row: { + original: { provider, url }, + }, + }) => { + const value = provider; + if (url) { return ( <Anchor - className={classes.noWrap} + className="table-no-wrap" href={url} target="_blank" rel="noopener noreferrer" @@ -104,51 +145,31 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { }, }, { - Header: "Release", - accessor: "release_info", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - const [open, setOpen] = useState(false); - - const items = useMemo( - () => value.slice(1).map((v, idx) => <Text key={idx}>{v}</Text>), - [value], - ); - - if (value.length === 0) { - return <Text color="dimmed">Cannot get release info</Text>; - } - - return ( - <Stack spacing={0} onClick={() => setOpen((o) => !o)}> - <Text className={classes.primary}> - {value[0]} - {value.length > 1 && ( - <FontAwesomeIcon - icon={faCaretDown} - rotation={open ? 180 : undefined} - ></FontAwesomeIcon> - )} - </Text> - <Collapse in={open}> - <>{items}</> - </Collapse> - </Stack> - ); + header: "Release", + accessorKey: "release_info", + cell: ({ + row: { + original: { release_info: releaseInfo }, + }, + }) => { + return <ReleaseInfoCell releaseInfo={releaseInfo} />; }, }, { - Header: "Uploader", - accessor: "uploader", - Cell: ({ value }) => { - const { classes } = useTableStyles(); - return <Text className={classes.noWrap}>{value ?? "-"}</Text>; + header: "Uploader", + accessorKey: "uploader", + cell: ({ + row: { + original: { uploader }, + }, + }) => { + return <Text className="table-no-wrap">{uploader ?? "-"}</Text>; }, }, { - Header: "Match", - accessor: "matches", - Cell: (row) => { + header: "Match", + accessorKey: "matches", + cell: (row) => { const { matches, dont_matches: dont } = row.row.original; return ( <StateIcon @@ -160,16 +181,15 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { }, }, { - Header: "Get", - accessor: "subtitle", - Cell: ({ row }) => { + header: "Get", + accessorKey: "subtitle", + cell: ({ row }) => { const result = row.original; return ( <Action label="Download" icon={faDownload} - color="brand" - variant="light" + c="brand" disabled={item === null} onClick={() => { if (!item) return; @@ -187,7 +207,7 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { }, }, ], - [download, item], + [download, item, ReleaseInfoCell], ); const bSceneNameAvailable = diff --git a/frontend/src/components/modals/SubtitleToolsModal.tsx b/frontend/src/components/modals/SubtitleToolsModal.tsx index 2ba99ec73..dca20d159 100644 --- a/frontend/src/components/modals/SubtitleToolsModal.tsx +++ b/frontend/src/components/modals/SubtitleToolsModal.tsx @@ -1,12 +1,19 @@ +import { FunctionComponent, useMemo, useState } from "react"; +import { + Badge, + Button, + Checkbox, + Divider, + Group, + Stack, + Text, +} from "@mantine/core"; +import { ColumnDef } from "@tanstack/react-table"; import Language from "@/components/bazarr/Language"; import SubtitleToolsMenu from "@/components/SubtitleToolsMenu"; -import { SimpleTable } from "@/components/tables"; -import { useCustomSelection } from "@/components/tables/plugins"; +import SimpleTable from "@/components/tables/SimpleTable"; import { withModal } from "@/modules/modals"; import { isMovie } from "@/utilities"; -import { Badge, Button, Divider, Group, Stack, Text } from "@mantine/core"; -import { FunctionComponent, useMemo, useState } from "react"; -import { Column, useRowSelect } from "react-table"; type SupportType = Item.Episode | Item.Movie; @@ -35,24 +42,53 @@ const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({ }) => { const [selections, setSelections] = useState<TableColumnType[]>([]); - const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>( + const columns = useMemo<ColumnDef<TableColumnType>[]>( () => [ { - Header: "Language", - accessor: "raw_language", - Cell: ({ value }) => ( + id: "selection", + header: ({ table }) => { + return ( + <Checkbox + id="table-header-selection" + indeterminate={table.getIsSomeRowsSelected()} + checked={table.getIsAllRowsSelected()} + onChange={table.getToggleAllRowsSelectedHandler()} + ></Checkbox> + ); + }, + cell: ({ row: { index, getIsSelected, getToggleSelectedHandler } }) => { + return ( + <Checkbox + id={`table-cell-${index}`} + checked={getIsSelected()} + onChange={getToggleSelectedHandler()} + onClick={getToggleSelectedHandler()} + ></Checkbox> + ); + }, + }, + { + header: "Language", + accessorKey: "raw_language", + cell: ({ + row: { + original: { raw_language: rawLanguage }, + }, + }) => ( <Badge color="secondary"> - <Language.Text value={value} long></Language.Text> + <Language.Text value={rawLanguage} long></Language.Text> </Badge> ), }, { id: "file", - Header: "File", - accessor: "path", - Cell: ({ value }) => { - const path = value; - + header: "File", + accessorKey: "path", + cell: ({ + row: { + original: { path }, + }, + }) => { let idx = path.lastIndexOf("/"); if (idx === -1) { @@ -94,16 +130,15 @@ const SubtitleToolView: FunctionComponent<SubtitleToolViewProps> = ({ [payload], ); - const plugins = [useRowSelect, useCustomSelection]; - return ( <Stack> <SimpleTable tableStyles={{ emptyText: "No external subtitles found" }} - plugins={plugins} + enableRowSelection={(row) => CanSelectSubtitle(row.original)} + onRowSelectionChanged={(rows) => + setSelections(rows.map((r) => r.original)) + } columns={columns} - onSelect={setSelections} - canSelect={CanSelectSubtitle} data={data} ></SimpleTable> <Divider></Divider> diff --git a/frontend/src/components/tables/BaseTable.module.scss b/frontend/src/components/tables/BaseTable.module.scss new file mode 100644 index 000000000..e1e1eff0b --- /dev/null +++ b/frontend/src/components/tables/BaseTable.module.scss @@ -0,0 +1,9 @@ +.container { + display: block; + max-width: 100%; + overflow-x: auto; +} + +.table { + border-collapse: collapse; +} diff --git a/frontend/src/components/tables/BaseTable.tsx b/frontend/src/components/tables/BaseTable.tsx index 6ec49e61a..b5a867b14 100644 --- a/frontend/src/components/tables/BaseTable.tsx +++ b/frontend/src/components/tables/BaseTable.tsx @@ -1,10 +1,17 @@ +import React, { ReactNode, useMemo } from "react"; +import { Box, Skeleton, Table, Text } from "@mantine/core"; +import { + flexRender, + Header, + Row, + Table as TableInstance, +} from "@tanstack/react-table"; import { useIsLoading } from "@/contexts"; import { usePageSize } from "@/utilities/storage"; -import { Box, createStyles, Skeleton, Table, Text } from "@mantine/core"; -import { ReactNode, useMemo } from "react"; -import { HeaderGroup, Row, TableInstance } from "react-table"; +import styles from "@/components/tables/BaseTable.module.scss"; -export type BaseTableProps<T extends object> = TableInstance<T> & { +export type BaseTableProps<T extends object> = { + instance: TableInstance<T>; tableStyles?: TableStyleProps<T>; }; @@ -14,116 +21,92 @@ export interface TableStyleProps<T extends object> { placeholder?: number; hideHeader?: boolean; fixHeader?: boolean; - headersRenderer?: (headers: HeaderGroup<T>[]) => JSX.Element[]; - rowRenderer?: (row: Row<T>) => Nullable<JSX.Element>; + headersRenderer?: (headers: Header<T, unknown>[]) => React.JSX.Element[]; + rowRenderer?: (row: Row<T>) => Nullable<React.JSX.Element>; } -const useStyles = createStyles((theme) => { - return { - container: { - display: "block", - maxWidth: "100%", - overflowX: "auto", - }, - table: { - borderCollapse: "collapse", - }, - header: {}, - }; -}); - function DefaultHeaderRenderer<T extends object>( - headers: HeaderGroup<T>[], -): JSX.Element[] { - return headers.map((col) => ( - <th style={{ whiteSpace: "nowrap" }} {...col.getHeaderProps()}> - {col.render("Header")} - </th> + headers: Header<T, unknown>[], +): React.JSX.Element[] { + return headers.map((header) => ( + <Table.Th style={{ whiteSpace: "nowrap" }} key={header.id}> + {flexRender(header.column.columnDef.header, header.getContext())} + </Table.Th> )); } -function DefaultRowRenderer<T extends object>(row: Row<T>): JSX.Element | null { +function DefaultRowRenderer<T extends object>( + row: Row<T>, +): React.JSX.Element | null { return ( - <tr {...row.getRowProps()}> - {row.cells.map((cell) => ( - <td {...cell.getCellProps()}>{cell.render("Cell")}</td> + <Table.Tr key={row.id}> + {row.getVisibleCells().map((cell) => ( + <Table.Td key={cell.id}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </Table.Td> ))} - </tr> + </Table.Tr> ); } export default function BaseTable<T extends object>(props: BaseTableProps<T>) { - const { - headerGroups, - rows: tableRows, - page: tablePages, - prepareRow, - getTableProps, - getTableBodyProps, - tableStyles, - } = props; + const { instance, tableStyles } = props; const headersRenderer = tableStyles?.headersRenderer ?? DefaultHeaderRenderer; const rowRenderer = tableStyles?.rowRenderer ?? DefaultRowRenderer; - const { classes } = useStyles(); - const colCount = useMemo(() => { - return headerGroups.reduce( - (prev, curr) => (curr.headers.length > prev ? curr.headers.length : prev), - 0, - ); - }, [headerGroups]); + return instance + .getHeaderGroups() + .reduce( + (prev, curr) => + curr.headers.length > prev ? curr.headers.length : prev, + 0, + ); + }, [instance]); - // Switch to usePagination plugin if enabled - const rows = tablePages ?? tableRows; - - const empty = rows.length === 0; + const empty = instance.getRowCount() === 0; const pageSize = usePageSize(); const isLoading = useIsLoading(); let body: ReactNode; + if (isLoading) { body = Array(tableStyles?.placeholder ?? pageSize) .fill(0) .map((_, i) => ( - <tr key={i}> - <td colSpan={colCount}> + <Table.Tr key={i}> + <Table.Td colSpan={colCount}> <Skeleton height={24}></Skeleton> - </td> - </tr> + </Table.Td> + </Table.Tr> )); } else if (empty && tableStyles?.emptyText) { body = ( - <tr> - <td colSpan={colCount}> - <Text align="center">{tableStyles.emptyText}</Text> - </td> - </tr> + <Table.Tr> + <Table.Td colSpan={colCount}> + <Text ta="center">{tableStyles.emptyText}</Text> + </Table.Td> + </Table.Tr> ); } else { - body = rows.map((row) => { - prepareRow(row); + body = instance.getRowModel().rows.map((row) => { return rowRenderer(row); }); } return ( - <Box className={classes.container}> - <Table - className={classes.table} - striped={tableStyles?.striped ?? true} - {...getTableProps()} - > - <thead className={classes.header} hidden={tableStyles?.hideHeader}> - {headerGroups.map((headerGroup) => ( - <tr {...headerGroup.getHeaderGroupProps()}> + <Box className={styles.container}> + <Table className={styles.table} striped={tableStyles?.striped ?? true}> + <Table.Thead hidden={tableStyles?.hideHeader}> + {instance.getHeaderGroups().map((headerGroup) => ( + <Table.Tr key={headerGroup.id}> {headersRenderer(headerGroup.headers)} - </tr> + </Table.Tr> ))} - </thead> - <tbody {...getTableBodyProps()}>{body}</tbody> + </Table.Thead> + <Table.Tbody>{body}</Table.Tbody> </Table> </Box> ); diff --git a/frontend/src/components/tables/GroupTable.tsx b/frontend/src/components/tables/GroupTable.tsx index 3a8be3d1b..b14edf3e6 100644 --- a/frontend/src/components/tables/GroupTable.tsx +++ b/frontend/src/components/tables/GroupTable.tsx @@ -1,38 +1,44 @@ +import React, { Fragment } from "react"; +import { Box, Table, Text } from "@mantine/core"; import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Box, Text } from "@mantine/core"; import { Cell, - HeaderGroup, + flexRender, + getExpandedRowModel, + getGroupedRowModel, + Header, Row, - useExpanded, - useGroupBy, - useSortBy, -} from "react-table"; -import SimpleTable, { SimpleTableProps } from "./SimpleTable"; +} from "@tanstack/react-table"; +import SimpleTable, { SimpleTableProps } from "@/components/tables/SimpleTable"; -function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) { - if (cell.isGrouped) { +function renderCell<T extends object = object>( + cell: Cell<T, unknown>, + row: Row<T>, +) { + if (cell.getIsGrouped()) { return ( - <div {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</div> + <div>{flexRender(cell.column.columnDef.cell, cell.getContext())}</div> ); - } else if (row.canExpand || cell.isAggregated) { + } else if (row.getCanExpand() || cell.getIsAggregated()) { return null; } else { - return cell.render("Cell"); + return flexRender(cell.column.columnDef.cell, cell.getContext()); } } function renderRow<T extends object>(row: Row<T>) { - if (row.canExpand) { - const cell = row.cells.find((cell) => cell.isGrouped); + if (row.getCanExpand()) { + const cell = row.getVisibleCells().find((cell) => cell.getIsGrouped()); + if (cell) { - const rotation = row.isExpanded ? 90 : undefined; + const rotation = row.getIsExpanded() ? 90 : undefined; + return ( - <tr {...row.getRowProps()}> - <td {...cell.getCellProps()} colSpan={row.cells.length}> - <Text {...row.getToggleRowExpandedProps()} p={2}> - {cell.render("Cell")} + <Table.Tr key={row.id} style={{ cursor: "pointer" }}> + <Table.Td key={cell.id} colSpan={row.getVisibleCells().length}> + <Text p={2} onClick={() => row.toggleExpanded()}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} <Box component="span" mx={12}> <FontAwesomeIcon icon={faChevronCircleRight} @@ -40,45 +46,55 @@ function renderRow<T extends object>(row: Row<T>) { ></FontAwesomeIcon> </Box> </Text> - </td> - </tr> + </Table.Td> + </Table.Tr> ); } else { return null; } } else { return ( - <tr {...row.getRowProps()}> - {row.cells - .filter((cell) => !cell.isPlaceholder) + <Table.Tr key={row.id}> + {row + .getVisibleCells() + .filter((cell) => !cell.getIsPlaceholder()) .map((cell) => ( - <td {...cell.getCellProps()}>{renderCell(cell, row)}</td> + <Table.Td key={cell.id}>{renderCell(cell, row)}</Table.Td> ))} - </tr> + </Table.Tr> ); } } function renderHeaders<T extends object>( - headers: HeaderGroup<T>[], -): JSX.Element[] { - return headers - .filter((col) => !col.isGrouped) - .map((col) => <th {...col.getHeaderProps()}>{col.render("Header")}</th>); + headers: Header<T, unknown>[], +): React.JSX.Element[] { + return headers.map((header) => { + if (header.column.getIsGrouped()) { + return <Fragment key={header.id}></Fragment>; + } + + return ( + <Table.Th key={header.id} colSpan={header.colSpan}> + {flexRender(header.column.columnDef.header, header.getContext())} + </Table.Th> + ); + }); } type Props<T extends object> = Omit< SimpleTableProps<T>, - "plugins" | "headersRenderer" | "rowRenderer" + "headersRenderer" | "rowRenderer" >; -const plugins = [useGroupBy, useSortBy, useExpanded]; - function GroupTable<T extends object = object>(props: Props<T>) { return ( <SimpleTable {...props} - plugins={plugins} + enableGrouping + enableExpanding + getGroupedRowModel={getGroupedRowModel()} + getExpandedRowModel={getExpandedRowModel()} tableStyles={{ headersRenderer: renderHeaders, rowRenderer: renderRow }} ></SimpleTable> ); diff --git a/frontend/src/components/tables/PageControl.tsx b/frontend/src/components/tables/PageControl.tsx index 0767593de..bcdf290e3 100644 --- a/frontend/src/components/tables/PageControl.tsx +++ b/frontend/src/components/tables/PageControl.tsx @@ -1,6 +1,7 @@ -import { useIsLoading } from "@/contexts"; -import { Group, Pagination, Text } from "@mantine/core"; import { FunctionComponent, useEffect } from "react"; +import { Group, Pagination, Text } from "@mantine/core"; +import { useIsLoading } from "@/contexts"; + interface Props { count: number; index: number; @@ -28,7 +29,7 @@ const PageControl: FunctionComponent<Props> = ({ }, [total, goto]); return ( - <Group p={16} position="apart"> + <Group p={16} justify="space-between"> <Text size="sm"> Show {start} to {end} of {total} entries </Text> diff --git a/frontend/src/components/tables/PageTable.tsx b/frontend/src/components/tables/PageTable.tsx index 4f64fe7b8..476ff2c2b 100644 --- a/frontend/src/components/tables/PageTable.tsx +++ b/frontend/src/components/tables/PageTable.tsx @@ -1,55 +1,62 @@ +import { MutableRefObject, useEffect } from "react"; +import { + getCoreRowModel, + getPaginationRowModel, + Table, + TableOptions, + useReactTable, +} from "@tanstack/react-table"; +import BaseTable, { TableStyleProps } from "@/components/tables/BaseTable"; import { ScrollToTop } from "@/utilities"; import { usePageSize } from "@/utilities/storage"; -import { useEffect } from "react"; -import { usePagination, useTable } from "react-table"; -import BaseTable from "./BaseTable"; import PageControl from "./PageControl"; -import { SimpleTableProps } from "./SimpleTable"; -import { useDefaultSettings } from "./plugins"; -type Props<T extends object> = SimpleTableProps<T> & { +type Props<T extends object> = Omit<TableOptions<T>, "getCoreRowModel"> & { + instanceRef?: MutableRefObject<Table<T> | null>; + tableStyles?: TableStyleProps<T>; autoScroll?: boolean; }; -const tablePlugins = [useDefaultSettings, usePagination]; - export default function PageTable<T extends object>(props: Props<T>) { - const { autoScroll = true, plugins, instanceRef, ...options } = props; + const { instanceRef, autoScroll, ...options } = props; - const instance = useTable( - options, - useDefaultSettings, - ...tablePlugins, - ...(plugins ?? []), - ); + const pageSize = usePageSize(); - // use page size as specified in UI settings - instance.state.pageSize = usePageSize(); + const instance = useReactTable({ + ...options, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { + pageSize: pageSize, + }, + }, + }); if (instanceRef) { instanceRef.current = instance; } + const pageIndex = instance.getState().pagination.pageIndex; + // Scroll to top when page is changed useEffect(() => { if (autoScroll) { ScrollToTop(); } - }, [instance.state.pageIndex, autoScroll]); + }, [pageIndex, autoScroll]); + + const state = instance.getState(); return ( <> - <BaseTable - {...options} - {...instance} - plugins={[...tablePlugins, ...(plugins ?? [])]} - ></BaseTable> + <BaseTable {...options} instance={instance}></BaseTable> <PageControl - count={instance.pageCount} - index={instance.state.pageIndex} - size={instance.state.pageSize} - total={instance.rows.length} - goto={instance.gotoPage} + count={instance.getPageCount()} + index={state.pagination.pageIndex} + size={pageSize} + total={instance.getRowCount()} + goto={instance.setPageIndex} ></PageControl> </> ); diff --git a/frontend/src/components/tables/QueryPageTable.tsx b/frontend/src/components/tables/QueryPageTable.tsx index 81eccee13..797d7a08e 100644 --- a/frontend/src/components/tables/QueryPageTable.tsx +++ b/frontend/src/components/tables/QueryPageTable.tsx @@ -1,9 +1,9 @@ +import { useEffect } from "react"; import { UsePaginationQueryResult } from "@/apis/queries/hooks"; +import SimpleTable, { SimpleTableProps } from "@/components/tables/SimpleTable"; import { LoadingProvider } from "@/contexts"; import { ScrollToTop } from "@/utilities"; -import { useEffect } from "react"; import PageControl from "./PageControl"; -import SimpleTable, { SimpleTableProps } from "./SimpleTable"; type Props<T extends object> = Omit<SimpleTableProps<T>, "data"> & { query: UsePaginationQueryResult<T>; diff --git a/frontend/src/components/tables/SimpleTable.tsx b/frontend/src/components/tables/SimpleTable.tsx index 90f76c7f2..e3e0b7ff3 100644 --- a/frontend/src/components/tables/SimpleTable.tsx +++ b/frontend/src/components/tables/SimpleTable.tsx @@ -1,23 +1,65 @@ -import { PluginHook, TableInstance, TableOptions, useTable } from "react-table"; -import BaseTable, { TableStyleProps } from "./BaseTable"; -import { useDefaultSettings } from "./plugins"; +import { MutableRefObject, useEffect, useMemo } from "react"; +import { + getCoreRowModel, + Row, + Table, + TableOptions, + useReactTable, +} from "@tanstack/react-table"; +import BaseTable, { TableStyleProps } from "@/components/tables/BaseTable"; +import { usePageSize } from "@/utilities/storage"; -export type SimpleTableProps<T extends object> = TableOptions<T> & { - plugins?: PluginHook<T>[]; - instanceRef?: React.MutableRefObject<TableInstance<T> | null>; +export type SimpleTableProps<T extends object> = Omit< + TableOptions<T>, + "getCoreRowModel" +> & { + instanceRef?: MutableRefObject<Table<T> | null>; tableStyles?: TableStyleProps<T>; + onRowSelectionChanged?: (selectedRows: Row<T>[]) => void; + onAllRowsExpandedChanged?: (isAllRowsExpanded: boolean) => void; }; export default function SimpleTable<T extends object>( props: SimpleTableProps<T>, ) { - const { plugins, instanceRef, tableStyles, ...options } = props; + const { + instanceRef, + tableStyles, + onRowSelectionChanged, + onAllRowsExpandedChanged, + ...options + } = props; - const instance = useTable(options, useDefaultSettings, ...(plugins ?? [])); + const pageSize = usePageSize(); + + const instance = useReactTable({ + ...options, + getCoreRowModel: getCoreRowModel(), + autoResetPageIndex: false, + autoResetExpanded: false, + pageCount: pageSize, + }); if (instanceRef) { instanceRef.current = instance; } - return <BaseTable tableStyles={tableStyles} {...instance}></BaseTable>; + const selectedRows = instance.getSelectedRowModel().rows; + + const memoizedRows = useMemo(() => selectedRows, [selectedRows]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const memoizedRowSelectionChanged = useMemo(() => onRowSelectionChanged, []); + + const isAllRowsExpanded = instance.getIsAllRowsExpanded(); + + useEffect(() => { + memoizedRowSelectionChanged?.(memoizedRows); + }, [memoizedRowSelectionChanged, memoizedRows]); + + useEffect(() => { + onAllRowsExpandedChanged?.(isAllRowsExpanded); + }, [onAllRowsExpandedChanged, isAllRowsExpanded]); + + return <BaseTable tableStyles={tableStyles} instance={instance}></BaseTable>; } diff --git a/frontend/src/components/tables/plugins/index.ts b/frontend/src/components/tables/plugins/index.ts deleted file mode 100644 index 39490a113..000000000 --- a/frontend/src/components/tables/plugins/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as useCustomSelection } from "./useCustomSelection"; -export { default as useDefaultSettings } from "./useDefaultSettings"; diff --git a/frontend/src/components/tables/plugins/useCustomSelection.tsx b/frontend/src/components/tables/plugins/useCustomSelection.tsx deleted file mode 100644 index d6ea82de4..000000000 --- a/frontend/src/components/tables/plugins/useCustomSelection.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Checkbox as MantineCheckbox } from "@mantine/core"; -import { forwardRef, useEffect, useRef } from "react"; -import { - CellProps, - Column, - ColumnInstance, - HeaderProps, - Hooks, - MetaBase, - TableInstance, - TableToggleCommonProps, - ensurePluginOrder, -} from "react-table"; - -const pluginName = "useCustomSelection"; - -const checkboxId = "---selection---"; - -interface CheckboxProps { - idIn: string; - disabled?: boolean; -} - -const Checkbox = forwardRef< - HTMLInputElement, - TableToggleCommonProps & CheckboxProps ->(({ indeterminate, checked, disabled, idIn, ...rest }, ref) => { - const defaultRef = useRef<HTMLInputElement>(null); - const resolvedRef = ref || defaultRef; - - useEffect(() => { - if (typeof resolvedRef === "object" && resolvedRef.current) { - resolvedRef.current.indeterminate = indeterminate ?? false; - - if (disabled) { - resolvedRef.current.checked = false; - } else { - resolvedRef.current.checked = checked ?? false; - } - } - }, [resolvedRef, indeterminate, checked, disabled]); - - return ( - <MantineCheckbox - key={idIn} - disabled={disabled} - ref={resolvedRef} - {...rest} - ></MantineCheckbox> - ); -}); - -function useCustomSelection<T extends object>(hooks: Hooks<T>) { - hooks.visibleColumns.push(visibleColumns); - hooks.useInstance.push(useInstance); -} - -useCustomSelection.pluginName = pluginName; - -function useInstance<T extends object>(instance: TableInstance<T>) { - const { - plugins, - rows, - onSelect, - canSelect, - state: { selectedRowIds }, - } = instance; - - ensurePluginOrder(plugins, ["useRowSelect"], pluginName); - - useEffect(() => { - // Performance - let items = Object.keys(selectedRowIds).flatMap( - (v) => rows.find((n) => n.id === v)?.original ?? [], - ); - - if (canSelect) { - items = items.filter((v) => canSelect(v)); - } - - onSelect && onSelect(items); - }, [selectedRowIds, onSelect, rows, canSelect]); -} - -function visibleColumns<T extends object>( - columns: ColumnInstance<T>[], - meta: MetaBase<T>, -): Column<T>[] { - const { instance } = meta; - const checkbox: Column<T> = { - id: checkboxId, - Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<T>) => ( - <Checkbox - idIn="table-header-selection" - {...getToggleAllRowsSelectedProps()} - ></Checkbox> - ), - Cell: ({ row }: CellProps<T>) => { - const canSelect = instance.canSelect; - const disabled = (canSelect && !canSelect(row.original)) ?? false; - return ( - <Checkbox - idIn={`table-cell-${row.index}`} - disabled={disabled} - {...row.getToggleRowSelectedProps()} - ></Checkbox> - ); - }, - }; - return [checkbox, ...columns]; -} - -export default useCustomSelection; diff --git a/frontend/src/components/tables/plugins/useDefaultSettings.tsx b/frontend/src/components/tables/plugins/useDefaultSettings.tsx deleted file mode 100644 index c833c9f79..000000000 --- a/frontend/src/components/tables/plugins/useDefaultSettings.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { usePageSize } from "@/utilities/storage"; -import { Hooks, TableOptions } from "react-table"; - -const pluginName = "useLocalSettings"; - -function useDefaultSettings<T extends object>(hooks: Hooks<T>) { - hooks.useOptions.push(useOptions); -} -useDefaultSettings.pluginName = pluginName; - -function useOptions<T extends object>(options: TableOptions<T>) { - const pageSize = usePageSize(); - - if (options.autoResetPage === undefined) { - options.autoResetPage = false; - } - - if (options.autoResetExpanded === undefined) { - options.autoResetExpanded = false; - } - - if (options.initialState === undefined) { - options.initialState = {}; - } - - if (options.initialState.pageSize === undefined) { - options.initialState.pageSize = pageSize; - } - - return options; -} - -export default useDefaultSettings; diff --git a/frontend/src/components/toolbox/Button.tsx b/frontend/src/components/toolbox/Button.tsx index 735ef3ca1..0a1d311e1 100644 --- a/frontend/src/components/toolbox/Button.tsx +++ b/frontend/src/components/toolbox/Button.tsx @@ -1,6 +1,3 @@ -import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Button, ButtonProps, Text } from "@mantine/core"; import { ComponentProps, FunctionComponent, @@ -8,6 +5,9 @@ import { useCallback, useState, } from "react"; +import { Button, ButtonProps, Text } from "@mantine/core"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; type ToolboxButtonProps = Omit<ButtonProps, "color" | "variant" | "leftIcon"> & Omit<ComponentProps<"button">, "ref"> & { @@ -24,7 +24,7 @@ const ToolboxButton: FunctionComponent<ToolboxButtonProps> = ({ <Button color="dark" variant="subtle" - leftIcon={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>} + leftSection={<FontAwesomeIcon icon={icon}></FontAwesomeIcon>} {...props} > <Text size="xs">{children}</Text> diff --git a/frontend/src/components/toolbox/Toolbox.module.scss b/frontend/src/components/toolbox/Toolbox.module.scss new file mode 100644 index 000000000..10529fd27 --- /dev/null +++ b/frontend/src/components/toolbox/Toolbox.module.scss @@ -0,0 +1,9 @@ +.group { + @include light { + background-color: var(--mantine-color-gray-3); + } + + @include dark { + background-color: var(--mantine-color-dark-5); + } +} diff --git a/frontend/src/components/toolbox/index.tsx b/frontend/src/components/toolbox/Toolbox.tsx index 6995e111d..f67ac60b1 100644 --- a/frontend/src/components/toolbox/index.tsx +++ b/frontend/src/components/toolbox/Toolbox.tsx @@ -1,15 +1,7 @@ -import { createStyles, Group } from "@mantine/core"; import { FunctionComponent, PropsWithChildren } from "react"; +import { Group } from "@mantine/core"; import ToolboxButton, { ToolboxMutateButton } from "./Button"; - -const useStyles = createStyles((theme) => ({ - group: { - backgroundColor: - theme.colorScheme === "light" - ? theme.colors.gray[3] - : theme.colors.dark[5], - }, -})); +import styles from "./Toolbox.module.scss"; declare type ToolboxComp = FunctionComponent<PropsWithChildren> & { Button: typeof ToolboxButton; @@ -17,9 +9,8 @@ declare type ToolboxComp = FunctionComponent<PropsWithChildren> & { }; const Toolbox: ToolboxComp = ({ children }) => { - const { classes } = useStyles(); return ( - <Group p={12} position="apart" className={classes.group}> + <Group p={12} justify="space-between" className={styles.group}> {children} </Group> ); |