Skip to content
Open
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
24 changes: 24 additions & 0 deletions .changeset/approval-attachment-chip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@object-ui/console": patch
"@object-ui/i18n": patch
---

fix(console): make the approval timeline attachment chip show its name and open (#2820)

A decision attachment in the approval inbox timeline (审批动态) rendered a
nameless "附件" chip that did nothing when clicked. Three separate bugs:

- **No filename.** The chip resolved its label by fetching `/data/sys_file/{id}`
— a system object a regular approver cannot read — and silently fell back to a
generic label when that was denied. The name now comes from the attachment
descriptor the server returns (framework #3266), so no `sys_file` access is
needed and 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 browser blocked the popup. It now opens
the tab synchronously up front, then points it at the signed URL once fetched.
- **Wrong origin.** The signed URL from the local storage adapter is
server-relative; `window.open` resolved it against the console origin. It is
now resolved against the API origin.
- Every open failure was swallowed silently. The user now gets a toast on
failure — new `approvalsInbox.attachmentOpenFailed` string across all 10
locales.
71 changes: 30 additions & 41 deletions apps/console/src/pages/system/ApprovalsInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import {
buildApproverIdentities,
type ApprovalRequestRow,
type ApprovalActionRow,
type ApprovalActionAttachment,
} from '../../services/approvalsApi';

type TabKey = 'pending' | 'submitted' | 'all';
Expand Down Expand Up @@ -420,49 +421,37 @@ export function ApprovalsInboxPage() {
// collects the comment and — since the shared upload-widget renderer (#2700/
// #2707) plus the declared `attachments` file param (#2698) — file
// attachments, so the inbox no longer hand-wires a decision composer. `authFetch`
// stays for the timeline's attachment-name resolution and signed-URL open.
// stays for opening an attachment's short-lived signed URL.
const authFetch = useMemo(() => createAuthenticatedFetch(), []);
// Resolve attachment fileIds → sys_file display names for the timeline chips
// (#2678 P1.5). Best-effort, cached per id; unresolved ids fall back to a
// generic "Attachment" label.
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
useEffect(() => {
const ids = new Set<string>();
for (const a of actions) for (const id of (a.attachments ?? [])) {
if (id && attachmentNames[id] === undefined) ids.add(id);
}
if (!ids.size) return;
const base = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
let cancelled = false;
void (async () => {
const resolved: Record<string, string> = {};
await Promise.all(Array.from(ids).map(async (id) => {
try {
const res = await authFetch(`${base}/api/v1/data/sys_file/${encodeURIComponent(id)}`);
if (!res.ok) { resolved[id] = ''; return; }
const body = await res.json().catch(() => null);
resolved[id] = String(body?.record?.name ?? body?.data?.name ?? body?.name ?? '');
} catch { resolved[id] = ''; }
}));
if (!cancelled) setAttachmentNames(prev => ({ ...prev, ...resolved }));
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed off actions; attachmentNames is the cache being filled
}, [actions, authFetch]);

/** Open an action's attachment via a short-lived signed URL (Bearer-authed fetch). */
const openAttachment = useCallback(async (fileId: string) => {

/**
* Open a timeline attachment. Three things the previous version got wrong,
* all of which made the chip look dead (framework#3266 follow-up):
* 1. `window.open` ran *after* `await` → not a user gesture → popup blocked.
* We open the tab synchronously, up front, then point it at the URL.
* 2. The signed URL is server-relative (`/api/v1/storage/_local/raw/…`) — it
* must resolve against the API origin, not the console origin.
* 3. Every failure was swallowed silently. Now the user gets a toast.
*/
const openAttachment = useCallback(async (att: ApprovalActionAttachment) => {
// Synchronous open keeps the user-gesture, so the browser won't block it.
const win = window.open('', '_blank', 'noopener');
try {
const base = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
const res = await authFetch(`${base}/api/v1/storage/files/${encodeURIComponent(fileId)}/url`);
const res = await authFetch(`${base}/api/v1/storage/files/${encodeURIComponent(att.id)}/url`);
if (!res.ok) throw new Error(`HTTP_${res.status}`);
const body = await res.json().catch(() => null);
const url = body?.data?.url ?? body?.url;
if (url) window.open(url, '_blank', 'noopener');
const raw = body?.data?.url ?? body?.url;
if (!raw) throw new Error('NO_URL');
// Signed URLs from the local adapter are relative; S3/GCS are absolute.
const url = /^https?:\/\//i.test(raw) ? raw : `${base}${raw}`;
if (win) win.location.href = url;
else window.open(url, '_blank', 'noopener');
} catch {
/* open failed — non-fatal; the chip stays visible */
win?.close();
toast.error(tr('attachmentOpenFailed', 'Could not open the attachment — please try again'));
}
}, [authFetch]);
}, [authFetch, tr]);

// Search + filters. On the paginated tabs (submitted/all) the free-text
// query is debounced and pushed to the server; the pending tab keeps
Expand Down Expand Up @@ -1821,16 +1810,16 @@ export function ApprovalsInboxPage() {
)}
{Array.isArray(a.attachments) && a.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{a.attachments.map((fileId, i) => (
{a.attachments.map((att, i) => (
<button
key={fileId}
key={att.id || i}
type="button"
onClick={() => void openAttachment(fileId)}
onClick={() => void openAttachment(att)}
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"
title={fileId}
title={att.name || att.id}
>
<Paperclip className="h-3 w-3" />
{attachmentNames[fileId]
{att.name
|| `${tr('attachmentChip', 'Attachment')}${a.attachments!.length > 1 ? ` ${i + 1}` : ''}`}
</button>
))}
Expand Down
18 changes: 16 additions & 2 deletions apps/console/src/services/approvalsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ export interface ApprovalRequestRow {
round?: number;
}

/**
* A file attached to a decision action (#3266). The server resolves the
* `sys_approval_action.attachments` file field into rich descriptors, so the
* chip has the display name + download URL without any `sys_file` lookup.
*/
export interface ApprovalActionAttachment {
id: string;
name?: string;
/** Stable download URL (`/api/v1/storage/files/:id`); may be server-relative. */
url?: string;
mimeType?: string;
size?: number;
}

export interface ApprovalActionRow {
id: string;
request_id: string;
Expand All @@ -112,8 +126,8 @@ export interface ApprovalActionRow {
actor_id?: string | null;
action: 'submit' | 'approve' | 'reject' | 'recall' | string;
comment?: string | null;
/** File references attached to this action (decision attachments, #3266). */
attachments?: string[] | null;
/** Files attached to this action (decision attachments, #3266). */
attachments?: ApprovalActionAttachment[] | null;
created_at?: string;
/** Display name of the actor, resolved server-side. */
actor_name?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,7 @@ const ar = {
progressBar: 'تقدم القرار',
declaredActions: 'الإجراءات',
attachmentChip: 'مرفق',
attachmentOpenFailed: 'تعذّر فتح المرفق — يرجى المحاولة مرة أخرى',
approveOneTitle: 'الموافقة على "{{title}}"؟',
approveOneBody: 'ستتم الموافقة على الطلب بهويتك. لإضافة تعليق أو مرفق، افتح الطلب.',
flowOrigin: 'بدأها التدفق',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,6 +1994,7 @@ const de = {
progressBar: 'Entscheidungsfortschritt',
declaredActions: 'Aktionen',
attachmentChip: 'Anhang',
attachmentOpenFailed: 'Anhang konnte nicht geöffnet werden – bitte erneut versuchen',
approveOneTitle: '„{{title}}“ genehmigen?',
approveOneBody: 'Die Anfrage wird mit Ihrer Identität genehmigt. Um einen Kommentar oder Anhang hinzuzufügen, öffnen Sie die Anfrage.',
flowOrigin: 'Vom Flow gestartet',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,7 @@ const en = {
progressBar: 'Decision progress',
declaredActions: 'Actions',
attachmentChip: 'Attachment',
attachmentOpenFailed: 'Could not open the attachment — please try again',
approveOneTitle: 'Approve "{{title}}"?',
approveOneBody: 'This approves the request with your identity. To add a comment or attachment, open the request instead.',
flowOrigin: 'Flow-initiated',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,6 +1994,7 @@ const es = {
progressBar: 'Progreso de la decisión',
declaredActions: 'Acciones',
attachmentChip: 'Adjunto',
attachmentOpenFailed: 'No se pudo abrir el archivo adjunto: inténtalo de nuevo',
approveOneTitle: '¿Aprobar «{{title}}»?',
approveOneBody: 'Esto aprueba la solicitud con tu identidad. Para añadir un comentario o un adjunto, abre la solicitud.',
flowOrigin: 'Iniciada por flujo',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,7 @@ const fr = {
progressBar: 'Avancement de la décision',
declaredActions: 'Actions',
attachmentChip: 'Pièce jointe',
attachmentOpenFailed: "Impossible d'ouvrir la pièce jointe — veuillez réessayer",
approveOneTitle: 'Approuver « {{title}} » ?',
approveOneBody: 'Cette action approuve la demande en votre nom. Pour ajouter un commentaire ou une pièce jointe, ouvrez la demande.',
flowOrigin: 'Initiée par un flux',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,6 +1994,7 @@ const ja = {
progressBar: '承認の進捗',
declaredActions: 'アクション',
attachmentChip: '添付ファイル',
attachmentOpenFailed: '添付ファイルを開けませんでした。もう一度お試しください',
approveOneTitle: '「{{title}}」を承認しますか?',
approveOneBody: 'あなたの名義でこのリクエストを承認します。コメントや添付を付ける場合は、リクエストを開いて操作してください。',
flowOrigin: 'フロー起動',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,6 +1994,7 @@ const ko = {
progressBar: '결정 진행 상황',
declaredActions: '작업',
attachmentChip: '첨부 파일',
attachmentOpenFailed: '첨부 파일을 열 수 없습니다. 다시 시도해 주세요',
approveOneTitle: '"{{title}}"을(를) 승인할까요?',
approveOneBody: '내 이름으로 이 요청을 승인합니다. 의견이나 첨부 파일을 추가하려면 요청을 열어 처리하세요.',
flowOrigin: '플로우 시작',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,7 @@ const pt = {
progressBar: 'Progresso da decisão',
declaredActions: 'Ações',
attachmentChip: 'Anexo',
attachmentOpenFailed: 'Não foi possível abrir o anexo — tente novamente',
approveOneTitle: 'Aprovar "{{title}}"?',
approveOneBody: 'Isso aprova a solicitação com a sua identidade. Para adicionar um comentário ou anexo, abra a solicitação.',
flowOrigin: 'Iniciada por fluxo',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,7 @@ const ru = {
progressBar: 'Ход решения',
declaredActions: 'Действия',
attachmentChip: 'Вложение',
attachmentOpenFailed: 'Не удалось открыть вложение — попробуйте ещё раз',
approveOneTitle: 'Согласовать «{{title}}»?',
approveOneBody: 'Запрос будет согласован от вашего имени. Чтобы добавить комментарий или вложение, откройте запрос.',
flowOrigin: 'Инициировано потоком',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2608,6 +2608,7 @@ const zh = {
progressBar: '决策进度',
declaredActions: '操作',
attachmentChip: '附件',
attachmentOpenFailed: '无法打开附件,请重试',
approveOneTitle: '通过“{{title}}”?',
approveOneBody: '将以你的身份通过该请求。如需填写审批意见或附件,请打开该请求处理。',
flowOrigin: '流程发起',
Expand Down
Loading