Skip to content

List view filtering

The rows a list view's saved filters keep, with Lightning's filter logic. The list view filter panel checks its filters with it.

Installation

pnpm dlx shadcn@latest add @wisty/list-view-filtering

The CLI installs the Radix or Base UI file to match your project.

When to use

Use it

  • A list view page has saved filters and needs the rows they keep before the table gets them, with a count it can trust.
  • Code checks or edits filter logic like 1 AND (2 OR 3) outside the filter panel, with Lightning's messages and its renumbering when a filter goes.
  • A read-only list applies a saved filter value without installing the panel.

Don't use it

  • To let people edit filters. Use List view filter panel, which checks its draft with this helper.
  • To query the org. It filters the rows the page already has, so build the query's WHERE from the saved value yourself.
  • For nested condition groups, cross-object filters, or a field kind it doesn't take, like a date and time, a multi-select picklist or a relative date such as "this week".

Usage

import { applyListViewFilters } from "@/components/wisty/list-view-filtering"

const { props: filtered, issues } = applyListViewFilters(
  accounts,
  { owner: "all", filters: [typeIsDirect, nameIsNotTest], logic: "1 AND 2" },
  accountFilterSchema
)
const list = useListView({ records: filtered.records, columns, getRowId })

The schema lists the fields people can filter by. Each has an id, a label, a type (text, picklist, number, currency, percent, date or boolean) and a read that takes a row and returns its value: null for blank, undefined when the field wasn't read. operators narrows the ones a field offers, and nullable lets a filter match a blank value. owner adds My accounts, with the signed-in user's id, and lockedFilters adds filters that always apply.

props.status is ready, partial when some rows couldn't be checked because a field wasn't read, invalid when the saved filters can't be applied, or unavailable without rows or a schema. Neither invalid nor unavailable means zero matches, and props.records is null for both. issues says why, each with its scope and, for a filter, its filterNumber. props.filteredBy is for the list view header's filteredBy. It lists every filter's field label in order, locked filters first, with repeats kept and never the owner scope: "Type, Type, Account Name". With no filters it's undefined, even with My accounts.

The value

filters is an ordered list of at most 10 conditions. A filter's number is its position, counted from 1, and filter logic refers to filters by those numbers. Filters have no ids. logic is null when filter logic is off, and then every filter must match. An empty or blank logic means the same.

Filter logic

  • The words are AND, OR and NOT, in any case, and the numbers are one or two digits (02 is 2). Parentheses group. Spaces and line breaks separate the words, so 1AND2 is a typo.
  • NOT applies to the number or parenthesized group after it. AND binds tighter than OR, but an OR beside an AND needs parentheses: 1 AND 2 OR 3 is refused, and (1 AND 2) OR 3 is fine.
  • Every filter must appear at least once. A number may repeat.
  • The logic is at most 120 characters, listViewFilterLimits.logicLength.
  • checkFilterLogic(text, count) returns one problem at a time. Typos come first, then numbers out of range, then filters the logic never names, then the grammar, read from the left. An error inside the right side of an AND or OR shows as that operator's missing right operand.
Problem Message
A word that isn't a number, operator or parenthesis Check the spelling in your filter logic.
A number below 1 or above the filter count The filter logic references an undefined filter: 4.
A filter the logic never names Some filter conditions are defined but not referenced in your filter logic.
An AND or OR with nothing valid after it Your filter is missing right operand to AND or OR.
A ( group that doesn't end in ) Your filter is missing a parenthesis.
AND and OR mixed without parentheses Your filter must use parentheses around successive AND and OR expressions.
Nothing after a NOT, with no filters Your filter is missing an operand.
A stray ), AND or OR, or words left over Check the spelling in your filter logic.

normalizeFilterLogic gives the logic as Save stores it: trimmed, line breaks as spaces and leading zeros dropped (1 AND 02 is 1 AND 2), its case kept, and null when empty.

Removing a filter

