@frontera-sdk/blueprint 1.52.39 → 1.52.40
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 +29 -0
- package/src/action-hooks.ts +71 -8
- package/src/action-types.ts +230 -39
- package/src/types.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/blueprint",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.40",
|
|
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.40"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"@tanstack/react-query": "^5.90.21",
|
package/src/action-client.ts
CHANGED
|
@@ -9,7 +9,9 @@ import type {
|
|
|
9
9
|
ActionRequestListOptions,
|
|
10
10
|
ActionRequestPage,
|
|
11
11
|
SubmitActionInput,
|
|
12
|
+
UnavailableAction,
|
|
12
13
|
} from './action-types'
|
|
14
|
+
import { unavailableReasonOf } from './action-types'
|
|
13
15
|
|
|
14
16
|
const BASE = '/v1/blueprint/governed-actions'
|
|
15
17
|
|
|
@@ -76,6 +78,26 @@ export class ActionClient {
|
|
|
76
78
|
return this.client.request<ActionDescriptor[]>(`${BASE}/discovery`)
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Why discovery does not offer each Action it leaves out, or `null` when
|
|
83
|
+
* this caller may not be told: only someone who may read the organization's
|
|
84
|
+
* Actions (a member who builds, or the App's dev session on a local
|
|
85
|
+
* runtime) is. A person signed in to an externally hosted App is never
|
|
86
|
+
* asked for it; a 403 is the same `null`. Advisory: discovery and submit
|
|
87
|
+
* stay the decision.
|
|
88
|
+
*/
|
|
89
|
+
async unavailable(): Promise<UnavailableAction[] | null> {
|
|
90
|
+
const credential = this.client.config.credential
|
|
91
|
+
if (credential.kind === 'token' && credential.token.startsWith(APP_USER_TOKEN_PREFIX)) return null
|
|
92
|
+
try {
|
|
93
|
+
const rows = await this.client.request<UnavailableAction[]>(`${BASE}/discovery/unavailable`)
|
|
94
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({ ...row, reason: unavailableReasonOf(row.reason) }))
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err instanceof FronteraError && (err.status === 403 || err.status === 404)) return null
|
|
97
|
+
throw err
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
/**
|
|
80
102
|
* Submit one Action. Returns as soon as the Request is recorded — NOT when
|
|
81
103
|
* the effect has landed.
|
|
@@ -246,6 +268,13 @@ function listQuery(options: ActionRequestListOptions): Record<string, string> {
|
|
|
246
268
|
/**
|
|
247
269
|
* The wire envelope, assembled from the flat input a caller actually has.
|
|
248
270
|
*
|
|
271
|
+
* For an edit it carries the subject (`subjectRef`, `expectedSubjectVersion`)
|
|
272
|
+
* and, when the Action declares it, the numeric `expectedVersion` input. The
|
|
273
|
+
* Action's key input is not guessed here (the descriptor does not say which
|
|
274
|
+
* input it is): the platform fills it from `subjectRef.objectId` when `input`
|
|
275
|
+
* leaves it out, and refuses a key or version in `input` that disagrees with
|
|
276
|
+
* the subject.
|
|
277
|
+
*
|
|
249
278
|
* Exported for the tests: this is the part with a trap in it, and the trap is
|
|
250
279
|
* silent — a wrong shape comes back as "Action invocation is invalid" with no
|
|
251
280
|
* field named.
|
package/src/action-hooks.ts
CHANGED
|
@@ -12,8 +12,12 @@ import {
|
|
|
12
12
|
import type { ActionClient } from './action-client'
|
|
13
13
|
import { blueprintKeys } from './hooks'
|
|
14
14
|
import {
|
|
15
|
+
actionAvailabilityOf,
|
|
15
16
|
actionEffectOf,
|
|
17
|
+
actionUnavailableText,
|
|
16
18
|
isTerminalLifecycle,
|
|
19
|
+
type ActionUnavailableReason,
|
|
20
|
+
type UnavailableAction,
|
|
17
21
|
type ActionDecisionResult,
|
|
18
22
|
type ActionDescriptor,
|
|
19
23
|
type ActionRequest,
|
|
@@ -39,6 +43,8 @@ export interface MyActionRequestsFilter extends ApprovalInboxFilter {
|
|
|
39
43
|
export const actionKeys = {
|
|
40
44
|
all: ['blueprint', 'actions'] as const,
|
|
41
45
|
discovery: () => ['blueprint', 'actions', 'discovery'] as const,
|
|
46
|
+
/** Why discovery leaves Actions out, for a caller who may be told. */
|
|
47
|
+
unavailable: () => ['blueprint', 'actions', 'discovery', 'unavailable'] as const,
|
|
42
48
|
requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>
|
|
43
49
|
['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,
|
|
44
50
|
request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,
|
|
@@ -90,27 +96,84 @@ export function useActions(
|
|
|
90
96
|
return useQuery({
|
|
91
97
|
queryKey: actionKeys.discovery(),
|
|
92
98
|
queryFn: () => client.discover(),
|
|
99
|
+
// Only what the caller can invoke: entries listed just to say why not are for `useAction`.
|
|
100
|
+
select: (entries) => entries.filter((entry) => actionAvailabilityOf(entry).available),
|
|
93
101
|
...options,
|
|
94
102
|
})
|
|
95
103
|
}
|
|
96
104
|
|
|
105
|
+
/** What `useAction` answers: the Action when it can be invoked, else why not. */
|
|
106
|
+
export interface UseActionResult {
|
|
107
|
+
/** The Action, or `null` when this caller cannot invoke it (or while discovery loads). */
|
|
108
|
+
action: ActionDescriptor | null
|
|
109
|
+
/** Whether it can be invoked. `false` while discovery loads too: check `isLoading`. */
|
|
110
|
+
available: boolean
|
|
111
|
+
/**
|
|
112
|
+
* Why not, once known: the platform's reason when this caller may be told,
|
|
113
|
+
* else `unknown`. `null` when available, or while discovery (or its
|
|
114
|
+
* explanation) loads.
|
|
115
|
+
*/
|
|
116
|
+
reason: ActionUnavailableReason | null
|
|
117
|
+
/** A sentence for the person (a disabled control's tooltip or the line beside it): the platform's own when it sent one. `null` when available or still loading. */
|
|
118
|
+
reasonText: string | null
|
|
119
|
+
/** What would make it available, for the person building the App; only ever told to a caller who may read the organization's Actions. */
|
|
120
|
+
next: string | null
|
|
121
|
+
isLoading: boolean
|
|
122
|
+
error: Error | null
|
|
123
|
+
}
|
|
124
|
+
|
|
97
125
|
/**
|
|
98
|
-
* One Action by name, or `null` when this user may not invoke it.
|
|
126
|
+
* One Action by name, or `null` when this user may not invoke it, with why.
|
|
99
127
|
*
|
|
100
128
|
* `null` rather than a thrown error, because "you may not do this" is an
|
|
101
129
|
* ordinary state for a UI to be in — it renders nothing, or a disabled control
|
|
102
|
-
* with an explanation — not an exception.
|
|
130
|
+
* with an explanation — not an exception. `reasonText` is that explanation: a
|
|
131
|
+
* control that stays visible says it (a tooltip, or a line beside it) rather
|
|
132
|
+
* than sitting disabled with no word of why.
|
|
133
|
+
*
|
|
134
|
+
* Only when discovery leaves the Action out does this ask the platform why
|
|
135
|
+
* (`discovery/unavailable`), and only a caller who may be told is answered: a
|
|
136
|
+
* person signed in to an externally hosted App gets the neutral `unknown`.
|
|
103
137
|
*/
|
|
104
138
|
export function useAction(
|
|
105
139
|
apiName: string,
|
|
106
140
|
options: ReadOptions<ActionDescriptor[]> = {},
|
|
107
|
-
):
|
|
141
|
+
): UseActionResult {
|
|
142
|
+
const client = useActionClient()
|
|
108
143
|
const { data, isLoading, error } = useActions(options)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
const missing = !!data && !data.some((entry) => entry.apiName === apiName)
|
|
145
|
+
const explained = useQuery({
|
|
146
|
+
queryKey: actionKeys.unavailable(),
|
|
147
|
+
queryFn: () => client.unavailable(),
|
|
148
|
+
enabled: missing,
|
|
149
|
+
// Advisory, and a refusal is an answer (null): never retried into a loop.
|
|
150
|
+
retry: false,
|
|
151
|
+
staleTime: 30_000,
|
|
152
|
+
})
|
|
153
|
+
const explanation = missing ? (explained.isPending ? undefined : (explained.data ?? null)) : null
|
|
154
|
+
return { ...actionLookup(data, apiName, explanation), isLoading, error: error ?? null }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One Action of a discovery answer, by name: it when it can be invoked, else
|
|
159
|
+
* why not. `explained` is the platform's explanation of what discovery left
|
|
160
|
+
* out: `undefined` while it loads, `null` when this caller may not be told.
|
|
161
|
+
*/
|
|
162
|
+
export function actionLookup(
|
|
163
|
+
entries: readonly ActionDescriptor[] | undefined,
|
|
164
|
+
apiName: string,
|
|
165
|
+
explained?: readonly UnavailableAction[] | null,
|
|
166
|
+
): Pick<UseActionResult, 'action' | 'available' | 'reason' | 'reasonText' | 'next'> {
|
|
167
|
+
const none = { action: null, available: false, reason: null, reasonText: null, next: null }
|
|
168
|
+
if (!entries) return none
|
|
169
|
+
const entry = entries.find((candidate) => candidate.apiName === apiName)
|
|
170
|
+
const availability = actionAvailabilityOf(entry)
|
|
171
|
+
if (availability.available) return { action: entry!, available: true, reason: null, reasonText: null, next: null }
|
|
172
|
+
if (entry) return { action: null, available: false, reason: availability.reason, reasonText: actionUnavailableText(availability.reason), next: null }
|
|
173
|
+
if (explained === undefined) return none
|
|
174
|
+
const why = explained?.find((row) => row.apiName === apiName)
|
|
175
|
+
if (!why) return { action: null, available: false, reason: 'unknown', reasonText: actionUnavailableText('unknown'), next: null }
|
|
176
|
+
return { action: null, available: false, reason: why.reason, reasonText: why.message || actionUnavailableText(why.reason), next: why.next ?? null }
|
|
114
177
|
}
|
|
115
178
|
|
|
116
179
|
/**
|
package/src/action-types.ts
CHANGED
|
@@ -8,34 +8,76 @@
|
|
|
8
8
|
* one fact a UI has to show: submitting is not the same as done.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Every state a Request can hold, in the platform's order.
|
|
13
|
+
*
|
|
14
|
+
* A copy of the platform's wire contract, not shared code: this package
|
|
15
|
+
* installs from public npm and cannot import the service. A test pins the two
|
|
16
|
+
* lists together, so a state added on one side fails the build on the other.
|
|
17
|
+
*/
|
|
18
|
+
export const ACTION_REQUEST_LIFECYCLES = [
|
|
19
|
+
'received',
|
|
20
|
+
'awaiting_approval',
|
|
21
|
+
'ready',
|
|
22
|
+
'executing',
|
|
23
|
+
'finalizing',
|
|
24
|
+
'succeeded',
|
|
25
|
+
'rejected',
|
|
26
|
+
'expired',
|
|
27
|
+
'cancelled',
|
|
28
|
+
'failed',
|
|
29
|
+
'outcome_unknown',
|
|
30
|
+
'awaiting_resolution',
|
|
31
|
+
'closed_unknown',
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where a Request has got to.
|
|
36
|
+
*
|
|
37
|
+
* - `received`: recorded, not yet checked against its approval policy.
|
|
38
|
+
* - `awaiting_approval`: waiting for someone to decide it.
|
|
39
|
+
* - `ready`: cleared to run; a worker picks it up.
|
|
40
|
+
* - `executing`: the write is being attempted now.
|
|
41
|
+
* - `finalizing`: the write committed; the platform is verifying it.
|
|
42
|
+
* - `succeeded`: applied and verified.
|
|
43
|
+
* - `rejected`, `expired`, `cancelled`: closed before anything was attempted.
|
|
44
|
+
* - `failed`: attempted, and the target refused it.
|
|
45
|
+
* - `outcome_unknown`: attempted, and whether it landed could not be
|
|
46
|
+
* established yet; the platform reconciles it against the target.
|
|
47
|
+
* - `awaiting_resolution`: a person has to look at it, because verification or
|
|
48
|
+
* reconciliation could not settle it.
|
|
49
|
+
* - `closed_unknown`: closed by a person without the outcome ever being
|
|
50
|
+
* established. The write may have landed.
|
|
51
|
+
*/
|
|
52
|
+
export type ActionRequestLifecycle = typeof ACTION_REQUEST_LIFECYCLES[number]
|
|
53
|
+
|
|
54
|
+
/** Every effect certainty a Request can carry, in the platform's order. Pinned to the platform's list by a test. */
|
|
55
|
+
export const ACTION_EFFECT_CERTAINTIES = [
|
|
56
|
+
'not_attempted',
|
|
57
|
+
'in_flight',
|
|
58
|
+
'confirmed_not_applied',
|
|
59
|
+
'confirmed_applied',
|
|
60
|
+
'unknown',
|
|
61
|
+
] as const
|
|
22
62
|
|
|
23
63
|
/**
|
|
24
64
|
* What is known about the effect on the target — NOT whether the request is
|
|
25
|
-
* finished. `
|
|
26
|
-
* outcome could not be established, and a UI must not
|
|
27
|
-
* the write may well have landed.
|
|
65
|
+
* finished. `in_flight` is a dispatch under way. `unknown` is the honest state
|
|
66
|
+
* after a dispatch whose outcome could not be established, and a UI must not
|
|
67
|
+
* render it as failure: the write may well have landed.
|
|
28
68
|
*/
|
|
29
|
-
export type ActionEffectCertainty =
|
|
30
|
-
| 'not_attempted'
|
|
31
|
-
| 'confirmed_applied'
|
|
32
|
-
| 'confirmed_not_applied'
|
|
33
|
-
| 'outcome_unknown'
|
|
69
|
+
export type ActionEffectCertainty = typeof ACTION_EFFECT_CERTAINTIES[number]
|
|
34
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The states a Request stops moving in by itself. `failed` and `closed_unknown`
|
|
73
|
+
* can still change if late evidence about the target arrives, but nothing is
|
|
74
|
+
* scheduled to move them, so polling stops there too.
|
|
75
|
+
*/
|
|
35
76
|
const TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([
|
|
36
|
-
'succeeded', '
|
|
77
|
+
'succeeded', 'rejected', 'expired', 'cancelled', 'failed', 'closed_unknown',
|
|
37
78
|
])
|
|
38
79
|
|
|
80
|
+
/** Whether a Request has settled: nothing will move it on its own. */
|
|
39
81
|
export function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {
|
|
40
82
|
return TERMINAL.has(lifecycle)
|
|
41
83
|
}
|
|
@@ -71,27 +113,41 @@ export function actionEffectOf(
|
|
|
71
113
|
if (!request) return 'pending'
|
|
72
114
|
switch (request.effectCertainty) {
|
|
73
115
|
case 'confirmed_applied': return 'applied'
|
|
74
|
-
case '
|
|
75
|
-
|
|
116
|
+
case 'unknown': return 'uncertain'
|
|
117
|
+
// A refused attempt is final only once the Request has settled: `ready`
|
|
118
|
+
// with this certainty is a retry the platform has already queued.
|
|
119
|
+
case 'confirmed_not_applied':
|
|
120
|
+
if (!request.lifecycle || isTerminalLifecycle(request.lifecycle)) return 'refused'
|
|
121
|
+
return 'pending'
|
|
76
122
|
default: break
|
|
77
123
|
}
|
|
78
|
-
// No certainty reported
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
: '
|
|
124
|
+
// No certainty reported, or one that says nothing about the effect yet
|
|
125
|
+
// (`not_attempted`, `in_flight`). The lifecycle still answers when it has
|
|
126
|
+
// settled: a rejected, expired or cancelled Request never reached the
|
|
127
|
+
// target, and an unknown outcome stays unknown. Anything else is genuinely
|
|
128
|
+
// still in flight.
|
|
129
|
+
switch (request.lifecycle) {
|
|
130
|
+
case 'succeeded': return 'applied'
|
|
131
|
+
case 'outcome_unknown':
|
|
132
|
+
case 'closed_unknown': return 'uncertain'
|
|
133
|
+
case 'rejected':
|
|
134
|
+
case 'expired':
|
|
135
|
+
case 'cancelled':
|
|
136
|
+
case 'failed': return 'refused'
|
|
137
|
+
default: return 'pending'
|
|
138
|
+
}
|
|
85
139
|
}
|
|
86
140
|
|
|
87
141
|
/**
|
|
88
142
|
* The record version to send with an edit, read off an object instance.
|
|
89
143
|
*
|
|
90
|
-
* The version arrives as `_meta.recordVersion`, and only
|
|
91
|
-
* route
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
144
|
+
* The version arrives as `_meta.recordVersion`, and only an editable type has
|
|
145
|
+
* one at all. The instance route always sends it; a query's rows carry it only
|
|
146
|
+
* when the query asks (`recordVersions: true`, which Functions, workflows and
|
|
147
|
+
* the CLI ask for), and this SDK's queries do not ask. Reaching into `_meta`
|
|
148
|
+
* by hand is how a caller ends up sending `undefined`, and an edit of an
|
|
149
|
+
* existing record always compares versions: one sent without a version is
|
|
150
|
+
* refused, and nothing changes.
|
|
95
151
|
*
|
|
96
152
|
* So a control that edits a row from a table fetches the instance first:
|
|
97
153
|
*
|
|
@@ -175,6 +231,99 @@ export interface ActionDescriptor {
|
|
|
175
231
|
inputSchema: Record<string, unknown>
|
|
176
232
|
}
|
|
177
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Why an Action is not offered to this caller, as the platform explains it
|
|
236
|
+
* (`discovery/unavailable`), in the order its gates refuse:
|
|
237
|
+
*
|
|
238
|
+
* - `not_deployed`: published, but no deployment runs it here (never
|
|
239
|
+
* deployed, or switched off).
|
|
240
|
+
* - `no_workspace_grant`: this workspace holds no Blueprint grant on the
|
|
241
|
+
* Action's object type.
|
|
242
|
+
* - `not_a_person`: the caller acts as a key, which belongs to no member.
|
|
243
|
+
* - `not_a_member`: the person is not a member of this workspace.
|
|
244
|
+
* - `business_user`: a Business user holds no governed authority here.
|
|
245
|
+
* - `role_cannot_submit`: the person's role may not submit Action requests.
|
|
246
|
+
* - `capability_not_granted`: the role does not hold the Action's invoke capability.
|
|
247
|
+
* - `agent_not_active`, `agent_no_grant`: for an Agent caller.
|
|
248
|
+
* - `agent_cannot_act`: the Agent may read the Action's object type, but its
|
|
249
|
+
* grant there has no `act`, so it submits no Action on it.
|
|
250
|
+
* - `refused`: refused for a reason not listed.
|
|
251
|
+
* - `unknown`: the platform did not say (the caller may not ask, such as a
|
|
252
|
+
* person signed in to an externally hosted App, or a platform that
|
|
253
|
+
* predates the explanation).
|
|
254
|
+
*/
|
|
255
|
+
export type ActionUnavailableReason =
|
|
256
|
+
| 'not_deployed'
|
|
257
|
+
| 'no_workspace_grant'
|
|
258
|
+
| 'not_a_person'
|
|
259
|
+
| 'not_a_member'
|
|
260
|
+
| 'business_user'
|
|
261
|
+
| 'role_cannot_submit'
|
|
262
|
+
| 'capability_not_granted'
|
|
263
|
+
| 'agent_not_active'
|
|
264
|
+
| 'agent_no_grant'
|
|
265
|
+
| 'agent_cannot_act'
|
|
266
|
+
| 'refused'
|
|
267
|
+
| 'unknown'
|
|
268
|
+
|
|
269
|
+
const REASONS: readonly ActionUnavailableReason[] = [
|
|
270
|
+
'not_deployed', 'no_workspace_grant', 'not_a_person', 'not_a_member', 'business_user',
|
|
271
|
+
'role_cannot_submit', 'capability_not_granted', 'agent_not_active', 'agent_no_grant', 'agent_cannot_act', 'refused', 'unknown',
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
/** A reason as the platform sent it: one of the known ones, else `unknown`. */
|
|
275
|
+
export function unavailableReasonOf(value: unknown): ActionUnavailableReason {
|
|
276
|
+
return typeof value === 'string' && (REASONS as readonly string[]).includes(value) ? (value as ActionUnavailableReason) : 'unknown'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One Action discovery did not offer this caller, and why
|
|
281
|
+
* (`GET /v1/blueprint/governed-actions/discovery/unavailable`). Only a caller
|
|
282
|
+
* who may read the organization's Actions is told; the explanation is
|
|
283
|
+
* advisory, and discovery and submit stay the decision.
|
|
284
|
+
*/
|
|
285
|
+
export interface UnavailableAction {
|
|
286
|
+
apiName: string
|
|
287
|
+
displayName: string
|
|
288
|
+
reason: ActionUnavailableReason
|
|
289
|
+
/** One sentence for a person. */
|
|
290
|
+
message: string
|
|
291
|
+
/** What would make it available, when there is something to do. */
|
|
292
|
+
next: string | null
|
|
293
|
+
invokeCapability: string
|
|
294
|
+
/** The role the decision was made for, when the caller is a member. */
|
|
295
|
+
role?: string
|
|
296
|
+
/** For `not_deployed`: the deployment's state. */
|
|
297
|
+
deploymentStatus?: string
|
|
298
|
+
deploymentReason?: string
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Whether a discovery entry can be invoked: discovery offers only what can, so anything but `available` (or no word) cannot. */
|
|
302
|
+
export function actionAvailabilityOf(entry: Pick<ActionDescriptor, 'availability'> | null | undefined): { available: true; reason: null } | { available: false; reason: ActionUnavailableReason } {
|
|
303
|
+
if (!entry) return { available: false, reason: 'unknown' }
|
|
304
|
+
return entry.availability === undefined || entry.availability === 'available' ? { available: true, reason: null } : { available: false, reason: 'not_deployed' }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A sentence for a control that stays visible but cannot be used, when the
|
|
309
|
+
* platform sent none of its own: show it beside the disabled button, or as
|
|
310
|
+
* its tooltip. `unknown` stays neutral: it says nothing about why.
|
|
311
|
+
*/
|
|
312
|
+
export function actionUnavailableText(reason: ActionUnavailableReason): string {
|
|
313
|
+
switch (reason) {
|
|
314
|
+
case 'not_deployed':
|
|
315
|
+
return "This action isn't set up in this workspace yet."
|
|
316
|
+
case 'capability_not_granted':
|
|
317
|
+
case 'role_cannot_submit':
|
|
318
|
+
case 'business_user':
|
|
319
|
+
return "Your role can't do this. Ask your administrator for access."
|
|
320
|
+
case 'not_a_member':
|
|
321
|
+
return "You're not a member of this workspace."
|
|
322
|
+
default:
|
|
323
|
+
return "This action isn't available to you here."
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
178
327
|
/**
|
|
179
328
|
* What raised a request, in the only terms a reader can act on.
|
|
180
329
|
*
|
|
@@ -288,6 +437,31 @@ export interface ActionRequest {
|
|
|
288
437
|
* other request, and absent from a platform that predates the field.
|
|
289
438
|
*/
|
|
290
439
|
approval?: ActionRequestApproval | null
|
|
440
|
+
/**
|
|
441
|
+
* Every approval or rejection recorded on the request, oldest first. Empty
|
|
442
|
+
* when nobody has decided yet. Present on reads of a request (single and
|
|
443
|
+
* listed), whatever its approval mode; absent from a platform that predates
|
|
444
|
+
* the field.
|
|
445
|
+
*/
|
|
446
|
+
approvalDecisions?: ActionRequestApprovalDecision[]
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* One decision recorded on a request: an approval or a rejection.
|
|
451
|
+
*
|
|
452
|
+
* `stage` is the approval-chain step it decided, or `null` when the Action
|
|
453
|
+
* approves in one step. `decidedBy.name` is `null` for someone no longer a
|
|
454
|
+
* member of the organization, and for a person whose App sign-in carried no
|
|
455
|
+
* name. `reason` is what the approver wrote; it is `null` when they wrote none
|
|
456
|
+
* or the stored reason could not be read.
|
|
457
|
+
*/
|
|
458
|
+
export interface ActionRequestApprovalDecision {
|
|
459
|
+
stage: string | null
|
|
460
|
+
decision: 'approve' | 'reject'
|
|
461
|
+
decidedBy: { name: string | null }
|
|
462
|
+
/** When the decision was recorded (ISO 8601). */
|
|
463
|
+
decidedAt: string
|
|
464
|
+
reason: string | null
|
|
291
465
|
}
|
|
292
466
|
|
|
293
467
|
/** One step of a request's approval chain. */
|
|
@@ -406,14 +580,31 @@ export interface ActionDecisionResult extends ActionRequest {
|
|
|
406
580
|
* gets wrong once and then debugs for an hour. `submit` assembles it.
|
|
407
581
|
*/
|
|
408
582
|
export interface SubmitActionInput {
|
|
409
|
-
/**
|
|
583
|
+
/**
|
|
584
|
+
* Primary key of the object being changed, sent as the request's subject
|
|
585
|
+
* (`subjectRef.objectId`). Omit only for `mode: 'create'`.
|
|
586
|
+
*
|
|
587
|
+
* The platform fills the Action's input named like the object type's
|
|
588
|
+
* primary key (`id`, `ticketId`, …) from it when `input` leaves that out,
|
|
589
|
+
* so `input` need not repeat the key. If `input` does carry it, the two must
|
|
590
|
+
* be the same key: a request where they differ is refused, naming the input.
|
|
591
|
+
*/
|
|
410
592
|
objectId?: string
|
|
411
|
-
/**
|
|
593
|
+
/**
|
|
594
|
+
* Parameter values, keyed by the Action's parameter API names. Leave out
|
|
595
|
+
* the key input and `expectedVersion`: they are filled from `objectId` and
|
|
596
|
+
* `expectedVersion` (see those).
|
|
597
|
+
*/
|
|
412
598
|
input: Record<string, unknown>
|
|
413
599
|
/**
|
|
414
|
-
* The version the
|
|
415
|
-
*
|
|
416
|
-
*
|
|
600
|
+
* The record version the change was decided on: `recordVersionOf` of the
|
|
601
|
+
* instance the person saw. Sent as the subject version
|
|
602
|
+
* (`expectedSubjectVersion`, as text) AND, when the Action declares an
|
|
603
|
+
* `expectedVersion` input, as that input (a number) — so a stale write is
|
|
604
|
+
* refused rather than clobbering a concurrent one. An edit sent without it
|
|
605
|
+
* is refused, never applied unchecked. When `input.expectedVersion` is given
|
|
606
|
+
* as well and differs, the platform refuses the request rather than
|
|
607
|
+
* picking one.
|
|
417
608
|
*/
|
|
418
609
|
expectedVersion?: number | string
|
|
419
610
|
/** Required when the Action declares `reason: 'required'`. */
|
package/src/types.ts
CHANGED
|
@@ -240,6 +240,11 @@ export interface QueryRequest {
|
|
|
240
240
|
* this list matches `select`.
|
|
241
241
|
*/
|
|
242
242
|
export interface QueryResponse<TRow = Record<string, unknown>> {
|
|
243
|
+
/**
|
|
244
|
+
* The records, as the platform sends them; the SDK changes no value. A
|
|
245
|
+
* timestamp property is ISO 8601 text in UTC (`'2026-10-17T09:17:00.000Z'`,
|
|
246
|
+
* which `new Date()` reads), a date property `'YYYY-MM-DD'`.
|
|
247
|
+
*/
|
|
243
248
|
rows: TRow[]
|
|
244
249
|
properties: Array<{
|
|
245
250
|
apiName: string
|