feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences * fix(settings): show metadata exceptions immediately * docs(settings): add metadata language screenshot * fix(settings): make language exceptions responsive * fix(settings): standardize language display names
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ORIGINAL_METADATA_LANGUAGE } from "@/lib/metadataLanguagePreferences";
|
||||
import { MetadataLanguageSetting } from "./MetadataLanguageSetting";
|
||||
|
||||
const languageOptions = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ja", label: "Japanese" },
|
||||
{ value: "no", label: "Norwegian" },
|
||||
];
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
window.HTMLElement.prototype.hasPointerCapture ??= () => false;
|
||||
window.HTMLElement.prototype.scrollIntoView ??= () => {};
|
||||
|
||||
describe("MetadataLanguageSetting", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("shows a source-language exception and removes only that rule", () => {
|
||||
const onOverridesChange = vi.fn();
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" }}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Norwegian")).toBeTruthy();
|
||||
expect(screen.getByText("Japanese")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Norwegian exception" }));
|
||||
|
||||
expect(onOverridesChange).toHaveBeenCalledWith({ ja: "en" });
|
||||
});
|
||||
|
||||
it("keeps the add action disabled until an original language is chosen", () => {
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback={ORIGINAL_METADATA_LANGUAGE}
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add exception" }).hasAttribute("disabled")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(screen.getByLabelText("Original language for new exception")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a new exception immediately while its save is still pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOverridesChange = vi.fn(() => new Promise<void>(() => {}));
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText("Original language for new exception"));
|
||||
await user.click(screen.getByRole("option", { name: "Norwegian" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add exception" }));
|
||||
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian")).toHaveTextContent(
|
||||
"Original language",
|
||||
);
|
||||
expect(onOverridesChange).toHaveBeenCalledWith({ no: ORIGINAL_METADATA_LANGUAGE });
|
||||
});
|
||||
|
||||
it("rolls back an optimistic exception when its save fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
let rejectSave: (reason: Error) => void = () => {};
|
||||
const onOverridesChange = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_, reject) => {
|
||||
rejectSave = reject;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText("Original language for new exception"));
|
||||
await user.click(screen.getByRole("option", { name: "Norwegian" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add exception" }));
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian")).toBeInTheDocument();
|
||||
|
||||
rejectSave(new Error("save failed"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("Metadata language for Norwegian")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a custom target selectable when it is outside the advisory catalog", () => {
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{ no: "pt-BR" }}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian").textContent).toContain(
|
||||
"Portuguese",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { withCurrentLanguageOption, type SettingOption } from "@/lib/languageOptions";
|
||||
import { getLanguageName } from "@/lib/languageNames";
|
||||
import {
|
||||
ORIGINAL_METADATA_LANGUAGE,
|
||||
withMetadataLanguageOverride,
|
||||
withoutMetadataLanguageOverride,
|
||||
type MetadataLanguageOverrides,
|
||||
} from "@/lib/metadataLanguagePreferences";
|
||||
|
||||
interface MetadataLanguageSettingProps {
|
||||
fallback: string | null;
|
||||
overrides: MetadataLanguageOverrides;
|
||||
languageOptions: readonly SettingOption[];
|
||||
disabled?: boolean;
|
||||
onFallbackChange: (language: string | null) => void;
|
||||
onOverridesChange: (overrides: MetadataLanguageOverrides) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const NO_PREFERENCE = "__library_default";
|
||||
|
||||
function overridesEqual(
|
||||
left: MetadataLanguageOverrides,
|
||||
right: MetadataLanguageOverrides,
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left);
|
||||
return (
|
||||
leftEntries.length === Object.keys(right).length &&
|
||||
leftEntries.every(([source, target]) => right[source] === target)
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataLanguageSetting({
|
||||
fallback,
|
||||
overrides,
|
||||
languageOptions,
|
||||
disabled = false,
|
||||
onFallbackChange,
|
||||
onOverridesChange,
|
||||
}: MetadataLanguageSettingProps) {
|
||||
const [newSource, setNewSource] = useState("");
|
||||
const [optimisticOverrides, setOptimisticOverrides] = useState<{
|
||||
base: MetadataLanguageOverrides;
|
||||
value: MetadataLanguageOverrides;
|
||||
} | null>(null);
|
||||
const currentOverrides =
|
||||
optimisticOverrides !== null && overridesEqual(overrides, optimisticOverrides.base)
|
||||
? optimisticOverrides.value
|
||||
: overrides;
|
||||
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
Object.entries(currentOverrides).sort(([left], [right]) =>
|
||||
getLanguageName(left).localeCompare(getLanguageName(right)),
|
||||
),
|
||||
[currentOverrides],
|
||||
);
|
||||
const namedOptions = useMemo(
|
||||
() => languageOptions.filter((language) => language.value !== ORIGINAL_METADATA_LANGUAGE),
|
||||
[languageOptions],
|
||||
);
|
||||
const availableSources = namedOptions.filter((language) => !(language.value in currentOverrides));
|
||||
const optionsForTarget = (target: string) => {
|
||||
if (target === ORIGINAL_METADATA_LANGUAGE) return namedOptions;
|
||||
return withCurrentLanguageOption(namedOptions, target);
|
||||
};
|
||||
|
||||
const changeOverrides = async (next: MetadataLanguageOverrides) => {
|
||||
const pending = { base: overrides, value: next };
|
||||
setOptimisticOverrides(pending);
|
||||
try {
|
||||
await onOverridesChange(next);
|
||||
} catch {
|
||||
setOptimisticOverrides((current) => (current === pending ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const addException = () => {
|
||||
if (!newSource) return;
|
||||
void changeOverrides(
|
||||
withMetadataLanguageOverride(currentOverrides, newSource, ORIGINAL_METADATA_LANGUAGE),
|
||||
);
|
||||
setNewSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
label="Metadata language"
|
||||
description="Choose the fallback for titles and descriptions, then add exceptions based on each item's original language. Missing descriptions can be translated automatically when AI translation is enabled."
|
||||
control={(id) => (
|
||||
<div className="w-full min-w-0 space-y-3 md:w-[430px]">
|
||||
<Select
|
||||
value={fallback ?? NO_PREFERENCE}
|
||||
onValueChange={(value) => onFallbackChange(value === NO_PREFERENCE ? null : value)}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full" disabled={disabled}>
|
||||
<SelectValue placeholder="Library default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PREFERENCE}>Library default</SelectItem>
|
||||
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
|
||||
{namedOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<div className="border-border/60 divide-border/60 divide-y border-y">
|
||||
{entries.map(([source, target]) => (
|
||||
<div
|
||||
key={source}
|
||||
className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)_auto]"
|
||||
>
|
||||
<span className="truncate text-sm">{getLanguageName(source)}</span>
|
||||
<Select
|
||||
value={target}
|
||||
onValueChange={(value) =>
|
||||
void changeOverrides(
|
||||
withMetadataLanguageOverride(currentOverrides, source, value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`Metadata language for ${getLanguageName(source)}`}
|
||||
className="col-span-2 col-start-1 row-start-2 h-9 w-full sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-8"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
|
||||
{optionsForTarget(target).map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="col-start-2 row-start-1 size-9 sm:col-start-3 sm:size-8"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${getLanguageName(source)} exception`}
|
||||
onClick={() =>
|
||||
void changeOverrides(
|
||||
withoutMetadataLanguageOverride(currentOverrides, source),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableSources.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Select value={newSource} onValueChange={setNewSource}>
|
||||
<SelectTrigger
|
||||
aria-label="Original language for new exception"
|
||||
className="h-10 w-full min-w-0 flex-1 sm:h-9 sm:w-auto"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue placeholder="Choose original language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full sm:h-8 sm:w-auto"
|
||||
disabled={disabled || !newSource}
|
||||
onClick={addException}
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" aria-hidden="true" />
|
||||
Add exception
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user