diff --git a/crates/api/src/config.rs b/crates/api/src/config.rs new file mode 100644 index 0000000..1765ff7 --- /dev/null +++ b/crates/api/src/config.rs @@ -0,0 +1,29 @@ +use axum::{Json, Router, extract::State, routing::get}; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::AppState; + +/// Public, non-sensitive instance configuration the SPA needs before login. +#[derive(Serialize, ToSchema)] +pub(crate) struct ConfigView { + /// User-facing product name. + pub app_name: String, + /// Default UI/content language (i18n key, e.g. "sv"). + pub default_language: String, + /// Default display timezone (IANA name). Storage is UTC; this is a display hint. + pub default_timezone: String, +} + +#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))] +pub(crate) async fn get_config(State(state): State) -> Json { + Json(ConfigView { + app_name: state.app_name.clone(), + default_language: state.default_language.clone(), + default_timezone: state.default_timezone.clone(), + }) +} + +pub(crate) fn routes() -> Router { + Router::new().route("/api/config", get(get_config)) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index ebf0c9b..bb65a8c 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -5,6 +5,7 @@ mod admin_authorities; mod admin_objects; mod admin_search; mod admin_vocab; +mod config; mod health; mod openapi; mod pagination; @@ -30,6 +31,10 @@ pub struct AppState { /// Search client for on-write index sync. `None` disables indexing (search is a /// best-effort feature; absent when Meilisearch is not configured). pub search: Option, + /// Instance default UI/content language (from config). + pub default_language: String, + /// Instance default display timezone, IANA name (from config). Storage stays UTC. + pub default_timezone: String, } /// Best-effort: keep the search index in step with a catalogue write that has already @@ -58,6 +63,7 @@ pub fn build_app(state: AppState) -> Router { .with_expiry(Expiry::OnInactivity(Duration::hours(8))); Router::new() + .merge(config::routes()) .merge(health::routes()) .merge(openapi::routes()) .merge(public::routes()) diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index ea75cbc..c176145 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get}; use utoipa::OpenApi; use crate::{ - AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public, + AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, config, health, + public, }; #[derive(OpenApi)] #[openapi( paths( + config::get_config, health::live, health::ready, public::list_objects, @@ -34,6 +36,7 @@ use crate::{ admin_authorities::create_authority ), components(schemas( + config::ConfigView, health::Live, health::Ready, public::PublicView, diff --git a/crates/api/tests/admin.rs b/crates/api/tests/admin.rs index 631a3d8..1aec9ed 100644 --- a/crates/api/tests/admin.rs +++ b/crates/api/tests/admin.rs @@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs index 413f098..04aec01 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_fields.rs b/crates/api/tests/admin_fields.rs index f4408d3..8a79c2e 100644 --- a/crates/api/tests/admin_fields.rs +++ b/crates/api/tests/admin_fields.rs @@ -33,6 +33,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_objects.rs b/crates/api/tests/admin_objects.rs index 441ecb1..fe6a677 100644 --- a/crates/api/tests/admin_objects.rs +++ b/crates/api/tests/admin_objects.rs @@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".into(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/admin_search.rs b/crates/api/tests/admin_search.rs index e552599..15236dd 100644 --- a/crates/api/tests/admin_search.rs +++ b/crates/api/tests/admin_search.rs @@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option) -> AppState { app_name: "Test".into(), cookie_secure: false, search, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/config.rs b/crates/api/tests/config.rs new file mode 100644 index 0000000..4d8b743 --- /dev/null +++ b/crates/api/tests/config.rs @@ -0,0 +1,41 @@ +use api::{AppState, build_app}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; + +fn state(pool: PgPool) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test Museum".into(), + cookie_secure: false, + search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), + } +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn config_is_public_and_reflects_state(pool: PgPool) { + let app = build_app(state(pool)); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: serde_json::Value = + serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert_eq!(body["app_name"], "Test Museum"); + assert_eq!(body["default_language"], "sv"); + assert_eq!(body["default_timezone"], "Europe/Stockholm"); +} diff --git a/crates/api/tests/health.rs b/crates/api/tests/health.rs index 806ad66..53e9405 100644 --- a/crates/api/tests/health.rs +++ b/crates/api/tests/health.rs @@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState { app_name: app_name.to_string(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/public.rs b/crates/api/tests/public.rs index b09737c..1d1519d 100644 --- a/crates/api/tests/public.rs +++ b/crates/api/tests/public.rs @@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState { app_name: "Test".to_string(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/api/tests/reindex.rs b/crates/api/tests/reindex.rs index 78392ec..ad77105 100644 --- a/crates/api/tests/reindex.rs +++ b/crates/api/tests/reindex.rs @@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState { app_name: "Test".into(), cookie_secure: false, search: Some(search), + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), } } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 08e809e..869b7e5 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -50,4 +50,21 @@ pub struct Config { default_value_t = 5 )] pub db_max_connections: u32, + + /// Default UI + content-authoring language for this instance (i18n key, e.g. "sv"). + #[arg( + long = "default-language", + env = "DEFAULT_LANGUAGE", + default_value = "sv" + )] + pub default_language: String, + + /// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC; + /// this is a display hint surfaced to clients (and, later, server-side renderers). + #[arg( + long = "default-timezone", + env = "DEFAULT_TIMEZONE", + default_value = "Europe/Stockholm" + )] + pub default_timezone: String, } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 53951b2..760668e 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -53,6 +53,8 @@ pub async fn run(config: Config) -> anyhow::Result<()> { app_name: config.app_name, cookie_secure: config.cookie_secure, search, + default_language: config.default_language, + default_timezone: config.default_timezone, }; let listener = TcpListener::bind(&config.bind_addr) diff --git a/crates/server/tests/config.rs b/crates/server/tests/config.rs index cb0501b..b985f9c 100644 --- a/crates/server/tests/config.rs +++ b/crates/server/tests/config.rs @@ -1,11 +1,13 @@ use clap::Parser; use server::Config; -const CLEARED: [(&str, Option<&str>); 4] = [ +const CLEARED: [(&str, Option<&str>); 6] = [ ("DATABASE_URL", None), ("BIND_ADDR", None), ("APP_NAME", None), ("SESSION_COOKIE_SECURE", None), + ("DEFAULT_LANGUAGE", None), + ("DEFAULT_TIMEZONE", None), ]; #[test] @@ -17,6 +19,8 @@ fn parses_from_args_with_defaults() { assert_eq!(cfg.database_url, "postgres://localhost/test"); assert_eq!(cfg.bind_addr, "0.0.0.0:8080"); assert_eq!(cfg.app_name, "Collection Management System"); + assert_eq!(cfg.default_language, "sv"); + assert_eq!(cfg.default_timezone, "Europe/Stockholm"); }); } diff --git a/crates/server/tests/serve.rs b/crates/server/tests/serve.rs index edf402a..7ab329f 100644 --- a/crates/server/tests/serve.rs +++ b/crates/server/tests/serve.rs @@ -17,6 +17,8 @@ async fn serves_health_live_over_tcp() { app_name: "Test".to_string(), cookie_secure: false, search: None, + default_language: "sv".into(), + default_timezone: "Europe/Stockholm".into(), }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index e0095dc..3fc7b8e 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -238,6 +238,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_config"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/public/objects": { parameters: { query?: never; @@ -355,6 +371,15 @@ export interface components { kind: components["schemas"]["AuthorityKind"]; labels: components["schemas"]["LabelView"][]; }; + /** @description Public, non-sensitive instance configuration the SPA needs before login. */ + ConfigView: { + /** @description User-facing product name. */ + app_name: string; + /** @description Default UI/content language (i18n key, e.g. "sv"). */ + default_language: string; + /** @description Default display timezone (IANA name). Storage is UTC; this is a display hint. */ + default_timezone: string; + }; CreatedField: { key: string; }; @@ -1308,6 +1333,25 @@ export interface operations { }; }; }; + get_config: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigView"]; + }; + }; + }; + }; list_objects: { parameters: { query?: { diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx index 13aa20d..6b97aa4 100644 --- a/web/src/authorities/authorities-page.tsx +++ b/web/src/authorities/authorities-page.tsx @@ -31,7 +31,7 @@ export function AuthoritiesPage() { const onCreate = (event: FormEvent) => { event.preventDefault(); - if (!labels.some((l) => l.lang === "en" && l.label)) { + if (!labels.some((l) => l.label)) { setError(true); return; } diff --git a/web/src/authorities/authorities.test.tsx b/web/src/authorities/authorities.test.tsx index 67406a7..fc2ffb8 100644 --- a/web/src/authorities/authorities.test.tsx +++ b/web/src/authorities/authorities.test.tsx @@ -25,7 +25,7 @@ test("lists authorities for the kind and creates one", async () => { ); renderApp(tree(), { route: "/authorities/person" }); expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Carl von Linné"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Carl von Linné"); await userEvent.click(screen.getByRole("button", { name: /create/i })); await waitFor(() => expect((body as { kind: string })?.kind).toBe("person")); expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné"); diff --git a/web/src/components/label-editor.test.tsx b/web/src/components/label-editor.test.tsx index 35bf81c..ac642e6 100644 --- a/web/src/components/label-editor.test.tsx +++ b/web/src/components/label-editor.test.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { expect, test } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderApp } from "../test/render"; import { LabelEditor } from "./label-editor"; @@ -10,28 +10,24 @@ type LabelInput = components["schemas"]["LabelInput"]; function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) { const [value, setValue] = useState([]); - return ( - { - setValue(v); - onChange(v); - }} - /> - ); + return { setValue(v); onChange(v); }} />; } -test("typing EN and SV emits both labels; empty langs are omitted", async () => { +test("emits a single label at the instance default language", async () => { const seen: LabelInput[][] = []; renderApp( seen.push(v)} />); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze"); - await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons"); - const last = seen[seen.length - 1]!; - expect(last).toEqual( - expect.arrayContaining([ - { lang: "en", label: "Bronze" }, - { lang: "sv", label: "Brons" }, - ]), - ); - expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true); + await userEvent.type(screen.getByLabelText(/^label$/i), "Brons"); + await waitFor(() => { + const last = seen[seen.length - 1]!; + expect(last).toEqual([{ lang: "sv", label: "Brons" }]); + }); +}); + +test("clearing the input emits an empty array", async () => { + const seen: LabelInput[][] = []; + renderApp( seen.push(v)} />); + const input = screen.getByLabelText(/^label$/i); + await userEvent.type(input, "X"); + await userEvent.clear(input); + await waitFor(() => expect(seen[seen.length - 1]).toEqual([])); }); diff --git a/web/src/components/label-editor.tsx b/web/src/components/label-editor.tsx index 1c07bc7..6d0ff33 100644 --- a/web/src/components/label-editor.tsx +++ b/web/src/components/label-editor.tsx @@ -1,12 +1,15 @@ import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; +import { useConfig } from "../config/config-context"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; type LabelInput = components["schemas"]["LabelInput"]; -/** Controlled sv/en label editor. Emits LabelInput[] with only the non-empty langs. */ +/** Single-language label editor. Authors one label at the instance default language; + * emits a one-entry LabelInput[] (empty array when blank). The multilingual data model + * is unchanged — this only simplifies authoring. */ export function LabelEditor({ value, onChange, @@ -15,33 +18,18 @@ export function LabelEditor({ onChange: (labels: LabelInput[]) => void; }) { const { t } = useTranslation(); + const { default_language } = useConfig(); - const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? ""; + const current = + value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? ""; - const set = (lang: string, label: string) => { - const others = value.filter((l) => l.lang !== lang); - - onChange(label.trim() ? [...others, { lang, label }] : others); - }; + const set = (label: string) => + onChange(label.trim() ? [{ lang: default_language, label }] : []); return ( -
-
- - set("en", e.target.value)} - /> -
-
- - set("sv", e.target.value)} - /> -
+
+ + set(e.target.value)} />
); } diff --git a/web/src/config/config-context.test.tsx b/web/src/config/config-context.test.tsx new file mode 100644 index 0000000..69e1f75 --- /dev/null +++ b/web/src/config/config-context.test.tsx @@ -0,0 +1,39 @@ +import { expect, test, beforeEach } from "vitest"; +import { screen, waitFor, render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import i18n, { LOCALE_KEY } from "../i18n"; +import { ConfigProvider } from "./config-provider"; +import { useConfig } from "./config-context"; + +function Probe() { + const config = useConfig(); + return {config.default_language}; +} + +function renderProvider() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(() => { + localStorage.clear(); + void i18n.changeLanguage("en"); +}); + +test("exposes config and applies default language when no stored preference", async () => { + renderProvider(); + expect(await screen.findByTestId("lang")).toHaveTextContent("sv"); + await waitFor(() => expect(i18n.language).toBe("sv")); +}); + +test("a stored locale preference wins over the instance default", async () => { + localStorage.setItem(LOCALE_KEY, "en"); + void i18n.changeLanguage("en"); + renderProvider(); + await screen.findByTestId("lang"); + await waitFor(() => expect(i18n.language).toBe("en")); +}); diff --git a/web/src/config/config-context.ts b/web/src/config/config-context.ts new file mode 100644 index 0000000..e2a801c --- /dev/null +++ b/web/src/config/config-context.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; + +import type { components } from "../api/schema"; + +export type ConfigView = components["schemas"]["ConfigView"]; + +export const DEFAULTS: ConfigView = { + app_name: "Collection Management System", + default_language: "sv", + default_timezone: "Europe/Stockholm", +}; + +export const ConfigContext = createContext(DEFAULTS); + +export function useConfig(): ConfigView { + return useContext(ConfigContext); +} diff --git a/web/src/config/config-provider.tsx b/web/src/config/config-provider.tsx new file mode 100644 index 0000000..8573294 --- /dev/null +++ b/web/src/config/config-provider.tsx @@ -0,0 +1,30 @@ +import { useEffect, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../api/client"; +import i18n, { LOCALE_KEY } from "../i18n"; +import { ConfigContext, DEFAULTS, type ConfigView } from "./config-context"; + +export function ConfigProvider({ children }: { children: ReactNode }) { + const { data } = useQuery({ + queryKey: ["config"], + queryFn: async (): Promise => { + const { data, error } = await api.GET("/api/config"); + + if (error || !data) throw new Error("failed to load config"); + + return data; + }, + staleTime: Infinity, + }); + + // Default the UI language to the instance default, unless the user has chosen one for + // this browser (LangSwitch persists to localStorage[LOCALE_KEY]). + useEffect(() => { + if (data && !localStorage.getItem(LOCALE_KEY)) { + void i18n.changeLanguage(data.default_language); + } + }, [data]); + + return {children}; +} diff --git a/web/src/fields/field-form.tsx b/web/src/fields/field-form.tsx index d7c77da..8177fd7 100644 --- a/web/src/fields/field-form.tsx +++ b/web/src/fields/field-form.tsx @@ -42,10 +42,10 @@ export function FieldForm() { const onSubmit = (event: FormEvent) => { event.preventDefault(); - const hasEn = labels.some((l) => l.lang === "en" && l.label); + const hasLabel = labels.some((l) => l.label); const termNeedsVocab = dataType === "term" && !vocabularyId; - if (!key.trim() || !hasEn || termNeedsVocab) { + if (!key.trim() || !hasLabel || termNeedsVocab) { setError(true); return; } diff --git a/web/src/fields/fields.test.tsx b/web/src/fields/fields.test.tsx index a3df0e8..6cfdb8a 100644 --- a/web/src/fields/fields.test.tsx +++ b/web/src/fields/fields.test.tsx @@ -35,7 +35,7 @@ test("creates a text field — posts the body and clears the key input", async ( renderApp(tree(), { route: "/fields" }); await userEvent.type(screen.getByLabelText(/^key$/i), "notes"); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Notes"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Notes"); await userEvent.click(screen.getByRole("button", { name: /create field/i })); await waitFor(() => expect(body?.key).toBe("notes")); @@ -55,7 +55,7 @@ test("selecting Authority reveals the kind picker and posts the chosen kind", as renderApp(tree(), { route: "/fields" }); await userEvent.type(screen.getByLabelText(/^key$/i), "maker"); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Maker"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Maker"); await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "authority"); const kind = await screen.findByLabelText(/authority kind/i); await userEvent.selectOptions(kind, "person"); @@ -76,7 +76,7 @@ test("selecting Term reveals the vocabulary picker and blocks submit until chose renderApp(tree(), { route: "/fields" }); await userEvent.type(screen.getByLabelText(/^key$/i), "material"); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Material"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Material"); await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term"); const vocab = await screen.findByLabelText(/^vocabulary$/i); diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 0e61bc9..b07cfce 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -7,7 +7,7 @@ "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }, - "labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }, + "labels": { "label": "Label", "externalUri": "External URI (optional)" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", "create": "Create", "selectPrompt": "Select a vocabulary to manage its terms", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 844a80a..b6c7567 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -7,7 +7,7 @@ "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }, - "labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }, + "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", "create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer", diff --git a/web/src/main.tsx b/web/src/main.tsx index 871fcd6..0b05e74 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "./app"; +import { ConfigProvider } from "./config/config-provider"; import "./index.css"; import "./i18n"; @@ -13,7 +14,9 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/web/src/objects/field-input.test.tsx b/web/src/objects/field-input.test.tsx index e8f8b3d..afb5c78 100644 --- a/web/src/objects/field-input.test.tsx +++ b/web/src/objects/field-input.test.tsx @@ -22,10 +22,9 @@ test("boolean field renders a checkbox", async () => { expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument(); }); -test("localized_text renders sv and en inputs", async () => { +test("localized_text renders a single input for the default language", async () => { renderApp(); - expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument(); + expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument(); }); test("term field renders a select populated from the vocabulary", async () => { diff --git a/web/src/objects/field-input.tsx b/web/src/objects/field-input.tsx index 859f919..5fc88e8 100644 --- a/web/src/objects/field-input.tsx +++ b/web/src/objects/field-input.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; import { useAuthorities, useTerms } from "../api/queries"; +import { useConfig } from "../config/config-context"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -70,6 +71,7 @@ export function FieldInput }>( form: FieldForm; }) { const { t, i18n } = useTranslation(); + const { default_language } = useConfig(); const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const label = labelIn(definition.labels, lang); const name = fieldPath(definition.key); @@ -128,30 +130,12 @@ export function FieldInput }>( case "localized_text": return (
-
{label}
- - - + (`${definition.key}.en`), { required: definition.required })} - /> - - - - (`${definition.key}.sv`))} + id={definition.key} + {...form.register(fieldPath(`${definition.key}.${default_language}`), { + required: definition.required, + })} />
); diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index f6dcfba..5b5c01d 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -3,6 +3,14 @@ import { http, HttpResponse } from "msw"; import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures"; export const handlers = [ + http.get("/api/config", () => + HttpResponse.json({ + app_name: "Test Museum", + default_language: "sv", + default_timezone: "Europe/Stockholm", + }), + ), + http.get("/api/admin/me", () => HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }), ), diff --git a/web/src/vocab/vocabularies.test.tsx b/web/src/vocab/vocabularies.test.tsx index 38e731c..5012fb8 100644 --- a/web/src/vocab/vocabularies.test.tsx +++ b/web/src/vocab/vocabularies.test.tsx @@ -45,7 +45,7 @@ test("selecting a vocabulary shows its terms and adds one", async () => { ); renderApp(tree(), { route: "/vocabularies/v-material" }); expect(await screen.findByText("Bronze")).toBeInTheDocument(); - await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Stone"); + await userEvent.type(screen.getByLabelText(/^label$/i), "Stone"); await userEvent.click(screen.getByRole("button", { name: /add term/i })); await waitFor(() => expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"), diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index 1d8d886..c4c75e4 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -34,7 +34,7 @@ export function VocabularyTerms() { const onAdd = (event: FormEvent) => { event.preventDefault(); - if (!labels.some((l) => l.lang === "en" && l.label)) { + if (!labels.some((l) => l.label)) { setError(true); return; }