fix(settings): one action row on the Dawarich card

The connected card showed the home country field and a second row of
buttons (Choose home, Save) above the card's own row of details, menu
and primary action. The home editor now opens from the card menu, or
from a prompt while the home place or country is missing, and carries
its own Cancel and Save only while it is open.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:15:29 +02:00
1 parent ff583104a8
commit c849619bee
2 files changed
+222 -92

No files matched your search

@@ -0,0 +1,99 @@
/**
* The connected Dawarich card has one action row.
*
* The home place and home country used to sit permanently in the card body
* with their own row of buttons (Choose home, Save) above the card's own row
* of details, menu and primary action. The editor now opens from the menu, or
* from a prompt while nothing is set, and only then carries its own Cancel and
* Save.
*/
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
const status = vi.hoisted(() => ({
current: { homePlaceSet: true, homeCountry: "DE", daysStored: 12 },
}));
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) =>
JSON.stringify(queryKey).includes("places")
? { data: { places: [] }, isLoading: false, isError: false }
: { data: status.current, isLoading: false, isError: false },
}));
let lastCardProps: IntegrationCardProps | null = null;
vi.mock("../integration-card", async (importOriginal) => {
const actual = await importOriginal<typeof import("../integration-card")>();
return {
...actual,
IntegrationCard: (props: IntegrationCardProps) => {
lastCardProps = props;
return createElement(actual.IntegrationCard, props);
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import type { IntegrationCardProps } from "../integration-card";
import { DawarichCard } from "../dawarich-card";
import type { IntegrationStatusViewModel } from "../shared";
import { healthFixture } from "./health-fixture";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DawarichCard
viewModel={
{
integration: "dawarich",
connected: true,
configured: true,
health: healthFixture(),
} as unknown as IntegrationStatusViewModel
}
/>
</I18nProvider>,
);
}
function actionRows(html: string): number {
return (
html.match(/data-slot="(?:settings-card-actions|integration-actions)"/g) ??
[]
).length;
}
describe("DawarichCard", () => {
it("has exactly one action row while the home editor is closed", () => {
status.current = { homePlaceSet: true, homeCountry: "DE", daysStored: 12 };
const html = render();
expect(actionRows(html)).toBe(1);
expect(html).toContain('data-slot="integration-actions"');
expect(html).not.toContain('data-testid="dawarich-save-home"');
expect(html).not.toContain('id="dawarich-home-country"');
// Nothing to prompt for: home and country are both set.
expect(html).not.toContain('data-testid="dawarich-pick-home"');
});
it("opens the home editor from the menu", () => {
render();
const home = lastCardProps?.menu?.find((item) => item.id === "home");
expect(home?.onSelect).toBeTypeOf("function");
expect(home?.label).toBe("Choose home");
});
it("prompts inline while the home place or country is missing", () => {
status.current = {
homePlaceSet: false,
homeCountry: null as never,
daysStored: 0,
};
const html = render();
expect(html).toContain('data-testid="dawarich-pick-home"');
expect(html).toContain('data-slot="inline-action"');
expect(actionRows(html)).toBe(1);
});
});
@@ -48,6 +48,7 @@ import {
} from "./setup-guide-link";
import type { IntegrationStatusViewModel } from "./shared";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { InlineAction } from "@/components/ui/inline-action";
interface DawarichStatus {
connected: boolean;
@@ -91,6 +92,7 @@ export function DawarichCard({
const connected = viewModel?.connected ?? false;
const test = useConnectionTest("/api/dawarich/test");
const [editing, setEditing] = useState(false);
const [homeEditing, setHomeEditing] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);
const refresh = () => {
@@ -181,6 +183,12 @@ export function DawarichCard({
disabled: test.testing,
onSelect: test.run,
},
{
id: "home",
label: t("settings.dawarichHomePlaceChange"),
icon: MapPinned,
onSelect: () => setHomeEditing(true),
},
{
id: "credentials",
label: t("settings.integrationCard.menu.address"),
@@ -231,22 +239,35 @@ export function DawarichCard({
primary={{ onReconnect: openForm, onResume: openForm }}
menu={menu}
>
<DawarichHome />
<DawarichHome editing={homeEditing} onEditingChange={setHomeEditing} />
<ActionResultLines provider="dawarich" test={test} />
{editing ? form : null}
</IntegrationCard>
);
}
/** The home place and home country, and what is stored. */
function DawarichHome() {
/**
* The home place and home country, and what is stored.
*
* The editor is closed by default and opens from the card menu (or from the
* prompt when nothing is set yet), like the address form beside it. Open, it
* carries its own Cancel and Save; closed, the card has exactly one action
* row, its own, at the bottom (UI standards §12). It used to show the
* country field and a second always-on row of buttons above the card's.
*/
function DawarichHome({
editing,
onEditingChange,
}: {
editing: boolean;
onEditingChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const status = useQuery({
queryKey: queryKeys.dawarichStatus(),
queryFn: () => apiGet<DawarichStatus>("/api/dawarich/status"),
});
const [picking, setPicking] = useState(false);
const [placeId, setPlaceId] = useState("");
const [country, setCountry] = useState("");
const places = useQuery({
@@ -255,7 +276,7 @@ function DawarichHome() {
apiGet<{ places: Array<{ id: number; name: string }> }>(
"/api/dawarich/places",
),
enabled: picking,
enabled: editing,
});
const save = useMutation({
@@ -265,7 +286,7 @@ function DawarichHome() {
}) => apiPatch("/api/dawarich/settings", body),
onSuccess: () => {
toast.success(t("settings.dawarichHomeSaved"));
setPicking(false);
onEditingChange(false);
setPlaceId("");
setCountry("");
queryClient.invalidateQueries({ queryKey: queryKeys.dawarichStatus() });
@@ -293,95 +314,105 @@ function DawarichHome() {
<p className="text-muted-foreground text-xs">
{t("settings.dawarichDaysStored", { count: data.daysStored })}
</p>
{picking ? (
<div className="space-y-1.5">
<Label htmlFor="dawarich-home-place">
{t("settings.dawarichHomePlace")}
</Label>
{places.isError ? (
<QueryErrorRow
inset
error={places.error}
slot="dawarich-places-error"
message={t(errorKeyOf(places.error))}
onRetry={() => void places.refetch()}
/>
) : (
<NativeSelect
id="dawarich-home-place"
value={placeId}
disabled={places.isLoading}
onChange={(e) => setPlaceId(e.target.value)}
>
<option value="">{t("settings.dawarichHomePlaceChoose")}</option>
{(places.data?.places ?? []).map((place) => (
<option key={place.id} value={String(place.id)}>
{place.name}
{!editing && (!data.homePlaceSet || !data.homeCountry) ? (
<InlineAction
onClick={() => onEditingChange(true)}
data-testid="dawarich-pick-home"
>
<MapPinned aria-hidden="true" />
{t("settings.dawarichHomePlaceChange")}
</InlineAction>
) : null}
{editing ? (
<div className="space-y-3" data-slot="dawarich-home-editor">
<div className="space-y-1.5">
<Label htmlFor="dawarich-home-place">
{t("settings.dawarichHomePlace")}
</Label>
{places.isError ? (
<QueryErrorRow
inset
error={places.error}
slot="dawarich-places-error"
message={t(errorKeyOf(places.error))}
onRetry={() => void places.refetch()}
/>
) : (
<NativeSelect
id="dawarich-home-place"
value={placeId}
disabled={places.isLoading}
onChange={(e) => setPlaceId(e.target.value)}
>
<option value="">
{t("settings.dawarichHomePlaceChoose")}
</option>
))}
</NativeSelect>
)}
{(places.data?.places ?? []).map((place) => (
<option key={place.id} value={String(place.id)}>
{place.name}
</option>
))}
</NativeSelect>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="dawarich-home-country" className="flex">
{t("settings.dawarichHomeCountry")}
</Label>
<Input
id="dawarich-home-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder={data.homeCountry ?? "DE"}
maxLength={2}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
className="w-24"
/>
<p className="text-muted-foreground text-xs">
{t("settings.dawarichHomeCountryHelp")}
</p>
</div>
<SettingsCardActions>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => onEditingChange(false)}
data-testid="dawarich-cancel-home"
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={
save.isPending ||
(placeId === "" && !/^[A-Z]{2}$/.test(countryValue))
}
onClick={() =>
save.mutate({
...(placeId !== "" ? { homePlaceId: Number(placeId) } : {}),
...(/^[A-Z]{2}$/.test(countryValue)
? { homeCountry: countryValue }
: {}),
})
}
data-testid="dawarich-save-home"
>
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="size-3.5" aria-hidden="true" />
)}
{t("settings.dawarichHomeSave")}
</Button>
</SettingsCardActions>
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="dawarich-home-country" className="flex">
{t("settings.dawarichHomeCountry")}
</Label>
<Input
id="dawarich-home-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder={data.homeCountry ?? "DE"}
maxLength={2}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
className="w-24"
/>
<p className="text-muted-foreground text-xs">
{t("settings.dawarichHomeCountryHelp")}
</p>
</div>
<SettingsCardActions>
{picking ? null : (
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => setPicking(true)}
data-testid="dawarich-pick-home"
>
<MapPinned className="size-3.5" aria-hidden="true" />
{t("settings.dawarichHomePlaceChange")}
</Button>
)}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={
save.isPending ||
(placeId === "" && !/^[A-Z]{2}$/.test(countryValue))
}
onClick={() =>
save.mutate({
...(placeId !== "" ? { homePlaceId: Number(placeId) } : {}),
...(/^[A-Z]{2}$/.test(countryValue)
? { homeCountry: countryValue }
: {}),
})
}
data-testid="dawarich-save-home"
>
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="size-3.5" aria-hidden="true" />
)}
{t("settings.dawarichHomeSave")}
</Button>
</SettingsCardActions>
</div>
);
}