@warkypublic/svelix 0.1.36 → 0.1.37

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.
@@ -18,6 +18,7 @@
18
18
  clearable = true,
19
19
  data = [],
20
20
  dataSource: dataSourceProp = undefined,
21
+ debounceMs = 300,
21
22
  disablePortal = false,
22
23
  disabled,
23
24
  error,
@@ -28,10 +29,12 @@
28
29
  onAPICall: onAPICallProp = undefined,
29
30
  onBufferChange,
30
31
  onChange,
32
+ onResolveValues: onResolveValuesProp = undefined,
31
33
  openOnClear,
32
34
  pageSize = 50,
33
35
  placeholder,
34
36
  rightSection,
37
+ searchColumns,
35
38
  selectFirst,
36
39
  showAll,
37
40
  value = $bindable<any>(undefined),
@@ -41,13 +44,23 @@
41
44
  const inputId = `${instanceId}-input`;
42
45
  const listboxId = `${instanceId}-listbox`;
43
46
 
44
- // Derive effective dataSource and onAPICall from adapter when not explicitly provided
47
+ // Derive effective dataSource, onAPICall, resolver from adapter when not explicitly provided.
45
48
  const dataSource = dataSourceProp ?? (adapter ? "server" : "local");
46
49
  const onAPICall =
47
50
  onAPICallProp ??
48
51
  (adapter
49
- ? (params: { page: number; pageSize: number; search?: string }) =>
50
- adapter!.fetch(params)
52
+ ? (params: {
53
+ page: number;
54
+ pageSize: number;
55
+ search?: string;
56
+ signal?: AbortSignal;
57
+ }) => adapter!.fetch(params)
58
+ : undefined);
59
+ const onResolveValues =
60
+ onResolveValuesProp ??
61
+ (adapter?.resolveByValue
62
+ ? (values: any[], signal?: AbortSignal) =>
63
+ adapter!.resolveByValue!(values, signal)
51
64
  : undefined);
52
65
 
53
66
  // Create store once with initial props
@@ -63,9 +76,11 @@
63
76
  onAPICall,
64
77
  onBufferChange,
65
78
  onChange,
79
+ onResolveValues,
66
80
  openOnClear,
67
81
  pageSize,
68
82
  placeholder,
83
+ searchColumns,
69
84
  selectFirst,
70
85
  showAll,
71
86
  value,
@@ -76,6 +91,7 @@
76
91
  const storeSearch = $derived($store.search);
77
92
  const storeOpened = $derived($store.opened);
78
93
  const storeBoxerData = $derived($store.boxerData);
94
+ const storeSelectedItems = $derived($store.selectedItems);
79
95
  const storeInput = $derived($store.input);
80
96
  const boxerDataLength = $derived($store.boxerData.length);
81
97
 
@@ -145,62 +161,141 @@
145
161
  }
146
162
  });
147
163
 
148
- // Debounced search — only reacts to actual search text changes, not unrelated store updates.
164
+ // Debounced search — only reacts to actual search text changes.
149
165
  let searchTimeout: ReturnType<typeof setTimeout> | undefined;
166
+ let didInitialFetch = false;
150
167
 
151
168
  $effect(() => {
152
169
  const search = storeSearch;
170
+ // Skip the initial tick: the initial-fetch effect below handles first load.
171
+ if (!didInitialFetch) return;
153
172
  clearTimeout(searchTimeout);
154
173
  searchTimeout = setTimeout(() => {
155
174
  if (search !== undefined && storeOpened) {
156
175
  store.fetchData(search, true);
176
+ parentEl?.scrollTo?.({ top: 0 });
157
177
  }
158
- }, 300);
178
+ }, debounceMs);
159
179
  return () => clearTimeout(searchTimeout);
160
180
  });
161
181
 
162
- // Initial data fetch
182
+ // Initial data fetch + resolve labels for preloaded value(s).
163
183
  $effect(() => {
184
+ didInitialFetch = true;
164
185
  store.fetchData("", true);
186
+ resolvePreloadedValue(value);
187
+ return () => {
188
+ clearTimeout(searchTimeout);
189
+ store.cancel();
190
+ };
165
191
  });
