import * as React from "react";
import type { HtmlHTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useState, isValidElement } from "react";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import queryString from "query-string";
import {
FieldTitle,
FilterLiveForm,
useFilterContext,
useListContext,
useResourceContext,
useTranslate,
} from "ra-core";
import { useNavigate } from "react-router";
import {
Bookmark,
BookmarkMinus,
BookmarkPlus,
Check,
Filter,
MinusCircle,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
extractValidSavedQueries,
SavedQuery,
useSavedQueries,
} from "@/hooks/saved-queries";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AddSavedQueryDialog,
RemoveSavedQueryDialog,
} from "@/components/admin/saved-queries";
/**
* A form for filter inputs with live updates. Included by default in List.
*
* To be used in conjunction with FilterButton.
*
* @see {@link https://marmelab.com/shadcn-admin-kit/docs/list/#filter-button--form-combo FilterForm documentation}
*/
export const FilterForm = (inProps: FilterFormProps) => {
const { filters: filtersProps, ...rest } = inProps;
const filters = useFilterContext() || filtersProps;
return (
);
};
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface FilterFormProps extends FilterFormBaseProps {}
/**
* @deprecated Use FilterFormBase from `ra-core` once available.
*/
export const FilterFormBase = (props: FilterFormBaseProps) => {
const { filters } = props;
const resource = useResourceContext(props);
const { displayedFilters = {}, filterValues, hideFilter } = useListContext();
useEffect(() => {
if (!filters) return;
filters
.filter((filterElement) => isValidElement(filterElement))
.forEach((filter) => {
if (
(filter.props as any).alwaysOn &&
(filter.props as any).defaultValue
) {
throw new Error(
"Cannot use alwaysOn and defaultValue on a filter input. Please set the filterDefaultValues props on the element instead.",
);
}
});
}, [filters]);
const getShownFilters = () => {
if (!filters) return [];
const values = filterValues;
return filters
.filter((filterElement) => isValidElement(filterElement))
.filter((filterElement) => {
const filterValue = get(values, (filterElement.props as any).source);
return (
(filterElement.props as any).alwaysOn ||
displayedFilters[(filterElement.props as any).source] ||
!isEmptyValue(filterValue)
);
});
};
const handleHide = useCallback(
(event: React.MouseEvent) =>
hideFilter(event.currentTarget.dataset.key!),
[hideFilter],
);
return (
<>
{getShownFilters().map((filterElement) => (
))}
>
);
};
const sanitizeRestProps = ({
hasCreate: _hasCreate,
resource: _resource,
...props
}: Partial & { hasCreate?: boolean }) => props;
export type FilterFormBaseProps = Omit<
HtmlHTMLAttributes,
"children"
> & {
className?: string;
resource?: string;
filters?: ReactNode[];
};
const StyledForm = (props: React.FormHTMLAttributes) => {
return (
);
};
const isEmptyValue = (filterValue: any): boolean => {
if (filterValue === "" || filterValue == null) return true;
// If one of the value leaf is not empty
// the value is considered not empty
if (typeof filterValue === "object") {
return Object.keys(filterValue).every((key) =>
isEmptyValue(filterValue[key]),
);
}
return false;
};
export const FilterFormInput = (inProps: FilterFormInputProps) => {
const { filterElement, handleHide, className } = inProps;
const resource = useResourceContext(inProps);
const translate = useTranslate();
return (
{React.cloneElement(filterElement, {
resource,
record: emptyRecord,
size: filterElement.props.size ?? "small",
helperText: false,
// ignore defaultValue in Field because it was already set in Form (via mergedInitialValuesWithDefaultValues)
defaultValue: undefined,
})}
{!filterElement.props.alwaysOn && (
)}
);
};
export interface FilterFormInputProps {
filterElement: React.ReactElement;
handleHide: (event: React.MouseEvent) => void;
className?: string;
resource?: string;
}
const emptyRecord = {};
/**
* A button that opens a dropdown to add, remove, and manage filters.
*
* Displays available filters, saved queries, and options to save or clear current filters.
* Works with the FilterForm to provide a complete filtering UI.
*
* @see {@link https://marmelab.com/shadcn-admin-kit/docs/list/#filter-button--form-combo FilterForm documentation}
*/
export const FilterButton = (props: FilterButtonProps) => {
const {
filters: filtersProp,
className,
disableSaveQuery,
size,
variant = "outline",
...rest
} = props;
const filters = useFilterContext() || filtersProp;
const resource = useResourceContext(props);
const translate = useTranslate();
if (!resource && !disableSaveQuery) {
throw new Error(
" must be called inside a ResourceContextProvider, or must provide a resource prop",
);
}
const [savedQueries] = useSavedQueries(resource || "");
const navigate = useNavigate();
const {
displayedFilters = {},
filterValues,
perPage,
setFilters,
showFilter,
hideFilter,
sort,
} = useListContext();
const hasFilterValues = !isEqual(filterValues, {});
const validSavedQueries = extractValidSavedQueries(savedQueries);
const hasSavedCurrentQuery = validSavedQueries.some((savedQuery) =>
isEqual(savedQuery.value, {
filter: filterValues,
sort,
perPage,
displayedFilters,
}),
);
const [open, setOpen] = useState(false);
if (filters === undefined) {
throw new Error(
"The component requires the prop to be set",
);
}
const allTogglableFilters = filters.filter(
(filterElement) =>
isValidElement(filterElement) && !(filterElement.props as any).alwaysOn,
);
const handleShow = useCallback(
({ source, defaultValue }: { source: string; defaultValue: any }) => {
showFilter(source, defaultValue === "" ? undefined : defaultValue);
// We have to fallback to imperative code because the new FilterFormInput
// has no way of knowing it has just been displayed (and thus that it should focus its input)
setTimeout(() => {
const inputElement = document.querySelector(
`input[name='${source}']`,
) as HTMLInputElement;
if (inputElement) {
inputElement.focus();
}
}, 50);
setOpen(false);
},
[showFilter, setOpen],
);
const handleRemove = useCallback(
({ source }: { source: string }) => {
hideFilter(source);
setOpen(false);
},
[hideFilter, setOpen],
);
// add query dialog state
const [addSavedQueryDialogOpen, setAddSavedQueryDialogOpen] = useState(false);
const hideAddSavedQueryDialog = (): void => {
setAddSavedQueryDialogOpen(false);
};
const showAddSavedQueryDialog = (): void => {
setOpen(false);
setAddSavedQueryDialogOpen(true);
};
// remove query dialog state
const [removeSavedQueryDialogOpen, setRemoveSavedQueryDialogOpen] =
useState(false);
const hideRemoveSavedQueryDialog = (): void => {
setRemoveSavedQueryDialogOpen(false);
};
const showRemoveSavedQueryDialog = (): void => {
setOpen(false);
setRemoveSavedQueryDialogOpen(true);
};
if (
allTogglableFilters.length === 0 &&
validSavedQueries.length === 0 &&
!hasFilterValues
) {
return null;
}
return (