From 0ff988338f1de10e2c8a4b18f45a1598a8b7666d Mon Sep 17 00:00:00 2001 From: Lin Latt Date: Wed, 1 Jul 2026 16:05:13 -0400 Subject: [PATCH 01/15] Initial commit for 2 new fields + added Open Choice Field, with Radio Field functionality + an additional "other" field that can have custom input. + Added File Field Type (which includes, max file, max file size, accepted file type) + stores as base64. + Remove selection / highlighting from occuring when double clicking on all field Options --- index | 0 .../adapters/src/fhir/fhir-adapter.spec.ts | 17 +- packages/adapters/src/fhir/fhir-adapter.ts | 67 ++- packages/adapters/src/fhir/utils.ts | 12 +- .../lib/components/edit-panel/LogicEditor.tsx | 5 + packages/builder/src/lib/register-defaults.ts | 4 + packages/core/src/index.ts | 2 + .../logic/comprehensive-conditions.spec.ts | 163 ++++++ packages/core/src/lib/logic/conditions.ts | 24 +- packages/core/src/lib/logic/validate.ts | 8 + packages/core/src/lib/registry.spec.ts | 12 +- packages/core/src/lib/registry.ts | 22 + packages/core/src/lib/types.spec.ts | 4 +- packages/core/src/lib/types.ts | 47 ++ packages/fields/src/fields/file/FileField.tsx | 484 ++++++++++++++++++ packages/fields/src/fields/index.ts | 4 + .../src/fields/matrix/MultiMatrixField.tsx | 4 +- .../src/fields/matrix/SingleMatrixField.tsx | 4 +- .../src/fields/selection/CheckField.tsx | 2 +- .../src/fields/selection/OpenChoiceField.tsx | 230 +++++++++ .../src/fields/selection/RadioField.tsx | 2 +- packages/fields/src/fields/selection/index.ts | 1 + packages/fields/src/icons.tsx | 20 + packages/fields/src/index.ts | 2 + .../renderer/src/lib/register-defaults.ts | 4 + 25 files changed, 1122 insertions(+), 22 deletions(-) create mode 100644 index create mode 100644 packages/fields/src/fields/file/FileField.tsx create mode 100644 packages/fields/src/fields/selection/OpenChoiceField.tsx diff --git a/index b/index new file mode 100644 index 0000000..e69de29 diff --git a/packages/adapters/src/fhir/fhir-adapter.spec.ts b/packages/adapters/src/fhir/fhir-adapter.spec.ts index 23d65a0..2eee7c5 100644 --- a/packages/adapters/src/fhir/fhir-adapter.spec.ts +++ b/packages/adapters/src/fhir/fhir-adapter.spec.ts @@ -194,7 +194,22 @@ describe('mapFhirTypeToEsheet', () => { it('maps attachment to diagram by default', () => { const result = mapFhirTypeToEsheet('attachment', makeItem('attachment')); - expect(result.fieldType).toBe('diagram'); + expect(result.fieldType).toBe('file'); + }); + + it('maps open-choice itemControl to openchoice field', () => { + const result = mapFhirTypeToEsheet( + 'choice', + makeItem('choice', { + extension: [ + { + url: FHIR_EXT.ITEM_CONTROL, + valueCodeableConcept: { coding: [{ code: 'open-choice' }] }, + }, + ], + }) + ); + expect(result.fieldType).toBe('openchoice'); }); it('maps attachment with signatureRequired to signature', () => { diff --git a/packages/adapters/src/fhir/fhir-adapter.ts b/packages/adapters/src/fhir/fhir-adapter.ts index 631d156..685383e 100644 --- a/packages/adapters/src/fhir/fhir-adapter.ts +++ b/packages/adapters/src/fhir/fhir-adapter.ts @@ -611,7 +611,13 @@ function extractSingleAnswerValue(answer: FhirResponseAnswer): unknown { if (answer.valueUri !== undefined) return answer.valueUri; if (answer.valueCoding) return answer.valueCoding.code; if (answer.valueQuantity) return answer.valueQuantity.value; - if (answer.valueAttachment) return answer.valueAttachment.data; + if (answer.valueAttachment) + return { + data: answer.valueAttachment.data, + contentType: answer.valueAttachment.contentType, + title: answer.valueAttachment.title, + url: answer.valueAttachment.url, + }; if (answer.valueReference) return answer.valueReference.reference; return undefined; @@ -731,6 +737,47 @@ function convertValueToAnswers( }, ]; + case 'file': { + // value may be a string (base64) or an object with data/dataUrl/contentType/title + if (typeof value === 'string') { + return [ + { + valueAttachment: { + contentType: 'application/octet-stream', + data: value, + }, + }, + ]; + } + + const v = value as { + data?: string; + dataUrl?: string; + contentType?: string; + title?: string; + }; + let dataBase64: string | undefined = undefined; + let contentType = v.contentType ?? 'application/octet-stream'; + if (v.data) dataBase64 = v.data; + else if (v.dataUrl) { + const comma = v.dataUrl.indexOf(','); + dataBase64 = comma >= 0 ? v.dataUrl.slice(comma + 1) : v.dataUrl; + const prefix = v.dataUrl.slice(0, comma); + const m = prefix.match(/data:([^;]+)/); + if (m && m[1]) contentType = m[1]; + } + + return [ + { + valueAttachment: { + contentType, + data: dataBase64, + title: v.title, + }, + }, + ]; + } + default: return [{ valueString: String(value) }]; } @@ -758,15 +805,27 @@ function convertTextAnswer( function createCodingAnswer( field: FieldDefinition, - selectedId: string + selectedIdOrObj: string | { id?: string; value?: string } ): FhirResponseAnswer { const options = 'options' in field ? field.options : undefined; + const selectedId = + typeof selectedIdOrObj === 'string' + ? selectedIdOrObj + : selectedIdOrObj.id ?? String(selectedIdOrObj.value ?? ''); const option = options?.find((o: FieldOption) => o.id === selectedId); return { valueCoding: { - code: option?.value ?? selectedId, - display: option?.text, + code: + option?.value ?? + (typeof selectedIdOrObj === 'string' + ? selectedIdOrObj + : selectedIdOrObj.value ?? selectedId), + display: + option?.text ?? + (typeof selectedIdOrObj === 'object' + ? selectedIdOrObj.value + : undefined), }, }; } diff --git a/packages/adapters/src/fhir/utils.ts b/packages/adapters/src/fhir/utils.ts index eca8b02..5736587 100644 --- a/packages/adapters/src/fhir/utils.ts +++ b/packages/adapters/src/fhir/utils.ts @@ -156,7 +156,7 @@ export function mapFhirTypeToEsheet( if (hasSignatureRequired(item.extension)) { return { fieldType: 'signature' }; } - return { fieldType: 'diagram' }; + return { fieldType: 'file' }; case 'reference': return { fieldType: 'text', subType: 'reference' }; @@ -181,6 +181,10 @@ function mapChoiceType( : { fieldType: 'dropdown' }; } + if (itemControl === 'open-choice') { + return { fieldType: 'openchoice' }; + } + if (itemControl === 'check-box') { return { fieldType: 'check' }; } @@ -251,6 +255,12 @@ export function mapEsheetTypeToFhir( case 'diagram': return { type: 'attachment' }; + case 'file': + return { type: 'attachment' }; + + case 'openchoice': + return { type: 'choice', itemControl: 'open-choice' }; + case 'singlematrix': case 'multimatrix': return { type: 'group' }; diff --git a/packages/builder/src/lib/components/edit-panel/LogicEditor.tsx b/packages/builder/src/lib/components/edit-panel/LogicEditor.tsx index 5b7810e..043bc28 100644 --- a/packages/builder/src/lib/components/edit-panel/LogicEditor.tsx +++ b/packages/builder/src/lib/components/edit-panel/LogicEditor.tsx @@ -1169,6 +1169,11 @@ function getOperatorsForTarget(target: TargetField): ConditionOperator[] { return ['empty', 'notEmpty']; } + // Media answers (file, signature, diagram) — only presence checks are meaningful. + if (answerType === 'media') { + return ['empty', 'notEmpty']; + } + // Single-value selection fields (radio/dropdown/boolean etc.). if (answerType === 'selection') { const ops: ConditionOperator[] = [ diff --git a/packages/builder/src/lib/register-defaults.ts b/packages/builder/src/lib/register-defaults.ts index 0813df7..ad6c336 100644 --- a/packages/builder/src/lib/register-defaults.ts +++ b/packages/builder/src/lib/register-defaults.ts @@ -14,6 +14,7 @@ import { MultiTextField, RadioField, CheckField, + OpenChoiceField, BooleanField, DropdownField, MultiSelectDropdownField, @@ -28,6 +29,7 @@ import { ImageField, HtmlField, DisplayField, + FileField, } from '@esheet/fields'; let defaultFieldComponentsRegistered = false; @@ -41,6 +43,7 @@ export function ensureDefaultFieldComponentsRegistered(): void { multitext: MultiTextField, radio: RadioField, check: CheckField, + openchoice: OpenChoiceField, boolean: BooleanField, dropdown: DropdownField, multiselectdropdown: MultiSelectDropdownField, @@ -53,6 +56,7 @@ export function ensureDefaultFieldComponentsRegistered(): void { signature: SignatureField, diagram: DiagramField, image: ImageField, + file: FileField, html: HtmlField, display: DisplayField, }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 528afa0..4e96f80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,12 +49,14 @@ export { type BooleanFieldDefinition, type DropdownFieldDefinition, type MultiselectDropdownFieldDefinition, + type OpenChoiceFieldDefinition, type RatingFieldDefinition, type RankingFieldDefinition, type SliderFieldDefinition, type SingleMatrixFieldDefinition, type MultiMatrixFieldDefinition, type ImageFieldDefinition, + type FileFieldDefinition, type HtmlFieldDefinition, type SignatureFieldDefinition, type DiagramFieldDefinition, diff --git a/packages/core/src/lib/logic/comprehensive-conditions.spec.ts b/packages/core/src/lib/logic/comprehensive-conditions.spec.ts index 8fa6df2..758309b 100644 --- a/packages/core/src/lib/logic/comprehensive-conditions.spec.ts +++ b/packages/core/src/lib/logic/comprehensive-conditions.spec.ts @@ -2206,4 +2206,167 @@ describe('comprehensive conditions integration', () => { ).toBe(false); }); }); + + // --------------------------------------------------------------------------- + // § New field types — openchoice and file + // --------------------------------------------------------------------------- + + describe('openchoice field conditions', () => { + const openChoiceDef: FieldDefinition = { + id: 'other-1', + fieldType: 'openchoice', + question: 'Pick one or other', + options: [ + { id: 'other-1', value: 'Alpha' }, + { id: 'other-2', value: 'Beta' }, + ], + }; + + it('equals — matches predefined option id', () => { + const driver: FieldDefinition = { ...openChoiceDef, id: 'other-3' }; + const target: FieldDefinition = { + id: 'other-4', + fieldType: 'text', + question: 'Target', + rules: [fieldRule('visible', 'other-3', 'equals', 'other-1')], + }; + const normalized = normalizeDefinition([driver, target]); + const respMatch: FieldResponse = { selected: sel('other-1', 'Alpha') }; + expect( + resolveEffect('visible', target, normalized, { 'other-3': respMatch }) + ).toBe(true); + + const respNoMatch: FieldResponse = { selected: sel('other-2', 'Beta') }; + expect( + resolveEffect('visible', target, normalized, { 'other-3': respNoMatch }) + ).toBe(false); + }); + + it('equals — matches __other__ when user types custom text', () => { + const { normalized, field } = setup(openChoiceDef); + field.rules = [fieldRule('visible', 'other-1', 'equals', '__other__')]; + const resp: FieldResponse = { + selected: { id: '__other__', value: 'custom text' }, + }; + expect(resolveEffect('visible', field, normalized, { 'other-1': resp })).toBe( + true + ); + }); + + it('notEquals — does not match different option', () => { + const { normalized, field } = setup(openChoiceDef); + field.rules = [fieldRule('visible', 'other-1', 'notEquals', 'other-1')]; + const resp: FieldResponse = { selected: sel('other-2', 'Beta') }; + expect(resolveEffect('visible', field, normalized, { 'other-1': resp })).toBe( + true + ); + }); + + it('empty — true when nothing selected', () => { + const { normalized, field } = setup(openChoiceDef); + field.rules = [fieldRule('visible', 'other-1', 'empty')]; + expect(resolveEffect('visible', field, normalized, {})).toBe(true); + }); + + it('notEmpty — true when option is selected', () => { + const { normalized, field } = setup(openChoiceDef); + field.rules = [fieldRule('visible', 'other-1', 'notEmpty')]; + const resp: FieldResponse = { selected: sel('other-1', 'Alpha') }; + expect(resolveEffect('visible', field, normalized, { 'other-1': resp })).toBe( + true + ); + }); + + it('expression — returns user-typed text for __other__', () => { + const driver: FieldDefinition = { + id: 'other-5', + fieldType: 'openchoice', + question: 'Source', + options: [{ id: 'other-6', value: 'X' }], + }; + const target: FieldDefinition = { + id: 'other-7', + fieldType: 'text', + question: 'Target', + rules: [exprRule('visible', "{other-5} == 'custom text'")], + }; + const normalized = normalizeDefinition([driver, target]); + const resp: FieldResponse = { + selected: { id: '__other__', value: 'custom text' }, + }; + expect( + resolveEffect('visible', target, normalized, { 'other-5': resp }) + ).toBe(true); + }); + }); + + describe('file field conditions', () => { + const fileDef: FieldDefinition = { + id: 'other-8', + fieldType: 'file', + question: 'Upload a file', + }; + + it('empty — true when no fileData', () => { + const { normalized, field } = setup(fileDef); + field.rules = [fieldRule('visible', 'other-8', 'empty')]; + expect(resolveEffect('visible', field, normalized, {})).toBe(true); + }); + + it('notEmpty — true when fileData is present', () => { + const { normalized, field } = setup(fileDef); + field.rules = [fieldRule('visible', 'other-8', 'notEmpty')]; + const resp: FieldResponse = { + fileData: { contentType: 'application/pdf', title: 'doc.pdf' }, + }; + expect(resolveEffect('visible', field, normalized, { 'other-8': resp })).toBe( + true + ); + }); + + it('notEmpty — true when fileData is an array with entries', () => { + const { normalized, field } = setup(fileDef); + field.rules = [fieldRule('visible', 'other-8', 'notEmpty')]; + const resp: FieldResponse = { + fileData: [ + { contentType: 'image/png', title: 'a.png' }, + { contentType: 'image/png', title: 'b.png' }, + ], + }; + expect(resolveEffect('visible', field, normalized, { 'other-8': resp })).toBe( + true + ); + }); + + it('expression — file count used for numeric comparison', () => { + const driver: FieldDefinition = { + id: 'other-9', + fieldType: 'file', + question: 'Source files', + }; + const target: FieldDefinition = { + id: 'other-10', + fieldType: 'text', + question: 'Target', + rules: [exprRule('visible', '{other-9} >= 2')], + }; + const normalized = normalizeDefinition([driver, target]); + const resp: FieldResponse = { + fileData: [ + { contentType: 'image/png', title: 'a.png' }, + { contentType: 'image/png', title: 'b.png' }, + ], + }; + expect( + resolveEffect('visible', target, normalized, { 'other-9': resp }) + ).toBe(true); + + const singleResp: FieldResponse = { + fileData: { contentType: 'image/png', title: 'a.png' }, + }; + expect( + resolveEffect('visible', target, normalized, { 'other-9': singleResp }) + ).toBe(false); + }); + }); }); diff --git a/packages/core/src/lib/logic/conditions.ts b/packages/core/src/lib/logic/conditions.ts index 436b211..bba6d71 100644 --- a/packages/core/src/lib/logic/conditions.ts +++ b/packages/core/src/lib/logic/conditions.ts @@ -936,7 +936,8 @@ function getExpressionFieldValue( definition.fieldType === 'dropdown' || definition.fieldType === 'boolean' || definition.fieldType === 'slider' || - definition.fieldType === 'rating' + definition.fieldType === 'rating' || + definition.fieldType === 'openchoice' ) { const selected = response.selected; if ( @@ -948,13 +949,22 @@ function getExpressionFieldValue( const selectedId = (selected as SelectedOption).id; const opt = definition.options?.find((o) => o.id === selectedId); if (opt?.score != null) return opt.score; - const raw = opt?.value ?? selectedId; + // For openchoice __other__, prefer the stored value (user-typed text) + // over the option lookup since the definition won't have that entry. + const raw = opt?.value ?? (selected as SelectedOption).value ?? selectedId; const parsed = parseFloat(raw); return Number.isNaN(parsed) ? raw : parsed; } return definition.fieldType === 'rating' ? 0 : ''; } + if (definition.fieldType === 'file') { + const fd = response.fileData; + if (!fd) return 0; + const files = Array.isArray(fd) ? fd : [fd]; + return files.length; + } + if ( definition.fieldType === 'check' || definition.fieldType === 'multiselectdropdown' @@ -1010,7 +1020,8 @@ function getActualValue( case 'dropdown': case 'boolean': case 'slider': - case 'rating': { + case 'rating': + case 'openchoice': { const sel = response.selected; if ( sel != null && @@ -1041,6 +1052,13 @@ function getActualValue( } return {}; + case 'file': { + const fd = response.fileData; + if (!fd) return null; + const files = Array.isArray(fd) ? fd : [fd]; + return files.length === 0 ? null : files.map((f) => f.title ?? f.url ?? ''); + } + default: return null; } diff --git a/packages/core/src/lib/logic/validate.ts b/packages/core/src/lib/logic/validate.ts index 802c66c..6a27071 100644 --- a/packages/core/src/lib/logic/validate.ts +++ b/packages/core/src/lib/logic/validate.ts @@ -199,6 +199,14 @@ function isResponseEmpty(response: FieldResponse | undefined): boolean { if (values.some((v) => v.trim() !== '')) return false; } + // fileData — single or multiple file attachments + if (response.fileData != null) { + const files = Array.isArray(response.fileData) + ? response.fileData + : [response.fileData]; + if (files.length > 0) return false; + } + // signatureData if (response.signatureData && response.signatureData.trim() !== '') return false; diff --git a/packages/core/src/lib/registry.spec.ts b/packages/core/src/lib/registry.spec.ts index ec496e8..523c633 100644 --- a/packages/core/src/lib/registry.spec.ts +++ b/packages/core/src/lib/registry.spec.ts @@ -12,8 +12,8 @@ describe('field type registry', () => { resetFieldTypeRegistry(); }); - it('should have all 19 built-in types registered by default', () => { - expect(getRegisteredFieldTypes()).toHaveLength(19); + it('should have all 21 built-in types registered by default', () => { + expect(getRegisteredFieldTypes()).toHaveLength(21); for (const ft of FIELD_TYPES) { expect(getFieldTypeMeta(ft)).toBeDefined(); } @@ -35,7 +35,7 @@ describe('field type registry', () => { expect(getFieldTypeMeta('vitals')).toBeDefined(); expect(getFieldTypeMeta('vitals')!.label).toBe('Vitals Field'); - expect(getRegisteredFieldTypes()).toHaveLength(20); + expect(getRegisteredFieldTypes()).toHaveLength(22); }); it('should allow overriding a built-in field type', () => { @@ -49,7 +49,7 @@ describe('field type registry', () => { }); expect(getFieldTypeMeta('text')!.label).toBe('Custom Text'); - expect(getRegisteredFieldTypes()).toHaveLength(19); + expect(getRegisteredFieldTypes()).toHaveLength(21); }); it('should reset to defaults', () => { @@ -61,10 +61,10 @@ describe('field type registry', () => { hasMatrix: false, defaultProps: {}, }); - expect(getRegisteredFieldTypes()).toHaveLength(20); + expect(getRegisteredFieldTypes()).toHaveLength(22); resetFieldTypeRegistry(); - expect(getRegisteredFieldTypes()).toHaveLength(19); + expect(getRegisteredFieldTypes()).toHaveLength(21); expect(getFieldTypeMeta('custom')).toBeUndefined(); }); diff --git a/packages/core/src/lib/registry.ts b/packages/core/src/lib/registry.ts index 75bac80..a794a91 100644 --- a/packages/core/src/lib/registry.ts +++ b/packages/core/src/lib/registry.ts @@ -149,6 +149,19 @@ const BUILT_IN_FIELD_TYPES: Record = { }, defaultOptionCount: 3, }, + openchoice: { + label: 'Open Choice', + category: 'selection', + answerType: 'selection', + hasOptions: true, + hasMatrix: false, + defaultProps: {}, + placeholder: { + question: 'Enter your question...', + options: 'Enter option text...', + }, + defaultOptionCount: 3, + }, rating: { label: 'Rating', category: 'rating', @@ -255,6 +268,15 @@ const BUILT_IN_FIELD_TYPES: Record = { pad: 'Draw on the diagram', }, }, + file: { + label: 'File', + category: 'rich', + answerType: 'media', + hasOptions: false, + hasMatrix: false, + defaultProps: {}, + placeholder: { question: 'Upload a file' }, + }, display: { label: 'Display', category: 'rich', diff --git a/packages/core/src/lib/types.spec.ts b/packages/core/src/lib/types.spec.ts index f298277..c8612ab 100644 --- a/packages/core/src/lib/types.spec.ts +++ b/packages/core/src/lib/types.spec.ts @@ -15,7 +15,9 @@ describe('schema types', () => { expect(FIELD_TYPES).toContain('text'); expect(FIELD_TYPES).toContain('section'); expect(FIELD_TYPES).toContain('display'); - expect(FIELD_TYPES).toHaveLength(19); + expect(FIELD_TYPES).toContain('file'); + expect(FIELD_TYPES).toContain('openchoice'); + expect(FIELD_TYPES).toHaveLength(21); }); it('should allow constructing a valid FormDefinition', () => { diff --git a/packages/core/src/lib/types.ts b/packages/core/src/lib/types.ts index 5443065..a460d6e 100644 --- a/packages/core/src/lib/types.ts +++ b/packages/core/src/lib/types.ts @@ -23,6 +23,8 @@ export const FIELD_TYPES = [ 'html', 'signature', 'diagram', + 'file', + 'openchoice', 'display', 'section', ] as const; @@ -397,6 +399,26 @@ export interface DiagramFieldDefinition extends BaseFieldDefinition { padPlaceholder?: string; } +export interface FileFieldDefinition extends BaseFieldDefinition { + fieldType: 'file'; + /** Accept string for file input (MIME types or extensions), e.g. "image/*,.pdf" */ + accept?: string; + /** Maximum allowed file size in bytes (optional). */ + maxFileSize?: number; + /** Maximum number of files allowed to upload (optional). Default: 1. */ + maxFiles?: number; +} + +export interface OpenChoiceFieldDefinition extends BaseFieldDefinition { + fieldType: 'openchoice'; + /** Predefined selectable options. */ + options?: FieldOption[]; + /** Maximum number of custom user-added options allowed at runtime (optional). */ + maxCustomOptions?: number; + /** Label for the "Other, please specify" option (optional). */ + otherLabel?: string; +} + export interface DisplayFieldDefinition { id: string; fieldType: 'display'; @@ -440,6 +462,7 @@ export type FieldDefinition = | BooleanFieldDefinition | DropdownFieldDefinition | MultiselectDropdownFieldDefinition + | OpenChoiceFieldDefinition // Rating | RatingFieldDefinition | RankingFieldDefinition @@ -449,6 +472,7 @@ export type FieldDefinition = | MultiMatrixFieldDefinition // Rich | ImageFieldDefinition + | FileFieldDefinition | HtmlFieldDefinition | SignatureFieldDefinition | DiagramFieldDefinition @@ -464,6 +488,7 @@ export type OptionBearingFieldDefinition = | DropdownFieldDefinition | MultiselectDropdownFieldDefinition | RatingFieldDefinition + | OpenChoiceFieldDefinition | RankingFieldDefinition | SliderFieldDefinition; @@ -511,6 +536,8 @@ const FIELD_TYPE_PROPERTIES: Record = { html: ['htmlContent', 'iframeHeight'], signature: ['padPlaceholder'], diagram: ['imageUri', 'padPlaceholder'], + file: ['accept', 'maxFileSize'], + openchoice: ['options', 'maxCustomOptions'], display: ['content'], // Organization category section: ['title', 'fields'], @@ -751,6 +778,20 @@ const diagramFieldSchema = z.strictObject({ padPlaceholder: z.optional(z.string()), }); +const fileFieldSchema = z.strictObject({ + ...baseFieldProps, + fieldType: z.literal('file'), + accept: z.optional(z.string()), + maxFileSize: z.optional(z.number()), +}); + +const openChoiceFieldSchema = z.strictObject({ + ...baseFieldProps, + fieldType: z.literal('openchoice'), + options: z.optional(z.array(fieldOptionSchema)), + maxCustomOptions: z.optional(z.number()), +}); + const displayBaseFieldProps = { id: z.string(), rules: z.optional(z.array(conditionalRuleSchema)), @@ -798,9 +839,11 @@ const _coreFieldSchema = z.discriminatedUnion('fieldType', [ multiMatrixFieldSchema, // Rich imageFieldSchema, + fileFieldSchema, htmlFieldSchema, signatureFieldSchema, diagramFieldSchema, + openChoiceFieldSchema, displayFieldSchema, // Organization sectionFieldSchema, @@ -901,6 +944,8 @@ export interface FieldResponse { markupData?: string; /** Base64 diagram image (diagram field). */ markupImage?: string; + /** File/attachment(s) uploaded by user (file field). Supports single or multiple files. */ + fileData?: AttachmentAnswer | AttachmentAnswer[]; /** Set to true when the response was filled programmatically by an AI agent. */ _ai?: boolean; } @@ -1052,6 +1097,8 @@ export type AttachmentAnswer = { dataUrl?: string; url?: string; title?: string; + /** File size in bytes (optional). */ + size?: number; }; /** All possible answer value shapes in a submission payload. */ diff --git a/packages/fields/src/fields/file/FileField.tsx b/packages/fields/src/fields/file/FileField.tsx new file mode 100644 index 0000000..145ac18 --- /dev/null +++ b/packages/fields/src/fields/file/FileField.tsx @@ -0,0 +1,484 @@ +import React, { useCallback, useState } from 'react'; +import type { + FieldComponentProps, + AttachmentAnswer, + FileFieldDefinition, +} from '@esheet/core'; +import { TrashIcon, UploadIcon } from '../../icons.js'; + +const PREDEFINED_FILE_TYPES = [ + { label: 'JPEG', value: 'image/jpeg', accept: '.jpg,.jpeg' }, + { label: 'PNG', value: 'image/png', accept: '.png' }, + { label: 'GIF', value: 'image/gif', accept: '.gif' }, + { label: 'WebP', value: 'image/webp', accept: '.webp' }, + { label: 'SVG', value: 'image/svg+xml', accept: '.svg' }, + { label: 'PDF', value: 'application/pdf', accept: '.pdf' }, + { label: 'Word (.doc)', value: 'application/msword', accept: '.doc' }, + { + label: 'Word (.docx)', + value: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + accept: '.docx', + }, + { label: 'Excel (.xls)', value: 'application/vnd.ms-excel', accept: '.xls' }, + { + label: 'Excel (.xlsx)', + value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + accept: '.xlsx', + }, + { label: 'CSV', value: 'text/csv', accept: '.csv' }, + { label: 'Text', value: 'text/plain', accept: '.txt' }, + { label: 'ZIP', value: 'application/zip', accept: '.zip' }, + { label: 'MP4', value: 'video/mp4', accept: '.mp4' }, + { label: 'WebM', value: 'video/webm', accept: '.webm' }, + { label: 'MP3', value: 'audio/mpeg', accept: '.mp3' }, + { label: 'WAV', value: 'audio/wav', accept: '.wav' }, +] as const; + +const getAcceptString = (selectedTypes: string[]): string => { + const accepts = selectedTypes + .map((val) => PREDEFINED_FILE_TYPES.find((ft) => ft.value === val)?.accept) + .filter(Boolean) as string[]; + return accepts.join(','); +}; + +const getSelectedTypes = (acceptString?: string): string[] => { + if (!acceptString) return []; + const parts = new Set(acceptString.split(',').map((s) => s.trim())); + return PREDEFINED_FILE_TYPES.filter((ft) => { + const ftParts = ft.accept.split(',').map((s) => s.trim()); + return ftParts.some((p) => parts.has(p)) || parts.has(ft.value); + }).map((ft) => ft.value); +}; + +const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +}; + +const SIZE_UNITS = { + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, +} as const; + +type SizeUnit = keyof typeof SIZE_UNITS; + +const getBestUnit = (bytes: number): SizeUnit => { + if (bytes >= SIZE_UNITS.GB) return 'GB'; + if (bytes >= SIZE_UNITS.MB) return 'MB'; + return 'KB'; +}; + +const convertBytesToUnit = (bytes: number, unit: SizeUnit): number => { + return Math.round((bytes / SIZE_UNITS[unit]) * 100) / 100; +}; + +const convertUnitToBytes = (value: number, unit: SizeUnit): number => { + return Math.round(value * SIZE_UNITS[unit]); +}; + +export const FileField = React.memo(function FileField({ + field, + form, + isPreview, + isEnabled, + isRequired, + isSoftRequired, + isReadOnly, + response, + onUpdate, + onResponse, +}: FieldComponentProps) { + const def = field.definition as FileFieldDefinition & { question?: string }; + const instanceId = form.getState().instanceId; + const maxFiles = def.maxFiles ?? 1; + const [isDragActive, setIsDragActive] = useState(false); + const [errorMsg, setErrorMsg] = useState(''); + const [sizeUnit, setSizeUnit] = useState( + def.maxFileSize ? getBestUnit(def.maxFileSize) : 'KB' + ); + const [typeSearch, setTypeSearch] = useState(''); + + const fileDataArr = React.useMemo(() => { + if (!response?.fileData) return []; + return Array.isArray(response.fileData) + ? response.fileData + : [response.fileData]; + }, [response?.fileData]); + + const handleFiles = useCallback( + (files: FileList | null) => { + if (!files || files.length === 0) return; + + setErrorMsg(''); + const availableSlots = maxFiles - fileDataArr.length; + const filesToProcess = Array.from(files).slice(0, availableSlots); + const maxFileSize = def.maxFileSize; + + // Validate file types against accept filter + const acceptParts = def.accept + ? def.accept.split(',').map((s) => s.trim()) + : []; + const rejectedTypes: string[] = []; + const typeAccepted: File[] = []; + + filesToProcess.forEach((file) => { + const ok = + acceptParts.length === 0 || + acceptParts.some((part) => { + if (part.startsWith('.')) + return file.name.toLowerCase().endsWith(part.toLowerCase()); + if (part.endsWith('/*')) + return file.type.startsWith(part.slice(0, -1)); + return file.type === part; + }); + if (ok) typeAccepted.push(file); + else rejectedTypes.push(file.name); + }); + + if (rejectedTypes.length > 0) { + setErrorMsg(`File type not accepted: ${rejectedTypes.join(', ')}`); + if (typeAccepted.length === 0) return; + } + + // Validate file sizes + const oversizedFiles: string[] = []; + const validFiles: File[] = []; + + typeAccepted.forEach((file) => { + if (maxFileSize && file.size > maxFileSize) { + oversizedFiles.push(`${file.name} (${formatFileSize(file.size)})`); + } else { + validFiles.push(file); + } + }); + + if (oversizedFiles.length > 0) { + setErrorMsg( + `File(s) exceed max size of ${formatFileSize( + maxFileSize! + )}: ${oversizedFiles.join(', ')}` + ); + if (validFiles.length === 0) return; + } + + let processed = 0; + const newFiles: AttachmentAnswer[] = []; + + validFiles.forEach((file) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const ans: AttachmentAnswer = { + contentType: file.type || 'application/octet-stream', + dataUrl: result, + title: file.name, + size: file.size, + }; + newFiles.push(ans); + processed++; + + if (processed === validFiles.length) { + const updated = [...fileDataArr, ...newFiles]; + onResponse({ fileData: maxFiles === 1 ? updated[0] : updated }); + } + }; + reader.readAsDataURL(file); + }); + }, + [fileDataArr, maxFiles, def.maxFileSize, def.accept, onResponse] + ); + + const handleRemoveFile = useCallback( + (index: number) => { + const updated = fileDataArr.filter((_, i) => i !== index); + onResponse({ + fileData: + updated.length === 0 + ? undefined + : maxFiles === 1 + ? updated[0] + : updated, + }); + }, + [fileDataArr, maxFiles, onResponse] + ); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragActive(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + if (isEnabled && !isReadOnly) { + handleFiles(e.dataTransfer.files); + } + }, + [isEnabled, isReadOnly, handleFiles] + ); + + if (isPreview) { + const canAddMore = fileDataArr.length < maxFiles; + + return ( +
+
+ {def.question || 'Upload file'} + {(isRequired || isSoftRequired) && ( + + * + + )} + {maxFiles > 1 && ( + + ({fileDataArr.length}/{maxFiles}) + + )} +
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {fileDataArr.length > 0 && ( +
+ {fileDataArr.map((file, index) => ( +
+
+
+ {file.title || 'Unnamed file'} +
+
+ {file.contentType} + {file.size && } + {file.size && {formatFileSize(file.size)}} +
+
+ {!isReadOnly && isEnabled && ( + + )} +
+ ))} +
+ )} + + {canAddMore && ( +
+ 1} + onClick={(e) => { + (e.target as HTMLInputElement).value = ''; + }} + onChange={(e) => handleFiles(e.target.files)} + disabled={!isEnabled || isReadOnly} + className="ms:hidden" + /> + +
+ )} +
+ ); + } + + // Builder mode + return ( +
+
+ + onUpdate({ question: e.target.value })} + placeholder="Enter question" + className="ms:px-3 ms:py-2 ms:h-10 ms:w-full ms:border ms:border-msborder ms:bg-mssurface ms:text-mstext ms:rounded-lg" + /> +
+ +
+ + setTypeSearch(e.target.value)} + className="ms:px-3 ms:py-2 ms:h-10 ms:w-full ms:border ms:border-msborder ms:bg-mssurface ms:text-mstext ms:rounded-lg ms:mb-2" + /> +
+ {PREDEFINED_FILE_TYPES.filter((ft) => + ft.label.toLowerCase().includes(typeSearch.toLowerCase()) + ).map((fileType) => { + const selectedTypes = getSelectedTypes(def.accept); + const isSelected = selectedTypes.includes(fileType.value); + return ( + + ); + })} +
+
+ +
+ +
+ { + const val = e.target.value ? Number(e.target.value) : undefined; + onUpdate({ + maxFileSize: val + ? convertUnitToBytes(val, sizeUnit) + : undefined, + }); + }} + placeholder="Optional" + className="ms:px-3 ms:py-2 ms:h-10 ms:flex-1 ms:border ms:border-msborder ms:bg-mssurface ms:text-mstext ms:rounded-lg" + /> + +
+
+ +
+ + + onUpdate({ + maxFiles: e.target.value ? Number(e.target.value) : undefined, + }) + } + onBlur={(e) => { + const val = e.target.value ? Number(e.target.value) : 0; + if (val < 1) { + onUpdate({ maxFiles: 1 }); + } + }} + placeholder="1" + className="ms:px-3 ms:py-2 ms:h-10 ms:w-full ms:border ms:border-msborder ms:bg-mssurface ms:text-mstext ms:rounded-lg" + /> +
+
+ ); +}); + +export default FileField; diff --git a/packages/fields/src/fields/index.ts b/packages/fields/src/fields/index.ts index ec456eb..f6f3521 100644 --- a/packages/fields/src/fields/index.ts +++ b/packages/fields/src/fields/index.ts @@ -5,6 +5,7 @@ export { TextField, LongTextField, MultiTextField } from './text/index.js'; export { RadioField, CheckField, + OpenChoiceField, BooleanField, DropdownField, MultiSelectDropdownField, @@ -28,6 +29,9 @@ export { ImageField, SignatureField, } from './rich/index.js'; + +// File field (not part of rich content grouping) +export { FileField } from './file/FileField.js'; export type { DrawingData, DrawingPadConfig, diff --git a/packages/fields/src/fields/matrix/MultiMatrixField.tsx b/packages/fields/src/fields/matrix/MultiMatrixField.tsx index dea2e8b..ea955c3 100644 --- a/packages/fields/src/fields/matrix/MultiMatrixField.tsx +++ b/packages/fields/src/fields/matrix/MultiMatrixField.tsx @@ -85,7 +85,7 @@ export const MultiMatrixField = React.memo(function MultiMatrixField({