Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions formulus-formplayer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ This file gives AI assistants and developers enough context to work effectively
3. **Custom question types**: Loaded from a **manifest** (source strings) from the RN app, evaluated in a sandbox with `React` and `MaterialUI` on `window`. They use **format** in the schema (e.g. `"format": "signature"`), not only `type`. Contract: `src/types/CustomQuestionTypeContract.ts`.
4. **Design tokens**: Use `@ode/tokens` via `src/theme/tokens-adapter.ts` and the theme in `src/theme/theme.ts`; avoid hardcoding colors/spacing that exist in tokens.
5. **Attachment-backed builtins** (`photo`, `audio`, `video`, `select_file`): Observation JSON stores **basename-only** `filename` plus portable metadata; RN writes files under **`attachments/draft/`** (etc.). Resolve previews with **`getAttachmentUri`** where applicable. **`select_file`** shows the chosen **name** only—no file preview.
6. **Numeric inputs** (`type: integer` / `type: number`): Built-in control is **`NumberStepperRenderer`** (`src/renderers/NumberStepperRenderer.tsx`) backed by **`useNumericDraftInput`** (`src/hooks/useNumericDraftInput.ts`). Policy: **draft text while focused** (`type="text"` + `inputMode` + `enterKeyHint` from `FormContext`); observation JSON stores **JSON numbers only** (parse on commit, never strings); **never clamp** to `minimum`/`maximum` while typing—surface AJV errors instead. Custom numeric question types must follow the same contract (see `CustomQuestionTypeContract.ts`).

## Adding or changing behavior

Expand Down
78 changes: 78 additions & 0 deletions formulus-formplayer/src/hooks/useNumericDraftInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
formatNumericDisplay,
parseNumericDraft,
} from './useNumericDraftInput';

describe('formatNumericDisplay', () => {
it('formats numbers and empty values', () => {
expect(formatNumericDisplay(16)).toBe('16');
expect(formatNumericDisplay(12.5)).toBe('12.5');
expect(formatNumericDisplay(undefined)).toBe('');
expect(formatNumericDisplay(null)).toBe('');
expect(formatNumericDisplay('')).toBe('');
});
});

describe('parseNumericDraft', () => {
describe('integer', () => {
it('parses complete integers without clamping to bounds', () => {
expect(parseNumericDraft('35', 'integer')).toEqual({
kind: 'complete',
value: 35,
});
expect(parseNumericDraft('0', 'integer')).toEqual({
kind: 'complete',
value: 0,
});
expect(parseNumericDraft('-3', 'integer')).toEqual({
kind: 'complete',
value: -3,
});
});

it('returns empty for blank input', () => {
expect(parseNumericDraft('', 'integer')).toEqual({ kind: 'empty' });
expect(parseNumericDraft(' ', 'integer')).toEqual({ kind: 'empty' });
});

it('returns incomplete for partial integer entry', () => {
expect(parseNumericDraft('-', 'integer')).toEqual({ kind: 'incomplete' });
expect(parseNumericDraft('1.', 'integer')).toEqual({
kind: 'incomplete',
});
});

it('commits non-integer decimals as float for AJV to reject', () => {
expect(parseNumericDraft('1.5', 'integer')).toEqual({
kind: 'complete',
value: 1.5,
});
});
});

describe('number', () => {
it('parses decimals', () => {
expect(parseNumericDraft('12.5', 'number')).toEqual({
kind: 'complete',
value: 12.5,
});
expect(parseNumericDraft('.5', 'number')).toEqual({
kind: 'complete',
value: 0.5,
});
});

it('allows partial decimal entry', () => {
expect(parseNumericDraft('1.', 'number')).toEqual({ kind: 'incomplete' });
expect(parseNumericDraft('-', 'number')).toEqual({ kind: 'incomplete' });
});

it('parses integers as numbers', () => {
expect(parseNumericDraft('35', 'number')).toEqual({
kind: 'complete',
value: 35,
});
});
});
});
159 changes: 159 additions & 0 deletions formulus-formplayer/src/hooks/useNumericDraftInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useCallback, useState } from 'react';
import type { InputHTMLAttributes } from 'react';
import type { KeyboardPrimaryEnterKeyHint } from '../utils/keyboardEnterKeyHint';

export type NumericSchemaKind = 'integer' | 'number';

