DEFAULT_LANGUAGE/DEFAULT_TIMEZONE env config surfaced via public GET /api/config; SPA config provider defaults the UI language from the instance (overridable per browser). Content authoring collapsed to a single language (LabelEditor + localized_text) at the instance default. The multilingual content SCHEMA is left completely untouched (dormant) — re-enabling is UI-only, zero migration. Storage stays UTC; timezone exposed for future display/PDF use. 81 web tests; full backend green; bundle 145.7 KB gz; en/sv parity 106/106. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<AppState>) -> Json<ConfigView> {
|
||||||
|
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<AppState> {
|
||||||
|
Router::new().route("/api/config", get(get_config))
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod admin_authorities;
|
|||||||
mod admin_objects;
|
mod admin_objects;
|
||||||
mod admin_search;
|
mod admin_search;
|
||||||
mod admin_vocab;
|
mod admin_vocab;
|
||||||
|
mod config;
|
||||||
mod health;
|
mod health;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pagination;
|
mod pagination;
|
||||||
@@ -30,6 +31,10 @@ pub struct AppState {
|
|||||||
/// Search client for on-write index sync. `None` disables indexing (search is a
|
/// Search client for on-write index sync. `None` disables indexing (search is a
|
||||||
/// best-effort feature; absent when Meilisearch is not configured).
|
/// best-effort feature; absent when Meilisearch is not configured).
|
||||||
pub search: Option<search::SearchClient>,
|
pub search: Option<search::SearchClient>,
|
||||||
|
/// 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
|
/// 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)));
|
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.merge(config::routes())
|
||||||
.merge(health::routes())
|
.merge(health::routes())
|
||||||
.merge(openapi::routes())
|
.merge(openapi::routes())
|
||||||
.merge(public::routes())
|
.merge(public::routes())
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get};
|
|||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{
|
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)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
|
config::get_config,
|
||||||
health::live,
|
health::live,
|
||||||
health::ready,
|
health::ready,
|
||||||
public::list_objects,
|
public::list_objects,
|
||||||
@@ -34,6 +36,7 @@ use crate::{
|
|||||||
admin_authorities::create_authority
|
admin_authorities::create_authority
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
|
config::ConfigView,
|
||||||
health::Live,
|
health::Live,
|
||||||
health::Ready,
|
health::Ready,
|
||||||
public::PublicView,
|
public::PublicView,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option<SearchClient>) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search,
|
search,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
|
|||||||
app_name: app_name.to_string(),
|
app_name: app_name.to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".to_string(),
|
app_name: "Test".to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: Some(search),
|
search: Some(search),
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,4 +50,21 @@ pub struct Config {
|
|||||||
default_value_t = 5
|
default_value_t = 5
|
||||||
)]
|
)]
|
||||||
pub db_max_connections: u32,
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
|||||||
app_name: config.app_name,
|
app_name: config.app_name,
|
||||||
cookie_secure: config.cookie_secure,
|
cookie_secure: config.cookie_secure,
|
||||||
search,
|
search,
|
||||||
|
default_language: config.default_language,
|
||||||
|
default_timezone: config.default_timezone,
|
||||||
};
|
};
|
||||||
|
|
||||||
let listener = TcpListener::bind(&config.bind_addr)
|
let listener = TcpListener::bind(&config.bind_addr)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use server::Config;
|
use server::Config;
|
||||||
|
|
||||||
const CLEARED: [(&str, Option<&str>); 4] = [
|
const CLEARED: [(&str, Option<&str>); 6] = [
|
||||||
("DATABASE_URL", None),
|
("DATABASE_URL", None),
|
||||||
("BIND_ADDR", None),
|
("BIND_ADDR", None),
|
||||||
("APP_NAME", None),
|
("APP_NAME", None),
|
||||||
("SESSION_COOKIE_SECURE", None),
|
("SESSION_COOKIE_SECURE", None),
|
||||||
|
("DEFAULT_LANGUAGE", None),
|
||||||
|
("DEFAULT_TIMEZONE", None),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -17,6 +19,8 @@ fn parses_from_args_with_defaults() {
|
|||||||
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
||||||
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
||||||
assert_eq!(cfg.app_name, "Collection Management System");
|
assert_eq!(cfg.app_name, "Collection Management System");
|
||||||
|
assert_eq!(cfg.default_language, "sv");
|
||||||
|
assert_eq!(cfg.default_timezone, "Europe/Stockholm");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ async fn serves_health_live_over_tcp() {
|
|||||||
app_name: "Test".to_string(),
|
app_name: "Test".to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|||||||
Vendored
+44
@@ -238,6 +238,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/public/objects": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -355,6 +371,15 @@ export interface components {
|
|||||||
kind: components["schemas"]["AuthorityKind"];
|
kind: components["schemas"]["AuthorityKind"];
|
||||||
labels: components["schemas"]["LabelView"][];
|
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: {
|
CreatedField: {
|
||||||
key: string;
|
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: {
|
list_objects: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function AuthoritiesPage() {
|
|||||||
const onCreate = (event: FormEvent) => {
|
const onCreate = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!labels.some((l) => l.lang === "en" && l.label)) {
|
if (!labels.some((l) => l.label)) {
|
||||||
setError(true);
|
setError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test("lists authorities for the kind and creates one", async () => {
|
|||||||
);
|
);
|
||||||
renderApp(tree(), { route: "/authorities/person" });
|
renderApp(tree(), { route: "/authorities/person" });
|
||||||
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
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 userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
|
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
|
||||||
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
|
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { expect, test } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
import { LabelEditor } from "./label-editor";
|
import { LabelEditor } from "./label-editor";
|
||||||
@@ -10,28 +10,24 @@ type LabelInput = components["schemas"]["LabelInput"];
|
|||||||
|
|
||||||
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||||
const [value, setValue] = useState<LabelInput[]>([]);
|
const [value, setValue] = useState<LabelInput[]>([]);
|
||||||
return (
|
return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
|
||||||
<LabelEditor
|
|
||||||
value={value}
|
|
||||||
onChange={(v) => {
|
|
||||||
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[][] = [];
|
const seen: LabelInput[][] = [];
|
||||||
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
||||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
|
||||||
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
|
await waitFor(() => {
|
||||||
const last = seen[seen.length - 1]!;
|
const last = seen[seen.length - 1]!;
|
||||||
expect(last).toEqual(
|
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
|
||||||
expect.arrayContaining([
|
});
|
||||||
{ lang: "en", label: "Bronze" },
|
});
|
||||||
{ lang: "sv", label: "Brons" },
|
|
||||||
]),
|
test("clearing the input emits an empty array", async () => {
|
||||||
);
|
const seen: LabelInput[][] = [];
|
||||||
expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true);
|
renderApp(<Harness onChange={(v) => 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([]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
type LabelInput = components["schemas"]["LabelInput"];
|
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({
|
export function LabelEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -15,33 +18,18 @@ export function LabelEditor({
|
|||||||
onChange: (labels: LabelInput[]) => void;
|
onChange: (labels: LabelInput[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 set = (label: string) =>
|
||||||
const others = value.filter((l) => l.lang !== lang);
|
onChange(label.trim() ? [{ lang: default_language, label }] : []);
|
||||||
|
|
||||||
onChange(label.trim() ? [...others, { lang, label }] : others);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="space-y-1">
|
<Label htmlFor="label">{t("labels.label")}</Label>
|
||||||
<Label htmlFor="label-en">{t("labels.en")}</Label>
|
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
|
||||||
<Input
|
|
||||||
id="label-en"
|
|
||||||
value={valueFor("en")}
|
|
||||||
onChange={(e) => set("en", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="label-sv">{t("labels.sv")}</Label>
|
|
||||||
<Input
|
|
||||||
id="label-sv"
|
|
||||||
value={valueFor("sv")}
|
|
||||||
onChange={(e) => set("sv", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <span data-testid="lang">{config.default_language}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ConfigProvider><Probe /></ConfigProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
});
|
||||||
@@ -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<ConfigView>(DEFAULTS);
|
||||||
|
|
||||||
|
export function useConfig(): ConfigView {
|
||||||
|
return useContext(ConfigContext);
|
||||||
|
}
|
||||||
@@ -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<ConfigView> => {
|
||||||
|
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 <ConfigContext.Provider value={data ?? DEFAULTS}>{children}</ConfigContext.Provider>;
|
||||||
|
}
|
||||||
@@ -42,10 +42,10 @@ export function FieldForm() {
|
|||||||
const onSubmit = (event: FormEvent) => {
|
const onSubmit = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const hasEn = labels.some((l) => l.lang === "en" && l.label);
|
const hasLabel = labels.some((l) => l.label);
|
||||||
const termNeedsVocab = dataType === "term" && !vocabularyId;
|
const termNeedsVocab = dataType === "term" && !vocabularyId;
|
||||||
|
|
||||||
if (!key.trim() || !hasEn || termNeedsVocab) {
|
if (!key.trim() || !hasLabel || termNeedsVocab) {
|
||||||
setError(true);
|
setError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test("creates a text field — posts the body and clears the key input", async (
|
|||||||
renderApp(tree(), { route: "/fields" });
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/^key$/i), "notes");
|
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 userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(body?.key).toBe("notes"));
|
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" });
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/^key$/i), "maker");
|
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");
|
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "authority");
|
||||||
const kind = await screen.findByLabelText(/authority kind/i);
|
const kind = await screen.findByLabelText(/authority kind/i);
|
||||||
await userEvent.selectOptions(kind, "person");
|
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" });
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/^key$/i), "material");
|
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");
|
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term");
|
||||||
|
|
||||||
const vocab = await screen.findByLabelText(/^vocabulary$/i);
|
const vocab = await screen.findByLabelText(/^vocabulary$/i);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
"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" },
|
"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." },
|
"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": {
|
"vocab": {
|
||||||
"newVocabulary": "New vocabulary", "key": "Key",
|
"newVocabulary": "New vocabulary", "key": "Key",
|
||||||
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
"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" },
|
"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." },
|
"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": {
|
"vocab": {
|
||||||
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||||
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
||||||
|
|||||||
+4
-1
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { App } from "./app";
|
import { App } from "./app";
|
||||||
|
import { ConfigProvider } from "./config/config-provider";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
|
||||||
@@ -13,7 +14,9 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<ConfigProvider>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ test("boolean field renders a checkbox", async () => {
|
|||||||
expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument();
|
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(<Harness defKey="title_ml" />);
|
renderApp(<Harness defKey="title_ml" />);
|
||||||
expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument();
|
expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("term field renders a select populated from the vocabulary", async () => {
|
test("term field renders a select populated from the vocabulary", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { useAuthorities, useTerms } from "../api/queries";
|
import { useAuthorities, useTerms } from "../api/queries";
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -70,6 +71,7 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
|
|||||||
form: FieldForm<TValues>;
|
form: FieldForm<TValues>;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { default_language } = useConfig();
|
||||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
const label = labelIn(definition.labels, lang);
|
const label = labelIn(definition.labels, lang);
|
||||||
const name = fieldPath<TValues>(definition.key);
|
const name = fieldPath<TValues>(definition.key);
|
||||||
@@ -128,30 +130,12 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
|
|||||||
case "localized_text":
|
case "localized_text":
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">{label}</div>
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
|
||||||
<Label
|
|
||||||
htmlFor={`${definition.key}-en`}
|
|
||||||
className="text-xs text-neutral-500"
|
|
||||||
>
|
|
||||||
{label} (EN)
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id={`${definition.key}-en`}
|
id={definition.key}
|
||||||
{...form.register(fieldPath<TValues>(`${definition.key}.en`), { required: definition.required })}
|
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
|
||||||
/>
|
required: definition.required,
|
||||||
|
})}
|
||||||
<Label
|
|
||||||
htmlFor={`${definition.key}-sv`}
|
|
||||||
className="text-xs text-neutral-500"
|
|
||||||
>
|
|
||||||
{label} (SV)
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id={`${definition.key}-sv`}
|
|
||||||
{...form.register(fieldPath<TValues>(`${definition.key}.sv`))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { http, HttpResponse } from "msw";
|
|||||||
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures";
|
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures";
|
||||||
|
|
||||||
export const handlers = [
|
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", () =>
|
http.get("/api/admin/me", () =>
|
||||||
HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }),
|
HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test("selecting a vocabulary shows its terms and adds one", async () => {
|
|||||||
);
|
);
|
||||||
renderApp(tree(), { route: "/vocabularies/v-material" });
|
renderApp(tree(), { route: "/vocabularies/v-material" });
|
||||||
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
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 userEvent.click(screen.getByRole("button", { name: /add term/i }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
|
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function VocabularyTerms() {
|
|||||||
const onAdd = (event: FormEvent) => {
|
const onAdd = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!labels.some((l) => l.lang === "en" && l.label)) {
|
if (!labels.some((l) => l.label)) {
|
||||||
setError(true);
|
setError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user