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
314 changes: 314 additions & 0 deletions formulus-formplayer/src/AdateQuestionRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import React, { useState, useCallback, useEffect } from 'react';
import { withJsonFormsControlProps } from '@jsonforms/react';
import { ControlProps, rankWith, schemaTypeIs, and, schemaMatches } from '@jsonforms/core';
import { TextField, Box, Typography, Alert, Button } from '@mui/material';
import { CalendarToday } from '@mui/icons-material';
import QuestionShell from './QuestionShell';
import {
adateToStorageFormat,
storageFormatToAdate,
displayAdate,
todayAdate,
yesterdayAdate,
} from './adateUtils';

// Tester function - determines when this renderer should be used
export const adateQuestionTester = rankWith(
5, // Priority (higher = more specific)
and(
schemaTypeIs('string'), // Expects string data type
schemaMatches((schema) => schema.format === 'adate'), // Matches format
),
);

const AdateQuestionRenderer: React.FC<ControlProps> = ({
data,
handleChange,
path,
errors,
schema,
uischema,
enabled = true,
visible = true,
}) => {
// State for date components
const [day, setDay] = useState<string>('');
const [month, setMonth] = useState<string>('');
const [year, setYear] = useState<string>('');
const [dayUnknown, setDayUnknown] = useState<boolean>(false);
const [monthUnknown, setMonthUnknown] = useState<boolean>(false);
const [yearUnknown, setYearUnknown] = useState<boolean>(false);

// Initialize from data
useEffect(() => {
if (data && typeof data === 'string') {
// Convert storage format to adate format for editing
const adateFormat = storageFormatToAdate(data);
if (adateFormat) {
const upperAdate = adateFormat.toUpperCase();
const dayMatch = upperAdate.match(/D:(\d+|NS)/);
const monthMatch = upperAdate.match(/M:(\d+|NS)/);
const yearMatch = upperAdate.match(/Y:(\d+|NS)/);

if (dayMatch) {
setDayUnknown(dayMatch[1] === 'NS');
setDay(dayMatch[1] === 'NS' ? '' : dayMatch[1]);
}
if (monthMatch) {
setMonthUnknown(monthMatch[1] === 'NS');
setMonth(monthMatch[1] === 'NS' ? '' : monthMatch[1]);
}
if (yearMatch) {
setYearUnknown(yearMatch[1] === 'NS');
setYear(yearMatch[1] === 'NS' ? '' : yearMatch[1]);
}
}
} else {
// Initialize empty
setDay('');
setMonth('');
setYear('');
setDayUnknown(false);
setMonthUnknown(false);
setYearUnknown(false);
}
}, [data]);

// Update form data when components change
const updateFormData = useCallback(() => {
const dayValue = dayUnknown ? 'NS' : day;
const monthValue = monthUnknown ? 'NS' : month;
const yearValue = yearUnknown ? 'NS' : year;

// Build adate string
const adateString = `D:${dayValue},M:${monthValue},Y:${yearValue}`;

// Convert to storage format and save
const storageFormat = adateToStorageFormat(adateString);
if (storageFormat) {
handleChange(path, storageFormat);
} else {
handleChange(path, '');
}
}, [day, month, year, dayUnknown, monthUnknown, yearUnknown, handleChange, path]);

// Handle day change
const handleDayChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
if (
value === '' ||
(/^\d+$/.test(value) && parseInt(value, 10) >= 1 && parseInt(value, 10) <= 31)
) {
setDay(value);
updateFormData();
}
},
[updateFormData],
);

// Handle month change
const handleMonthChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
if (
value === '' ||
(/^\d+$/.test(value) && parseInt(value, 10) >= 1 && parseInt(value, 10) <= 12)
) {
setMonth(value);
updateFormData();
}
},
[updateFormData],
);

// Handle year change
const handleYearChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
if (value === '' || /^\d{4}$/.test(value)) {
setYear(value);
updateFormData();
}
},
[updateFormData],
);

// Handle quick date buttons
const handleToday = useCallback(() => {
const today = todayAdate();
const upperAdate = today.toUpperCase();
const dayMatch = upperAdate.match(/D:(\d+)/);
const monthMatch = upperAdate.match(/M:(\d+)/);
const yearMatch = upperAdate.match(/Y:(\d+)/);

if (dayMatch) setDay(dayMatch[1]);
if (monthMatch) setMonth(monthMatch[1]);
if (yearMatch) setYear(yearMatch[1]);
setDayUnknown(false);
setMonthUnknown(false);
setYearUnknown(false);
updateFormData();
}, [updateFormData]);

const handleYesterday = useCallback(() => {
const yesterday = yesterdayAdate();
const upperAdate = yesterday.toUpperCase();
const dayMatch = upperAdate.match(/D:(\d+)/);
const monthMatch = upperAdate.match(/M:(\d+)/);
const yearMatch = upperAdate.match(/Y:(\d+)/);

if (dayMatch) setDay(dayMatch[1]);
if (monthMatch) setMonth(monthMatch[1]);
if (yearMatch) setYear(yearMatch[1]);
setDayUnknown(false);
setMonthUnknown(false);
setYearUnknown(false);
updateFormData();
}, [updateFormData]);

// Don't render if not visible
if (!visible) {
return null;
}

const hasError = errors && (Array.isArray(errors) ? errors.length > 0 : errors.length > 0);
const displayValue = data ? displayAdate(data) : '';
const errorMessage = hasError
? Array.isArray(errors)
? errors.join(', ')
: String(errors)
: undefined;

