@frontera-sdk/blueprint 1.52.6 → 1.52.8
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 +2 -2
- package/src/action-client.ts +117 -20
- package/src/action-hooks.ts +242 -7
- package/src/action-types.ts +155 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/blueprint",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.8",
|
|
4
4
|
"description": "React hooks for reading Blueprint data and invoking governed Actions from inside a Frontera app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"smoke": "bun run scripts/smoke.ts"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@frontera-sdk/core": "1.52.
|
|
59
|
+
"@frontera-sdk/core": "1.52.8"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"@tanstack/react-query": "^5.90.21",
|
package/src/action-client.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
2
|
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
3
3
|
import type {
|
|
4
|
+
ActionApprovalDecision,
|
|
5
|
+
ActionDecisionResult,
|
|
4
6
|
ActionDescriptor,
|
|
5
7
|
ActionRequest,
|
|
6
8
|
ActionRequestLifecycle,
|
|
9
|
+
ActionRequestListOptions,
|
|
10
|
+
ActionRequestPage,
|
|
7
11
|
SubmitActionInput,
|
|
8
12
|
} from './action-types'
|
|
9
13
|
|
|
@@ -30,8 +34,14 @@ const APP_USER_TOKEN_PREFIX = 'sk-au-'
|
|
|
30
34
|
* reads a request they raised. `submit` is accepted only for records that a
|
|
31
35
|
* role listing that Action lets the person read. A role that only reads never
|
|
32
36
|
* widens what they can change. A referenced record need only be readable.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
37
|
+
*
|
|
38
|
+
* Such a person also decides the approval steps their App role is given:
|
|
39
|
+
* `awaitingMe` lists the requests waiting on one, `decide` approves or
|
|
40
|
+
* rejects one, and `request` reads it. Any other request answers as not
|
|
41
|
+
* found, whether or not it exists. `mine` lists the requests they raised, and
|
|
42
|
+
* `cancel` withdraws one of those while it waits. `requests`, every request
|
|
43
|
+
* in the workspace, is for Frontera members and is refused here for such a
|
|
44
|
+
* person, before anything is sent.
|
|
35
45
|
*/
|
|
36
46
|
export class ActionClient {
|
|
37
47
|
constructor(private readonly client: FronteraClient) {}
|
|
@@ -98,8 +108,8 @@ export class ActionClient {
|
|
|
98
108
|
|
|
99
109
|
requests(lifecycle?: readonly ActionRequestLifecycle[]): Promise<ActionRequest[]> {
|
|
100
110
|
const refused = this.membersOnly(
|
|
101
|
-
'Listing Action
|
|
102
|
-
'
|
|
111
|
+
'Listing every Action request in the workspace',
|
|
112
|
+
'Read the requests waiting for their decision with `awaitingMe()` or `useApprovalInbox`, and the requests they raised with `mine()` or `useMyActionRequests`.',
|
|
103
113
|
)
|
|
104
114
|
if (refused) return Promise.reject(refused)
|
|
105
115
|
return this.client.request<ActionRequest[]>(`${BASE}/requests`, {
|
|
@@ -107,6 +117,62 @@ export class ActionClient {
|
|
|
107
117
|
})
|
|
108
118
|
}
|
|
109
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The waiting requests this person could decide right now, newest first.
|
|
122
|
+
*
|
|
123
|
+
* For a Frontera member: requests waiting on a step workspace members
|
|
124
|
+
* decide, in a workspace where they may approve. For a person signed in to
|
|
125
|
+
* an externally hosted App: requests waiting on a step that names an App
|
|
126
|
+
* role they hold, for a record that role's data scope covers.
|
|
127
|
+
*
|
|
128
|
+
* Never one they raised, one they already decided a step of, or one past
|
|
129
|
+
* its deadline. A request leaves the list as soon as any of that changes, so
|
|
130
|
+
* a list read a moment ago can hold a request that `decide` now refuses.
|
|
131
|
+
* Nothing here says how many other requests are waiting for other people.
|
|
132
|
+
*
|
|
133
|
+
* A page looks at a bounded number of the most recent waiting requests.
|
|
134
|
+
* When it stopped there, the page says `truncated: true`: more may wait
|
|
135
|
+
* behind them.
|
|
136
|
+
*/
|
|
137
|
+
awaitingMe(options: ActionRequestListOptions = {}): Promise<ActionRequestPage> {
|
|
138
|
+
return this.page({ awaiting: 'me', ...listQuery(options) })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The requests this person raised, newest first, in every state unless
|
|
143
|
+
* `lifecycle` narrows it. Each carries its approval steps, who decided them
|
|
144
|
+
* by name and, for a rejected request, the reason.
|
|
145
|
+
*
|
|
146
|
+
* For a person signed in to an externally hosted App: the requests they
|
|
147
|
+
* raised through this App.
|
|
148
|
+
*/
|
|
149
|
+
mine(
|
|
150
|
+
options: ActionRequestListOptions & { lifecycle?: readonly ActionRequestLifecycle[] } = {},
|
|
151
|
+
): Promise<ActionRequestPage> {
|
|
152
|
+
return this.page({
|
|
153
|
+
mine: 'true',
|
|
154
|
+
...(options.lifecycle?.length ? { lifecycle: options.lifecycle.join(',') } : {}),
|
|
155
|
+
...listQuery(options),
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One page of a named list: the requests, and where the next page starts. */
|
|
160
|
+
private async page(query: Record<string, string>): Promise<ActionRequestPage> {
|
|
161
|
+
const envelope = await this.client.requestEnvelope<{
|
|
162
|
+
data?: ActionRequest[]
|
|
163
|
+
nextCursor?: string | null
|
|
164
|
+
truncated?: boolean
|
|
165
|
+
} | null>(
|
|
166
|
+
`${BASE}/requests`,
|
|
167
|
+
{ query },
|
|
168
|
+
)
|
|
169
|
+
return {
|
|
170
|
+
requests: envelope?.data ?? [],
|
|
171
|
+
nextCursor: envelope?.nextCursor ?? null,
|
|
172
|
+
truncated: envelope?.truncated === true,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
110
176
|
request(requestId: string): Promise<ActionRequest> {
|
|
111
177
|
return this.client.request<ActionRequest>(`${BASE}/requests/${encodeURIComponent(requestId)}`)
|
|
112
178
|
}
|
|
@@ -115,30 +181,52 @@ export class ActionClient {
|
|
|
115
181
|
* Approve or reject. Separate from `submit` because it is a different act by
|
|
116
182
|
* a different person — an Action with separation of duties refuses a decision
|
|
117
183
|
* from whoever submitted it.
|
|
184
|
+
*
|
|
185
|
+
* `stage` names the approval-chain step being decided, as `approval.currentStage`
|
|
186
|
+
* showed it to the person. It is required for a request approved in steps
|
|
187
|
+
* (one whose `approval` is not `null`): without it the decision is refused as
|
|
188
|
+
* invalid, with reason `ACTION_APPROVAL_STEP_REQUIRED`. If another step is
|
|
189
|
+
* waiting by the time the decision arrives, it is refused with reason
|
|
190
|
+
* `ACTION_APPROVAL_STEP_CHANGED` rather than recorded on that step. Leave it
|
|
191
|
+
* out only for a request that has no approval steps, where naming one is
|
|
192
|
+
* refused.
|
|
193
|
+
*
|
|
194
|
+
* A Frontera member decides the requests and steps that name workspace
|
|
195
|
+
* members. A person signed in to an externally hosted App decides a step
|
|
196
|
+
* that names an App role they hold, for a record that role's data scope
|
|
197
|
+
* covers. To that person every other request answers `ACTION_NOT_FOUND`,
|
|
198
|
+
* the same as a request that does not exist: show "nothing to decide here",
|
|
199
|
+
* not an error about permissions.
|
|
200
|
+
*
|
|
201
|
+
* Resolves to the request as the decision left it, with `request` (the same
|
|
202
|
+
* request) and `approvalDecision` (the decision recorded) beside it: see
|
|
203
|
+
* `ActionDecisionResult`.
|
|
118
204
|
*/
|
|
119
|
-
decide(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
205
|
+
async decide(
|
|
206
|
+
requestId: string,
|
|
207
|
+
decision: 'approve' | 'reject',
|
|
208
|
+
reason: string,
|
|
209
|
+
stage?: string,
|
|
210
|
+
): Promise<ActionDecisionResult> {
|
|
211
|
+
const answer = await this.client.request<{ approval: ActionApprovalDecision; request: ActionRequest }>(
|
|
126
212
|
`${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,
|
|
127
|
-
{ method: 'POST', body: { decision, reason } },
|
|
213
|
+
{ method: 'POST', body: { decision, reason, ...(stage ? { stage } : {}) } },
|
|
128
214
|
)
|
|
215
|
+
return { ...answer.request, request: answer.request, approvalDecision: answer.approval }
|
|
129
216
|
}
|
|
130
217
|
|
|
131
218
|
/**
|
|
132
|
-
* Cancel
|
|
133
|
-
* the credential
|
|
134
|
-
*
|
|
219
|
+
* Cancel sends no body. The platform reads the request from the URL and the
|
|
220
|
+
* caller from the credential, and ignores anything sent as a body: a
|
|
221
|
+
* `reason` sent here would be dropped, not recorded, so none is taken.
|
|
222
|
+
*
|
|
223
|
+
* A Frontera member with permission to cancel requests cancels a request
|
|
224
|
+
* that waits for approval, or is approved and has not started to run. A person signed in to an externally hosted App withdraws
|
|
225
|
+
* only a request they raised, and only while it waits for approval: any
|
|
226
|
+
* other request answers as not found, and one already approved answers
|
|
227
|
+
* `CONFLICT`.
|
|
135
228
|
*/
|
|
136
229
|
cancel(requestId: string): Promise<ActionRequest> {
|
|
137
|
-
const refused = this.membersOnly(
|
|
138
|
-
'Cancelling an Action request',
|
|
139
|
-
'A Frontera member with permission to cancel requests can cancel it in Frontera.',
|
|
140
|
-
)
|
|
141
|
-
if (refused) return Promise.reject(refused)
|
|
142
230
|
return this.client.request<ActionRequest>(
|
|
143
231
|
`${BASE}/requests/${encodeURIComponent(requestId)}/cancel`,
|
|
144
232
|
{ method: 'POST' },
|
|
@@ -146,6 +234,15 @@ export class ActionClient {
|
|
|
146
234
|
}
|
|
147
235
|
}
|
|
148
236
|
|
|
237
|
+
/** The query a list sends for its filter and position: only what was given. */
|
|
238
|
+
function listQuery(options: ActionRequestListOptions): Record<string, string> {
|
|
239
|
+
return {
|
|
240
|
+
...(options.actionDefinitionId ? { actionDefinitionId: options.actionDefinitionId } : {}),
|
|
241
|
+
...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
|
|
242
|
+
...(options.cursor ? { cursor: options.cursor } : {}),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
149
246
|
/**
|
|
150
247
|
* The wire envelope, assembled from the flat input a caller actually has.
|
|
151
248
|
*
|
package/src/action-hooks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useEffect } from 'react'
|
|
2
2
|
import {
|
|
3
|
+
useInfiniteQuery,
|
|
3
4
|
useMutation,
|
|
4
5
|
useQuery,
|
|
5
6
|
useQueryClient,
|
|
@@ -13,18 +14,47 @@ import { blueprintKeys } from './hooks'
|
|
|
13
14
|
import {
|
|
14
15
|
actionEffectOf,
|
|
15
16
|
isTerminalLifecycle,
|
|
17
|
+
type ActionDecisionResult,
|
|
16
18
|
type ActionDescriptor,
|
|
17
19
|
type ActionRequest,
|
|
18
20
|
type ActionRequestLifecycle,
|
|
21
|
+
type ActionRequestPage,
|
|
19
22
|
type SubmitActionInput,
|
|
20
23
|
} from './action-types'
|
|
21
24
|
|
|
25
|
+
/** Which requests `useApprovalInbox` reads. */
|
|
26
|
+
export interface ApprovalInboxFilter {
|
|
27
|
+
/** Only requests of this Action. */
|
|
28
|
+
actionDefinitionId?: string
|
|
29
|
+
/** Requests read per page: 1 to 200. Left out, 50. */
|
|
30
|
+
pageSize?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Which requests `useMyActionRequests` reads. */
|
|
34
|
+
export interface MyActionRequestsFilter extends ApprovalInboxFilter {
|
|
35
|
+
/** Only requests in these states. Left out, every state. */
|
|
36
|
+
lifecycle?: readonly ActionRequestLifecycle[]
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
export const actionKeys = {
|
|
23
40
|
all: ['blueprint', 'actions'] as const,
|
|
24
41
|
discovery: () => ['blueprint', 'actions', 'discovery'] as const,
|
|
25
42
|
requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>
|
|
26
43
|
['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,
|
|
27
44
|
request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,
|
|
45
|
+
/** With no filter: the prefix every approval inbox is cached under. */
|
|
46
|
+
awaitingMe: (filter?: ApprovalInboxFilter) => (filter
|
|
47
|
+
? ['blueprint', 'actions', 'awaiting-me', filter.actionDefinitionId ?? 'all', filter.pageSize ?? 'default'] as const
|
|
48
|
+
: ['blueprint', 'actions', 'awaiting-me'] as const),
|
|
49
|
+
/** With no filter: the prefix every list of one's own requests is cached under. */
|
|
50
|
+
mine: (filter?: MyActionRequestsFilter) => (filter
|
|
51
|
+
? [
|
|
52
|
+
'blueprint', 'actions', 'mine',
|
|
53
|
+
filter.lifecycle?.join(',') ?? 'all',
|
|
54
|
+
filter.actionDefinitionId ?? 'all',
|
|
55
|
+
filter.pageSize ?? 'default',
|
|
56
|
+
] as const
|
|
57
|
+
: ['blueprint', 'actions', 'mine'] as const),
|
|
28
58
|
}
|
|
29
59
|
|
|
30
60
|
export const FronteraActionContext = createContext<ActionClient | null>(null)
|
|
@@ -114,6 +144,8 @@ export function useSubmitAction(
|
|
|
114
144
|
onSuccess: (request) => {
|
|
115
145
|
queryClient.setQueryData(actionKeys.request(request.id), request)
|
|
116
146
|
void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })
|
|
147
|
+
// A request they raised: their own list has one more.
|
|
148
|
+
void queryClient.invalidateQueries({ queryKey: actionKeys.mine() })
|
|
117
149
|
},
|
|
118
150
|
})
|
|
119
151
|
}
|
|
@@ -184,6 +216,164 @@ export function useActionRequests(
|
|
|
184
216
|
})
|
|
185
217
|
}
|
|
186
218
|
|
|
219
|
+
/** One named list of requests, read page by page. */
|
|
220
|
+
export interface ActionRequestList {
|
|
221
|
+
/** Every request read so far, newest first. */
|
|
222
|
+
requests: ActionRequest[]
|
|
223
|
+
/** True until the first page has arrived. */
|
|
224
|
+
isLoading: boolean
|
|
225
|
+
/** True while any page is being read, the first or a later one. */
|
|
226
|
+
isFetching: boolean
|
|
227
|
+
error: Error | null
|
|
228
|
+
/** Whether the platform has at least one more request past what was read. */
|
|
229
|
+
hasMore: boolean
|
|
230
|
+
/**
|
|
231
|
+
* The approval inbox only: a page stopped at the platform's limit before it
|
|
232
|
+
* had looked at every waiting request, so the list may be incomplete even
|
|
233
|
+
* when `hasMore` is false. Always false for `useMyActionRequests`.
|
|
234
|
+
*/
|
|
235
|
+
truncated: boolean
|
|
236
|
+
/** Read the next page. Does nothing when there is none, or one is on its way. */
|
|
237
|
+
loadMore(): Promise<void>
|
|
238
|
+
/** Read the list again from the top. */
|
|
239
|
+
refetch(): Promise<void>
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
type ListReadOptions = {
|
|
243
|
+
/** Set false to read nothing until it is true. */
|
|
244
|
+
enabled?: boolean
|
|
245
|
+
/** Read the list again this often, in milliseconds. Left out, only on `refetch` and after a decision. */
|
|
246
|
+
refetchInterval?: number | false
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A named list as React Query reads it: one page per cursor. */
|
|
250
|
+
export interface ActionRequestListQuery {
|
|
251
|
+
queryKey: readonly unknown[]
|
|
252
|
+
queryFn(context: { pageParam: string | null }): Promise<ActionRequestPage>
|
|
253
|
+
initialPageParam: string | null
|
|
254
|
+
getNextPageParam(last: ActionRequestPage): string | null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The query behind `useApprovalInbox`: its cache key and how it reads a page.
|
|
259
|
+
* Exported for an App that prefetches or drives the cache itself.
|
|
260
|
+
*/
|
|
261
|
+
export function approvalInboxQuery(client: ActionClient, filter: ApprovalInboxFilter = {}): ActionRequestListQuery {
|
|
262
|
+
return {
|
|
263
|
+
queryKey: actionKeys.awaitingMe(filter),
|
|
264
|
+
queryFn: ({ pageParam }) => client.awaitingMe({
|
|
265
|
+
...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),
|
|
266
|
+
...(filter.pageSize ? { limit: filter.pageSize } : {}),
|
|
267
|
+
...(pageParam ? { cursor: pageParam } : {}),
|
|
268
|
+
}),
|
|
269
|
+
initialPageParam: null,
|
|
270
|
+
getNextPageParam: (last) => last.nextCursor,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The query behind `useMyActionRequests`. */
|
|
275
|
+
export function myActionRequestsQuery(client: ActionClient, filter: MyActionRequestsFilter = {}): ActionRequestListQuery {
|
|
276
|
+
return {
|
|
277
|
+
queryKey: actionKeys.mine(filter),
|
|
278
|
+
queryFn: ({ pageParam }) => client.mine({
|
|
279
|
+
...(filter.lifecycle?.length ? { lifecycle: filter.lifecycle } : {}),
|
|
280
|
+
...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),
|
|
281
|
+
...(filter.pageSize ? { limit: filter.pageSize } : {}),
|
|
282
|
+
...(pageParam ? { cursor: pageParam } : {}),
|
|
283
|
+
}),
|
|
284
|
+
initialPageParam: null,
|
|
285
|
+
getNextPageParam: (last) => last.nextCursor,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The pages read so far as one list. A request that moved between two reads
|
|
291
|
+
* can be on two pages; it is listed once, where it first appears.
|
|
292
|
+
*/
|
|
293
|
+
export function requestsOfPages(pages: readonly ActionRequestPage[] | undefined): ActionRequest[] {
|
|
294
|
+
const seen = new Set<string>()
|
|
295
|
+
const requests: ActionRequest[] = []
|
|
296
|
+
for (const page of pages ?? []) {
|
|
297
|
+
for (const request of page.requests) {
|
|
298
|
+
if (seen.has(request.id)) continue
|
|
299
|
+
seen.add(request.id)
|
|
300
|
+
requests.push(request)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return requests
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Whether any page read so far stopped at the platform's limit: the list may
|
|
308
|
+
* then be incomplete, whatever its last page says about a next one.
|
|
309
|
+
*/
|
|
310
|
+
export function pagesTruncated(pages: readonly ActionRequestPage[] | undefined): boolean {
|
|
311
|
+
return (pages ?? []).some((page) => page.truncated)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function useRequestList(query: ActionRequestListQuery, options: ListReadOptions): ActionRequestList {
|
|
315
|
+
const result = useInfiniteQuery({
|
|
316
|
+
...query,
|
|
317
|
+
...(options.enabled === undefined ? {} : { enabled: options.enabled }),
|
|
318
|
+
...(options.refetchInterval === undefined ? {} : { refetchInterval: options.refetchInterval }),
|
|
319
|
+
})
|
|
320
|
+
return {
|
|
321
|
+
requests: requestsOfPages(result.data?.pages),
|
|
322
|
+
isLoading: result.isLoading,
|
|
323
|
+
isFetching: result.isFetching,
|
|
324
|
+
error: result.error ?? null,
|
|
325
|
+
hasMore: result.hasNextPage,
|
|
326
|
+
truncated: pagesTruncated(result.data?.pages),
|
|
327
|
+
loadMore: async () => {
|
|
328
|
+
if (result.hasNextPage && !result.isFetchingNextPage) await result.fetchNextPage()
|
|
329
|
+
},
|
|
330
|
+
refetch: async () => {
|
|
331
|
+
await result.refetch()
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* What awaits this person's approval: the waiting requests they could decide
|
|
338
|
+
* right now, newest first.
|
|
339
|
+
*
|
|
340
|
+
* Works for a Frontera member and for a person signed in to an externally
|
|
341
|
+
* hosted App, each under their own rules (`ActionClient.awaitingMe`). Render
|
|
342
|
+
* the review screen from `requests`: each carries `intent` (what is asked,
|
|
343
|
+
* and by whom) and `approval` (the steps, and the one waiting). Decide with
|
|
344
|
+
* `useDecideActionRequest`, passing `approval.currentStage` as `stage`; the
|
|
345
|
+
* list is read again after every decision made through that hook.
|
|
346
|
+
*
|
|
347
|
+
* `truncated` is true when a page stopped at the platform's limit of waiting
|
|
348
|
+
* requests looked at: say that the list may be incomplete, and narrow it with
|
|
349
|
+
* `actionDefinitionId`.
|
|
350
|
+
*
|
|
351
|
+
* The list is a moment's answer. Someone else may decide a request, or the
|
|
352
|
+
* record may leave the person's data, between the read and the decision; the
|
|
353
|
+
* decision then answers as it would for a request that is not there. Call
|
|
354
|
+
* `refetch`, or set `refetchInterval`, to keep a screen left open current.
|
|
355
|
+
*/
|
|
356
|
+
export function useApprovalInbox(options: ApprovalInboxFilter & ListReadOptions = {}): ActionRequestList {
|
|
357
|
+
const client = useActionClient()
|
|
358
|
+
const { enabled, refetchInterval, ...filter } = options
|
|
359
|
+
return useRequestList(approvalInboxQuery(client, filter), { enabled, refetchInterval })
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The requests this person raised, newest first: what became of each, the
|
|
364
|
+
* approval steps it passed, who decided them by name and, for a rejected
|
|
365
|
+
* request, the reason given.
|
|
366
|
+
*
|
|
367
|
+
* For a person signed in to an externally hosted App: the requests they
|
|
368
|
+
* raised through this App. Read again after `useSubmitAction`,
|
|
369
|
+
* `useDecideActionRequest` and `useCancelActionRequest` succeed.
|
|
370
|
+
*/
|
|
371
|
+
export function useMyActionRequests(options: MyActionRequestsFilter & ListReadOptions = {}): ActionRequestList {
|
|
372
|
+
const client = useActionClient()
|
|
373
|
+
const { enabled, refetchInterval, ...filter } = options
|
|
374
|
+
return useRequestList(myActionRequestsQuery(client, filter), { enabled, refetchInterval })
|
|
375
|
+
}
|
|
376
|
+
|
|
187
377
|
/**
|
|
188
378
|
* Approve or reject a Request awaiting a decision.
|
|
189
379
|
*
|
|
@@ -191,20 +381,65 @@ export function useActionRequests(
|
|
|
191
381
|
* submitted it, so this will fail for the requester — correctly. Surface that
|
|
192
382
|
* refusal rather than hiding the control: "someone else must approve this" is
|
|
193
383
|
* the information the user needs.
|
|
384
|
+
*
|
|
385
|
+
* Works with the session of a person signed in to an externally hosted App,
|
|
386
|
+
* for a step their App role decides.
|
|
387
|
+
*
|
|
388
|
+
* For a request approved in steps, `stage` is required: pass
|
|
389
|
+
* `request.approval.currentStage` from the request the person is looking at,
|
|
390
|
+
* so a decision is never recorded on a step they did not read. The hook does
|
|
391
|
+
* not fill it in from its cache: the cache may have been read again since the
|
|
392
|
+
* screen was drawn, and only your component knows which step it showed. A
|
|
393
|
+
* decision without it is refused (`ACTION_APPROVAL_STEP_REQUIRED`). Leave it
|
|
394
|
+
* out for a request whose `approval` is `null`.
|
|
395
|
+
*
|
|
396
|
+
* Resolves to the request as the decision left it (`ActionDecisionResult`).
|
|
397
|
+
* The request, the approval inbox and the lists of requests are read again.
|
|
194
398
|
*/
|
|
195
399
|
export function useDecideActionRequest(): UseMutationResult<
|
|
196
|
-
|
|
400
|
+
ActionDecisionResult,
|
|
197
401
|
Error,
|
|
198
|
-
{ requestId: string; decision: 'approve' | 'reject'; reason: string }
|
|
402
|
+
{ requestId: string; decision: 'approve' | 'reject'; reason: string; stage?: string }
|
|
199
403
|
> {
|
|
200
404
|
const client = useActionClient()
|
|
201
405
|
const queryClient = useQueryClient()
|
|
202
406
|
|
|
203
407
|
return useMutation({
|
|
204
|
-
mutationFn: ({ requestId, decision, reason }) => client.decide(requestId, decision, reason),
|
|
205
|
-
onSuccess: (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
408
|
+
mutationFn: ({ requestId, decision, reason, stage }) => client.decide(requestId, decision, reason, stage),
|
|
409
|
+
onSuccess: (decided) => refreshAfterWrite(queryClient, decided.id),
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Withdraw a Request that is still waiting.
|
|
415
|
+
*
|
|
416
|
+
* A person signed in to an externally hosted App withdraws only a request
|
|
417
|
+
* they raised, while it waits for approval. A Frontera member needs the
|
|
418
|
+
* permission to cancel requests. The request and the lists are read again.
|
|
419
|
+
*/
|
|
420
|
+
export function useCancelActionRequest(): UseMutationResult<ActionRequest, Error, string> {
|
|
421
|
+
const client = useActionClient()
|
|
422
|
+
const queryClient = useQueryClient()
|
|
423
|
+
|
|
424
|
+
return useMutation({
|
|
425
|
+
mutationFn: (requestId) => client.cancel(requestId),
|
|
426
|
+
onSuccess: (cancelled) => refreshAfterWrite(queryClient, cancelled.id),
|
|
209
427
|
})
|
|
210
428
|
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* After a decision or a cancellation: read the request again rather than
|
|
432
|
+
* cache the reply, which carries no `intent` and no approval steps, and read
|
|
433
|
+
* every list it may have left or changed in.
|
|
434
|
+
*/
|
|
435
|
+
export function refreshAfterWrite(
|
|
436
|
+
queryClient: { invalidateQueries(filters: { queryKey: readonly unknown[] }): Promise<void> },
|
|
437
|
+
requestId: string,
|
|
438
|
+
): void {
|
|
439
|
+
for (const queryKey of [
|
|
440
|
+
actionKeys.request(requestId),
|
|
441
|
+
actionKeys.requests(),
|
|
442
|
+
actionKeys.awaitingMe(),
|
|
443
|
+
actionKeys.mine(),
|
|
444
|
+
]) void queryClient.invalidateQueries({ queryKey })
|
|
445
|
+
}
|
package/src/action-types.ts
CHANGED
|
@@ -111,12 +111,49 @@ export function recordVersionOf(instance: unknown): number | undefined {
|
|
|
111
111
|
return typeof version === 'number' ? version : undefined
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* What a submission of this Action waits for.
|
|
116
|
+
*
|
|
117
|
+
* Any mode but `'none'` means the request may wait for approval. Test
|
|
118
|
+
* `mode !== 'none'`, not `mode === 'required'`: a mode you did not test for
|
|
119
|
+
* would otherwise read as "no approval". Under `'chain'` the request passes
|
|
120
|
+
* its steps one after another, and a step whose condition does not hold for
|
|
121
|
+
* it is skipped; read the request's `approval` to see where it stands.
|
|
122
|
+
*/
|
|
114
123
|
export interface ActionApprovalPolicy {
|
|
115
|
-
mode: 'none' | 'required'
|
|
124
|
+
mode: 'none' | 'required' | 'conditional' | 'chain'
|
|
116
125
|
threshold?: number
|
|
117
|
-
separationOfDuties?: boolean
|
|
126
|
+
separationOfDuties?: boolean | 'strict'
|
|
127
|
+
/** Under `'chain'`: how long a request may wait for its steps, in hours or days, such as `24h` or `3d`. */
|
|
128
|
+
expiresAfter?: string
|
|
129
|
+
/** Under `'chain'`: the steps, in order. Absent for every other mode. */
|
|
130
|
+
stages?: ActionApprovalChainStep[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One step of an Action's approval chain, as discovery describes it.
|
|
135
|
+
*
|
|
136
|
+
* `conditional` says whether the step applies only to some requests. The
|
|
137
|
+
* condition itself is not told: read a request's `approval` to see which
|
|
138
|
+
* steps applied to it.
|
|
139
|
+
*
|
|
140
|
+
* A step whose `approver` is an App role is decided for requests raised
|
|
141
|
+
* through an App that has that role. When such a step is not `conditional`,
|
|
142
|
+
* every request for the Action must be raised through such an App: one raised
|
|
143
|
+
* with no App is refused when it is submitted.
|
|
144
|
+
*/
|
|
145
|
+
export interface ActionApprovalChainStep {
|
|
146
|
+
key: string
|
|
147
|
+
/** Workspace members who may approve Action Requests, or the people who hold the named App role. */
|
|
148
|
+
approver: 'members' | { role: string }
|
|
149
|
+
/** How many approvals the step needs. */
|
|
150
|
+
quorum: number
|
|
151
|
+
conditional: boolean
|
|
118
152
|
}
|
|
119
153
|
|
|
154
|
+
/** Who decides a step of a request's approval chain: workspace members who may approve, or the people who hold an App role. */
|
|
155
|
+
export type ActionApproverRule = { members: true } | { role: string; scope: 'subject' }
|
|
156
|
+
|
|
120
157
|
/**
|
|
121
158
|
* One Action this caller may invoke.
|
|
122
159
|
*
|
|
@@ -161,6 +198,11 @@ export interface ActionDescriptor {
|
|
|
161
198
|
* `appUserName` when it is present; it can be `null` when the person's identity
|
|
162
199
|
* provider sent no name. Both are `null` for every other request, and absent
|
|
163
200
|
* from a platform that predates the fields.
|
|
201
|
+
*
|
|
202
|
+
* A reader who is signed in to an externally hosted App is given names and no
|
|
203
|
+
* identifier of anyone else: on a request someone else raised, `userId`,
|
|
204
|
+
* `agentId`, `appUserId` and `applicationId` are all `null`. On a request they
|
|
205
|
+
* raised themselves, `appUserId` and `applicationId` are their own.
|
|
164
206
|
*/
|
|
165
207
|
export interface ActionRequestRequester {
|
|
166
208
|
kind: 'agent' | 'person' | 'automation'
|
|
@@ -240,6 +282,117 @@ export interface ActionRequest {
|
|
|
240
282
|
* Never fall back to a guess.
|
|
241
283
|
*/
|
|
242
284
|
intent?: ActionRequestIntent | null
|
|
285
|
+
/**
|
|
286
|
+
* Where the request stands in its Action's approval chain. Present on reads
|
|
287
|
+
* of a request whose Action uses approval mode `chain`; `null` for every
|
|
288
|
+
* other request, and absent from a platform that predates the field.
|
|
289
|
+
*/
|
|
290
|
+
approval?: ActionRequestApproval | null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** One step of a request's approval chain. */
|
|
294
|
+
export interface ActionRequestApprovalStep {
|
|
295
|
+
/** The step's key, as the Action's approval chain names it. */
|
|
296
|
+
key: string
|
|
297
|
+
/**
|
|
298
|
+
* `open` is the step waiting for decisions now; `pending` waits for the
|
|
299
|
+
* steps before it. `skipped` never applied to this request: its condition
|
|
300
|
+
* did not hold.
|
|
301
|
+
*/
|
|
302
|
+
status: 'pending' | 'open' | 'approved' | 'skipped' | 'rejected'
|
|
303
|
+
/**
|
|
304
|
+
* Who decides the step: workspace members who may approve Action Requests,
|
|
305
|
+
* or the people who hold the named role in the App the request was raised
|
|
306
|
+
* through.
|
|
307
|
+
*/
|
|
308
|
+
approverRule: ActionApproverRule
|
|
309
|
+
/** How many approvals the step needs. */
|
|
310
|
+
quorum: number
|
|
311
|
+
/**
|
|
312
|
+
* Who decided this step, in the order they decided, by display name.
|
|
313
|
+
* `name` is `null` for someone no longer a member of the organization, and
|
|
314
|
+
* for a person whose App sign-in carried no name.
|
|
315
|
+
*/
|
|
316
|
+
decidedBy: Array<{ name: string | null }>
|
|
317
|
+
/** When the step was approved or rejected; `null` while it is not settled. */
|
|
318
|
+
decidedAt: string | null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A request's approval chain: every step, the one waiting now, and the
|
|
323
|
+
* rejection that closed the request, if one did.
|
|
324
|
+
*/
|
|
325
|
+
export interface ActionRequestApproval {
|
|
326
|
+
stages: ActionRequestApprovalStep[]
|
|
327
|
+
/** The key of the step waiting for decisions; `null` when none is. */
|
|
328
|
+
currentStage: string | null
|
|
329
|
+
/**
|
|
330
|
+
* The rejection that closed the request. `reason` is what the approver
|
|
331
|
+
* wrote; it is `null` when the stored reason could not be read.
|
|
332
|
+
*/
|
|
333
|
+
rejection: { stage: string; reason: string | null; decidedBy: { name: string | null } } | null
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* One page of a request list. `nextCursor` is `null` on the last page;
|
|
338
|
+
* otherwise pass it back as `cursor` to read the next one.
|
|
339
|
+
*
|
|
340
|
+
* `truncated` is only ever true for `awaitingMe()`: the platform looks at a
|
|
341
|
+
* bounded number of the most recent waiting requests for one page, and it
|
|
342
|
+
* stopped there. Requests the person could decide may wait behind them, so a
|
|
343
|
+
* short or empty page with no `nextCursor` is not proof that nothing else
|
|
344
|
+
* waits. Tell the person the list may be incomplete, and narrow it with
|
|
345
|
+
* `actionDefinitionId`.
|
|
346
|
+
*/
|
|
347
|
+
export interface ActionRequestPage {
|
|
348
|
+
requests: ActionRequest[]
|
|
349
|
+
nextCursor: string | null
|
|
350
|
+
truncated: boolean
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Which requests a list reads, and where in it. */
|
|
354
|
+
export interface ActionRequestListOptions {
|
|
355
|
+
/** Only requests of this Action. */
|
|
356
|
+
actionDefinitionId?: string
|
|
357
|
+
/** Requests per page: 1 to 200. Left out, 50. */
|
|
358
|
+
limit?: number
|
|
359
|
+
/** `nextCursor` of the page before. */
|
|
360
|
+
cursor?: string
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A decision as the platform recorded it. */
|
|
364
|
+
export interface ActionApprovalDecision {
|
|
365
|
+
id: string
|
|
366
|
+
requestId: string
|
|
367
|
+
decision: 'approve' | 'reject'
|
|
368
|
+
/** The approval-chain step decided; `null` for an Action that does not use a chain. */
|
|
369
|
+
stage: string | null
|
|
370
|
+
decidedAt: string
|
|
371
|
+
expiresAt?: string
|
|
372
|
+
policyThreshold?: number
|
|
373
|
+
separationOfDuties?: boolean
|
|
374
|
+
humanApproverId?: string
|
|
375
|
+
humanApproverRole?: string
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* What `decide` resolves to: the request as the decision left it, with the
|
|
380
|
+
* two parts the platform answers a decision with.
|
|
381
|
+
*
|
|
382
|
+
* It IS the request (`id`, `lifecycle` and the rest read as they do on any
|
|
383
|
+
* `ActionRequest`), so code written against the earlier `ActionRequest`
|
|
384
|
+
* return type keeps compiling and now reads real values. `request` is that
|
|
385
|
+
* same request under the name the platform gives it, and `approvalDecision`
|
|
386
|
+
* is the decision just recorded. The platform calls that part `approval`; it
|
|
387
|
+
* has another name here because `approval` on a request already means where
|
|
388
|
+
* the request stands in its approval chain.
|
|
389
|
+
*
|
|
390
|
+
* Like every reply to a write, it carries no `intent` and no chain progress:
|
|
391
|
+
* read the request again for those.
|
|
392
|
+
*/
|
|
393
|
+
export interface ActionDecisionResult extends ActionRequest {
|
|
394
|
+
request: ActionRequest
|
|
395
|
+
approvalDecision: ActionApprovalDecision
|
|
243
396
|
}
|
|
244
397
|
|
|
245
398
|
/**
|