166
192
 
167
- // Sync value -> input label
193
+ let lastResolvedKey: string | null = null;
194
+ async function resolvePreloadedValue(val: unknown) {
195
+ if (val == null || val === "" || (Array.isArray(val) && val.length === 0)) return;
196
+ if (!onResolveValues) return;
197
+
198
+ const values = Array.isArray(val) ? val : [val];
199
+ const key = JSON.stringify(values);
200
+ if (key === lastResolvedKey) return;
201
+ lastResolvedKey = key;
202
+
203
+ try {
204
+ const items = await onResolveValues(values);
205
+ if (!items || items.length === 0) return;
206
+ if (multiSelect) {
207
+ store.setSelectedItems(items);
208
+ } else {
209
+ store.setSelectedItems(items.slice(0, 1));
210
+ }
211
+ } catch (err) {
212
+ console.error("Boxer resolveByValue error:", err);
213
+ }
214
+ }
215
+
216
+ // When value prop changes from outside, resolve any new labels we don't yet have.
168
217
  $effect(() => {
169
- const boxerData = storeBoxerData;
218
+ void value;
219
+ untrack(() => {
220
+ if (value == null) {
221
+ store.clearSelectedItems();
222
+ return;
223
+ }
224
+ const knownKeys = new Set(
225
+ $store.selectedItems.map((i) => JSON.stringify(i.value)),
226
+ );
227
+ const needed = (Array.isArray(value) ? value : [value]).filter(
228
+ (v) => !knownKeys.has(JSON.stringify(v)),
229
+ );
230
+ if (needed.length === 0) {
231
+ if (!multiSelect) {
232
+ // Keep the single-item list in sync with the chosen value.
233
+ const match = $store.selectedItems.find(
234
+ (i) => JSON.stringify(i.value) === JSON.stringify(value),
235
+ );
236
+ if (match) store.setSelectedItems([match]);
237
+ } else {
238
+ // Drop any selected items whose value is no longer in `value`.
239
+ const keep = new Set((value as any[]).map((v) => JSON.stringify(v)));
240
+ const filtered = $store.selectedItems.filter((i) =>
241
+ keep.has(JSON.stringify(i.value)),
242
+ );
243
+ if (filtered.length !== $store.selectedItems.length) {
244
+ store.setSelectedItems(filtered);
245
+ }
246
+ }
247
+ return;
248
+ }
249
+ // Fall back to boxerData, then to the resolver.
250
+ const fromBoxer = $store.boxerData.filter((i) =>
251
+ needed.some((v) => JSON.stringify(v) === JSON.stringify(i.value)),
252
+ );
253
+ fromBoxer.forEach((i) => store.addSelectedItem(i));
254
+ const stillMissing = needed.filter(
255
+ (v) => !fromBoxer.some((i) => JSON.stringify(i.value) === JSON.stringify(v)),
256
+ );
257
+ if (stillMissing.length > 0) resolvePreloadedValue(stillMissing);
258
+ });
259
+ });
260
+
261
+ // Sync displayed input + emit onBufferChange from selectedItems (single source of truth).
262
+ $effect(() => {
263
+ const selected = storeSelectedItems;
170
264
  const opened = storeOpened;
171
265
  const currentInput = storeInput;
172
266
 
173
267
  if (multiSelect) {
174
- const labels = boxerData
175
- .filter(
176
- (item: BoxerItem) =>
177
- Array.isArray(value) && value.includes(item.value),
178
- )
179
- .map((item: BoxerItem) => item.label)
180
- .join(", ");
181
- if (!opened && currentInput !== labels) {
182
- store.setInput(labels);
183
- }
268
+ const labels = selected.map((i) => i.label).join(", ");
269
+ if (!opened && currentInput !== labels) store.setInput(labels);
270
+ onBufferChange?.(selected);
184
271
  } else {
185
- const found = boxerData.find((item: BoxerItem) => item.value === value);
186
- if (found && currentInput !== found.label && !opened) {
187
- store.setInput(found.label);
188
- } else if (!value && !opened && currentInput !== "") {
272
+ const item = selected[0] ?? null;
273
+ if (item && currentInput !== item.label && !opened) {
274
+ store.setInput(item.label);
275
+ } else if (!item && !opened && currentInput !== "") {
189
276
  store.setInput("");
190
277
  }
278
+ onBufferChange?.(item);
191
279
  }
280
+ });
192
281
 
193
- // Buffer change
194
- if (multiSelect) {
195
- const buffer = boxerData.filter(
196
- (item: BoxerItem) => Array.isArray(value) && value.includes(item.value),
282
+ // When data arrives, opportunistically enrich selectedItems for multiselect
283
+ // (adds labels for items that just loaded).
284
+ $effect(() => {
285
+ const boxerData = storeBoxerData;
286
+ if (!multiSelect || !Array.isArray(value) || value.length === 0) return;
287
+ untrack(() => {
288
+ const known = new Set(
289
+ $store.selectedItems.map((i) => JSON.stringify(i.value)),
197
290
  );
198
- onBufferChange?.(buffer);
199
- } else {
200
- const buffer =
201
- boxerData.find((item: BoxerItem) => item.value === value) ?? null;
202
- onBufferChange?.(buffer);
203
- }
291
+ for (const v of value as any[]) {
292
+ if (known.has(JSON.stringify(v))) continue;
293
+ const match = boxerData.find(
294
+ (i) => JSON.stringify(i.value) === JSON.stringify(v),
295
+ );
296
+ if (match) store.addSelectedItem(match);
297
+ }
298
+ });
204
299
  });
205
300
 
206
301
  // Select first
@@ -220,17 +315,17 @@
220
315
  if (multiSelect) {
221
316
  const currentValues = Array.isArray(value) ? value : [];
222
317
  const isSelected = currentValues.includes(option.value);
223
- const newValues = isSelected
224
- ? currentValues.filter((v: unknown) => v !== option.value)
225
- : [...currentValues, option.value];
226
- value = newValues;
227
- onChange?.(newValues);
228
- const newBuffer = $store.boxerData.filter((item: BoxerItem) =>
229
- newValues.includes(item.value),
230
- );
231
- onBufferChange?.(newBuffer);
318
+ if (isSelected) {
319
+ value = currentValues.filter((v: unknown) => v !== option.value);
320
+ store.removeSelectedItem(option.value);
321
+ } else {
322
+ value = [...currentValues, option.value];
323
+ store.addSelectedItem(option);
324
+ }
325
+ onChange?.(value);
232
326
  } else {
233
327
  value = option.value;
328
+ store.setSelectedItems([option]);
234
329
  onChange?.(option.value);
235
330
  store.setSearch("");
236
331
  store.setInput(option.label);
@@ -244,6 +339,7 @@
244
339
  onOptionSubmit(0);
245
340
  } else {
246
341
  value = multiSelect ? [] : null;
342
+ store.clearSelectedItems();
247
343
  onChange?.(value);
248
344
  store.setSearch("");
249
345
  store.setInput("");
@@ -256,8 +352,11 @@
256
352
  }
257
353
 
258
354
  function focusDropdownItem(index: number) {
259
- const clamped = Math.max(0, Math.min(index, $store.boxerData.length - 1));
355
+ const length = $store.boxerData.length;
356
+ if (length === 0) return;
357
+ const clamped = Math.max(0, Math.min(index, length - 1));
260
358
  activeOptionIndex = clamped;
359
+ rawVirtualizer?.scrollToIndex(clamped, { align: "auto" });
261
360
  requestAnimationFrame(() => {
262
361
  const el = parentEl?.querySelector<HTMLDivElement>(
263
362
  `[data-index="${clamped}"]`,
@@ -314,10 +413,13 @@
314
413
  }
315
414
 
316
415
  function onDropdownItemKeydown(e: KeyboardEvent, index: number) {
416
+ const length = $store.boxerData.length;
417
+ const pageSizeNav = Math.max(1, Math.floor(mah / 36));
418
+
317
419
  switch (e.key) {
318
420
  case "ArrowDown":
319
421
  e.preventDefault();
320
- if (index < $store.boxerData.length - 1) focusDropdownItem(index + 1);
422
+ if (index < length - 1) focusDropdownItem(index + 1);
321
423
  break;
322
424
  case "ArrowUp":
323
425
  e.preventDefault();
@@ -328,6 +430,22 @@
328
430
  focusDropdownItem(index - 1);
329
431
  }
330
432
  break;
433
+ case "Home":
434
+ e.preventDefault();
435
+ focusDropdownItem(0);
436
+ break;
437
+ case "End":
438
+ e.preventDefault();
439
+ focusDropdownItem(length - 1);
440
+ break;
441
+ case "PageDown":
442
+ e.preventDefault();
443
+ focusDropdownItem(Math.min(length - 1, index + pageSizeNav));
444
+ break;
445
+ case "PageUp":
446
+ e.preventDefault();
447
+ focusDropdownItem(Math.max(0, index - pageSizeNav));
448
+ break;
331
449
  case "Enter":
332
450
  case " ":
333
451
  e.preventDefault();
@@ -402,9 +520,13 @@
402
520
  return value;
403
521
  }
404
522
  export function open() {
523
+ if (disabled) return;
405
524
  store.setOpened(true);
406
525
  activeOptionIndex = null;
407
526
  }
527
+ export function refetch() {
528
+ return store.refetch();
529
+ }
408
530
  export function setValue(val: unknown) {
409
531
  value = val;
410
532
  onChange?.(val);
@@ -504,7 +626,7 @@
504
626
  {clearable}
505
627
  controlsId={listboxId}
506
628
  {disabled}
507
- {error}
629
+ error={error ?? $store.error ?? undefined}
508
630
  expanded={$store.opened}
509
631
  {inputId}
510
632
  {label}
@@ -555,7 +677,23 @@
555
677
  style:left={!effectiveDisablePortal ? `${popupLeft}px` : undefined}
556
678
  style:width={!effectiveDisablePortal ? `${popupWidth}px` : undefined}
557
679
  >
558
- {#if $store.boxerData.length > 0}
680
+ {#if $store.error}
681
+ <div
682
+ aria-live="polite"
683
+ class="px-3 py-2 text-sm text-error-500 flex items-center justify-between gap-2"
684
+ data-testid="boxer-error"
685
+ role="alert"
686
+ >
687
+ <span>{$store.error}</span>
688
+ <button
689
+ class="btn btn-sm preset-tonal"
690
+ type="button"
691
+ onclick={() => store.refetch()}
692
+ >
693
+ retry
694
+ </button>
695
+ </div>
696
+ {:else if $store.boxerData.length > 0}
559
697
  <div
560
698
  bind:this={parentEl}
561
699
  class="overflow-auto"
@@ -603,6 +741,15 @@
603
741
  </div>
604
742
  </div>
605
743
  </div>
744
+ {:else if $store.isFetching}
745
+ <div
746
+ aria-live="polite"
747
+ class="px-3 py-2 text-sm text-surface-500"
748
+ data-testid="boxer-loading"
749
+ role="status"
750
+ >
751
+ loading...
752
+ </div>
606
753
  {:else}
607
754
  <div
608
755
  aria-live="polite"
@@ -5,6 +5,7 @@ declare const Boxer: import("svelte").Component<BoxerProps, {
5
5
  focus: () => void;
6
6
  getValue: () => any;
7
7
  open: () => void;
8
+ refetch: () => Promise<void>;
8
9
  setValue: (val: unknown) => void;
9
10
  }, "value">;
10
11
  type Boxer = ReturnType<typeof Boxer>;
@@ -0,0 +1,31 @@
1
+ import type { Options } from '@warkypublic/resolvespec-js';
2
+ import type { BoxerAdapterConfig, BoxerFetchParams, BoxerItem, BoxerServerAdapter } from './types.js';
3
+ type ReadFn = (schema: string, entity: string, id: undefined, options: Options) => Promise<any>;
4
+ interface ResolvedAdapterConfig extends BoxerAdapterConfig {
5
+ labelField: string;
6
+ valueField: string;
7
+ searchOperator: string;
8
+ }
9
+ /**
10
+ * Shared implementation for server-backed adapters. Subclasses plug in a
11
+ * concrete resolvespec client by providing a `read` function.
12
+ */
13
+ export declare abstract class BoxerBaseAdapter implements BoxerServerAdapter {
14
+ protected readonly config: ResolvedAdapterConfig;
15
+ constructor(config: BoxerAdapterConfig);
16
+ protected abstract read: ReadFn;
17
+ fetch({ page, pageSize, search, signal }: BoxerFetchParams): Promise<{
18
+ data: BoxerItem[];
19
+ total: number;
20
+ }>;
21
+ resolveByValue(values: any[], signal?: AbortSignal): Promise<BoxerItem[]>;
22
+ private resolveColumns;
23
+ /**
24
+ * Each search column is emitted with `logic_operator: 'OR'` so the backend
25
+ * treats them as a single OR group, AND-combined with any base filters.
26
+ * This matches the convention used elsewhere in the project (see SvarkGrid).
27
+ */
28
+ private buildSearchFilters;
29
+ private mapRow;
30
+ }
31
+ export {};
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared implementation for server-backed adapters. Subclasses plug in a
3
+ * concrete resolvespec client by providing a `read` function.
4
+ */
5
+ export class BoxerBaseAdapter {
6
+ config;
7
+ constructor(config) {
8
+ this.config = {
9
+ labelField: 'label',
10
+ valueField: 'id',
11
+ searchOperator: 'ilike',
12
+ ...config,
13
+ };
14
+ }
15
+ async fetch({ page, pageSize, search, signal }) {
16
+ signal?.throwIfAborted?.();
17
+ const { schema, entity, labelField, valueField, columns, sort, filters, searchColumns, searchOperator, mapItem } = this.config;
18
+ const cols = this.resolveColumns(columns, labelField, valueField);
19
+ const searchFilters = this.buildSearchFilters(search, labelField, searchColumns, searchOperator);
20
+ const options = {
21
+ columns: cols,
22
+ sort: sort ?? [],
23
+ filters: [...(filters ?? []), ...searchFilters],
24
+ limit: pageSize,
25
+ offset: page * pageSize,
26
+ };
27
+ const response = await this.read(schema, entity, undefined, options);
28
+ signal?.throwIfAborted?.();
29
+ const rows = Array.isArray(response?.data) ? response.data : (Array.isArray(response) ? response : []);
30
+ const total = response?.metadata?.total ?? rows.length;
31
+ return {
32
+ data: rows.map((row) => this.mapRow(row, labelField, valueField, mapItem)),
33
+ total,
34
+ };
35
+ }
36
+ async resolveByValue(values, signal) {
37
+ if (!values || values.length === 0)
38
+ return [];
39
+ signal?.throwIfAborted?.();
40
+ const { schema, entity, labelField, valueField, columns, filters, mapItem } = this.config;
41
+ const cols = this.resolveColumns(columns, labelField, valueField);
42
+ const response = await this.read(schema, entity, undefined, {
43
+ columns: cols,
44
+ filters: [
45
+ ...(filters ?? []),
46
+ { column: valueField, operator: 'in', value: values },
47
+ ],
48
+ limit: values.length,
49
+ offset: 0,
50
+ });
51
+ signal?.throwIfAborted?.();
52
+ const rows = Array.isArray(response?.data) ? response.data : (Array.isArray(response) ? response : []);
53
+ return rows.map((row) => this.mapRow(row, labelField, valueField, mapItem));
54
+ }
55
+ resolveColumns(columns, labelField, valueField) {
56
+ if (!columns)
57
+ return undefined;
58
+ return [...new Set([...columns, labelField, valueField])];
59
+ }
60
+ /**
61
+ * Each search column is emitted with `logic_operator: 'OR'` so the backend
62
+ * treats them as a single OR group, AND-combined with any base filters.
63
+ * This matches the convention used elsewhere in the project (see SvarkGrid).
64
+ */
65
+ buildSearchFilters(search, labelField, searchColumns, searchOperator) {
66
+ const trimmed = search?.trim();
67
+ if (!trimmed)
68
+ return [];
69
+ const cols = searchColumns?.length ? searchColumns : [labelField];
70
+ const value = searchOperator === 'ilike' || searchOperator === 'like' ? `%${trimmed}%` : trimmed;
71
+ return cols.map((column) => ({
72
+ column,
73
+ operator: searchOperator,
74
+ value,
75
+ logic_operator: 'OR',
76
+ }));
77
+ }
78
+ mapRow(row, labelField, valueField, mapItem) {
79
+ if (mapItem)
80
+ return mapItem(row);
81
+ return { ...row, label: String(row[labelField] ?? ''), value: row[valueField] };
82
+ }
83
+ }
@@ -1,17 +1,8 @@
1
- import type { BoxerAdapterConfig, BoxerItem, BoxerServerAdapter } from './types.js';
2
- export declare class BoxerResolveSpecAdapter implements BoxerServerAdapter {
1
+ import type { Options } from '@warkypublic/resolvespec-js';
2
+ import { BoxerBaseAdapter } from './BoxerBaseAdapter.js';
3
+ import type { BoxerAdapterConfig } from './types.js';
4
+ export declare class BoxerResolveSpecAdapter extends BoxerBaseAdapter {
3
5
  private readonly client;
4
- private readonly config;
5
6
  constructor(config: BoxerAdapterConfig);
6
- fetch({ page, pageSize, search }: {
7
- page: number;
8
- pageSize: number;
9
- search?: string;
10
- }): Promise<{
11
- data: BoxerItem[];
12
- total: number;
13
- }>;
14
- private resolveColumns;
15
- private buildSearchFilters;
16
- private mapRow;
7
+ protected read: (schema: string, entity: string, id: undefined, options: Options) => Promise<any>;
17
8
  }
@@ -1,56 +1,13 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { ResolveSpecClient } from '@warkypublic/resolvespec-js';
3
- export class BoxerResolveSpecAdapter {
3
+ import { BoxerBaseAdapter } from './BoxerBaseAdapter.js';
4
+ export class BoxerResolveSpecAdapter extends BoxerBaseAdapter {
4
5
  client;
5
- config;
6
6
  constructor(config) {
7
+ super(config);
7
8
  this.client = new ResolveSpecClient({ baseUrl: config.baseUrl, token: config.token });
8
- this.config = {
9
- labelField: 'label',
10
- valueField: 'id',
11
- searchOperator: 'ilike',
12
- ...config,
13
- };
14
- }
15
- async fetch({ page, pageSize, search }) {
16
- const { schema, entity, labelField, valueField, columns, sort, filters, searchColumns, searchOperator, mapItem } = this.config;
17
- const cols = this.resolveColumns(columns, labelField, valueField);
18
- const searchFilters = this.buildSearchFilters(search, labelField, searchColumns, searchOperator);
19
- const options = {
20
- columns: cols,
21
- sort: sort ?? [],
22
- filters: [...(filters ?? []), ...searchFilters],
23
- limit: pageSize,
24
- offset: page * pageSize,
25
- };
26
- const response = await this.client.read(schema, entity, undefined, options);
27
- const rows = Array.isArray(response?.data) ? response.data : (Array.isArray(response) ? response : []);
28
- const total = response?.metadata?.total ?? rows.length;
29
- return {
30
- data: rows.map((row) => this.mapRow(row, labelField, valueField, mapItem)),
31
- total,
32
- };
33
- }
34
- resolveColumns(columns, labelField, valueField) {
35
- if (!columns)
36
- return undefined;
37
- return [...new Set([...columns, labelField, valueField])];
38
- }
39
- buildSearchFilters(search, labelField, searchColumns, searchOperator) {
40
- const trimmed = search?.trim();
41
- if (!trimmed)
42
- return [];
43
- const cols = searchColumns?.length ? searchColumns : [labelField];
44
- return cols.map((column, index) => ({
45
- column,
46
- operator: searchOperator,
47
- value: `%${trimmed}%`,
48
- logic_operator: index === 0 ? 'AND' : 'OR',
49
- }));
50
- }
51
- mapRow(row, labelField, valueField, mapItem) {
52
- if (mapItem)
53
- return mapItem(row);
54
- return { ...row, label: String(row[labelField] ?? ''), value: row[valueField] };
55
9
  }
10
+ read = (schema, entity, id, options) => {
11
+ return this.client.read(schema, entity, id, options);
12
+ };
56
13
  }
@@ -1,17 +1,8 @@
1
- import type { BoxerAdapterConfig, BoxerItem, BoxerServerAdapter } from './types.js';
2
- export declare class BoxerRestHeaderSpecAdapter implements BoxerServerAdapter {
1
+ import type { Options } from '@warkypublic/resolvespec-js';
2
+ import { BoxerBaseAdapter } from './BoxerBaseAdapter.js';
3
+ import type { BoxerAdapterConfig } from './types.js';
4
+ export declare class BoxerRestHeaderSpecAdapter extends BoxerBaseAdapter {
3
5
  private readonly client;
4
- private readonly config;
5
6
  constructor(config: BoxerAdapterConfig);
6
- fetch({ page, pageSize, search }: {
7
- page: number;
8
- pageSize: number;
9
- search?: string;
10
- }): Promise<{
11
- data: BoxerItem[];
12
- total: number;
13
- }>;
14
- private resolveColumns;
15
- private buildSearchFilters;
16
- private mapRow;
7
+ protected read: (schema: string, entity: string, id: undefined, options: Options) => Promise<any>;
17
8
  }
@@ -1,56 +1,13 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { HeaderSpecClient } from '@warkypublic/resolvespec-js';
3
- export class BoxerRestHeaderSpecAdapter {
3
+ import { BoxerBaseAdapter } from './BoxerBaseAdapter.js';
4
+ export class BoxerRestHeaderSpecAdapter extends BoxerBaseAdapter {
4
5
  client;
5
- config;
6
6
  constructor(config) {
7
+ super(config);
7
8
  this.client = new HeaderSpecClient({ baseUrl: config.baseUrl, token: config.token });
8
- this.config = {
9
- labelField: 'label',
10
- valueField: 'id',
11
- searchOperator: 'ilike',
12
- ...config,
13
- };
14
- }
15
- async fetch({ page, pageSize, search }) {
16
- const { schema, entity, labelField, valueField, columns, sort, filters, searchColumns, searchOperator, mapItem } = this.config;
17
- const cols = this.resolveColumns(columns, labelField, valueField);
18
- const searchFilters = this.buildSearchFilters(search, labelField, searchColumns, searchOperator);
19
- const options = {
20
- columns: cols,
21
- sort: sort ?? [],
22
- filters: [...(filters ?? []), ...searchFilters],
23
- limit: pageSize,
24
- offset: page * pageSize,
25
- };
26
- const response = await this.client.read(schema, entity, undefined, options);
27
- const rows = Array.isArray(response?.data) ? response.data : (Array.isArray(response) ? response : []);
28
- const total = response?.metadata?.total ?? rows.length;
29
- return {
30
- data: rows.map((row) => this.mapRow(row, labelField, valueField, mapItem)),
31
- total,
32
- };
33
- }
34
- resolveColumns(columns, labelField, valueField) {
35
- if (!columns)
36
- return undefined;
37
- return [...new Set([...columns, labelField, valueField])];
38
- }
39
- buildSearchFilters(search, labelField, searchColumns, searchOperator) {
40
- const trimmed = search?.trim();
41
- if (!trimmed)
42
- return [];
43
- const cols = searchColumns?.length ? searchColumns : [labelField];
44
- return cols.map((column, index) => ({
45
- column,
46
- operator: searchOperator,
47
- value: `%${trimmed}%`,
48
- logic_operator: index === 0 ? 'AND' : 'OR',
49
- }));
50
- }
51
- mapRow(row, labelField, valueField, mapItem) {
52
- if (mapItem)
53
- return mapItem(row);
54
- return { ...row, label: String(row[labelField] ?? ''), value: row[valueField] };
55
9
  }
10
+ read = (schema, entity, id, options) => {
11
+ return this.client.read(schema, entity, id, options);
12
+ };
56
13
  }