@budibase/frontend-core 3.47.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.47.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": "adfff86585b1d393941175f58cbe74f2005616b4"
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,
@@ -32,6 +38,14 @@
32
38
  let isResolved = $derived(!!statusMessage || resolution !== "pending")
33
39
  let expanded = $state(true)
34
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))
35
49
 
36
50
  const toggleDetails = () => {
37
51
  expanded = !expanded
@@ -44,11 +58,11 @@
44
58
  type="button"
45
59
  class="escalation-card-header escalation-card-toggle"
46
60
  aria-expanded={expanded}
47
- aria-label={`${expanded ? "Collapse" : "Expand"} approval details${title ? ` for ${title}` : ""}`}
61
+ aria-label={`${expanded ? "Collapse" : "Expand"} approval details${displayTitle ? ` for ${displayTitle}` : ""}`}
48
62
  onclick={toggleDetails}
49
63
  >
50
64
  <Icon name={isResolved ? "check-circle" : "clock"} size="M" />
51
- <span class="escalation-card-title">{title || "Approval required"}</span>
65
+ <span class="escalation-card-heading">Approval required</span>
52
66
  <span class="escalation-card-header-end">
53
67
  {#if !isResolved}
54
68
  <span class="escalation-card-badge">Test mode</span>
@@ -61,11 +75,23 @@
61
75
  {:else}
62
76
  <div class="escalation-card-header">
63
77
  <Icon name={isResolved ? "check-circle" : "clock"} size="M" />
64
- <span class="escalation-card-title">{title || "Approval required"}</span>
78
+ <span class="escalation-card-heading">Approval required</span>
65
79
  </div>
66
80
  {/if}
67
81
 
68
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
+
69
95
  {#if summary}
70
96
  <Body size="S" color="var(--spectrum-global-color-gray-700)">
71
97
  {summary}
@@ -85,6 +111,24 @@
85
111
  Awaiting a human response.
86
112
  </Body>
87
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}
88
132
  {/if}
89
133
 
90
134
  {#if showApproval && !isResolved}
@@ -129,25 +173,22 @@
129
173
  color: inherit;
130
174
  font: inherit;
131
175
  }
176
+ .escalation-card-heading,
177
+ .escalation-card-title,
178
+ .escalation-card-parameters-heading {
179
+ font-weight: 600;
180
+ font-size: 14px;
181
+ color: var(--spectrum-global-color-gray-900);
182
+ }
183
+ .escalation-card-title {
184
+ margin-top: var(--spacing-xs);
185
+ }
132
186
  .escalation-card-header-end {
133
187
  display: flex;
134
188
  align-items: center;
135
189
  gap: var(--spacing-xs);
136
190
  margin-left: auto;
137
191
  }
138
- .escalation-card-chevron {
139
- display: flex;
140
- color: var(--spectrum-global-color-gray-700);
141
- transition: transform 130ms ease-in-out;
142
- }
143
- .escalation-card-chevron.expanded {
144
- transform: rotate(180deg);
145
- }
146
- .escalation-card-title {
147
- font-weight: 600;
148
- font-size: 14px;
149
- color: var(--spectrum-global-color-gray-900);
150
- }
151
192
  .escalation-card-badge {
152
193
  border-radius: 999px;
153
194
  border: 1px solid var(--spectrum-global-color-blue-400);
@@ -158,9 +199,61 @@
158
199
  line-height: 1;
159
200
  padding: 4px 8px;
160
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
+ }
161
210
  .escalation-card-actions {
162
211
  display: flex;
163
212
  gap: var(--spacing-s);
164
213
  margin-top: var(--spacing-xs);
165
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
+ }
166
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(
@@ -490,7 +516,18 @@
490
516
  inputValue = ""
491
517
  promptHistoryIndex = undefined
492
518
  onpromptsubmitted?.(text)
493
- 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
+ })
494
531
  isPreparingResponse = false
495
532
  }
496
533
 
@@ -758,6 +795,7 @@
758
795
  <EscalationCard
759
796
  title={card.title}
760
797
  summary={card.summary}
798
+ reviewContext={card.reviewContext}
761
799
  resolution={card.resolution}
762
800
  statusMessage={card.escalationId
763
801
  ? resolveMessages[card.escalationId]
@@ -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,
@@ -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
+ }