removeFilterFromLogic(logic, k) rewrites the logic once filter k is gone. Every filter after k moves up one number. Every reference to k goes, an AND or OR that loses one side becomes its other side, two NOTs cancel, and numbers above k drop by one. A parenthesized group keeps its parentheses when it collapses, so the result never needs new ones: 1 OR (2 AND 3 AND 4) without filter 2 becomes 1 OR (2 AND 3). The rewrite prints in upper case with single spaces.

  • 1 AND (2 OR 3) without filter 1 becomes (1 OR 2), and without filter 3 becomes 1 AND 2.
  • 1 AND NOT (2 OR 3) without filter 1 becomes NOT(1 OR 2).
  • When one filter number without NOT is all that's left, the logic becomes "", which still means every filter must match.
  • Logic that doesn't parse, like 1 AND 2 OR 3, comes back as it is.
  • null stays null.

API reference

allowedFilterOperatorsfunction

The operators a field offers: its kind's, narrowed by the field's own list.

ParameterTypeDefault
field*AnyField

Returns readonly ListViewFilterOperator[].

checkFilterConditionfunction

Why a condition can't be applied to its field, or undefined when it can.

ParameterTypeDefault
condition*ListViewFilterCondition
field*AnyField

Returns string | undefined.

checkFilterLogicfunction

The first problem with filter logic over this many filters, or undefined when it's valid.

ParameterTypeDefault
text*string
count*number

Returns string | undefined.

removeFilterFromLogicfunction

Rewrites filter logic once filter number is gone, renumbering the rest. Unparseable logic stays as it is.

ParameterTypeDefault
logic*string | null
number*number

Returns string | null.

normalizeFilterLogicfunction

Filter logic as Save stores it: trimmed, one line, no leading zeros, and null when empty.

ParameterTypeDefault
logic*string | null

Returns string | null.

validateListViewFiltersfunction

Every reason a value can't be applied with this schema, in order. A null filter isn't checked.

ParameterTypeDefault
value*ListViewFiltersDraftValue
schema*ListViewFilterSchema<Row>

Returns ListViewFilterIssue[].

describeFilterConditionfunction

A condition's operator and value in words, as equals "Acme" or less than $50,000.

ParameterTypeDefault
condition*ListViewFilterCondition
field*AnyField
localestring"en-US"

Returns string.

applyListViewFiltersfunction

Keeps the rows saved filters match, and counts the rows it couldn't check.

ParameterTypeDefault
records*Row[] | null
value*ListViewFiltersValue
schema*ListViewFilterSchema<Row> | null
__3{ locale?: string }{}

Returns { props: ListViewFilterResult<Row>; issues: ListViewFilterIssue[]; }.

ListViewFilterTypetype

A filterable field's kind, which decides its operators and its value's type.

type ListViewFilterType =
  | "text"
  | "picklist"
  | "number"
  | "currency"
  | "percent"
  | "date"
  | "boolean"

ListViewFilterEqualityOperatortype

Operators every kind of field takes.

type ListViewFilterEqualityOperator = "equals" | "not-equal"

ListViewFilterTextOperatortype

Operators a text field takes.

type ListViewFilterTextOperator =
  | ListViewFilterEqualityOperator
  | "contains"
  | "not-contains"
  | "starts-with"

ListViewFilterRangeOperatortype

Operators a number, currency, percent or date field takes.

type ListViewFilterRangeOperator =
  | ListViewFilterEqualityOperator
  | "less-than"
  | "greater-than"
  | "less-or-equal"
  | "greater-or-equal"

ListViewFilterOperatortype

Every operator a filter can use.

type ListViewFilterOperator =
  | ListViewFilterTextOperator
  | ListViewFilterRangeOperator

ListViewFilterOptiontype

A picklist value and its label.

MemberTypeDefaultDescription
value*stringWhat the records hold.
label*stringWhat the panel shows.

ListViewFilterFieldtype

A field people can filter by: its id, label, kind and reader, as the page allows it.

type ListViewFilterField<Row> =
  | (ListViewFilterFieldBase<Row, string> & {
      type: "text"
      caseSensitive?: boolean
    })
  | (ListViewFilterFieldBase<Row, string> & {
      type: "picklist"
      options: readonly ListViewFilterOption[]
    })
  | (ListViewFilterFieldBase<Row, number> & {
      type: "number"
    })
  | (ListViewFilterFieldBase<Row, number> & {
      type: "currency"
      currencyCode?: string
    })
  | (ListViewFilterFieldBase<Row, number> & {
      type: "percent"
    })
  | (ListViewFilterFieldBase<Row, string> & {
      type: "date"
      today?: string
    })
  | (ListViewFilterFieldBase<Row, boolean> & {
      type: "boolean"
    })

ListViewFilterConditiontype