export type ParseNumericResult =
| { kind: 'empty' }
| { kind: 'complete'; value: number }
| { kind: 'incomplete' };

/** Format committed observation data for display when the field is not focused. */
export function formatNumericDisplay(data: unknown): string {
if (data === undefined || data === null || data === '') return '';
if (typeof data === 'number' && !Number.isNaN(data)) return String(data);
return '';
}

/**
* Parse draft text from a numeric input.
* Never clamps to schema bounds — callers validate via AJV.
*/
export function parseNumericDraft(
text: string,
schemaKind: NumericSchemaKind,
): ParseNumericResult {
const trimmed = text.trim();
if (trimmed === '') return { kind: 'empty' };

if (schemaKind === 'integer') {
if (trimmed === '-') return { kind: 'incomplete' };
if (/^-?\d+$/.test(trimmed)) {
return { kind: 'complete', value: parseInt(trimmed, 10) };
}
if (/^-?\d*\.$/.test(trimmed) || trimmed === '.') {
return { kind: 'incomplete' };
}
// Non-integer decimal text (e.g. "1.5") — commit as float so AJV type:integer fails.
const asFloat = parseFloat(trimmed);
if (!Number.isNaN(asFloat) && /^-?(\d+\.\d+|\.\d+)$/.test(trimmed)) {
return { kind: 'complete', value: asFloat };
}
return { kind: 'incomplete' };
}

if (trimmed === '-' || trimmed === '.' || trimmed === '-.') {
return { kind: 'incomplete' };
}
if (/^-?\d+\.$/.test(trimmed)) {
return { kind: 'incomplete' };
}
if (/^-?(\d+\.?\d*|\.\d+)$/.test(trimmed)) {
const value = parseFloat(trimmed);
if (!Number.isNaN(value)) {
return { kind: 'complete', value };
}
}
return { kind: 'incomplete' };
}

export type UseNumericDraftInputOptions = {
data: unknown;
path: string;
handleChange: (path: string, value: unknown) => void;
schemaKind: NumericSchemaKind;
enterKeyHint?: KeyboardPrimaryEnterKeyHint;
enabled?: boolean;
};

export type UseNumericDraftInputResult = {
isFocused: boolean;
displayValue: string;
inputMode: 'numeric' | 'decimal';
onFocus: () => void;
onBlur: () => void;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
/** Sync draft text after a programmatic value change (e.g. stepper +/-). */
syncDraftFromData: (value: unknown) => void;
inputProps: Pick<
InputHTMLAttributes<HTMLInputElement>,
'type' | 'inputMode' | 'enterKeyHint' | 'autoComplete'
>;
};

export function useNumericDraftInput({
data,
path,
handleChange,
schemaKind,
enterKeyHint,
enabled = true,
}: UseNumericDraftInputOptions): UseNumericDraftInputResult {
const [isFocused, setIsFocused] = useState(false);
const [draftText, setDraftText] = useState<string | null>(null);

const commitParsed = useCallback(
(text: string) => {
const parsed = parseNumericDraft(text, schemaKind);
if (parsed.kind === 'empty') {
handleChange(path, undefined);
} else if (parsed.kind === 'complete') {
handleChange(path, parsed.value);
}
},
[handleChange, path, schemaKind],
);

const displayValue =
isFocused && draftText !== null ? draftText : formatNumericDisplay(data);

const onFocus = useCallback(() => {
if (!enabled) return;
setIsFocused(true);
setDraftText(formatNumericDisplay(data));
}, [data, enabled]);

const onBlur = useCallback(() => {
if (draftText !== null) {
commitParsed(draftText);
}
setIsFocused(false);
setDraftText(null);
}, [commitParsed, draftText]);

const onChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const text = event.target.value;
setDraftText(text);
commitParsed(text);
},
[commitParsed],
);

const syncDraftFromData = useCallback(
(value: unknown) => {
const formatted = formatNumericDisplay(value);
if (isFocused) {
setDraftText(formatted);
}
},
[isFocused],
);

return {
isFocused,
displayValue,
inputMode: schemaKind === 'integer' ? 'numeric' : 'decimal',
onFocus,
onBlur,
onChange,
syncDraftFromData,
inputProps: {
type: 'text',
inputMode: schemaKind === 'integer' ? 'numeric' : 'decimal',
...(enterKeyHint ? { enterKeyHint } : {}),
autoComplete: 'off',
},
};
}
Loading
Loading