Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@eslint/js": "^10.0.1",
"@objectstack/spec": "^7.2.1",
"@objectstack/spec": "^7.3.0",
"@playwright/test": "^1.60.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"@object-ui/providers": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^7.2.1",
"@objectstack/spec": "^7.3.0",
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "^10.53.1",
"jsonc-parser": "^3.3.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export {
} from './useNavigationSync';
export { useObjectActions } from './useObjectActions';
export { useRecentItems, type RecentItem } from './useRecentItems';
export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite } from './useRecordApprovals';
export { useRecordApprovals, type ApprovalRequestLite } from './useRecordApprovals';
export { useResponsiveSidebar } from './useResponsiveSidebar';
export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
export {
Expand Down
95 changes: 33 additions & 62 deletions packages/app-shell/src/hooks/useRecordApprovals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@
* useRecordApprovals
*
* Resolves the approval state for a single record so the detail-view header
* can surface "Submit for Approval" / "Recall" actions and a status badge.
* can surface a status badge and — when the current user is a pending
* approver — "Approve" / "Reject" actions.
*
* Since ADR-0019 an approval is a **flow node** (`type: 'approval'`), not a
* standalone process: the flow opens the request when it reaches the node,
* and a decision resumes the run down its `approve` / `reject` edge. There is
* therefore no manual "submit" or "recall" from the record header — those
* endpoints were removed. This hook reads the record's requests and lets a
* pending approver record a decision.
*
* Talks directly to the framework REST endpoints under
* `/api/v1/approvals/*`. Fails open: if the approvals plugin is not
* installed (404) or the user has no identity, returns inert state so the
* detail view continues to render normally.
* `/api/v1/approvals/*`. Fails open: if the approvals plugin is not installed
* (404 / 501) or the user has no identity, returns inert state so the detail
* view continues to render normally.
*/

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

export interface ApprovalProcessLite {
id: string;
name: string;
label?: string;
object_name: string;
active?: boolean;
}

export interface ApprovalRequestLite {
id: string;
process_name: string;
Expand All @@ -36,13 +36,12 @@ export interface ApprovalRequestLite {
interface UseRecordApprovalsResult {
loading: boolean;
available: boolean;
processes: ApprovalProcessLite[];
pendingRequest: ApprovalRequestLite | null;
latestRequest: ApprovalRequestLite | null;
canSubmit: boolean;
canRecall: boolean;
submit: (input?: { processName?: string; comment?: string }) => Promise<ApprovalRequestLite>;
recall: (input?: { comment?: string }) => Promise<ApprovalRequestLite>;
/** The current user is among the pending approvers and may record a decision. */
canDecide: boolean;
approve: (input?: { comment?: string }) => Promise<ApprovalRequestLite | undefined>;
reject: (input?: { comment?: string }) => Promise<ApprovalRequestLite | undefined>;
refresh: () => Promise<void>;
}

Expand Down Expand Up @@ -75,7 +74,6 @@ export function useRecordApprovals(
): UseRecordApprovalsResult {
const [loading, setLoading] = useState(false);
const [available, setAvailable] = useState(true);
const [processes, setProcesses] = useState<ApprovalProcessLite[]>([]);
const [requests, setRequests] = useState<ApprovalRequestLite[]>([]);
const unavailableRef = useRef(false);

Expand All @@ -84,19 +82,13 @@ export function useRecordApprovals(
if (unavailableRef.current) return;
setLoading(true);
try {
const [procResp, reqResp] = await Promise.all([
fetchJson<{ data: ApprovalProcessLite[] }>(
`/approvals/processes?object=${encodeURIComponent(objectName)}&activeOnly=true`,
),
fetchJson<{ data: ApprovalRequestLite[] }>(
`/approvals/requests?object=${encodeURIComponent(objectName)}&recordId=${encodeURIComponent(recordId)}`,
),
]);
setProcesses(procResp?.data ?? []);
const reqResp = await fetchJson<{ data: ApprovalRequestLite[] }>(
`/approvals/requests?object=${encodeURIComponent(objectName)}&recordId=${encodeURIComponent(recordId)}`,
);
setRequests(reqResp?.data ?? []);
setAvailable(true);
} catch (err: any) {
if (err?.status === 404) {
if (err?.status === 404 || err?.status === 501) {
unavailableRef.current = true;
setAvailable(false);
}
Expand All @@ -108,7 +100,6 @@ export function useRecordApprovals(

useEffect(() => {
if (!objectName || !recordId) {
setProcesses([]);
setRequests([]);
return;
}
Expand All @@ -130,35 +121,14 @@ export function useRecordApprovals(
return sorted[0] ?? null;
}, [requests]);

const canSubmit = available && processes.length > 0 && !pendingRequest;
const canRecall = !!pendingRequest && !!currentUserId
&& pendingRequest.submitter_id === currentUserId;
const canDecide = !!pendingRequest && !!currentUserId
&& (pendingRequest.pending_approvers ?? []).includes(currentUserId);

const submit = useCallback(
async (input?: { processName?: string; comment?: string }) => {
if (!objectName || !recordId) throw new Error('Missing object or record');
const processName = input?.processName
?? (processes.length === 1 ? processes[0].name : undefined);
const row = await fetchJson<ApprovalRequestLite>(`/approvals/requests`, {
method: 'POST',
body: JSON.stringify({
object: objectName,
recordId,
...(processName ? { processName } : {}),
...(input?.comment ? { comment: input.comment } : {}),
}),
});
await refresh();
return row;
},
[objectName, recordId, processes, refresh],
);

const recall = useCallback(
async (input?: { comment?: string }) => {
const decide = useCallback(
async (decision: 'approve' | 'reject', input?: { comment?: string }) => {
if (!pendingRequest) throw new Error('No pending request');
const out = await fetchJson<{ request: ApprovalRequestLite }>(
`/approvals/requests/${encodeURIComponent(pendingRequest.id)}/recall`,
const out = await fetchJson<{ request?: ApprovalRequestLite }>(
`/approvals/requests/${encodeURIComponent(pendingRequest.id)}/${decision}`,
{
method: 'POST',
body: JSON.stringify({
Expand All @@ -168,21 +138,22 @@ export function useRecordApprovals(
},
);
await refresh();
return out.request;
return out?.request;
},
[pendingRequest, currentUserId, refresh],
);

const approve = useCallback((input?: { comment?: string }) => decide('approve', input), [decide]);
const reject = useCallback((input?: { comment?: string }) => decide('reject', input), [decide]);

return {
loading,
available,
processes,
pendingRequest,
latestRequest,
canSubmit,
canRecall,
submit,
recall,
canDecide,
approve,
reject,
refresh,
};
}
109 changes: 47 additions & 62 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -492,9 +492,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
}, [authFetch, pureRecordId, objectName]);

// ─── Approvals ─────────────────────────────────────────────────────
// Surfaces "Submit for Approval" / "Recall" buttons on the record header
// when an active approval process is registered for this object, and a
// status badge when a request exists.
// Since ADR-0019 an approval is a flow node: the flow opens the request,
// there is no manual submit/recall from the record header. When the current
// user is a pending approver, surface "Approve" / "Reject" on the header and
// a status badge whenever a request exists.
const approvals = useRecordApprovals(objectName, pureRecordId, user?.id);
// Hold latest approvals snapshot in a ref so the action handler
// (memoized once inside ActionRunner) always sees fresh state instead of
Expand All @@ -508,13 +509,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
? (action.params as Record<string, any>)
: {};
try {
if (target === 'submit_approval') {
await approvalsRef.current.submit({
processName: params.processName,
comment: params.comment,
});
} else if (target === 'recall_approval') {
await approvalsRef.current.recall({ comment: params.comment });
if (target === 'approve_request') {
await approvalsRef.current.approve({ comment: params.comment });
} else if (target === 'reject_request') {
await approvalsRef.current.reject({ comment: params.comment });
} else {
return { success: false, error: `Unknown approval target: ${target}` };
}
Expand Down Expand Up @@ -1184,57 +1182,44 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
}),
}));

// Inject approval actions — only when the approvals plugin is
// available and an active process exists for this object.
if (approvals.available && approvals.processes.length > 0) {
if (approvals.canSubmit) {
base.push({
name: 'submit_approval',
type: 'approval',
target: 'submit_approval',
label: t('approvals.submitForApproval', { defaultValue: 'Submit for Approval' }),
icon: 'send',
variant: 'default',
locations: ['record_header'],
refreshAfter: true,
successMessage: t('approvals.submitSuccess', { defaultValue: 'Approval request submitted' }),
...(approvals.processes.length === 1
? { params: { processName: approvals.processes[0].name } }
: {
collectParams: [{
name: 'processName',
label: t('approvals.process', { defaultValue: 'Process' }),
type: 'select',
required: true,
options: approvals.processes.map((p) => ({
value: p.name,
label: p.label || p.name,
})),
}, {
name: 'comment',
label: t('approvals.comment', { defaultValue: 'Comment (optional)' }),
type: 'text',
multiline: true,
}],
}),
});
}
if (approvals.canRecall) {
base.push({
name: 'recall_approval',
type: 'approval',
target: 'recall_approval',
label: t('approvals.recall', { defaultValue: 'Recall' }),
icon: 'undo',
variant: 'outline',
locations: ['record_header'],
refreshAfter: true,
confirmText: t('approvals.recallConfirm', {
defaultValue: 'Recall this pending approval request?',
}),
successMessage: t('approvals.recallSuccess', { defaultValue: 'Approval recalled' }),
});
}
// Inject approval actions — only when the current user is a pending
// approver for this record (ADR-0019: approvals are opened by a flow
// node, so there is no manual submit/recall; an approver records a
// decision that resumes the flow down its approve/reject edge).
if (approvals.available && approvals.canDecide) {
const commentParam = {
name: 'comment',
label: t('approvals.comment', { defaultValue: 'Comment (optional)' }),
type: 'text',
multiline: true,
};
base.push({
name: 'approve_request',
type: 'approval',
target: 'approve_request',
label: t('approvals.approve', { defaultValue: 'Approve' }),
icon: 'check',
variant: 'default',
locations: ['record_header'],
refreshAfter: true,
collectParams: [commentParam],
successMessage: t('approvals.approveSuccess', { defaultValue: 'Approved' }),
});
base.push({
name: 'reject_request',
type: 'approval',
target: 'reject_request',
label: t('approvals.reject', { defaultValue: 'Reject' }),
icon: 'x',
variant: 'destructive',
locations: ['record_header'],
refreshAfter: true,
confirmText: t('approvals.rejectConfirm', {
defaultValue: 'Reject this approval request?',
}),
collectParams: [commentParam],
successMessage: t('approvals.rejectSuccess', { defaultValue: 'Rejected' }),
});
}

return base;
Expand Down Expand Up @@ -1378,7 +1363,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
}),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.processes, approvals.canSubmit, approvals.canRecall, approvals.pendingRequest, approvals.latestRequest, embedded]);
}, [objectDef?.name, pureRecordId, childRelatedData, actionRefreshKey, appName, navigate, dataSource, t, objectLabel, objects, historyEnabled, historyEntries, historyLoading, approvals.available, approvals.canDecide, approvals.pendingRequest, approvals.latestRequest, embedded]);

if (isLoading) {
return <SkeletonDetail />;
Expand Down
28 changes: 3 additions & 25 deletions packages/app-shell/src/views/metadata-admin/anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,9 @@ export function registerBuiltinAnchors(): void {
createDefaults: { events: [] },
});

// approval.object → object (approval processes targeting this object)
registerMetadataResource({
type: 'approval',
anchors: [{
anchorType: 'object',
match: anchorByField('object'),
groupLabel: 'Approval Processes',
order: 70,
}],
createFields: ['label', 'name', 'object', 'description'],
createDerive: [
{ from: 'label', to: 'name', transform: 'slugify', untilUserEdits: true },
],
createDefaults: {
active: true,
lockRecord: true,
steps: [{
name: 'step_1',
label: 'First approval',
approvers: [{ type: 'manager', value: 'manager' }],
behavior: 'first_response',
rejectionBehavior: 'reject_process',
}],
},
});
// Approval is no longer a standalone metadata type (ADR-0019) — it is a flow
// node (`type: 'approval'`). Approvals therefore surface on an object through
// the Flows it belongs to, not a separate "Approval Processes" group.

// page.object → object (auto-generated record pages, etc.)
registerMetadataResource({
Expand Down
Loading
Loading