@budibase/frontend-core 3.46.0 → 3.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@budibase/frontend-core",
3
- "version": "v3.46.0",
3
+ "version": "v3.48.0",
4
4
  "description": "Budibase frontend core libraries used in builder and client",
5
5
  "author": "Budibase",
6
6
  "license": "MPL-2.0",
@@ -24,5 +24,5 @@
24
24
  "devDependencies": {
25
25
  "vitest": "^5.0.0"
26
26
  },
27
- "gitHead": "1444a702ce29cf158b10c31d3f205fc84c463169"
27
+ "gitHead": "0d896a86074936025b8ef6e3400ab650faeb8d06"
28
28
  }
@@ -1,10 +1,15 @@
1
1
  <script lang="ts">
2
2
  import { Body, Button, Icon } from "@budibase/bbui"
3
- import type { EscalationContextDoc } from "@budibase/types"
3
+ import { APPROVAL_REQUIRED_TITLE_PREFIX } from "@budibase/shared-core"
4
+ import type {
5
+ EscalationContextDoc,
6
+ EscalationReviewContext,
7
+ } from "@budibase/types"
4
8
 
5
9
  interface Props {
6
10
  title?: string
7
11
  summary?: string
12
+ reviewContext?: EscalationReviewContext
8
13
  // Live resolution from the poll (not the frozen tool output).
9
14
  resolution: EscalationContextDoc["resolution"]
10
15
  // Message relayed from resolve ("Response recorded." etc.) - set as soon as
@@ -20,6 +25,7 @@
20
25
  let {
21
26
  title,
22
27
  summary,
28
+ reviewContext,
23
29
  resolution,
24
30
  statusMessage,
25
31
  showApproval = false,
@@ -30,31 +36,102 @@
30
36
 
31
37
  // Resolved either by this card's action (statusMessage) or elsewhere (poll).
32
38
  let isResolved = $derived(!!statusMessage || resolution !== "pending")
39
+ let expanded = $state(true)
40
+ let showDetails = $derived(!showApproval || expanded)
41
+ // The card heading already reads "Approval required", so drop the matching
42
+ // prefix that operation escalations put on the title.
43
+ let displayTitle = $derived(
44
+ title?.startsWith(APPROVAL_REQUIRED_TITLE_PREFIX)
45
+ ? title.slice(APPROVAL_REQUIRED_TITLE_PREFIX.length).trim()
46
+ : title
47
+ )
48
+ let hasSharedParameters = $derived(Boolean(reviewContext?.parameters?.length))
49
+
50
+ const toggleDetails = () => {
51
+ expanded = !expanded
52
+ }
33
53
  </script>
34
54
 
35
55
  <div class="escalation-card" aria-live="polite">
36
- <div class="escalation-card-header">
37
- <Icon name={isResolved ? "check-circle" : "clock"} size="M" />
38
- <span class="escalation-card-title">{title || "Approval required"}</span>
39
- {#if showApproval && !isResolved}
40
- <span class="escalation-card-badge">Test mode</span>
56
+ {#if showApproval}
57
+ <button
58
+ type="button"
59
+ class="escalation-card-header escalation-card-toggle"
60
+ aria-expanded={expanded}
61
+ aria-label={`${expanded ? "Collapse" : "Expand"} approval details${displayTitle ? ` for ${displayTitle}` : ""}`}
62
+ onclick={toggleDetails}
63
+ >
64
+ <Icon name={isResolved ? "check-circle" : "clock"} size="M" />
65
+ <span class="escalation-card-heading">Approval required</span>
66
+ <span class="escalation-card-header-end">
67
+ {#if !isResolved}
68
+ <span class="escalation-card-badge">Test mode</span>
69
+ {/if}
70
+ <span class="escalation-card-chevron" class:expanded>
71
+ <Icon name="caret-down" size="S" />
72
+ </span>
73
+ </span>
74
+ </button>
75
+ {:else}
76
+ <div class="escalation-card-header">
77
+ <Icon name={isResolved ? "check-circle" : "clock"} size="M" />
78
+ <span class="escalation-card-heading">Approval required</span>
79
+ </div>
80
+ {/if}
81
+
82
+ {#if showDetails}
83
+ {#if displayTitle}
84
+ <div class="escalation-card-title">{displayTitle}</div>
85
+ {/if}
86
+
87
+ {#if reviewContext}
88
+ <Body size="S" color="var(--spectrum-global-color-gray-700)">
89
+ {reviewContext.requestedBy} is requesting approval for
90
+ <strong>{reviewContext.action}</strong> as part of
91
+ <strong>{reviewContext.operation}</strong>.
92
+ </Body>
93
+ {/if}
94
+
95
+ {#if summary}
96
+ <Body size="S" color="var(--spectrum-global-color-gray-700)">
97
+ {summary}
98
+ </Body>
41
99
  {/if}
42
- </div>
43
100
 
44
- {#if summary}
45
- <Body size="S" color="var(--spectrum-global-color-gray-700)">
46
- {summary}
47
- </Body>
101
+ {#if isResolved}
102
+ <Body size="XS" color="var(--spectrum-global-color-gray-600)">
103
+ {statusMessage || "Response recorded."}
104
+ </Body>
105
+ {:else if showApproval}
106
+ <Body size="XS" color="var(--spectrum-global-color-gray-600)">
107
+ Approve or reject here to simulate a reviewer response.
108
+ </Body>
109
+ {:else}
110
+ <Body size="XS" color="var(--spectrum-global-color-gray-600)">
111
+ Awaiting a human response.
112
+ </Body>
113
+ {/if}
114
+
115
+ {#if hasSharedParameters && reviewContext?.parameters}
116
+ <div class="escalation-card-divider"></div>
117
+ <div class="escalation-card-parameters-heading">
118
+ <span>Tool parameters</span>
119
+ {#if reviewContext.toolName}
120
+ <code>{reviewContext.toolName}</code>
121
+ {/if}
122
+ </div>
123
+ <div class="escalation-card-parameters-list">
124
+ {#each reviewContext.parameters as parameter (parameter.name)}
125
+ <div class="escalation-card-parameter">
126
+ <code>{parameter.name}</code>
127
+ <pre class="escalation-card-parameters">{parameter.value}</pre>
128
+ </div>
129
+ {/each}
130
+ </div>
131
+ {/if}
48
132
  {/if}
49
133
 
50
- {#if isResolved}
51
- <Body size="XS" color="var(--spectrum-global-color-gray-600)">
52
- {statusMessage || "Response recorded."}
53
- </Body>
54
- {:else if showApproval}
55
- <Body size="XS" color="var(--spectrum-global-color-gray-600)">
56
- Approve or reject here to simulate a reviewer response.
57
- </Body>
134
+ {#if showApproval && !isResolved}
58
135
  <div class="escalation-card-actions">
59
136
  <Button cta disabled={resolving} on:click={() => onApprove?.()}>
60
137
  Approve
@@ -63,10 +140,6 @@
63
140
  Reject
64
141
  </Button>
65
142
  </div>
66
- {:else}
67
- <Body size="XS" color="var(--spectrum-global-color-gray-600)">
68
- Awaiting a human response.
69
- </Body>
70
143
  {/if}
71
144
  </div>
72
145
 
@@ -89,13 +162,34 @@
89
162
  align-items: center;
90
163
  gap: var(--spacing-s);
91
164
  }
92
- .escalation-card-title {
165
+ .escalation-card-toggle {
166
+ width: 100%;
167
+ margin: 0;
168
+ padding: 0;
169
+ border: none;
170
+ background: none;
171
+ cursor: pointer;
172
+ text-align: left;
173
+ color: inherit;
174
+ font: inherit;
175
+ }
176
+ .escalation-card-heading,
177
+ .escalation-card-title,
178
+ .escalation-card-parameters-heading {
93
179
  font-weight: 600;
94
180
  font-size: 14px;
95
181
  color: var(--spectrum-global-color-gray-900);
96
182
  }
97
- .escalation-card-badge {
183
+ .escalation-card-title {
184
+ margin-top: var(--spacing-xs);
185
+ }
186
+ .escalation-card-header-end {
187
+ display: flex;
188
+ align-items: center;
189
+ gap: var(--spacing-xs);
98
190
  margin-left: auto;
191
+ }
192
+ .escalation-card-badge {
99
193
  border-radius: 999px;
100
194
  border: 1px solid var(--spectrum-global-color-blue-400);
101
195
  color: var(--spectrum-global-color-blue-700);
@@ -105,9 +199,61 @@
105
199
  line-height: 1;
106
200
  padding: 4px 8px;
107
201
  }
202
+ .escalation-card-chevron {
203
+ display: flex;
204
+ color: var(--spectrum-global-color-gray-700);
205
+ transition: transform 130ms ease-in-out;
206
+ }
207
+ .escalation-card-chevron.expanded {
208
+ transform: rotate(180deg);
209
+ }
108
210
  .escalation-card-actions {
109
211
  display: flex;
110
212
  gap: var(--spacing-s);
111
213
  margin-top: var(--spacing-xs);
112
214
  }
215
+ .escalation-card-divider {
216
+ height: 1px;
217
+ margin: var(--spacing-xs) calc(-1 * var(--spacing-m));
218
+ background: var(--spectrum-global-color-gray-300);
219
+ }
220
+ .escalation-card-parameters-heading {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: var(--spacing-xs);
224
+ }
225
+ .escalation-card-parameters-heading code {
226
+ min-width: 0;
227
+ padding: 2px 5px;
228
+ border-radius: 4px;
229
+ background: var(--spectrum-global-color-gray-200);
230
+ font-size: 12px;
231
+ font-weight: 400;
232
+ overflow-wrap: anywhere;
233
+ white-space: normal;
234
+ }
235
+
236
+ .escalation-card-parameters-list,
237
+ .escalation-card-parameter {
238
+ display: flex;
239
+ flex-direction: column;
240
+ gap: var(--spacing-xs);
241
+ }
242
+ .escalation-card-parameters {
243
+ box-sizing: content-box;
244
+ min-height: 12px;
245
+ max-height: 360px;
246
+ margin: 0;
247
+ padding: var(--spacing-s);
248
+ overflow: auto;
249
+ border: 1px solid var(--spectrum-global-color-gray-300);
250
+ border-radius: 4px;
251
+ background: var(--spectrum-global-color-gray-100);
252
+ color: var(--spectrum-global-color-gray-800);
253
+ font-family: monospace;
254
+ font-size: 12px;
255
+ line-height: 16px;
256
+ white-space: pre-wrap;
257
+ overflow-wrap: anywhere;
258
+ }
113
259
  </style>
@@ -12,6 +12,7 @@
12
12
  DraftChatConversation,
13
13
  AgentMessageMetadata,
14
14
  EscalationContextDoc,
15
+ EscalationReviewContext,
15
16
  EscalationRespondResult,
16
17
  } from "@budibase/types"
17
18
  import { ApprovalToolResultStatus } from "@budibase/types"
@@ -93,7 +94,12 @@
93
94
  // casts live here rather than cluttering the template.
94
95
  const escalationCardProps = (part: { input?: unknown; output?: unknown }) => {
95
96
  const output = part.output as
96
- | { escalationId?: string; title?: string; summary?: string }
97
+ | {
98
+ escalationId?: string
99
+ title?: string
100
+ summary?: string
101
+ reviewContext?: EscalationReviewContext
102
+ }
97
103
  | undefined
98
104
  const input = part.input as { title?: string; summary?: string } | undefined
99
105
  const escalationId = output?.escalationId
@@ -101,6 +107,7 @@
101
107
  escalationId,
102
108
  title: output?.title ?? input?.title,
103
109
  summary: output?.summary ?? input?.summary,
110
+ reviewContext: output?.reviewContext,
104
111
  resolution:
105
112
  (escalationId && escalationState?.[escalationId]?.resolution) ||
106
113
  "pending",
@@ -266,7 +273,25 @@
266
273
 
267
274
  let resolvedConversationId = $state<string | undefined>()
268
275
 
276
+ // The consumer must get plain objects, otherwise reading them keeps
277
+ // subscribing to the live chat state and every streamed token counts as a
278
+ // change. $state.snapshot cannot type UIMessage, hence the manual clone.
279
+ const snapshotMessages = (
280
+ nextMessages: UIMessage<AgentMessageMetadata>[]
281
+ ): UIMessage<AgentMessageMetadata>[] =>
282
+ nextMessages.map(message => JSON.parse(JSON.stringify(message)))
283
+
284
+ const publishChatMessages = (
285
+ nextMessages: UIMessage<AgentMessageMetadata>[] = chatInstance.messages
286
+ ) => {
287
+ chat = {
288
+ ...chat,
289
+ messages: snapshotMessages(nextMessages),
290
+ }
291
+ }
292
+
269
293
  const chatInstance = new Chat<UIMessage<AgentMessageMetadata>>({
294
+ messages: chat?.messages || [],
270
295
  transport: new DefaultChatTransport({
271
296
  headers: () => ({ [Header.WORKSPACE_ID]: workspaceId }),
272
297
  prepareSendMessagesRequest: ({ messages }) => {
@@ -286,13 +311,13 @@
286
311
  }
287
312
  },
288
313
  }),
289
- messages: chat?.messages || [],
290
314
  onFinish: async () => {
291
315
  isPreparingResponse = false
292
- chat = { ...chat, messages: chatInstance.messages }
316
+ publishChatMessages()
293
317
  },
294
318
  onError: error => {
295
319
  resetPendingResponse()
320
+ publishChatMessages()
296
321
 
297
322
  console.error(error)
298
323
  let message = error.message || "Failed to send message"
@@ -350,6 +375,7 @@
350
375
  message: UIMessage<AgentMessageMetadata>
351
376
  ) {
352
377
  chatInstance.messages = [...chatInstance.messages, message]
378
+ publishChatMessages()
353
379
  }
354
380
 
355
381
  let lastAssistantUsage = $derived(
@@ -412,6 +438,10 @@
412
438
  }
413
439
  })
414
440
 
441
+ // Tool/reasoning toggles mutate the chat DOM; the observer below would
442
+ // otherwise pin the viewport to the bottom as height changes.
443
+ let skipAutoScroll = false
444
+
415
445
  const scrollToBottom = async () => {
416
446
  await tick()
417
447
  if (chatAreaElement) {
@@ -421,6 +451,9 @@
421
451
 
422
452
  $effect(() => {
423
453
  if (messages?.length) {
454
+ if (skipAutoScroll) {
455
+ return
456
+ }
424
457
  scrollToBottom()
425
458
  }
426
459
  })
@@ -483,7 +516,18 @@
483
516
  inputValue = ""
484
517
  promptHistoryIndex = undefined
485
518
  onpromptsubmitted?.(text)
486
- chatInstance.sendMessage({ text })
519
+
520
+ const userMessage: UIMessage<AgentMessageMetadata> = {
521
+ id: Helpers.uuid(),
522
+ role: "user",
523
+ parts: [{ type: "text", text }],
524
+ }
525
+ publishChatMessages([...(chat.messages ?? []), userMessage])
526
+ chatInstance.sendMessage({
527
+ id: userMessage.id,
528
+ role: "user",
529
+ parts: userMessage.parts,
530
+ })
487
531
  isPreparingResponse = false
488
532
  }
489
533
 
@@ -495,8 +539,17 @@
495
539
  await sendMessage()
496
540
  }
497
541
 
498
- const toggleTool = (toolId: string) => {
499
- expandedTools = { ...expandedTools, [toolId]: !expandedTools[toolId] }
542
+ const toggleExpanded = (id: string) => {
543
+ const area = chatAreaElement
544
+ const previousScrollTop = area?.scrollTop ?? 0
545
+ skipAutoScroll = true
546
+ expandedTools = { ...expandedTools, [id]: !expandedTools[id] }
547
+ tick().then(() => {
548
+ if (area) {
549
+ area.scrollTop = previousScrollTop
550
+ }
551
+ skipAutoScroll = false
552
+ })
500
553
  }
501
554
 
502
555
  const formatToolOutput = (output: unknown): string =>
@@ -530,7 +583,12 @@
530
583
  $effect(() => {
531
584
  if (!chatAreaElement) return
532
585
 
533
- const obs = new MutationObserver(scrollToBottom)
586
+ const obs = new MutationObserver(() => {
587
+ if (skipAutoScroll) {
588
+ return
589
+ }
590
+ scrollToBottom()
591
+ })
534
592
  obs.observe(chatAreaElement, {
535
593
  childList: true,
536
594
  subtree: true,
@@ -543,8 +601,8 @@
543
601
  })
544
602
  </script>
545
603
 
546
- <div class="chat-area" bind:this={chatAreaElement}>
547
- <div class="chatbox">
604
+ <div class="chat-area">
605
+ <div class="chatbox" bind:this={chatAreaElement}>
548
606
  {#if !hasMessages && !isRequestPending}
549
607
  <div class="empty-state">
550
608
  <div class="empty-state-icon">
@@ -597,35 +655,13 @@
597
655
  interactive={!!reasoningText}
598
656
  expanded={Boolean(expandedTools[reasoningId])}
599
657
  content={reasoningText}
600
- ontoggle={() =>
601
- (expandedTools = {
602
- ...expandedTools,
603
- [reasoningId]: !expandedTools[reasoningId],
604
- })}
658
+ ontoggle={() => toggleExpanded(reasoningId)}
605
659
  />
606
660
  {/if}
607
661
  {#each message.parts ?? [] as part, partIndex}
608
662
  {#if isTextUIPart(part)}
609
663
  <MarkdownViewer value={part.text} />
610
- {:else if isToolUIPart(part) && isRaisedEscalation(part.output)}
611
- {@const card = escalationCardProps(part)}
612
- <EscalationCard
613
- title={card.title}
614
- summary={card.summary}
615
- resolution={card.resolution}
616
- statusMessage={card.escalationId
617
- ? resolveMessages[card.escalationId]
618
- : undefined}
619
- showApproval={showInlineApproval}
620
- resolving={!!card.escalationId &&
621
- !!resolvingEscalations[card.escalationId]}
622
- onApprove={() =>
623
- card.escalationId && handleResolve(card.escalationId, true)}
624
- onReject={() =>
625
- card.escalationId &&
626
- handleResolve(card.escalationId, false)}
627
- />
628
- {:else if isToolUIPart(part)}
664
+ {:else if isToolUIPart(part) && !isRaisedEscalation(part.output)}
629
665
  {@const rawToolName = getToolName(part)}
630
666
  {@const displayToolName = formatToolName(
631
667
  rawToolName,
@@ -642,7 +678,7 @@
642
678
  class="tool-header"
643
679
  class:tool-header-expanded={expandedTools[toolId]}
644
680
  type="button"
645
- onclick={() => toggleTool(toolId)}
681
+ onclick={() => toggleExpanded(toolId)}
646
682
  >
647
683
  <span
648
684
  class="tool-chevron"
@@ -753,6 +789,28 @@
753
789
  </ul>
754
790
  </div>
755
791
  {/if}
792
+ {#each message.parts ?? [] as part}
793
+ {#if isToolUIPart(part) && isRaisedEscalation(part.output)}
794
+ {@const card = escalationCardProps(part)}
795
+ <EscalationCard
796
+ title={card.title}
797
+ summary={card.summary}
798
+ reviewContext={card.reviewContext}
799
+ resolution={card.resolution}
800
+ statusMessage={card.escalationId
801
+ ? resolveMessages[card.escalationId]
802
+ : undefined}
803
+ showApproval={showInlineApproval}
804
+ resolving={!!card.escalationId &&
805
+ !!resolvingEscalations[card.escalationId]}
806
+ onApprove={() =>
807
+ card.escalationId && handleResolve(card.escalationId, true)}
808
+ onReject={() =>
809
+ card.escalationId &&
810
+ handleResolve(card.escalationId, false)}
811
+ />
812
+ {/if}
813
+ {/each}
756
814
  </div>
757
815
  {/if}
758
816
  {/if}
@@ -803,7 +861,7 @@
803
861
  flex: 1 1 0;
804
862
  display: flex;
805
863
  flex-direction: column;
806
- overflow-y: auto;
864
+ overflow: hidden;
807
865
  min-height: 0;
808
866
  font-family: var(--chat-font-sans, var(--font-sans));
809
867
  --font-serif: var(--chat-font-sans, var(--font-sans));
@@ -819,7 +877,9 @@
819
877
  flex-direction: column;
820
878
  gap: 24px;
821
879
  width: 100%;
822
- flex: 1 1 auto;
880
+ flex: 1 1 0;
881
+ min-height: 0;
882
+ overflow-y: auto;
823
883
  padding: 48px 0 24px 0;
824
884
  }
825
885
 
@@ -868,14 +928,13 @@
868
928
  }
869
929
 
870
930
  .input-wrapper {
871
- position: sticky;
872
- bottom: 0;
873
931
  width: 100%;
874
932
  display: flex;
875
933
  flex-direction: column;
876
934
  flex-shrink: 0;
877
935
  line-height: 1.4;
878
936
  gap: 6px;
937
+ padding-top: 8px;
879
938
  }
880
939
 
881
940
  .input-footer {
@@ -983,7 +1042,9 @@
983
1042
  }
984
1043
 
985
1044
  .tool-part {
986
- position: relative;
1045
+ display: flex;
1046
+ flex-direction: column;
1047
+ align-items: stretch;
987
1048
  margin-top: var(--spacing-l);
988
1049
  margin-bottom: 0;
989
1050
  }
@@ -1076,22 +1137,17 @@
1076
1137
  }
1077
1138
 
1078
1139
  .tool-details {
1079
- position: absolute;
1080
- top: 100%;
1081
- left: 0;
1082
- margin-top: var(--spacing-m);
1140
+ margin-top: var(--spacing-s);
1083
1141
  width: 100%;
1084
1142
  max-width: 100%;
1085
1143
  box-sizing: border-box;
1086
1144
  display: flex;
1087
1145
  flex-direction: column;
1088
1146
  gap: var(--spacing-s);
1089
- background: var(--background);
1147
+ background: var(--spectrum-global-color-gray-75);
1090
1148
  border: 1px solid var(--spectrum-global-color-gray-200);
1091
1149
  border-radius: 6px;
1092
1150
  padding: var(--spacing-m);
1093
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
1094
- z-index: 1;
1095
1151
  overflow-x: hidden;
1096
1152
  min-width: 0;
1097
1153
  }
@@ -1,14 +1,25 @@
1
1
  <script>
2
2
  import { Select, Multiselect } from "@budibase/bbui"
3
- import { fetchData, loadTranslationsByGroup } from "@budibase/frontend-core"
3
+ import {
4
+ fetchData,
5
+ fetchUsersById,
6
+ loadTranslationsByGroup,
7
+ Utils,
8
+ } from "@budibase/frontend-core"
4
9
  import { createAPIClient } from "../api"
5
10
 
6
11
  export let API = createAPIClient()
7
12
 
13
+ /** @type {string | string[] | null} */
8
14
  export let value = null
9
- export let disabled
15
+ export let disabled = false
10
16
  export let multiselect = false
11
17
 
18
+ let searchTerm = null
19
+ let appliedTerm = ""
20
+ let selectedCache = {}
21
+ let loadingIds = new Set()
22
+
12
23
  $: fetch = fetchData({
13
24
  API,
14
25
  datasource: {
@@ -19,10 +30,57 @@
19
30
  },
20
31
  })
21
32
 
22
- $: options = $fetch.rows
33
+ $: search(searchTerm || "")
34
+ $: selectedIds = !value ? [] : Array.isArray(value) ? value : [value]
35
+ $: pageIds = new Set(($fetch.rows || []).map(user => user._id))
36
+ $: loadMissing(
37
+ selectedIds.filter(id => !pageIds.has(id) && !selectedCache[id])
38
+ )
39
+ $: options = sortSelectedFirst(
40
+ [
41
+ ...($fetch.rows || []),
42
+ ...Object.values(selectedCache).filter(
43
+ user => selectedIds.includes(user._id) && !pageIds.has(user._id)
44
+ ),
45
+ ],
46
+ selectedIds
47
+ )
23
48
 
24
49
  $: component = multiselect ? Multiselect : Select
25
50
  const pickerLabels = loadTranslationsByGroup("picker")
51
+
52
+ const search = Utils.debounce(term => {
53
+ if (term === appliedTerm) {
54
+ return
55
+ }
56
+ appliedTerm = term
57
+ fetch.update({ query: term ? { string: { email: term } } : {} })
58
+ }, 250)
59
+
60
+ const loadMissing = async ids => {
61
+ const wanted = ids.filter(id => !loadingIds.has(id))
62
+ if (!wanted.length) {
63
+ return
64
+ }
65
+ wanted.forEach(id => loadingIds.add(id))
66
+ const users = await fetchUsersById(API, wanted)
67
+ wanted.forEach(id => loadingIds.delete(id))
68
+ if (Object.keys(users).length) {
69
+ selectedCache = { ...selectedCache, ...users }
70
+ }
71
+ }
72
+
73
+ const sortSelectedFirst = (users, ids) => {
74
+ const selected = new Set(ids)
75
+ return [...users].sort((a, b) => {
76
+ const aSelected = selected.has(a._id)
77
+ const bSelected = selected.has(b._id)
78
+ if (aSelected !== bSelected) {
79
+ return aSelected ? -1 : 1
80
+ }
81
+ return (a.email || "").localeCompare(b.email || "")
82
+ })
83
+ }
26
84
  </script>
27
85
 
28
86
  <div class="user-control">
@@ -37,5 +95,6 @@
37
95
  {disabled}
38
96
  searchPlaceholder={pickerLabels.searchPlaceholder}
39
97
  popoverAutoWidth
98
+ bind:searchTerm
40
99
  />
41
100
  </div>
@@ -84,6 +84,7 @@ export default abstract class BaseDataFetch<
84
84
  }
85
85
  store: Writable<DataFetchStore<TDefinition, TQuery, TRow>>
86
86
  derivedStore: Readable<DataFetchDerivedStore<TDefinition, TQuery, TRow>>
87
+ lastLoadId: number | undefined
87
88
 
88
89
  /**
89
90
  * Constructs a new DataFetch instance.
@@ -203,11 +204,22 @@ export default abstract class BaseDataFetch<
203
204
  async getInitialData() {
204
205
  const { filter, paginate } = this.options
205
206
 
207
+ // Load using IDs to track invocations and ensure we're handling the
208
+ // latest update
209
+ this.lastLoadId = Math.random()
210
+ const thisLoadId = this.lastLoadId
211
+
206
212
  // Fetch datasource definition and extract sort properties if configured
207
213
  const definition = await this.getDefinition()
208
214
 
209
215
  // Determine feature flags
210
216
  const features = await this.determineFeatureFlags()
217
+
218
+ // In case a newer load started while we waited, abandon this one before
219
+ // it writes its query to the store
220
+ if (thisLoadId !== this.lastLoadId) {
221
+ return
222
+ }
211
223
  this.features = {
212
224
  supportsSearch: !!features?.supportsSearch,
213
225
  supportsSort: !!features?.supportsSort,
@@ -259,6 +271,11 @@ export default abstract class BaseDataFetch<
259
271
 
260
272
  // Actually fetch data
261
273
  const page = await this.getPage()
274
+
275
+ // In case fetching takes longer than a newer load, abandon these results
276
+ if (thisLoadId !== this.lastLoadId) {
277
+ return
278
+ }
262
279
  this.store.update($store => ({
263
280
  ...$store,
264
281
  loading: false,
@@ -11,7 +11,6 @@ import BaseDataFetch, { DataFetchParams } from "./DataFetch"
11
11
 
12
12
  interface UserFetchQuery {
13
13
  appId?: string
14
- workspaceId?: string
15
14
  paginated?: boolean
16
15
  string?: {
17
16
  email: string
@@ -36,6 +35,7 @@ export default class UserFetch extends BaseDataFetch<
36
35
  super({
37
36
  ...opts,
38
37
  datasource: {
38
+ ...opts.datasource,
39
39
  type: "user",
40
40
  },
41
41
  })
@@ -56,9 +56,14 @@ export default class UserFetch extends BaseDataFetch<
56
56
  async getData() {
57
57
  const { limit, paginate } = this.options
58
58
  const { cursor, query } = get(this.store)
59
+ const {
60
+ groupIds,
61
+ workspaceId: datasourceWorkspaceId,
62
+ workspaceRoleId,
63
+ } = this.options.datasource
59
64
 
60
65
  // Convert old format to new one - we now allow use of the lucene format
61
- const { appId, paginated, workspaceId, ...rest } = query
66
+ const { appId, paginated, ...rest } = query
62
67
 
63
68
  const finalQuery: SearchFilters = utils.isSupportedUserSearch(rest)
64
69
  ? rest
@@ -69,7 +74,9 @@ export default class UserFetch extends BaseDataFetch<
69
74
  bookmark: cursor ?? undefined,
70
75
  query: finalQuery ?? undefined,
71
76
  appId: appId,
72
- workspaceId,
77
+ workspaceId: datasourceWorkspaceId,
78
+ groupIds,
79
+ workspaceRoleId,
73
80
  paginate: paginated || paginate,
74
81
  limit,
75
82
  }
@@ -18,4 +18,5 @@ export * from "./validation"
18
18
  export * from "./formatting"
19
19
  export * from "./aiTools"
20
20
  export * from "./login"
21
+ export * from "./users"
21
22
  export * from "./translationGroups"
@@ -0,0 +1,37 @@
1
+ import { StrippedUser, User } from "@budibase/types"
2
+ import { APIClient } from "../api"
3
+
4
+ export type PickerUser = User | StrippedUser | { _id: string; email: string }
5
+
6
+ // Resolves user ids that fall outside a picker's first page. Ids that no
7
+ // longer match a user get a placeholder so callers stop re-requesting them
8
+ // and still have something to render.
9
+ export const fetchUsersById = async (
10
+ API: APIClient,
11
+ ids: string[]
12
+ ): Promise<Record<string, PickerUser>> => {
13
+ const wanted = Array.from(new Set(ids.filter(Boolean)))
14
+ const users: Record<string, PickerUser> = {}
15
+ if (!wanted.length) {
16
+ return users
17
+ }
18
+ try {
19
+ const res = await API.searchUsers({
20
+ query: { oneOf: { _id: wanted } },
21
+ limit: wanted.length,
22
+ })
23
+ for (const user of res?.data || []) {
24
+ if (user?._id) {
25
+ users[user._id] = user
26
+ }
27
+ }
28
+ for (const id of wanted) {
29
+ if (!users[id]) {
30
+ users[id] = { _id: id, email: id }
31
+ }
32
+ }
33
+ } catch (error) {
34
+ console.error("Failed to load users by id", error)
35
+ }
36
+ return users
37
+ }