> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Saved Filters Button – Code

> Standalone trigger for managing saved filter presets — apply, save, edit, and delete.

<Tabs>
  <Tab title="Implementation">
    <Note>
      **Beta Feature**

      This feature is currently in beta, and needs to be imported from `@servicetitan/anvil2/beta`.

      While we hope to minimize breaking changes, they may occur due to feedback we receive or other improvements. These will always be documented in the changelog and communicated in Slack.

      Please reach out in the [#ask-designsystem](https://servicetitan.enterprise.slack.com/archives/CBSRGHTRS) channel with any questions or feedback!
    </Note>

    ## Overview

    `SavedFiltersButton` is an opt-in companion for filter UIs (typically a `FilterBar`) that lets users apply, save, edit, and delete preset filter selections.

    It is **fully stateless**: the consumer owns the `savedFilters` array and writes its own persistence (server, localStorage, etc.) by reacting to the callbacks the component fires. The component itself never mutates a preset.

    Visually, the trigger is a button that opens a popover listing the presets, with two footer actions:

    * **Save Current Filter** opens a Drawer with a name field that snapshots `currentFilters` into a new preset.
    * **Edit Saved Filters** opens a Drawer listing every preset with an Edit affordance. Clicking Edit drills into a per-preset form that reuses the FilterBar adapter `renderDrawer` machinery — meaning every filter type supported in the drawer (boolean, single/multi-select, date, date range, dateList, custom, asyncSelect, asyncMultiSelect) works inside the drilldown automatically.

    ## Standalone vs. coupled to FilterBar

    The component is not coupled to `FilterBar`. It expects two things:

    * `savedFilters` — the presets to list. Each is `{ id, name, filters }` where `filters` is the full schema (same shape as `FilterBar`'s `filters` prop) with the preset's values baked in.
    * `currentFilters` — the filter schema currently in use (so the "Save Current Filter" drawer can snapshot it).

    The consumer wires `onApplySavedFilter` to whatever target state they own. With FilterBar that's typically just `setFilters(saved.filters)`.

    ```tsx theme={null}
    import { SavedFiltersButton, FilterBar } from "@servicetitan/anvil2/beta";
    import { useState } from "react";

    function ExampleComponent() {
      const [filters, setFilters] = useState(initialFilters);
      const [savedFilters, setSavedFilters] = useState(initialPresets);

      return (
        <Flex alignItems="center" gap="2" wrap="wrap">
          <FilterBar
            associatedContent="orders"
            filters={filters}
            onFilterChange={setFilters}
          />
          <SavedFiltersButton
            savedFilters={savedFilters}
            currentFilters={filters}
            onApplySavedFilter={(saved) => setFilters(saved.filters)}
            onSaveCurrentFilter={({ name, filters }) =>
              setSavedFilters((prev) => [
                ...prev,
                { id: crypto.randomUUID(), name, filters },
              ])
            }
            onUpdateSavedFilter={(id, update) =>
              setSavedFilters((prev) =>
                prev.map((saved) =>
                  saved.id === id ? { ...saved, ...update } : saved,
                ),
              )
            }
            onDeleteSavedFilter={(id) =>
              setSavedFilters((prev) => prev.filter((s) => s.id !== id))
            }
          />
        </Flex>
      );
    }
    ```

    ## Async callbacks and alerts

    Several mutation callbacks (`onSaveCurrentFilter`, `onUpdateSavedFilter`, `onDeleteSavedFilter`, `onReorderSavedFilters`) may return a Promise. While the promise is pending, the relevant submit button shows a loading spinner and the form is disabled.

    When the promise settles:

    * **Save success:** the Save drawer closes.
    * **Save failure:** the rejection's message surfaces as the Filter Name field's error; the drawer stays open so the user can retry.
    * **Update success:** the drilldown returns to the list view with a success `Alert`.
    * **Update failure:** the drilldown stays open with an error `Alert` above the form so the user can adjust and retry.
    * **Delete success or failure:** the drilldown returns to the list view with the corresponding `Alert`.
    * **Reorder failure:** the list view surfaces an error `Alert` (success doesn't — the new order is its own visible confirmation). See [Drag-to-reorder](#drag-to-reorder).

    Alerts persist until the drawer closes or another outcome replaces them. Use `alertText` to customize the messages for the update, delete, and reorder-failure outcomes — the paths that surface drawer-level Alerts today. Save uses field-level errors instead of an Alert and just closes on success, so `saveSuccess` / `saveError` are reserved but not surfaced.

    For every failure path (save, update, delete, reorder), a thrown `Error` with a non-empty `message` takes precedence over the corresponding `alertText` entry — so a server response surfaced as `throw new Error(serverMessage)` reaches the user verbatim. Use `alertText` for the generic fallback when no usable message is available.

    ```tsx theme={null}
    <SavedFiltersButton
      alertText={{
        updateSuccess: "Preset updated.",
        updateError: "Could not update preset.",
        deleteSuccess: "Preset deleted.",
        deleteError: "Could not delete preset.",
        reorderError: "Could not save new order.",
      }}
      // ...callbacks
    />
    ```

    ## Drag-to-reorder

    Providing `onReorderSavedFilters` opts the Edit drawer into drag-to-reorder UI. Each row picks up a drag handle plus keyboard-accessible drop affordances (powered by `DndSort`, which detects the surrounding Drawer and disables sensors during its open/close animations). The callback receives the new id ordering after a drop — the consumer re-sorts their `savedFilters` array to match.

    ```tsx theme={null}
    <SavedFiltersButton
      savedFilters={savedFilters}
      // ...other callbacks
      onReorderSavedFilters={(orderedIds) => {
        setSavedFilters((prev) => {
          const byId = new Map(prev.map((s) => [s.id, s]));
          return orderedIds
            .map((id) => byId.get(id))
            .filter((s): s is SavedFilter => Boolean(s));
        });
      }}
    />
    ```

    Omitting the prop renders the list as a static stack with no handles.

    The callback may return a promise. A rejection surfaces as a danger `Alert` on the list view (using `alertText.reorderError`, per the precedence rule in [Async callbacks and alerts](#async-callbacks-and-alerts)), so reorder failures aren't invisible to the user.

    ## Per-preset locks

    Each `SavedFilter` accepts two optional booleans for marking individual presets immutable — handy for seeded or system presets a consumer wants to expose but not let users mutate.

    * **`disableEdit`** — the row in the Edit drawer renders without an Edit affordance and the drilldown is unreachable.
    * **`disableDelete`** — the preset is still editable, but the **Delete Filter** button is omitted from its drilldown footer.

    The two flags are independent — a preset can be editable but not deletable, or vice versa. Neither flag affects whether the preset can be applied from the popover.

    ```tsx theme={null}
    const savedFilters: SavedFilter[] = [
      { id: "team-default", name: "Team default", filters, disableEdit: true, disableDelete: true },
      { id: "my-vips",      name: "My VIPs",      filters },
    ];
    ```

    ## Empty state

    When `savedFilters` is empty the popover shows a subdued "No saved filters yet." message and the **Edit Saved Filters** footer button is disabled — there's nothing to edit, but the user can still hit **Save Current Filter** to create their first preset.

    Pass `emptyState` to override the default. The prop is `string | ReactNode`:

    * **Pass a `string`** to only swap the copy. The default subdued / small `Text` styling is preserved so the new message reads the same as the default.
    * **Pass any other `ReactNode`** (a CTA, an illustration, a styled block) to take full control of the empty-state content.

    ```tsx theme={null}
    // String — just swap the copy.
    <SavedFiltersButton
      savedFilters={savedFilters}
      // ...callbacks
      emptyState="Nothing here yet — try saving a filter."
    />

    // ReactNode — full control.
    <SavedFiltersButton
      savedFilters={savedFilters}
      // ...callbacks
      emptyState={
        <Flex direction="column" alignItems="center" gap="2">
          <Text subdued size="small">No presets yet.</Text>
          <Text subdued size="small">
            Apply a filter combination, then hit Save Current Filter below.
          </Text>
        </Flex>
      }
    />
    ```

    ## Active-preset detection

    Pass `activeSavedFilterId` to mark which preset (if any) reflects the user's current selection — the matching row in the popover renders with a check affordance, and the trigger label reflects the preset's name (when `label` is not set).

    The component does no comparison itself. Track which preset matches the live filter state in your own state, set the id when the user applies one, and clear it whenever the live filters drift (e.g. the user edits a filter directly on the bar).

    ## Validation

    Use `validateName` to block submit on invalid names (e.g. duplicate detection). Return an error string to block; return `undefined` when valid. The `currentId` arg is set during the edit drilldown so you can permit the row's own name in uniqueness checks.

    ```tsx theme={null}
    <SavedFiltersButton
      validateName={(name, currentId) =>
        savedFilters.some(
          (saved) =>
            saved.name.toLowerCase() === name.trim().toLowerCase() &&
            saved.id !== currentId,
        )
          ? "A saved filter with this name already exists."
          : undefined
      }
      // ...callbacks
    />
    ```
  </Tab>

  <Tab title="Props">
    ## `SavedFiltersButton` Props

    <ParamField path="currentFilters" type="Filter[]" required>
      The filters currently committed in the FilterBar (or any analogous filter source). Used as the snapshot payload when the user clicks **Save Current Filter**.
    </ParamField>

    <ParamField path="onApplySavedFilter" type="(saved: SavedFilter) => void" required>
      Called when the user picks a preset from the list. The popover closes immediately after the click; the consumer is responsible for pushing `saved.filters` into their FilterBar (or equivalent) state.
    </ParamField>

    <ParamField path="onDeleteSavedFilter" type="(id: string) => void | Promise<void>" required>
      Called when the user clicks **Delete Filter** in the drilldown. Same async / alert semantics as `onSaveCurrentFilter`.
    </ParamField>

    <ParamField path="onSaveCurrentFilter" type="(payload: { name, filters }) => void | Promise<void>" required>
      Called when the user submits the **Save Current Filter** drawer. `filters` is the snapshot of `currentFilters` captured when the drawer opened. May return a promise; while pending, the Add button shows a loading state. Rejecting surfaces the error on the field; resolving closes the drawer.
    </ParamField>

    <ParamField path="onUpdateSavedFilter" type="(id, update: { name, filters }) => void | Promise<void>" required>
      Called when the user submits the drilldown's **Update Filter** action. Same async / alert semantics as `onSaveCurrentFilter`.
    </ParamField>

    <ParamField path="savedFilters" type="SavedFilter[]" required>
      The presets to list. Each is `{ id, name, filters }` plus optional `disableEdit` / `disableDelete` booleans for marking individual presets immutable. The consumer owns this array; the component never mutates it.
    </ParamField>

    <ParamField path="activeSavedFilterId" type="string">
      Optional id of the preset currently applied. The matching row in the popover renders with a check affordance and the trigger label reflects the preset's name (when `label` is not set). The component does no comparison itself — the consumer tracks which preset (if any) reflects the live filter state and clears the id when filters drift.
    </ParamField>

    <ParamField path="alertText" type="SavedFiltersAlertText">
      Optional override for the auto-managed alert text. Today the component surfaces drawer-level Alerts for the update, delete, and reorder-failure outcomes (`updateSuccess`, `updateError`, `deleteSuccess`, `deleteError`, `reorderError`); `saveSuccess` / `saveError` are reserved in the type but not surfaced — save success closes the drawer and save failure surfaces as a field-level error. There is no `reorderSuccess` — a successful reorder is its own visible confirmation.
    </ParamField>

    <ParamField path="label" type="string" default="Saved Filters">
      Optional trigger button label.
    </ParamField>

    <ParamField path="emptyState" type="string | ReactNode">
      Optional override for the popover's empty-state content, shown when `savedFilters` is empty. Defaults to "No saved filters yet." Pass a `string` to swap only the copy (default subdued / small `Text` styling is preserved); pass any other `ReactNode` to take full control of the empty-state content. The **Edit Saved Filters** footer button is independently disabled while the list is empty.
    </ParamField>

    <ParamField path="onReorderSavedFilters" type="(orderedIds: string[]) => void | Promise<void>">
      Optional. When provided, the Edit drawer renders each row with a drag handle and emits the new id ordering after a drop. The consumer is responsible for re-sorting their `savedFilters` array. Omitting the prop renders the list as a static stack with no handles. May return a promise — a rejection surfaces as a danger `Alert` on the list view (`alertText.reorderError`).
    </ParamField>

    <ParamField path="validateName" type="(name: string, currentId?: string) => string | undefined">
      Optional validator for the name field in the save and edit flows. Return an error string to block submit; return `undefined` when valid. `currentId` is set during the edit drilldown so consumers can permit the row's own name in uniqueness checks.
    </ParamField>
  </Tab>

  <Tab title="Types">
    ## `SavedFilter`

    ```tsx theme={null}
    type SavedFilter = {
      id: string;
      name: string;
      filters: Filter[];
      disableEdit?: boolean;
      disableDelete?: boolean;
    };
    ```

    A consumer-owned preset. `filters` is a snapshot of the bar's full filter schema with the preset's values baked in — in the same shape as `FilterBar`'s `filters` prop. That uniformity lets the edit drilldown reuse the FilterBar adapter `renderDrawer` machinery without bespoke per-filter-type rendering.

    Set `disableEdit` to mark a preset as immutable — its row in the Edit drawer renders without an Edit affordance and the drilldown is unreachable. Set `disableDelete` to keep the row editable but omit the **Delete Filter** button from its drilldown footer. The two flags are independent.

    ## `SavedFiltersAlertText`

    ```tsx theme={null}
    type SavedFiltersAlertText = {
      saveSuccess?: string;
      saveError?: string;
      updateSuccess?: string;
      updateError?: string;
      deleteSuccess?: string;
      deleteError?: string;
      reorderError?: string;
    };
    ```
  </Tab>
</Tabs>
