tsense 0.2.0-next.0 → 0.2.0-next.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ export type FilterState = {
11
11
  };
12
12
  export declare function createInitialState(): FilterState;
13
13
  export declare function addRow(state: FilterState): FilterState;
14
+ export declare function addRowWithField(state: FilterState, field: string): FilterState;
14
15
  export declare function removeRow(state: FilterState, index: number): FilterState;
15
16
  export declare function setRowField(state: FilterState, index: number, field: string): FilterState;
16
17
  export declare function setRowCondition(state: FilterState, index: number, condition: string): FilterState;
@@ -11,11 +11,14 @@ const conditionToOperator = {
11
11
  };
12
12
  let nextRowId = 0;
13
13
  export function createInitialState() {
14
- return { rows: [{ id: ++nextRowId }] };
14
+ return { rows: [] };
15
15
  }
16
16
  export function addRow(state) {
17
17
  return { rows: [...state.rows, { id: ++nextRowId }] };
18
18
  }
19
+ export function addRowWithField(state, field) {
20
+ return { rows: [...state.rows, { id: ++nextRowId, field }] };
21
+ }
19
22
  export function removeRow(state, index) {
20
23
  return { rows: state.rows.filter((_, i) => i !== index) };
21
24
  }
@@ -37,54 +40,63 @@ export function setRowValue(state, index, value) {
37
40
  export function clearState() {
38
41
  return { rows: [] };
39
42
  }
40
- export function applyPreset(state, descriptor, field, name) {
41
- const column = descriptor.columns.find((c) => c.key === field);
42
- const preset = column?.presets?.find((p) => p.name === name);
43
- if (!preset)
44
- return state;
45
- const filterValue = preset.filter[field];
46
- if (filterValue == null)
47
- return state;
43
+ function filterValueToRow(field, filterValue) {
44
+ if (filterValue == null) {
45
+ return null;
46
+ }
48
47
  if (Array.isArray(filterValue)) {
49
48
  return {
50
- rows: [
51
- ...state.rows,
52
- { id: ++nextRowId, field, condition: "is_in", value: filterValue },
53
- ],
49
+ id: ++nextRowId,
50
+ field,
51
+ condition: "is_in",
52
+ value: filterValue,
54
53
  };
55
54
  }
56
55
  if (typeof filterValue !== "object" || filterValue instanceof Date) {
57
56
  return {
58
- rows: [
59
- ...state.rows,
60
- { id: ++nextRowId, field, condition: "equals", value: filterValue },
61
- ],
57
+ id: ++nextRowId,
58
+ field,
59
+ condition: "equals",
60
+ value: filterValue,
62
61
  };
63
62
  }
64
- const ops = filterValue;
65
- const keys = Object.keys(ops);
63
+ const operators = filterValue;
64
+ const keys = Object.keys(operators);
66
65
  if (keys.includes("gte") && keys.includes("lte")) {
67
66
  return {
68
- rows: [
69
- ...state.rows,
70
- {
71
- id: ++nextRowId,
72
- field,
73
- condition: "between",
74
- value: [ops.gte, ops.lte],
75
- },
76
- ],
67
+ id: ++nextRowId,
68
+ field,
69
+ condition: "between",
70
+ value: [operators.gte, operators.lte],
77
71
  };
78
72
  }
79
- if (keys[0]) {
80
- return {
81
- rows: [
82
- ...state.rows,
83
- { id: ++nextRowId, field, condition: keys[0], value: ops[keys[0]] },
84
- ],
85
- };
73
+ if (!keys[0]) {
74
+ return null;
86
75
  }
87
- return state;
76
+ return {
77
+ id: ++nextRowId,
78
+ field,
79
+ condition: keys[0],
80
+ value: operators[keys[0]],
81
+ };
82
+ }
83
+ export function applyPreset(state, descriptor, field, name) {
84
+ const column = descriptor.columns.find((c) => c.key === field);
85
+ const preset = column?.presets?.find((p) => p.name === name);
86
+ if (!preset) {
87
+ return state;
88
+ }
89
+ const rows = [];
90
+ for (const [key, value] of Object.entries(preset.filter)) {
91
+ const row = filterValueToRow(key, value);
92
+ if (row) {
93
+ rows.push(row);
94
+ }
95
+ }
96
+ if (!rows.length) {
97
+ return state;
98
+ }
99
+ return { rows };
88
100
  }
89
101
  export function conditionsFor(descriptor, field) {
90
102
  const column = descriptor.columns.find((c) => c.key === field);
@@ -98,8 +110,9 @@ export function columnFor(descriptor, field) {
98
110
  return column;
99
111
  }
100
112
  function isRowComplete(row) {
101
- if (!row.field || !row.condition || row.value == null)
113
+ if (!row.field || !row.condition || row.value == null) {
102
114
  return false;
115
+ }
103
116
  if (row.condition === "between") {
104
117
  return (Array.isArray(row.value) && row.value[0] != null && row.value[1] != null);
105
118
  }
@@ -111,8 +124,9 @@ function isRowComplete(row) {
111
124
  export function buildResult(state) {
112
125
  const result = {};
113
126
  for (const row of state.rows) {
114
- if (!isRowComplete(row))
127
+ if (!isRowComplete(row)) {
115
128
  continue;
129
+ }
116
130
  if (row.condition === "equals" || row.condition === "is_in") {
117
131
  result[row.field] = row.value;
118
132
  continue;
@@ -126,8 +140,9 @@ export function buildResult(state) {
126
140
  continue;
127
141
  }
128
142
  const operator = conditionToOperator[row.condition];
129
- if (!operator)
143
+ if (!operator) {
130
144
  continue;
145
+ }
131
146
  const existing = result[row.field];
132
147
  if (typeof existing === "object" &&
133
148
  existing !== null &&
@@ -58,11 +58,23 @@ export function createFilterBuilder(collection, config, options) {
58
58
  "not?": "string",
59
59
  "notIn?": "string[]",
60
60
  });
61
+ const dateInput = "number | string | Date";
62
+ const dateOps = type.raw({
63
+ "not?": dateInput,
64
+ "gt?": dateInput,
65
+ "gte?": dateInput,
66
+ "lt?": dateInput,
67
+ "lte?": dateInput,
68
+ "notIn?": `(${dateInput})[]`,
69
+ });
61
70
  const fieldSchemas = {
62
71
  number: type.raw("number").or(type.raw("number[]")).or(numberOps),
63
72
  string: type.raw("string").or(type.raw("string[]")).or(stringOps),
64
73
  boolean: type.raw("boolean"),
65
- date: type.raw("number").or(type.raw("number[]")).or(numberOps),
74
+ date: type
75
+ .raw(dateInput)
76
+ .or(type.raw(`(${dateInput})[]`))
77
+ .or(dateOps),
66
78
  };
67
79
  const descriptor = this.describe();
68
80
  const filterDef = {};
@@ -17,6 +17,11 @@ function coerceValue(raw, columnType) {
17
17
  }
18
18
  return raw;
19
19
  }
20
+ function appendValues(params, key, values) {
21
+ for (const value of values) {
22
+ params.append(key, serializeValue(value));
23
+ }
24
+ }
20
25
  export function serializeFilter(filter, descriptor) {
21
26
  const params = new URLSearchParams();
22
27
  const columnMap = new Map(descriptor.columns.map((c) => [c.key, c]));
@@ -26,12 +31,7 @@ export function serializeFilter(filter, descriptor) {
26
31
  continue;
27
32
  }
28
33
  if (Array.isArray(value)) {
29
- if (value.length === 1) {
30
- params.set(key, serializeValue(value[0]));
31
- }
32
- else if (value.length > 1) {
33
- params.set(key, value.map((v) => serializeValue(v)).join(","));
34
- }
34
+ appendValues(params, key, value);
35
35
  continue;
36
36
  }
37
37
  if (typeof value === "object" && !(value instanceof Date)) {
@@ -40,7 +40,7 @@ export function serializeFilter(filter, descriptor) {
40
40
  continue;
41
41
  }
42
42
  if (Array.isArray(opValue)) {
43
- params.set(`${key}.${op}`, opValue.map((v) => serializeValue(v)).join(","));
43
+ appendValues(params, `${key}.${op}`, opValue);
44
44
  }
45
45
  else {
46
46
  params.set(`${key}.${op}`, serializeValue(opValue));
@@ -55,7 +55,13 @@ export function serializeFilter(filter, descriptor) {
55
55
  export function deserializeFilter(params, descriptor) {
56
56
  const result = {};
57
57
  const columnMap = new Map(descriptor.columns.map((c) => [c.key, c]));
58
- for (const [paramKey, rawValue] of params.entries()) {
58
+ const visited = new Set();
59
+ for (const paramKey of params.keys()) {
60
+ if (visited.has(paramKey)) {
61
+ continue;
62
+ }
63
+ visited.add(paramKey);
64
+ const rawValues = params.getAll(paramKey);
59
65
  const dotIndex = paramKey.indexOf(".");
60
66
  if (dotIndex !== -1) {
61
67
  const field = paramKey.slice(0, dotIndex);
@@ -66,12 +72,10 @@ export function deserializeFilter(params, descriptor) {
66
72
  }
67
73
  const existing = (result[field] ?? {});
68
74
  if (ARRAY_OPERATORS.has(operator)) {
69
- existing[operator] = rawValue
70
- .split(",")
71
- .map((v) => coerceValue(v, column.type));
75
+ existing[operator] = rawValues.map((value) => coerceValue(value, column.type));
72
76
  }
73
77
  else {
74
- existing[operator] = coerceValue(rawValue, column.type);
78
+ existing[operator] = coerceValue(rawValues[0], column.type);
75
79
  }
76
80
  result[field] = existing;
77
81
  continue;
@@ -80,13 +84,11 @@ export function deserializeFilter(params, descriptor) {
80
84
  if (!column) {
81
85
  continue;
82
86
  }
83
- if (rawValue.includes(",")) {
84
- result[paramKey] = rawValue
85
- .split(",")
86
- .map((v) => coerceValue(v, column.type));
87
+ if (rawValues.length > 1) {
88
+ result[paramKey] = rawValues.map((value) => coerceValue(value, column.type));
87
89
  }
88
90
  else {
89
- result[paramKey] = coerceValue(rawValue, column.type);
91
+ result[paramKey] = coerceValue(rawValues[0], column.type);
90
92
  }
91
93
  }
92
94
  return result;
@@ -0,0 +1,7 @@
1
+ import type { AddButtonSlotProps, ConditionSelectSlotProps, FieldSelectSlotProps, PresetButtonSlotProps, RemoveButtonSlotProps, ValueInputSlotProps } from "./filter-builder-types.js";
2
+ export declare function defaultFieldSelect({ columns, value, onChange }: FieldSelectSlotProps): import("react/jsx-runtime").JSX.Element;
3
+ export declare function defaultConditionSelect({ conditions, value, onChange, disabled }: ConditionSelectSlotProps): import("react/jsx-runtime").JSX.Element;
4
+ export declare function defaultValueInput({ column, condition, value, onChange }: ValueInputSlotProps): import("react/jsx-runtime").JSX.Element;
5
+ export declare function defaultRemoveButton({ onClick }: RemoveButtonSlotProps): import("react/jsx-runtime").JSX.Element;
6
+ export declare function defaultAddButton({ onClick }: AddButtonSlotProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function defaultPresetButton({ preset, onClick }: PresetButtonSlotProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ function formatDateForInput(date) {
3
+ if (!(date instanceof Date)) {
4
+ return "";
5
+ }
6
+ return date.toISOString().slice(0, 10);
7
+ }
8
+ function parseDateInput(value) {
9
+ if (!value) {
10
+ return;
11
+ }
12
+ return new Date(value + "T00:00:00Z");
13
+ }
14
+ function parseNumberInput(value) {
15
+ if (!value) {
16
+ return;
17
+ }
18
+ return Number(value);
19
+ }
20
+ function parseBooleanInput(value) {
21
+ if (!value) {
22
+ return;
23
+ }
24
+ return value === "true";
25
+ }
26
+ function asTuple(value) {
27
+ if (Array.isArray(value)) {
28
+ return [value[0], value[1]];
29
+ }
30
+ return [undefined, undefined];
31
+ }
32
+ function renderSelectOptions(options, defaultValue) {
33
+ const allOptions = defaultValue
34
+ ? [{ key: "", label: defaultValue }, ...options]
35
+ : options;
36
+ return allOptions.map((option) => (_jsx("option", { value: option.key, children: option.label }, option.key)));
37
+ }
38
+ export function defaultFieldSelect({ columns, value, onChange, }) {
39
+ return (_jsx("select", { className: "rounded border px-2 py-1", value: value ?? "", onChange: (event) => onChange(event.target.value), children: renderSelectOptions(columns, "Column") }));
40
+ }
41
+ export function defaultConditionSelect({ conditions, value, onChange, disabled, }) {
42
+ return (_jsx("select", { className: "rounded border px-2 py-1", value: value ?? "", onChange: (event) => onChange(event.target.value), disabled: disabled, children: renderSelectOptions(conditions, "Condition") }));
43
+ }
44
+ export function defaultValueInput({ column, condition, value, onChange, }) {
45
+ if (condition === "between" && column.type === "date") {
46
+ const tuple = asTuple(value);
47
+ return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { className: "rounded border px-2 py-1", type: "date", value: formatDateForInput(tuple[0]), onChange: (event) => onChange([parseDateInput(event.target.value), tuple[1]]) }), _jsx("span", { className: "text-sm text-gray-500", children: "and" }), _jsx("input", { className: "rounded border px-2 py-1", type: "date", value: formatDateForInput(tuple[1]), onChange: (event) => onChange([tuple[0], parseDateInput(event.target.value)]) })] }));
48
+ }
49
+ if (condition === "between") {
50
+ const tuple = asTuple(value);
51
+ return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { className: "w-24 rounded border px-2 py-1", type: "number", value: String(tuple[0] ?? ""), onChange: (event) => onChange([parseNumberInput(event.target.value), tuple[1]]) }), _jsx("span", { className: "text-sm text-gray-500", children: "and" }), _jsx("input", { className: "w-24 rounded border px-2 py-1", type: "number", value: String(tuple[1] ?? ""), onChange: (event) => onChange([tuple[0], parseNumberInput(event.target.value)]) })] }));
52
+ }
53
+ if (column.values) {
54
+ const selected = Array.isArray(value) ? value : [];
55
+ return (_jsx("div", { className: "flex flex-wrap gap-2", children: column.values.map((option) => {
56
+ const checked = selected.includes(option.value);
57
+ return (_jsxs("label", { className: "flex cursor-pointer select-none items-center gap-1 text-sm", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: () => onChange(checked
58
+ ? selected.filter((item) => item !== option.value)
59
+ : [...selected, option.value]) }), option.label] }, option.value));
60
+ }) }));
61
+ }
62
+ if (column.type === "date") {
63
+ return (_jsx("input", { className: "rounded border px-2 py-1", type: "date", value: formatDateForInput(value), onChange: (event) => onChange(parseDateInput(event.target.value)) }));
64
+ }
65
+ if (column.type === "number") {
66
+ return (_jsx("input", { className: "rounded border px-2 py-1", type: "number", value: String(value ?? ""), onChange: (event) => onChange(parseNumberInput(event.target.value)) }));
67
+ }
68
+ if (column.type === "boolean") {
69
+ const options = [
70
+ { key: "", label: "Value" },
71
+ { key: "true", label: "true" },
72
+ { key: "false", label: "false" },
73
+ ];
74
+ return (_jsx("select", { className: "rounded border px-2 py-1", value: value == null ? "" : String(value), onChange: (event) => onChange(parseBooleanInput(event.target.value)), children: renderSelectOptions(options) }));
75
+ }
76
+ return (_jsx("input", { className: "rounded border px-2 py-1", type: "text", value: typeof value === "string" ? value : "", onChange: (event) => onChange(event.target.value) }));
77
+ }
78
+ export function defaultRemoveButton({ onClick }) {
79
+ return (_jsx("button", { type: "button", className: "text-red-500 hover:text-red-700", onClick: onClick, children: "\u00D7" }));
80
+ }
81
+ export function defaultAddButton({ onClick }) {
82
+ return (_jsx("button", { type: "button", className: "text-blue-500 hover:text-blue-700", onClick: onClick, children: "+ Add filter" }));
83
+ }
84
+ export function defaultPresetButton({ preset, onClick, }) {
85
+ return (_jsx("button", { type: "button", className: "rounded bg-gray-100 px-2 py-1 text-sm hover:bg-gray-200", onClick: onClick, children: preset.name }));
86
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from "react";
2
+ import type { FilterValue } from "../../filters/filter-state.js";
3
+ import type { FilterDescriptor } from "../../filters/index.js";
4
+ import type { FilterFor } from "../../types.js";
5
+ export type FieldSelectSlotProps = {
6
+ columns: FilterDescriptor["columns"];
7
+ value: string | undefined;
8
+ onChange: (field: string) => void;
9
+ };
10
+ export type ConditionSelectSlotProps = {
11
+ conditions: FilterDescriptor["columns"][number]["conditions"];
12
+ value: string | undefined;
13
+ onChange: (condition: string) => void;
14
+ disabled: boolean;
15
+ };
16
+ export type ValueInputSlotProps = {
17
+ column: FilterDescriptor["columns"][number];
18
+ condition: string;
19
+ value: FilterValue;
20
+ onChange: (value: FilterValue) => void;
21
+ };
22
+ export type RemoveButtonSlotProps = {
23
+ onClick: () => void;
24
+ };
25
+ export type AddButtonSlotProps = {
26
+ onClick: () => void;
27
+ };
28
+ export type PresetButtonSlotProps = {
29
+ preset: {
30
+ field: string;
31
+ name: string;
32
+ };
33
+ onClick: () => void;
34
+ };
35
+ export type RowSlotProps = {
36
+ index: number;
37
+ fieldSelect: ReactNode;
38
+ conditionSelect: ReactNode;
39
+ valueInput: ReactNode | null;
40
+ removeButton: ReactNode;
41
+ };
42
+ export type RootSlotProps = {
43
+ rows: ReactNode;
44
+ addButton: ReactNode;
45
+ presets: ReactNode | null;
46
+ };
47
+ export type FilterBuilderProps<T> = {
48
+ descriptor: FilterDescriptor<T>;
49
+ onChange?: (filter: FilterFor<T>) => void;
50
+ renderRoot?: (props: RootSlotProps) => ReactNode;
51
+ renderRow?: (props: RowSlotProps) => ReactNode;
52
+ renderFieldSelect?: (props: FieldSelectSlotProps) => ReactNode;
53
+ renderConditionSelect?: (props: ConditionSelectSlotProps) => ReactNode;
54
+ renderValueInput?: (props: ValueInputSlotProps) => ReactNode;
55
+ renderAddButton?: (props: AddButtonSlotProps) => ReactNode;
56
+ renderRemoveButton?: (props: RemoveButtonSlotProps) => ReactNode;
57
+ renderPresetButton?: (props: PresetButtonSlotProps) => ReactNode;
58
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { type ReactNode } from "react";
2
+ import type { FilterBuilderProps } from "./filter-builder-types.js";
3
+ export declare function FilterBuilder<T>({ descriptor, onChange, renderRoot, renderRow, renderFieldSelect, renderConditionSelect, renderValueInput, renderAddButton, renderRemoveButton, renderPresetButton }: FilterBuilderProps<T>): ReactNode;
@@ -0,0 +1,40 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment, useEffect, useRef } from "react";
3
+ import { useFilterBuilder } from "../use-filter-builder.js";
4
+ import { defaultAddButton, defaultConditionSelect, defaultFieldSelect, defaultPresetButton, defaultRemoveButton, defaultValueInput, } from "./filter-builder-defaults.js";
5
+ export function FilterBuilder({ descriptor, onChange, renderRoot, renderRow, renderFieldSelect, renderConditionSelect, renderValueInput, renderAddButton, renderRemoveButton, renderPresetButton, }) {
6
+ const filters = useFilterBuilder(descriptor);
7
+ const onChangeRef = useRef(onChange);
8
+ onChangeRef.current = onChange;
9
+ useEffect(() => {
10
+ onChangeRef.current?.(filters.result);
11
+ }, [filters.result]);
12
+ const FieldSelect = renderFieldSelect ?? defaultFieldSelect;
13
+ const ConditionSelect = renderConditionSelect ?? defaultConditionSelect;
14
+ const ValueInput = renderValueInput ?? defaultValueInput;
15
+ const RemoveButton = renderRemoveButton ?? defaultRemoveButton;
16
+ const AddButton = renderAddButton ?? defaultAddButton;
17
+ const PresetButton = renderPresetButton ?? defaultPresetButton;
18
+ const rows = filters.rows.map((row, index) => {
19
+ const fieldSelect = (_jsx(FieldSelect, { columns: filters.columns, value: row.field, onChange: (field) => filters.setField(index, field) }));
20
+ const conditionSelect = (_jsx(ConditionSelect, { conditions: row.field ? filters.conditionsFor(row.field) : [], value: row.condition, onChange: (condition) => filters.setCondition(index, condition), disabled: !row.field }));
21
+ const valueInput = row.field && row.condition ? (_jsx(ValueInput, { column: filters.columnFor(row.field), condition: row.condition, value: row.value, onChange: (value) => filters.setValue(index, value) })) : null;
22
+ const removeButton = _jsx(RemoveButton, { onClick: () => filters.remove(index) });
23
+ if (renderRow) {
24
+ return (_jsx(Fragment, { children: renderRow({
25
+ index,
26
+ fieldSelect,
27
+ conditionSelect,
28
+ valueInput,
29
+ removeButton,
30
+ }) }, row.id));
31
+ }
32
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [fieldSelect, conditionSelect, valueInput, removeButton] }, row.id));
33
+ });
34
+ const addButton = _jsx(AddButton, { onClick: filters.add });
35
+ const presets = filters.presets.length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-1 border-t pt-2", children: filters.presets.map((preset) => (_jsx(Fragment, { children: _jsx(PresetButton, { preset: preset, onClick: () => filters.applyPreset(preset.field, preset.name) }) }, `${preset.field}-${preset.name}`))) })) : null;
36
+ if (renderRoot) {
37
+ return _jsx(_Fragment, { children: renderRoot({ rows: _jsx(_Fragment, { children: rows }), addButton, presets }) });
38
+ }
39
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [rows, addButton, presets] }));
40
+ }
@@ -1,12 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useEffect, useRef } from "react";
3
3
  import { useFilterBuilder } from "./use-filter-builder.js";
