Upload feature (f): demo scenarios (upload-slow/fail) + a11y (file-input label)
- upload-slow/upload-fail scenarios simulated in the adapter (XHR POST bypasses the HTTP interceptor); categories/status/delete already honour the global slow/error - file-input gets a per-category accessible name (aria-label + visible button text) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,15 @@
|
|||||||
export type Scenario = 'default' | 'slow' | 'loading' | 'empty' | 'error';
|
export type Scenario =
|
||||||
|
| 'default'
|
||||||
|
| 'slow'
|
||||||
|
| 'loading'
|
||||||
|
| 'empty'
|
||||||
|
| 'error'
|
||||||
|
// upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
|
||||||
|
// interceptor — these are simulated in upload.adapter.ts instead):
|
||||||
|
| 'upload-slow'
|
||||||
|
| 'upload-fail';
|
||||||
|
|
||||||
const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error'];
|
const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error', 'upload-slow', 'upload-fail'];
|
||||||
|
|
||||||
/** Reads ?scenario= from the URL so a demo can force each async state. */
|
/** Reads ?scenario= from the URL so a demo can force each async state. */
|
||||||
export function currentScenario(): Scenario {
|
export function currentScenario(): Scenario {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine';
|
import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine';
|
||||||
import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component';
|
import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component';
|
||||||
import { FileInputComponent } from '../file-input/file-input.component';
|
import { FileInputComponent } from '../file-input/file-input.component';
|
||||||
@@ -42,6 +42,7 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
|
|||||||
@if (channel() === 'digital') {
|
@if (channel() === 'digital') {
|
||||||
<app-file-input
|
<app-file-input
|
||||||
[inputId]="category().categoryId + '-file'"
|
[inputId]="category().categoryId + '-file'"
|
||||||
|
[label]="fileInputLabel()"
|
||||||
[accept]="category().acceptedTypes"
|
[accept]="category().acceptedTypes"
|
||||||
[multiple]="category().multiple"
|
[multiple]="category().multiple"
|
||||||
(filesSelected)="fileSelected.emit($event)" />
|
(filesSelected)="fileSelected.emit($event)" />
|
||||||
@@ -67,6 +68,9 @@ export class DocumentCategoryComponent {
|
|||||||
channel = input.required<DeliveryChannel>();
|
channel = input.required<DeliveryChannel>();
|
||||||
rejection = input<string>();
|
rejection = input<string>();
|
||||||
|
|
||||||
|
/** 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:`);
|
||||||
|
|
||||||
fileSelected = output<File[]>();
|
fileSelected = output<File[]>();
|
||||||
removeUpload = output<string>();
|
removeUpload = output<string>();
|
||||||
retryUpload = output<string>();
|
retryUpload = output<string>();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
|
|||||||
#fileInput
|
#fileInput
|
||||||
type="file"
|
type="file"
|
||||||
[id]="inputId()"
|
[id]="inputId()"
|
||||||
|
[attr.aria-label]="label()"
|
||||||
[accept]="accept().join(',')"
|
[accept]="accept().join(',')"
|
||||||
[multiple]="multiple()"
|
[multiple]="multiple()"
|
||||||
[disabled]="disabled()"
|
[disabled]="disabled()"
|
||||||
@@ -38,7 +39,7 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
|
|||||||
class="utrecht-button rhc-button utrecht-button--secondary-action label"
|
class="utrecht-button rhc-button utrecht-button--secondary-action label"
|
||||||
[class.utrecht-button--disabled]="disabled()">
|
[class.utrecht-button--disabled]="disabled()">
|
||||||
<span aria-hidden="true">↑</span>
|
<span aria-hidden="true">↑</span>
|
||||||
<span i18n="@@upload.fileInput.label">Bestand kiezen</span>
|
<span>{{ label() }}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
`,
|
`,
|
||||||
@@ -48,6 +49,8 @@ export class FileInputComponent {
|
|||||||
multiple = input(false);
|
multiple = input(false);
|
||||||
disabled = input(false);
|
disabled = input(false);
|
||||||
inputId = input.required<string>();
|
inputId = input.required<string>();
|
||||||
|
/** Accessible name + button text; the domain caller supplies a per-category label. */
|
||||||
|
label = input($localize`:@@upload.fileInput.label:Bestand kiezen`);
|
||||||
|
|
||||||
filesSelected = output<File[]>();
|
filesSelected = output<File[]>();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
UploadStatusItemDto,
|
UploadStatusItemDto,
|
||||||
} from '@shared/infrastructure/api-client';
|
} from '@shared/infrastructure/api-client';
|
||||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
import { currentScenario } from '@shared/infrastructure/scenario';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { DocumentCategory } from './upload.machine';
|
import { DocumentCategory } from './upload.machine';
|
||||||
|
|
||||||
@@ -63,6 +64,9 @@ export class UploadAdapter {
|
|||||||
* transport behind UploadTransport for true background sync.
|
* transport behind UploadTransport for true background sync.
|
||||||
*/
|
*/
|
||||||
xhrUpload(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle {
|
xhrUpload(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle {
|
||||||
|
const scenario = currentScenario();
|
||||||
|
if (scenario === 'upload-slow' || scenario === 'upload-fail') return simulateUpload(scenario, onProgress);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', req.file);
|
form.append('file', req.file);
|
||||||
@@ -98,6 +102,34 @@ export class UploadAdapter {
|
|||||||
|
|
||||||
const UPLOAD_FAILED = $localize`:@@upload.failed:Uploaden is niet gelukt. Probeer het opnieuw.`;
|
const UPLOAD_FAILED = $localize`:@@upload.failed:Uploaden is niet gelukt. Probeer het opnieuw.`;
|
||||||
const genericError = (): string => UPLOAD_FAILED;
|
const genericError = (): string => UPLOAD_FAILED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo-only (dev): the real XHR POST finishes instantly for metadata, so progress
|
||||||
|
* and failure can't otherwise be shown. Drives the progress bar over ~2.5s, then
|
||||||
|
* succeeds (`upload-slow`) or fails (`upload-fail`). ponytail: timer-based, cancel
|
||||||
|
* via the returned handle; no network.
|
||||||
|
*/
|
||||||
|
function simulateUpload(scenario: 'upload-slow' | 'upload-fail', onProgress: (pct: number) => void): XhrUploadHandle {
|
||||||
|
let pct = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const done = new Promise<{ documentId: string }>((resolve, reject) => {
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) {
|
||||||
|
reject(UPLOAD_ABORTED);
|
||||||
|
} else if (pct < 100) {
|
||||||
|
pct += 10;
|
||||||
|
onProgress(Math.min(pct, 100));
|
||||||
|
setTimeout(tick, 250);
|
||||||
|
} else if (scenario === 'upload-fail') {
|
||||||
|
reject(UPLOAD_FAILED);
|
||||||
|
} else {
|
||||||
|
resolve({ documentId: `demo-${crypto.randomUUID()}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(tick, 250);
|
||||||
|
});
|
||||||
|
return { done, cancel: () => void (cancelled = true) };
|
||||||
|
}
|
||||||
const parseError = (body: string): string => {
|
const parseError = (body: string): string => {
|
||||||
try {
|
try {
|
||||||
return problemDetail(JSON.parse(body), UPLOAD_FAILED);
|
return problemDetail(JSON.parse(body), UPLOAD_FAILED);
|
||||||
|
|||||||
Reference in New Issue
Block a user