@stubber/form-fields 1.1.4 → 1.2.1

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.
@@ -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, onDestroy } from "svelte";
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
- let clickOutside = utils.clickOutside;
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
- // set field state if changed
69
- if (!deepEqual(a?.state?.internal, _clone)) {
70
- $field.state.internal = _clone;
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
- let debounceLoad = utils.debounce(loadResults, 200);
102
- $: debounceLoad(filter_text);
103
- async function loadResults(ft) {
104
- let comparison = _.cloneDeep($internal);
25
+ const load_options = async (search_term) => {
26
+ if (!clienthub) return [];
105
27
 
106
- if (!clienthub) return;
107
- let details = {
28
+ const details = {
108
29
  resource_name: "contacts",
109
30
  };
110
31
 
111
- details.params = structuredClone($field.spec.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 = ft;
36
+ details.params.input = search_term;
116
37
  if (details.params.limit === undefined) {
117
38
  details.params.limit = 50;
118
39
  }
119
40
 
120
- // emit join page event
121
- socket.emit(
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
- comparison.raw_items = res.payload?.contacts;
131
-
132
- if (!deepEqual(comparison, $internal)) {
133
- $internal = _.cloneDeep(comparison);
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
- {#if $internal}
236
- <div
237
- use:clickOutside={() => {
238
- if (is_focused) {
239
- is_focused = false;
240
- filter_text = $field?.data?.base_label ?? "";
241
- }
242
- }}
243
- class="relative flex flex-col w-full text-surface-900"
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
- {/if}
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
- $: items = isArray($field.spec?.params?.options)
18
- ? $field.spec?.params?.options?.map((o, index) => {
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) return;
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 collisionBoundary = document.querySelector("body");
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 portal={collisionBoundary}>
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 {collisionBoundary} class="p-0 z-[100]">
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.Empty>No results found.</Command.Empty>
88
- <Command.Group>
89
- {#each items as item}
90
- <Command.Item
91
- value={item._key}
92
- onSelect={(item_key) => {
93
- simpleSetSelected(item_key);
94
- closeAndFocusTrigger(ids.trigger);
95
- }}
96
- class={$field.data.base === item._value ? "bg-primary-100" : ""}
97
- >
98
- {item._label}
99
- </Command.Item>
100
- {/each}
101
- </Command.Group>
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, onDestroy } from "svelte";
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";
5
+ import Select from "./Select.svelte";
11
6
 
12
7
  export let form;
13
8
  export let field;
14
9
 
15
- const internal = writable();
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
- let clickOutside = utils.clickOutside;
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
- syncStoreToStore(
55
- field,
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
- // get parts from data
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 = ft;
32
+ details.params.input = search_term;
109
33
  if (details.params.limit === undefined) {
110
34
  details.params.limit = 50;
111
35
  }
112
36
 
113
- // emit join page event
114
- socket.emit(
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
- // console.log("selectresource success", res);
124
- comparison.raw_items = res.payload[resource_name];
125
-
126
- if (!deepEqual(comparison, $internal)) {
127
- $internal = _.cloneDeep(comparison);
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
- {#if $internal}
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.1.4",
3
+ "version": "1.2.1",
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.1.1",
61
+ "@stubber/ui": "^1.4.1",
61
62
  "ag-grid-community": "^31.0.2",
62
63
  "ag-grid-enterprise": "^31.0.2",
63
64
  "currency-symbol-map": "^5.1.0",