feat(filters): import/export functionality (#755)

* initial commit

* import working

* some more changes

* import is working

* added text field for import

* made exported json look pretty

* use filter name as title in export

takes the name of the exported filter and add it as title to the json

wont be used for anything on import

* snake case for title

* visual improvements

* added export function to filter dropdown

* added import to filter list

* include empty values on export

this is needed for the import to work

* styled the add button

* reduced needed values for const defaultFilter

this is the minimum required for successful import

* reduced defaultFilter to bits

* Made export and import require minimum values

added "version": "1.0", to export json

* changed filter name

* made the import textfield dynamic

* incremental numbering for imported filter names

Updated the filter import logic to check for existing filter names and
appending incremental number to the filter name if a conflict is found

* reverted changes in details.tsx

* Improved code comments a bit

* add icon and tooltip to filter.actions_count === 0

* changed the 0-action icon to a red animate-ping

- made the tooltip trigger on both the name and the animate-ping hover
- improved colors a bit

* fixed bg color for textarea

made the focus ring less intrusive
This commit is contained in:
soup 2023-03-19 21:22:07 +01:00 committed by GitHub
parent 603828be9d
commit 4449df66aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 254 additions and 19 deletions

View file

@ -25,8 +25,6 @@ import { APIClient } from "../../api/APIClient";
import { useToggle } from "../../hooks/hooks";
import { classNames } from "../../utils";
import { CustomTooltip } from "../../components/tooltips/CustomTooltip";
import {
CheckboxField,
IndexerMultiSelect,
@ -334,7 +332,6 @@ export function General() {
return (
<div>
<div className="mt-6 lg:pb-8">
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
@ -360,7 +357,6 @@ export function General() {
<div className="border-t dark:border-gray-700">
<SwitchGroup name="enabled" label="Enabled" description="Enable or disable this filter." />
</div>
</div>
);
}

View file

@ -3,15 +3,22 @@ import { Link } from "react-router-dom";
import { toast } from "react-hot-toast";
import { Listbox, Menu, Switch, Transition } from "@headlessui/react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { FormikValues } from "formik";
import { useCallback } from "react";
import { Tooltip } from "react-tooltip";
import {
ArrowsRightLeftIcon,
CheckIcon,
ChevronDownIcon,
PlusIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
TrashIcon
TrashIcon,
ExclamationTriangleIcon
} from "@heroicons/react/24/outline";
import { queryClient } from "../../App";
@ -22,6 +29,7 @@ import { APIClient } from "../../api/APIClient";
import Toast from "../../components/notifications/Toast";
import { EmptyListState } from "../../components/emptystates";
import { DeleteModal } from "../../components/modals";
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
type FilterListState = {
indexerFilter: string[],
@ -71,9 +79,69 @@ const FilterListReducer = (state: FilterListState, action: Actions): FilterListS
}
};
export default function Filters() {
interface FilterProps {
values?: FormikValues;
}
export default function Filters({}: FilterProps){
const queryClient = useQueryClient();
const [createFilterIsOpen, toggleCreateFilter] = useToggle(false);
const [showImportModal, setShowImportModal] = useState(false);
const [importJson, setImportJson] = useState("");
// This function handles the import of a filter from a JSON string
const handleImportJson = async () => {
try {
const importedData = JSON.parse(importJson);
// Extract the filter data and name from the imported object
const importedFilter = importedData.data;
const filterName = importedData.name;
// Check if the required properties are present and add them with default values if they are missing
const requiredProperties = ["resolutions", "sources", "codecs", "containers"];
requiredProperties.forEach((property) => {
if (!importedFilter.hasOwnProperty(property)) {
importedFilter[property] = [];
}
});
// Fetch existing filters from the API
const existingFilters = await APIClient.filters.getAll();
// Create a unique filter title by appending an incremental number if title is taken by another filter
let nameCounter = 0;
let uniqueFilterName = filterName;
while (existingFilters.some((filter) => filter.name === uniqueFilterName)) {
nameCounter++;
uniqueFilterName = `${filterName}-${nameCounter}`;
}
// Create a new filter using the API
const newFilter: Filter = {
...importedFilter,
name: uniqueFilterName
};
await APIClient.filters.create(newFilter);
// Update the filter list
queryClient.invalidateQueries("filters");
toast.custom((t) => <Toast type="success" body="Filter imported successfully." t={t} />);
setShowImportModal(false);
} catch (error) {
// Log the error and show an error toast message
console.error("Error:", error);
toast.custom((t) => <Toast type="error" body="Failed to import JSON data. Please check your input." t={t} />);
}
};
const [showDropdown, setShowDropdown] = useState(false);
return (
<main>
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
@ -83,17 +151,66 @@ export default function Filters() {
<h1 className="text-3xl font-bold text-black dark:text-white">
Filters
</h1>
<div className="flex-shrink-0">
<div className="relative">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
className="relative inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-l-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={toggleCreateFilter}
>
Add new
<PlusIcon className="h-5 w-5 mr-1" />
Add Filter
</button>
<button
type="button"
className="relative inline-flex items-center px-2 py-2 border-l border-spacing-1 dark:border-black shadow-sm text-sm font-medium rounded-r-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={() => setShowDropdown(!showDropdown)}
>
<ChevronDownIcon className="h-5 w-5" />
</button>
{showDropdown && (
<div className="absolute right-0 mt-2 w-46 bg-white dark:bg-gray-700 rounded-md shadow-lg z-50">
<button
type="button"
className="w-full text-left py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={() => setShowImportModal(true)}
>
Import Filter
</button>
</div>
)}
</div>
</div>
</header>
{showImportModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="w-1/2 md:w-1/2 bg-white dark:bg-gray-800 p-6 rounded-md shadow-lg">
<h2 className="text-lg font-medium mb-4 text-black dark:text-white">Import Filter JSON</h2>
<textarea
className="form-input block w-full resize-y rounded-md border-gray-300 dark:bg-gray-800 dark:border-gray-600 shadow-sm text-sm font-medium text-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-blue-500 dark:focus:ring-blue-500 mb-4"
placeholder="Paste JSON data here"
value={importJson}
onChange={(event) => setImportJson(event.target.value)}
style={{ minHeight: "30vh", maxHeight: "50vh" }}
/>
<div className="flex justify-end">
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={() => setShowImportModal(false)}
>
Cancel
</button>
<button
type="button"
className="ml-4 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={handleImportJson}
>
Import
</button>
</div>
</div>
</div>
)}
<FilterList toggleCreateFilter={toggleCreateFilter} />
</main>
@ -157,7 +274,7 @@ function FilterList({ toggleCreateFilter }: any) {
{data && data.length > 0 ? (
<ol className="min-w-full">
{filtered.filtered.map((filter: Filter, idx) => (
<FilterListItem filter={filter} key={filter.id} idx={idx} />
<FilterListItem filter={filter} values={filter} key={filter.id} idx={idx} />
))}
</ol>
) : (
@ -201,14 +318,86 @@ const StatusButton = ({ data, label, value, currentValue, dispatch }: StatusButt
};
interface FilterItemDropdownProps {
values: FormikValues;
filter: Filter;
onToggle: (newState: boolean) => void;
}
const FilterItemDropdown = ({
filter,
onToggle
}: FilterItemDropdownProps) => {
const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
// This function handles the export of a filter to a JSON string
const handleExportJson = useCallback(async () => {
try {
type CompleteFilterType = {
id: number;
name: string;
created_at: Date;
updated_at: Date;
indexers: any;
actions: any;
actions_count: any;
external_script_enabled: any;
external_script_cmd: any;
external_script_args: any;
external_script_expect_status: any;
external_webhook_enabled: any;
external_webhook_host: any;
external_webhook_data: any;
external_webhook_expect_status: any;
};
const completeFilter = await APIClient.filters.getByID(filter.id) as Partial<CompleteFilterType>;
// Extract the filter name and remove unwanted properties
const title = completeFilter.name;
delete completeFilter.name;
delete completeFilter.id;
delete completeFilter.created_at;
delete completeFilter.updated_at;
delete completeFilter.actions_count;
delete completeFilter.indexers;
delete completeFilter.actions;
delete completeFilter.external_script_enabled;
delete completeFilter.external_script_cmd;
delete completeFilter.external_script_args;
delete completeFilter.external_script_expect_status;
delete completeFilter.external_webhook_enabled;
delete completeFilter.external_webhook_host;
delete completeFilter.external_webhook_data;
delete completeFilter.external_webhook_expect_status;
// Remove properties with default values from the exported filter to minimize the size of the JSON string
["enabled", "priority", "smart_episode", "resolutions", "sources", "codecs", "containers"].forEach((key) => {
const value = completeFilter[key as keyof CompleteFilterType];
if (["enabled", "priority", "smart_episode"].includes(key) && (value === false || value === 0)) {
delete completeFilter[key as keyof CompleteFilterType];
} else if (["resolutions", "sources", "codecs", "containers"].includes(key) && Array.isArray(value) && value.length === 0) {
delete completeFilter[key as keyof CompleteFilterType];
}
});
// Create a JSON string from the filter data, including a name and version
const json = JSON.stringify(
{
"name": title,
"version": "1.0",
data: completeFilter
},
null,
4
);
navigator.clipboard.writeText(json).then(() => {
toast.custom((t) => <Toast type="success" body="Filter copied to clipboard." t={t} />);
}, () => {
toast.custom((t) => <Toast type="error" body="Failed to copy JSON to clipboard." t={t} />);
});
} catch (error) {
console.error(error);
toast.custom((t) => <Toast type="error" body="Failed to get filter data." t={t} />);
}
}, [filter]);
const cancelModalButtonRef = useRef(null);
const queryClient = useQueryClient();
@ -289,6 +478,26 @@ const FilterItemDropdown = ({
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
onClick={handleExportJson}
>
<ArrowDownTrayIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Export JSON
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
@ -360,10 +569,11 @@ const FilterItemDropdown = ({
interface FilterListItemProps {
filter: Filter;
values: FormikValues;
idx: number;
}
function FilterListItem({ filter, idx }: FilterListItemProps) {
function FilterListItem({ filter, values, idx }: FilterListItemProps) {
const [enabled, setEnabled] = useState(filter.enabled);
const updateMutation = useMutation(
@ -426,7 +636,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
{filter.name}
</Link>
</span>
<div className="flex-col">
<div className="flex items-center">
<span className="mr-2 break-words whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
Priority: {filter.priority}
</span>
@ -435,7 +645,35 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
to={`${filter.id.toString()}/actions`}
className="hover:text-black dark:hover:text-gray-300"
>
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>Actions: {filter.actions_count}</span>
<span
id={`tooltip-actions-${filter.id}`}
className="flex items-center hover:cursor-pointer"
>
<span className={classNames(filter.actions_count == 0 ? "text-red-500" : "")}>
<span
className={
classNames(
filter.actions_count == 0 ? "hover:text-red-400 dark:hover:text-red-400" : ""
)
}
>
Actions: {filter.actions_count}
</span>
</span>
{filter.actions_count === 0 && (
<>
<span className="mr-2 ml-2 flex h-3 w-3 relative">
<span className="animate-ping inline-flex h-full w-full rounded-full dark:bg-red-500 bg-red-400 opacity-75" />
<span
className="inline-flex absolute rounded-full h-3 w-3 dark:bg-red-500 bg-red-400"
/>
</span>
<span className="text-sm text-gray-800 dark:text-gray-500">
<Tooltip style={{ width: "350px", fontSize: "12px", textTransform: "none", fontWeight: "normal", borderRadius: "0.375rem", backgroundColor: "#34343A", color: "#fff", opacity: "1", whiteSpace: "pre-wrap", overflow: "hidden", textOverflow: "ellipsis" }} delayShow={100} delayHide={150} data-html={true} place="right" anchorId={`tooltip-actions-${filter.id}`} html="<p>You need to setup an action in the filter otherwise you will not get any snatches.</p>" />
</span>
</>
)}
</span>
</Link>
</span>
</div>
@ -445,6 +683,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
</span>
<span className="min-w-fit px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<FilterItemDropdown
values={values}
filter={filter}
onToggle={toggleActive}
/>