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 (
{allTogglableFilters .filter((filterElement) => isValidElement(filterElement)) .map((filterElement, index: number) => ( ))} {(hasFilterValues || validSavedQueries.length > 0) && ( )} {validSavedQueries.map((savedQuery: SavedQuery, index: number) => isEqual(savedQuery.value, { filter: filterValues, sort, perPage, displayedFilters, }) ? ( {translate("ra.saved_queries.remove_label_with_name", { _: 'Remove query "%{name}"', name: savedQuery.label, })} ) : ( { navigate({ search: queryString.stringify({ filter: JSON.stringify(savedQuery.value.filter), sort: savedQuery.value.sort?.field, order: savedQuery.value.sort?.order, page: 1, perPage: savedQuery.value.perPage, displayedFilters: JSON.stringify( savedQuery.value.displayedFilters, ), }), }); setOpen(false); }} key={index} > {savedQuery.label} ), )} {hasFilterValues && !hasSavedCurrentQuery && !disableSaveQuery && ( {translate("ra.saved_queries.new_label", { _: "Save current query...", })} )} {hasFilterValues && ( { setFilters({}, {}); setOpen(false); }} > {translate("ra.action.remove_all_filters", { _: "Remove all filters", })} )} {!disableSaveQuery && ( <> )}
); }; export interface FilterButtonProps extends HtmlHTMLAttributes { className?: string; disableSaveQuery?: boolean; filters?: ReactNode[]; resource?: string; variant?: | "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; size?: "default" | "sm" | "lg" | "icon"; } export const FilterButtonMenuItem = React.forwardRef< HTMLDivElement, FilterButtonMenuItemProps >((props, ref) => { const { filter, onShow, onHide, displayed } = props; const resource = useResourceContext(props); const handleShow = useCallback(() => { onShow({ source: filter.props.source, defaultValue: filter.props.defaultValue, }); }, [filter.props.defaultValue, filter.props.source, onShow]); const handleHide = useCallback(() => { onHide({ source: filter.props.source, }); }, [filter.props.source, onHide]); return (
{displayed && }
); }); export interface FilterButtonMenuItemProps { filter: React.ReactElement; displayed: boolean; onShow: (params: { source: string; defaultValue: any }) => void; onHide: (params: { source: string }) => void; resource?: string; autoFocus?: boolean; }