diff --git a/formulus-formplayer/src/renderers/GPSQuestionRenderer.test.tsx b/formulus-formplayer/src/renderers/GPSQuestionRenderer.test.tsx new file mode 100644 index 000000000..4672a37d5 --- /dev/null +++ b/formulus-formplayer/src/renderers/GPSQuestionRenderer.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { JsonForms } from '@jsonforms/react'; +import Ajv from 'ajv'; +import GPSQuestionRenderer, { gpsQuestionTester } from './GPSQuestionRenderer'; +import FormulusClient from '../services/FormulusInterface'; + +const ajv = new Ajv({ strict: false }); +ajv.addFormat('gps', () => true); + +const objectGpsSchema = { + type: 'object', + properties: { + location: { + type: 'object', + format: 'gps', + title: 'Current Location', + properties: { + latitude: { type: 'number' }, + longitude: { type: 'number' }, + accuracy: { type: 'number' }, + timestamp: { type: 'string' }, + }, + }, + }, +}; + +const gpsUischema = { + type: 'Control', + scope: '#/properties/location', +}; + +const renderers = [ + { tester: gpsQuestionTester, renderer: GPSQuestionRenderer }, +]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function ObjectGpsForm({ value }: { value: Record }) { + return ( + + ); +} + +describe('gpsQuestionTester', () => { + it('matches an object schema with gps format', () => { + const rootSchema = { + type: 'object', + properties: { + location: { + type: 'object', + format: 'gps', + title: 'Current Location', + properties: { + latitude: { type: 'number' }, + longitude: { type: 'number' }, + }, + }, + }, + }; + + const uischema = { + type: 'Control', + scope: '#/properties/location', + }; + + expect( + gpsQuestionTester(uischema as any, rootSchema as any, {} as any), + ).toBe(10); + }); + + it('matches an object GPS schema without properties', () => { + const rootSchema = { + type: 'object', + properties: { + location: { + type: 'object', + format: 'gps', + title: 'Current Location', + }, + }, + }; + + const uischema = { + type: 'Control', + scope: '#/properties/location', + }; + + expect( + gpsQuestionTester(uischema as any, rootSchema as any, {} as any), + ).toBe(10); + }); +}); + +describe('GPS capture storage', () => { + it('stores captured GPS data as an object for an object schema', async () => { + vi.spyOn(FormulusClient.getInstance(), 'requestLocation').mockResolvedValue( + { + status: 'success', + data: { + type: 'location', + latitude: 0.347596, + longitude: 32.58252, + accuracy: 5, + altitude: null, + altitudeAccuracy: null, + timestamp: '2026-10-27T10:00:00.000Z', + }, + }, + ); + + const onChange = vi.fn(); + + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Capture GPS location' }), + ); + + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + const latest = onChange.mock.calls.at(-1)?.[0]?.data; + + expect(latest?.location).toEqual({ + latitude: 0.347596, + longitude: 32.58252, + accuracy: 5, + altitude: undefined, + altitudeAccuracy: undefined, + timestamp: '2026-10-27T10:00:00.000Z', + }); + }); + }); +}); + +describe('GPS object observation values', () => { + it('displays an existing object GPS value', () => { + render( + , + ); + + expect(screen.getByText('Location Captured')).toBeInTheDocument(); + expect(screen.getByText('0.347596° N')).toBeInTheDocument(); + expect(screen.getByText('32.582520° E')).toBeInTheDocument(); + }); + + it('displays an existing legacy string GPS value', () => { + const legacyStringGpsSchema = { + type: 'object', + properties: { + location: { + type: 'string', + format: 'gps', + title: 'Current Location', + }, + }, + }; + + render( + , + ); + + expect(screen.getByText('Location Captured')).toBeInTheDocument(); + expect(screen.getByText('0.347596° N')).toBeInTheDocument(); + expect(screen.getByText('32.582520° E')).toBeInTheDocument(); + }); +}); diff --git a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx index 79093350b..8e2bb06dc 100644 --- a/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useRef, useCallback } from 'react'; -import { rankWith, ControlProps, formatIs } from '@jsonforms/core'; +import { rankWith, ControlProps, schemaMatches } from '@jsonforms/core'; import { withJsonFormsControlProps } from '@jsonforms/react'; import { Typography, @@ -44,21 +44,31 @@ interface LocationDisplayData { } function parseLocationDisplayData(data: unknown): LocationDisplayData | null { - if (!data || typeof data !== 'string') { + if (!data) { return null; } - try { - const parsed = JSON.parse(data); - if ( - parsed && - typeof parsed.latitude === 'number' && - typeof parsed.longitude === 'number' - ) { - return parsed as LocationDisplayData; - } - } catch (e) { - console.warn('Failed to parse existing location data:', e); + + const parsed = + typeof data === 'string' + ? (() => { + try { + return JSON.parse(data); + } catch (e) { + console.warn('Failed to parse existing location data:', e); + return null; + } + })() + : data; + + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as LocationDisplayData).latitude === 'number' && + typeof (parsed as LocationDisplayData).longitude === 'number' + ) { + return parsed as LocationDisplayData; } + return null; } @@ -100,7 +110,10 @@ const GPSQuestionRenderer: React.FC = props => { altitudeAccuracy: result.data.altitudeAccuracy ?? undefined, timestamp: result.data.timestamp, }; - handleChange(path, JSON.stringify(stored)); + handleChange( + path, + schema.type === 'object' ? stored : JSON.stringify(stored), + ); } else { const msg = result.message || @@ -131,7 +144,7 @@ const GPSQuestionRenderer: React.FC = props => { } finally { setIsCapturing(false); } - }, [enabled, fieldId, handleChange, path]); + }, [enabled, fieldId, handleChange, path, schema.type]); const handleDeleteLocation = () => { setError(null); @@ -341,10 +354,11 @@ const GPSQuestionRenderer: React.FC = props => { ); }; -// Tester function to determine when this renderer should be used +// Match on format alone so both documented object schemas and legacy +// string schemas use the GPS renderer. export const gpsQuestionTester = rankWith( - 10, // Priority - higher than default string renderer - formatIs('gps'), + 10, + schemaMatches(schema => schema.format === 'gps'), ); export default withJsonFormsControlProps(GPSQuestionRenderer);