Skip to content

Commit 4b60d2d

Browse files
baozhoutaoclaude
andauthored
fix(console): approval timeline attachment chip shows its name and opens (#2820) (#2821)
A decision attachment in the approval inbox timeline rendered a nameless "附件" chip that did nothing on click. Three bugs: - No filename: the chip resolved its label from `/data/sys_file/{id}` — a system object a regular approver cannot read — and silently fell back to a generic label. The name now comes from the attachment descriptor the server returns (framework #3504), so the real filename shows for every approver. - Dead click: `openAttachment` called `window.open` after an `await`, so it was no longer a user gesture and the popup was blocked. It now opens the tab synchronously up front, then points it at the signed URL. - Wrong origin: the local adapter's signed URL is server-relative; it is now resolved against the API origin instead of the console origin. - Open failures were swallowed silently; the user now gets a toast. New `approvalsInbox.attachmentOpenFailed` string across all 10 locales. Verified end-to-end against app-showcase. Refs framework #3504. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e886eb commit 4b60d2d

13 files changed

Lines changed: 80 additions & 43 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@object-ui/console": patch
3+
"@object-ui/i18n": patch
4+
---
5+
6+
fix(console): make the approval timeline attachment chip show its name and open (#2820)
7+
8+
A decision attachment in the approval inbox timeline (审批动态) rendered a
9+
nameless "附件" chip that did nothing when clicked. Three separate bugs:
10+
11+
- **No filename.** The chip resolved its label by fetching `/data/sys_file/{id}`
12+
— a system object a regular approver cannot read — and silently fell back to a
13+
generic label when that was denied. The name now comes from the attachment
14+
descriptor the server returns (framework #3266), so no `sys_file` access is
15+
needed and the real filename shows for every approver.
16+
- **Dead click.** `openAttachment` called `window.open` *after* an `await`, so
17+
it was no longer a user gesture and the browser blocked the popup. It now opens
18+
the tab synchronously up front, then points it at the signed URL once fetched.
19+
- **Wrong origin.** The signed URL from the local storage adapter is
20+
server-relative; `window.open` resolved it against the console origin. It is
21+
now resolved against the API origin.
22+
- Every open failure was swallowed silently. The user now gets a toast on
23+
failure — new `approvalsInbox.attachmentOpenFailed` string across all 10
24+
locales.

apps/console/src/pages/system/ApprovalsInboxPage.tsx

Lines changed: 30 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
buildApproverIdentities,
101101
type ApprovalRequestRow,
102102
type ApprovalActionRow,
103+
type ApprovalActionAttachment,
103104
} from '../../services/approvalsApi';
104105

105106
type TabKey = 'pending' | 'submitted' | 'all';
@@ -428,49 +429,37 @@ export function ApprovalsInboxPage() {
428429
// collects the comment and — since the shared upload-widget renderer (#2700/
429430
// #2707) plus the declared `attachments` file param (#2698) — file
430431
// attachments, so the inbox no longer hand-wires a decision composer. `authFetch`
431-
// stays for the timeline's attachment-name resolution and signed-URL open.
432+
// stays for opening an attachment's short-lived signed URL.
432433
const authFetch = useMemo(() => createAuthenticatedFetch(), []);
433-
// Resolve attachment fileIds → sys_file display names for the timeline chips
434-
// (#2678 P1.5). Best-effort, cached per id; unresolved ids fall back to a
435-
// generic "Attachment" label.
436-
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
437-
useEffect(() => {
438-
const ids = new Set<string>();
439-
for (const a of actions) for (const id of (a.attachments ?? [])) {
440-
if (id && attachmentNames[id] === undefined) ids.add(id);
441-
}
442-
if (!ids.size) return;
443-
const base = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
444-
let cancelled = false;
445-
void (async () => {
446-
const resolved: Record<string, string> = {};
447-
await Promise.all(Array.from(ids).map(async (id) => {
448-
try {
449-
const res = await authFetch(`${base}/api/v1/data/sys_file/${encodeURIComponent(id)}`);
450-
if (!res.ok) { resolved[id] = ''; return; }
451-
const body = await res.json().catch(() => null);
452-
resolved[id] = String(body?.record?.name ?? body?.data?.name ?? body?.name ?? '');
453-
} catch { resolved[id] = ''; }
454-
}));
455-
if (!cancelled) setAttachmentNames(prev => ({ ...prev, ...resolved }));
456-
})();
457-
return () => { cancelled = true; };
458-
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed off actions; attachmentNames is the cache being filled
459-
}, [actions, authFetch]);
460-
461-
/** Open an action's attachment via a short-lived signed URL (Bearer-authed fetch). */
462-
const openAttachment = useCallback(async (fileId: string) => {
434+
435+
/**
436+
* Open a timeline attachment. Three things the previous version got wrong,
437+
* all of which made the chip look dead (framework#3266 follow-up):
438+
* 1. `window.open` ran *after* `await` → not a user gesture → popup blocked.
439+
* We open the tab synchronously, up front, then point it at the URL.
440+
* 2. The signed URL is server-relative (`/api/v1/storage/_local/raw/…`) — it
441+
* must resolve against the API origin, not the console origin.
442+
* 3. Every failure was swallowed silently. Now the user gets a toast.
443+
*/
444+
const openAttachment = useCallback(async (att: ApprovalActionAttachment) => {
445+
// Synchronous open keeps the user-gesture, so the browser won't block it.
446+
const win = window.open('', '_blank', 'noopener');
463447
try {
464448
const base = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
465-
const res = await authFetch(`${base}/api/v1/storage/files/${encodeURIComponent(fileId)}/url`);
449+
const res = await authFetch(`${base}/api/v1/storage/files/${encodeURIComponent(att.id)}/url`);
466450
if (!res.ok) throw new Error(`HTTP_${res.status}`);
467451
const body = await res.json().catch(() => null);
468-
const url = body?.data?.url ?? body?.url;
469-
if (url) window.open(url, '_blank', 'noopener');
452+
const raw = body?.data?.url ?? body?.url;
453+
if (!raw) throw new Error('NO_URL');
454+
// Signed URLs from the local adapter are relative; S3/GCS are absolute.
455+
const url = /^https?:\/\//i.test(raw) ? raw : `${base}${raw}`;
456+
if (win) win.location.href = url;
457+
else window.open(url, '_blank', 'noopener');
470458
} catch {
471-
/* open failed — non-fatal; the chip stays visible */
459+
win?.close();
460+
toast.error(tr('attachmentOpenFailed', 'Could not open the attachment — please try again'));
472461
}
473-
}, [authFetch]);
462+
}, [authFetch, tr]);
474463

475464
// Search + filters. On the paginated tabs (submitted/all) the free-text
476465
// query is debounced and pushed to the server; the pending tab keeps
@@ -1829,16 +1818,16 @@ export function ApprovalsInboxPage() {
18291818
)}
18301819
{Array.isArray(a.attachments) && a.attachments.length > 0 && (
18311820
<div className="flex flex-wrap gap-1.5 mt-1">
1832-
{a.attachments.map((fileId, i) => (
1821+
{a.attachments.map((att, i) => (
18331822
<button
1834-
key={fileId}
1823+
key={att.id || i}
18351824
type="button"
1836-
onClick={() => void openAttachment(fileId)}
1825+
onClick={() => void openAttachment(att)}
18371826
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
1838-
title={fileId}
1827+
title={att.name || att.id}
18391828
>
18401829
<Paperclip className="h-3 w-3" />
1841-
{attachmentNames[fileId]
1830+
{att.name
18421831
|| `${tr('attachmentChip', 'Attachment')}${a.attachments!.length > 1 ? ` ${i + 1}` : ''}`}
18431832
</button>
18441833
))}

apps/console/src/services/approvalsApi.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ export interface ApprovalRequestRow {
106106
round?: number;
107107
}
108108

109+
/**
110+
* A file attached to a decision action (#3266). The server resolves the
111+
* `sys_approval_action.attachments` file field into rich descriptors, so the
112+
* chip has the display name + download URL without any `sys_file` lookup.
113+
*/
114+
export interface ApprovalActionAttachment {
115+
id: string;
116+
name?: string;
117+
/** Stable download URL (`/api/v1/storage/files/:id`); may be server-relative. */
118+
url?: string;
119+
mimeType?: string;
120+
size?: number;
121+
}
122+
109123
export interface ApprovalActionRow {
110124
id: string;
111125
request_id: string;
@@ -114,8 +128,8 @@ export interface ApprovalActionRow {
114128
actor_id?: string | null;
115129
action: 'submit' | 'approve' | 'reject' | 'recall' | string;
116130
comment?: string | null;
117-
/** File references attached to this action (decision attachments, #3266). */
118-
attachments?: string[] | null;
131+
/** Files attached to this action (decision attachments, #3266). */
132+
attachments?: ApprovalActionAttachment[] | null;
119133
created_at?: string;
120134
/** Display name of the actor, resolved server-side. */
121135
actor_name?: string;

packages/i18n/src/locales/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,6 +2008,7 @@ const ar = {
20082008
progressBar: 'تقدم القرار',
20092009
declaredActions: 'الإجراءات',
20102010
attachmentChip: 'مرفق',
2011+
attachmentOpenFailed: 'تعذّر فتح المرفق — يرجى المحاولة مرة أخرى',
20112012
approveOneTitle: 'الموافقة على "{{title}}"؟',
20122013
approveOneBody: 'ستتم الموافقة على الطلب بهويتك. لإضافة تعليق أو مرفق، افتح الطلب.',
20132014
flowOrigin: 'بدأها التدفق',

packages/i18n/src/locales/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,6 +2010,7 @@ const de = {
20102010
progressBar: 'Entscheidungsfortschritt',
20112011
declaredActions: 'Aktionen',
20122012
attachmentChip: 'Anhang',
2013+
attachmentOpenFailed: 'Anhang konnte nicht geöffnet werden – bitte erneut versuchen',
20132014
approveOneTitle: '„{{title}}“ genehmigen?',
20142015
approveOneBody: 'Die Anfrage wird mit Ihrer Identität genehmigt. Um einen Kommentar oder Anhang hinzuzufügen, öffnen Sie die Anfrage.',
20152016
flowOrigin: 'Vom Flow gestartet',

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2550,6 +2550,7 @@ const en = {
25502550
progressBar: 'Decision progress',
25512551
declaredActions: 'Actions',
25522552
attachmentChip: 'Attachment',
2553+
attachmentOpenFailed: 'Could not open the attachment — please try again',
25532554
approveOneTitle: 'Approve "{{title}}"?',
25542555
approveOneBody: 'This approves the request with your identity. To add a comment or attachment, open the request instead.',
25552556
flowOrigin: 'Flow-initiated',

packages/i18n/src/locales/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,6 +2010,7 @@ const es = {
20102010
progressBar: 'Progreso de la decisión',
20112011
declaredActions: 'Acciones',
20122012
attachmentChip: 'Adjunto',
2013+
attachmentOpenFailed: 'No se pudo abrir el archivo adjunto: inténtalo de nuevo',
20132014
approveOneTitle: '¿Aprobar «{{title}}»?',
20142015
approveOneBody: 'Esto aprueba la solicitud con tu identidad. Para añadir un comentario o un adjunto, abre la solicitud.',
20152016
flowOrigin: 'Iniciada por flujo',

packages/i18n/src/locales/fr.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,6 +2008,7 @@ const fr = {
20082008
progressBar: 'Avancement de la décision',
20092009
declaredActions: 'Actions',
20102010
attachmentChip: 'Pièce jointe',
2011+
attachmentOpenFailed: "Impossible d'ouvrir la pièce jointe — veuillez réessayer",
20112012
approveOneTitle: 'Approuver « {{title}} » ?',
20122013
approveOneBody: 'Cette action approuve la demande en votre nom. Pour ajouter un commentaire ou une pièce jointe, ouvrez la demande.',
20132014
flowOrigin: 'Initiée par un flux',

packages/i18n/src/locales/ja.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,6 +2010,7 @@ const ja = {
20102010
progressBar: '承認の進捗',
20112011
declaredActions: 'アクション',
20122012
attachmentChip: '添付ファイル',
2013+
attachmentOpenFailed: '添付ファイルを開けませんでした。もう一度お試しください',
20132014
approveOneTitle: '「{{title}}」を承認しますか?',
20142015
approveOneBody: 'あなたの名義でこのリクエストを承認します。コメントや添付を付ける場合は、リクエストを開いて操作してください。',
20152016
flowOrigin: 'フロー起動',

packages/i18n/src/locales/ko.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,6 +2010,7 @@ const ko = {
20102010
progressBar: '결정 진행 상황',
20112011
declaredActions: '작업',
20122012
attachmentChip: '첨부 파일',
2013+
attachmentOpenFailed: '첨부 파일을 열 수 없습니다. 다시 시도해 주세요',
20132014
approveOneTitle: '"{{title}}"을(를) 승인할까요?',
20142015
approveOneBody: '내 이름으로 이 요청을 승인합니다. 의견이나 첨부 파일을 추가하려면 요청을 열어 처리하세요.',
20152016
flowOrigin: '플로우 시작',

0 commit comments

Comments
 (0)