Mijn aanvragen (F2): document preview/download on completed uploads
Re-opening a Concept wizard now lets the user preview/download what they already uploaded (PRD 0001 goal 2). - document-chip: optional previewUrl input → a "Voorbeeld / Download" link (opens the stored bytes; server serves inline for pdf/image, attachment otherwise). - previewUrlFor callback threaded document-upload → document-category → single-upload (which builds the URL from a completed upload's documentId). Keeps URL-building out of the presentational atoms. - registratie + herregistratie wizards supply previewUrlFor via UploadAdapter.contentUrl, returning undefined for dev-simulation `demo-*` ids (no stored bytes → no link). - Story: DocumentChip/WithPreview. Gates green: vitest 128, lint, ng build, check:tokens; backend dotnet 56. (build-storybook has a pre-existing Compodoc/$localize issue in untouched files.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
16778
documentation.json
16778
documentation.json
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@ import { WizardState, WizardMsg, Draft, initial, reduce, hasProgress } from '@he
|
|||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||||
import { createUploadController } from '@shared/upload/upload-controller';
|
import { createUploadController } from '@shared/upload/upload-controller';
|
||||||
|
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||||
@@ -57,6 +58,7 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
|||||||
@case (3) {
|
@case (3) {
|
||||||
<app-document-upload
|
<app-document-upload
|
||||||
[state]="upload()"
|
[state]="upload()"
|
||||||
|
[previewUrlFor]="previewUrlFor"
|
||||||
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
||||||
(removeUpload)="uploadCtl.onRemove($event)"
|
(removeUpload)="uploadCtl.onRemove($event)"
|
||||||
(retryUpload)="uploadCtl.onRetry($event)"
|
(retryUpload)="uploadCtl.onRetry($event)"
|
||||||
@@ -76,8 +78,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
|||||||
})
|
})
|
||||||
export class HerregistratieWizardComponent {
|
export class HerregistratieWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||||
|
|
||||||
|
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
|
||||||
|
no stored bytes, so they get no link. */
|
||||||
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||||
|
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<WizardState>(initial);
|
seed = input<WizardState>(initial);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||||
import { createUploadController } from '@shared/upload/upload-controller';
|
import { createUploadController } from '@shared/upload/upload-controller';
|
||||||
|
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
const KANALEN = [
|
const KANALEN = [
|
||||||
@@ -140,6 +141,7 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
<app-document-upload
|
<app-document-upload
|
||||||
class="app-section"
|
class="app-section"
|
||||||
[state]="upload()"
|
[state]="upload()"
|
||||||
|
[previewUrlFor]="previewUrlFor"
|
||||||
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
||||||
(removeUpload)="uploadCtl.onRemove($event)"
|
(removeUpload)="uploadCtl.onRemove($event)"
|
||||||
(retryUpload)="uploadCtl.onRetry($event)"
|
(retryUpload)="uploadCtl.onRetry($event)"
|
||||||
@@ -183,8 +185,14 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
export class RegistratieWizardComponent {
|
export class RegistratieWizardComponent {
|
||||||
private brp = inject(BrpAdapter);
|
private brp = inject(BrpAdapter);
|
||||||
private duo = inject(DuoAdapter);
|
private duo = inject(DuoAdapter);
|
||||||
|
private uploadAdapter = inject(UploadAdapter);
|
||||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
||||||
|
|
||||||
|
/** Preview/download link for a completed upload; the dev-simulation `demo-*` ids
|
||||||
|
have no stored bytes, so they get no link. */
|
||||||
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||||
|
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
||||||
|
|
||||||
protected adresRes = this.brp.adresResource();
|
protected adresRes = this.brp.adresResource();
|
||||||
private diplomasRes = this.duo.diplomasResource();
|
private diplomasRes = this.duo.diplomasResource();
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
|
|||||||
@for (u of uploads(); track u.localId) {
|
@for (u of uploads(); track u.localId) {
|
||||||
<app-single-upload
|
<app-single-upload
|
||||||
[upload]="u"
|
[upload]="u"
|
||||||
|
[previewUrlFor]="previewUrlFor()"
|
||||||
(remove)="onRemove(u)"
|
(remove)="onRemove(u)"
|
||||||
(retry)="retryUpload.emit(u.localId)" />
|
(retry)="retryUpload.emit(u.localId)" />
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ export class DocumentCategoryComponent {
|
|||||||
uploads = input.required<Upload[]>();
|
uploads = input.required<Upload[]>();
|
||||||
channel = input.required<DeliveryChannel>();
|
channel = input.required<DeliveryChannel>();
|
||||||
rejection = input<string>();
|
rejection = input<string>();
|
||||||
|
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||||
|
|
||||||
/** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
|
/** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
|
||||||
protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`);
|
protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`);
|
||||||
|
|||||||
@@ -29,10 +29,16 @@ import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-i
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.action:hover { color: var(--rhc-color-foreground-link-hover); }
|
.action:hover { color: var(--rhc-color-foreground-link-hover); }
|
||||||
|
a.action { display: inline-block; }
|
||||||
`],
|
`],
|
||||||
template: `
|
template: `
|
||||||
<app-upload-status-icon [status]="status().type" />
|
<app-upload-status-icon [status]="status().type" />
|
||||||
<span class="name">{{ fileName() }}</span>
|
<span class="name">{{ fileName() }}</span>
|
||||||
|
@if (previewUrl()) {
|
||||||
|
<a class="action" [href]="previewUrl()" target="_blank" rel="noopener" [attr.aria-label]="previewLabel">
|
||||||
|
<span i18n="@@upload.chip.preview">Voorbeeld / Download</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
@if (status().type === 'failed') {
|
@if (status().type === 'failed') {
|
||||||
<button type="button" class="action" [attr.aria-label]="retryLabel" (click)="retry.emit()">
|
<button type="button" class="action" [attr.aria-label]="retryLabel" (click)="retry.emit()">
|
||||||
<span i18n="@@upload.chip.retry">Opnieuw</span>
|
<span i18n="@@upload.chip.retry">Opnieuw</span>
|
||||||
@@ -48,6 +54,8 @@ import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-i
|
|||||||
export class DocumentChipComponent {
|
export class DocumentChipComponent {
|
||||||
fileName = input.required<string>();
|
fileName = input.required<string>();
|
||||||
status = input.required<UploadStatus>();
|
status = input.required<UploadStatus>();
|
||||||
|
/** When set, show a preview/download link (opens the stored bytes). */
|
||||||
|
previewUrl = input<string>();
|
||||||
|
|
||||||
remove = output<void>();
|
remove = output<void>();
|
||||||
retry = output<void>();
|
retry = output<void>();
|
||||||
@@ -55,6 +63,9 @@ export class DocumentChipComponent {
|
|||||||
protected get removeLabel(): string {
|
protected get removeLabel(): string {
|
||||||
return $localize`:@@upload.chip.removeAria:${this.fileName()}:fileName: verwijderen`;
|
return $localize`:@@upload.chip.removeAria:${this.fileName()}:fileName: verwijderen`;
|
||||||
}
|
}
|
||||||
|
protected get previewLabel(): string {
|
||||||
|
return $localize`:@@upload.chip.previewAria:${this.fileName()}:fileName: bekijken of downloaden`;
|
||||||
|
}
|
||||||
protected get retryLabel(): string {
|
protected get retryLabel(): string {
|
||||||
return $localize`:@@upload.chip.retryAria:${this.fileName()}:fileName: opnieuw uploaden`;
|
return $localize`:@@upload.chip.retryAria:${this.fileName()}:fileName: opnieuw uploaden`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const meta: Meta<DocumentChipComponent> = {
|
|||||||
component: DocumentChipComponent,
|
component: DocumentChipComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: args,
|
props: args,
|
||||||
template: `<app-document-chip [fileName]="fileName" [status]="status" />`,
|
template: `<app-document-chip [fileName]="fileName" [status]="status" [previewUrl]="previewUrl" />`,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -17,6 +17,10 @@ export const Complete: Story = {
|
|||||||
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
|
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WithPreview: Story = {
|
||||||
|
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, previewUrl: '/api/v1/uploads/doc-1/content' },
|
||||||
|
};
|
||||||
|
|
||||||
export const Failed: Story = {
|
export const Failed: Story = {
|
||||||
args: { fileName: 'diploma.pdf', status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
|
args: { fileName: 'diploma.pdf', status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
|
|||||||
[uploads]="uploadsFor(c.categoryId)"
|
[uploads]="uploadsFor(c.categoryId)"
|
||||||
[channel]="channelFor(c.categoryId)"
|
[channel]="channelFor(c.categoryId)"
|
||||||
[rejection]="state().rejections[c.categoryId]"
|
[rejection]="state().rejections[c.categoryId]"
|
||||||
|
[previewUrlFor]="previewUrlFor()"
|
||||||
(fileSelected)="fileSelected.emit({ categoryId: c.categoryId, files: $event })"
|
(fileSelected)="fileSelected.emit({ categoryId: c.categoryId, files: $event })"
|
||||||
(removeUpload)="removeUpload.emit($event)"
|
(removeUpload)="removeUpload.emit($event)"
|
||||||
(retryUpload)="retryUpload.emit($event)"
|
(retryUpload)="retryUpload.emit($event)"
|
||||||
@@ -36,6 +37,8 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
|
|||||||
})
|
})
|
||||||
export class DocumentUploadComponent {
|
export class DocumentUploadComponent {
|
||||||
state = input.required<UploadState>();
|
state = input.required<UploadState>();
|
||||||
|
/** Optional: builds a preview/download URL for a completed upload's documentId. */
|
||||||
|
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||||
|
|
||||||
fileSelected = output<{ categoryId: string; files: File[] }>();
|
fileSelected = output<{ categoryId: string; files: File[] }>();
|
||||||
removeUpload = output<string>();
|
removeUpload = output<string>();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre
|
|||||||
<app-document-chip
|
<app-document-chip
|
||||||
[fileName]="upload().fileName"
|
[fileName]="upload().fileName"
|
||||||
[status]="upload().status"
|
[status]="upload().status"
|
||||||
|
[previewUrl]="previewUrl()"
|
||||||
(remove)="remove.emit()"
|
(remove)="remove.emit()"
|
||||||
(retry)="retry.emit()" />
|
(retry)="retry.emit()" />
|
||||||
@if (progressPct() !== null) {
|
@if (progressPct() !== null) {
|
||||||
@@ -24,6 +25,8 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre
|
|||||||
})
|
})
|
||||||
export class SingleUploadComponent {
|
export class SingleUploadComponent {
|
||||||
upload = input.required<Upload>();
|
upload = input.required<Upload>();
|
||||||
|
/** Builds a preview URL for a completed upload's documentId (undefined = no link). */
|
||||||
|
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||||
|
|
||||||
/** Narrow the status union once, in TS, so the template stays type-safe. */
|
/** Narrow the status union once, in TS, so the template stays type-safe. */
|
||||||
protected readonly progressPct = computed<number | null>(() => {
|
protected readonly progressPct = computed<number | null>(() => {
|
||||||
@@ -31,6 +34,11 @@ export class SingleUploadComponent {
|
|||||||
return status.type === 'uploading' ? status.progressPct : null;
|
return status.type === 'uploading' ? status.progressPct : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected readonly previewUrl = computed<string | undefined>(() => {
|
||||||
|
const status = this.upload().status;
|
||||||
|
return status.type === 'complete' ? this.previewUrlFor()?.(status.documentId) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
remove = output<void>();
|
remove = output<void>();
|
||||||
retry = output<void>();
|
retry = output<void>();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user