return (
<QuestionShell
title={schema.title || 'Approximate Date'}
description={schema.description}
required={schema.required?.includes(path.split('.').pop() || '')}
error={errorMessage}
>
<Box sx={{ mb: 2 }}>
{/* Quick date buttons */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="outlined"
size="small"
startIcon={<CalendarToday />}
onClick={handleToday}
disabled={!enabled}
>
Today
</Button>
<Button variant="outlined" size="small" onClick={handleYesterday} disabled={!enabled}>
Yesterday
</Button>
</Box>

{/* Date input fields */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start', flexWrap: 'wrap' }}>
{/* Day */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 120 }}>
<TextField
label="Day"
value={day}
onChange={handleDayChange}
disabled={!enabled || dayUnknown}
type="number"
inputProps={{ min: 1, max: 31 }}
size="small"
fullWidth
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<input
type="checkbox"
checked={dayUnknown}
onChange={(e) => {
setDayUnknown(e.target.checked);
if (e.target.checked) setDay('');
updateFormData();
}}
disabled={!enabled}
style={{ cursor: enabled ? 'pointer' : 'not-allowed' }}
/>
<Typography variant="caption">Unknown</Typography>
</Box>
</Box>

{/* Month */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 120 }}>
<TextField
label="Month"
value={month}
onChange={handleMonthChange}
disabled={!enabled || monthUnknown}
type="number"
inputProps={{ min: 1, max: 12 }}
size="small"
fullWidth
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<input
type="checkbox"
checked={monthUnknown}
onChange={(e) => {
setMonthUnknown(e.target.checked);
if (e.target.checked) setMonth('');
updateFormData();
}}
disabled={!enabled}
style={{ cursor: enabled ? 'pointer' : 'not-allowed' }}
/>
<Typography variant="caption">Unknown</Typography>
</Box>
</Box>

{/* Year */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 120 }}>
<TextField
label="Year"
value={year}
onChange={handleYearChange}
disabled={!enabled || yearUnknown}
type="number"
inputProps={{ min: 1000, max: 9999 }}
size="small"
fullWidth
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<input
type="checkbox"
checked={yearUnknown}
onChange={(e) => {
setYearUnknown(e.target.checked);
if (e.target.checked) setYear('');
updateFormData();
}}
disabled={!enabled}
style={{ cursor: enabled ? 'pointer' : 'not-allowed' }}
/>
<Typography variant="caption">Unknown</Typography>
</Box>
</Box>
</Box>

{/* Display current value */}
{displayValue && displayValue !== 'n/a' && (
<Box sx={{ mt: 2, p: 1.5, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="body2" color="text.secondary">
Current value: <strong>{displayValue}</strong>
</Typography>
</Box>
)}

{/* Validation errors */}
{hasError && (
<Alert severity="error" sx={{ mt: 2 }}>
{Array.isArray(errors) ? errors.join(', ') : String(errors)}
</Alert>
)}
</Box>
</QuestionShell>
);
};

export default withJsonFormsControlProps(AdateQuestionRenderer);
11 changes: 11 additions & 0 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import AudioQuestionRenderer, { audioQuestionTester } from './AudioQuestionRende
import GPSQuestionRenderer, { gpsQuestionTester } from './GPSQuestionRenderer';
import VideoQuestionRenderer, { videoQuestionTester } from './VideoQuestionRenderer';
import HtmlLabelRenderer, { htmlLabelTester } from './HtmlLabelRenderer';
import AdateQuestionRenderer, { adateQuestionTester } from './AdateQuestionRenderer';
import { shellMaterialRenderers } from './material-wrappers';

import ErrorBoundary from './ErrorBoundary';
Expand Down Expand Up @@ -174,6 +175,7 @@ export const customRenderers = [
{ tester: gpsQuestionTester, renderer: GPSQuestionRenderer },
{ tester: videoQuestionTester, renderer: VideoQuestionRenderer },
{ tester: htmlLabelTester, renderer: HtmlLabelRenderer },
{ tester: adateQuestionTester, renderer: AdateQuestionRenderer },
];

function App() {
Expand Down Expand Up @@ -551,6 +553,15 @@ function App() {
ajv.addFormat('audio', () => true); // Accept any value for audio format
ajv.addFormat('gps', () => true); // Accept any value for GPS format
ajv.addFormat('video', () => true); // Accept any value for video format
ajv.addFormat('adate', (data: any) => {
// Allow null, undefined, or empty string (for optional fields)
if (data === null || data === undefined || data === '') {
return true;
}
// Validate YYYY-MM-DD format (may contain ?? for unknown parts)
const dateRegex = /^(\d{4}|\?\?\?\?)-(\d{2}|\?\?)-(\d{2}|\?\?)$/;
return typeof data === 'string' && dateRegex.test(data);
});

// Show draft selector if we have pending form init and available drafts
if (showDraftSelector && pendingFormInit) {
Expand Down
3 changes: 3 additions & 0 deletions formulus-formplayer/src/FinalizeRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ControlProps } from '@jsonforms/core';
import { ErrorObject } from 'ajv';
import { useFormContext } from './App';
import EditIcon from '@mui/icons-material/Edit';
import { displayAdate } from './adateUtils';

interface SummaryItem {
label: string;
Expand Down Expand Up @@ -101,6 +102,8 @@ const FinalizeRenderer = ({
return new Date(value).toLocaleString();
case 'time':
return value;
case 'adate':
return displayAdate(value);
}
}

Expand Down
Loading
Loading