@aletheia-dev/collection-flow-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/FieldInput.tsx","../src/FileField.tsx","../src/SubmissionScope.ts","../src/StepForm.tsx","../src/StepPreview.tsx"],"sourcesContent":["export { FieldInput, type FieldInputProps } from './FieldInput.js';\nexport { FileField } from './FileField.js';\nexport {\n SubmissionScopeContext,\n useSubmissionScope,\n type SubmissionScope,\n} from './SubmissionScope.js';\nexport { StepForm, type StepFormProps } from './StepForm.js';\nexport { StepPreview, type StepPreviewProps } from './StepPreview.js';\n","import type { Field } from '@aletheia-dev/core';\nimport { useContext } from 'react';\nimport { FileField } from './FileField.js';\nimport { SubmissionScopeContext } from './SubmissionScope.js';\n\nexport interface FieldInputProps {\n field: Field;\n value: unknown;\n error?: string;\n onChange: (value: unknown) => void;\n /** Renders the control disabled (previews, read-only views). */\n readOnly?: boolean;\n}\n\n/** Empty strings become undefined for every type except free text so optional fields validate. */\nfunction normalise(field: Field, raw: string): unknown {\n if (raw === '' && field.type !== 'text') return undefined;\n return raw;\n}\n\nfunction Control({ field, value, onChange, readOnly, id }: FieldInputProps & { id: string }) {\n const scope = useContext(SubmissionScopeContext);\n const text = typeof value === 'string' || typeof value === 'number' ? String(value) : '';\n switch (field.type) {\n case 'text':\n case 'email':\n case 'number':\n case 'date':\n return (\n <input\n id={id}\n type={field.type}\n value={text}\n placeholder={field.placeholder}\n min={field.type === 'number' ? field.validation?.min : undefined}\n max={field.type === 'number' ? field.validation?.max : undefined}\n disabled={readOnly}\n onChange={(e) => onChange(normalise(field, e.target.value))}\n />\n );\n case 'select':\n return (\n <select\n id={id}\n value={text}\n disabled={readOnly}\n onChange={(e) => onChange(normalise(field, e.target.value))}\n >\n <option value=\"\">Select…</option>\n {(field.options ?? []).map((o) => (\n <option key={o.value} value={o.value}>\n {o.label}\n </option>\n ))}\n </select>\n );\n case 'boolean':\n return (\n <input\n id={id}\n type=\"checkbox\"\n checked={value === true}\n disabled={readOnly}\n onChange={(e) => onChange(e.target.checked)}\n />\n );\n case 'file':\n // Uploads are scoped to a submission; without one (builder preview) the control is inert.\n if (!scope) {\n return (\n <div className=\"file-field\">\n <input id={id} type=\"file\" disabled />\n <p className=\"file-status muted\">File upload needs a submission</p>\n </div>\n );\n }\n return (\n <FileField id={id} field={field} value={value} onChange={onChange} disabled={readOnly} />\n );\n }\n}\n\nexport function FieldInput(props: FieldInputProps) {\n const { field, error } = props;\n const id = `field-${field.key}`;\n return (\n <div className=\"field\">\n <label htmlFor={id}>\n {field.label}\n {field.required && ' *'}\n </label>\n <Control {...props} id={id} />\n {field.help && <small>{field.help}</small>}\n {error && <span className=\"error\">{error}</span>}\n </div>\n );\n}\n","import {\n checkFileBeforeUpload,\n isTerminalDocumentStatus,\n pollDocument,\n uploadToStorage,\n type Document,\n type DocumentContentType,\n type FileFieldValue,\n} from '@aletheia-dev/api-client';\nimport type { Field } from '@aletheia-dev/core';\nimport { useEffect, useRef, useState, type ChangeEvent } from 'react';\nimport { useSubmissionScope } from './SubmissionScope.js';\n\n/** Formats an unknown thrown value for display. */\nfunction errorMessage(value: unknown): string {\n return value instanceof Error ? value.message : String(value);\n}\n\nconst ACCEPT = '.jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf';\n\ntype Phase =\n | { kind: 'empty' }\n /** A saved value exists; its status is being fetched. */\n | { kind: 'loading'; fileName?: string }\n | { kind: 'uploading'; fileName: string; fraction: number }\n | { kind: 'scanning'; fileName: string }\n | { kind: 'clean'; fileName: string }\n | { kind: 'rejected'; fileName: string; reason: string }\n | { kind: 'infected'; fileName: string }\n /** Pre-check failure (not retryable) or an upload/network error (retryable from `create`). */\n | { kind: 'error'; fileName: string; message: string; retryable: boolean };\n\ninterface FileFieldProps {\n id: string;\n field: Field;\n value: unknown;\n onChange: (value: FileFieldValue | undefined) => void;\n /** Blocks picking a file (read-only views); upload/scan progress still shows. */\n disabled?: boolean;\n}\n\nfunction savedValue(value: unknown): FileFieldValue | undefined {\n if (typeof value !== 'object' || value === null) return undefined;\n const { fileId, name } = value as { fileId?: unknown; name?: unknown };\n if (typeof fileId !== 'string') return undefined;\n return { fileId, name: typeof name === 'string' ? name : undefined };\n}\n\nfunction phaseFor(document: Document, fileName: string): Phase {\n switch (document.status) {\n case 'clean':\n return { kind: 'clean', fileName };\n case 'infected':\n return { kind: 'infected', fileName };\n case 'rejected':\n return {\n kind: 'rejected',\n fileName,\n reason: document.rejectionReason ?? 'The file was refused.',\n };\n default:\n return { kind: 'scanning', fileName };\n }\n}\n\n/**\n * A `file` field: pre-checks the pick, uploads straight to storage with progress, finalizes and\n * waits for the scan. The field value becomes `{ fileId, name }` only once the document is clean.\n */\nexport function FileField({ id, field, value, onChange, disabled }: FileFieldProps) {\n const { api, submissionId, upload = uploadToStorage, pollIntervalMs } = useSubmissionScope();\n const saved = savedValue(value);\n const [phase, setPhase] = useState<Phase>(() =>\n saved ? { kind: 'loading', fileName: saved.name } : { kind: 'empty' },\n );\n const inputRef = useRef<HTMLInputElement>(null);\n const lastFile = useRef<File | undefined>(undefined);\n const onChangeRef = useRef(onChange);\n // Attempt counter: results of a superseded attempt (or after unmount) are dropped.\n const attempt = useRef(0);\n const mounted = useRef(false);\n // Ids this field resolved itself, so a value it just set is not fetched again.\n const resolvedId = useRef<string | undefined>(undefined);\n\n useEffect(() => {\n onChangeRef.current = onChange;\n });\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n const savedId = saved?.fileId;\n const savedName = saved?.name;\n useEffect(() => {\n if (!savedId || savedId === resolvedId.current) return;\n const current = ++attempt.current;\n const alive = () => mounted.current && current === attempt.current;\n const controller = new AbortController();\n pollDocument(api, savedId, {\n submissionId,\n signal: controller.signal,\n intervalMs: pollIntervalMs,\n })\n .then((document) => {\n if (!alive()) return;\n resolvedId.current = savedId;\n const next = phaseFor(document, savedName ?? document.fileName);\n setPhase(next);\n if (next.kind !== 'clean') onChangeRef.current(undefined);\n })\n .catch((err: unknown) => {\n if (!alive() || controller.signal.aborted) return;\n setPhase({\n kind: 'error',\n fileName: savedName ?? '',\n message: errorMessage(err),\n retryable: false,\n });\n });\n return () => controller.abort();\n }, [api, submissionId, savedId, savedName, pollIntervalMs]);\n\n async function start(file: File) {\n const current = ++attempt.current;\n const alive = () => mounted.current && current === attempt.current;\n lastFile.current = file;\n const check = checkFileBeforeUpload(file);\n if (!check.ok) {\n setPhase({ kind: 'error', fileName: file.name, message: check.reason, retryable: false });\n return;\n }\n onChangeRef.current(undefined);\n setPhase({ kind: 'uploading', fileName: file.name, fraction: 0 });\n try {\n const ticket = await api.documents.create({\n fileName: file.name,\n contentType: file.type as DocumentContentType,\n sizeBytes: file.size,\n submissionId,\n fieldKey: field.key,\n });\n if (!alive()) return;\n await upload(ticket, file, {\n onProgress: (fraction) => {\n if (alive()) setPhase({ kind: 'uploading', fileName: file.name, fraction });\n },\n });\n if (!alive()) return;\n setPhase({ kind: 'scanning', fileName: file.name });\n let document = await api.documents.finalize(ticket.documentId, { submissionId });\n if (!alive()) return;\n if (!isTerminalDocumentStatus(document.status)) {\n document = await pollDocument(api, ticket.documentId, {\n submissionId,\n intervalMs: pollIntervalMs,\n });\n if (!alive()) return;\n }\n const next = phaseFor(document, file.name);\n setPhase(next);\n if (next.kind === 'clean') {\n resolvedId.current = document.id;\n onChangeRef.current({ fileId: document.id, name: file.name });\n }\n } catch (err) {\n if (!alive()) return;\n setPhase({ kind: 'error', fileName: file.name, message: errorMessage(err), retryable: true });\n }\n }\n\n function onPick(event: ChangeEvent<HTMLInputElement>) {\n const file = event.target.files?.[0];\n // Clear the control so picking the same file again fires `change`.\n event.target.value = '';\n if (file) void start(file);\n }\n\n const busy = phase.kind === 'uploading' || phase.kind === 'scanning' || phase.kind === 'loading';\n const retry = (\n <button\n type=\"button\"\n className=\"secondary inline\"\n onClick={() => {\n if (lastFile.current) void start(lastFile.current);\n }}\n >\n Retry\n </button>\n );\n\n return (\n <div className=\"file-field\">\n <input\n ref={inputRef}\n id={id}\n type=\"file\"\n accept={ACCEPT}\n disabled={busy || disabled}\n onChange={onPick}\n />\n {phase.kind === 'uploading' && (\n <>\n <progress value={phase.fraction} max={1} aria-label={`Uploading ${phase.fileName}`} />\n <p className=\"file-status\" role=\"status\">\n Uploading {phase.fileName}… {Math.round(phase.fraction * 100)}%\n </p>\n </>\n )}\n {phase.kind === 'loading' && (\n <p className=\"file-status muted\" role=\"status\">\n Checking {phase.fileName ?? 'the saved file'}…\n </p>\n )}\n {phase.kind === 'scanning' && (\n <p className=\"file-status muted\" role=\"status\">\n Scanning {phase.fileName}…\n </p>\n )}\n {phase.kind === 'clean' && (\n <p className=\"file-status ok\" role=\"status\">\n <span>{phase.fileName} uploaded and checked.</span>\n <button\n type=\"button\"\n className=\"secondary inline\"\n onClick={() => inputRef.current?.click()}\n >\n Replace\n </button>\n </p>\n )}\n {phase.kind === 'rejected' && (\n <p className=\"file-status bad\" role=\"status\">\n <span>\n {phase.fileName} was rejected: {phase.reason}\n </span>\n {retry}\n </p>\n )}\n {phase.kind === 'infected' && (\n <p className=\"file-status bad\" role=\"status\">\n {phase.fileName} failed the malware scan. Please choose another file.\n </p>\n )}\n {phase.kind === 'error' && (\n <p className=\"file-status bad\" role=\"status\">\n <span>{phase.message}</span>\n {phase.retryable && retry}\n </p>\n )}\n </div>\n );\n}\n","import type { ApiClient, uploadToStorage } from '@aletheia-dev/api-client';\nimport { createContext, useContext } from 'react';\n\n/** What fields that talk to the API (file uploads) need from the page rendering the flow. */\nexport interface SubmissionScope {\n api: ApiClient;\n submissionId: string;\n /** Storage uploader; injectable so tests need no XMLHttpRequest. */\n upload?: typeof uploadToStorage;\n /** Scan polling interval; defaults to the client's 750 ms. */\n pollIntervalMs?: number;\n}\n\nexport const SubmissionScopeContext = createContext<SubmissionScope | undefined>(undefined);\n\nexport function useSubmissionScope(): SubmissionScope {\n const scope = useContext(SubmissionScopeContext);\n if (!scope) throw new Error('useSubmissionScope must be used inside SubmissionScopeContext');\n return scope;\n}\n","import { visibleFields } from '@aletheia-dev/collection-flow';\nimport type { FlowStep } from '@aletheia-dev/core';\nimport { FieldInput } from './FieldInput.js';\n\nexport interface StepFormProps {\n step: FlowStep;\n /** Current answers, keyed by field key; drives `visibleWhen`. */\n value: Record<string, unknown>;\n onChange: (key: string, value: unknown) => void;\n /** Messages keyed by field key. */\n errors?: Record<string, string>;\n readOnly?: boolean;\n}\n\n/** Renders the currently visible fields of one flow step. */\nexport function StepForm({ step, value, onChange, errors, readOnly }: StepFormProps) {\n return (\n <>\n {visibleFields(step, value).map((field) => (\n <FieldInput\n key={field.key}\n field={field}\n value={value[field.key]}\n error={errors?.[field.key]}\n readOnly={readOnly}\n onChange={(next) => onChange(field.key, next)}\n />\n ))}\n </>\n );\n}\n","import { buildZodForStep } from '@aletheia-dev/collection-flow';\nimport type { FlowStep } from '@aletheia-dev/core';\nimport { useState } from 'react';\nimport { StepForm } from './StepForm.js';\n\nexport interface StepPreviewProps {\n step: FlowStep;\n /** Starting sample data; the preview keeps its own copy afterwards. */\n initialData?: Record<string, unknown>;\n}\n\ntype Result =\n | { kind: 'valid' }\n | { kind: 'invalid'; issues: Array<{ path: string; message: string }> }\n | { kind: 'broken'; message: string };\n\nfunction validate(step: FlowStep, data: Record<string, unknown>): Result {\n try {\n const parsed = buildZodForStep(step, data).safeParse(data);\n if (parsed.success) return { kind: 'valid' };\n return {\n kind: 'invalid',\n issues: parsed.error.issues.map((issue) => {\n const path = issue.path.map(String).join('.');\n const missing = issue.code === 'invalid_type' && data[path] === undefined;\n return { path, message: missing ? 'This field is required' : issue.message };\n }),\n };\n } catch (err) {\n // A step that cannot produce a schema (e.g. a select without options) is a definition error.\n return { kind: 'broken', message: err instanceof Error ? err.message : String(err) };\n }\n}\n\n/**\n * A step rendered as the applicant would see it, with sample data and a \"Validate\" button that\n * runs the step's validation. Needs no API: file fields render disabled outside a submission.\n */\nexport function StepPreview({ step, initialData }: StepPreviewProps) {\n const [data, setData] = useState<Record<string, unknown>>(() => ({ ...initialData }));\n const [result, setResult] = useState<Result | undefined>();\n\n const errors: Record<string, string> = {};\n if (result?.kind === 'invalid') {\n for (const issue of result.issues) {\n const key = issue.path.split('.')[0] ?? '';\n if (!(key in errors)) errors[key] = issue.message;\n }\n }\n\n return (\n <div className=\"step-preview\">\n <h3>{step.title}</h3>\n {step.description && <p className=\"muted\">{step.description}</p>}\n <form\n className=\"form\"\n onSubmit={(e) => {\n e.preventDefault();\n setResult(validate(step, data));\n }}\n >\n <StepForm\n step={step}\n value={data}\n errors={errors}\n onChange={(key, value) => {\n setData((d) => ({ ...d, [key]: value }));\n setResult(undefined);\n }}\n />\n <div className=\"actions\">\n <button type=\"submit\">Validate</button>\n </div>\n </form>\n {result?.kind === 'valid' && (\n <p className=\"preview-result ok\" role=\"status\">\n Valid: this step would pass.\n </p>\n )}\n {result?.kind === 'invalid' && (\n <div className=\"preview-result bad\" role=\"status\">\n <p>\n {result.issues.length} issue{result.issues.length === 1 ? '' : 's'}:\n </p>\n <ul>\n {result.issues.map((issue, i) => (\n <li key={`${issue.path}-${i}`}>\n <code>{issue.path || '(step)'}</code>: {issue.message}\n </li>\n ))}\n </ul>\n </div>\n )}\n {result?.kind === 'broken' && (\n <p className=\"preview-result bad\" role=\"status\">\n This step cannot be validated: {result.message}\n </p>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["import_react","import_react","import_jsx_runtime","import_jsx_runtime","import_collection_flow","import_react","import_jsx_runtime"]}
@@ -0,0 +1,64 @@
1
+ import * as react from 'react';
2
+ import { Field, FlowStep } from '@aletheia-dev/core';
3
+ import { FileFieldValue, ApiClient, uploadToStorage } from '@aletheia-dev/api-client';
4
+
5
+ interface FieldInputProps {
6
+ field: Field;
7
+ value: unknown;
8
+ error?: string;
9
+ onChange: (value: unknown) => void;
10
+ /** Renders the control disabled (previews, read-only views). */
11
+ readOnly?: boolean;
12
+ }
13
+ declare function FieldInput(props: FieldInputProps): react.JSX.Element;
14
+
15
+ interface FileFieldProps {
16
+ id: string;
17
+ field: Field;
18
+ value: unknown;
19
+ onChange: (value: FileFieldValue | undefined) => void;
20
+ /** Blocks picking a file (read-only views); upload/scan progress still shows. */
21
+ disabled?: boolean;
22
+ }
23
+ /**
24
+ * A `file` field: pre-checks the pick, uploads straight to storage with progress, finalizes and
25
+ * waits for the scan. The field value becomes `{ fileId, name }` only once the document is clean.
26
+ */
27
+ declare function FileField({ id, field, value, onChange, disabled }: FileFieldProps): react.JSX.Element;
28
+
29
+ /** What fields that talk to the API (file uploads) need from the page rendering the flow. */
30
+ interface SubmissionScope {
31
+ api: ApiClient;
32
+ submissionId: string;
33
+ /** Storage uploader; injectable so tests need no XMLHttpRequest. */
34
+ upload?: typeof uploadToStorage;
35
+ /** Scan polling interval; defaults to the client's 750 ms. */
36
+ pollIntervalMs?: number;
37
+ }
38
+ declare const SubmissionScopeContext: react.Context<SubmissionScope | undefined>;
39
+ declare function useSubmissionScope(): SubmissionScope;
40
+
41
+ interface StepFormProps {
42
+ step: FlowStep;
43
+ /** Current answers, keyed by field key; drives `visibleWhen`. */
44
+ value: Record<string, unknown>;
45
+ onChange: (key: string, value: unknown) => void;
46
+ /** Messages keyed by field key. */
47
+ errors?: Record<string, string>;
48
+ readOnly?: boolean;
49
+ }
50
+ /** Renders the currently visible fields of one flow step. */
51
+ declare function StepForm({ step, value, onChange, errors, readOnly }: StepFormProps): react.JSX.Element;
52
+
53
+ interface StepPreviewProps {
54
+ step: FlowStep;
55
+ /** Starting sample data; the preview keeps its own copy afterwards. */
56
+ initialData?: Record<string, unknown>;
57
+ }
58
+ /**
59
+ * A step rendered as the applicant would see it, with sample data and a "Validate" button that
60
+ * runs the step's validation. Needs no API: file fields render disabled outside a submission.
61
+ */
62
+ declare function StepPreview({ step, initialData }: StepPreviewProps): react.JSX.Element;
63
+
64
+ export { FieldInput, type FieldInputProps, FileField, StepForm, type StepFormProps, StepPreview, type StepPreviewProps, type SubmissionScope, SubmissionScopeContext, useSubmissionScope };
@@ -0,0 +1,64 @@
1
+ import * as react from 'react';
2
+ import { Field, FlowStep } from '@aletheia-dev/core';
3
+ import { FileFieldValue, ApiClient, uploadToStorage } from '@aletheia-dev/api-client';
4
+
5
+ interface FieldInputProps {
6
+ field: Field;
7
+ value: unknown;
8
+ error?: string;
9
+ onChange: (value: unknown) => void;
10
+ /** Renders the control disabled (previews, read-only views). */
11
+ readOnly?: boolean;
12
+ }
13
+ declare function FieldInput(props: FieldInputProps): react.JSX.Element;
14
+
15
+ interface FileFieldProps {
16
+ id: string;
17
+ field: Field;
18
+ value: unknown;
19
+ onChange: (value: FileFieldValue | undefined) => void;
20
+ /** Blocks picking a file (read-only views); upload/scan progress still shows. */
21
+ disabled?: boolean;
22
+ }
23
+ /**
24
+ * A `file` field: pre-checks the pick, uploads straight to storage with progress, finalizes and
25
+ * waits for the scan. The field value becomes `{ fileId, name }` only once the document is clean.
26
+ */
27
+ declare function FileField({ id, field, value, onChange, disabled }: FileFieldProps): react.JSX.Element;
28
+
29
+ /** What fields that talk to the API (file uploads) need from the page rendering the flow. */
30
+ interface SubmissionScope {
31
+ api: ApiClient;
32
+ submissionId: string;
33
+ /** Storage uploader; injectable so tests need no XMLHttpRequest. */
34
+ upload?: typeof uploadToStorage;
35
+ /** Scan polling interval; defaults to the client's 750 ms. */
36
+ pollIntervalMs?: number;
37
+ }
38
+ declare const SubmissionScopeContext: react.Context<SubmissionScope | undefined>;
39
+ declare function useSubmissionScope(): SubmissionScope;
40
+
41
+ interface StepFormProps {
42
+ step: FlowStep;
43
+ /** Current answers, keyed by field key; drives `visibleWhen`. */
44
+ value: Record<string, unknown>;
45
+ onChange: (key: string, value: unknown) => void;
46
+ /** Messages keyed by field key. */
47
+ errors?: Record<string, string>;
48
+ readOnly?: boolean;
49
+ }
50
+ /** Renders the currently visible fields of one flow step. */
51
+ declare function StepForm({ step, value, onChange, errors, readOnly }: StepFormProps): react.JSX.Element;
52
+
53
+ interface StepPreviewProps {
54
+ step: FlowStep;
55
+ /** Starting sample data; the preview keeps its own copy afterwards. */
56
+ initialData?: Record<string, unknown>;
57
+ }
58
+ /**
59
+ * A step rendered as the applicant would see it, with sample data and a "Validate" button that
60
+ * runs the step's validation. Needs no API: file fields render disabled outside a submission.
61
+ */
62
+ declare function StepPreview({ step, initialData }: StepPreviewProps): react.JSX.Element;
63
+
64
+ export { FieldInput, type FieldInputProps, FileField, StepForm, type StepFormProps, StepPreview, type StepPreviewProps, type SubmissionScope, SubmissionScopeContext, useSubmissionScope };
package/dist/index.js ADDED
@@ -0,0 +1,408 @@
1
+ // src/FieldInput.tsx
2
+ import { useContext as useContext2 } from "react";
3
+
4
+ // src/FileField.tsx
5
+ import {
6
+ checkFileBeforeUpload,
7
+ isTerminalDocumentStatus,
8
+ pollDocument,
9
+ uploadToStorage
10
+ } from "@aletheia-dev/api-client";
11
+ import { useEffect, useRef, useState } from "react";
12
+
13
+ // src/SubmissionScope.ts
14
+ import { createContext, useContext } from "react";
15
+ var SubmissionScopeContext = createContext(void 0);
16
+ function useSubmissionScope() {
17
+ const scope = useContext(SubmissionScopeContext);
18
+ if (!scope) throw new Error("useSubmissionScope must be used inside SubmissionScopeContext");
19
+ return scope;
20
+ }
21
+
22
+ // src/FileField.tsx
23
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
24
+ function errorMessage(value) {
25
+ return value instanceof Error ? value.message : String(value);
26
+ }
27
+ var ACCEPT = ".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf";
28
+ function savedValue(value) {
29
+ if (typeof value !== "object" || value === null) return void 0;
30
+ const { fileId, name } = value;
31
+ if (typeof fileId !== "string") return void 0;
32
+ return { fileId, name: typeof name === "string" ? name : void 0 };
33
+ }
34
+ function phaseFor(document, fileName) {
35
+ switch (document.status) {
36
+ case "clean":
37
+ return { kind: "clean", fileName };
38
+ case "infected":
39
+ return { kind: "infected", fileName };
40
+ case "rejected":
41
+ return {
42
+ kind: "rejected",
43
+ fileName,
44
+ reason: document.rejectionReason ?? "The file was refused."
45
+ };
46
+ default:
47
+ return { kind: "scanning", fileName };
48
+ }
49
+ }
50
+ function FileField({ id, field, value, onChange, disabled }) {
51
+ const { api, submissionId, upload = uploadToStorage, pollIntervalMs } = useSubmissionScope();
52
+ const saved = savedValue(value);
53
+ const [phase, setPhase] = useState(
54
+ () => saved ? { kind: "loading", fileName: saved.name } : { kind: "empty" }
55
+ );
56
+ const inputRef = useRef(null);
57
+ const lastFile = useRef(void 0);
58
+ const onChangeRef = useRef(onChange);
59
+ const attempt = useRef(0);
60
+ const mounted = useRef(false);
61
+ const resolvedId = useRef(void 0);
62
+ useEffect(() => {
63
+ onChangeRef.current = onChange;
64
+ });
65
+ useEffect(() => {
66
+ mounted.current = true;
67
+ return () => {
68
+ mounted.current = false;
69
+ };
70
+ }, []);
71
+ const savedId = saved?.fileId;
72
+ const savedName = saved?.name;
73
+ useEffect(() => {
74
+ if (!savedId || savedId === resolvedId.current) return;
75
+ const current = ++attempt.current;
76
+ const alive = () => mounted.current && current === attempt.current;
77
+ const controller = new AbortController();
78
+ pollDocument(api, savedId, {
79
+ submissionId,
80
+ signal: controller.signal,
81
+ intervalMs: pollIntervalMs
82
+ }).then((document) => {
83
+ if (!alive()) return;
84
+ resolvedId.current = savedId;
85
+ const next = phaseFor(document, savedName ?? document.fileName);
86
+ setPhase(next);
87
+ if (next.kind !== "clean") onChangeRef.current(void 0);
88
+ }).catch((err) => {
89
+ if (!alive() || controller.signal.aborted) return;
90
+ setPhase({
91
+ kind: "error",
92
+ fileName: savedName ?? "",
93
+ message: errorMessage(err),
94
+ retryable: false
95
+ });
96
+ });
97
+ return () => controller.abort();
98
+ }, [api, submissionId, savedId, savedName, pollIntervalMs]);
99
+ async function start(file) {
100
+ const current = ++attempt.current;
101
+ const alive = () => mounted.current && current === attempt.current;
102
+ lastFile.current = file;
103
+ const check = checkFileBeforeUpload(file);
104
+ if (!check.ok) {
105
+ setPhase({ kind: "error", fileName: file.name, message: check.reason, retryable: false });
106
+ return;
107
+ }
108
+ onChangeRef.current(void 0);
109
+ setPhase({ kind: "uploading", fileName: file.name, fraction: 0 });
110
+ try {
111
+ const ticket = await api.documents.create({
112
+ fileName: file.name,
113
+ contentType: file.type,
114
+ sizeBytes: file.size,
115
+ submissionId,
116
+ fieldKey: field.key
117
+ });
118
+ if (!alive()) return;
119
+ await upload(ticket, file, {
120
+ onProgress: (fraction) => {
121
+ if (alive()) setPhase({ kind: "uploading", fileName: file.name, fraction });
122
+ }
123
+ });
124
+ if (!alive()) return;
125
+ setPhase({ kind: "scanning", fileName: file.name });
126
+ let document = await api.documents.finalize(ticket.documentId, { submissionId });
127
+ if (!alive()) return;
128
+ if (!isTerminalDocumentStatus(document.status)) {
129
+ document = await pollDocument(api, ticket.documentId, {
130
+ submissionId,
131
+ intervalMs: pollIntervalMs
132
+ });
133
+ if (!alive()) return;
134
+ }
135
+ const next = phaseFor(document, file.name);
136
+ setPhase(next);
137
+ if (next.kind === "clean") {
138
+ resolvedId.current = document.id;
139
+ onChangeRef.current({ fileId: document.id, name: file.name });
140
+ }
141
+ } catch (err) {
142
+ if (!alive()) return;
143
+ setPhase({ kind: "error", fileName: file.name, message: errorMessage(err), retryable: true });
144
+ }
145
+ }
146
+ function onPick(event) {
147
+ const file = event.target.files?.[0];
148
+ event.target.value = "";
149
+ if (file) void start(file);
150
+ }
151
+ const busy = phase.kind === "uploading" || phase.kind === "scanning" || phase.kind === "loading";
152
+ const retry = /* @__PURE__ */ jsx(
153
+ "button",
154
+ {
155
+ type: "button",
156
+ className: "secondary inline",
157
+ onClick: () => {
158
+ if (lastFile.current) void start(lastFile.current);
159
+ },
160
+ children: "Retry"
161
+ }
162
+ );
163
+ return /* @__PURE__ */ jsxs("div", { className: "file-field", children: [
164
+ /* @__PURE__ */ jsx(
165
+ "input",
166
+ {
167
+ ref: inputRef,
168
+ id,
169
+ type: "file",
170
+ accept: ACCEPT,
171
+ disabled: busy || disabled,
172
+ onChange: onPick
173
+ }
174
+ ),
175
+ phase.kind === "uploading" && /* @__PURE__ */ jsxs(Fragment, { children: [
176
+ /* @__PURE__ */ jsx("progress", { value: phase.fraction, max: 1, "aria-label": `Uploading ${phase.fileName}` }),
177
+ /* @__PURE__ */ jsxs("p", { className: "file-status", role: "status", children: [
178
+ "Uploading ",
179
+ phase.fileName,
180
+ "\u2026 ",
181
+ Math.round(phase.fraction * 100),
182
+ "%"
183
+ ] })
184
+ ] }),
185
+ phase.kind === "loading" && /* @__PURE__ */ jsxs("p", { className: "file-status muted", role: "status", children: [
186
+ "Checking ",
187
+ phase.fileName ?? "the saved file",
188
+ "\u2026"
189
+ ] }),
190
+ phase.kind === "scanning" && /* @__PURE__ */ jsxs("p", { className: "file-status muted", role: "status", children: [
191
+ "Scanning ",
192
+ phase.fileName,
193
+ "\u2026"
194
+ ] }),
195
+ phase.kind === "clean" && /* @__PURE__ */ jsxs("p", { className: "file-status ok", role: "status", children: [
196
+ /* @__PURE__ */ jsxs("span", { children: [
197
+ phase.fileName,
198
+ " uploaded and checked."
199
+ ] }),
200
+ /* @__PURE__ */ jsx(
201
+ "button",
202
+ {
203
+ type: "button",
204
+ className: "secondary inline",
205
+ onClick: () => inputRef.current?.click(),
206
+ children: "Replace"
207
+ }
208
+ )
209
+ ] }),
210
+ phase.kind === "rejected" && /* @__PURE__ */ jsxs("p", { className: "file-status bad", role: "status", children: [
211
+ /* @__PURE__ */ jsxs("span", { children: [
212
+ phase.fileName,
213
+ " was rejected: ",
214
+ phase.reason
215
+ ] }),
216
+ retry
217
+ ] }),
218
+ phase.kind === "infected" && /* @__PURE__ */ jsxs("p", { className: "file-status bad", role: "status", children: [
219
+ phase.fileName,
220
+ " failed the malware scan. Please choose another file."
221
+ ] }),
222
+ phase.kind === "error" && /* @__PURE__ */ jsxs("p", { className: "file-status bad", role: "status", children: [
223
+ /* @__PURE__ */ jsx("span", { children: phase.message }),
224
+ phase.retryable && retry
225
+ ] })
226
+ ] });
227
+ }
228
+
229
+ // src/FieldInput.tsx
230
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
231
+ function normalise(field, raw) {
232
+ if (raw === "" && field.type !== "text") return void 0;
233
+ return raw;
234
+ }
235
+ function Control({ field, value, onChange, readOnly, id }) {
236
+ const scope = useContext2(SubmissionScopeContext);
237
+ const text = typeof value === "string" || typeof value === "number" ? String(value) : "";
238
+ switch (field.type) {
239
+ case "text":
240
+ case "email":
241
+ case "number":
242
+ case "date":
243
+ return /* @__PURE__ */ jsx2(
244
+ "input",
245
+ {
246
+ id,
247
+ type: field.type,
248
+ value: text,
249
+ placeholder: field.placeholder,
250
+ min: field.type === "number" ? field.validation?.min : void 0,
251
+ max: field.type === "number" ? field.validation?.max : void 0,
252
+ disabled: readOnly,
253
+ onChange: (e) => onChange(normalise(field, e.target.value))
254
+ }
255
+ );
256
+ case "select":
257
+ return /* @__PURE__ */ jsxs2(
258
+ "select",
259
+ {
260
+ id,
261
+ value: text,
262
+ disabled: readOnly,
263
+ onChange: (e) => onChange(normalise(field, e.target.value)),
264
+ children: [
265
+ /* @__PURE__ */ jsx2("option", { value: "", children: "Select\u2026" }),
266
+ (field.options ?? []).map((o) => /* @__PURE__ */ jsx2("option", { value: o.value, children: o.label }, o.value))
267
+ ]
268
+ }
269
+ );
270
+ case "boolean":
271
+ return /* @__PURE__ */ jsx2(
272
+ "input",
273
+ {
274
+ id,
275
+ type: "checkbox",
276
+ checked: value === true,
277
+ disabled: readOnly,
278
+ onChange: (e) => onChange(e.target.checked)
279
+ }
280
+ );
281
+ case "file":
282
+ if (!scope) {
283
+ return /* @__PURE__ */ jsxs2("div", { className: "file-field", children: [
284
+ /* @__PURE__ */ jsx2("input", { id, type: "file", disabled: true }),
285
+ /* @__PURE__ */ jsx2("p", { className: "file-status muted", children: "File upload needs a submission" })
286
+ ] });
287
+ }
288
+ return /* @__PURE__ */ jsx2(FileField, { id, field, value, onChange, disabled: readOnly });
289
+ }
290
+ }
291
+ function FieldInput(props) {
292
+ const { field, error } = props;
293
+ const id = `field-${field.key}`;
294
+ return /* @__PURE__ */ jsxs2("div", { className: "field", children: [
295
+ /* @__PURE__ */ jsxs2("label", { htmlFor: id, children: [
296
+ field.label,
297
+ field.required && " *"
298
+ ] }),
299
+ /* @__PURE__ */ jsx2(Control, { ...props, id }),
300
+ field.help && /* @__PURE__ */ jsx2("small", { children: field.help }),
301
+ error && /* @__PURE__ */ jsx2("span", { className: "error", children: error })
302
+ ] });
303
+ }
304
+
305
+ // src/StepForm.tsx
306
+ import { visibleFields } from "@aletheia-dev/collection-flow";
307
+ import { Fragment as Fragment2, jsx as jsx3 } from "react/jsx-runtime";
308
+ function StepForm({ step, value, onChange, errors, readOnly }) {
309
+ return /* @__PURE__ */ jsx3(Fragment2, { children: visibleFields(step, value).map((field) => /* @__PURE__ */ jsx3(
310
+ FieldInput,
311
+ {
312
+ field,
313
+ value: value[field.key],
314
+ error: errors?.[field.key],
315
+ readOnly,
316
+ onChange: (next) => onChange(field.key, next)
317
+ },
318
+ field.key
319
+ )) });
320
+ }
321
+
322
+ // src/StepPreview.tsx
323
+ import { buildZodForStep } from "@aletheia-dev/collection-flow";
324
+ import { useState as useState2 } from "react";
325
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
326
+ function validate(step, data) {
327
+ try {
328
+ const parsed = buildZodForStep(step, data).safeParse(data);
329
+ if (parsed.success) return { kind: "valid" };
330
+ return {
331
+ kind: "invalid",
332
+ issues: parsed.error.issues.map((issue) => {
333
+ const path = issue.path.map(String).join(".");
334
+ const missing = issue.code === "invalid_type" && data[path] === void 0;
335
+ return { path, message: missing ? "This field is required" : issue.message };
336
+ })
337
+ };
338
+ } catch (err) {
339
+ return { kind: "broken", message: err instanceof Error ? err.message : String(err) };
340
+ }
341
+ }
342
+ function StepPreview({ step, initialData }) {
343
+ const [data, setData] = useState2(() => ({ ...initialData }));
344
+ const [result, setResult] = useState2();
345
+ const errors = {};
346
+ if (result?.kind === "invalid") {
347
+ for (const issue of result.issues) {
348
+ const key = issue.path.split(".")[0] ?? "";
349
+ if (!(key in errors)) errors[key] = issue.message;
350
+ }
351
+ }
352
+ return /* @__PURE__ */ jsxs3("div", { className: "step-preview", children: [
353
+ /* @__PURE__ */ jsx4("h3", { children: step.title }),
354
+ step.description && /* @__PURE__ */ jsx4("p", { className: "muted", children: step.description }),
355
+ /* @__PURE__ */ jsxs3(
356
+ "form",
357
+ {
358
+ className: "form",
359
+ onSubmit: (e) => {
360
+ e.preventDefault();
361
+ setResult(validate(step, data));
362
+ },
363
+ children: [
364
+ /* @__PURE__ */ jsx4(
365
+ StepForm,
366
+ {
367
+ step,
368
+ value: data,
369
+ errors,
370
+ onChange: (key, value) => {
371
+ setData((d) => ({ ...d, [key]: value }));
372
+ setResult(void 0);
373
+ }
374
+ }
375
+ ),
376
+ /* @__PURE__ */ jsx4("div", { className: "actions", children: /* @__PURE__ */ jsx4("button", { type: "submit", children: "Validate" }) })
377
+ ]
378
+ }
379
+ ),
380
+ result?.kind === "valid" && /* @__PURE__ */ jsx4("p", { className: "preview-result ok", role: "status", children: "Valid: this step would pass." }),
381
+ result?.kind === "invalid" && /* @__PURE__ */ jsxs3("div", { className: "preview-result bad", role: "status", children: [
382
+ /* @__PURE__ */ jsxs3("p", { children: [
383
+ result.issues.length,
384
+ " issue",
385
+ result.issues.length === 1 ? "" : "s",
386
+ ":"
387
+ ] }),
388
+ /* @__PURE__ */ jsx4("ul", { children: result.issues.map((issue, i) => /* @__PURE__ */ jsxs3("li", { children: [
389
+ /* @__PURE__ */ jsx4("code", { children: issue.path || "(step)" }),
390
+ ": ",
391
+ issue.message
392
+ ] }, `${issue.path}-${i}`)) })
393
+ ] }),
394
+ result?.kind === "broken" && /* @__PURE__ */ jsxs3("p", { className: "preview-result bad", role: "status", children: [
395
+ "This step cannot be validated: ",
396
+ result.message
397
+ ] })
398
+ ] });
399
+ }
400
+ export {
401
+ FieldInput,
402
+ FileField,
403
+ StepForm,
404
+ StepPreview,
405
+ SubmissionScopeContext,
406
+ useSubmissionScope
407
+ };
408
+ //# sourceMappingURL=index.js.map