@stubber/form-fields 1.1.3 → 1.2.0
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/clienthub/clienthub.d.ts +7 -0
- package/dist/clienthub/clienthub.js +20 -0
- package/dist/fields/component_parts/arraybuilder/FieldWrapper.svelte +1 -1
- package/dist/fields/components/Contactselector.svelte +36 -329
- package/dist/fields/components/Select.svelte +75 -26
- package/dist/fields/components/Select.svelte.d.ts +8 -2
- package/dist/fields/components/Selectresource.svelte +24 -275
- package/package.json +3 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Socket as IOSocket } from "socket.io-client";
|
|
2
|
+
type ConnectCallback = (socket: ReturnType<typeof establishClientHubSocket>) => void;
|
|
3
|
+
export declare function establishClientHubSocket(connectCallback?: ConnectCallback): {
|
|
4
|
+
updateAuth: (stubberauthtoken: string) => void;
|
|
5
|
+
socket: IOSocket<import("@socket.io/component-emitter").DefaultEventsMap, import("@socket.io/component-emitter").DefaultEventsMap>;
|
|
6
|
+
};
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { io, Socket as IOSocket } from "socket.io-client";
|
|
2
|
+
import { env } from "$env/dynamic/public";
|
|
3
|
+
export function establishClientHubSocket(connectCallback) {
|
|
4
|
+
const socket = io(env.PUBLIC_STUBBER_CLIENTHUB_URL);
|
|
5
|
+
socket.on("connect", () => {
|
|
6
|
+
console.log("___Connected to client-hub");
|
|
7
|
+
if (connectCallback)
|
|
8
|
+
connectCallback(api);
|
|
9
|
+
});
|
|
10
|
+
socket.on("disconnect", () => {
|
|
11
|
+
console.log("___Disconnected from client-hub");
|
|
12
|
+
});
|
|
13
|
+
function updateAuth(stubberauthtoken) {
|
|
14
|
+
socket.emit("update_socket_data", {
|
|
15
|
+
update: { stubberauthtoken },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const api = { updateAuth, socket };
|
|
19
|
+
return api;
|
|
20
|
+
}
|
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import { onMount
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
3
|
import _ from "lodash-es";
|
|
4
|
-
import * as utils from "../../utils/index.js";
|
|
5
|
-
import { deepEqual } from "fast-equals";
|
|
6
|
-
import { writable } from "svelte/store";
|
|
7
|
-
import { syncStoreToStore } from "../../utils/syncing";
|
|
8
4
|
|
|
9
|
-
import { scale } from "svelte/transition";
|
|
10
|
-
import { Label } from "@stubber/ui/label";
|
|
11
5
|
import { Button } from "@stubber/ui/button";
|
|
6
|
+
import Select from "./Select.svelte";
|
|
12
7
|
|
|
13
8
|
export let form;
|
|
14
9
|
export let field;
|
|
15
10
|
|
|
16
|
-
const internal = writable();
|
|
17
|
-
|
|
18
|
-
let filter_text = "";
|
|
19
|
-
let is_focused = false;
|
|
20
|
-
let input;
|
|
21
|
-
|
|
22
11
|
let dependencies = form.dependencies;
|
|
23
12
|
let clienthub = dependencies?.clienthub;
|
|
24
13
|
let createWantToModal = dependencies?.createWantToModal;
|
|
@@ -27,338 +16,56 @@
|
|
|
27
16
|
let stubref = stubber?.stubref;
|
|
28
17
|
let orguuid = stubber?.orguuid;
|
|
29
18
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
$: state_key = $field.state?.state_key;
|
|
33
|
-
$: label = $field.spec?.title;
|
|
34
|
-
$: hide_label = $field.spec?.hide_label;
|
|
35
|
-
|
|
36
|
-
$: isValid = !$field.state?.validation || $field.state?.validation?.valid;
|
|
37
|
-
$: validationMessage = $field.state?.validation?.message;
|
|
38
|
-
|
|
39
|
-
onMount(() => {
|
|
40
|
-
// set field values that aren't set yet
|
|
41
|
-
let f = _.cloneDeep($field);
|
|
42
|
-
let initial_state_internal = {
|
|
43
|
-
selected_item: null,
|
|
44
|
-
raw_items: [],
|
|
45
|
-
focused_index: -1,
|
|
46
|
-
};
|
|
47
|
-
let initial_data = {
|
|
48
|
-
base: f?.data?.base,
|
|
49
|
-
base_label: f?.data?.base_label,
|
|
50
|
-
};
|
|
51
|
-
_.set(f, "data", initial_data);
|
|
52
|
-
_.set(f, "state.internal", initial_state_internal);
|
|
53
|
-
if (!deepEqual(f, $field)) $field = f;
|
|
54
|
-
|
|
55
|
-
syncStoreToStore(
|
|
56
|
-
field,
|
|
57
|
-
internal,
|
|
58
|
-
(a, b) => {
|
|
59
|
-
let _clone = _.cloneDeep(a.state?.internal) || {};
|
|
60
|
-
|
|
61
|
-
// get parts from data
|
|
62
|
-
if (a?.data?.base) {
|
|
63
|
-
_.set(_clone, "selected_item._value", a?.data?.base);
|
|
64
|
-
_.set(_clone, "selected_item._label", a?.data?.base_label);
|
|
65
|
-
}
|
|
66
|
-
filter_text = a?.data?.base_label ?? "";
|
|
19
|
+
onMount(() => {});
|
|
67
20
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
return _clone;
|
|
73
|
-
},
|
|
74
|
-
(a, b) => {
|
|
75
|
-
let _clone = _.cloneDeep(a) || {};
|
|
76
|
-
// update the state
|
|
77
|
-
_.set(_clone, "state.internal", _.cloneDeep(b));
|
|
78
|
-
// update the data
|
|
79
|
-
_.set(_clone, "data.base", b?.selected_item?._value);
|
|
80
|
-
_.set(_clone, "data.base_label", b?.selected_item?._label ?? "");
|
|
81
|
-
return _clone;
|
|
82
|
-
}
|
|
83
|
-
);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
$: items = _.isArray($internal?.raw_items)
|
|
87
|
-
? $internal?.raw_items
|
|
88
|
-
.sort((a, b) => {
|
|
89
|
-
// show all is_personal items last
|
|
90
|
-
if (a.is_personal && !b.is_personal) return 1;
|
|
91
|
-
if (!a.is_personal && b.is_personal) return -1;
|
|
92
|
-
return 0;
|
|
93
|
-
})
|
|
94
|
-
.map((item) => {
|
|
95
|
-
let _label = item[$field.spec?.params?.label || $field.spec.label] ?? item._default_label;
|
|
96
|
-
let _value = item;
|
|
97
|
-
return { _label, _value };
|
|
98
|
-
})
|
|
99
|
-
: [];
|
|
21
|
+
const show_create_contact_modal = () => {
|
|
22
|
+
createWantToModal("_create_new_contact", stubref, orguuid);
|
|
23
|
+
};
|
|
100
24
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
async function loadResults(ft) {
|
|
104
|
-
let comparison = _.cloneDeep($internal);
|
|
25
|
+
const load_options = async (search_term) => {
|
|
26
|
+
if (!clienthub) return [];
|
|
105
27
|
|
|
106
|
-
|
|
107
|
-
let details = {
|
|
28
|
+
const details = {
|
|
108
29
|
resource_name: "contacts",
|
|
109
30
|
};
|
|
110
31
|
|
|
111
|
-
details.params =
|
|
32
|
+
details.params = $field.spec.params || {};
|
|
112
33
|
// add required/dynamic params (orguuid, stubref, input, and limit)
|
|
113
34
|
details.params.orguuid = orguuid;
|
|
114
35
|
details.params.stubref = stubref;
|
|
115
|
-
details.params.input =
|
|
36
|
+
details.params.input = search_term;
|
|
116
37
|
if (details.params.limit === undefined) {
|
|
117
38
|
details.params.limit = 50;
|
|
118
39
|
}
|
|
119
40
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"request",
|
|
123
|
-
{
|
|
124
|
-
type: "resource",
|
|
125
|
-
details,
|
|
126
|
-
},
|
|
127
|
-
(res) => {
|
|
128
|
-
// TODO : handle failure
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
socket.emit("request", { type: "resource", details }, (res) => {
|
|
129
43
|
if (res.success) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
44
|
+
const contacts = res.payload?.contacts || [];
|
|
45
|
+
const result = contacts.map((contact) => ({
|
|
46
|
+
value: contact,
|
|
47
|
+
label:
|
|
48
|
+
contact[$field.spec?.params?.label || $field.spec.label] ?? contact._default_label,
|
|
49
|
+
}));
|
|
50
|
+
resolve(result);
|
|
51
|
+
} else {
|
|
52
|
+
console.error("Failed to load contacts:", res);
|
|
53
|
+
resolve([]);
|
|
135
54
|
}
|
|
136
|
-
}
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
let teleportedNode = null;
|
|
141
|
-
onDestroy(() => {
|
|
142
|
-
if (teleportedNode) {
|
|
143
|
-
teleportedNode.remove();
|
|
144
|
-
teleportedNode = null;
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// used to show the dropdown panel in the body instead of inside the component
|
|
149
|
-
// fixes an issue where the dropdown panel would be cut off by the parent container
|
|
150
|
-
function teleport(node) {
|
|
151
|
-
// Get the original position and width
|
|
152
|
-
let rect = node.getBoundingClientRect();
|
|
153
|
-
let originalWidth = node.offsetWidth;
|
|
154
|
-
let originalHeight = node.offsetHeight;
|
|
155
|
-
|
|
156
|
-
// Teleport to the body
|
|
157
|
-
let teleportContainer = document.body;
|
|
158
|
-
teleportContainer.appendChild(node);
|
|
159
|
-
|
|
160
|
-
teleportedNode = node;
|
|
161
|
-
|
|
162
|
-
// Apply the original width and position to the teleported element
|
|
163
|
-
node.style.width = originalWidth + "px";
|
|
164
|
-
node.style.height = originalHeight + "px";
|
|
165
|
-
node.style.position = "absolute";
|
|
166
|
-
node.style.top = rect.top + window.scrollY + "px";
|
|
167
|
-
node.style.left = rect.left + "px";
|
|
168
|
-
|
|
169
|
-
// set z-index to 1000
|
|
170
|
-
node.style.zIndex = 1000;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const setSelected = (item) => {
|
|
174
|
-
if (item?.is_personal) {
|
|
175
|
-
initImport(selectedItem);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
filter_text = item._label;
|
|
179
|
-
let comparison = _.cloneDeep($internal);
|
|
180
|
-
comparison.focused_index = -1;
|
|
181
|
-
comparison.selected_item = item;
|
|
182
|
-
|
|
183
|
-
if (!deepEqual(comparison, $internal)) {
|
|
184
|
-
$internal = _.cloneDeep(comparison);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
is_focused = false;
|
|
188
|
-
input.blur();
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
function handleDeselect() {
|
|
192
|
-
let comparison = _.cloneDeep($internal);
|
|
193
|
-
comparison.selected_item = null;
|
|
194
|
-
|
|
195
|
-
if (!deepEqual(comparison, $internal)) {
|
|
196
|
-
$internal = _.cloneDeep(comparison);
|
|
197
|
-
}
|
|
198
|
-
filter_text = "";
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
let createNew = false;
|
|
202
|
-
const toggleCreateNew = () => {
|
|
203
|
-
createNew = !createNew;
|
|
204
|
-
if (createNew) {
|
|
205
|
-
createWantToModal("_create_new_contact", stubref, orguuid);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const handleCloseModal = async (contact) => {
|
|
210
|
-
let res = await loadResults(contact.descname);
|
|
211
|
-
if (res.success) {
|
|
212
|
-
let firstItem = items[0];
|
|
213
|
-
if (!firstItem?.is_personal) firstItem && setSelected(firstItem);
|
|
214
|
-
else {
|
|
215
|
-
loadResults(filter_text);
|
|
216
|
-
is_focused = true;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const initImport = (contact) => {
|
|
222
|
-
createWantToModal(
|
|
223
|
-
"_copy_personal_contact",
|
|
224
|
-
stubref,
|
|
225
|
-
orguuid,
|
|
226
|
-
{ _personal_contact_copy: contact },
|
|
227
|
-
() => {
|
|
228
|
-
handleCloseModal(contact);
|
|
229
|
-
return true;
|
|
230
|
-
} // need to do it like this because cb must not be async
|
|
231
|
-
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
232
57
|
};
|
|
233
58
|
</script>
|
|
234
59
|
|
|
235
|
-
{
|
|
236
|
-
<div
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<Label for="input_{state_key}" class="block {hide_label ? 'hidden' : ''}">
|
|
246
|
-
{label}
|
|
247
|
-
</Label>
|
|
248
|
-
<div class="relative flex mt-2 rounded-md">
|
|
249
|
-
<input
|
|
250
|
-
on:keydown={(e) => {
|
|
251
|
-
switch (e.key) {
|
|
252
|
-
case "Enter":
|
|
253
|
-
if ($internal.focused_index >= 0 && $internal.focused_index < items.length) {
|
|
254
|
-
setSelected(items[$internal.focused_index]);
|
|
255
|
-
}
|
|
256
|
-
e.preventDefault();
|
|
257
|
-
break;
|
|
258
|
-
case "ArrowDown":
|
|
259
|
-
$internal.focused_index = ($internal.focused_index + 1) % items.length;
|
|
260
|
-
e.preventDefault();
|
|
261
|
-
break;
|
|
262
|
-
case "ArrowUp":
|
|
263
|
-
$internal.focused_index = ($internal.focused_index - 1 + items.length) % items.length;
|
|
264
|
-
e.preventDefault();
|
|
265
|
-
break;
|
|
266
|
-
}
|
|
267
|
-
}}
|
|
268
|
-
on:focus={() => {
|
|
269
|
-
if (!is_focused) {
|
|
270
|
-
is_focused = true;
|
|
271
|
-
filter_text = "";
|
|
272
|
-
}
|
|
273
|
-
}}
|
|
274
|
-
bind:this={input}
|
|
275
|
-
type="select"
|
|
276
|
-
id="input_mask{state_key}"
|
|
277
|
-
placeholder={label}
|
|
278
|
-
class="bg-gradient-to-b from-[#2727271F] to-[#27272719] shadow-select
|
|
279
|
-
flex h-7 w-full items-center justify-between rounded px-3 py-2
|
|
280
|
-
text-sm focus:outline-none
|
|
281
|
-
[&>span]:line-clamp-1 aria-[invalid]:border-destructive
|
|
282
|
-
placeholder:text-muted-foreground
|
|
283
|
-
{!isValid ? 'ring-danger-500' : 'ring-surface-300 focus:ring-transparent'}"
|
|
284
|
-
bind:value={filter_text}
|
|
285
|
-
autocomplete="off"
|
|
286
|
-
/>
|
|
287
|
-
{#if !$internal.selected_item}
|
|
288
|
-
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
289
|
-
<i class="text-surface-300 fa-regular fa-arrows-up-down fa-fw" />
|
|
290
|
-
</div>
|
|
291
|
-
{/if}
|
|
292
|
-
{#if $internal.selected_item}
|
|
293
|
-
<button
|
|
294
|
-
on:click={handleDeselect}
|
|
295
|
-
class="absolute inset-y-0 right-0 flex items-center pr-2 text-surface-300 hover:text-danger-500"
|
|
296
|
-
>
|
|
297
|
-
<i class="fa-regular fa-x fa-fw" />
|
|
298
|
-
</button>
|
|
299
|
-
{/if}
|
|
300
|
-
</div>
|
|
301
|
-
{#if is_focused}
|
|
302
|
-
<div use:teleport class="z-10 w-full inset-y-[70px] mt-1">
|
|
303
|
-
<div
|
|
304
|
-
transition:scale={{
|
|
305
|
-
start: 0.95,
|
|
306
|
-
opacity: 0,
|
|
307
|
-
duration: 50,
|
|
308
|
-
}}
|
|
309
|
-
class="bg-popover text-popover-foreground relative min-w-[8rem]
|
|
310
|
-
overflow-hidden rounded-md border shadow-md outline-none
|
|
311
|
-
max-h-[200px] overflow-y-auto w-full p-1"
|
|
312
|
-
>
|
|
313
|
-
{#if items?.length > 0}
|
|
314
|
-
<ul>
|
|
315
|
-
{#each items as item, index (item?._value?._key)}
|
|
316
|
-
<li class="group {$internal.focused_index === index ? 'bg-primary-400' : ''}">
|
|
317
|
-
<button
|
|
318
|
-
type="button"
|
|
319
|
-
on:click|preventDefault={() => setSelected(item)}
|
|
320
|
-
class="selectitem relative flex w-full cursor-default
|
|
321
|
-
select-none items-center rounded-sm py-1 pl-8 pr-2
|
|
322
|
-
text-sm outline-none hover:bg-accent
|
|
323
|
-
hover:text-accent-foreground
|
|
324
|
-
data-[highlighted]:bg-accent
|
|
325
|
-
data-[highlighted]:text-accent-foreground
|
|
326
|
-
data-[disabled]:pointer-events-none
|
|
327
|
-
data-[disabled]:opacity-50"
|
|
328
|
-
>
|
|
329
|
-
{item._label}
|
|
330
|
-
{#if item.is_personal}
|
|
331
|
-
<div class="mx-2 bg-surface-300 rounded-full text-xs p-0.5 px-2 text-white">
|
|
332
|
-
Import from personal
|
|
333
|
-
</div>
|
|
334
|
-
{/if}
|
|
335
|
-
{#if item?._value?._key == $internal?.selected_item?._value?._key}
|
|
336
|
-
<i class="absolute left-2 h-3.5 w-3.5 fa-regular fa-check" />
|
|
337
|
-
{/if}
|
|
338
|
-
</button>
|
|
339
|
-
</li>
|
|
340
|
-
{/each}
|
|
341
|
-
</ul>
|
|
342
|
-
{:else}
|
|
343
|
-
<div class="flex items-center justify-center py-4">
|
|
344
|
-
<span class="text-sm text-surface-400">Start typing to search...</span>
|
|
345
|
-
</div>
|
|
346
|
-
{/if}
|
|
347
|
-
</div>
|
|
348
|
-
</div>
|
|
349
|
-
{/if}
|
|
350
|
-
<div class="mt-1 ml-auto">
|
|
351
|
-
<Button
|
|
352
|
-
on:click={toggleCreateNew}
|
|
353
|
-
variant="ghost"
|
|
354
|
-
class="text-surface-500 text-sm hover:text-surface-700"
|
|
355
|
-
>
|
|
356
|
-
<i class="fa-regular fa-plus" />
|
|
357
|
-
Create new contact
|
|
358
|
-
</Button>
|
|
359
|
-
</div>
|
|
360
|
-
{#if validationMessage}
|
|
361
|
-
<Label class={!isValid ? `text-danger-500` : `text-success-500`}>{validationMessage}</Label>
|
|
362
|
-
{/if}
|
|
60
|
+
<Select {field} {load_options}>
|
|
61
|
+
<div class="ml-auto pt-1">
|
|
62
|
+
<Button
|
|
63
|
+
on:click={show_create_contact_modal}
|
|
64
|
+
variant="ghost"
|
|
65
|
+
class="text-surface-500 text-sm hover:text-surface-700"
|
|
66
|
+
>
|
|
67
|
+
<i class="fa-regular fa-plus" />
|
|
68
|
+
Create new contact
|
|
69
|
+
</Button>
|
|
363
70
|
</div>
|
|
364
|
-
|
|
71
|
+
</Select>
|
|
@@ -1,28 +1,37 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { onMount, tick } from "svelte";
|
|
3
|
-
|
|
3
|
+
import { createState } from "cmdk-sv";
|
|
4
4
|
import { Label } from "@stubber/ui/label";
|
|
5
5
|
import * as Command from "@stubber/ui/command";
|
|
6
6
|
import * as Popover from "@stubber/ui/popover";
|
|
7
7
|
import { Button } from "@stubber/ui/button";
|
|
8
|
-
import { isArray, isEqual } from "lodash-es";
|
|
8
|
+
import { debounce, isArray, isEqual } from "lodash-es";
|
|
9
9
|
|
|
10
10
|
export let field;
|
|
11
11
|
|
|
12
|
+
// instead of contactselector and resourceselector being separate components,
|
|
13
|
+
// form fields should expose this through form.dependencies so people can dynamically load things as they whish.
|
|
14
|
+
// for now though, contactselector and resourceselector just tie into this function
|
|
15
|
+
export let load_options;
|
|
16
|
+
|
|
12
17
|
$: state_key = $field.state?.state_key;
|
|
13
18
|
$: label = $field.spec?.title;
|
|
14
19
|
$: hide_label = $field.spec?.hide_label;
|
|
15
20
|
$: isValid = !$field.state?.validation || $field.state?.validation?.valid;
|
|
16
21
|
$: validationMessage = $field.state?.validation?.message;
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
let items = [];
|
|
23
|
+
|
|
24
|
+
$: {
|
|
25
|
+
if (isArray($field.spec?.params?.options)) {
|
|
26
|
+
items = $field.spec?.params?.options?.map((o, index) => {
|
|
19
27
|
let { label, value } = o || {};
|
|
20
28
|
let _value = value !== undefined ? value : label;
|
|
21
29
|
let _label = label ?? value;
|
|
22
30
|
let _key = `${state_key}${label}${index}`;
|
|
23
31
|
return { _key, _label, _value };
|
|
24
|
-
})
|
|
25
|
-
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
26
35
|
|
|
27
36
|
onMount(() => {
|
|
28
37
|
if ($field.data.base && !$field.spec?.without_value_details) {
|
|
@@ -36,7 +45,9 @@
|
|
|
36
45
|
|
|
37
46
|
function simpleSetSelected(item_key) {
|
|
38
47
|
const item = items.find((i) => i._key === item_key);
|
|
39
|
-
if (!item)
|
|
48
|
+
if (!item) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
40
51
|
|
|
41
52
|
field.update((f) => {
|
|
42
53
|
f.data.base = item?._value;
|
|
@@ -48,6 +59,7 @@
|
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
let open = false;
|
|
62
|
+
let loading = false;
|
|
51
63
|
|
|
52
64
|
$: dropdownLabel = items.find((i) => isEqual(i._value, $field.data.base))?._label ?? "Select...";
|
|
53
65
|
// We want to refocus the trigger button when the user selects
|
|
@@ -60,14 +72,43 @@
|
|
|
60
72
|
});
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
const
|
|
75
|
+
const state = createState();
|
|
76
|
+
|
|
77
|
+
let last_search = undefined;
|
|
78
|
+
const handle_search = async (search) => {
|
|
79
|
+
if (search === last_search) return;
|
|
80
|
+
last_search = search;
|
|
81
|
+
|
|
82
|
+
if (load_options) {
|
|
83
|
+
loading = true;
|
|
84
|
+
const remote_results = await load_options($state.search);
|
|
85
|
+
loading = false;
|
|
86
|
+
|
|
87
|
+
items = remote_results.map((o, index) => {
|
|
88
|
+
let { label, value } = o || {};
|
|
89
|
+
let _value = value !== undefined ? value : label;
|
|
90
|
+
let _label = label ?? value;
|
|
91
|
+
let _key = `${state_key}${label}${index}`;
|
|
92
|
+
return { _key, _label, _value };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let debounced_handle_search = null;
|
|
98
|
+
if (load_options) {
|
|
99
|
+
debounced_handle_search = debounce(handle_search, 300);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
$: if (load_options && $state.search !== undefined) {
|
|
103
|
+
debounced_handle_search($state.search);
|
|
104
|
+
}
|
|
64
105
|
</script>
|
|
65
106
|
|
|
66
107
|
<div class="flex flex-col w-full text-surface-900 my-2">
|
|
67
108
|
<Label for="input_{state_key}" class="block py-2 {hide_label ? 'hidden' : ''}">
|
|
68
109
|
{label}
|
|
69
110
|
</Label>
|
|
70
|
-
<Popover.Root bind:open let:ids
|
|
111
|
+
<Popover.Root bind:open let:ids>
|
|
71
112
|
<Popover.Trigger asChild let:builder>
|
|
72
113
|
<Button
|
|
73
114
|
builders={[builder]}
|
|
@@ -81,28 +122,36 @@
|
|
|
81
122
|
<i class="fas fa-sort ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
82
123
|
</Button>
|
|
83
124
|
</Popover.Trigger>
|
|
84
|
-
<Popover.Content sameWidth
|
|
85
|
-
<Command.Root>
|
|
125
|
+
<Popover.Content sameWidth class="p-0 z-[100]">
|
|
126
|
+
<Command.Root shouldFilter={!load_options} {state}>
|
|
86
127
|
<Command.Input class="border-none outline-none focus:ring-0" placeholder="Search..." />
|
|
87
|
-
<Command.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
128
|
+
<Command.List>
|
|
129
|
+
{#if loading}
|
|
130
|
+
<Command.Loading class="py-6 text-sm text-center">Loading…</Command.Loading>
|
|
131
|
+
{:else}
|
|
132
|
+
<Command.Empty>No results found.</Command.Empty>
|
|
133
|
+
|
|
134
|
+
{#each items as item}
|
|
135
|
+
<Command.Item
|
|
136
|
+
value={item._key}
|
|
137
|
+
onSelect={(item_key) => {
|
|
138
|
+
simpleSetSelected(item_key);
|
|
139
|
+
closeAndFocusTrigger(ids.trigger);
|
|
140
|
+
}}
|
|
141
|
+
class={$field.data.base === item._value ? "bg-primary-100" : ""}
|
|
142
|
+
>
|
|
143
|
+
{item._label}
|
|
144
|
+
</Command.Item>
|
|
145
|
+
{/each}
|
|
146
|
+
{/if}
|
|
147
|
+
</Command.List>
|
|
102
148
|
</Command.Root>
|
|
103
149
|
</Popover.Content>
|
|
104
150
|
</Popover.Root>
|
|
105
151
|
|
|
152
|
+
<!-- slot used by ContactSelector to place a create button above the validation error -->
|
|
153
|
+
<slot />
|
|
154
|
+
|
|
106
155
|
{#if validationMessage}
|
|
107
156
|
<Label class={!isValid ? `text-danger-500` : `text-success-500`}>{validationMessage}</Label>
|
|
108
157
|
{/if}
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
/** @typedef {typeof __propDef.slots} SelectSlots */
|
|
4
4
|
export default class Select extends SvelteComponent<{
|
|
5
5
|
field: any;
|
|
6
|
+
load_options: any;
|
|
6
7
|
}, {
|
|
7
8
|
[evt: string]: CustomEvent<any>;
|
|
8
|
-
}, {
|
|
9
|
+
}, {
|
|
10
|
+
default: {};
|
|
11
|
+
}> {
|
|
9
12
|
}
|
|
10
13
|
export type SelectProps = typeof __propDef.props;
|
|
11
14
|
export type SelectEvents = typeof __propDef.events;
|
|
@@ -14,11 +17,14 @@ import { SvelteComponent } from "svelte";
|
|
|
14
17
|
declare const __propDef: {
|
|
15
18
|
props: {
|
|
16
19
|
field: any;
|
|
20
|
+
load_options: any;
|
|
17
21
|
};
|
|
18
22
|
events: {
|
|
19
23
|
[evt: string]: CustomEvent<any>;
|
|
20
24
|
};
|
|
21
|
-
slots: {
|
|
25
|
+
slots: {
|
|
26
|
+
default: {};
|
|
27
|
+
};
|
|
22
28
|
exports?: undefined;
|
|
23
29
|
bindings?: undefined;
|
|
24
30
|
};
|
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import { onMount
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
3
|
import _ from "lodash-es";
|
|
4
|
-
import * as utils from "../../utils/index.js";
|
|
5
|
-
import { deepEqual } from "fast-equals";
|
|
6
|
-
import { writable } from "svelte/store";
|
|
7
|
-
import { syncStoreToStore } from "../../utils/syncing";
|
|
8
4
|
|
|
9
|
-
import
|
|
10
|
-
import { Label } from "@stubber/ui/label";
|
|
5
|
+
import Select from "./Select.svelte";
|
|
11
6
|
|
|
12
7
|
export let form;
|
|
13
8
|
export let field;
|
|
14
9
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
let filter_text = "";
|
|
18
|
-
let is_focused = false;
|
|
19
|
-
let input;
|
|
10
|
+
$: resource_name = $field.spec?.params?.resource_name;
|
|
20
11
|
|
|
21
12
|
let dependencies = form.dependencies;
|
|
22
13
|
let clienthub = dependencies?.clienthub;
|
|
@@ -25,79 +16,12 @@
|
|
|
25
16
|
let stubref = stubber?.stubref;
|
|
26
17
|
let orguuid = stubber?.orguuid;
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
$: state_key = $field.state?.state_key;
|
|
31
|
-
$: label = $field.spec?.title;
|
|
32
|
-
$: hide_label = $field.spec?.hide_label;
|
|
33
|
-
$: resource_name = $field.spec?.params?.resource_name;
|
|
34
|
-
|
|
35
|
-
$: isValid = !$field.state?.validation || $field.state?.validation?.valid;
|
|
36
|
-
$: validationMessage = $field.state?.validation?.message;
|
|
37
|
-
|
|
38
|
-
onMount(() => {
|
|
39
|
-
// set field values that aren't set yet
|
|
40
|
-
let f = _.cloneDeep($field);
|
|
41
|
-
let initial_state_internal = {
|
|
42
|
-
selected_item: null,
|
|
43
|
-
raw_items: [],
|
|
44
|
-
focused_index: -1,
|
|
45
|
-
};
|
|
46
|
-
let initial_data = {
|
|
47
|
-
base: f?.data?.base,
|
|
48
|
-
base_label: f?.data?.base_label,
|
|
49
|
-
};
|
|
50
|
-
_.set(f, "data", initial_data);
|
|
51
|
-
_.set(f, "state.internal", initial_state_internal);
|
|
52
|
-
if (!deepEqual(f, $field)) $field = f;
|
|
19
|
+
onMount(() => {});
|
|
53
20
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
internal,
|
|
57
|
-
(a, b) => {
|
|
58
|
-
let _clone = _.cloneDeep(a.state?.internal) || {};
|
|
21
|
+
const load_options = async (search_term) => {
|
|
22
|
+
if (!clienthub) return [];
|
|
59
23
|
|
|
60
|
-
|
|
61
|
-
if (a?.data?.base) {
|
|
62
|
-
_.set(_clone, "selected_item._value", a?.data?.base);
|
|
63
|
-
_.set(_clone, "selected_item._label", a?.data?.base_label);
|
|
64
|
-
}
|
|
65
|
-
filter_text = a?.data?.base_label ?? "";
|
|
66
|
-
|
|
67
|
-
// set field state if changed
|
|
68
|
-
if (!deepEqual(a?.state?.internal, _clone)) {
|
|
69
|
-
$field.state.internal = _clone;
|
|
70
|
-
}
|
|
71
|
-
return _clone;
|
|
72
|
-
},
|
|
73
|
-
(a, b) => {
|
|
74
|
-
let _clone = _.cloneDeep(a) || {};
|
|
75
|
-
// update the state
|
|
76
|
-
_.set(_clone, "state.internal", _.cloneDeep(b));
|
|
77
|
-
// update the data
|
|
78
|
-
_.set(_clone, "data.base", b?.selected_item?._value);
|
|
79
|
-
_.set(_clone, "data.base_label", b?.selected_item?._label ?? "");
|
|
80
|
-
return _clone;
|
|
81
|
-
}
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
$: items = _.isArray($internal?.raw_items)
|
|
86
|
-
? $internal?.raw_items.map((item) => {
|
|
87
|
-
let _label = item[$field.spec?.params?.label || $field.spec.label] ?? item._default_label;
|
|
88
|
-
let _value = item;
|
|
89
|
-
return { _label, _value };
|
|
90
|
-
})
|
|
91
|
-
: [];
|
|
92
|
-
|
|
93
|
-
// $: console.log("items updated", items);
|
|
94
|
-
|
|
95
|
-
let debounceLoad = utils.debounce(loadResults, 200);
|
|
96
|
-
$: debounceLoad(filter_text);
|
|
97
|
-
async function loadResults(ft) {
|
|
98
|
-
let comparison = _.cloneDeep($internal);
|
|
99
|
-
if (!clienthub) return;
|
|
100
|
-
let details = {
|
|
24
|
+
const details = {
|
|
101
25
|
resource_name,
|
|
102
26
|
};
|
|
103
27
|
|
|
@@ -105,204 +29,29 @@
|
|
|
105
29
|
// add required/dynamic params (orguuid, stubref, input, and limit)
|
|
106
30
|
details.params.orguuid = orguuid;
|
|
107
31
|
details.params.stubref = stubref;
|
|
108
|
-
details.params.input =
|
|
32
|
+
details.params.input = search_term;
|
|
109
33
|
if (details.params.limit === undefined) {
|
|
110
34
|
details.params.limit = 50;
|
|
111
35
|
}
|
|
112
36
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
"request",
|
|
116
|
-
{
|
|
117
|
-
type: "resource",
|
|
118
|
-
details,
|
|
119
|
-
},
|
|
120
|
-
(res) => {
|
|
121
|
-
// TODO : handle failure
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
socket.emit("request", { type: "resource", details }, (res) => {
|
|
122
39
|
if (res.success) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
40
|
+
const resources = res.payload?.[resource_name] || [];
|
|
41
|
+
// console.log("Loaded resources:", resources);
|
|
42
|
+
const result = resources.map((resource) => ({
|
|
43
|
+
value: resource,
|
|
44
|
+
label:
|
|
45
|
+
resource[$field.spec?.params?.label || $field.spec.label] ?? resource._default_label,
|
|
46
|
+
}));
|
|
47
|
+
resolve(result);
|
|
48
|
+
} else {
|
|
49
|
+
console.error("Failed to load resource:", res);
|
|
50
|
+
resolve([]);
|
|
129
51
|
}
|
|
130
|
-
}
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
let teleportedNode = null;
|
|
135
|
-
onDestroy(() => {
|
|
136
|
-
if (teleportedNode) {
|
|
137
|
-
teleportedNode.remove();
|
|
138
|
-
teleportedNode = null;
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// used to show the dropdown panel in the body instead of inside the component
|
|
143
|
-
// fixes an issue where the dropdown panel would be cut off by the parent container
|
|
144
|
-
function teleport(node) {
|
|
145
|
-
// Get the original position and width
|
|
146
|
-
let rect = node.getBoundingClientRect();
|
|
147
|
-
let originalWidth = node.offsetWidth;
|
|
148
|
-
let originalHeight = node.offsetHeight;
|
|
149
|
-
|
|
150
|
-
// Teleport to the body
|
|
151
|
-
let teleportContainer = document.body;
|
|
152
|
-
teleportContainer.appendChild(node);
|
|
153
|
-
|
|
154
|
-
teleportedNode = node;
|
|
155
|
-
|
|
156
|
-
// Apply the original width and position to the teleported element
|
|
157
|
-
node.style.width = originalWidth + "px";
|
|
158
|
-
node.style.height = originalHeight + "px";
|
|
159
|
-
node.style.position = "absolute";
|
|
160
|
-
node.style.top = rect.top + window.scrollY + "px";
|
|
161
|
-
node.style.left = rect.left + "px";
|
|
162
|
-
|
|
163
|
-
// set z-index to 1000
|
|
164
|
-
node.style.zIndex = 1000;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const setSelected = (item) => {
|
|
168
|
-
filter_text = item._label;
|
|
169
|
-
let comparison = _.cloneDeep($internal);
|
|
170
|
-
comparison.focused_index = -1;
|
|
171
|
-
comparison.selected_item = item;
|
|
172
|
-
|
|
173
|
-
if (!deepEqual(comparison, $internal)) {
|
|
174
|
-
$internal = _.cloneDeep(comparison);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
is_focused = false;
|
|
178
|
-
input.blur();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
179
54
|
};
|
|
180
|
-
|
|
181
|
-
function handleDeselect() {
|
|
182
|
-
let comparison = _.cloneDeep($internal);
|
|
183
|
-
comparison.selected_item = null;
|
|
184
|
-
|
|
185
|
-
if (!deepEqual(comparison, $internal)) {
|
|
186
|
-
$internal = _.cloneDeep(comparison);
|
|
187
|
-
}
|
|
188
|
-
filter_text = "";
|
|
189
|
-
}
|
|
190
55
|
</script>
|
|
191
56
|
|
|
192
|
-
{
|
|
193
|
-
<div
|
|
194
|
-
use:clickOutside={() => {
|
|
195
|
-
if (is_focused) {
|
|
196
|
-
is_focused = false;
|
|
197
|
-
filter_text = $field?.data?.base_label ?? "";
|
|
198
|
-
}
|
|
199
|
-
}}
|
|
200
|
-
class="relative flex flex-col w-full text-surface-900"
|
|
201
|
-
>
|
|
202
|
-
<Label for="input_{state_key}" class="block {hide_label ? 'hidden' : ''}">
|
|
203
|
-
{label}
|
|
204
|
-
</Label>
|
|
205
|
-
<div class="relative flex mt-2 rounded-md">
|
|
206
|
-
<input
|
|
207
|
-
on:keydown={(e) => {
|
|
208
|
-
switch (e.key) {
|
|
209
|
-
case "Enter":
|
|
210
|
-
if ($internal.focused_index >= 0 && $internal.focused_index < items.length) {
|
|
211
|
-
setSelected(items[$internal.focused_index]);
|
|
212
|
-
}
|
|
213
|
-
e.preventDefault();
|
|
214
|
-
break;
|
|
215
|
-
case "ArrowDown":
|
|
216
|
-
$internal.focused_index = ($internal.focused_index + 1) % items.length;
|
|
217
|
-
e.preventDefault();
|
|
218
|
-
break;
|
|
219
|
-
case "ArrowUp":
|
|
220
|
-
$internal.focused_index = ($internal.focused_index - 1 + items.length) % items.length;
|
|
221
|
-
e.preventDefault();
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
}}
|
|
225
|
-
on:focus={() => {
|
|
226
|
-
if (!is_focused) {
|
|
227
|
-
is_focused = true;
|
|
228
|
-
filter_text = "";
|
|
229
|
-
}
|
|
230
|
-
}}
|
|
231
|
-
bind:this={input}
|
|
232
|
-
type="select"
|
|
233
|
-
id="input_mask{state_key}"
|
|
234
|
-
placeholder={label}
|
|
235
|
-
class="bg-gradient-to-b from-[#2727271F] to-[#27272719] shadow-select
|
|
236
|
-
flex h-7 w-full items-center justify-between rounded px-3 py-2
|
|
237
|
-
text-sm focus:outline-none
|
|
238
|
-
[&>span]:line-clamp-1 aria-[invalid]:border-destructive
|
|
239
|
-
placeholder:text-muted-foreground
|
|
240
|
-
{!isValid ? 'ring-danger-500' : 'ring-surface-300 focus:ring-transparent'}"
|
|
241
|
-
bind:value={filter_text}
|
|
242
|
-
autocomplete="off"
|
|
243
|
-
/>
|
|
244
|
-
{#if !$internal.selected_item}
|
|
245
|
-
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
246
|
-
<i class="text-surface-300 fa-regular fa-arrows-up-down fa-fw" />
|
|
247
|
-
</div>
|
|
248
|
-
{/if}
|
|
249
|
-
{#if $internal.selected_item}
|
|
250
|
-
<button
|
|
251
|
-
on:click={handleDeselect}
|
|
252
|
-
class="absolute inset-y-0 right-0 flex items-center pr-2 text-surface-300 hover:text-danger-500"
|
|
253
|
-
>
|
|
254
|
-
<i class="fa-regular fa-x fa-fw" />
|
|
255
|
-
</button>
|
|
256
|
-
{/if}
|
|
257
|
-
</div>
|
|
258
|
-
{#if is_focused}
|
|
259
|
-
<div use:teleport class="z-10 w-full inset-y-[70px] mt-1">
|
|
260
|
-
<div
|
|
261
|
-
transition:scale={{
|
|
262
|
-
start: 0.95,
|
|
263
|
-
opacity: 0,
|
|
264
|
-
duration: 50,
|
|
265
|
-
}}
|
|
266
|
-
class="bg-popover text-popover-foreground relative min-w-[8rem]
|
|
267
|
-
overflow-hidden rounded-md border shadow-md outline-none
|
|
268
|
-
max-h-[200px] overflow-y-auto w-full p-1"
|
|
269
|
-
>
|
|
270
|
-
{#if items?.length > 0}
|
|
271
|
-
<ul>
|
|
272
|
-
{#each items as item, index (item?._value?._key)}
|
|
273
|
-
<li class="group {$internal.focused_index === index ? 'bg-primary-400' : ''}">
|
|
274
|
-
<button
|
|
275
|
-
type="button"
|
|
276
|
-
on:click|preventDefault={() => setSelected(item)}
|
|
277
|
-
class="selectitem relative flex w-full cursor-default
|
|
278
|
-
select-none items-center rounded-sm py-1 pl-8 pr-2
|
|
279
|
-
text-sm outline-none hover:bg-accent
|
|
280
|
-
hover:text-accent-foreground
|
|
281
|
-
data-[highlighted]:bg-accent
|
|
282
|
-
data-[highlighted]:text-accent-foreground
|
|
283
|
-
data-[disabled]:pointer-events-none
|
|
284
|
-
data-[disabled]:opacity-50"
|
|
285
|
-
>
|
|
286
|
-
{item._label}
|
|
287
|
-
{#if item?._value?._key == $internal?.selected_item?._value?._key}
|
|
288
|
-
<i class="absolute left-2 h-3.5 w-3.5 fa-regular fa-check" />
|
|
289
|
-
{/if}
|
|
290
|
-
</button>
|
|
291
|
-
</li>
|
|
292
|
-
{/each}
|
|
293
|
-
</ul>
|
|
294
|
-
{:else}
|
|
295
|
-
<div class="flex items-center justify-center py-4">
|
|
296
|
-
<span class="text-sm text-surface-400">Start typing to search...</span>
|
|
297
|
-
</div>
|
|
298
|
-
{/if}
|
|
299
|
-
</div>
|
|
300
|
-
</div>
|
|
301
|
-
{/if}
|
|
302
|
-
{#if validationMessage}
|
|
303
|
-
<Label class=" {!isValid ? `text-danger-500` : `text-success-500`}">
|
|
304
|
-
{validationMessage}
|
|
305
|
-
</Label>
|
|
306
|
-
{/if}
|
|
307
|
-
</div>
|
|
308
|
-
{/if}
|
|
57
|
+
<Select {field} {load_options} />
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stubber/form-fields",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "An automatic form builder based on field specifications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"components",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"prettier": "^2.8.0",
|
|
46
46
|
"prettier-plugin-svelte": "^2.10.1",
|
|
47
47
|
"publint": "^0.2.2",
|
|
48
|
+
"socket.io-client": "^4.8.1",
|
|
48
49
|
"svelte-check": "^3.4.3",
|
|
49
50
|
"tailwindcss": "^3.3.3",
|
|
50
51
|
"typescript": "^5.0.0",
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"@codemirror/commands": "^6.7.1",
|
|
58
59
|
"@codemirror/state": "^6.4.1",
|
|
59
60
|
"@codemirror/view": "^6.34.1",
|
|
60
|
-
"@stubber/ui": "^1.
|
|
61
|
+
"@stubber/ui": "^1.3.0",
|
|
61
62
|
"ag-grid-community": "^31.0.2",
|
|
62
63
|
"ag-grid-enterprise": "^31.0.2",
|
|
63
64
|
"currency-symbol-map": "^5.1.0",
|