diff options
42 files changed, 1761 insertions, 1416 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b1064664b..981b8acbd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@mantine/modals": "^7.11.0", "@mantine/notifications": "^7.11.0", "@tanstack/react-query": "^5.40.1", + "@tanstack/react-table": "^8.19.2", "axios": "^1.6.8", "braces": "^3.0.3", "react": "^18.3.1", @@ -39,7 +40,6 @@ "@types/node": "^20.12.6", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@types/react-table": "^7.7.20", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^1.4.0", "@vitest/ui": "^1.2.2", @@ -57,7 +57,6 @@ "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", "pretty-quick": "^4.0.0", - "react-table": "^7.8.0", "recharts": "^2.12.6", "sass": "^1.74.1", "typescript": "^5.4.4", @@ -3381,6 +3380,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.19.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.2.tgz", + "integrity": "sha512-itoSIAkA/Vsg+bjY23FSemcTyPhc5/1YjYyaMsr9QSH/cdbZnQxHVWrpWn0Sp2BWN71qkzR7e5ye8WuMmwyOjg==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.19.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.19.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.19.2.tgz", + "integrity": "sha512-KpRjhgehIhbfH78ARm/GJDXGnpdw4bCg3qas6yjWSi7czJhI/J6pWln7NHtmBkGE9ZbohiiNtLqwGzKmBfixig==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz", @@ -3714,13 +3746,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true + "dev": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, + "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3735,15 +3767,6 @@ "@types/react": "*" } }, - "node_modules/@types/react-table": { - "version": "7.7.20", - "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.20.tgz", - "integrity": "sha512-ahMp4pmjVlnExxNwxyaDrFgmKxSbPwU23sGQw2gJK4EhCvnvmib2s/O/+y1dfV57dXOwpr2plfyBol+vEHbi2w==", - "dev": true, - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -8958,19 +8981,6 @@ } } }, - "node_modules/react-table": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz", - "integrity": "sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.3 || ^17.0.0-0 || ^18.0.0" - } - }, "node_modules/react-textarea-autosize": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 53e9c4c47..f964fb9f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@mantine/modals": "^7.11.0", "@mantine/notifications": "^7.11.0", "@tanstack/react-query": "^5.40.1", + "@tanstack/react-table": "^8.19.2", "axios": "^1.6.8", "braces": "^3.0.3", "react": "^18.3.1", @@ -43,7 +44,6 @@ "@types/node": "^20.12.6", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@types/react-table": "^7.7.20", "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^1.4.0", "@vitest/ui": "^1.2.2", @@ -61,7 +61,6 @@ "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", "pretty-quick": "^4.0.0", - "react-table": "^7.8.0", "recharts": "^2.12.6", "sass": "^1.74.1", "typescript": "^5.4.4", diff --git a/frontend/src/components/forms/MovieUploadForm.tsx b/frontend/src/components/forms/MovieUploadForm.tsx index 7e3df4b33..8e318d7ad 100644 --- a/frontend/src/components/forms/MovieUploadForm.tsx +++ b/frontend/src/components/forms/MovieUploadForm.tsx @@ -1,5 +1,4 @@ import { FunctionComponent, useEffect, useMemo } from "react"; -import { Column } from "react-table"; import { Button, Checkbox, @@ -17,10 +16,11 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { isString } from "lodash"; import { useMovieSubtitleModification } from "@/apis/hooks"; import { Action, Selector } from "@/components/inputs"; -import { SimpleTable } from "@/components/tables"; +import SimpleTable from "@/components/tables/SimpleTable"; import TextPopover from "@/components/TextPopover"; import { useModals, withModal } from "@/modules/modals"; import { task, TaskGroup } from "@/modules/task"; @@ -143,61 +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 c={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 }) => { - return <Text className="table-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 }); }} @@ -206,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 }); }} @@ -220,14 +236,14 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }, }, { - Header: "Language", - accessor: "language", - Cell: ({ row: { original, index }, value }) => { + header: "Language", + accessorKey: "language", + cell: ({ row: { original, index } }) => { return ( <Selector {...languageOptions} className="table-long-break" - value={value} + value={original.language} onChange={(item) => { action.mutate(index, { ...original, language: item }); }} @@ -237,8 +253,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }, { id: "action", - accessor: "file", - Cell: ({ row: { index } }) => { + cell: ({ row: { index } }) => { return ( <Action label="Remove" diff --git a/frontend/src/components/forms/ProfileEditForm.tsx b/frontend/src/components/forms/ProfileEditForm.tsx index e994888d2..75e2f9df7 100644 --- a/frontend/src/components/forms/ProfileEditForm.tsx +++ b/frontend/src/components/forms/ProfileEditForm.tsx @@ -1,5 +1,4 @@ -import { FunctionComponent, useCallback, useMemo } from "react"; -import { Column } from "react-table"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; import { Accordion, Button, @@ -12,8 +11,10 @@ import { } from "@mantine/core"; import { useForm } from "@mantine/form"; import { faTrash } from "@fortawesome/free-solid-svg-icons"; -import { Action, Selector, SelectorOption, SimpleTable } from "@/components"; +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"; @@ -145,76 +146,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], - ); - - return ( - <Selector - {...languageOptions} - className="table-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, @@ -228,8 +241,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({ }, { id: "action", - accessor: "id", - Cell: ({ row }) => { + cell: ({ row }) => { return ( <Action label="Remove" @@ -241,7 +253,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({ }, }, ], - [action, languageOptions], + [action, LanguageCell, SubtitleTypeCell], ); return ( diff --git a/frontend/src/components/forms/SeriesUploadForm.tsx b/frontend/src/components/forms/SeriesUploadForm.tsx index 99a8e8e30..e4482cab4 100644 --- a/frontend/src/components/forms/SeriesUploadForm.tsx +++ b/frontend/src/components/forms/SeriesUploadForm.tsx @@ -1,5 +1,4 @@ import { FunctionComponent, useEffect, useMemo } from "react"; -import { Column } from "react-table"; import { Button, Checkbox, @@ -17,6 +16,7 @@ import { 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, @@ -24,7 +24,7 @@ import { useSubtitleInfos, } from "@/apis/hooks"; import { Action, Selector } from "@/components/inputs"; -import { SimpleTable } from "@/components/tables"; +import SimpleTable from "@/components/tables/SimpleTable"; import TextPopover from "@/components/TextPopover"; import { useModals, withModal } from "@/modules/modals"; import { task, TaskGroup } from "@/modules/task"; @@ -169,61 +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 } }) => { + 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, @@ -236,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, @@ -254,7 +272,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, }, { - Header: ( + header: () => ( <Selector {...languageOptions} value={null} @@ -269,13 +287,13 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }} ></Selector> ), - accessor: "language", - Cell: ({ row: { original, index }, value }) => { + accessorKey: "language", + cell: ({ row: { original, index } }) => { return ( <Selector {...languageOptions} className="table-select" - value={value} + value={original.language} onChange={(item) => { action.mutate(index, { ...original, language: item }); }} @@ -285,17 +303,17 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, { id: "episode", - Header: "Episode", - accessor: "episode", - Cell: ({ value, row }) => { + header: "Episode", + accessorKey: "episode", + cell: ({ row: { original, index } }) => { return ( <Selector {...episodeOptions} searchable className="table-select" - value={value} + value={original.episode} onChange={(item) => { - action.mutate(row.index, { ...row.original, episode: item }); + action.mutate(index, { ...original, episode: item }); }} ></Selector> ); @@ -303,8 +321,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, { id: "action", - accessor: "file", - Cell: ({ row: { index } }) => { + cell: ({ row: { index } }) => { return ( <Action label="Remove" diff --git a/frontend/src/components/modals/HistoryModal.tsx b/frontend/src/components/modals/HistoryModal.tsx index 888f0bafb..88d57ac65 100644 --- a/frontend/src/components/modals/HistoryModal.tsx +++ b/frontend/src/components/modals/HistoryModal.tsx @@ -1,21 +1,21 @@ /* eslint-disable camelcase */ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; 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 { PageTable } from "@/components"; 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"; @@ -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,7 +307,7 @@ const EpisodeHistoryView: FunctionComponent<EpisodeHistoryViewProps> = ({ }, }, ], - [], + [addEpisodeToBlacklist], ); return ( diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx index d29a2deba..81a49f0f3 100644 --- a/frontend/src/components/modals/ManualSearchModal.tsx +++ b/frontend/src/components/modals/ManualSearchModal.tsx @@ -1,5 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; -import { Column } from "react-table"; +import React, { useCallback, useMemo, useState } from "react"; import { Alert, Anchor, @@ -18,10 +17,12 @@ import { } 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 { Action, PageTable } from "@/components"; +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"; @@ -51,23 +52,63 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { 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 }) => { - return <Text className="table-no-wrap">{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 ( @@ -78,11 +119,15 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { }, }, { - Header: "Provider", - accessor: "provider", - Cell: (row) => { - 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 @@ -100,49 +145,31 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) { }, }, { - Header: "Release", - accessor: "release_info", - Cell: ({ value }) => { - 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 c="dimmed">Cannot get release info</Text>; - } - - return ( - <Stack gap={0} onClick={() => setOpen((o) => !o)}> - <Text className="table-primary" span> - {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 }) => { - return <Text className="table-no-wrap">{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 @@ -154,9 +181,9 @@ 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 @@ -180,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 150788b48..dca20d159 100644 --- a/frontend/src/components/modals/SubtitleToolsModal.tsx +++ b/frontend/src/components/modals/SubtitleToolsModal.tsx @@ -1,10 +1,17 @@ import { FunctionComponent, useMemo, useState } from "react"; -import { Column, useRowSelect } from "react-table"; -import { Badge, Button, Divider, Group, Stack, Text } from "@mantine/core"; +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"; @@ -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.tsx b/frontend/src/components/tables/BaseTable.tsx index 53058032d..b5a867b14 100644 --- a/frontend/src/components/tables/BaseTable.tsx +++ b/frontend/src/components/tables/BaseTable.tsx @@ -1,11 +1,17 @@ -import { ReactNode, useMemo } from "react"; -import { HeaderGroup, Row, TableInstance } from "react-table"; +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 styles from "./BaseTable.module.scss"; +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>; }; @@ -15,60 +21,57 @@ 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>; } function DefaultHeaderRenderer<T extends object>( - headers: HeaderGroup<T>[], -): JSX.Element[] { - return headers.map((col) => ( - <Table.Th style={{ whiteSpace: "nowrap" }} {...col.getHeaderProps()}> - {col.render("Header")} + 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 ( - <Table.Tr {...row.getRowProps()}> - {row.cells.map((cell) => ( - <Table.Td {...cell.getCellProps()}>{cell.render("Cell")}</Table.Td> + <Table.Tr key={row.id}> + {row.getVisibleCells().map((cell) => ( + <Table.Td key={cell.id}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </Table.Td> ))} </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 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) @@ -88,27 +91,22 @@ export default function BaseTable<T extends object>(props: BaseTableProps<T>) { </Table.Tr> ); } else { - body = rows.map((row) => { - prepareRow(row); + body = instance.getRowModel().rows.map((row) => { return rowRenderer(row); }); } return ( <Box className={styles.container}> - <Table - className={styles.table} - striped={tableStyles?.striped ?? true} - {...getTableProps()} - > + <Table className={styles.table} striped={tableStyles?.striped ?? true}> <Table.Thead hidden={tableStyles?.hideHeader}> - {headerGroups.map((headerGroup) => ( - <Table.Tr {...headerGroup.getHeaderGroupProps()}> + {instance.getHeaderGroups().map((headerGroup) => ( + <Table.Tr key={headerGroup.id}> {headersRenderer(headerGroup.headers)} </Table.Tr> ))} </Table.Thead> - <Table.Tbody {...getTableBodyProps()}>{body}</Table.Tbody> + <Table.Tbody>{body}</Table.Tbody> </Table> </Box> ); diff --git a/frontend/src/components/tables/GroupTable.tsx b/frontend/src/components/tables/GroupTable.tsx index c05182aa6..b14edf3e6 100644 --- a/frontend/src/components/tables/GroupTable.tsx +++ b/frontend/src/components/tables/GroupTable.tsx @@ -1,38 +1,44 @@ -import { - Cell, - HeaderGroup, - Row, - useExpanded, - useGroupBy, - useSortBy, -} from "react-table"; +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 SimpleTable, { SimpleTableProps } from "./SimpleTable"; +import { + Cell, + flexRender, + getExpandedRowModel, + getGroupedRowModel, + Header, + Row, +} 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 ( - <Table.Tr {...row.getRowProps()}> - <Table.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} @@ -48,13 +54,12 @@ function renderRow<T extends object>(row: Row<T>) { } } else { return ( - <Table.Tr {...row.getRowProps()}> - {row.cells - .filter((cell) => !cell.isPlaceholder) + <Table.Tr key={row.id}> + {row + .getVisibleCells() + .filter((cell) => !cell.getIsPlaceholder()) .map((cell) => ( - <Table.Td {...cell.getCellProps()}> - {renderCell(cell, row)} - </Table.Td> + <Table.Td key={cell.id}>{renderCell(cell, row)}</Table.Td> ))} </Table.Tr> ); @@ -62,27 +67,34 @@ function renderRow<T extends object>(row: Row<T>) { } function renderHeaders<T extends object>( - headers: HeaderGroup<T>[], -): JSX.Element[] { - return headers - .filter((col) => !col.isGrouped) - .map((col) => ( - <Table.Th {...col.getHeaderProps()}>{col.render("Header")}</Table.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/PageTable.tsx b/frontend/src/components/tables/PageTable.tsx index ee4824db6..476ff2c2b 100644 --- a/frontend/src/components/tables/PageTable.tsx +++ b/frontend/src/components/tables/PageTable.tsx @@ -1,55 +1,62 @@ -import { useEffect } from "react"; -import { usePagination, useTable } from "react-table"; +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 BaseTable from "./BaseTable"; import PageControl from "./PageControl"; -import { useDefaultSettings } from "./plugins"; -import { SimpleTableProps } from "./SimpleTable"; -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 c144b3b54..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 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 572926093..000000000 --- a/frontend/src/components/tables/plugins/useCustomSelection.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { forwardRef, useEffect, useRef } from "react"; -import { - CellProps, - Column, - ColumnInstance, - ensurePluginOrder, - HeaderProps, - Hooks, - MetaBase, - TableInstance, - TableToggleCommonProps, -} from "react-table"; -import { Checkbox as MantineCheckbox } from "@mantine/core"; - -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 ac34334b4..000000000 --- a/frontend/src/components/tables/plugins/useDefaultSettings.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Hooks, TableOptions } from "react-table"; -import { usePageSize } from "@/utilities/storage"; - -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/pages/Blacklist/Movies/table.tsx b/frontend/src/pages/Blacklist/Movies/table.tsx index 0ac3bf3a8..00730a850 100644 --- a/frontend/src/pages/Blacklist/Movies/table.tsx +++ b/frontend/src/pages/Blacklist/Movies/table.tsx @@ -1,56 +1,70 @@ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Text } from "@mantine/core"; import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { useMovieDeleteBlacklist } from "@/apis/hooks"; -import { PageTable } from "@/components"; import MutateAction from "@/components/async/MutateAction"; import Language from "@/components/bazarr/Language"; +import PageTable from "@/components/tables/PageTable"; import TextPopover from "@/components/TextPopover"; interface Props { - blacklist: readonly Blacklist.Movie[]; + blacklist: Blacklist.Movie[]; } const Table: FunctionComponent<Props> = ({ blacklist }) => { - const columns = useMemo<Column<Blacklist.Movie>[]>( + const remove = useMovieDeleteBlacklist(); + + const columns = useMemo<ColumnDef<Blacklist.Movie>[]>( () => [ { - Header: "Name", - accessor: "title", - Cell: (row) => { - const target = `/movies/${row.row.original.radarrId}`; + header: "Name", + accessorKey: "title", + cell: ({ + row: { + original: { radarrId }, + }, + }) => { + const target = `/movies/${radarrId}`; return ( <Anchor className="table-primary" component={Link} to={target}> - {row.value} + {radarrId} </Anchor> ); }, }, { - Header: "Language", - accessor: "language", - Cell: ({ value }) => { - if (value) { - return <Language.Text value={value} long></Language.Text>; + header: "Language", + accessorKey: "language", + cell: ({ + row: { + original: { language }, + }, + }) => { + if (language) { + return <Language.Text value={language} long></Language.Text>; } else { return null; } }, }, { - Header: "Provider", - accessor: "provider", + header: "Provider", + accessorKey: "provider", }, { - Header: "Date", - accessor: "timestamp", - Cell: (row) => { - if (row.value) { + header: "Date", + accessorKey: "timestamp", + cell: ({ + row: { + original: { timestamp, parsed_timestamp: parsedTimestamp }, + }, + }) => { + if (timestamp) { return ( - <TextPopover text={row.row.original.parsed_timestamp}> - <Text>{row.value}</Text> + <TextPopover text={parsedTimestamp}> + <Text>{timestamp}</Text> </TextPopover> ); } else { @@ -59,10 +73,12 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => { }, }, { - accessor: "subs_id", - Cell: ({ row, value }) => { - const remove = useMovieDeleteBlacklist(); - + id: "subs_id", + cell: ({ + row: { + original: { subs_id: subsId, provider }, + }, + }) => { return ( <MutateAction label="Remove from Blacklist" @@ -72,9 +88,9 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => { args={() => ({ all: false, form: { - provider: row.original.provider, + provider: provider, // eslint-disable-next-line camelcase - subs_id: value, + subs_id: subsId, }, })} ></MutateAction> @@ -82,7 +98,7 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => { }, }, ], - [], + [remove], ); return ( <PageTable diff --git a/frontend/src/pages/Blacklist/Series/table.tsx b/frontend/src/pages/Blacklist/Series/table.tsx index 655b8d67a..3d67e637d 100644 --- a/frontend/src/pages/Blacklist/Series/table.tsx +++ b/frontend/src/pages/Blacklist/Series/table.tsx @@ -1,63 +1,77 @@ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Text } from "@mantine/core"; import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { useEpisodeDeleteBlacklist } from "@/apis/hooks"; -import { PageTable } from "@/components"; import MutateAction from "@/components/async/MutateAction"; import Language from "@/components/bazarr/Language"; +import PageTable from "@/components/tables/PageTable"; import TextPopover from "@/components/TextPopover"; interface Props { - blacklist: readonly Blacklist.Episode[]; + blacklist: Blacklist.Episode[]; } const Table: FunctionComponent<Props> = ({ blacklist }) => { - const columns = useMemo<Column<Blacklist.Episode>[]>( + const removeFromBlacklist = useEpisodeDeleteBlacklist(); + + const columns = useMemo<ColumnDef<Blacklist.Episode>[]>( () => [ { - Header: "Series", - accessor: "seriesTitle", - Cell: (row) => { - const target = `/series/${row.row.original.sonarrSeriesId}`; + header: "Series", + accessorKey: "seriesTitle", + cell: ({ + row: { + original: { sonarrSeriesId, seriesTitle }, + }, + }) => { + const target = `/series/${sonarrSeriesId}`; return ( <Anchor className="table-primary" component={Link} to={target}> - {row.value} + {seriesTitle} </Anchor> ); }, }, { - Header: "Episode", - accessor: "episode_number", + header: "Episode", + accessorKey: "episode_number", }, { - accessor: "episodeTitle", + id: "episodeTitle", }, { - Header: "Language", - accessor: "language", - Cell: ({ value }) => { - if (value) { - return <Language.Text value={value} long></Language.Text>; + header: "Language", + accessorKey: "language", + cell: ({ + row: { + original: { language }, + }, + }) => { + if (language) { + return <Language.Text value={language} long></Language.Text>; } else { return null; } }, }, { - Header: "Provider", - accessor: "provider", + header: "Provider", + accessorKey: "provider", }, { - Header: "Date", - accessor: "timestamp", - Cell: (row) => { - if (row.value) { + header: "Date", + accessorKey: "timestamp", + cell: ({ + row: { + original: { timestamp, parsed_timestamp: parsedTimestamp }, + }, + }) => { + if (timestamp) { return ( - <TextPopover text={row.row.original.parsed_timestamp}> - <Text>{row.value}</Text> + <TextPopover text={parsedTimestamp}> + <Text>{timestamp}</Text> </TextPopover> ); } else { @@ -66,22 +80,24 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => { }, }, { - accessor: "subs_id", - Cell: ({ row, value }) => { - const remove = useEpisodeDeleteBlacklist(); - + id: "subs_id", + cell: ({ + row: { + original: { subs_id: subsId, provider }, + }, + }) => { return ( <MutateAction label="Remove from Blacklist" noReset icon={faTrash} - mutation={remove} + mutation={removeFromBlacklist} args={() => ({ all: false, form: { - provider: row.original.provider, + provider: provider, // eslint-disable-next-line camelcase - subs_id: value, + subs_id: subsId, }, })} ></MutateAction> @@ -89,7 +105,7 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => { }, }, ], - [], + [removeFromBlacklist], ); return ( <PageTable diff --git a/frontend/src/pages/Episodes/index.tsx b/frontend/src/pages/Episodes/index.tsx index 13c112050..8075e77a1 100644 --- a/frontend/src/pages/Episodes/index.tsx +++ b/frontend/src/pages/Episodes/index.tsx @@ -21,6 +21,7 @@ import { faSync, faWrench, } from "@fortawesome/free-solid-svg-icons"; +import { Table as TableInstance } from "@tanstack/table-core/build/lib/types"; import { useEpisodesBySeriesId, useIsAnyActionRunning, @@ -41,12 +42,6 @@ import { useLanguageProfileBy } from "@/utilities/languages"; import Table from "./table"; const SeriesEpisodesView: FunctionComponent = () => { - const [state, setState] = useState({ - expand: false, - buttonText: "Expand All", - initial: true, - }); - const params = useParams(); const id = Number.parseInt(params.id as string); @@ -102,18 +97,18 @@ const SeriesEpisodesView: FunctionComponent = () => { useDocumentTitle(`${series?.title ?? "Unknown Series"} - Bazarr (Series)`); + const tableRef = useRef<TableInstance<Item.Episode> | null>(null); + + const [isAllRowExpanded, setIsAllRowExpanded] = useState( + tableRef?.current?.getIsAllRowsExpanded(), + ); + const openDropzone = useRef<VoidFunction>(null); if (isNaN(id) || (isFetched && !series)) { return <Navigate to={RouterNames.NotFound}></Navigate>; } - const toggleState = () => { - state.expand - ? setState({ expand: false, buttonText: "Expand All", initial: false }) - : setState({ expand: true, buttonText: "Collapse All", initial: false }); - }; - return ( <Container px={0} fluid> <QueryOverlay result={seriesQuery}> @@ -210,12 +205,14 @@ const SeriesEpisodesView: FunctionComponent = () => { Edit Series </Toolbox.Button> <Toolbox.Button - icon={state.expand ? faCircleChevronRight : faCircleChevronDown} + icon={ + isAllRowExpanded ? faCircleChevronRight : faCircleChevronDown + } onClick={() => { - toggleState(); + tableRef.current?.toggleAllRowsExpanded(); }} > - {state.buttonText} + {isAllRowExpanded ? "Collapse All" : "Expand All"} </Toolbox.Button> </Group> </Toolbox> @@ -223,11 +220,11 @@ const SeriesEpisodesView: FunctionComponent = () => { <ItemOverview item={series ?? null} details={details}></ItemOverview> <QueryOverlay result={episodesQuery}> <Table - expand={state.expand} - initial={state.initial} + ref={tableRef} episodes={episodes ?? null} profile={profile} disabled={hasTask || !series || series.profileId === null} + onAllRowsExpandedChanged={setIsAllRowExpanded} ></Table> </QueryOverlay> </Stack> diff --git a/frontend/src/pages/Episodes/table.tsx b/frontend/src/pages/Episodes/table.tsx index b28e0d7e8..7b8d4494f 100644 --- a/frontend/src/pages/Episodes/table.tsx +++ b/frontend/src/pages/Episodes/table.tsx @@ -1,11 +1,4 @@ -import { - FunctionComponent, - useCallback, - useEffect, - useMemo, - useRef, -} from "react"; -import { Column, TableInstance } from "react-table"; +import React, { forwardRef, useCallback, useEffect, useMemo } from "react"; import { Group, Text } from "@mantine/core"; import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons"; import { @@ -14,6 +7,7 @@ import { faUser, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef, Table as TableInstance } from "@tanstack/react-table"; import { useDownloadEpisodeSubtitles, useEpisodesProvider } from "@/apis/hooks"; import { useShowOnlyDesired } from "@/apis/hooks/site"; import { Action, GroupTable } from "@/components"; @@ -30,219 +24,227 @@ interface Props { episodes: Item.Episode[] | null; disabled?: boolean; profile?: Language.Profile; - expand?: boolean; - initial?: boolean; + onAllRowsExpandedChanged: (isAllRowsExpanded: boolean) => void; } -const Table: FunctionComponent<Props> = ({ - episodes, - profile, - disabled, - expand, - initial, -}) => { - const onlyDesired = useShowOnlyDesired(); - - const profileItems = useProfileItemsToLanguages(profile); - const { mutateAsync } = useDownloadEpisodeSubtitles(); - - const download = useCallback( - (item: Item.Episode, result: SearchResultType) => { - const { - language, - hearing_impaired: hi, - forced, - provider, - subtitle, - original_format: originalFormat, - } = result; - const { sonarrSeriesId: seriesId, sonarrEpisodeId: episodeId } = item; - - return mutateAsync({ - seriesId, - episodeId, - form: { +const Table = forwardRef<TableInstance<Item.Episode> | null, Props>( + ({ episodes, profile, disabled, onAllRowsExpandedChanged }, ref) => { + const onlyDesired = useShowOnlyDesired(); + + const tableRef = + ref as React.MutableRefObject<TableInstance<Item.Episode> | null>; + + const profileItems = useProfileItemsToLanguages(profile); + + const { mutateAsync } = useDownloadEpisodeSubtitles(); + + const modals = useModals(); + + const download = useCallback( + (item: Item.Episode, result: SearchResultType) => { + const { language, - hi, + hearing_impaired: hi, forced, provider, subtitle, - // eslint-disable-next-line camelcase original_format: originalFormat, + } = result; + const { sonarrSeriesId: seriesId, sonarrEpisodeId: episodeId } = item; + + return mutateAsync({ + seriesId, + episodeId, + form: { + language, + hi, + forced, + provider, + subtitle, + // eslint-disable-next-line camelcase + original_format: originalFormat, + }, + }); + }, + [mutateAsync], + ); + + const SubtitlesCell = React.memo( + ({ episode }: { episode: Item.Episode }) => { + const seriesId = episode.sonarrSeriesId; + + const elements = useMemo(() => { + const episodeId = episode.sonarrEpisodeId; + + const missing = episode.missing_subtitles.map((val, idx) => ( + <Subtitle + missing + key={BuildKey(idx, val.code2, "missing")} + seriesId={seriesId} + episodeId={episodeId} + subtitle={val} + ></Subtitle> + )); + + let rawSubtitles = episode.subtitles; + if (onlyDesired) { + rawSubtitles = filterSubtitleBy(rawSubtitles, profileItems); + } + + const subtitles = rawSubtitles.map((val, idx) => ( + <Subtitle + key={BuildKey(idx, val.code2, "valid")} + seriesId={seriesId} + episodeId={episodeId} + subtitle={val} + ></Subtitle> + )); + + return [...missing, ...subtitles]; + }, [episode, seriesId]); + + return ( + <Group gap="xs" wrap="nowrap"> + {elements} + </Group> + ); + }, + ); + + const columns = useMemo<ColumnDef<Item.Episode>[]>( + () => [ + { + id: "monitored", + cell: ({ + row: { + original: { monitored }, + }, + }) => { + return ( + <FontAwesomeIcon + title={monitored ? "monitored" : "unmonitored"} + icon={monitored ? faBookmark : farBookmark} + ></FontAwesomeIcon> + ); + }, }, - }); - }, - [mutateAsync], - ); - - const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>( - () => [ - { - accessor: "monitored", - Cell: (row) => { - return ( - <FontAwesomeIcon - title={row.value ? "monitored" : "unmonitored"} - icon={row.value ? faBookmark : farBookmark} - ></FontAwesomeIcon> - ); + { + header: "", + accessorKey: "season", + cell: ({ + row: { + original: { season }, + }, + }) => { + return <Text span>Season {season}</Text>; + }, }, - }, - { - accessor: "season", - Cell: (row) => { - return <Text span>Season {row.value}</Text>; + { + header: "Episode", + accessorKey: "episode", }, - }, - { - Header: "Episode", - accessor: "episode", - }, - { - Header: "Title", - accessor: "title", - Cell: ({ value, row }) => { - return ( - <TextPopover text={row.original.sceneName}> - <Text className="table-primary">{value}</Text> - </TextPopover> - ); + { + header: "Title", + accessorKey: "title", + cell: ({ + row: { + original: { sceneName, title }, + }, + }) => { + return ( + <TextPopover text={sceneName}> + <Text className="table-primary">{title}</Text> + </TextPopover> + ); + }, }, - }, - { - Header: "Audio", - accessor: "audio_language", - Cell: ({ value }) => <AudioList audios={value}></AudioList>, - }, - { - Header: "Subtitles", - accessor: "missing_subtitles", - Cell: ({ row }) => { - const episode = row.original; - - const seriesId = episode.sonarrSeriesId; - - const elements = useMemo(() => { - const episodeId = episode.sonarrEpisodeId; - - const missing = episode.missing_subtitles.map((val, idx) => ( - <Subtitle - missing - key={BuildKey(idx, val.code2, "missing")} - seriesId={seriesId} - episodeId={episodeId} - subtitle={val} - ></Subtitle> - )); - - let rawSubtitles = episode.subtitles; - if (onlyDesired) { - rawSubtitles = filterSubtitleBy(rawSubtitles, profileItems); - } - - const subtitles = rawSubtitles.map((val, idx) => ( - <Subtitle - key={BuildKey(idx, val.code2, "valid")} - seriesId={seriesId} - episodeId={episodeId} - subtitle={val} - ></Subtitle> - )); - - return [...missing, ...subtitles]; - }, [episode, seriesId]); - - return ( - <Group gap="xs" wrap="nowrap"> - {elements} - </Group> - ); + { + header: "Audio", + accessorKey: "audio_language", + cell: ({ + row: { + original: { audio_language: audioLanguage }, + }, + }) => <AudioList audios={audioLanguage}></AudioList>, }, - }, - { - Header: "Actions", - accessor: "sonarrEpisodeId", - Cell: ({ row }) => { - const modals = useModals(); - return ( - <Group gap="xs" wrap="nowrap"> - <Action - label="Manual Search" - disabled={disabled} - onClick={() => { - modals.openContextModal(EpisodeSearchModal, { - item: row.original, - download, - query: useEpisodesProvider, - }); - }} - icon={faUser} - ></Action> - <Action - label="History" - disabled={disabled} - onClick={() => { - modals.openContextModal( - EpisodeHistoryModal, - { - episode: row.original, - }, - { - title: `History - ${row.original.title}`, - }, - ); - }} - icon={faHistory} - ></Action> - </Group> - ); + { + header: "Subtitles", + accessorKey: "missing_subtitles", + cell: ({ row: { original } }) => { + return <SubtitlesCell episode={original} />; + }, }, - }, - ], - [onlyDesired, profileItems, disabled, download], - ); - - const maxSeason = useMemo( - () => - episodes?.reduce<number>( - (prev, curr) => Math.max(prev, curr.season), - 0, - ) ?? 0, - [episodes], - ); - - const instance = useRef<TableInstance<Item.Episode> | null>(null); - - useEffect(() => { - if (instance.current) { - if (initial) { - // start with all rows collapsed - instance.current.toggleAllRowsExpanded(false); - // expand the last/current season on initial display - instance.current.toggleRowExpanded([`season:${maxSeason}`], true); - } else { - if (expand !== undefined) { - instance.current.toggleAllRowsExpanded(expand); - } - } - } - }, [maxSeason, expand, initial]); - - return ( - <GroupTable - columns={columns} - data={episodes ?? []} - instanceRef={instance} - initialState={{ - sortBy: [ - { id: "season", desc: true }, - { id: "episode", desc: true }, - ], - groupBy: ["season"], - }} - tableStyles={{ emptyText: "No Episode Found For This Series" }} - ></GroupTable> - ); -}; + { + header: "Actions", + cell: ({ row }) => { + return ( + <Group gap="xs" wrap="nowrap"> + <Action + label="Manual Search" + disabled={disabled} + onClick={() => { + modals.openContextModal(EpisodeSearchModal, { + item: row.original, + download, + query: useEpisodesProvider, + }); + }} + icon={faUser} + ></Action> + <Action + label="History" + disabled={disabled} + onClick={() => { + modals.openContextModal( + EpisodeHistoryModal, + { + episode: row.original, + }, + { + title: `History - ${row.original.title}`, + }, + ); + }} + icon={faHistory} + ></Action> + </Group> + ); + }, + }, + ], + [disabled, download, modals, SubtitlesCell], + ); + + const maxSeason = useMemo( + () => + episodes?.reduce<number>( + (prev, curr) => Math.max(prev, curr.season), + 0, + ) ?? 0, + [episodes], + ); + + useEffect(() => { + tableRef?.current?.setExpanded(() => ({ [`season:${maxSeason}`]: true })); + }, [tableRef, maxSeason]); + + return ( + <GroupTable + columns={columns} + data={episodes ?? []} + instanceRef={tableRef} + onAllRowsExpandedChanged={onAllRowsExpandedChanged} + initialState={{ + sorting: [ + { id: "season", desc: true }, + { id: "episode", desc: true }, + ], + grouping: ["season"], + }} + tableStyles={{ emptyText: "No Episode Found For This Series" }} + ></GroupTable> + ); + }, +); export default Table; diff --git a/frontend/src/pages/History/Movies/index.tsx b/frontend/src/pages/History/Movies/index.tsx index 39365e348..92d1aa280 100644 --- a/frontend/src/pages/History/Movies/index.tsx +++ b/frontend/src/pages/History/Movies/index.tsx @@ -1,7 +1,6 @@ /* eslint-disable camelcase */ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Badge, Text } from "@mantine/core"; import { faFileExcel, @@ -9,6 +8,7 @@ import { faRecycle, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useMovieAddBlacklist, useMovieHistoryPagination } from "@/apis/hooks"; import { MutateAction } from "@/components/async"; import { HistoryIcon } from "@/components/bazarr"; @@ -18,32 +18,40 @@ import TextPopover from "@/components/TextPopover"; import HistoryView from "@/pages/views/HistoryView"; const MoviesHistoryView: FunctionComponent = () => { - const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>( + const addToBlacklist = useMovieAddBlacklist(); + + const columns = useMemo<ColumnDef<History.Movie>[]>( () => [ { - accessor: "action", - Cell: (row) => <HistoryIcon action={row.value}></HistoryIcon>, + id: "action", + cell: ({ row }) => ( + <HistoryIcon action={row.original.action}></HistoryIcon> + ), }, { - Header: "Name", - accessor: "title", - Cell: ({ row, value }) => { + header: "Name", + accessorKey: "title", + cell: ({ row }) => { const target = `/movies/${row.original.radarrId}`; return ( <Anchor className="table-primary" component={Link} to={target}> - {value} + {row.original.title} </Anchor> ); }, }, { - 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 { @@ -52,13 +60,13 @@ const MoviesHistoryView: FunctionComponent = () => { }, }, { - Header: "Score", - accessor: "score", + header: "Score", + accessorKey: "score", }, { - Header: "Match", - accessor: "matches", - Cell: (row) => { + header: "Match", + accessorKey: "matches", + cell: (row) => { const { matches, dont_matches: dont } = row.row.original; if (matches.length || dont.length) { return ( @@ -74,13 +82,17 @@ const MoviesHistoryView: FunctionComponent = () => { }, }, { - Header: "Date", - accessor: "timestamp", - Cell: (row) => { - if (row.value) { + header: "Date", + accessorKey: "timestamp", + cell: ({ + row: { + original: { timestamp, parsed_timestamp }, + }, + }) => { + if (timestamp) { return ( - <TextPopover text={row.row.original.parsed_timestamp}> - <Text>{row.value}</Text> + <TextPopover text={parsed_timestamp}> + <Text>{timestamp}</Text> </TextPopover> ); } else { @@ -89,21 +101,29 @@ const MoviesHistoryView: FunctionComponent = () => { }, }, { - Header: "Info", - accessor: "description", - Cell: ({ value }) => { + header: "Info", + accessorKey: "description", + cell: ({ + row: { + original: { description }, + }, + }) => { return ( - <TextPopover text={value}> + <TextPopover text={description}> <FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon> </TextPopover> ); }, }, { - Header: "Upgrade", - accessor: "upgradable", - Cell: (row) => { - if (row.value) { + header: "Upgrade", + accessorKey: "upgradable", + cell: ({ + row: { + original: { upgradable }, + }, + }) => { + if (upgradable) { return ( <TextPopover text="This Subtitle File Is Eligible For An Upgrade."> <FontAwesomeIcon size="sm" icon={faRecycle}></FontAwesomeIcon> @@ -115,20 +135,25 @@ const MoviesHistoryView: FunctionComponent = () => { }, }, { - Header: "Blacklist", - accessor: "blacklisted", - Cell: ({ row, value }) => { - const add = useMovieAddBlacklist(); - const { radarrId, provider, subs_id, language, subtitles_path } = - row.original; + header: "Blacklist", + accessorKey: "blacklisted", + cell: ({ row }) => { + const { + blacklisted, + radarrId, + provider, + subs_id, + language, + subtitles_path, + } = row.original; if (subs_id && provider && language) { return ( <MutateAction label="Add to Blacklist" - disabled={value} + disabled={blacklisted} icon={faFileExcel} - mutation={add} + mutation={addToBlacklist} args={() => ({ id: radarrId, form: { @@ -146,7 +171,7 @@ const MoviesHistoryView: FunctionComponent = () => { }, }, ], - [], + [addToBlacklist], ); const query = useMovieHistoryPagination(); diff --git a/frontend/src/pages/History/Series/index.tsx b/frontend/src/pages/History/Series/index.tsx index 67f18b9d9..a5d75516a 100644 --- a/frontend/src/pages/History/Series/index.tsx +++ b/frontend/src/pages/History/Series/index.tsx @@ -1,7 +1,6 @@ /* eslint-disable camelcase */ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Badge, Text } from "@mantine/core"; import { faFileExcel, @@ -9,6 +8,7 @@ import { faRecycle, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useEpisodeAddBlacklist, useEpisodeHistoryPagination, @@ -21,44 +21,60 @@ import TextPopover from "@/components/TextPopover"; import HistoryView from "@/pages/views/HistoryView"; const SeriesHistoryView: FunctionComponent = () => { - const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>( + const addToBlacklist = useEpisodeAddBlacklist(); + + const columns = useMemo<ColumnDef<History.Episode>[]>( () => [ { - accessor: "action", - Cell: ({ value }) => <HistoryIcon action={value}></HistoryIcon>, + id: "action", + cell: ({ row: { original } }) => ( + <HistoryIcon action={original.action}></HistoryIcon> + ), }, { - Header: "Series", - accessor: "seriesTitle", - Cell: (row) => { - const target = `/series/${row.row.original.sonarrSeriesId}`; + header: "Series", + accessorKey: "seriesTitle", + cell: ({ + row: { + original: { seriesTitle, sonarrSeriesId }, + }, + }) => { + const target = `/series/${sonarrSeriesId}`; return ( <Anchor className="table-primary" component={Link} to={target}> - {row.value} + {seriesTitle} </Anchor> ); }, }, { - Header: "Episode", - accessor: "episode_number", + header: "Episode", + accessorKey: "episode_number", }, { - Header: "Title", - accessor: "episodeTitle", - Cell: ({ value }) => { - return <Text className="table-no-wrap">{value}</Text>; + header: "Title", + accessorKey: "episodeTitle", + cell: ({ + row: { + original: { episodeTitle }, + }, + }) => { + return <Text className="table-no-wrap">{episodeTitle}</Text>; }, }, { - Header: "Language", - accessor: "language", - Cell: ({ value }) => { - if (value) { + header: "Language", + accessorKey: "language", + cell: ({ + row: { + original: { language }, + }, + }) => { + if (language) { return ( <Badge color="secondary"> - <Language.Text value={value} long></Language.Text> + <Language.Text value={language} long></Language.Text> </Badge> ); } else { @@ -67,13 +83,13 @@ const SeriesHistoryView: FunctionComponent = () => { }, }, { - Header: "Score", - accessor: "score", + header: "Score", + accessorKey: "score", }, { - Header: "Match", - accessor: "matches", - Cell: (row) => { + header: "Match", + accessorKey: "matches", + cell: (row) => { const { matches, dont_matches: dont } = row.row.original; if (matches.length || dont.length) { return ( @@ -89,13 +105,17 @@ const SeriesHistoryView: FunctionComponent = () => { }, }, { - Header: "Date", - accessor: "timestamp", - Cell: (row) => { - if (row.value) { + header: "Date", + accessorKey: "timestamp", + cell: ({ + row: { + original: { timestamp, parsed_timestamp }, + }, + }) => { + if (timestamp) { return ( - <TextPopover text={row.row.original.parsed_timestamp}> - <Text>{row.value}</Text> + <TextPopover text={parsed_timestamp}> + <Text>{timestamp}</Text> </TextPopover> ); } else { @@ -104,21 +124,29 @@ const SeriesHistoryView: FunctionComponent = () => { }, }, { - Header: "Info", - accessor: "description", - Cell: ({ row, value }) => { + header: "Info", + accessorKey: "description", + cell: ({ + row: { + original: { description }, + }, + }) => { return ( - <TextPopover text={value}> + <TextPopover text={description}> <FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon> </TextPopover> ); }, }, { - Header: "Upgrade", - accessor: "upgradable", - Cell: (row) => { - if (row.value) { + header: "Upgrade", + accessorKey: "upgradable", + cell: ({ + row: { + original: { upgradable }, + }, + }) => { + if (upgradable) { return ( <TextPopover text="This Subtitle File Is Eligible For An Upgrade."> <FontAwesomeIcon size="sm" icon={faRecycle}></FontAwesomeIcon> @@ -130,9 +158,9 @@ const SeriesHistoryView: FunctionComponent = () => { }, }, { - Header: "Blacklist", - accessor: "blacklisted", - Cell: ({ row, value }) => { + header: "Blacklist", + accessorKey: "blacklisted", + cell: ({ row }) => { const { sonarrEpisodeId, sonarrSeriesId, @@ -140,16 +168,15 @@ const SeriesHistoryView: FunctionComponent = () => { subs_id, language, subtitles_path, + blacklisted, } = row.original; - const add = useEpisodeAddBlacklist(); - if (subs_id && provider && language) { return ( <MutateAction label="Add to Blacklist" - disabled={value} + disabled={blacklisted} icon={faFileExcel} - mutation={add} + mutation={addToBlacklist} args={() => ({ seriesId: sonarrSeriesId, episodeId: sonarrEpisodeId, @@ -168,7 +195,7 @@ const SeriesHistoryView: FunctionComponent = () => { }, }, ], - [], + [addToBlacklist], ); const query = useEpisodeHistoryPagination(); diff --git a/frontend/src/pages/Movies/Details/table.tsx b/frontend/src/pages/Movies/Details/table.tsx index 03593102c..7d0c20a30 100644 --- a/frontend/src/pages/Movies/Details/table.tsx +++ b/frontend/src/pages/Movies/Details/table.tsx @@ -1,13 +1,14 @@ -import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; +import React, { FunctionComponent, useMemo } from "react"; import { Badge, Text, TextProps } from "@mantine/core"; import { faEllipsis, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { isString } from "lodash"; import { useMovieSubtitleModification } from "@/apis/hooks"; import { useShowOnlyDesired } from "@/apis/hooks/site"; -import { Action, SimpleTable } from "@/components"; +import { Action } from "@/components"; import Language from "@/components/bazarr/Language"; import SubtitleToolsMenu from "@/components/SubtitleToolsMenu"; +import SimpleTable from "@/components/tables/SimpleTable"; import { task, TaskGroup } from "@/modules/task"; import { filterSubtitleBy, toPython } from "@/utilities"; import { useProfileItemsToLanguages } from "@/utilities/languages"; @@ -33,35 +34,125 @@ const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => { const profileItems = useProfileItemsToLanguages(profile); - const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>( + const { download, remove } = useMovieSubtitleModification(); + + const CodeCell = React.memo(({ item }: { item: Subtitle }) => { + const { code2, path, hi, forced } = item; + + const selections = useMemo(() => { + const list: FormType.ModifySubtitle[] = []; + + if (path && !isSubtitleMissing(path) && movie !== null) { + list.push({ + type: "movie", + path, + id: movie.radarrId, + language: code2, + forced: toPython(forced), + hi: toPython(hi), + }); + } + + return list; + }, [code2, path, forced, hi]); + + if (movie === null) { + return null; + } + + const { radarrId } = movie; + + if (isSubtitleMissing(path)) { + return ( + <Action + label="Search Subtitle" + icon={faSearch} + disabled={disabled} + onClick={() => { + task.create( + movie.title, + TaskGroup.SearchSubtitle, + download.mutateAsync, + { + radarrId, + form: { + language: code2, + forced, + hi, + }, + }, + ); + }} + ></Action> + ); + } + + return ( + <SubtitleToolsMenu + selections={selections} + onAction={(action) => { + if (action === "delete" && path) { + task.create( + movie.title, + TaskGroup.DeleteSubtitle, + remove.mutateAsync, + { + radarrId, + form: { + language: code2, + forced, + hi, + path, + }, + }, + ); + } else if (action === "search") { + throw new Error("This shouldn't happen, please report the bug"); + } + }} + > + <Action + label="Subtitle Actions" + disabled={isSubtitleTrack(path)} + icon={faEllipsis} + ></Action> + </SubtitleToolsMenu> + ); + }); + + const columns = useMemo<ColumnDef<Subtitle>[]>( () => [ { - Header: "Subtitle Path", - accessor: "path", - Cell: ({ value }) => { + header: "Subtitle Path", + accessorKey: "path", + cell: ({ + row: { + original: { path }, + }, + }) => { const props: TextProps = { className: "table-primary", }; - if (isSubtitleTrack(value)) { + if (isSubtitleTrack(path)) { return ( <Text className="table-primary">Video File Subtitle Track</Text> ); - } else if (isSubtitleMissing(value)) { + } else if (isSubtitleMissing(path)) { return ( <Text {...props} c="dimmed"> - {value} + {path} </Text> ); } else { - return <Text {...props}>{value}</Text>; + return <Text {...props}>{path}</Text>; } }, }, { - Header: "Language", - accessor: "name", - Cell: ({ row }) => { + header: "Language", + accessorKey: "name", + cell: ({ row }) => { if (row.original.path === missingText) { return ( <Badge color="primary"> @@ -78,99 +169,13 @@ const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => { }, }, { - accessor: "code2", - Cell: ({ row }) => { - const { - original: { code2, path, hi, forced }, - } = row; - - const { download, remove } = useMovieSubtitleModification(); - - const selections = useMemo(() => { - const list: FormType.ModifySubtitle[] = []; - - if (path && !isSubtitleMissing(path) && movie !== null) { - list.push({ - type: "movie", - path, - id: movie.radarrId, - language: code2, - forced: toPython(forced), - hi: toPython(hi), - }); - } - - return list; - }, [code2, path, forced, hi]); - - if (movie === null) { - return null; - } - - const { radarrId } = movie; - - if (isSubtitleMissing(path)) { - return ( - <Action - label="Search Subtitle" - icon={faSearch} - disabled={disabled} - onClick={() => { - task.create( - movie.title, - TaskGroup.SearchSubtitle, - download.mutateAsync, - { - radarrId, - form: { - language: code2, - forced, - hi, - }, - }, - ); - }} - ></Action> - ); - } - - return ( - <SubtitleToolsMenu - selections={selections} - onAction={(action) => { - if (action === "delete" && path) { - task.create( - movie.title, - TaskGroup.DeleteSubtitle, - remove.mutateAsync, - { - radarrId, - form: { - language: code2, - forced, - hi, - path, - }, - }, - ); - } else if (action === "search") { - throw new Error( - "This shouldn't happen, please report the bug", - ); - } - }} - > - <Action - label="Subtitle Actions" - disabled={isSubtitleTrack(path)} - icon={faEllipsis} - ></Action> - </SubtitleToolsMenu> - ); + id: "code2", + cell: ({ row: { original } }) => { + return <CodeCell item={original} />; }, }, ], - [movie, disabled], + [CodeCell], ); const data: Subtitle[] = useMemo(() => { diff --git a/frontend/src/pages/Movies/Editor.tsx b/frontend/src/pages/Movies/Editor.tsx index 0c568a5b1..1ec84a52c 100644 --- a/frontend/src/pages/Movies/Editor.tsx +++ b/frontend/src/pages/Movies/Editor.tsx @@ -1,6 +1,7 @@ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; +import { Checkbox } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; +import { ColumnDef } from "@tanstack/react-table"; import { useMovieModification, useMovies } from "@/apis/hooks"; import { QueryOverlay } from "@/components/async"; import { AudioList } from "@/components/bazarr"; @@ -11,32 +12,63 @@ const MovieMassEditor: FunctionComponent = () => { const query = useMovies(); const mutation = useMovieModification(); - const columns = useMemo<Column<Item.Movie>[]>( + useDocumentTitle("Movies - Bazarr (Mass Editor)"); + + const columns = useMemo<ColumnDef<Item.Movie>[]>( () => [ { - Header: "Name", - accessor: "title", + 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: "Name", + accessorKey: "title", }, { - Header: "Audio", - accessor: "audio_language", - Cell: ({ value }) => { - return <AudioList audios={value}></AudioList>; + header: "Audio", + accessorKey: "audio_language", + cell: ({ + row: { + original: { audio_language: audioLanguage }, + }, + }) => { + return <AudioList audios={audioLanguage}></AudioList>; }, }, { - Header: "Languages Profile", - accessor: "profileId", - Cell: ({ value }) => { - return <LanguageProfileName index={value}></LanguageProfileName>; + header: "Languages Profile", + accessorKey: "profileId", + cell: ({ + row: { + original: { profileId }, + }, + }) => { + return <LanguageProfileName index={profileId}></LanguageProfileName>; }, }, ], [], ); - useDocumentTitle("Movies - Bazarr (Mass Editor)"); - return ( <QueryOverlay result={query}> <MassEditor diff --git a/frontend/src/pages/Movies/index.tsx b/frontend/src/pages/Movies/index.tsx index 1cbd14a36..0429e1fdd 100644 --- a/frontend/src/pages/Movies/index.tsx +++ b/frontend/src/pages/Movies/index.tsx @@ -1,11 +1,11 @@ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Badge, Container } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons"; import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useMovieModification, useMoviesPagination } from "@/apis/hooks"; import { Action } from "@/components"; import { AudioList } from "@/components/bazarr"; @@ -17,55 +17,81 @@ import ItemView from "@/pages/views/ItemView"; import { BuildKey } from "@/utilities"; const MovieView: FunctionComponent = () => { + const modifyMovie = useMovieModification(); + + const modals = useModals(); + const query = useMoviesPagination(); - const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>( + const columns = useMemo<ColumnDef<Item.Movie>[]>( () => [ { - accessor: "monitored", - Cell: ({ value }) => ( + id: "monitored", + cell: ({ + row: { + original: { monitored }, + }, + }) => ( <FontAwesomeIcon - title={value ? "monitored" : "unmonitored"} - icon={value ? faBookmark : farBookmark} + title={monitored ? "monitored" : "unmonitored"} + icon={monitored ? faBookmark : farBookmark} ></FontAwesomeIcon> ), }, { - Header: "Name", - accessor: "title", - Cell: ({ row, value }) => { - const target = `/movies/${row.original.radarrId}`; + header: "Name", + accessorKey: "title", + cell: ({ + row: { + original: { title, radarrId }, + }, + }) => { + const target = `/movies/${radarrId}`; return ( <Anchor className="table-primary" component={Link} to={target}> - {value} + {title} </Anchor> ); }, }, { - Header: "Audio", - accessor: "audio_language", - Cell: ({ value }) => { - return <AudioList audios={value}></AudioList>; + header: "Audio", + accessorKey: "audio_language", + cell: ({ + row: { + original: { audio_language: audioLanguage }, + }, + }) => { + return <AudioList audios={audioLanguage}></AudioList>; }, }, { - Header: "Languages Profile", - accessor: "profileId", - Cell: ({ value }) => { + header: "Languages Profile", + accessorKey: "profileId", + cell: ({ + row: { + original: { profileId }, + }, + }) => { return ( - <LanguageProfileName index={value} empty=""></LanguageProfileName> + <LanguageProfileName + index={profileId} + empty="" + ></LanguageProfileName> ); }, }, { - Header: "Missing Subtitles", - accessor: "missing_subtitles", - Cell: (row) => { - const missing = row.value; + header: "Missing Subtitles", + accessorKey: "missing_subtitles", + cell: ({ + row: { + original: { missing_subtitles: missingSubtitles }, + }, + }) => { return ( <> - {missing.map((v) => ( + {missingSubtitles.map((v) => ( <Badge mr="xs" color="yellow" @@ -79,10 +105,8 @@ const MovieView: FunctionComponent = () => { }, }, { - accessor: "radarrId", - Cell: ({ row }) => { - const modals = useModals(); - const mutation = useMovieModification(); + id: "radarrId", + cell: ({ row }) => { return ( <Action label="Edit Movie" @@ -91,7 +115,7 @@ const MovieView: FunctionComponent = () => { modals.openContextModal( ItemEditModal, { - mutation, + mutation: modifyMovie, item: row.original, }, { @@ -105,7 +129,7 @@ const MovieView: FunctionComponent = () => { }, }, ], - [], + [modals, modifyMovie], ); useDocumentTitle("Movies - Bazarr"); diff --git a/frontend/src/pages/Series/Editor.tsx b/frontend/src/pages/Series/Editor.tsx index 239481069..45a277d17 100644 --- a/frontend/src/pages/Series/Editor.tsx +++ b/frontend/src/pages/Series/Editor.tsx @@ -1,6 +1,7 @@ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; +import { Checkbox } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; +import { ColumnDef } from "@tanstack/react-table"; import { useSeries, useSeriesModification } from "@/apis/hooks"; import { QueryOverlay } from "@/components/async"; import { AudioList } from "@/components/bazarr"; @@ -11,24 +12,55 @@ const SeriesMassEditor: FunctionComponent = () => { const query = useSeries(); const mutation = useSeriesModification(); - const columns = useMemo<Column<Item.Series>[]>( + const columns = useMemo<ColumnDef<Item.Series>[]>( () => [ { - Header: "Name", - accessor: "title", + 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: "Name", + accessorKey: "title", }, { - Header: "Audio", - accessor: "audio_language", - Cell: ({ value }) => { - return <AudioList audios={value}></AudioList>; + header: "Audio", + accessorKey: "audio_language", + cell: ({ + row: { + original: { audio_language: audioLanguage }, + }, + }) => { + return <AudioList audios={audioLanguage}></AudioList>; }, }, { - Header: "Languages Profile", - accessor: "profileId", - Cell: ({ value }) => { - return <LanguageProfileName index={value}></LanguageProfileName>; + header: "Languages Profile", + accessorKey: "profileId", + cell: ({ + row: { + original: { profileId }, + }, + }) => { + return <LanguageProfileName index={profileId}></LanguageProfileName>; }, }, ], diff --git a/frontend/src/pages/Series/index.tsx b/frontend/src/pages/Series/index.tsx index 92f63c9ab..229082444 100644 --- a/frontend/src/pages/Series/index.tsx +++ b/frontend/src/pages/Series/index.tsx @@ -1,11 +1,11 @@ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Container, Progress } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons"; import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useSeriesModification, useSeriesPagination } from "@/apis/hooks"; import { Action } from "@/components"; import LanguageProfileName from "@/components/bazarr/LanguageProfile"; @@ -18,42 +18,51 @@ const SeriesView: FunctionComponent = () => { const query = useSeriesPagination(); - const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>( + const modals = useModals(); + + const columns = useMemo<ColumnDef<Item.Series>[]>( () => [ { - accessor: "monitored", - Cell: ({ value }) => ( + id: "monitored", + cell: ({ + row: { + original: { monitored }, + }, + }) => ( <FontAwesomeIcon - title={value ? "monitored" : "unmonitored"} - icon={value ? faBookmark : farBookmark} + title={monitored ? "monitored" : "unmonitored"} + icon={monitored ? faBookmark : farBookmark} ></FontAwesomeIcon> ), }, { - Header: "Name", - accessor: "title", - Cell: ({ row, value }) => { - const target = `/series/${row.original.sonarrSeriesId}`; + header: "Name", + accessorKey: "title", + cell: ({ row: { original } }) => { + const target = `/series/${original.sonarrSeriesId}`; return ( <Anchor className="table-primary" component={Link} to={target}> - {value} + {original.title} </Anchor> ); }, }, { - Header: "Languages Profile", - accessor: "profileId", - Cell: ({ value }) => { + header: "Languages Profile", + accessorKey: "profileId", + cell: ({ row: { original } }) => { return ( - <LanguageProfileName index={value} empty=""></LanguageProfileName> + <LanguageProfileName + index={original.profileId} + empty="" + ></LanguageProfileName> ); }, }, { - Header: "Episodes", - accessor: "episodeFileCount", - Cell: (row) => { + header: "Episodes", + accessorKey: "episodeFileCount", + cell: (row) => { const { episodeFileCount, episodeMissingCount, profileId, title } = row.row.original; let progress = 0; @@ -80,9 +89,8 @@ const SeriesView: FunctionComponent = () => { }, }, { - accessor: "sonarrSeriesId", - Cell: ({ row: { original } }) => { - const modals = useModals(); + id: "sonarrSeriesId", + cell: ({ row: { original } }) => { return ( <Action label="Edit Series" @@ -105,7 +113,7 @@ const SeriesView: FunctionComponent = () => { }, }, ], - [mutation], + [mutation, modals], ); useDocumentTitle("Series - Bazarr"); diff --git a/frontend/src/pages/Settings/Languages/equals.tsx b/frontend/src/pages/Settings/Languages/equals.tsx index dae20a75a..08642f27e 100644 --- a/frontend/src/pages/Settings/Languages/equals.tsx +++ b/frontend/src/pages/Settings/Languages/equals.tsx @@ -1,11 +1,12 @@ import { FunctionComponent, useCallback, useMemo } from "react"; -import { Column } from "react-table"; import { Button, Checkbox } from "@mantine/core"; import { faEquals, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useLanguages } from "@/apis/hooks"; -import { Action, SimpleTable } from "@/components"; +import { Action } from "@/components"; import LanguageSelector from "@/components/bazarr/LanguageSelector"; +import SimpleTable from "@/components/tables/SimpleTable"; import { languageEqualsKey } from "@/pages/Settings/keys"; import { useFormActions } from "@/pages/Settings/utilities/FormValues"; import { useSettingValue } from "@/pages/Settings/utilities/hooks"; @@ -196,22 +197,22 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => { [equals, setEquals], ); - const columns = useMemo<Column<LanguageEqualData>[]>( + const columns = useMemo<ColumnDef<LanguageEqualData>[]>( () => [ { - Header: "Source", + header: "Source", id: "source-lang", - accessor: "source", - Cell: ({ value: { content }, row }) => { + accessorKey: "source", + cell: ({ row: { original, index } }) => { return ( <LanguageSelector enabled - value={content} + value={original.source.content} onChange={(result) => { if (result !== null) { - update(row.index, { - ...row.original, - source: { ...row.original.source, content: result }, + update(index, { + ...original, + source: { ...original.source, content: result }, }); } }} @@ -221,12 +222,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => { }, { id: "source-hi", - accessor: "source", - Cell: ({ value: { hi }, row }) => { + cell: ({ row }) => { return ( <Checkbox label="HI" - checked={hi} + checked={row.original.source.hi} onChange={({ currentTarget: { checked } }) => { update(row.index, { ...row.original, @@ -243,12 +243,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => { }, { id: "source-forced", - accessor: "source", - Cell: ({ value: { forced }, row }) => { + cell: ({ row }) => { return ( <Checkbox label="Forced" - checked={forced} + checked={row.original.source.forced} onChange={({ currentTarget: { checked } }) => { update(row.index, { ...row.original, @@ -265,19 +264,18 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => { }, { id: "equal-icon", - Cell: () => { + cell: () => { return <FontAwesomeIcon icon={faEquals} />; }, }, { - Header: "Target", + header: "Target", id: "target-lang", - accessor: "target", - Cell: ({ value: { content }, row }) => { + cell: ({ row }) => { return ( <LanguageSelector enabled - value={content} + value={row.original.target.content} onChange={(result) => { if (result !== null) { update(row.index, { @@ -292,12 +290,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => { }, { id: "target-hi", - accessor: "target", - Cell: ({ value: { hi }, row }) => { + cell: ({ row }) => { return ( <Checkbox label="HI" - checked={hi} + checked={row.original.target.hi} onChange={({ currentTarget: { checked } }) => { update(row.index, { ...row.original, @@ -314,12 +311,11 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => { }, { id: "target-forced", - accessor: "target", - Cell: ({ value: { forced }, row }) => { + cell: ({ row }) => { return ( <Checkbox label="Forced" - checked={forced} + checked={row.original.target.forced} onChange={({ currentTarget: { checked } }) => { update(row.index, { ...row.original, @@ -336,8 +332,7 @@ const EqualsTable: FunctionComponent<EqualsTableProps> = () => { }, { id: "action", - accessor: "target", - Cell: ({ row }) => { + cell: ({ row }) => { return ( <Action label="Remove" diff --git a/frontend/src/pages/Settings/Languages/table.tsx b/frontend/src/pages/Settings/Languages/table.tsx index c2ed9d968..c32300628 100644 --- a/frontend/src/pages/Settings/Languages/table.tsx +++ b/frontend/src/pages/Settings/Languages/table.tsx @@ -1,13 +1,14 @@ import { FunctionComponent, useCallback, useMemo } from "react"; -import { Column } from "react-table"; import { Badge, Button, Group } from "@mantine/core"; import { faTrash, faWrench } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { cloneDeep } from "lodash"; -import { Action, SimpleTable } from "@/components"; +import { Action } from "@/components"; import { anyCutoff, ProfileEditModal, } from "@/components/forms/ProfileEditForm"; +import SimpleTable from "@/components/tables/SimpleTable"; import { useModals } from "@/modules/modals"; import { languageProfileKey } from "@/pages/Settings/keys"; import { useFormActions } from "@/pages/Settings/utilities/FormValues"; @@ -40,6 +41,7 @@ const Table: FunctionComponent = () => { const updateProfile = useCallback( (profile: Language.Profile) => { const list = [...profiles]; + const idx = list.findIndex((v) => v.profileId === profile.profileId); if (idx !== -1) { @@ -57,18 +59,20 @@ const Table: FunctionComponent = () => { submitProfiles(fn(list)); }); - const columns = useMemo<Column<Language.Profile>[]>( + const columns = useMemo<ColumnDef<Language.Profile>[]>( () => [ { - Header: "Name", - accessor: "name", + header: "Name", + accessorKey: "name", }, { - Header: "Languages", - accessor: "items", - Cell: (row) => { - const items = row.value; - const cutoff = row.row.original.cutoff; + header: "Languages", + accessorKey: "items", + cell: ({ + row: { + original: { items, cutoff }, + }, + }) => { return ( <Group gap="xs" wrap="nowrap"> {items.map((v) => { @@ -82,16 +86,19 @@ const Table: FunctionComponent = () => { }, }, { - Header: "Must contain", - accessor: "mustContain", - Cell: (row) => { - const items = row.value; - if (!items) { + header: "Must contain", + accessorKey: "mustContain", + cell: ({ + row: { + original: { mustContain }, + }, + }) => { + if (!mustContain) { return null; } return ( <> - {items.map((v, idx) => { + {mustContain.map((v, idx) => { return ( <Badge key={BuildKey(idx, v)} color="gray"> {v} @@ -103,16 +110,19 @@ const Table: FunctionComponent = () => { }, }, { - Header: "Must not contain", - accessor: "mustNotContain", - Cell: (row) => { - const items = row.value; - if (!items) { + header: "Must not contain", + accessorKey: "mustNotContain", + cell: ({ + row: { + original: { mustNotContain }, + }, + }) => { + if (!mustNotContain) { return null; } return ( <> - {items.map((v, idx) => { + {mustNotContain.map((v, idx) => { return ( <Badge key={BuildKey(idx, v)} color="gray"> {v} @@ -124,8 +134,8 @@ const Table: FunctionComponent = () => { }, }, { - accessor: "profileId", - Cell: ({ row }) => { + id: "profileId", + cell: ({ row }) => { const profile = row.original; return ( <Group gap="xs" wrap="nowrap"> @@ -160,7 +170,7 @@ const Table: FunctionComponent = () => { return ( <> - <SimpleTable columns={columns} data={profiles}></SimpleTable> + <SimpleTable columns={columns} data={[...profiles]}></SimpleTable> <Button fullWidth disabled={!canAdd} diff --git a/frontend/src/pages/Settings/components/pathMapper.tsx b/frontend/src/pages/Settings/components/pathMapper.tsx index cc525402a..6b2c7baa2 100644 --- a/frontend/src/pages/Settings/components/pathMapper.tsx +++ b/frontend/src/pages/Settings/components/pathMapper.tsx @@ -1,10 +1,11 @@ import { FunctionComponent, useCallback, useMemo } from "react"; -import { Column } from "react-table"; import { Button } from "@mantine/core"; import { faArrowCircleRight, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { capitalize } from "lodash"; -import { Action, FileBrowser, SimpleTable } from "@/components"; +import { Action, FileBrowser } from "@/components"; +import SimpleTable from "@/components/tables/SimpleTable"; import { moviesEnabledKey, pathMappingsKey, @@ -78,16 +79,16 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => { updateRow(fn(data)); }); - const columns = useMemo<Column<PathMappingItem>[]>( + const columns = useMemo<ColumnDef<PathMappingItem>[]>( () => [ { - Header: capitalize(type), - accessor: "from", - Cell: ({ value, row: { original, index } }) => { + header: capitalize(type), + accessorKey: "from", + cell: ({ row: { original, index } }) => { return ( <FileBrowser type={type} - defaultValue={value} + defaultValue={original.from} onChange={(path) => { action.mutate(index, { ...original, from: path }); }} @@ -97,17 +98,17 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => { }, { id: "arrow", - Cell: () => ( + cell: () => ( <FontAwesomeIcon icon={faArrowCircleRight}></FontAwesomeIcon> ), }, { - Header: "Bazarr", - accessor: "to", - Cell: ({ value, row: { original, index } }) => { + header: "Bazarr", + accessorKey: "to", + cell: ({ row: { original, index } }) => { return ( <FileBrowser - defaultValue={value} + defaultValue={original.to} type="bazarr" onChange={(path) => { action.mutate(index, { ...original, to: path }); @@ -118,8 +119,8 @@ export const PathMappingTable: FunctionComponent<TableProps> = ({ type }) => { }, { id: "action", - accessor: "to", - Cell: ({ row: { index } }) => { + accessorKey: "to", + cell: ({ row: { index } }) => { return ( <Action label="Remove" diff --git a/frontend/src/pages/System/Announcements/table.tsx b/frontend/src/pages/System/Announcements/table.tsx index 7c2ddf127..febb32fa1 100644 --- a/frontend/src/pages/System/Announcements/table.tsx +++ b/frontend/src/pages/System/Announcements/table.tsx @@ -1,65 +1,82 @@ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; import { Anchor, Text } from "@mantine/core"; import { faWindowClose } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { useSystemAnnouncementsAddDismiss } from "@/apis/hooks"; -import { SimpleTable } from "@/components"; import { MutateAction } from "@/components/async"; +import SimpleTable from "@/components/tables/SimpleTable"; interface Props { - announcements: readonly System.Announcements[]; + announcements: System.Announcements[]; } const Table: FunctionComponent<Props> = ({ announcements }) => { - const columns: Column<System.Announcements>[] = useMemo< - Column<System.Announcements>[] + const addDismiss = useSystemAnnouncementsAddDismiss(); + + const columns: ColumnDef<System.Announcements>[] = useMemo< + ColumnDef<System.Announcements>[] >( () => [ { - Header: "Since", + header: "Since", accessor: "timestamp", - Cell: ({ value }) => { - return <Text className="table-primary">{value}</Text>; + cell: ({ + row: { + original: { timestamp }, + }, + }) => { + return <Text className="table-primary">{timestamp}</Text>; }, }, { - Header: "Announcement", + header: "Announcement", accessor: "text", - Cell: ({ value }) => { - return <Text className="table-primary">{value}</Text>; + cell: ({ + row: { + original: { text }, + }, + }) => { + return <Text className="table-primary">{text}</Text>; }, }, { - Header: "More Info", + header: "More Info", accessor: "link", - Cell: ({ value }) => { - if (value) { - return <Label link={value}>Link</Label>; + cell: ({ + row: { + original: { link }, + }, + }) => { + if (link) { + return <Label link={link}>Link</Label>; } else { return <Text>n/a</Text>; } }, }, { - Header: "Dismiss", + header: "Dismiss", accessor: "hash", - Cell: ({ row, value }) => { - const add = useSystemAnnouncementsAddDismiss(); + cell: ({ + row: { + original: { dismissible, hash }, + }, + }) => { return ( <MutateAction label="Dismiss announcement" - disabled={!row.original.dismissible} + disabled={!dismissible} icon={faWindowClose} - mutation={add} + mutation={addDismiss} args={() => ({ - hash: value, + hash: hash, })} ></MutateAction> ); }, }, ], - [], + [addDismiss], ); return ( diff --git a/frontend/src/pages/System/Backups/table.tsx b/frontend/src/pages/System/Backups/table.tsx index efd60f78f..5c9a97f1f 100644 --- a/frontend/src/pages/System/Backups/table.tsx +++ b/frontend/src/pages/System/Backups/table.tsx @@ -1,53 +1,74 @@ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; import { Anchor, Text } from "@mantine/core"; import { faHistory, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { useDeleteBackups, useRestoreBackups } from "@/apis/hooks"; -import { Action, PageTable } from "@/components"; +import { Action } from "@/components"; +import PageTable from "@/components/tables/PageTable"; import { useModals } from "@/modules/modals"; import { Environment } from "@/utilities"; interface Props { - backups: readonly System.Backups[]; + backups: System.Backups[]; } const Table: FunctionComponent<Props> = ({ backups }) => { - const columns: Column<System.Backups>[] = useMemo<Column<System.Backups>[]>( + const modals = useModals(); + + const restore = useRestoreBackups(); + + const remove = useDeleteBackups(); + + const columns = useMemo<ColumnDef<System.Backups>[]>( () => [ { - Header: "Name", - accessor: "filename", - Cell: ({ value }) => { + header: "Name", + accessorKey: "filename", + cell: ({ + row: { + original: { filename }, + }, + }) => { return ( <Anchor - href={`${Environment.baseUrl}/system/backup/download/${value}`} + href={`${Environment.baseUrl}/system/backup/download/${filename}`} > - {value} + {filename} </Anchor> ); }, }, { - Header: "Size", - accessor: "size", - Cell: ({ value }) => { - return <Text className="table-no-wrap">{value}</Text>; + header: "Size", + accessorKey: "size", + cell: ({ + row: { + original: { size }, + }, + }) => { + return <Text className="table-no-wrap">{size}</Text>; }, }, { - Header: "Time", - accessor: "date", - Cell: ({ value }) => { - return <Text className="table-no-wrap">{value}</Text>; + header: "Time", + accessorKey: "date", + cell: ({ + row: { + original: { date }, + }, + }) => { + return <Text className="table-no-wrap">{date}</Text>; }, }, { id: "restore", - Header: "Restore", - accessor: "filename", - Cell: ({ value }) => { - const modals = useModals(); - const restore = useRestoreBackups(); + header: "Restore", + accessorKey: "filename", + cell: ({ + row: { + original: { filename }, + }, + }) => { return ( <Action label="Restore" @@ -56,14 +77,14 @@ const Table: FunctionComponent<Props> = ({ backups }) => { title: "Restore Backup", children: ( <Text size="sm"> - Are you sure you want to restore the backup ({value})? + Are you sure you want to restore the backup ({filename})? Bazarr will automatically restart and reload the UI during the restore process. </Text> ), labels: { confirm: "Restore", cancel: "Cancel" }, confirmProps: { color: "red" }, - onConfirm: () => restore.mutate(value), + onConfirm: () => restore.mutate(filename), }) } icon={faHistory} @@ -72,12 +93,14 @@ const Table: FunctionComponent<Props> = ({ backups }) => { }, }, { - id: "delet4", - Header: "Delete", - accessor: "filename", - Cell: ({ value }) => { - const modals = useModals(); - const remove = useDeleteBackups(); + id: "delete", + header: "Delete", + accessorKey: "filename", + cell: ({ + row: { + original: { filename }, + }, + }) => { return ( <Action label="Delete" @@ -87,12 +110,12 @@ const Table: FunctionComponent<Props> = ({ backups }) => { title: "Delete Backup", children: ( <Text size="sm"> - Are you sure you want to delete the backup ({value})? + Are you sure you want to delete the backup ({filename})? </Text> ), labels: { confirm: "Delete", cancel: "Cancel" }, confirmProps: { color: "red" }, - onConfirm: () => remove.mutate(value), + onConfirm: () => remove.mutate(filename), }) } icon={faTrash} @@ -101,7 +124,7 @@ const Table: FunctionComponent<Props> = ({ backups }) => { }, }, ], - [], + [modals, remove, restore], ); return <PageTable columns={columns} data={backups}></PageTable>; diff --git a/frontend/src/pages/System/Logs/table.tsx b/frontend/src/pages/System/Logs/table.tsx index 541a8e285..0b1397c97 100644 --- a/frontend/src/pages/System/Logs/table.tsx +++ b/frontend/src/pages/System/Logs/table.tsx @@ -1,5 +1,4 @@ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { faBug, @@ -10,12 +9,14 @@ import { faQuestion, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Action, PageTable } from "@/components"; +import { ColumnDef } from "@tanstack/react-table"; +import { Action } from "@/components"; +import PageTable from "@/components/tables/PageTable"; import { useModals } from "@/modules/modals"; import SystemLogModal from "./modal"; interface Props { - logs: readonly System.Log[]; + logs: System.Log[]; } function mapTypeToIcon(type: System.LogType): IconDefinition { @@ -34,33 +35,40 @@ function mapTypeToIcon(type: System.LogType): IconDefinition { } const Table: FunctionComponent<Props> = ({ logs }) => { - const columns: Column<System.Log>[] = useMemo<Column<System.Log>[]>( + const modals = useModals(); + + const columns = useMemo<ColumnDef<System.Log>[]>( () => [ { - accessor: "type", - Cell: (row) => ( - <FontAwesomeIcon icon={mapTypeToIcon(row.value)}></FontAwesomeIcon> - ), + accessorKey: "type", + cell: ({ + row: { + original: { type }, + }, + }) => <FontAwesomeIcon icon={mapTypeToIcon(type)}></FontAwesomeIcon>, }, { Header: "Message", - accessor: "message", + accessorKey: "message", }, { Header: "Date", - accessor: "timestamp", + accessorKey: "timestamp", }, { - accessor: "exception", - Cell: ({ value }) => { - const modals = useModals(); - if (value) { + accessorKey: "exception", + cell: ({ + row: { + original: { exception }, + }, + }) => { + if (exception) { return ( <Action label="Detail" icon={faLayerGroup} onClick={() => - modals.openContextModal(SystemLogModal, { stack: value }) + modals.openContextModal(SystemLogModal, { stack: exception }) } ></Action> ); @@ -70,7 +78,7 @@ const Table: FunctionComponent<Props> = ({ logs }) => { }, }, ], - [], + [modals], ); return ( diff --git a/frontend/src/pages/System/Providers/table.tsx b/frontend/src/pages/System/Providers/table.tsx index 0f9856d99..8e3ff7b89 100644 --- a/frontend/src/pages/System/Providers/table.tsx +++ b/frontend/src/pages/System/Providers/table.tsx @@ -1,25 +1,25 @@ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; -import { SimpleTable } from "@/components"; +import { ColumnDef } from "@tanstack/react-table"; +import SimpleTable from "@/components/tables/SimpleTable"; interface Props { - providers: readonly System.Provider[]; + providers: System.Provider[]; } const Table: FunctionComponent<Props> = (props) => { - const columns: Column<System.Provider>[] = useMemo<Column<System.Provider>[]>( + const columns = useMemo<ColumnDef<System.Provider>[]>( () => [ { - Header: "Name", - accessor: "name", + header: "Name", + accessorKey: "name", }, { - Header: "Status", - accessor: "status", + header: "Status", + accessorKey: "status", }, { - Header: "Next Retry", - accessor: "retry", + header: "Next Retry", + accessorKey: "retry", }, ], [], diff --git a/frontend/src/pages/System/Status/table.tsx b/frontend/src/pages/System/Status/table.tsx index a22f4e435..7dad6757f 100644 --- a/frontend/src/pages/System/Status/table.tsx +++ b/frontend/src/pages/System/Status/table.tsx @@ -1,27 +1,35 @@ import { FunctionComponent, useMemo } from "react"; -import { Column } from "react-table"; import { Text } from "@mantine/core"; -import { SimpleTable } from "@/components"; +import { ColumnDef } from "@tanstack/react-table"; +import SimpleTable from "@/components/tables/SimpleTable"; interface Props { - health: readonly System.Health[]; + health: System.Health[]; } const Table: FunctionComponent<Props> = ({ health }) => { - const columns: Column<System.Health>[] = useMemo<Column<System.Health>[]>( + const columns = useMemo<ColumnDef<System.Health>[]>( () => [ { - Header: "Object", - accessor: "object", - Cell: ({ value }) => { - return <Text className="table-no-wrap">{value}</Text>; + header: "Object", + accessorKey: "object", + cell: ({ + row: { + original: { object }, + }, + }) => { + return <Text className="table-no-wrap">{object}</Text>; }, }, { - Header: "Issue", - accessor: "issue", - Cell: ({ value }) => { - return <Text className="table-primary">{value}</Text>; + header: "Issue", + accessorKey: "issue", + cell: ({ + row: { + original: { issue }, + }, + }) => { + return <Text className="table-primary">{issue}</Text>; }, }, ], diff --git a/frontend/src/pages/System/Tasks/table.tsx b/frontend/src/pages/System/Tasks/table.tsx index 548cc7b70..ed3248b6f 100644 --- a/frontend/src/pages/System/Tasks/table.tsx +++ b/frontend/src/pages/System/Tasks/table.tsx @@ -1,48 +1,59 @@ import { FunctionComponent, useMemo } from "react"; -import { Column, useSortBy } from "react-table"; import { Text } from "@mantine/core"; import { faPlay } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef, getSortedRowModel } from "@tanstack/react-table"; import { useRunTask } from "@/apis/hooks"; -import { SimpleTable } from "@/components"; import MutateAction from "@/components/async/MutateAction"; +import SimpleTable from "@/components/tables/SimpleTable"; interface Props { - tasks: readonly System.Task[]; + tasks: System.Task[]; } const Table: FunctionComponent<Props> = ({ tasks }) => { - const columns: Column<System.Task>[] = useMemo<Column<System.Task>[]>( + const runTask = useRunTask(); + + const columns: ColumnDef<System.Task>[] = useMemo<ColumnDef<System.Task>[]>( () => [ { - Header: "Name", + header: "Name", accessor: "name", - Cell: ({ value }) => { - return <Text className="table-primary">{value}</Text>; + cell: ({ + row: { + original: { name }, + }, + }) => { + return <Text className="table-primary">{name}</Text>; }, }, { - Header: "Interval", + header: "Interval", accessor: "interval", - Cell: ({ value }) => { - return <Text className="table-no-wrap">{value}</Text>; + cell: ({ + row: { + original: { interval }, + }, + }) => { + return <Text className="table-no-wrap">{interval}</Text>; }, }, { - Header: "Next Execution", + header: "Next Execution", accessor: "next_run_in", }, { - Header: "Run", + header: "Run", accessor: "job_running", - Cell: ({ row, value }) => { - const { job_id: jobId } = row.original; - const runTask = useRunTask(); - + cell: ({ + row: { + original: { job_id: jobId, job_running: jobRunning }, + }, + }) => { return ( <MutateAction label="Run Job" icon={faPlay} - iconProps={{ spin: value }} + iconProps={{ spin: jobRunning }} mutation={runTask} args={() => jobId} ></MutateAction> @@ -50,15 +61,16 @@ const Table: FunctionComponent<Props> = ({ tasks }) => { }, }, ], - [], + [runTask], ); return ( <SimpleTable - initialState={{ sortBy: [{ id: "name", desc: false }] }} + initialState={{ sorting: [{ id: "name", desc: false }] }} columns={columns} data={tasks} - plugins={[useSortBy]} + enableSorting + getSortedRowModel={getSortedRowModel()} ></SimpleTable> ); }; diff --git a/frontend/src/pages/Wanted/Movies/index.tsx b/frontend/src/pages/Wanted/Movies/index.tsx index 139bf0c46..7c497f799 100644 --- a/frontend/src/pages/Wanted/Movies/index.tsx +++ b/frontend/src/pages/Wanted/Movies/index.tsx @@ -1,9 +1,9 @@ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Badge, Group } from "@mantine/core"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useMovieAction, useMovieSubtitleModification, @@ -15,32 +15,37 @@ import WantedView from "@/pages/views/WantedView"; import { BuildKey } from "@/utilities"; const WantedMoviesView: FunctionComponent = () => { - const columns: Column<Wanted.Movie>[] = useMemo<Column<Wanted.Movie>[]>( + const { download } = useMovieSubtitleModification(); + + const columns = useMemo<ColumnDef<Wanted.Movie>[]>( () => [ { - Header: "Name", + header: "Name", accessor: "title", - Cell: (row) => { - const target = `/movies/${row.row.original.radarrId}`; + cell: ({ + row: { + original: { title, radarrId }, + }, + }) => { + const target = `/movies/${radarrId}`; return ( <Anchor component={Link} to={target}> - {row.value} + {title} </Anchor> ); }, }, { - Header: "Missing", + header: "Missing", accessor: "missing_subtitles", - Cell: ({ row, value }) => { - const wanted = row.original; - const { radarrId } = wanted; - - const { download } = useMovieSubtitleModification(); - + cell: ({ + row: { + original: { radarrId, missing_subtitles: missingSubtitles }, + }, + }) => { return ( <Group gap="sm"> - {value.map((item, idx) => ( + {missingSubtitles.map((item, idx) => ( <Badge color={download.isPending ? "gray" : undefined} leftSection={<FontAwesomeIcon icon={faSearch} />} @@ -70,7 +75,7 @@ const WantedMoviesView: FunctionComponent = () => { }, }, ], - [], + [download], ); const { mutateAsync } = useMovieAction(); diff --git a/frontend/src/pages/Wanted/Series/index.tsx b/frontend/src/pages/Wanted/Series/index.tsx index 082cc7931..0501ecef5 100644 --- a/frontend/src/pages/Wanted/Series/index.tsx +++ b/frontend/src/pages/Wanted/Series/index.tsx @@ -1,9 +1,9 @@ import { FunctionComponent, useMemo } from "react"; import { Link } from "react-router-dom"; -import { Column } from "react-table"; import { Anchor, Badge, Group } from "@mantine/core"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ColumnDef } from "@tanstack/react-table"; import { useEpisodeSubtitleModification, useEpisodeWantedPagination, @@ -15,40 +15,51 @@ import WantedView from "@/pages/views/WantedView"; import { BuildKey } from "@/utilities"; const WantedSeriesView: FunctionComponent = () => { - const columns: Column<Wanted.Episode>[] = useMemo<Column<Wanted.Episode>[]>( + const { download } = useEpisodeSubtitleModification(); + + const columns = useMemo<ColumnDef<Wanted.Episode>[]>( () => [ { - Header: "Name", - accessor: "seriesTitle", - Cell: (row) => { - const target = `/series/${row.row.original.sonarrSeriesId}`; + header: "Name", + accessorKey: "seriesTitle", + cell: ({ + row: { + original: { sonarrSeriesId, seriesTitle }, + }, + }) => { + const target = `/series/${sonarrSeriesId}`; return ( <Anchor className="table-primary" component={Link} to={target}> - {row.value} + {seriesTitle} </Anchor> ); }, }, { - Header: "Episode", - accessor: "episode_number", + header: "Episode", + accessorKey: "episode_number", }, { - accessor: "episodeTitle", + accessorKey: "episodeTitle", }, { - Header: "Missing", - accessor: "missing_subtitles", - Cell: ({ row, value }) => { - const wanted = row.original; - const seriesId = wanted.sonarrSeriesId; - const episodeId = wanted.sonarrEpisodeId; - - const { download } = useEpisodeSubtitleModification(); + header: "Missing", + accessorKey: "missing_subtitles", + cell: ({ + row: { + original: { + sonarrSeriesId, + sonarrEpisodeId, + missing_subtitles: missingSubtitles, + }, + }, + }) => { + const seriesId = sonarrSeriesId; + const episodeId = sonarrEpisodeId; return ( <Group gap="sm"> - {value.map((item, idx) => ( + {missingSubtitles.map((item, idx) => ( <Badge color={download.isPending ? "gray" : undefined} leftSection={<FontAwesomeIcon icon={faSearch} />} @@ -79,7 +90,7 @@ const WantedSeriesView: FunctionComponent = () => { }, }, ], - [], + [download], ); const { mutateAsync } = useSeriesAction(); diff --git a/frontend/src/pages/views/HistoryView.tsx b/frontend/src/pages/views/HistoryView.tsx index 3553f58c6..f9fe8a27f 100644 --- a/frontend/src/pages/views/HistoryView.tsx +++ b/frontend/src/pages/views/HistoryView.tsx @@ -1,13 +1,13 @@ -import { Column } from "react-table"; import { Container } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; +import { ColumnDef } from "@tanstack/react-table"; import { UsePaginationQueryResult } from "@/apis/queries/hooks"; import { QueryPageTable } from "@/components"; interface Props<T extends History.Base> { name: string; query: UsePaginationQueryResult<T>; - columns: Column<T>[]; + columns: ColumnDef<T>[]; } function HistoryView<T extends History.Base = History.Base>({ diff --git a/frontend/src/pages/views/ItemView.tsx b/frontend/src/pages/views/ItemView.tsx index e00d330ee..c4ff250ea 100644 --- a/frontend/src/pages/views/ItemView.tsx +++ b/frontend/src/pages/views/ItemView.tsx @@ -1,12 +1,12 @@ import { useNavigate } from "react-router-dom"; -import { Column } from "react-table"; import { faList } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { UsePaginationQueryResult } from "@/apis/queries/hooks"; import { QueryPageTable, Toolbox } from "@/components"; interface Props<T extends Item.Base = Item.Base> { query: UsePaginationQueryResult<T>; - columns: Column<T>[]; + columns: ColumnDef<T>[]; } function ItemView<T extends Item.Base>({ query, columns }: Props<T>) { diff --git a/frontend/src/pages/views/MassEditor.tsx b/frontend/src/pages/views/MassEditor.tsx index ee9ba476a..48068d1c6 100644 --- a/frontend/src/pages/views/MassEditor.tsx +++ b/frontend/src/pages/views/MassEditor.tsx @@ -1,22 +1,17 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Column, useRowSelect } from "react-table"; import { Box, Container, useCombobox } from "@mantine/core"; import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons"; import { UseMutationResult } from "@tanstack/react-query"; +import { ColumnDef, Table } from "@tanstack/react-table"; import { uniqBy } from "lodash"; import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks"; -import { - GroupedSelector, - GroupedSelectorOptions, - SimpleTable, - Toolbox, -} from "@/components"; -import { useCustomSelection } from "@/components/tables/plugins"; +import { GroupedSelector, GroupedSelectorOptions, Toolbox } from "@/components"; +import SimpleTable from "@/components/tables/SimpleTable"; import { GetItemId, useSelectorOptions } from "@/utilities"; interface MassEditorProps<T extends Item.Base = Item.Base> { - columns: Column<T>[]; + columns: ColumnDef<T>[]; data: T[]; mutation: UseMutationResult<void, unknown, FormType.ModifyItem>; } @@ -28,6 +23,7 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) { const [dirties, setDirties] = useState<T[]>([]); const hasTask = useIsAnyMutationRunning(); const { data: profiles } = useLanguageProfiles(); + const tableRef = useRef<Table<T>>(null); const navigate = useNavigate(); @@ -120,6 +116,8 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) { setDirties((dirty) => { return uniqBy([...newItems, ...dirty], GetItemId); }); + + tableRef.current?.toggleAllRowsSelected(false); }, [selections], ); @@ -163,10 +161,13 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) { </Box> </Toolbox> <SimpleTable + instanceRef={tableRef} columns={columns} data={data} - onSelect={setSelections} - plugins={[useRowSelect, useCustomSelection]} + enableRowSelection + onRowSelectionChanged={(row) => { + setSelections(row.map((r) => r.original)); + }} ></SimpleTable> </Container> ); diff --git a/frontend/src/pages/views/WantedView.tsx b/frontend/src/pages/views/WantedView.tsx index aa71775ac..e04f583b8 100644 --- a/frontend/src/pages/views/WantedView.tsx +++ b/frontend/src/pages/views/WantedView.tsx @@ -1,14 +1,14 @@ -import { Column } from "react-table"; import { Container } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { ColumnDef } from "@tanstack/react-table"; import { useIsAnyActionRunning } from "@/apis/hooks"; import { UsePaginationQueryResult } from "@/apis/queries/hooks"; import { QueryPageTable, Toolbox } from "@/components"; interface Props<T extends Wanted.Base> { name: string; - columns: Column<T>[]; + columns: ColumnDef<T>[]; query: UsePaginationQueryResult<T>; searchAll: () => Promise<void>; } |