One criterion: a field, an operator and a typed value. null is an intentional blank.

type ListViewFilterCondition =
  | {
      field: string
      type: "text"
      operator: ListViewFilterTextOperator
      value: string | null
    }
  | {
      field: string
      type: "picklist"
      operator: ListViewFilterEqualityOperator
      value: string | null
    }
  | {
      field: string
      type: "number"
      operator: ListViewFilterRangeOperator
      value: number | null
    }
  | {
      field: string
      type: "currency"
      operator: ListViewFilterRangeOperator
      value: number | null
    }
  | {
      field: string
      type: "percent"
      operator: ListViewFilterRangeOperator
      value: number | null
    }
  | {
      field: string
      type: "date"
      operator: ListViewFilterRangeOperator
      value: string | null
    }
  | {
      field: string
      type: "boolean"
      operator: ListViewFilterEqualityOperator
      value: boolean | null
    }

ListViewLockedFiltertype

A filter the page applies that people can see but not change or remove.

MemberTypeDefaultDescription
key*stringUnique among the locked filters.
condition*ListViewFilterConditionWhat the filter matches.
reason*stringWhy it's locked, shown under it: "This view shows open cases only."

ListViewFiltersValuetype

A list view's saved filters, as Save hands them over.

MemberTypeDefaultDescription
owner*"all" | "mine"Everyone's records, or the signed-in user's.
filters*readonly ListViewFilterCondition[]The filters people can change, numbered from 1 by position for filter logic.
logic*string | nullHow the filters combine, as "1 AND (2 OR 3)". null matches all of them.

ListViewFilterOwnertype

Filter by Owner: whose records are "mine", and the two choices' words.

MemberTypeDefaultDescription
allLabel*stringThe choice for everyone's records: "All accounts".
mineLabel*stringThe choice for the signed-in user's records: "My accounts".
currentUserId*string | nullThe signed-in user's id. Without it, "mine" can't be applied.
read*(row: Row) => string | nullReads a row's owner id: null when it has none, undefined when it wasn't read.

ListViewFilterSchematype

What people may filter by, as the page allows it.

MemberTypeDefaultDescription
fields*readonly ListViewFilterField<Row>[]The fields people can filter by, in the order the field menu lists them.
ownerListViewFilterOwner<Row>Shows Filter by Owner. Leave it out for records without owners.
lockedFiltersreadonly ListViewLockedFilter[]Filters that always apply, ANDed with everything else.

ListViewFilterIssuetype

Why filters can't be applied, or rows the filters couldn't check.

MemberTypeDefaultDescription
scope*"schema" | "owner" | "filter" | "locked" | "logic" | "data"What the issue is about.
message*stringThe issue in words, for people.
filterNumbernumberThe editable filter it's about, by its number: its position from 1.
lockedKeystringThe locked filter it's about.
fieldIdstringThe field it's about.

ListViewFilterResulttype

The rows saved filters keep. Unavailable and invalid never mean zero matches.

type ListViewFilterResult<Row> =
  | (ListViewFilterResultBase & {
      status: "ready"
      records: Row[]
      indeterminateCount: 0
    })
  | (ListViewFilterResultBase & {
      status: "partial"
      records: Row[]
      indeterminateCount: number
    })
  | (ListViewFilterResultBase & {
      status: "unavailable" | "invalid"
      records: null
      indeterminateCount: 0
    })

ListViewFiltersDraftValuetype

A value being edited, where a null filter is one whose field isn't chosen yet.

type ListViewFiltersDraftValue = Omit<
  ListViewFiltersValue,
  "filters"
> & {
  filters: readonly (ListViewFilterCondition | null)[]
}

AnyFieldtype

type AnyField = ListViewFilterField<never>

Rules

  • Keep undefined for a field the app didn't read and null for a blank one. Unread values never match, even under NOT or not equal to, so the result is partial instead of a guess.
  • A percent is a fraction, 0.35 for 35%, and an amount is in the field's currencyCode. A date is "YYYY-MM-DD".
  • A filter's number is its position. Code that keeps logic beside the filters must rewrite it with removeFilterFromLogic when one goes, or the logic names the wrong filters.
  • Locked filters always apply, outside the filter logic. They're how the page shows a filter, not a permission check: enforce access where the data is read.
  • It filters the rows the page has loaded. When more rows exist, say so beside the count.