Skip to content
Merged
213 changes: 213 additions & 0 deletions formulus-formplayer/src/renderers/GPSQuestionRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) {
return (
<JsonForms
schema={objectGpsSchema}
uischema={gpsUischema}
data={{ location: value }}
renderers={renderers}
ajv={ajv}
/>
);
}

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(
<JsonForms
schema={objectGpsSchema}
uischema={gpsUischema}
data={{}}
renderers={renderers}
ajv={ajv}
onChange={onChange}
/>,
);

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(
<ObjectGpsForm
value={{
latitude: 0.347596,
longitude: 32.58252,
accuracy: 5,
timestamp: '2026-10-27T10:00:00.000Z',
}}
/>,
);

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(
<JsonForms
schema={legacyStringGpsSchema}
uischema={gpsUischema}
data={{
location: JSON.stringify({
latitude: 0.347596,
longitude: 32.58252,
accuracy: 5,
timestamp: '2026-10-27T10:00:00.000Z',
}),
}}
renderers={renderers}
ajv={ajv}
/>,
);

expect(screen.getByText('Location Captured')).toBeInTheDocument();
expect(screen.getByText('0.347596° N')).toBeInTheDocument();
expect(screen.getByText('32.582520° E')).toBeInTheDocument();
});
});
50 changes: 32 additions & 18 deletions formulus-formplayer/src/renderers/GPSQuestionRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -100,7 +110,10 @@ const GPSQuestionRenderer: React.FC<GPSQuestionRendererProps> = 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 ||
Expand Down Expand Up @@ -131,7 +144,7 @@ const GPSQuestionRenderer: React.FC<GPSQuestionRendererProps> = props => {
} finally {
setIsCapturing(false);
}
}, [enabled, fieldId, handleChange, path]);
}, [enabled, fieldId, handleChange, path, schema.type]);

const handleDeleteLocation = () => {
setError(null);
Expand Down Expand Up @@ -341,10 +354,11 @@ const GPSQuestionRenderer: React.FC<GPSQuestionRendererProps> = 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);
Loading