mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +00:00
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:
parent
603828be9d
commit
4449df66aa
2 changed files with 254 additions and 19 deletions
|
@ -25,8 +25,6 @@ import { APIClient } from "../../api/APIClient";
|
||||||
import { useToggle } from "../../hooks/hooks";
|
import { useToggle } from "../../hooks/hooks";
|
||||||
import { classNames } from "../../utils";
|
import { classNames } from "../../utils";
|
||||||
|
|
||||||
import { CustomTooltip } from "../../components/tooltips/CustomTooltip";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
IndexerMultiSelect,
|
IndexerMultiSelect,
|
||||||
|
@ -319,7 +317,7 @@ export default function FilterDetails() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function General() {
|
export function General(){
|
||||||
const { isLoading, data: indexers } = useQuery(
|
const { isLoading, data: indexers } = useQuery(
|
||||||
["filters", "indexer_list"],
|
["filters", "indexer_list"],
|
||||||
() => APIClient.indexers.getOptions(),
|
() => APIClient.indexers.getOptions(),
|
||||||
|
@ -334,7 +332,6 @@ export function General() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-6 lg:pb-8">
|
<div className="mt-6 lg:pb-8">
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
<TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
|
<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">
|
<div className="border-t dark:border-gray-700">
|
||||||
<SwitchGroup name="enabled" label="Enabled" description="Enable or disable this filter." />
|
<SwitchGroup name="enabled" label="Enabled" description="Enable or disable this filter." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,15 +3,22 @@ import { Link } from "react-router-dom";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { Listbox, Menu, Switch, Transition } from "@headlessui/react";
|
import { Listbox, Menu, Switch, Transition } from "@headlessui/react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
|
import { FormikValues } from "formik";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowsRightLeftIcon,
|
ArrowsRightLeftIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
PlusIcon,
|
||||||
DocumentDuplicateIcon,
|
DocumentDuplicateIcon,
|
||||||
EllipsisHorizontalIcon,
|
EllipsisHorizontalIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
TrashIcon
|
TrashIcon,
|
||||||
|
ExclamationTriangleIcon
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { queryClient } from "../../App";
|
import { queryClient } from "../../App";
|
||||||
|
@ -22,6 +29,7 @@ import { APIClient } from "../../api/APIClient";
|
||||||
import Toast from "../../components/notifications/Toast";
|
import Toast from "../../components/notifications/Toast";
|
||||||
import { EmptyListState } from "../../components/emptystates";
|
import { EmptyListState } from "../../components/emptystates";
|
||||||
import { DeleteModal } from "../../components/modals";
|
import { DeleteModal } from "../../components/modals";
|
||||||
|
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
type FilterListState = {
|
type FilterListState = {
|
||||||
indexerFilter: string[],
|
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 [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 (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
||||||
|
@ -81,19 +149,68 @@ export default function Filters() {
|
||||||
<header className="py-10">
|
<header className="py-10">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
|
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
|
||||||
<h1 className="text-3xl font-bold text-black dark:text-white">
|
<h1 className="text-3xl font-bold text-black dark:text-white">
|
||||||
Filters
|
Filters
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex-shrink-0">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={toggleCreateFilter}
|
||||||
>
|
>
|
||||||
Add new
|
<PlusIcon className="h-5 w-5 mr-1" />
|
||||||
|
Add Filter
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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} />
|
<FilterList toggleCreateFilter={toggleCreateFilter} />
|
||||||
</main>
|
</main>
|
||||||
|
@ -157,7 +274,7 @@ function FilterList({ toggleCreateFilter }: any) {
|
||||||
{data && data.length > 0 ? (
|
{data && data.length > 0 ? (
|
||||||
<ol className="min-w-full">
|
<ol className="min-w-full">
|
||||||
{filtered.filtered.map((filter: Filter, idx) => (
|
{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>
|
</ol>
|
||||||
) : (
|
) : (
|
||||||
|
@ -201,14 +318,86 @@ const StatusButton = ({ data, label, value, currentValue, dispatch }: StatusButt
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FilterItemDropdownProps {
|
interface FilterItemDropdownProps {
|
||||||
|
values: FormikValues;
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
onToggle: (newState: boolean) => void;
|
onToggle: (newState: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilterItemDropdown = ({
|
const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
||||||
filter,
|
|
||||||
onToggle
|
// This function handles the export of a filter to a JSON string
|
||||||
}: FilterItemDropdownProps) => {
|
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 cancelModalButtonRef = useRef(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -289,6 +478,26 @@ const FilterItemDropdown = ({
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</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>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<button
|
<button
|
||||||
|
@ -360,10 +569,11 @@ const FilterItemDropdown = ({
|
||||||
|
|
||||||
interface FilterListItemProps {
|
interface FilterListItemProps {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
|
values: FormikValues;
|
||||||
idx: number;
|
idx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterListItem({ filter, idx }: FilterListItemProps) {
|
function FilterListItem({ filter, values, idx }: FilterListItemProps) {
|
||||||
const [enabled, setEnabled] = useState(filter.enabled);
|
const [enabled, setEnabled] = useState(filter.enabled);
|
||||||
|
|
||||||
const updateMutation = useMutation(
|
const updateMutation = useMutation(
|
||||||
|
@ -426,7 +636,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||||
{filter.name}
|
{filter.name}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</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">
|
<span className="mr-2 break-words whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
Priority: {filter.priority}
|
Priority: {filter.priority}
|
||||||
</span>
|
</span>
|
||||||
|
@ -435,7 +645,35 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||||
to={`${filter.id.toString()}/actions`}
|
to={`${filter.id.toString()}/actions`}
|
||||||
className="hover:text-black dark:hover:text-gray-300"
|
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>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -445,6 +683,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-fit px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<span className="min-w-fit px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<FilterItemDropdown
|
<FilterItemDropdown
|
||||||
|
values={values}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onToggle={toggleActive}
|
onToggle={toggleActive}
|
||||||
/>
|
/>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue