@usefillo/core 0.1.0 → 0.2.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.
- package/dist/index.d.ts +75 -3
- package/dist/index.js +180 -14
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -212,6 +212,11 @@ interface UploadSession {
|
|
|
212
212
|
status: UploadStatus;
|
|
213
213
|
/** Defaults to the Fillo protocol when omitted. */
|
|
214
214
|
transport?: UploadTransport;
|
|
215
|
+
/**
|
|
216
|
+
* Per-session bearer returned at creation; sent back (X-Fillo-Upload-Token)
|
|
217
|
+
* on chunk/complete/status so only the creator can drive this upload.
|
|
218
|
+
*/
|
|
219
|
+
token?: string;
|
|
215
220
|
/** Set when status is "complete". */
|
|
216
221
|
file?: FileValue;
|
|
217
222
|
}
|
|
@@ -358,6 +363,8 @@ interface UploadFileOptions {
|
|
|
358
363
|
signal?: AbortSignal;
|
|
359
364
|
/** Resume an interrupted session instead of starting fresh. */
|
|
360
365
|
sessionId?: string;
|
|
366
|
+
/** Ownership token for the resumed session (from the original create call). */
|
|
367
|
+
uploadToken?: string;
|
|
361
368
|
}
|
|
362
369
|
declare class FilloError extends Error {
|
|
363
370
|
status?: number | undefined;
|
|
@@ -395,7 +402,7 @@ declare class FilloClient {
|
|
|
395
402
|
completed?: boolean;
|
|
396
403
|
}): void;
|
|
397
404
|
/** Current state of an upload session — used to resume after interruption. */
|
|
398
|
-
getUploadSession(sessionId: string): Promise<UploadSession>;
|
|
405
|
+
getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
|
|
399
406
|
/**
|
|
400
407
|
* Resumable chunked upload. Creates (or resumes) a session, streams the file
|
|
401
408
|
* chunk by chunk with progress callbacks, and finalizes into a FileValue that
|
|
@@ -425,6 +432,71 @@ declare class FilloClient {
|
|
|
425
432
|
}
|
|
426
433
|
declare function createClient(options: FilloClientOptions): FilloClient;
|
|
427
434
|
|
|
435
|
+
type FormStatus = "idle" | "submitting" | "submitted" | "error";
|
|
436
|
+
interface FormControllerOptions {
|
|
437
|
+
form: FormSchema;
|
|
438
|
+
/** Target form id — required for client submission and funnel tracking. */
|
|
439
|
+
formId?: string;
|
|
440
|
+
/** Collect responses into a Fillo workspace. Omit and pass `onSubmit` to keep them yourself. */
|
|
441
|
+
client?: FilloClient;
|
|
442
|
+
initialData?: ResponseData;
|
|
443
|
+
onChange?: (data: ResponseData) => void;
|
|
444
|
+
onSubmitted?: (responseId: string | undefined, data: ResponseData) => void;
|
|
445
|
+
/** Handle submission yourself instead of the client. */
|
|
446
|
+
onSubmit?: (data: ResponseData) => Promise<void> | void;
|
|
447
|
+
/** Honeypot value, supplied by your renderer; must be empty for a human. */
|
|
448
|
+
getHoneypot?: () => string;
|
|
449
|
+
}
|
|
450
|
+
interface FormControllerState {
|
|
451
|
+
data: ResponseData;
|
|
452
|
+
errors: Record<string, string>;
|
|
453
|
+
status: FormStatus;
|
|
454
|
+
pageIndex: number;
|
|
455
|
+
pageCount: number;
|
|
456
|
+
/** The current page (after clamping). */
|
|
457
|
+
page: FormPage;
|
|
458
|
+
/** Blocks visible on the current page, after conditional logic. */
|
|
459
|
+
blocks: Block[];
|
|
460
|
+
isFirstPage: boolean;
|
|
461
|
+
isLastPage: boolean;
|
|
462
|
+
/** True while any file field is uploading. */
|
|
463
|
+
uploading: boolean;
|
|
464
|
+
}
|
|
465
|
+
interface FormController {
|
|
466
|
+
/** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
|
|
467
|
+
getState(): FormControllerState;
|
|
468
|
+
/** Subscribe to state changes; returns an unsubscribe function. */
|
|
469
|
+
subscribe(listener: () => void): () => void;
|
|
470
|
+
setValue(fieldId: string, value: FieldValue): void;
|
|
471
|
+
/** Mark a file field busy/idle so `state.uploading` reflects it. */
|
|
472
|
+
setUploading(fieldId: string, busy: boolean): void;
|
|
473
|
+
/** Validate the current page, then advance. No-op on the last page or with errors. */
|
|
474
|
+
next(): void;
|
|
475
|
+
back(): void;
|
|
476
|
+
submit(): Promise<void>;
|
|
477
|
+
/**
|
|
478
|
+
* Late-bind inputs after creation, keeping current answers/page/status:
|
|
479
|
+
* - `form` updates the rendered schema in place (a builder live preview being
|
|
480
|
+
* edited), recomputing visible blocks and re-notifying.
|
|
481
|
+
* - `formId`/`client` set the submission target (e.g. a code-defined form id
|
|
482
|
+
* that resolves asynchronously).
|
|
483
|
+
*/
|
|
484
|
+
setContext(ctx: {
|
|
485
|
+
form?: FormSchema;
|
|
486
|
+
formId?: string;
|
|
487
|
+
client?: FilloClient;
|
|
488
|
+
}): void;
|
|
489
|
+
/** Drop all listeners. */
|
|
490
|
+
destroy(): void;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Framework-agnostic form engine: validation, conditional logic, pages, spam
|
|
494
|
+
* signals, and submission as a subscribable store — with no rendering. Drive it
|
|
495
|
+
* from vanilla JS, Vue, Svelte, or any framework and lay the fields out
|
|
496
|
+
* yourself. (In React, `<FilloProvider>` / `useFillo()` wrap this same engine.)
|
|
497
|
+
*/
|
|
498
|
+
declare function createFormController(options: FormControllerOptions): FormController;
|
|
499
|
+
|
|
428
500
|
/**
|
|
429
501
|
* A form whose structure lives in user code. Framework renderers can show it
|
|
430
502
|
* immediately, then sync it into a Fillo workspace when a publishable key is
|
|
@@ -472,7 +544,7 @@ declare function resolveText(text: string, data: ResponseData, form: FormSchema)
|
|
|
472
544
|
/** Return a copy of a block with its visible text fields piped, or the block unchanged. */
|
|
473
545
|
declare function pipeBlock(block: Block, data: ResponseData, form: FormSchema): Block;
|
|
474
546
|
|
|
475
|
-
/** Dependency-free nanoid-style id. Crypto-random
|
|
547
|
+
/** Dependency-free nanoid-style id with an unbiased alphabet. Crypto-random. */
|
|
476
548
|
declare function createId(size?: number): string;
|
|
477
549
|
|
|
478
|
-
export { BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CustomField, DRAFT_KINDS, type DateField, type DividerBlock, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, FilloClient, type FilloClientOptions, FilloError, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormTheme, type HeadingBlock, type HiddenField, type JsonValue, type LinearScaleField, type MatrixField, type NumberField, type ParagraphBlock, type PublishedForm, type RankingField, type RatingField, type ResponseData, type SchemaValidationResult, type SelectOption, type SignatureField, type SubmitMeta, type SubmitResult, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, allFields, assembleForm, createBlock, createClient, createEmptyForm, createId, defineForm, formatAnswer, isBlockVisible, isCodeForm, isField, pipeBlock, prefillFromParams, resolveText, syncCodeForm, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
|
|
550
|
+
export { BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CustomField, DRAFT_KINDS, type DateField, type DividerBlock, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, FilloClient, type FilloClientOptions, FilloError, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, type JsonValue, type LinearScaleField, type MatrixField, type NumberField, type ParagraphBlock, type PublishedForm, type RankingField, type RatingField, type ResponseData, type SchemaValidationResult, type SelectOption, type SignatureField, type SubmitMeta, type SubmitResult, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, allFields, assembleForm, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, formatAnswer, isBlockVisible, isCodeForm, isField, pipeBlock, prefillFromParams, resolveText, syncCodeForm, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields };
|
package/dist/index.js
CHANGED
|
@@ -335,15 +335,21 @@ function formatAnswer(field, value) {
|
|
|
335
335
|
|
|
336
336
|
// src/ids.ts
|
|
337
337
|
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
338
|
+
var MASK = 256 - 256 % ALPHABET.length;
|
|
338
339
|
function createId(size = 12) {
|
|
339
|
-
const bytes = new Uint8Array(size);
|
|
340
|
-
if (typeof globalThis.crypto !== "undefined") {
|
|
341
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
342
|
-
} else {
|
|
343
|
-
for (let i = 0; i < size; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
344
|
-
}
|
|
345
340
|
let id = "";
|
|
346
|
-
|
|
341
|
+
while (id.length < size) {
|
|
342
|
+
const bytes = new Uint8Array(size);
|
|
343
|
+
if (typeof globalThis.crypto !== "undefined") {
|
|
344
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
345
|
+
} else {
|
|
346
|
+
for (let i = 0; i < size; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
347
|
+
}
|
|
348
|
+
for (let i = 0; i < size && id.length < size; i++) {
|
|
349
|
+
const b = bytes[i];
|
|
350
|
+
if (b < MASK) id += ALPHABET[b % ALPHABET.length];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
347
353
|
return id;
|
|
348
354
|
}
|
|
349
355
|
|
|
@@ -657,8 +663,11 @@ var FilloClient = class {
|
|
|
657
663
|
}
|
|
658
664
|
}
|
|
659
665
|
/** Current state of an upload session — used to resume after interruption. */
|
|
660
|
-
getUploadSession(sessionId) {
|
|
661
|
-
return this.json(
|
|
666
|
+
getUploadSession(sessionId, token) {
|
|
667
|
+
return this.json(
|
|
668
|
+
`/uploads/${encodeURIComponent(sessionId)}`,
|
|
669
|
+
token ? { headers: { "X-Fillo-Upload-Token": token } } : void 0
|
|
670
|
+
);
|
|
662
671
|
}
|
|
663
672
|
/**
|
|
664
673
|
* Resumable chunked upload. Creates (or resumes) a session, streams the file
|
|
@@ -677,7 +686,7 @@ var FilloClient = class {
|
|
|
677
686
|
const mime = file.type || "application/octet-stream";
|
|
678
687
|
let session;
|
|
679
688
|
if (options.sessionId) {
|
|
680
|
-
session = await this.getUploadSession(options.sessionId);
|
|
689
|
+
session = await this.getUploadSession(options.sessionId, options.uploadToken);
|
|
681
690
|
} else {
|
|
682
691
|
session = await this.json(
|
|
683
692
|
`/forms/${encodeURIComponent(formId)}/uploads`,
|
|
@@ -701,7 +710,7 @@ var FilloClient = class {
|
|
|
701
710
|
}
|
|
702
711
|
const done = await this.json(
|
|
703
712
|
`/uploads/${encodeURIComponent(session.id)}/complete`,
|
|
704
|
-
{ method: "POST" }
|
|
713
|
+
{ method: "POST", headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0 }
|
|
705
714
|
);
|
|
706
715
|
if (!done.file) throw new FilloError("Upload completed but no file was returned");
|
|
707
716
|
return done.file;
|
|
@@ -722,7 +731,8 @@ var FilloClient = class {
|
|
|
722
731
|
method: "PUT",
|
|
723
732
|
headers: {
|
|
724
733
|
"Content-Type": "application/octet-stream",
|
|
725
|
-
"X-Upload-Offset": String(offset)
|
|
734
|
+
"X-Upload-Offset": String(offset),
|
|
735
|
+
...session.token ? { "X-Fillo-Upload-Token": session.token } : {}
|
|
726
736
|
},
|
|
727
737
|
body: chunk,
|
|
728
738
|
signal: options.signal
|
|
@@ -742,7 +752,7 @@ var FilloClient = class {
|
|
|
742
752
|
attempt += 1;
|
|
743
753
|
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
744
754
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
745
|
-
const fresh = await this.getUploadSession(session.id);
|
|
755
|
+
const fresh = await this.getUploadSession(session.id, session.token);
|
|
746
756
|
offset = fresh.uploadedBytes;
|
|
747
757
|
}
|
|
748
758
|
}
|
|
@@ -790,7 +800,7 @@ var FilloClient = class {
|
|
|
790
800
|
attempt += 1;
|
|
791
801
|
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
792
802
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
793
|
-
const fresh = await this.getUploadSession(session.id);
|
|
803
|
+
const fresh = await this.getUploadSession(session.id, session.token);
|
|
794
804
|
offset = fresh.uploadedBytes;
|
|
795
805
|
}
|
|
796
806
|
}
|
|
@@ -829,6 +839,161 @@ function createClient(options) {
|
|
|
829
839
|
return new FilloClient(options);
|
|
830
840
|
}
|
|
831
841
|
|
|
842
|
+
// src/controller.ts
|
|
843
|
+
var EMPTY_PAGE = { id: "empty", blocks: [] };
|
|
844
|
+
function createFormController(options) {
|
|
845
|
+
const { onChange, onSubmitted, onSubmit } = options;
|
|
846
|
+
let form = options.form;
|
|
847
|
+
let formId = options.formId;
|
|
848
|
+
let client = options.client;
|
|
849
|
+
let data = { ...options.initialData ?? {} };
|
|
850
|
+
let errors = {};
|
|
851
|
+
let pageIndex = 0;
|
|
852
|
+
let status = "idle";
|
|
853
|
+
const uploadingFields = /* @__PURE__ */ new Set();
|
|
854
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
855
|
+
const mountedAt = Date.now();
|
|
856
|
+
let sessionId = null;
|
|
857
|
+
let sessionStarted = false;
|
|
858
|
+
function computeSnapshot() {
|
|
859
|
+
const pageCount = form.pages.length;
|
|
860
|
+
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
861
|
+
const page = form.pages[clamped] ?? EMPTY_PAGE;
|
|
862
|
+
return {
|
|
863
|
+
data,
|
|
864
|
+
errors,
|
|
865
|
+
status,
|
|
866
|
+
pageIndex: clamped,
|
|
867
|
+
pageCount,
|
|
868
|
+
page,
|
|
869
|
+
blocks: visibleBlocks(page, data),
|
|
870
|
+
isFirstPage: clamped === 0,
|
|
871
|
+
isLastPage: clamped === pageCount - 1,
|
|
872
|
+
uploading: uploadingFields.size > 0
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
let snapshot = computeSnapshot();
|
|
876
|
+
function notify() {
|
|
877
|
+
snapshot = computeSnapshot();
|
|
878
|
+
for (const listener of listeners) listener();
|
|
879
|
+
}
|
|
880
|
+
function validatePage(index) {
|
|
881
|
+
const target = form.pages[index];
|
|
882
|
+
if (!target) return {};
|
|
883
|
+
const pageErrors = {};
|
|
884
|
+
for (const block of visibleBlocks(target, data)) {
|
|
885
|
+
if (!isField(block)) continue;
|
|
886
|
+
const error = validateField(block, data[block.id]);
|
|
887
|
+
if (error) pageErrors[block.id] = error;
|
|
888
|
+
}
|
|
889
|
+
return pageErrors;
|
|
890
|
+
}
|
|
891
|
+
function setValue(fieldId, value) {
|
|
892
|
+
if (!sessionStarted && client && formId) {
|
|
893
|
+
sessionStarted = true;
|
|
894
|
+
void client.startSession(formId, form.pages.length).then((id) => sessionId = id);
|
|
895
|
+
}
|
|
896
|
+
data = { ...data, [fieldId]: value };
|
|
897
|
+
onChange?.(data);
|
|
898
|
+
if (fieldId in errors) {
|
|
899
|
+
errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
|
|
900
|
+
}
|
|
901
|
+
notify();
|
|
902
|
+
}
|
|
903
|
+
function setUploading(fieldId, busy) {
|
|
904
|
+
if (busy) uploadingFields.add(fieldId);
|
|
905
|
+
else uploadingFields.delete(fieldId);
|
|
906
|
+
notify();
|
|
907
|
+
}
|
|
908
|
+
function next() {
|
|
909
|
+
const pageCount = form.pages.length;
|
|
910
|
+
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
911
|
+
const pageErrors = validatePage(clamped);
|
|
912
|
+
errors = pageErrors;
|
|
913
|
+
if (Object.keys(pageErrors).length > 0) {
|
|
914
|
+
notify();
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
pageIndex = Math.min(clamped + 1, pageCount - 1);
|
|
918
|
+
if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
|
|
919
|
+
notify();
|
|
920
|
+
}
|
|
921
|
+
function back() {
|
|
922
|
+
errors = {};
|
|
923
|
+
pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
|
|
924
|
+
notify();
|
|
925
|
+
}
|
|
926
|
+
async function submit() {
|
|
927
|
+
if (status === "submitting" || status === "submitted") return;
|
|
928
|
+
const result = validateResponse(form, data);
|
|
929
|
+
if (!result.ok) {
|
|
930
|
+
errors = result.errors;
|
|
931
|
+
const firstBad = form.pages.findIndex((p) => p.blocks.some((b) => b.id in result.errors));
|
|
932
|
+
if (firstBad >= 0) pageIndex = firstBad;
|
|
933
|
+
notify();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
status = "submitting";
|
|
937
|
+
notify();
|
|
938
|
+
try {
|
|
939
|
+
if (onSubmit) {
|
|
940
|
+
await onSubmit(result.data);
|
|
941
|
+
status = "submitted";
|
|
942
|
+
notify();
|
|
943
|
+
onSubmitted?.(void 0, result.data);
|
|
944
|
+
} else if (client && formId) {
|
|
945
|
+
const res = await client.submit(formId, result.data, {
|
|
946
|
+
hp: options.getHoneypot?.() ?? "",
|
|
947
|
+
elapsedMs: Date.now() - mountedAt
|
|
948
|
+
});
|
|
949
|
+
if (!res.ok) {
|
|
950
|
+
errors = res.errors ?? {};
|
|
951
|
+
status = "idle";
|
|
952
|
+
notify();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
956
|
+
status = "submitted";
|
|
957
|
+
notify();
|
|
958
|
+
onSubmitted?.(res.responseId, result.data);
|
|
959
|
+
} else {
|
|
960
|
+
status = "submitted";
|
|
961
|
+
notify();
|
|
962
|
+
onSubmitted?.(void 0, result.data);
|
|
963
|
+
}
|
|
964
|
+
} catch (err) {
|
|
965
|
+
status = "idle";
|
|
966
|
+
notify();
|
|
967
|
+
throw err;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
getState: () => snapshot,
|
|
972
|
+
subscribe(listener) {
|
|
973
|
+
listeners.add(listener);
|
|
974
|
+
return () => {
|
|
975
|
+
listeners.delete(listener);
|
|
976
|
+
};
|
|
977
|
+
},
|
|
978
|
+
setValue,
|
|
979
|
+
setUploading,
|
|
980
|
+
next,
|
|
981
|
+
back,
|
|
982
|
+
submit,
|
|
983
|
+
setContext(ctx) {
|
|
984
|
+
if ("formId" in ctx) formId = ctx.formId;
|
|
985
|
+
if ("client" in ctx) client = ctx.client;
|
|
986
|
+
if (ctx.form && ctx.form !== form) {
|
|
987
|
+
form = ctx.form;
|
|
988
|
+
notify();
|
|
989
|
+
}
|
|
990
|
+
},
|
|
991
|
+
destroy() {
|
|
992
|
+
listeners.clear();
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
832
997
|
// src/define.ts
|
|
833
998
|
function defineForm(def) {
|
|
834
999
|
return {
|
|
@@ -958,6 +1123,7 @@ export {
|
|
|
958
1123
|
createBlock,
|
|
959
1124
|
createClient,
|
|
960
1125
|
createEmptyForm,
|
|
1126
|
+
createFormController,
|
|
961
1127
|
createId,
|
|
962
1128
|
defineForm,
|
|
963
1129
|
formatAnswer,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usefillo/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
44
44
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
45
|
-
"typecheck": "tsc --noEmit"
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "node --test test/*.test.mjs"
|
|
46
47
|
}
|
|
47
48
|
}
|