@warkypublic/svelix 0.1.8 → 0.1.10
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.
- package/dist/components/Boxer/Boxer.stories.d.ts +2 -0
- package/dist/components/Boxer/Boxer.stories.js +72 -0
- package/dist/components/Boxer/Boxer.svelte +50 -25
- package/dist/components/Boxer/BoxerResolveSpecAdapter.d.ts +17 -0
- package/dist/components/Boxer/BoxerResolveSpecAdapter.js +56 -0
- package/dist/components/Boxer/BoxerRestHeaderSpecAdapter.d.ts +17 -0
- package/dist/components/Boxer/BoxerRestHeaderSpecAdapter.js +56 -0
- package/dist/components/Boxer/index.d.ts +2 -0
- package/dist/components/Boxer/index.js +2 -0
- package/dist/components/Boxer/store.js +9 -0
- package/dist/components/Boxer/types.d.ts +36 -0
- package/dist/components/Former/Former.stories.js +31 -9
- package/dist/components/Former/Former.svelte +27 -3
- package/dist/components/Former/FormerButtonArea.svelte +18 -5
- package/dist/components/Former/FormerButtonArea.svelte.d.ts +1 -0
- package/dist/components/Former/FormerResolveSpecAPI.js +5 -2
- package/dist/components/Former/FormerRestApiPreview.svelte +204 -188
- package/dist/components/Former/FormerRestHeadSpecAPI.js +11 -5
- package/dist/components/GlobalStateStore/GlobalStateStore.utils.js +2 -2
- package/dist/components/Svark/Svark.svelte +24 -8
- package/dist/components/Svark/Svark.svelte.d.ts +5 -3
- package/dist/components/Svark/SvarkResolveSpecAdapter.d.ts +2 -1
- package/dist/components/Svark/SvarkResolveSpecAdapter.js +6 -19
- package/dist/components/Svark/SvarkRestHeaderSpecAdapter.d.ts +16 -0
- package/dist/components/Svark/SvarkRestHeaderSpecAdapter.js +52 -0
- package/dist/components/Svark/index.d.ts +1 -0
- package/dist/components/Svark/index.js +1 -0
- package/dist/components/Svark/types.d.ts +9 -6
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { expect, userEvent, within } from '@storybook/test';
|
|
2
2
|
import Boxer from './Boxer.svelte';
|
|
3
|
+
import { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';
|
|
4
|
+
import { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';
|
|
3
5
|
const meta = {
|
|
4
6
|
title: 'Components/Boxer',
|
|
5
7
|
component: Boxer,
|
|
@@ -100,3 +102,73 @@ export const WithSearch = {
|
|
|
100
102
|
expect(canvas.queryByText('Banana')).toBeFalsy();
|
|
101
103
|
},
|
|
102
104
|
};
|
|
105
|
+
export const WithResolveSpec = {
|
|
106
|
+
argTypes: {
|
|
107
|
+
apiUrl: { control: 'text', description: 'ResolveSpec API base URL' },
|
|
108
|
+
authToken: { control: 'text', description: 'Bearer token for ResolveSpec authentication' },
|
|
109
|
+
schema: { control: 'text', description: 'Database schema' },
|
|
110
|
+
entity: { control: 'text', description: 'Entity / table name' },
|
|
111
|
+
labelField: { control: 'text', description: 'Field to display as the label' },
|
|
112
|
+
valueField: { control: 'text', description: 'Field to use as the selected value' },
|
|
113
|
+
},
|
|
114
|
+
args: {
|
|
115
|
+
apiUrl: "https://utils.btsys.tech/api/v2",
|
|
116
|
+
authToken: 'A684939F-BA4F-4CC1-9F5F-7050A2637171',
|
|
117
|
+
schema: 'public',
|
|
118
|
+
entity: "process",
|
|
119
|
+
labelField: "process",
|
|
120
|
+
valueField: "id_process",
|
|
121
|
+
label: 'ResolveSpec Select',
|
|
122
|
+
placeholder: 'Search...',
|
|
123
|
+
searchable: true,
|
|
124
|
+
},
|
|
125
|
+
render: ({ apiUrl, authToken, schema, entity, labelField, valueField, ...rest }) => ({
|
|
126
|
+
Component: Boxer,
|
|
127
|
+
props: {
|
|
128
|
+
...rest,
|
|
129
|
+
adapter: new BoxerResolveSpecAdapter({
|
|
130
|
+
baseUrl: apiUrl,
|
|
131
|
+
token: authToken,
|
|
132
|
+
schema,
|
|
133
|
+
entity,
|
|
134
|
+
labelField,
|
|
135
|
+
valueField,
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
export const WithHeaderSpec = {
|
|
141
|
+
argTypes: {
|
|
142
|
+
apiUrl: { control: 'text', description: 'HeaderSpec API base URL' },
|
|
143
|
+
authToken: { control: 'text', description: 'Bearer token for HeaderSpec authentication' },
|
|
144
|
+
schema: { control: 'text', description: 'Database schema' },
|
|
145
|
+
entity: { control: 'text', description: 'Entity / table name' },
|
|
146
|
+
labelField: { control: 'text', description: 'Field to display as the label' },
|
|
147
|
+
valueField: { control: 'text', description: 'Field to use as the selected value' },
|
|
148
|
+
},
|
|
149
|
+
args: {
|
|
150
|
+
apiUrl: 'https://api.example.com',
|
|
151
|
+
authToken: 'your-token-here',
|
|
152
|
+
schema: 'public',
|
|
153
|
+
entity: 'items',
|
|
154
|
+
labelField: 'name',
|
|
155
|
+
valueField: 'id',
|
|
156
|
+
label: 'HeaderSpec Select',
|
|
157
|
+
placeholder: 'Search...',
|
|
158
|
+
searchable: true,
|
|
159
|
+
},
|
|
160
|
+
render: ({ apiUrl, authToken, schema, entity, labelField, valueField, ...rest }) => ({
|
|
161
|
+
Component: Boxer,
|
|
162
|
+
props: {
|
|
163
|
+
...rest,
|
|
164
|
+
adapter: new BoxerRestHeaderSpecAdapter({
|
|
165
|
+
baseUrl: apiUrl,
|
|
166
|
+
token: authToken,
|
|
167
|
+
schema,
|
|
168
|
+
entity,
|
|
169
|
+
labelField,
|
|
170
|
+
valueField,
|
|
171
|
+
}),
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
|
3
|
+
import { untrack } from "svelte";
|
|
3
4
|
import {
|
|
4
5
|
createVirtualizer,
|
|
5
6
|
type SvelteVirtualizer,
|
|
@@ -7,19 +8,19 @@
|
|
|
7
8
|
import type { BoxerItem, BoxerProps } from "./types";
|
|
8
9
|
import { createBoxerStore } from "./store";
|
|
9
10
|
import BoxerTarget from "./BoxerTarget.svelte";
|
|
10
|
-
import Portal from "../Portal/Portal.svelte";
|
|
11
11
|
|
|
12
12
|
let {
|
|
13
|
+
adapter,
|
|
13
14
|
clearable = true,
|
|
14
15
|
data = [],
|
|
15
|
-
dataSource =
|
|
16
|
+
dataSource: dataSourceProp = undefined,
|
|
16
17
|
disabled,
|
|
17
18
|
error,
|
|
18
19
|
label,
|
|
19
20
|
leftSection,
|
|
20
21
|
mah = 200,
|
|
21
22
|
multiSelect,
|
|
22
|
-
onAPICall,
|
|
23
|
+
onAPICall: onAPICallProp = undefined,
|
|
23
24
|
onBufferChange,
|
|
24
25
|
onChange,
|
|
25
26
|
openOnClear,
|
|
@@ -31,6 +32,15 @@
|
|
|
31
32
|
value = $bindable<any>(undefined),
|
|
32
33
|
}: BoxerProps = $props();
|
|
33
34
|
|
|
35
|
+
// Derive effective dataSource and onAPICall from adapter when not explicitly provided
|
|
36
|
+
const dataSource = dataSourceProp ?? (adapter ? "server" : "local");
|
|
37
|
+
const onAPICall =
|
|
38
|
+
onAPICallProp ??
|
|
39
|
+
(adapter
|
|
40
|
+
? (params: { page: number; pageSize: number; search?: string }) =>
|
|
41
|
+
adapter!.fetch(params)
|
|
42
|
+
: undefined);
|
|
43
|
+
|
|
34
44
|
// Create store once with initial props
|
|
35
45
|
const store = createBoxerStore({
|
|
36
46
|
clearable,
|
|
@@ -52,8 +62,13 @@
|
|
|
52
62
|
value,
|
|
53
63
|
});
|
|
54
64
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
65
|
+
// Fine-grained derived values so $effect blocks only re-run when the
|
|
66
|
+
// specific field they care about actually changes (not on every store update).
|
|
67
|
+
const storeSearch = $derived($store.search);
|
|
68
|
+
const storeOpened = $derived($store.opened);
|
|
69
|
+
const storeBoxerData = $derived($store.boxerData);
|
|
70
|
+
const storeInput = $derived($store.input);
|
|
71
|
+
const boxerDataLength = $derived($store.boxerData.length);
|
|
57
72
|
|
|
58
73
|
// Sync value prop changes into store
|
|
59
74
|
$effect(() => {
|
|
@@ -63,31 +78,45 @@
|
|
|
63
78
|
// Virtualizer
|
|
64
79
|
let parentEl = $state<HTMLDivElement | undefined>(undefined);
|
|
65
80
|
let targetRef = $state<{ focus: () => void } | undefined>(undefined);
|
|
66
|
-
|
|
81
|
+
// Plain variable — NOT $state to avoid deep proxy on the complex virtualizer object.
|
|
82
|
+
let rawVirtualizer: SvelteVirtualizer<
|
|
67
83
|
HTMLDivElement,
|
|
68
84
|
HTMLDivElement
|
|
69
|
-
> | null
|
|
85
|
+
> | null = null;
|
|
86
|
+
// Virtualizer outputs as $state — updated directly by the subscribe callback.
|
|
87
|
+
let virtualItems = $state.raw<
|
|
88
|
+
ReturnType<
|
|
89
|
+
SvelteVirtualizer<HTMLDivElement, HTMLDivElement>["getVirtualItems"]
|
|
90
|
+
>
|
|
91
|
+
>([]);
|
|
92
|
+
let totalSize = $state(0);
|
|
70
93
|
|
|
94
|
+
// Create virtualizer only when parentEl changes (dropdown mount/unmount).
|
|
71
95
|
$effect(() => {
|
|
72
96
|
if (!parentEl) {
|
|
73
|
-
|
|
97
|
+
rawVirtualizer = null;
|
|
98
|
+
virtualItems = [];
|
|
99
|
+
totalSize = 0;
|
|
74
100
|
return;
|
|
75
101
|
}
|
|
102
|
+
const initialCount = untrack(() => boxerDataLength);
|
|
76
103
|
const v = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
|
77
|
-
count:
|
|
104
|
+
count: initialCount,
|
|
78
105
|
estimateSize: () => 36,
|
|
79
106
|
getScrollElement: () => parentEl!,
|
|
80
107
|
});
|
|
81
108
|
return v.subscribe((instance) => {
|
|
82
|
-
|
|
109
|
+
rawVirtualizer = instance;
|
|
110
|
+
virtualItems = instance.getVirtualItems();
|
|
111
|
+
totalSize = instance.getTotalSize();
|
|
83
112
|
});
|
|
84
113
|
});
|
|
85
114
|
|
|
86
|
-
// Update virtualizer count when data changes
|
|
115
|
+
// Update virtualizer count when data length actually changes (preserves scroll).
|
|
87
116
|
$effect(() => {
|
|
88
|
-
const count =
|
|
89
|
-
if (
|
|
90
|
-
|
|
117
|
+
const count = boxerDataLength;
|
|
118
|
+
if (rawVirtualizer && parentEl) {
|
|
119
|
+
rawVirtualizer.setOptions({
|
|
91
120
|
count,
|
|
92
121
|
estimateSize: () => 36,
|
|
93
122
|
getScrollElement: () => parentEl!,
|
|
@@ -95,18 +124,14 @@
|
|
|
95
124
|
}
|
|
96
125
|
});
|
|
97
126
|
|
|
98
|
-
|
|
99
|
-
const totalSize = $derived(virtualizerInstance?.getTotalSize() ?? 0);
|
|
100
|
-
|
|
101
|
-
// Debounced search
|
|
127
|
+
// Debounced search — only reacts to actual search text changes, not unrelated store updates.
|
|
102
128
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
103
129
|
|
|
104
130
|
$effect(() => {
|
|
105
|
-
const search =
|
|
106
|
-
const opened = $store.opened;
|
|
131
|
+
const search = storeSearch;
|
|
107
132
|
clearTimeout(searchTimeout);
|
|
108
133
|
searchTimeout = setTimeout(() => {
|
|
109
|
-
if (search !== undefined &&
|
|
134
|
+
if (search !== undefined && storeOpened) {
|
|
110
135
|
store.fetchData(search, true);
|
|
111
136
|
}
|
|
112
137
|
}, 300);
|
|
@@ -120,9 +145,9 @@
|
|
|
120
145
|
|
|
121
146
|
// Sync value -> input label
|
|
122
147
|
$effect(() => {
|
|
123
|
-
const boxerData =
|
|
124
|
-
const opened =
|
|
125
|
-
const currentInput =
|
|
148
|
+
const boxerData = storeBoxerData;
|
|
149
|
+
const opened = storeOpened;
|
|
150
|
+
const currentInput = storeInput;
|
|
126
151
|
|
|
127
152
|
if (multiSelect) {
|
|
128
153
|
const labels = boxerData
|
|
@@ -159,7 +184,7 @@
|
|
|
159
184
|
|
|
160
185
|
// Select first
|
|
161
186
|
$effect(() => {
|
|
162
|
-
const boxerData =
|
|
187
|
+
const boxerData = storeBoxerData;
|
|
163
188
|
if (selectFirst && boxerData.length > 0 && !multiSelect && !value) {
|
|
164
189
|
onOptionSubmit(0);
|
|
165
190
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BoxerAdapterConfig, BoxerItem, BoxerServerAdapter } from './types.js';
|
|
2
|
+
export declare class BoxerResolveSpecAdapter implements BoxerServerAdapter {
|
|
3
|
+
private readonly client;
|
|
4
|
+
private readonly config;
|
|
5
|
+
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;
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { ResolveSpecClient } from '@warkypublic/resolvespec-js';
|
|
3
|
+
export class BoxerResolveSpecAdapter {
|
|
4
|
+
client;
|
|
5
|
+
config;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
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
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BoxerAdapterConfig, BoxerItem, BoxerServerAdapter } from './types.js';
|
|
2
|
+
export declare class BoxerRestHeaderSpecAdapter implements BoxerServerAdapter {
|
|
3
|
+
private readonly client;
|
|
4
|
+
private readonly config;
|
|
5
|
+
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;
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { HeaderSpecClient } from '@warkypublic/resolvespec-js';
|
|
3
|
+
export class BoxerRestHeaderSpecAdapter {
|
|
4
|
+
client;
|
|
5
|
+
config;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
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
|
+
}
|
|
56
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { default as Boxer } from './Boxer.svelte';
|
|
2
2
|
export { default as BoxerTarget } from './BoxerTarget.svelte';
|
|
3
|
+
export { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';
|
|
4
|
+
export { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';
|
|
3
5
|
export { createBoxerStore } from './store';
|
|
4
6
|
export * from './types';
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { default as Boxer } from './Boxer.svelte';
|
|
2
2
|
export { default as BoxerTarget } from './BoxerTarget.svelte';
|
|
3
|
+
export { BoxerResolveSpecAdapter } from './BoxerResolveSpecAdapter.js';
|
|
4
|
+
export { BoxerRestHeaderSpecAdapter } from './BoxerRestHeaderSpecAdapter.js';
|
|
3
5
|
export { createBoxerStore } from './store';
|
|
4
6
|
export * from './types';
|
|
@@ -22,6 +22,7 @@ export function createBoxerStore(initialProps) {
|
|
|
22
22
|
subscribe((s) => (current = s))();
|
|
23
23
|
return current;
|
|
24
24
|
}
|
|
25
|
+
let lastResetSearch;
|
|
25
26
|
async function fetchData(search, reset) {
|
|
26
27
|
const state = getState();
|
|
27
28
|
if (state.dataSource === 'local' || !state.onAPICall) {
|
|
@@ -35,7 +36,15 @@ export function createBoxerStore(initialProps) {
|
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
37
38
|
if (state.onAPICall) {
|
|
39
|
+
// Prevent concurrent fetches
|
|
40
|
+
if (state.isFetching)
|
|
41
|
+
return;
|
|
42
|
+
// Prevent duplicate reset fetches for the same search term
|
|
43
|
+
if (reset && search === lastResetSearch && state.boxerData.length > 0)
|
|
44
|
+
return;
|
|
38
45
|
try {
|
|
46
|
+
if (reset)
|
|
47
|
+
lastResetSearch = search;
|
|
39
48
|
update((s) => ({ ...s, isFetching: true }));
|
|
40
49
|
const currentPage = reset ? 0 : state.page;
|
|
41
50
|
const result = await state.onAPICall({ page: currentPage, pageSize: state.pageSize ?? 50, search });
|
|
@@ -1,11 +1,47 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { FilterOption, SortOption } from '@warkypublic/resolvespec-js';
|
|
2
3
|
export type BoxerDataSource = 'local' | 'server';
|
|
4
|
+
export type BoxerClientType = 'body' | 'header';
|
|
5
|
+
export interface BoxerAdapterConfig {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
schema: string;
|
|
9
|
+
entity: string;
|
|
10
|
+
/** Field to display as the label. Defaults to `'label'`. */
|
|
11
|
+
labelField?: string;
|
|
12
|
+
/** Field to use as the selected value. Defaults to `'id'`. */
|
|
13
|
+
valueField?: string;
|
|
14
|
+
/** Columns to fetch. When omitted, all columns are fetched. `labelField` and `valueField` are always included. */
|
|
15
|
+
columns?: string[];
|
|
16
|
+
/** Default sort applied to every request. */
|
|
17
|
+
sort?: SortOption[];
|
|
18
|
+
/** Base filters always applied to every request. */
|
|
19
|
+
filters?: FilterOption[];
|
|
20
|
+
/** Columns to search against. Defaults to `[labelField]`. */
|
|
21
|
+
searchColumns?: string[];
|
|
22
|
+
/** Filter operator used for search. Defaults to `'ilike'`. */
|
|
23
|
+
searchOperator?: string;
|
|
24
|
+
/** Custom row-to-BoxerItem mapping. When omitted, `labelField` and `valueField` are mapped automatically. */
|
|
25
|
+
mapItem?: (row: any) => BoxerItem;
|
|
26
|
+
}
|
|
27
|
+
export interface BoxerServerAdapter {
|
|
28
|
+
fetch(params: {
|
|
29
|
+
page: number;
|
|
30
|
+
pageSize: number;
|
|
31
|
+
search?: string;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
data: BoxerItem[];
|
|
34
|
+
total: number;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
3
37
|
export type BoxerItem = {
|
|
4
38
|
[key: string]: any;
|
|
5
39
|
label: string;
|
|
6
40
|
value: any;
|
|
7
41
|
};
|
|
8
42
|
export interface BoxerProps {
|
|
43
|
+
/** Server adapter (ResolveSpec or HeaderSpec). Automatically sets `dataSource` to `'server'` when provided. */
|
|
44
|
+
adapter?: BoxerServerAdapter;
|
|
9
45
|
clearable?: boolean;
|
|
10
46
|
data?: Array<BoxerItem>;
|
|
11
47
|
dataSource?: BoxerDataSource;
|
|
@@ -51,10 +51,17 @@ export const DeleteUser = {
|
|
|
51
51
|
render: () => ({ Component: FormerPreview, props: { request: 'delete' } }),
|
|
52
52
|
play: async ({ canvasElement }) => {
|
|
53
53
|
const canvas = within(canvasElement);
|
|
54
|
-
//
|
|
55
|
-
const
|
|
56
|
-
await expect(
|
|
57
|
-
|
|
54
|
+
// Form fields should be disabled in delete mode
|
|
55
|
+
const textInputs = canvas.getAllByRole('textbox');
|
|
56
|
+
await expect(textInputs[0]).toBeDisabled();
|
|
57
|
+
// Delete button should be disabled until confirmation is checked
|
|
58
|
+
const deleteBtn = canvas.getByRole('button', { name: /delete/i });
|
|
59
|
+
await expect(deleteBtn).toBeDisabled();
|
|
60
|
+
// Check the confirmation checkbox
|
|
61
|
+
const confirmCheckbox = canvas.getByRole('checkbox', { name: /confirm.*delete/i });
|
|
62
|
+
await userEvent.click(confirmCheckbox);
|
|
63
|
+
// Delete button should now be enabled
|
|
64
|
+
await expect(deleteBtn).not.toBeDisabled();
|
|
58
65
|
},
|
|
59
66
|
};
|
|
60
67
|
export const ViewUser = {
|
|
@@ -148,9 +155,16 @@ export const ModalDelete = {
|
|
|
148
155
|
await userEvent.click(canvas.getByRole('button', { name: /open form/i }));
|
|
149
156
|
const body = within(document.body);
|
|
150
157
|
await expect(body.getByRole('heading', { name: /delete record/i })).toBeVisible();
|
|
151
|
-
//
|
|
152
|
-
const
|
|
153
|
-
await expect(
|
|
158
|
+
// Form fields should be disabled
|
|
159
|
+
const textInputs = body.getAllByRole('textbox');
|
|
160
|
+
await expect(textInputs[0]).toBeDisabled();
|
|
161
|
+
// Delete button should be disabled until confirmation is checked
|
|
162
|
+
const deleteBtn = body.getByRole('button', { name: /delete/i });
|
|
163
|
+
await expect(deleteBtn).toBeDisabled();
|
|
164
|
+
// Check the confirmation checkbox
|
|
165
|
+
const confirmCheckbox = body.getByRole('checkbox', { name: /confirm.*delete/i });
|
|
166
|
+
await userEvent.click(confirmCheckbox);
|
|
167
|
+
await expect(deleteBtn).not.toBeDisabled();
|
|
154
168
|
},
|
|
155
169
|
};
|
|
156
170
|
// ── Drawer layout ─────────────────────────────────────────────────────────────
|
|
@@ -188,8 +202,16 @@ export const DrawerDelete = {
|
|
|
188
202
|
await userEvent.click(canvas.getByRole('button', { name: /open drawer/i }));
|
|
189
203
|
const body = within(document.body);
|
|
190
204
|
await expect(body.getByRole('heading', { name: /delete record/i })).toBeVisible();
|
|
191
|
-
|
|
192
|
-
|
|
205
|
+
// Form fields should be disabled
|
|
206
|
+
const textInputs = body.getAllByRole('textbox');
|
|
207
|
+
await expect(textInputs[0]).toBeDisabled();
|
|
208
|
+
// Delete button should be disabled until confirmation is checked
|
|
209
|
+
const deleteBtn = body.getByRole('button', { name: /delete/i });
|
|
210
|
+
await expect(deleteBtn).toBeDisabled();
|
|
211
|
+
// Check the confirmation checkbox
|
|
212
|
+
const confirmCheckbox = body.getByRole('checkbox', { name: /confirm.*delete/i });
|
|
213
|
+
await userEvent.click(confirmCheckbox);
|
|
214
|
+
await expect(deleteBtn).not.toBeDisabled();
|
|
193
215
|
},
|
|
194
216
|
};
|
|
195
217
|
// ── REST API live example ─────────────────────────────────────────────────────
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
// Reset when form closes so re-opens start fresh.
|
|
78
78
|
initialValues = undefined;
|
|
79
79
|
dirty = false;
|
|
80
|
+
deleteConfirmed = false;
|
|
81
|
+
error = undefined;
|
|
80
82
|
}
|
|
81
83
|
});
|
|
82
84
|
|
|
@@ -153,6 +155,7 @@
|
|
|
153
155
|
async function load(reset?: boolean): Promise<void> {
|
|
154
156
|
try {
|
|
155
157
|
loading = true;
|
|
158
|
+
error = undefined;
|
|
156
159
|
|
|
157
160
|
// Base data for "load" comes from existing values or primeData.
|
|
158
161
|
// If `beforeGet` is provided, it can normalize/augment this data even
|
|
@@ -192,6 +195,7 @@
|
|
|
192
195
|
async function save(): Promise<any> {
|
|
193
196
|
try {
|
|
194
197
|
loading = true;
|
|
198
|
+
error = undefined;
|
|
195
199
|
|
|
196
200
|
let data = values ? { ...values } : {};
|
|
197
201
|
|
|
@@ -226,6 +230,7 @@
|
|
|
226
230
|
values = clearedData;
|
|
227
231
|
initialValues = JSON.parse(JSON.stringify(clearedData));
|
|
228
232
|
dirty = false;
|
|
233
|
+
deleteConfirmed = false;
|
|
229
234
|
onChange?.(clearedData, getAllState());
|
|
230
235
|
return newData;
|
|
231
236
|
}
|
|
@@ -355,6 +360,7 @@
|
|
|
355
360
|
{:else}
|
|
356
361
|
<FormerButtonArea
|
|
357
362
|
closeButtonTitle={layout?.closeButtonTitle}
|
|
363
|
+
{deleteConfirmed}
|
|
358
364
|
{dirty}
|
|
359
365
|
{keepOpen}
|
|
360
366
|
{request}
|
|
@@ -395,10 +401,27 @@
|
|
|
395
401
|
save();
|
|
396
402
|
}}
|
|
397
403
|
>
|
|
398
|
-
{
|
|
399
|
-
{
|
|
400
|
-
|
|
404
|
+
<fieldset disabled={request === 'delete'} class="contents">
|
|
405
|
+
{#if children}
|
|
406
|
+
{@render children(formerState)}
|
|
407
|
+
{/if}
|
|
408
|
+
</fieldset>
|
|
401
409
|
</form>
|
|
410
|
+
|
|
411
|
+
{#if request === 'delete'}
|
|
412
|
+
<div class="mt-3 p-3 rounded border border-error-500 bg-error-50 dark:bg-error-950 text-sm space-y-2">
|
|
413
|
+
<p class="font-semibold text-error-700 dark:text-error-300">⚠ This action cannot be undone</p>
|
|
414
|
+
<label class="flex items-center gap-2 cursor-pointer">
|
|
415
|
+
<input
|
|
416
|
+
type="checkbox"
|
|
417
|
+
class="checkbox"
|
|
418
|
+
checked={deleteConfirmed}
|
|
419
|
+
onchange={(e) => (deleteConfirmed = e.currentTarget.checked)}
|
|
420
|
+
/>
|
|
421
|
+
<span>I confirm I want to permanently delete this record</span>
|
|
422
|
+
</label>
|
|
423
|
+
</div>
|
|
424
|
+
{/if}
|
|
402
425
|
</div>
|
|
403
426
|
|
|
404
427
|
<!-- Bottom button area -->
|
|
@@ -408,6 +431,7 @@
|
|
|
408
431
|
{:else if layout?.buttonArea !== 'none'}
|
|
409
432
|
<FormerButtonArea
|
|
410
433
|
closeButtonTitle={layout?.closeButtonTitle}
|
|
434
|
+
{deleteConfirmed}
|
|
411
435
|
{dirty}
|
|
412
436
|
{keepOpen}
|
|
413
437
|
{request}
|