mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
refactor(admin): share the admin settings saves and the TREK API lists between desktop and phone
useAdminSettingsActions now holds the OIDC and file type saves and the five place switches, writing through the admin bag both shells already receive. trekApiModel keeps the TREK Places API fields, facts and sources the desktop card and the phone block list.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TREK_API_SOURCES, trekApiFacts, trekApiFields } from './trekApiModel';
|
||||
|
||||
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-003
|
||||
|
||||
const t = (key: string) => `<${key}>`;
|
||||
|
||||
describe('trekApiModel', () => {
|
||||
it('FE-TREKAPIMODEL-001: lists the ten result fields in their order, translated', () => {
|
||||
expect(trekApiFields(t)).toEqual([
|
||||
'<places.formName>',
|
||||
'<collections.coordinates>',
|
||||
'<places.formCategory>',
|
||||
'<places.formAddress>',
|
||||
'<admin.trekApi.fieldPhone>',
|
||||
'<common.email>',
|
||||
'<places.formWebsite>',
|
||||
'<places.formDescription>',
|
||||
'<inspector.openingHours>',
|
||||
'<admin.trekApi.fieldStableId>',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-002: leads with four facts, each with a glyph', () => {
|
||||
const facts = trekApiFacts(t);
|
||||
expect(facts.map((f) => f.text)).toEqual([
|
||||
'<admin.trekApi.factPlaces>',
|
||||
'<admin.trekApi.factNoKey>',
|
||||
'<admin.trekApi.factOffline>',
|
||||
'<admin.trekApi.factPrivacy>',
|
||||
]);
|
||||
for (const f of facts) expect(f.Icon).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-003: names the data sources untranslated', () => {
|
||||
expect(TREK_API_SOURCES).toEqual(['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Globe2, KeyRound, ShieldOff, WifiOff } from 'lucide-react';
|
||||
|
||||
import type { TranslationFn } from '../../types';
|
||||
|
||||
/**
|
||||
* Proper nouns, so they are not translated. Naming them is also a licence obligation,
|
||||
* not decoration: ODbL and CC BY-SA both require attribution wherever their content is
|
||||
* shown.
|
||||
*/
|
||||
export const TREK_API_SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia'];
|
||||
|
||||
/**
|
||||
* The fields a TREK Places API result carries, for the desktop card and the phone block
|
||||
* alike. Reuses the words TREK already has for these fields wherever it has them, so
|
||||
* the chip row costs two new strings instead of ten.
|
||||
*/
|
||||
export function trekApiFields(t: TranslationFn): string[] {
|
||||
return [
|
||||
t('places.formName'),
|
||||
t('collections.coordinates'),
|
||||
t('places.formCategory'),
|
||||
t('places.formAddress'),
|
||||
t('admin.trekApi.fieldPhone'),
|
||||
t('common.email'),
|
||||
t('places.formWebsite'),
|
||||
t('places.formDescription'),
|
||||
t('inspector.openingHours'),
|
||||
t('admin.trekApi.fieldStableId'),
|
||||
];
|
||||
}
|
||||
|
||||
/** The four facts the TREK Places API block leads with, each with its glyph. */
|
||||
export function trekApiFacts(t: TranslationFn) {
|
||||
return [
|
||||
{ Icon: Globe2, text: t('admin.trekApi.factPlaces') },
|
||||
{ Icon: KeyRound, text: t('admin.trekApi.factNoKey') },
|
||||
{ Icon: WifiOff, text: t('admin.trekApi.factOffline') },
|
||||
{ Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useAdminSettingsActions } from './useAdminSettingsActions';
|
||||
|
||||
// FE-HOOK-ADMSETACT-001 to FE-HOOK-ADMSETACT-008
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
updateOidc: vi.fn(),
|
||||
updateAppSettings: vi.fn(),
|
||||
updatePlacesPhotos: vi.fn(),
|
||||
updatePlacesAutocomplete: vi.fn(),
|
||||
updatePlacesDetails: vi.fn(),
|
||||
updatePlacesEnrich: vi.fn(),
|
||||
updatePlaceShadow: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../api/client', () => ({
|
||||
adminApi: {
|
||||
updateOidc: api.updateOidc,
|
||||
updatePlacesPhotos: api.updatePlacesPhotos,
|
||||
updatePlacesAutocomplete: api.updatePlacesAutocomplete,
|
||||
updatePlacesDetails: api.updatePlacesDetails,
|
||||
updatePlacesEnrich: api.updatePlacesEnrich,
|
||||
updatePlaceShadow: api.updatePlaceShadow,
|
||||
},
|
||||
authApi: { updateAppSettings: api.updateAppSettings },
|
||||
}));
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
function host(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
oidcConfig: {
|
||||
issuer: 'https://idp',
|
||||
client_id: 'trek',
|
||||
client_secret: '',
|
||||
display_name: 'SSO',
|
||||
discovery_url: '',
|
||||
},
|
||||
setSavingOidc: vi.fn(),
|
||||
allowedFileTypes: 'jpg,pdf',
|
||||
setSavingFileTypes: vi.fn(),
|
||||
setPlacesPhotosEnabledState: vi.fn(),
|
||||
setPlacesPhotosEnabled: vi.fn(),
|
||||
setPlacesAutocompleteEnabledState: vi.fn(),
|
||||
setPlacesAutocompleteEnabled: vi.fn(),
|
||||
setPlacesDetailsEnabledState: vi.fn(),
|
||||
setPlacesDetailsEnabled: vi.fn(),
|
||||
setPlacesEnrichEnabledState: vi.fn(),
|
||||
setPlacesEnrichEnabled: vi.fn(),
|
||||
setPlaceShadowEnabledState: vi.fn(),
|
||||
setPlaceShadowEnabled: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(admin = host()) {
|
||||
return { admin, actions: renderHook(() => useAdminSettingsActions(admin, t)).result.current };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(api)) fn.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('useAdminSettingsActions', () => {
|
||||
it('FE-HOOK-ADMSETACT-001: saving OIDC leaves an empty secret out and toasts', async () => {
|
||||
const { admin, actions } = mount();
|
||||
await act(() => actions.saveOidc());
|
||||
expect(api.updateOidc).toHaveBeenCalledWith({
|
||||
issuer: 'https://idp',
|
||||
client_id: 'trek',
|
||||
display_name: 'SSO',
|
||||
discovery_url: '',
|
||||
});
|
||||
expect(admin.toast.success).toHaveBeenCalledWith('admin.oidcSaved');
|
||||
expect(admin.setSavingOidc.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMSETACT-002: a typed secret is sent along', async () => {
|
||||
const admin = host({
|
||||
oidcConfig: { issuer: 'i', client_id: 'c', client_secret: 's3cret', display_name: 'd', discovery_url: 'u' },
|
||||
});
|
||||
const { actions } = mount(admin);
|
||||
await act(() => actions.saveOidc());
|
||||
expect(api.updateOidc).toHaveBeenCalledWith(expect.objectContaining({ client_secret: 's3cret' }));
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMSETACT-003: a failed OIDC save shows the server message and clears saving', async () => {
|
||||
api.updateOidc.mockRejectedValue({ response: { data: { error: 'Bad issuer' } } });
|
||||
const { admin, actions } = mount();
|
||||
await act(() => actions.saveOidc());
|
||||
expect(admin.toast.error).toHaveBeenCalledWith('Bad issuer');
|
||||
expect(admin.setSavingOidc).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMSETACT-004: saving file types writes the list and toasts', async () => {
|
||||
const { admin, actions } = mount();
|
||||
await act(() => actions.saveFileTypes());
|
||||
expect(api.updateAppSettings).toHaveBeenCalledWith({ allowed_file_types: 'jpg,pdf' });
|
||||
expect(admin.toast.success).toHaveBeenCalledWith('admin.fileTypesSaved');
|
||||
expect(admin.setSavingFileTypes.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMSETACT-005: a failed file type save shows the generic error', async () => {
|
||||
api.updateAppSettings.mockRejectedValue(new Error('x'));
|
||||
const { admin, actions } = mount();
|
||||
await act(() => actions.saveFileTypes());
|
||||
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(admin.toast.success).not.toHaveBeenCalled();
|
||||
expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMSETACT-006: a place switch sets the tab and the store, then saves', async () => {
|
||||
const { admin, actions } = mount();
|
||||
await act(() => actions.togglePlacesPhotos(true));
|
||||
expect(admin.setPlacesPhotosEnabledState).toHaveBeenCalledWith(true);
|
||||
expect(admin.setPlacesPhotosEnabled).toHaveBeenCalledWith(true);
|
||||
expect(api.updatePlacesPhotos).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMSETACT-007: a failed place switch flips both back without a toast', async () => {
|
||||
api.updatePlaceShadow.mockRejectedValue(new Error('x'));
|
||||
const { admin, actions } = mount();
|
||||
await act(() => actions.togglePlaceShadow(true));
|
||||
expect(admin.setPlaceShadowEnabledState.mock.calls).toEqual([[true], [false]]);
|
||||
expect(admin.setPlaceShadowEnabled.mock.calls).toEqual([[true], [false]]);
|
||||
expect(admin.toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADMSETACT-008: each place switch writes its own setting', async () => {
|
||||
const { admin, actions } = mount();
|
||||
await act(() => actions.togglePlacesAutocomplete(false));
|
||||
await act(() => actions.togglePlacesDetails(false));
|
||||
await act(() => actions.togglePlacesEnrich(false));
|
||||
expect(api.updatePlacesAutocomplete).toHaveBeenCalledWith(false);
|
||||
expect(api.updatePlacesDetails).toHaveBeenCalledWith(false);
|
||||
expect(api.updatePlacesEnrich).toHaveBeenCalledWith(false);
|
||||
expect(admin.setPlacesAutocompleteEnabled).toHaveBeenCalledWith(false);
|
||||
expect(admin.setPlacesDetailsEnabledState).toHaveBeenCalledWith(false);
|
||||
expect(admin.setPlacesEnrichEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { adminApi, authApi } from '../../api/client';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
|
||||
type Flag = (value: boolean) => void;
|
||||
|
||||
interface OidcFields {
|
||||
issuer: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
display_name: string;
|
||||
discovery_url: string;
|
||||
}
|
||||
|
||||
interface SettingsActionsHost {
|
||||
toast: { success: (message: string) => void; error: (message: string) => void };
|
||||
oidcConfig: OidcFields;
|
||||
setSavingOidc: Flag;
|
||||
allowedFileTypes: string;
|
||||
setSavingFileTypes: Flag;
|
||||
setPlacesPhotosEnabledState: Flag;
|
||||
setPlacesPhotosEnabled: Flag;
|
||||
setPlacesAutocompleteEnabledState: Flag;
|
||||
setPlacesAutocompleteEnabled: Flag;
|
||||
setPlacesDetailsEnabledState: Flag;
|
||||
setPlacesDetailsEnabled: Flag;
|
||||
setPlacesEnrichEnabledState: Flag;
|
||||
setPlacesEnrichEnabled: Flag;
|
||||
setPlaceShadowEnabledState: Flag;
|
||||
setPlaceShadowEnabled: Flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* The saves behind the admin Settings tab and its phone twin: the OIDC provider, the
|
||||
* allowed file types and the place switches. The values live in the admin hook; this
|
||||
* only writes them, so both shells hand in the same admin bag.
|
||||
*/
|
||||
export function useAdminSettingsActions(admin: SettingsActionsHost, t: TranslationFn) {
|
||||
const { toast, oidcConfig, setSavingOidc, allowedFileTypes, setSavingFileTypes } = admin;
|
||||
|
||||
const saveOidc = async () => {
|
||||
setSavingOidc(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
issuer: oidcConfig.issuer,
|
||||
client_id: oidcConfig.client_id,
|
||||
display_name: oidcConfig.display_name,
|
||||
discovery_url: oidcConfig.discovery_url,
|
||||
};
|
||||
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret;
|
||||
await adminApi.updateOidc(payload);
|
||||
toast.success(t('admin.oidcSaved'));
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setSavingOidc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFileTypes = async () => {
|
||||
setSavingFileTypes(true);
|
||||
try {
|
||||
await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes });
|
||||
toast.success(t('admin.fileTypesSaved'));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
} finally {
|
||||
setSavingFileTypes(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A place switch shows the new value at once, in the tab and in the store the rest of
|
||||
* the app reads, and both flip back quietly when the save fails.
|
||||
*/
|
||||
const placeSwitch = (setLocal: Flag, setStore: Flag, save: (enabled: boolean) => Promise<unknown>) => {
|
||||
return async (next: boolean) => {
|
||||
setLocal(next);
|
||||
setStore(next);
|
||||
try {
|
||||
await save(next);
|
||||
} catch {
|
||||
setLocal(!next);
|
||||
setStore(!next);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
saveOidc,
|
||||
saveFileTypes,
|
||||
togglePlacesPhotos: placeSwitch(admin.setPlacesPhotosEnabledState, admin.setPlacesPhotosEnabled, (enabled) =>
|
||||
adminApi.updatePlacesPhotos(enabled)
|
||||
),
|
||||
togglePlacesAutocomplete: placeSwitch(
|
||||
admin.setPlacesAutocompleteEnabledState,
|
||||
admin.setPlacesAutocompleteEnabled,
|
||||
(enabled) => adminApi.updatePlacesAutocomplete(enabled)
|
||||
),
|
||||
togglePlacesDetails: placeSwitch(admin.setPlacesDetailsEnabledState, admin.setPlacesDetailsEnabled, (enabled) =>
|
||||
adminApi.updatePlacesDetails(enabled)
|
||||
),
|
||||
togglePlacesEnrich: placeSwitch(admin.setPlacesEnrichEnabledState, admin.setPlacesEnrichEnabled, (enabled) =>
|
||||
adminApi.updatePlacesEnrich(enabled)
|
||||
),
|
||||
togglePlaceShadow: placeSwitch(admin.setPlaceShadowEnabledState, admin.setPlaceShadowEnabled, (enabled) =>
|
||||
adminApi.updatePlaceShadow(enabled)
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user