> ## 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 - Web

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

> [!NOTE]
>
> For development, import `SavedFiltersButton` from
> `@servicetitan/anvil2/beta`. For design, find it in the
> [Anvil2 file](https://www.figma.com/design/xaNQsvRKczoxu7pj0M6QT8/Anvil2-v3).

**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!

## 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
/>
```

## Testing

`SavedFiltersButton` interactive sub-elements carry a stable `data-anv-part` attribute so end-to-end tests can target them directly. The library owns the part names; you supply the instance identifier.

Attach your own `data-*` attribute (such as `data-cy` or `data-testid`) to `SavedFiltersButton`. It is forwarded to the trigger button, letting you scope queries to a single instance:

```tsx theme={null}
<SavedFiltersButton data-cy="job-presets" {...rest} />
```

```ts theme={null}
// Open the menu, then target a footer action
cy.get('[data-cy="job-presets"]').click();
cy.get('[data-anv-part="save-current-button"]').click();
cy.get('[data-anv-part="input"]').type("My preset");
```

The trigger also carries `data-id="saved-filters"` (from the shared filter trigger chrome). Edit-row buttons in the Edit drawer carry `data-id` matching the preset id: `[data-anv-part="edit-row-button"][data-id="p-1"]`.

| `data-anv-part` | Element |
| - | - |
| `trigger` | Trigger button (via FilterTriggerButton) |
| `save-current-button` | Popover footer **Save Current Filter** action |
| `manage-button` | Popover footer **Edit Saved Filters** action |
| `add-drawer` | Save Current Filter drawer shell |
| `edit-drawer` | Edit Saved Filters drawer shell |
| `drilldown` | Per-preset edit screen inside the Edit drawer |
| `input` | Filter name field in the add or edit drawer |
| `apply-button` | **Add Saved Filter** action in the add drawer |
| `cancel-button` | Cancel action in the add drawer or edit drilldown |
| `edit-row-button` | Per-preset Edit affordance in the edit drawer list |
| `close-button` | **Close** action on the edit drawer list view |
| `update-button` | **Update Filter** action in the edit drilldown |
| `delete-button` | **Delete Filter** action in the edit drilldown |

The popover menu also exposes shared option-list parts (`popover`, `menu`, `option`) from the select internals — see SelectField docs for those roles.

> [!NOTE]
>
> The popover, menu, options, and drawers render in the browser's top layer, outside the trigger. Descendant queries scoped to your `data-cy` on the trigger will not match those parts. Open the surface first, then target parts by their `data-anv-part` attribute directly. Only one menu or drawer is open at a time.

**Props**

## `SavedFiltersButton` Props

**currentFilters** `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**.

**onApplySavedFilter** `(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.

**onDeleteSavedFilter** `(id: string) =\> void | Promise\<void\>` *required*

Called when the user clicks **Delete Filter** in the drilldown. Same async / alert semantics as `onSaveCurrentFilter`.

**onSaveCurrentFilter** `(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.

**onUpdateSavedFilter** `(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`.

**savedFilters** `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.

**activeSavedFilterId** `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.

**alertText** `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.

**label** `string` (default: Saved Filters)

Optional trigger button label.

**emptyState** `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.

**onReorderSavedFilters** `(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`).

**validateName** `(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.

**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;
};
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.