Upload feature (d): pure upload domain machine + spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
229
src/app/shared/upload/upload.machine.spec.ts
Normal file
229
src/app/shared/upload/upload.machine.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DocumentCategory,
|
||||
Upload,
|
||||
UploadState,
|
||||
initialUpload,
|
||||
reduceUpload,
|
||||
categorySatisfied,
|
||||
requiredCategoriesSatisfied,
|
||||
deliveryRefs,
|
||||
inFlight,
|
||||
} from './upload.machine';
|
||||
|
||||
const cat = (over: Partial<DocumentCategory> = {}): DocumentCategory => ({
|
||||
categoryId: 'diploma',
|
||||
label: 'Diploma',
|
||||
description: '',
|
||||
required: true,
|
||||
acceptedTypes: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
const stateWith = (categories: DocumentCategory[], over: Partial<UploadState> = {}): UploadState =>
|
||||
reduceUpload({ ...initialUpload, ...over }, { type: 'CategoriesLoaded', categories });
|
||||
|
||||
const select = (s: UploadState, categoryId: string, localId: string): UploadState =>
|
||||
reduceUpload(s, { type: 'FileSelected', categoryId, localId, fileName: `${localId}.pdf`, fileSizeMb: 1 });
|
||||
|
||||
const get = (s: UploadState, localId: string): Upload | undefined => s.uploads.find((u) => u.localId === localId);
|
||||
|
||||
describe('CategoriesLoaded', () => {
|
||||
it('defaults every category to digital without clobbering existing choices', () => {
|
||||
const s0 = stateWith([cat({ categoryId: 'a' })], { deliveryChannel: { a: 'post' } });
|
||||
expect(s0.deliveryChannel['a']).toBe('post');
|
||||
const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'a' }), cat({ categoryId: 'b' })] });
|
||||
expect(s1.deliveryChannel).toEqual({ a: 'post', b: 'digital' });
|
||||
});
|
||||
|
||||
it('clears a prior categoriesError', () => {
|
||||
const s = reduceUpload({ ...initialUpload, categoriesError: 'boom' }, { type: 'CategoriesLoaded', categories: [] });
|
||||
expect(s.categoriesError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CategoriesLoadFailed / BackgroundSyncAvailability', () => {
|
||||
it('records the error', () => {
|
||||
expect(reduceUpload(initialUpload, { type: 'CategoriesLoadFailed', reason: 'boom' }).categoriesError).toBe('boom');
|
||||
});
|
||||
it('flips background sync availability', () => {
|
||||
expect(reduceUpload(initialUpload, { type: 'BackgroundSyncAvailability', available: true }).backgroundSyncAvailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSelected', () => {
|
||||
it('queues a new upload and clears any rejection for that category', () => {
|
||||
const s = select(stateWith([cat()], { rejections: { diploma: 'old' } }), 'diploma', 'u1');
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'queued' });
|
||||
expect(s.rejections['diploma']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores selection for an unknown category', () => {
|
||||
const s = stateWith([cat()]);
|
||||
expect(select(s, 'nope', 'u1').uploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores selection for a category set to post-delivery', () => {
|
||||
const s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } });
|
||||
expect(select(s, 'diploma', 'u1').uploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('single-file category: a new selection replaces the existing upload', () => {
|
||||
let s = select(stateWith([cat({ multiple: false })]), 'diploma', 'u1');
|
||||
s = select(s, 'diploma', 'u2');
|
||||
expect(s.uploads.map((u) => u.localId)).toEqual(['u2']);
|
||||
});
|
||||
|
||||
it('multiple category: selections accumulate', () => {
|
||||
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
|
||||
s = select(s, 'diploma', 'u2');
|
||||
expect(s.uploads.map((u) => u.localId)).toEqual(['u1', 'u2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileRejected', () => {
|
||||
it('stores a per-category message', () => {
|
||||
const s = reduceUpload(stateWith([cat()]), { type: 'FileRejected', categoryId: 'diploma', reason: 'size' });
|
||||
expect(s.rejections['diploma']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload lifecycle messages', () => {
|
||||
it('queued → progress → complete', () => {
|
||||
let s = select(stateWith([cat()]), 'diploma', 'u1');
|
||||
s = reduceUpload(s, { type: 'UploadQueued', localId: 'u1', backgroundSync: true });
|
||||
expect(get(s, 'u1')?.backgroundSync).toBe(true);
|
||||
s = reduceUpload(s, { type: 'UploadProgress', localId: 'u1', progressPct: 42 });
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'uploading', progressPct: 42 });
|
||||
s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' });
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'complete', documentId: 'doc1' });
|
||||
});
|
||||
|
||||
it('failed then retried returns to queued', () => {
|
||||
let s = select(stateWith([cat()]), 'diploma', 'u1');
|
||||
s = reduceUpload(s, { type: 'UploadFailed', localId: 'u1', reason: 'network' });
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'failed', reason: 'network' });
|
||||
s = reduceUpload(s, { type: 'UploadRetried', localId: 'u1' });
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'queued' });
|
||||
});
|
||||
|
||||
it('UploadRemoved drops the upload', () => {
|
||||
let s = select(stateWith([cat()]), 'diploma', 'u1');
|
||||
s = reduceUpload(s, { type: 'UploadRemoved', localId: 'u1' });
|
||||
expect(s.uploads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete flow (optimistic, revertible)', () => {
|
||||
const completed = (): UploadState => {
|
||||
let s = select(stateWith([cat()]), 'diploma', 'u1');
|
||||
return reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' });
|
||||
};
|
||||
|
||||
it('UploadDeleteRequested keeps the documentId for revert', () => {
|
||||
const s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' });
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'deleting', documentId: 'doc1' });
|
||||
});
|
||||
|
||||
it('UploadDeleteComplete removes the upload', () => {
|
||||
let s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' });
|
||||
s = reduceUpload(s, { type: 'UploadDeleteComplete', localId: 'u1' });
|
||||
expect(s.uploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('UploadDeleteFailed reverts to complete with the original documentId', () => {
|
||||
let s = reduceUpload(completed(), { type: 'UploadDeleting', localId: 'u1' });
|
||||
s = reduceUpload(s, { type: 'UploadDeleteFailed', localId: 'u1', reason: 'boom' });
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'complete', documentId: 'doc1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeliveryChannelChanged', () => {
|
||||
it('switching to post removes that category uploads', () => {
|
||||
let s = select(stateWith([cat()]), 'diploma', 'u1');
|
||||
s = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' });
|
||||
expect(s.deliveryChannel['diploma']).toBe('post');
|
||||
expect(s.uploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects post for a category that does not allow it', () => {
|
||||
const s = stateWith([cat({ allowPostDelivery: false })]);
|
||||
const next = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' });
|
||||
expect(next.deliveryChannel['diploma']).toBe('digital');
|
||||
});
|
||||
|
||||
it('switching back to digital starts clean', () => {
|
||||
let s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } });
|
||||
s = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'digital' });
|
||||
expect(s.deliveryChannel['diploma']).toBe('digital');
|
||||
expect(s.uploads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackgroundUploadsReturned', () => {
|
||||
it('resolves each in-flight upload to complete or failed', () => {
|
||||
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
|
||||
s = select(s, 'diploma', 'u2');
|
||||
s = reduceUpload(s, {
|
||||
type: 'BackgroundUploadsReturned',
|
||||
results: [
|
||||
{ localId: 'u1', success: true, documentId: 'doc1' },
|
||||
{ localId: 'u2', success: false, reason: 'rejected' },
|
||||
],
|
||||
});
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'complete', documentId: 'doc1' });
|
||||
expect(get(s, 'u2')?.status).toEqual({ type: 'failed', reason: 'rejected' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('satisfaction helpers', () => {
|
||||
it('a post-delivery choice satisfies a required category', () => {
|
||||
const s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } });
|
||||
expect(categorySatisfied(s, 'diploma')).toBe(true);
|
||||
});
|
||||
|
||||
it('an active upload satisfies; a failed one does not', () => {
|
||||
let s = select(stateWith([cat()]), 'diploma', 'u1');
|
||||
expect(categorySatisfied(s, 'diploma')).toBe(true);
|
||||
s = reduceUpload(s, { type: 'UploadFailed', localId: 'u1', reason: 'x' });
|
||||
expect(categorySatisfied(s, 'diploma')).toBe(false);
|
||||
});
|
||||
|
||||
it('requiredCategoriesSatisfied ignores optional categories', () => {
|
||||
const s = stateWith([cat({ categoryId: 'req', required: true }), cat({ categoryId: 'opt', required: false })]);
|
||||
expect(requiredCategoriesSatisfied(s)).toBe(false);
|
||||
const s2 = select(s, 'req', 'u1');
|
||||
expect(requiredCategoriesSatisfied(s2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliveryRefs', () => {
|
||||
it('emits documentId for completed digital uploads and channel for post', () => {
|
||||
let s = stateWith([cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], { deliveryChannel: { b: 'post' } });
|
||||
s = select(s, 'a', 'u1');
|
||||
s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' });
|
||||
expect(deliveryRefs(s)).toEqual([
|
||||
{ categoryId: 'a', channel: 'digital', documentId: 'doc1' },
|
||||
{ categoryId: 'b', channel: 'post' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits digital categories with no completed upload', () => {
|
||||
const s = select(stateWith([cat({ categoryId: 'a' })]), 'a', 'u1'); // still queued
|
||||
expect(deliveryRefs(s)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inFlight', () => {
|
||||
it('returns only queued/uploading uploads', () => {
|
||||
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
|
||||
s = select(s, 'diploma', 'u2');
|
||||
s = reduceUpload(s, { type: 'UploadProgress', localId: 'u2', progressPct: 10 });
|
||||
s = select(s, 'diploma', 'u3');
|
||||
s = reduceUpload(s, { type: 'UploadComplete', localId: 'u3', documentId: 'doc3' });
|
||||
expect(inFlight(s).map((u) => u.localId).sort()).toEqual(['u1', 'u2']);
|
||||
});
|
||||
});
|
||||
212
src/app/shared/upload/upload.machine.ts
Normal file
212
src/app/shared/upload/upload.machine.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* Pure upload domain (no Angular). Reusable across wizards: the host wizard folds
|
||||
* `UploadState` into its Model and delegates upload `Msg`s to `reduceUpload`.
|
||||
* Illegal states are unrepresentable by construction; the few transitions a union
|
||||
* can't express (channel↔upload exclusivity, single-file categories) are enforced
|
||||
* here in the reducer. Effects live in the shell (upload-shell.service.ts).
|
||||
*/
|
||||
|
||||
export type UploadStatus =
|
||||
| { type: 'idle' }
|
||||
| { type: 'queued' }
|
||||
| { type: 'uploading'; progressPct: number }
|
||||
| { type: 'complete'; documentId: string }
|
||||
| { type: 'failed'; reason: string }
|
||||
| { type: 'deleting'; documentId: string } // carries the id so a failed delete can revert
|
||||
| { type: 'deleted' };
|
||||
|
||||
export interface Upload {
|
||||
localId: string; // client-generated UUID; the sync tag / status key
|
||||
categoryId: string;
|
||||
fileName: string;
|
||||
fileSizeMb: number;
|
||||
status: UploadStatus;
|
||||
backgroundSync: boolean;
|
||||
}
|
||||
|
||||
export type DeliveryChannel = 'digital' | 'post';
|
||||
|
||||
export interface DocumentCategory {
|
||||
categoryId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
acceptedTypes: string[];
|
||||
maxSizeMb: number;
|
||||
multiple: boolean;
|
||||
allowPostDelivery: boolean;
|
||||
}
|
||||
|
||||
export interface UploadState {
|
||||
categories: DocumentCategory[];
|
||||
uploads: Upload[];
|
||||
deliveryChannel: Record<string, DeliveryChannel>; // categoryId → channel; default 'digital'
|
||||
rejections: Record<string, string>; // categoryId → client-side validation message (transient)
|
||||
backgroundSyncAvailable: boolean;
|
||||
categoriesError?: string;
|
||||
}
|
||||
|
||||
export const initialUpload: UploadState = {
|
||||
categories: [],
|
||||
uploads: [],
|
||||
deliveryChannel: {},
|
||||
rejections: {},
|
||||
backgroundSyncAvailable: false,
|
||||
};
|
||||
|
||||
export type UploadMsg =
|
||||
| { type: 'CategoriesLoaded'; categories: DocumentCategory[] }
|
||||
| { type: 'CategoriesLoadFailed'; reason: string }
|
||||
| { type: 'BackgroundSyncAvailability'; available: boolean }
|
||||
| { type: 'FileSelected'; categoryId: string; localId: string; fileName: string; fileSizeMb: number }
|
||||
| { type: 'FileRejected'; categoryId: string; reason: 'type' | 'size' | 'multiple' }
|
||||
| { type: 'UploadQueued'; localId: string; backgroundSync: boolean }
|
||||
| { type: 'UploadProgress'; localId: string; progressPct: number }
|
||||
| { type: 'UploadComplete'; localId: string; documentId: string }
|
||||
| { type: 'UploadFailed'; localId: string; reason: string }
|
||||
| { type: 'UploadRetried'; localId: string }
|
||||
| { type: 'UploadRemoved'; localId: string }
|
||||
| { type: 'UploadDeleteRequested'; localId: string; documentId: string }
|
||||
| { type: 'UploadDeleting'; localId: string }
|
||||
| { type: 'UploadDeleteComplete'; localId: string }
|
||||
| { type: 'UploadDeleteFailed'; localId: string; reason: string }
|
||||
| { type: 'DeliveryChannelChanged'; categoryId: string; channel: DeliveryChannel }
|
||||
| {
|
||||
type: 'BackgroundUploadsReturned';
|
||||
results: Array<{ localId: string } & ({ success: true; documentId: string } | { success: false; reason: string })>;
|
||||
};
|
||||
|
||||
const REJECTION_MESSAGES: Record<'type' | 'size' | 'multiple', string> = {
|
||||
type: $localize`:@@upload.reject.type:Dit bestandstype is niet toegestaan voor deze categorie.`,
|
||||
size: $localize`:@@upload.reject.size:Dit bestand is te groot.`,
|
||||
multiple: $localize`:@@upload.reject.multiple:U kunt voor deze categorie maar één bestand uploaden.`,
|
||||
};
|
||||
|
||||
const ACTIVE: ReadonlyArray<UploadStatus['type']> = ['queued', 'uploading', 'complete'];
|
||||
|
||||
/** A required category is satisfied by an initiated upload OR a post-delivery choice. */
|
||||
export function categorySatisfied(s: UploadState, categoryId: string): boolean {
|
||||
if (s.deliveryChannel[categoryId] === 'post') return true;
|
||||
return s.uploads.some((u) => u.categoryId === categoryId && ACTIVE.includes(u.status.type));
|
||||
}
|
||||
|
||||
export function requiredCategoriesSatisfied(s: UploadState): boolean {
|
||||
return s.categories.filter((c) => c.required).every((c) => categorySatisfied(s, c.categoryId));
|
||||
}
|
||||
|
||||
/** 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)) };
|
||||
}
|
||||
|
||||
const find = (s: UploadState, localId: string) => s.uploads.find((u) => u.localId === localId);
|
||||
const categoryOf = (s: UploadState, categoryId: string) => s.categories.find((c) => c.categoryId === categoryId);
|
||||
|
||||
export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
switch (m.type) {
|
||||
case 'CategoriesLoaded': {
|
||||
const deliveryChannel = { ...s.deliveryChannel };
|
||||
for (const c of m.categories) deliveryChannel[c.categoryId] ??= 'digital';
|
||||
return { ...s, categories: m.categories, deliveryChannel, categoriesError: undefined };
|
||||
}
|
||||
case 'CategoriesLoadFailed':
|
||||
return { ...s, categoriesError: m.reason };
|
||||
case 'BackgroundSyncAvailability':
|
||||
return { ...s, backgroundSyncAvailable: m.available };
|
||||
|
||||
case 'FileSelected': {
|
||||
// Reject at dispatch: unknown category, or a category set to post-delivery.
|
||||
if (!categoryOf(s, m.categoryId) || s.deliveryChannel[m.categoryId] === 'post') return s;
|
||||
const cat = categoryOf(s, m.categoryId)!;
|
||||
// Single-file categories: a new selection replaces any existing upload.
|
||||
const uploads = cat.multiple ? s.uploads : s.uploads.filter((u) => u.categoryId !== m.categoryId);
|
||||
const next: Upload = {
|
||||
localId: m.localId,
|
||||
categoryId: m.categoryId,
|
||||
fileName: m.fileName,
|
||||
fileSizeMb: m.fileSizeMb,
|
||||
status: { type: 'queued' },
|
||||
backgroundSync: false,
|
||||
};
|
||||
const { [m.categoryId]: _cleared, ...rejections } = s.rejections;
|
||||
return { ...s, uploads: [...uploads, next], rejections };
|
||||
}
|
||||
case 'FileRejected':
|
||||
return { ...s, rejections: { ...s.rejections, [m.categoryId]: REJECTION_MESSAGES[m.reason] } };
|
||||
|
||||
case 'UploadQueued':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' }, backgroundSync: m.backgroundSync }));
|
||||
case 'UploadProgress':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'uploading', progressPct: m.progressPct } }));
|
||||
case 'UploadComplete':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'complete', documentId: m.documentId } }));
|
||||
case 'UploadFailed':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'failed', reason: m.reason } }));
|
||||
case 'UploadRetried':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' } }));
|
||||
|
||||
case 'UploadRemoved':
|
||||
return { ...s, uploads: s.uploads.filter((u) => u.localId !== m.localId) };
|
||||
|
||||
case 'UploadDeleteRequested':
|
||||
case 'UploadDeleting':
|
||||
// Both mark the in-flight delete; keep the documentId so a failure can revert.
|
||||
return mapUpload(s, m.localId, (u) => {
|
||||
const documentId = u.status.type === 'complete' ? u.status.documentId
|
||||
: u.status.type === 'deleting' ? u.status.documentId : '';
|
||||
return { ...u, status: { type: 'deleting', documentId } };
|
||||
});
|
||||
case 'UploadDeleteComplete':
|
||||
return { ...s, uploads: s.uploads.filter((u) => u.localId !== m.localId) };
|
||||
case 'UploadDeleteFailed':
|
||||
return mapUpload(s, m.localId, (u) =>
|
||||
u.status.type === 'deleting' ? { ...u, status: { type: 'complete', documentId: u.status.documentId } } : u);
|
||||
|
||||
case 'DeliveryChannelChanged': {
|
||||
const cat = categoryOf(s, m.categoryId);
|
||||
// Reject post for a category that doesn't permit it.
|
||||
if (m.channel === 'post' && (!cat || !cat.allowPostDelivery)) return s;
|
||||
const deliveryChannel = { ...s.deliveryChannel, [m.categoryId]: m.channel };
|
||||
// Switching to post removes that category's uploads; switching to digital starts clean.
|
||||
const uploads = s.uploads.filter((u) => u.categoryId !== m.categoryId);
|
||||
return { ...s, deliveryChannel, uploads };
|
||||
}
|
||||
|
||||
case 'BackgroundUploadsReturned': {
|
||||
let next = s;
|
||||
for (const r of m.results) {
|
||||
next = mapUpload(next, r.localId, (u) => ({
|
||||
...u,
|
||||
status: r.success ? { type: 'complete', documentId: r.documentId } : { type: 'failed', reason: r.reason },
|
||||
}));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
|
||||
/** The submit payload fragment: digital docs carry their documentId, post categories the channel. */
|
||||
export function deliveryRefs(s: UploadState): Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> {
|
||||
const refs: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> = [];
|
||||
for (const c of s.categories) {
|
||||
if (s.deliveryChannel[c.categoryId] === 'post') {
|
||||
refs.push({ categoryId: c.categoryId, channel: 'post' });
|
||||
} else {
|
||||
for (const u of s.uploads) {
|
||||
if (u.categoryId === c.categoryId && u.status.type === 'complete') {
|
||||
refs.push({ categoryId: c.categoryId, channel: 'digital', documentId: u.status.documentId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/** Used by the shell to find what to poll on return: still-in-flight uploads. */
|
||||
export const inFlight = (s: UploadState): Upload[] =>
|
||||
s.uploads.filter((u) => u.status.type === 'queued' || u.status.type === 'uploading');
|
||||
Reference in New Issue
Block a user