Upload feature (e): wire inline upload (registratie beroep) + documenten step (herregistratie)
- Fold UploadState into both wizard machines; route via { tag: 'Upload', msg }
- Gate step validation on requiredCategoriesSatisfied; include deliveryRefs in submit
- Shared createUploadController (effectful glue: categories, transport, focus-poll, File map)
- rejectReason pure format validator + specs; bump registratie storage key to v2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { DestroyRef, effect, inject } from '@angular/core';
|
||||
import { UploadAdapter } from './upload.adapter';
|
||||
import { UploadShellService } from './upload-shell.service';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason } from './upload.machine';
|
||||
|
||||
export interface UploadControllerDeps {
|
||||
wizardId: string;
|
||||
getUpload: () => UploadState;
|
||||
dispatch: (m: UploadMsg) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The effectful glue between the `<app-document-upload>` organism's events and the
|
||||
* pure upload reducer + transport. Both wizards instantiate one (in a field
|
||||
* initializer, like `createStore`). Holds the only state a reducer can't: the live
|
||||
* `File` blobs keyed by localId (needed to retry an upload). Loads categories,
|
||||
* reports background-sync availability, and polls on tab refocus.
|
||||
*/
|
||||
export function createUploadController(deps: UploadControllerDeps) {
|
||||
const adapter = inject(UploadAdapter);
|
||||
const shell = inject(UploadShellService);
|
||||
const files = new Map<string, File>();
|
||||
const categoriesRes = adapter.categoriesResource(deps.wizardId);
|
||||
|
||||
// Runs after the host's `Seed`/restore microtask (effects fire in CD, not field
|
||||
// init), so these dispatches aren't wiped by a state reseed.
|
||||
effect(() => {
|
||||
deps.dispatch({ type: 'BackgroundSyncAvailability', available: shell.backgroundSyncAvailable });
|
||||
const status = categoriesRes.status();
|
||||
if (status === 'resolved' || status === 'local') {
|
||||
deps.dispatch({ type: 'CategoriesLoaded', categories: categoriesRes.value() ?? [] });
|
||||
} else if (status === 'error') {
|
||||
deps.dispatch({ type: 'CategoriesLoadFailed', reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED) });
|
||||
}
|
||||
});
|
||||
|
||||
const onFocus = () => void shell.pollReturning(inFlight(deps.getUpload()), deps.dispatch);
|
||||
window.addEventListener('focus', onFocus);
|
||||
inject(DestroyRef).onDestroy(() => window.removeEventListener('focus', onFocus));
|
||||
|
||||
function start(categoryId: string, file: File) {
|
||||
const localId = crypto.randomUUID();
|
||||
files.set(localId, file);
|
||||
deps.dispatch({ type: 'FileSelected', categoryId, localId, fileName: file.name, fileSizeMb: file.size / 1e6 });
|
||||
shell.upload({ localId, categoryId, wizardId: deps.wizardId, file }, deps.dispatch);
|
||||
}
|
||||
|
||||
return {
|
||||
onFileSelected(categoryId: string, selected: File[]) {
|
||||
const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId);
|
||||
if (!cat) return;
|
||||
if (!cat.multiple && selected.length > 1) {
|
||||
deps.dispatch({ type: 'FileRejected', categoryId, reason: 'multiple' });
|
||||
return;
|
||||
}
|
||||
for (const file of selected) {
|
||||
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
|
||||
if (reason) deps.dispatch({ type: 'FileRejected', categoryId, reason });
|
||||
else start(categoryId, file);
|
||||
}
|
||||
},
|
||||
onRemove(localId: string) {
|
||||
shell.cancel([localId]);
|
||||
files.delete(localId);
|
||||
deps.dispatch({ type: 'UploadRemoved', localId });
|
||||
},
|
||||
onRetry(localId: string) {
|
||||
const file = files.get(localId);
|
||||
const up = deps.getUpload().uploads.find((u) => u.localId === localId);
|
||||
if (!file || !up) return;
|
||||
deps.dispatch({ type: 'UploadRetried', localId });
|
||||
shell.upload({ localId, categoryId: up.categoryId, wizardId: deps.wizardId, file }, deps.dispatch);
|
||||
},
|
||||
onDelete(e: { localId: string; documentId: string }) {
|
||||
shell.delete(e.localId, e.documentId, deps.dispatch);
|
||||
},
|
||||
onChannelChange(categoryId: string, channel: DeliveryChannel) {
|
||||
const ids = deps
|
||||
.getUpload()
|
||||
.uploads.filter((u) => u.categoryId === categoryId)
|
||||
.map((u) => u.localId);
|
||||
shell.cancel(ids);
|
||||
deps.dispatch({ type: 'DeliveryChannelChanged', categoryId, channel });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
requiredCategoriesSatisfied,
|
||||
deliveryRefs,
|
||||
inFlight,
|
||||
rejectReason,
|
||||
} from './upload.machine';
|
||||
|
||||
const cat = (over: Partial<DocumentCategory> = {}): DocumentCategory => ({
|
||||
@@ -217,6 +218,21 @@ describe('deliveryRefs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectReason', () => {
|
||||
it('rejects a disallowed type', () => {
|
||||
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 })).toBe('type');
|
||||
});
|
||||
it('rejects an oversized file', () => {
|
||||
expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe('size');
|
||||
});
|
||||
it('accepts a valid file', () => {
|
||||
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 1 })).toBeNull();
|
||||
});
|
||||
it('allows any type when the category lists none', () => {
|
||||
expect(rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inFlight', () => {
|
||||
it('returns only queued/uploading uploads', () => {
|
||||
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
|
||||
|
||||
@@ -96,6 +96,16 @@ export function requiredCategoriesSatisfied(s: UploadState): boolean {
|
||||
return s.categories.filter((c) => c.required).every((c) => categorySatisfied(s, c.categoryId));
|
||||
}
|
||||
|
||||
/**
|
||||
* FE format-validation (never authority — the server re-validates). Returns the
|
||||
* rejection reason for one file, or null if it passes the category's type/size.
|
||||
*/
|
||||
export function rejectReason(cat: DocumentCategory, file: { type: string; sizeMb: number }): 'type' | 'size' | null {
|
||||
if (cat.acceptedTypes.length > 0 && !cat.acceptedTypes.includes(file.type)) return 'type';
|
||||
if (cat.maxSizeMb > 0 && file.sizeMb > cat.maxSizeMb) return 'size';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Map one upload's status, leaving the rest of the list untouched. */
|
||||
function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState {
|
||||
return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) };
|
||||
|
||||
Reference in New Issue
Block a user