chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Converter unit tests for the table query builder. Cover the operator
|
||||
* round-trips — UI rule → Filter object → UI rule — with attention to the
|
||||
* valueless `$empty` operator that maps to two distinct UI operators.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
|
||||
import type { FilterRule } from '@/lib/table/types'
|
||||
|
||||
function rule(overrides: Partial<FilterRule>): FilterRule {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
logicalOperator: 'and',
|
||||
column: 'name',
|
||||
operator: 'eq',
|
||||
value: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('filterRulesToFilter', () => {
|
||||
it('emits a bare value for eq (containment shorthand)', () => {
|
||||
expect(filterRulesToFilter([rule({ operator: 'eq', value: 'John' })])).toEqual({ name: 'John' })
|
||||
})
|
||||
|
||||
it('wraps non-eq operators in a $-prefixed operator object', () => {
|
||||
expect(
|
||||
filterRulesToFilter([rule({ column: 'email', operator: 'startsWith', value: 'a' })])
|
||||
).toEqual({ email: { $startsWith: 'a' } })
|
||||
expect(
|
||||
filterRulesToFilter([rule({ column: 'email', operator: 'ncontains', value: 'x' })])
|
||||
).toEqual({ email: { $ncontains: 'x' } })
|
||||
})
|
||||
|
||||
it('parses comma-separated values into arrays for in / nin', () => {
|
||||
expect(
|
||||
filterRulesToFilter([rule({ column: 'status', operator: 'nin', value: 'a, b' })])
|
||||
).toEqual({ status: { $nin: ['a', 'b'] } })
|
||||
})
|
||||
|
||||
it('serializes isEmpty / isNotEmpty to $empty without a value', () => {
|
||||
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isEmpty' })])).toEqual({
|
||||
phone: { $empty: true },
|
||||
})
|
||||
expect(filterRulesToFilter([rule({ column: 'phone', operator: 'isNotEmpty' })])).toEqual({
|
||||
phone: { $empty: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('merges two AND rules on the same column into one operator object', () => {
|
||||
const filter = filterRulesToFilter([
|
||||
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
|
||||
rule({ id: 'b', column: 'age', operator: 'lt', value: '65' }),
|
||||
])
|
||||
expect(filter).toEqual({ age: { $gt: 18, $lt: 65 } })
|
||||
})
|
||||
|
||||
it('normalizes a bare-equality shorthand when merging with an operator', () => {
|
||||
const filter = filterRulesToFilter([
|
||||
rule({ id: 'a', column: 'name', operator: 'eq', value: 'John' }),
|
||||
rule({ id: 'b', column: 'name', operator: 'contains', value: 'oh' }),
|
||||
])
|
||||
expect(filter).toEqual({ name: { $eq: 'John', $contains: 'oh' } })
|
||||
})
|
||||
|
||||
it('keeps same-column rules across an OR boundary in separate groups', () => {
|
||||
const filter = filterRulesToFilter([
|
||||
rule({ id: 'a', column: 'age', operator: 'gt', value: '18' }),
|
||||
rule({ id: 'b', logicalOperator: 'or', column: 'age', operator: 'lt', value: '5' }),
|
||||
])
|
||||
expect(filter).toEqual({ $or: [{ age: { $gt: 18 } }, { age: { $lt: 5 } }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterToRules', () => {
|
||||
it('maps $empty: true back to isEmpty and $empty: false back to isNotEmpty', () => {
|
||||
const empty = filterToRules({ phone: { $empty: true } })
|
||||
expect(empty).toHaveLength(1)
|
||||
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })
|
||||
|
||||
const notEmpty = filterToRules({ phone: { $empty: false } })
|
||||
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
|
||||
})
|
||||
|
||||
it("treats the string '$empty' operand the same as the boolean (no predicate flip)", () => {
|
||||
const empty = filterToRules({ phone: { $empty: 'true' } } as unknown as Parameters<
|
||||
typeof filterToRules
|
||||
>[0])
|
||||
expect(empty[0]).toMatchObject({ column: 'phone', operator: 'isEmpty', value: '' })
|
||||
|
||||
const notEmpty = filterToRules({ phone: { $empty: 'false' } } as unknown as Parameters<
|
||||
typeof filterToRules
|
||||
>[0])
|
||||
expect(notEmpty[0]).toMatchObject({ column: 'phone', operator: 'isNotEmpty', value: '' })
|
||||
})
|
||||
|
||||
it('round-trips string-pattern operators', () => {
|
||||
for (const operator of ['contains', 'ncontains', 'startsWith', 'endsWith'] as const) {
|
||||
const filter = filterRulesToFilter([rule({ column: 'name', operator, value: 'abc' })])
|
||||
const back = filterToRules(filter)
|
||||
expect(back[0]).toMatchObject({ column: 'name', operator, value: 'abc' })
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips isEmpty through filterRulesToFilter', () => {
|
||||
const filter = filterRulesToFilter([rule({ column: 'name', operator: 'isEmpty' })])
|
||||
const back = filterToRules(filter)
|
||||
expect(back[0]).toMatchObject({ column: 'name', operator: 'isEmpty', value: '' })
|
||||
})
|
||||
|
||||
it('round-trips a multi-operator column (Filter → rules → Filter) without loss', () => {
|
||||
const original = { age: { $gte: 18, $lte: 65 } }
|
||||
const rules = filterToRules(original)
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(filterRulesToFilter(rules)).toEqual(original)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Constants for table query builder UI (filtering and sorting).
|
||||
*/
|
||||
|
||||
export type { FilterRule, SortRule } from '@/lib/table/types'
|
||||
|
||||
export const COMPARISON_OPERATORS = [
|
||||
{ value: 'eq', label: 'equals' },
|
||||
{ value: 'ne', label: 'not equals' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'ncontains', label: 'does not contain' },
|
||||
{ value: 'startsWith', label: 'starts with' },
|
||||
{ value: 'endsWith', label: 'ends with' },
|
||||
{ value: 'gt', label: 'greater than' },
|
||||
{ value: 'gte', label: 'greater or equal' },
|
||||
{ value: 'lt', label: 'less than' },
|
||||
{ value: 'lte', label: 'less or equal' },
|
||||
{ value: 'in', label: 'in array' },
|
||||
{ value: 'nin', label: 'not in array' },
|
||||
{ value: 'isEmpty', label: 'is empty' },
|
||||
{ value: 'isNotEmpty', label: 'is not empty' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Operators that take no value — the filter is fully specified by column +
|
||||
* operator alone. The UI hides the value input and skips the value-required
|
||||
* check for these, and the converter serializes them to `{ $empty: bool }`.
|
||||
*/
|
||||
export const VALUELESS_OPERATORS = new Set<string>(['isEmpty', 'isNotEmpty'])
|
||||
|
||||
export const LOGICAL_OPERATORS = [
|
||||
{ value: 'and', label: 'and' },
|
||||
{ value: 'or', label: 'or' },
|
||||
] as const
|
||||
|
||||
export const SORT_DIRECTIONS = [
|
||||
{ value: 'asc', label: 'ascending' },
|
||||
{ value: 'desc', label: 'descending' },
|
||||
] as const
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Converters for transforming between UI builder state and API filter/sort objects.
|
||||
*/
|
||||
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import type {
|
||||
Filter,
|
||||
FilterRule,
|
||||
JsonValue,
|
||||
Sort,
|
||||
SortDirection,
|
||||
SortRule,
|
||||
} from '@/lib/table/types'
|
||||
|
||||
/** Converts UI filter rules to a Filter object for API queries. */
|
||||
export function filterRulesToFilter(rules: FilterRule[]): Filter | null {
|
||||
if (rules.length === 0) return null
|
||||
|
||||
const orGroups: Filter[] = []
|
||||
let currentGroup: Filter = {}
|
||||
|
||||
for (const rule of rules) {
|
||||
// Honor the OR boundary before skipping incomplete rows, so an incomplete
|
||||
// `or` row between two valid conditions still starts a new group.
|
||||
const isOr = rule.logicalOperator === 'or'
|
||||
if (isOr && Object.keys(currentGroup).length > 0) {
|
||||
orGroups.push({ ...currentGroup })
|
||||
currentGroup = {}
|
||||
}
|
||||
|
||||
// Skip incomplete rows (no column selected) so a blank builder row never
|
||||
// serializes to a `{ '': ... }` predicate. The OR boundary above is still
|
||||
// applied; the row just contributes no condition.
|
||||
if (!rule.column) continue
|
||||
|
||||
const ruleValue = toRuleValue(rule.operator, rule.value)
|
||||
const existing = currentGroup[rule.column]
|
||||
currentGroup[rule.column] =
|
||||
existing === undefined
|
||||
? (ruleValue as Filter[string])
|
||||
: (mergeConditions(existing, ruleValue) as Filter[string])
|
||||
}
|
||||
|
||||
if (Object.keys(currentGroup).length > 0) {
|
||||
orGroups.push(currentGroup)
|
||||
}
|
||||
|
||||
return orGroups.length > 1 ? { $or: orGroups } : orGroups[0] || null
|
||||
}
|
||||
|
||||
/** Converts a Filter object back to UI filter rules. */
|
||||
export function filterToRules(filter: Filter | null): FilterRule[] {
|
||||
if (!filter) return []
|
||||
|
||||
if (filter.$or && Array.isArray(filter.$or)) {
|
||||
const groups = filter.$or
|
||||
.map((orGroup) => parseFilterGroup(orGroup as Filter))
|
||||
.filter((group) => group.length > 0)
|
||||
return applyLogicalOperators(groups)
|
||||
}
|
||||
|
||||
return parseFilterGroup(filter)
|
||||
}
|
||||
|
||||
/** Converts a single UI sort rule to a Sort object for API queries. */
|
||||
export function sortRuleToSort(rule: SortRule | null): Sort | null {
|
||||
if (!rule || !rule.column) return null
|
||||
return { [rule.column]: rule.direction }
|
||||
}
|
||||
|
||||
/** Converts multiple UI sort rules to a Sort object. */
|
||||
export function sortRulesToSort(rules: SortRule[]): Sort | null {
|
||||
if (rules.length === 0) return null
|
||||
|
||||
const sort: Sort = {}
|
||||
for (const rule of rules) {
|
||||
if (rule.column) {
|
||||
sort[rule.column] = rule.direction
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(sort).length > 0 ? sort : null
|
||||
}
|
||||
|
||||
/** Converts a Sort object back to UI sort rules. */
|
||||
export function sortToRules(sort: Sort | null): SortRule[] {
|
||||
if (!sort) return []
|
||||
|
||||
return Object.entries(sort).map(([column, direction]) => ({
|
||||
id: generateShortId(),
|
||||
column,
|
||||
direction: normalizeSortDirection(direction),
|
||||
}))
|
||||
}
|
||||
|
||||
function toRuleValue(operator: string, value: string): JsonValue {
|
||||
if (operator === 'isEmpty') return { $empty: true }
|
||||
if (operator === 'isNotEmpty') return { $empty: false }
|
||||
const parsedValue = parseValue(value, operator)
|
||||
return operator === 'eq' ? parsedValue : { [`$${operator}`]: parsedValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two conditions targeting the same column within one AND group into a
|
||||
* single operator object, so `age > 18 AND age < 65` becomes
|
||||
* `{ age: { $gt: 18, $lt: 65 } }` instead of the second rule clobbering the
|
||||
* first. Bare-equality shorthands are normalized to `{ $eq: value }` so they
|
||||
* can coexist with operators. On a same-operator collision (e.g. two
|
||||
* `$contains`) the later rule wins.
|
||||
*/
|
||||
function mergeConditions(existing: unknown, incoming: unknown): Record<string, JsonValue> {
|
||||
return { ...toOperatorObject(existing), ...toOperatorObject(incoming) }
|
||||
}
|
||||
|
||||
function toOperatorObject(value: unknown): Record<string, JsonValue> {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return { ...(value as Record<string, JsonValue>) }
|
||||
}
|
||||
return { $eq: value as JsonValue }
|
||||
}
|
||||
|
||||
function applyLogicalOperators(groups: FilterRule[][]): FilterRule[] {
|
||||
const rules: FilterRule[] = []
|
||||
|
||||
groups.forEach((group, groupIndex) => {
|
||||
group.forEach((rule, ruleIndex) => {
|
||||
rules.push({
|
||||
...rule,
|
||||
logicalOperator:
|
||||
groupIndex === 0 && ruleIndex === 0
|
||||
? 'and'
|
||||
: groupIndex > 0 && ruleIndex === 0
|
||||
? 'or'
|
||||
: 'and',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
const ARRAY_OPERATORS = new Set(['in', 'nin'])
|
||||
const TEXT_MATCH_OPERATORS = new Set(['contains', 'ncontains', 'startsWith', 'endsWith'])
|
||||
|
||||
function parseValue(value: string, operator: string): JsonValue {
|
||||
if (ARRAY_OPERATORS.has(operator)) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.map((part) => parseScalar(part))
|
||||
}
|
||||
|
||||
// Substring/prefix/suffix matches are textual — keep the raw string so a value
|
||||
// like "123" isn't coerced to a number the SQL builder's ILIKE path can't use.
|
||||
if (TEXT_MATCH_OPERATORS.has(operator)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return parseScalar(value)
|
||||
}
|
||||
|
||||
function parseScalar(value: string): JsonValue {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
if (value === 'null') return null
|
||||
if (!Number.isNaN(Number(value)) && value !== '') return Number(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function parseFilterGroup(group: Filter): FilterRule[] {
|
||||
if (!group || typeof group !== 'object' || Array.isArray(group)) return []
|
||||
|
||||
const rules: FilterRule[] = []
|
||||
|
||||
for (const [column, value] of Object.entries(group)) {
|
||||
if (column === '$or' || column === '$and') continue
|
||||
|
||||
if (isRecordLike(value)) {
|
||||
for (const [op, opValue] of Object.entries(value)) {
|
||||
if (!op.startsWith('$')) continue
|
||||
// `$empty` is a valueless boolean operator — map it back to the two
|
||||
// distinct UI operators rather than exposing a raw `empty` operator.
|
||||
// Accept the string forms `'true'`/`'false'` too, matching the lenient
|
||||
// coercion in the SQL builder's `coerceEmptyFlag` so a filter authored
|
||||
// via the raw API doesn't flip its predicate when re-opened in the UI.
|
||||
if (op === '$empty') {
|
||||
rules.push({
|
||||
id: generateShortId(),
|
||||
logicalOperator: 'and',
|
||||
column,
|
||||
operator: opValue === true || opValue === 'true' ? 'isEmpty' : 'isNotEmpty',
|
||||
value: '',
|
||||
})
|
||||
continue
|
||||
}
|
||||
rules.push({
|
||||
id: generateShortId(),
|
||||
logicalOperator: 'and',
|
||||
column,
|
||||
operator: op.substring(1),
|
||||
value: formatValueForBuilder(opValue as JsonValue),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
rules.push({
|
||||
id: generateShortId(),
|
||||
logicalOperator: 'and',
|
||||
column,
|
||||
operator: 'eq',
|
||||
value: formatValueForBuilder(value as JsonValue),
|
||||
})
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
function formatValueForBuilder(value: JsonValue): string {
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value)) return value.map(formatValueForBuilder).join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function normalizeSortDirection(direction: string): SortDirection {
|
||||
return direction === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Query builder UI utilities for filtering and sorting tables.
|
||||
*/
|
||||
|
||||
export * from '@/lib/table/query-builder/constants'
|
||||
export * from '@/lib/table/query-builder/converters'
|
||||
export * from '@/lib/table/query-builder/use-query-builder'
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Hooks for query builder UI state management (filters and sorting).
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import {
|
||||
COMPARISON_OPERATORS,
|
||||
type FilterRule,
|
||||
LOGICAL_OPERATORS,
|
||||
SORT_DIRECTIONS,
|
||||
type SortRule,
|
||||
} from '@/lib/table/query-builder/constants'
|
||||
import type { ColumnOption } from '@/lib/table/types'
|
||||
|
||||
const comparisonOptions: ColumnOption[] = COMPARISON_OPERATORS.map((op) => ({
|
||||
value: op.value,
|
||||
label: op.label,
|
||||
}))
|
||||
|
||||
const logicalOptions: ColumnOption[] = LOGICAL_OPERATORS.map((op) => ({
|
||||
value: op.value,
|
||||
label: op.label,
|
||||
}))
|
||||
|
||||
const sortDirectionOptions: ColumnOption[] = SORT_DIRECTIONS.map((d) => ({
|
||||
value: d.value,
|
||||
label: d.label,
|
||||
}))
|
||||
|
||||
/** Manages filter rule state with add/remove/update operations. */
|
||||
export function useFilterBuilder({
|
||||
columns,
|
||||
rules,
|
||||
setRules,
|
||||
isReadOnly = false,
|
||||
}: UseFilterBuilderProps): UseFilterBuilderReturn {
|
||||
const createDefaultRule = useCallback((): FilterRule => {
|
||||
return {
|
||||
id: generateShortId(),
|
||||
logicalOperator: 'and',
|
||||
column: columns[0]?.value || '',
|
||||
operator: 'eq',
|
||||
value: '',
|
||||
}
|
||||
}, [columns])
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
if (isReadOnly) return
|
||||
setRules([...rules, createDefaultRule()])
|
||||
}, [isReadOnly, rules, setRules, createDefaultRule])
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id: string) => {
|
||||
if (isReadOnly) return
|
||||
setRules(rules.filter((r) => r.id !== id))
|
||||
},
|
||||
[isReadOnly, rules, setRules]
|
||||
)
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id: string, field: keyof FilterRule, value: string) => {
|
||||
if (isReadOnly) return
|
||||
setRules(rules.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
|
||||
},
|
||||
[isReadOnly, rules, setRules]
|
||||
)
|
||||
|
||||
return {
|
||||
comparisonOptions,
|
||||
logicalOptions,
|
||||
sortDirectionOptions,
|
||||
addRule,
|
||||
removeRule,
|
||||
updateRule,
|
||||
createDefaultRule,
|
||||
}
|
||||
}
|
||||
|
||||
/** Manages sort rule state with add/remove/update operations. */
|
||||
export function useSortBuilder({
|
||||
columns,
|
||||
sortRule,
|
||||
setSortRule,
|
||||
}: UseSortBuilderProps): UseSortBuilderReturn {
|
||||
const addSort = useCallback(() => {
|
||||
setSortRule({
|
||||
id: generateShortId(),
|
||||
column: columns[0]?.value || '',
|
||||
direction: 'asc',
|
||||
})
|
||||
}, [columns, setSortRule])
|
||||
|
||||
const removeSort = useCallback(() => {
|
||||
setSortRule(null)
|
||||
}, [setSortRule])
|
||||
|
||||
const updateSortColumn = useCallback(
|
||||
(column: string) => {
|
||||
if (sortRule) {
|
||||
setSortRule({ ...sortRule, column })
|
||||
}
|
||||
},
|
||||
[sortRule, setSortRule]
|
||||
)
|
||||
|
||||
const updateSortDirection = useCallback(
|
||||
(direction: 'asc' | 'desc') => {
|
||||
if (sortRule) {
|
||||
setSortRule({ ...sortRule, direction })
|
||||
}
|
||||
},
|
||||
[sortRule, setSortRule]
|
||||
)
|
||||
|
||||
return {
|
||||
sortDirectionOptions,
|
||||
addSort,
|
||||
removeSort,
|
||||
updateSortColumn,
|
||||
updateSortDirection,
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseFilterBuilderProps {
|
||||
columns: ColumnOption[]
|
||||
rules: FilterRule[]
|
||||
setRules: (rules: FilterRule[]) => void
|
||||
isReadOnly?: boolean
|
||||
}
|
||||
|
||||
export interface UseFilterBuilderReturn {
|
||||
comparisonOptions: ColumnOption[]
|
||||
logicalOptions: ColumnOption[]
|
||||
sortDirectionOptions: ColumnOption[]
|
||||
addRule: () => void
|
||||
removeRule: (id: string) => void
|
||||
updateRule: (id: string, field: keyof FilterRule, value: string) => void
|
||||
createDefaultRule: () => FilterRule
|
||||
}
|
||||
|
||||
interface UseSortBuilderProps {
|
||||
columns: ColumnOption[]
|
||||
sortRule: SortRule | null
|
||||
setSortRule: (sort: SortRule | null) => void
|
||||
}
|
||||
|
||||
interface UseSortBuilderReturn {
|
||||
sortDirectionOptions: ColumnOption[]
|
||||
addSort: () => void
|
||||
removeSort: () => void
|
||||
updateSortColumn: (column: string) => void
|
||||
updateSortDirection: (direction: 'asc' | 'desc') => void
|
||||
}
|
||||
Reference in New Issue
Block a user