Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e824d51823 |
@@ -258,6 +258,12 @@
|
|||||||
color: var(--nq-a-ink-secondary);
|
color: var(--nq-a-ink-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-input {
|
.settings-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: var(--nq-a-tap-min);
|
min-height: var(--nq-a-tap-min);
|
||||||
|
|||||||
@@ -499,37 +499,37 @@ describe("SettingsScreen — preferences", () => {
|
|||||||
expect(writes()).toHaveLength(1);
|
expect(writes()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a changed timezone patches exactly the timezone", async () => {
|
it("a timezone picked from the list patches exactly the timezone", async () => {
|
||||||
const { form } = await renderPreferences();
|
const { form } = await renderPreferences();
|
||||||
const input = field(form, "Timezone");
|
fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
|
||||||
expect(input.getAttribute("list")).toBe("settings-timezones");
|
|
||||||
fireEvent.change(input, { target: { value: " Europe/London " } });
|
|
||||||
save(form);
|
save(form);
|
||||||
await screen.findByText("Preferences saved.");
|
await screen.findByText("Preferences saved.");
|
||||||
expect(writes()).toEqual([
|
expect(writes()).toEqual([
|
||||||
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } },
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
||||||
]);
|
]);
|
||||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||||
expect(field(reloaded, "Timezone").value).toBe("Europe/London");
|
expect(field(reloaded, "Timezone").value).toBe("America/New_York");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
|
it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])(
|
||||||
"an invalid timezone %j is rejected before any request",
|
"an invalid other timezone %j is rejected before any request",
|
||||||
async (value) => {
|
async (value) => {
|
||||||
const { form } = await renderPreferences();
|
const { form } = await renderPreferences();
|
||||||
fireEvent.change(field(form, "Timezone"), { target: { value } });
|
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
|
||||||
|
fireEvent.change(field(form, "Other timezone"), { target: { value } });
|
||||||
save(form);
|
save(form);
|
||||||
expect(screen.getByTestId("preferences-message").textContent).toBe(
|
expect(screen.getByTestId("preferences-message").textContent).toBe(
|
||||||
"Timezone must be an IANA zone like America/New_York, or UTC.",
|
"Timezone must be an IANA zone like America/New_York, or UTC.",
|
||||||
);
|
);
|
||||||
expect(field(form, "Timezone").value).toBe(value);
|
expect(field(form, "Other timezone").value).toBe(value);
|
||||||
expect(writes()).toEqual([]);
|
expect(writes()).toEqual([]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
|
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
|
||||||
const { form } = await renderPreferences();
|
const { form } = await renderPreferences();
|
||||||
fireEvent.change(field(form, "Timezone"), { target: { value: "Mars/Olympus_Mons" } });
|
fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } });
|
||||||
|
fireEvent.change(field(form, "Other timezone"), { target: { value: "Mars/Olympus_Mons" } });
|
||||||
writeResponse = () =>
|
writeResponse = () =>
|
||||||
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
|
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
|
||||||
save(form);
|
save(form);
|
||||||
@@ -537,8 +537,9 @@ describe("SettingsScreen — preferences", () => {
|
|||||||
expect(message.textContent).toBe(
|
expect(message.textContent).toBe(
|
||||||
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
|
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'",
|
||||||
);
|
);
|
||||||
expect(field(form, "Timezone").value).toBe("Mars/Olympus_Mons");
|
expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons");
|
||||||
expect(field(form, "Timezone").disabled).toBe(false);
|
expect(field(form, "Other timezone").disabled).toBe(false);
|
||||||
|
expect(settings.timezone).toBe("America/Chicago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a failed load shows the reason and Try again refetches", async () => {
|
it("a failed load shows the reason and Try again refetches", async () => {
|
||||||
@@ -643,3 +644,150 @@ describe("SettingsScreen — no timezone auto-set", () => {
|
|||||||
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
|
expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SettingsScreen — manual timezone control", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
children = [child(1, "Declan", 1)];
|
||||||
|
settings = { ...DEFAULT_SETTINGS, timezone: "" };
|
||||||
|
writeResponse = null;
|
||||||
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderForm() {
|
||||||
|
render(<SettingsScreen onClose={vi.fn()} />);
|
||||||
|
await screen.findByTestId("settings-child-1");
|
||||||
|
return screen.findByRole("form", { name: "Preferences" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(form: HTMLElement) {
|
||||||
|
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Timezone control, which must be a real select, not a free-text box. */
|
||||||
|
function picker(form: HTMLElement): HTMLSelectElement {
|
||||||
|
const control = within(form).getByRole("combobox", { name: "Timezone" });
|
||||||
|
expect(control).toBeInstanceOf(HTMLSelectElement);
|
||||||
|
return control as HTMLSelectElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is a picker of curated zones with host-local and other-zone choices", async () => {
|
||||||
|
const form = await renderForm();
|
||||||
|
const select = picker(form);
|
||||||
|
const values = Array.from(select.options).map((o) => o.value);
|
||||||
|
expect(values[0]).toBe("");
|
||||||
|
expect(select.options[0].textContent).toBe("API host local time (no zone set)");
|
||||||
|
for (const zone of [
|
||||||
|
"UTC",
|
||||||
|
"America/New_York",
|
||||||
|
"America/Chicago",
|
||||||
|
"America/Denver",
|
||||||
|
"America/Phoenix",
|
||||||
|
"America/Los_Angeles",
|
||||||
|
"America/Anchorage",
|
||||||
|
"Pacific/Honolulu",
|
||||||
|
]) {
|
||||||
|
expect(values).toContain(zone);
|
||||||
|
}
|
||||||
|
expect(select.options[select.options.length - 1].textContent).toBe("Other IANA zone…");
|
||||||
|
expect(select.value).toBe("");
|
||||||
|
expect(within(form).queryByLabelText("Other timezone")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains that it is the application timezone for every surface", async () => {
|
||||||
|
const form = await renderForm();
|
||||||
|
const help = document.getElementById(picker(form).getAttribute("aria-describedby") ?? "");
|
||||||
|
expect(help?.textContent).toMatch(/application timezone/);
|
||||||
|
expect(help?.textContent).toMatch(/panel, the scheduler and this admin app/);
|
||||||
|
expect(help?.textContent).toMatch(/API host's local time/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a picked zone saves via PATCH and is shown after a reload", async () => {
|
||||||
|
const form = await renderForm();
|
||||||
|
expect(writes()).toEqual([]);
|
||||||
|
fireEvent.change(picker(form), { target: { value: "America/New_York" } });
|
||||||
|
save(form);
|
||||||
|
await screen.findByText("Preferences saved.");
|
||||||
|
expect(writes()).toEqual([
|
||||||
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } },
|
||||||
|
]);
|
||||||
|
cleanup();
|
||||||
|
const reloaded = await renderForm();
|
||||||
|
expect(picker(reloaded).value).toBe("America/New_York");
|
||||||
|
expect(writes()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => {
|
||||||
|
const form = await renderForm();
|
||||||
|
fireEvent.change(picker(form), { target: { value: "__other__" } });
|
||||||
|
const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement;
|
||||||
|
expect(entry.value).toBe("");
|
||||||
|
fireEvent.change(entry, { target: { value: " America/Boise " } });
|
||||||
|
save(form);
|
||||||
|
await screen.findByText("Preferences saved.");
|
||||||
|
expect(writes()).toEqual([
|
||||||
|
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } },
|
||||||
|
]);
|
||||||
|
cleanup();
|
||||||
|
const reloaded = await renderForm();
|
||||||
|
expect(picker(reloaded).value).toBe("__other__");
|
||||||
|
expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
||||||
|
"America/Boise",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a saved zone outside the list is shown in the other-zone entry on load", async () => {
|
||||||
|
settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" };
|
||||||
|
const form = await renderForm();
|
||||||
|
expect(picker(form).value).toBe("__other__");
|
||||||
|
expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe(
|
||||||
|
"Asia/Kathmandu",
|
||||||
|
);
|
||||||
|
expect(writes()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("choosing API host local time clears the zone and persists after a reload", async () => {
|
||||||
|
settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" };
|
||||||
|
const form = await renderForm();
|
||||||
|
expect(picker(form).value).toBe("America/Denver");
|
||||||
|
fireEvent.change(picker(form), { target: { value: "" } });
|
||||||
|
save(form);
|
||||||
|
await screen.findByText("Preferences saved.");
|
||||||
|
expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
|
||||||
|
cleanup();
|
||||||
|
const reloaded = await renderForm();
|
||||||
|
expect(picker(reloaded).value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picking a zone and then the saved one again sends nothing", async () => {
|
||||||
|
settings = { ...DEFAULT_SETTINGS, timezone: "UTC" };
|
||||||
|
const form = await renderForm();
|
||||||
|
fireEvent.change(picker(form), { target: { value: "Europe/Paris" } });
|
||||||
|
fireEvent.change(picker(form), { target: { value: "UTC" } });
|
||||||
|
save(form);
|
||||||
|
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
|
||||||
|
expect(writes()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never sends a timezone_configured field", async () => {
|
||||||
|
const form = await renderForm();
|
||||||
|
fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } });
|
||||||
|
save(form);
|
||||||
|
await screen.findByText("Preferences saved.");
|
||||||
|
const again = screen.getByRole("form", { name: "Preferences" });
|
||||||
|
fireEvent.change(picker(again), { target: { value: "" } });
|
||||||
|
save(again);
|
||||||
|
await waitFor(() => expect(writes()).toHaveLength(2));
|
||||||
|
for (const write of writes()) {
|
||||||
|
expect(Object.keys(write.body as object)).toEqual(["timezone"]);
|
||||||
|
expect(write.body).not.toHaveProperty("timezone_configured");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
*
|
*
|
||||||
* Preferences: the household settings (planning horizon, day rollover,
|
* Preferences: the household settings (planning horizon, day rollover,
|
||||||
* timezone, notifications). Save patches only the changed fields. The
|
* timezone, notifications). Save patches only the changed fields. The
|
||||||
* timezone is only ever set by the admin saving it here.
|
* timezone — the application's source of local time — is only ever set by
|
||||||
|
* the admin picking or typing a zone here and saving it.
|
||||||
*
|
*
|
||||||
* One write at a time across both sections.
|
* One write at a time across both sections.
|
||||||
*/
|
*/
|
||||||
@@ -256,7 +257,7 @@ const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|||||||
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
|
/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
|
||||||
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
|
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/;
|
||||||
|
|
||||||
/** The Timezone field's suggestions; any other IANA zone can be typed. */
|
/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */
|
||||||
export const COMMON_TIMEZONES = [
|
export const COMMON_TIMEZONES = [
|
||||||
"UTC",
|
"UTC",
|
||||||
"America/New_York",
|
"America/New_York",
|
||||||
@@ -290,6 +291,9 @@ export const COMMON_TIMEZONES = [
|
|||||||
"Pacific/Auckland",
|
"Pacific/Auckland",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** The picker's "Other IANA zone…" option; never sent to the API. */
|
||||||
|
const OTHER_ZONE = "__other__";
|
||||||
|
|
||||||
/** The form's working copy: the number is text so it can be mid-edit. */
|
/** The form's working copy: the number is text so it can be mid-edit. */
|
||||||
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
|
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
|
||||||
|
|
||||||
@@ -348,6 +352,10 @@ function PreferencesForm({
|
|||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
|
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
|
||||||
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
|
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||||
|
// A saved zone outside the curated list opens in the free-text entry.
|
||||||
|
const [otherZone, setOtherZone] = useState(
|
||||||
|
() => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone),
|
||||||
|
);
|
||||||
const disabled = saving || locked;
|
const disabled = saving || locked;
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
function submit(event: FormEvent) {
|
||||||
@@ -400,25 +408,49 @@ function PreferencesForm({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{timeInput("day_rollover_time", "Day rollover")}
|
{timeInput("day_rollover_time", "Day rollover")}
|
||||||
<label className="settings-field">
|
<div className="settings-field">
|
||||||
<span className="settings-label">Timezone</span>
|
<label className="settings-label" htmlFor="settings-timezone">
|
||||||
<input
|
Timezone
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="settings-timezone"
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
type="text"
|
aria-describedby="settings-timezone-help"
|
||||||
list="settings-timezones"
|
value={otherZone ? OTHER_ZONE : draft.timezone}
|
||||||
placeholder="Host local time"
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
value={draft.timezone}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => update({ timezone: event.target.value })}
|
onChange={(event) => {
|
||||||
/>
|
const choice = event.target.value;
|
||||||
</label>
|
setOtherZone(choice === OTHER_ZONE);
|
||||||
<datalist id="settings-timezones">
|
if (choice !== OTHER_ZONE) update({ timezone: choice });
|
||||||
{COMMON_TIMEZONES.map((zone) => (
|
}}
|
||||||
<option key={zone} value={zone} />
|
>
|
||||||
))}
|
<option value="">API host local time (no zone set)</option>
|
||||||
</datalist>
|
{COMMON_TIMEZONES.map((zone) => (
|
||||||
|
<option key={zone} value={zone}>
|
||||||
|
{zone}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={OTHER_ZONE}>Other IANA zone…</option>
|
||||||
|
</select>
|
||||||
|
{otherZone ? (
|
||||||
|
<input
|
||||||
|
className="settings-input"
|
||||||
|
type="text"
|
||||||
|
aria-label="Other timezone"
|
||||||
|
placeholder="Area/Location, e.g. America/Boise"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={draft.timezone}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => update({ timezone: event.target.value })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p className="settings-hint" id="settings-timezone-help">
|
||||||
|
The application timezone: the source of local time for the panel, the scheduler and this
|
||||||
|
admin app. It changes only when you save it here; with no zone set, the API host's
|
||||||
|
local time is used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
<span className="settings-label">Notify target</span>
|
<span className="settings-label">Notify target</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -270,6 +270,24 @@ async def test_manual_timezone_patch_persists_and_is_returned(
|
|||||||
assert "timezone_configured" not in stored
|
assert "timezone_configured" not in stored
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("zone", ["America/Los_Angeles", ""])
|
||||||
|
async def test_timezone_alone_sticks_on_a_fresh_database(
|
||||||
|
settings_client: SimpleNamespace, zone: str
|
||||||
|
) -> None:
|
||||||
|
"""A lone ``timezone`` PATCH needs no other field to persist."""
|
||||||
|
client = settings_client.client
|
||||||
|
patched = await client.patch(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"timezone": zone},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200
|
||||||
|
reread = await client.get(
|
||||||
|
"/api/v1/admin/settings", headers=_admin_headers()
|
||||||
|
)
|
||||||
|
assert reread.json() == {**DEFAULT_SETTINGS, "timezone": zone}
|
||||||
|
|
||||||
|
|
||||||
async def test_timezone_configured_is_an_unknown_field(
|
async def test_timezone_configured_is_an_unknown_field(
|
||||||
settings_client: SimpleNamespace,
|
settings_client: SimpleNamespace,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -320,6 +338,9 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
|
|||||||
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
||||||
({"celebration_enabled": 1}, "celebration_enabled"),
|
({"celebration_enabled": 1}, "celebration_enabled"),
|
||||||
({"notify_target": 5}, "notify_target"),
|
({"notify_target": 5}, "notify_target"),
|
||||||
|
({"timezone": "Mars/Olympus_Mons"}, "timezone"),
|
||||||
|
({"timezone": "America/New York"}, "timezone"),
|
||||||
|
({"timezone": 5}, "timezone"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
||||||
@@ -474,6 +495,13 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
|
|||||||
)
|
)
|
||||||
assert absent_patch.status_code == 401
|
assert absent_patch.status_code == 401
|
||||||
|
|
||||||
|
forbidden_zone = await client.patch(
|
||||||
|
"/api/v1/admin/settings",
|
||||||
|
json={"timezone": "America/New_York"},
|
||||||
|
headers={"Authorization": f"Bearer {non_admin}"},
|
||||||
|
)
|
||||||
|
assert forbidden_zone.status_code == 403
|
||||||
|
|
||||||
# No refused request changed anything: the settings are untouched.
|
# No refused request changed anything: the settings are untouched.
|
||||||
after = await client.get(
|
after = await client.get(
|
||||||
"/api/v1/admin/settings", headers=_admin_headers()
|
"/api/v1/admin/settings", headers=_admin_headers()
|
||||||
|
|||||||
Reference in New Issue
Block a user