4
- function defaultFieldSelect({ columns, value, onChange, }) {
5
- return (_jsxs("select", { className: "rounded border px-2 py-1", value: value ?? "", onChange: (e) => onChange(e.target.value), children: [_jsx("option", { value: "", children: "Column" }), columns.map((col) => (_jsx("option", { value: col.key, children: col.label }, col.key)))] }));
6
- }
7
- function defaultConditionSelect({ conditions, value, onChange, disabled, }) {
8
- return (_jsxs("select", { className: "rounded border px-2 py-1", value: value ?? "", onChange: (e) => onChange(e.target.value), disabled: disabled, children: [_jsx("option", { value: "", children: "Condition" }), conditions.map((c) => (_jsx("option", { value: c.key, children: c.label }, c.key)))] }));
9
- }
4
+ // ======== Helpers ========
10
5
  function formatDateForInput(date) {
11
6
  if (!(date instanceof Date))
12
7
  return "";
@@ -23,6 +18,18 @@ function asTuple(value) {
23
18
  }
24
19
  return [undefined, undefined];
25
20
  }
21
+ function renderSelectOptions(options, defaultValue) {
22
+ const opts = defaultValue
23
+ ? [{ key: "", label: defaultValue }, ...options]
24
+ : options;
25
+ return opts.map((o) => (_jsx("option", { value: o.key, children: o.label }, o.key)));
26
+ }
27
+ function defaultFieldSelect({ columns, value, onChange, }) {
28
+ return (_jsx("select", { className: "rounded border px-2 py-1", value: value ?? "", onChange: (e) => onChange(e.target.value), children: renderSelectOptions(columns, "Column") }));
29
+ }
30
+ function defaultConditionSelect({ conditions, value, onChange, disabled, }) {
31
+ return (_jsx("select", { className: "rounded border px-2 py-1", value: value ?? "", onChange: (e) => onChange(e.target.value), disabled: disabled, children: renderSelectOptions(conditions, "Condition") }));
32
+ }
26
33
  function defaultValueInput({ column, condition, value, onChange, }) {
27
34
  if (condition === "between" && column.type === "date") {
28
35
  const tuple = asTuple(value);
@@ -36,7 +43,7 @@ function defaultValueInput({ column, condition, value, onChange, }) {
36
43
  const selected = Array.isArray(value) ? value : [];
37
44
  return (_jsx("div", { className: "flex flex-wrap gap-2", children: column.values.map((v) => {
38
45
  const checked = selected.includes(v.value);
39
- return (_jsxs("label", { className: "flex items-center gap-1 text-sm", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: () => onChange(checked
46
+ return (_jsxs("label", { className: "flex cursor-pointer select-none items-center gap-1 text-sm", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: () => onChange(checked
40
47
  ? selected.filter((s) => s !== v.value)
41
48
  : [...selected, v.value]) }), v.label] }, v.value));
42
49
  }) }));
@@ -48,7 +55,12 @@ function defaultValueInput({ column, condition, value, onChange, }) {
48
55
  return (_jsx("input", { className: "rounded border px-2 py-1", type: "number", value: String(value ?? ""), onChange: (e) => onChange(Number(e.target.value)) }));
49
56
  }
50
57
  if (column.type === "boolean") {
51
- return (_jsxs("select", { className: "rounded border px-2 py-1", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "true"), children: [_jsx("option", { value: "", children: "Value" }), _jsx("option", { value: "true", children: "true" }), _jsx("option", { value: "false", children: "false" })] }));
58
+ const options = [
59
+ { key: "", label: "Value" },
60
+ { key: "true", label: "true" },
61
+ { key: "false", label: "false" },
62
+ ];
63
+ return (_jsx("select", { className: "rounded border px-2 py-1", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "true"), children: renderSelectOptions(options) }));
52
64
  }
53
65
  return (_jsx("input", { className: "rounded border px-2 py-1", type: "text", value: typeof value === "string" ? value : "", onChange: (e) => onChange(e.target.value) }));
54
66
  }
@@ -1,4 +1,4 @@
1
- export { FilterBuilder } from "./filter-builder.js";
2
- export type { AddButtonSlotProps, ConditionSelectSlotProps, FieldSelectSlotProps, PresetButtonSlotProps, RemoveButtonSlotProps, RootSlotProps, RowSlotProps, ValueInputSlotProps, } from "./filter-builder.js";
1
+ export { FilterBuilder } from "./filter-builder/filter-builder.js";
2
+ export type { AddButtonSlotProps, ConditionSelectSlotProps, FieldSelectSlotProps, PresetButtonSlotProps, RemoveButtonSlotProps, RootSlotProps, RowSlotProps, ValueInputSlotProps, } from "./filter-builder/filter-builder-types.js";
3
3
  export type { FilterValue } from "../filters/filter-state.js";
4
4
  export { useFilterBuilder } from "./use-filter-builder.js";
@@ -1,2 +1,2 @@
1
- export { FilterBuilder } from "./filter-builder.js";
1
+ export { FilterBuilder } from "./filter-builder/filter-builder.js";
2
2
  export { useFilterBuilder } from "./use-filter-builder.js";
@@ -8,6 +8,7 @@ type UseFilterBuilderReturn = {
8
8
  columns: FilterDescriptor["columns"];
9
9
  rows: FilterRow[];
10
10
  add: () => void;
11
+ addWithField: (field: string) => void;
11
12
  remove: (index: number) => void;
12
13
  setField: (index: number, field: string) => void;
13
14
  setCondition: (index: number, condition: string) => void;
@@ -1,5 +1,5 @@
1
1
  import { useMemo, useState } from "react";
2
- import { addRow, applyPreset as applyPresetState, buildResult, clearState, columnFor, conditionsFor, createInitialState, removeRow, setRowCondition, setRowField, setRowValue, } from "../filters/filter-state.js";
2
+ import { addRow, addRowWithField, applyPreset as applyPresetState, buildResult, clearState, columnFor, conditionsFor, createInitialState, removeRow, setRowCondition, setRowField, setRowValue, } from "../filters/filter-state.js";
3
3
  export function useFilterBuilder(descriptor) {
4
4
  const [state, setState] = useState(createInitialState);
5
5
  const presets = useMemo(() => descriptor.columns.flatMap((col) => (col.presets ?? []).map((p) => ({ field: col.key, name: p.name }))), [descriptor]);
@@ -8,6 +8,7 @@ export function useFilterBuilder(descriptor) {
8
8
  columns: descriptor.columns,
9
9
  rows: state.rows,
10
10
  add: () => setState(addRow),
11
+ addWithField: (field) => setState((s) => addRowWithField(s, field)),
11
12
  remove: (index) => setState((s) => removeRow(s, index)),
12
13
  setField: (index, field) => setState((s) => setRowField(s, index, field)),
13
14
  setCondition: (index, condition) => setState((s) => setRowCondition(s, index, condition)),
@@ -1,6 +1,14 @@
1
1
  export const DateTransformer = {
2
2
  match: (expr, domain) => expr === "Date" || domain === "Date",
3
3
  storageType: "int64",
4
- serialize: (date) => date.getTime(),
4
+ serialize: (date) => {
5
+ if (typeof date === "number") {
6
+ return date;
7
+ }
8
+ if (typeof date === "string") {
9
+ return new Date(date).getTime();
10
+ }
11
+ return date.getTime();
12
+ },
5
13
  deserialize: (ts) => new Date(ts),
6
14
  };
package/dist/tsense.d.ts CHANGED
@@ -76,17 +76,25 @@ export declare class TSense<T extends Type> {
76
76
  syncSchema(): Promise<void>;
77
77
  private buildObjectFilter;
78
78
  private buildFilter;
79
+ private validateFilterFields;
79
80
  private validateFields;
81
+ private buildFilterExpression;
82
+ private combineFilterExpressions;
80
83
  private buildSort;
81
84
  create(): Promise<this>;
82
85
  drop(): Promise<void>;
83
86
  get(id: string): Promise<T["infer"] | null>;
84
87
  delete(id: string): Promise<boolean>;
88
+ private deleteManyWithFilterBy;
85
89
  deleteMany(filter: FilterFor<T["infer"]>): Promise<DeleteResult>;
86
90
  update(id: string, data: Partial<T["infer"]>): Promise<T["infer"]>;
91
+ private updateManyWithFilterBy;
87
92
  updateMany(filter: FilterFor<T["infer"]>, data: Partial<T["infer"]>): Promise<UpdateResult>;
93
+ private searchWithFilterBy;
88
94
  search<const O extends SearchOptions<T["infer"]> = SearchOptionsPlain<T["infer"]>>(options: O): Promise<SearchResult<ProjectSearch<T["infer"], O>>>;
95
+ private searchListWithFilterBy;
89
96
  searchList(options: SearchListOptions<T["infer"]>): Promise<SearchListResult<T["infer"]>>;
97
+ private countWithFilterBy;
90
98
  count(filter?: FilterFor<T["infer"]>): Promise<number>;
91
99
  upsert(docs: T["infer"] | T["infer"][]): Promise<UpsertResult[]>;
92
100
  syncData(options?: SyncOptions): Promise<SyncResult>;
package/dist/tsense.js CHANGED
@@ -72,6 +72,11 @@ export class TSense {
72
72
  }
73
73
  serializeDoc(doc) {
74
74
  const result = { ...doc };
75
+ for (const key of Object.keys(result)) {
76
+ if (result[key] == null) {
77
+ delete result[key];
78
+ }
79
+ }
75
80
  for (const [field, transformer] of this.fieldTransformers) {
76
81
  if (result[field] != null) {
77
82
  result[field] = transformer.serialize(result[field]);
@@ -204,8 +209,14 @@ export class TSense {
204
209
  const orParts = [];
205
210
  for (const condition of rawValue) {
206
211
  const inner = this.buildFilter(condition);
212
+ if (!inner.length) {
213
+ continue;
214
+ }
207
215
  orParts.push(`(${inner.join("&&")})`);
208
216
  }
217
+ if (!orParts.length) {
218
+ continue;
219
+ }
209
220
  result.push(`(${orParts.join("||")})`);
210
221
  continue;
211
222
  }
@@ -227,6 +238,28 @@ export class TSense {
227
238
  }
228
239
  return result;
229
240
  }
241
+ validateFilterFields(filter) {
242
+ if (!filter) {
243
+ return;
244
+ }
245
+ const fields = [];
246
+ for (const [key, value] of Object.entries(filter)) {
247
+ if (value == null) {
248
+ continue;
249
+ }
250
+ if (key === "OR") {
251
+ for (const condition of value) {
252
+ this.validateFilterFields(condition);
253
+ }
254
+ continue;
255
+ }
256
+ fields.push(key);
257
+ }
258
+ if (!fields.length) {
259
+ return;
260
+ }
261
+ this.validateFields(fields);
262
+ }
230
263
  validateFields(fields) {
231
264
  const valid = new Set(this.fields.map((f) => f.name));
232
265
  for (const field of fields) {
@@ -235,6 +268,20 @@ export class TSense {
235
268
  }
236
269
  }
237
270
  }
271
+ buildFilterExpression(filter) {
272
+ this.validateFilterFields(filter);
273
+ const parts = this.buildFilter(filter);
274
+ if (!parts.length) {
275
+ return;
276
+ }
277
+ return `(${parts.join("&&")})`;
278
+ }
279
+ combineFilterExpressions(...filters) {
280
+ return filters
281
+ .map((filter) => this.buildFilterExpression(filter))
282
+ .filter((filter) => filter != null)
283
+ .join("&&");
284
+ }
238
285
  buildSort(options) {
239
286
  if (!options.sortBy)
240
287
  return;
@@ -292,9 +339,8 @@ export class TSense {
292
339
  });
293
340
  return data != null;
294
341
  }
295
- async deleteMany(filter) {
342
+ async deleteManyWithFilterBy(filterBy) {
296
343
  await this.ensureSynced();
297
- const filterBy = this.buildFilter(filter).join("&&");
298
344
  if (!filterBy) {
299
345
  throw new Error("FILTER_REQUIRED");
300
346
  }
@@ -305,6 +351,9 @@ export class TSense {
305
351
  });
306
352
  return { deleted: data.num_deleted };
307
353
  }
354
+ async deleteMany(filter) {
355
+ return await this.deleteManyWithFilterBy(this.combineFilterExpressions(filter));
356
+ }
308
357
  async update(id, data) {
309
358
  await this.ensureSynced();
310
359
  const serialized = this.serializeDoc(data);
@@ -315,9 +364,8 @@ export class TSense {
315
364
  });
316
365
  return this.deserializeDoc(updated);
317
366
  }
318
- async updateMany(filter, data) {
367
+ async updateManyWithFilterBy(filterBy, data) {
319
368
  await this.ensureSynced();
320
- const filterBy = this.buildFilter(filter).join("&&");
321
369
  if (!filterBy) {
322
370
  throw new Error("FILTER_REQUIRED");
323
371
  }
@@ -330,7 +378,10 @@ export class TSense {
330
378
  });
331
379
  return { updated: result.num_updated };
332
380
  }
333
- async search(options) {
381
+ async updateMany(filter, data) {
382
+ return await this.updateManyWithFilterBy(this.combineFilterExpressions(filter), data);
383
+ }
384
+ async searchWithFilterBy(options, filterBy) {
334
385
  await this.ensureSynced();
335
386
  const queryByFields = options.queryBy ?? [
336
387
  this.options.defaultSearchField,
@@ -352,7 +403,6 @@ export class TSense {
352
403
  const sortBy = this.buildSort(options);
353
404
  if (sortBy)
354
405
  params.sort_by = sortBy;
355
- const filterBy = this.buildFilter(options.filter).join("&&");
356
406
  if (filterBy)
357
407
  params.filter_by = filterBy;
358
408
  if (options.page != null)
@@ -418,17 +468,19 @@ export class TSense {
418
468
  scores,
419
469
  };
420
470
  }
421
- async searchList(options) {
471
+ async search(options) {
472
+ return await this.searchWithFilterBy(options, this.combineFilterExpressions(options.filter));
473
+ }
474
+ async searchListWithFilterBy(options, filterBy) {
422
475
  const page = options.cursor ? Number(options.cursor) : 1;
423
476
  const limit = Math.min(options.limit ?? 20, 100);
424
- const result = await this.search({
477
+ const result = await this.searchWithFilterBy({
425
478
  query: options.query,
426
479
  queryBy: options.queryBy,
427
- filter: options.filter,
428
480
  sortBy: [options.sortBy],
429
481
  page,
430
482
  limit,
431
- });
483
+ }, filterBy);
432
484
  const hasMore = page * limit < result.count;
433
485
  return {
434
486
  data: result.data,
@@ -436,9 +488,11 @@ export class TSense {
436
488
  total: result.count,
437
489
  };
438
490
  }
439
- async count(filter) {
491
+ async searchList(options) {
492
+ return await this.searchListWithFilterBy(options, this.combineFilterExpressions(options.filter));
493
+ }
494
+ async countWithFilterBy(filterBy) {
440
495
  await this.ensureSynced();
441
- const filterBy = this.buildFilter(filter).join("&&");
442
496
  if (!filterBy) {
443
497
  const { data } = await this.axios({
444
498
  method: "GET",
@@ -459,6 +513,9 @@ export class TSense {
459
513
  });
460
514
  return data.found;
461
515
  }
516
+ async count(filter) {
517
+ return await this.countWithFilterBy(this.combineFilterExpressions(filter));
518
+ }
462
519
  async upsert(docs) {
463
520
  await this.ensureSynced();
464
521
  const items = Array.isArray(docs) ? docs : [docs];
@@ -538,17 +595,11 @@ export class TSense {
538
595
  }
539
596
  scoped(baseFilter) {
540
597
  return {
541
- search: (options) => this.search({
542
- ...options,
543
- filter: { ...options.filter, ...baseFilter },
544
- }),
545
- searchList: (options) => this.searchList({
546
- ...options,
547
- filter: { ...options.filter, ...baseFilter },
548
- }),
549
- count: (filter) => this.count({ ...filter, ...baseFilter }),
550
- deleteMany: (filter) => this.deleteMany({ ...filter, ...baseFilter }),
551
- updateMany: (filter, data) => this.updateMany({ ...filter, ...baseFilter }, data),
598
+ search: (options) => this.searchWithFilterBy(options, this.combineFilterExpressions(baseFilter, options.filter)),
599
+ searchList: (options) => this.searchListWithFilterBy(options, this.combineFilterExpressions(baseFilter, options.filter)),
600
+ count: (filter) => this.countWithFilterBy(this.combineFilterExpressions(baseFilter, filter)),
601
+ deleteMany: (filter) => this.deleteManyWithFilterBy(this.combineFilterExpressions(baseFilter, filter)),
602
+ updateMany: (filter, data) => this.updateManyWithFilterBy(this.combineFilterExpressions(baseFilter, filter), data),
552
603
  };
553
604
  }
554
605
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsense",
3
- "version": "0.2.0-next.0",
3
+ "version": "0.2.0-next.3",
4
4
  "private": false,
5
5
  "description": "Opinionated, fully typed typesense client",
6
6
  "keywords": [