Skip to main content

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.

Beta FeatureThis 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 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).
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.
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.
<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.
<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), 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.
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.
// 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.
<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
/>
Last modified on May 26, 2026