summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
committerAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
commit254f90a4081a3f15dfe54277da922579dbaee254 (patch)
treef5f4e409196109baf938cd9d557e244242df18ce /src/features/plugins/ui
parent535215bfd1fee6087613108f42d28cd2807184d3 (diff)
downloaddispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.tar.gz
dispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.zip
feat(layout): plan 04 — ABI layout.applyOps + reset affordance
Diffstat (limited to 'src/features/plugins/ui')
-rw-r--r--src/features/plugins/ui/ResetLayoutConfirm.svelte30
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.svelte56
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.test.ts81
3 files changed, 167 insertions, 0 deletions
diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte
new file mode 100644
index 0000000..1f26601
--- /dev/null
+++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ /**
+ * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy
+ * names the consequence exactly: this workspace's layout returns to the
+ * default; plugin and dropdown customizations are lost (there is NO
+ * undo — the no-revert semantics make the reset the recovery path).
+ */
+ let {
+ onConfirm,
+ onCancel,
+ }: {
+ onConfirm: () => void;
+ onCancel: () => void;
+ } = $props();
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?">
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3>
+ <p class="py-3 text-sm">
+ This workspace's layout returns to the default; plugin and dropdown customizations are lost.
+ </p>
+ <div class="modal-action">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onCancel}></div>
+</div>
diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte
index bce885b..d1bb27a 100644
--- a/src/features/plugins/ui/WorkspacePluginModal.svelte
+++ b/src/features/plugins/ui/WorkspacePluginModal.svelte
@@ -2,17 +2,25 @@
import { onMount } from "svelte";
import type { PluginsStore } from "../store.svelte";
import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row";
+ import type { WorkspaceLayoutPort } from "../logic/types";
+ import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte";
let {
workspaceId,
workspaceTitle,
plugins,
+ layout,
onClose,
}: {
workspaceId: string;
/** The workspace's display title (modal heading). */
workspaceTitle: string;
plugins: PluginsStore;
+ /**
+ * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to
+ * default" affordance. Absent → the section doesn't render (tests).
+ */
+ layout?: WorkspaceLayoutPort;
onClose: () => void;
} = $props();
@@ -31,6 +39,21 @@
async function toggle(pluginId: string, checked: boolean): Promise<void> {
await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled");
}
+
+ // ── The reset affordance (plan 04 §4.4) ─────────────────────────────────
+ let resetOpen = $state(false);
+ let resetting = $state(false);
+ let resetError = $state<string | null>(null);
+
+ async function confirmReset(): Promise<void> {
+ if (layout === undefined || resetting) return;
+ resetting = true;
+ resetError = null;
+ const failure = await layout.reset(workspaceId);
+ resetting = false;
+ resetOpen = false;
+ if (failure !== null) resetError = failure;
+ }
</script>
<div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}>
@@ -101,6 +124,35 @@
</table>
{/if}
+ {#if layout}
+ <div class="mt-3 border-t border-base-300 pt-3">
+ <div class="flex items-center justify-between gap-3">
+ <div>
+ <p class="text-sm font-medium">Layout</p>
+ {#if layout.providerActive}
+ <p class="text-xs opacity-60">Managed by the active layout plugin.</p>
+ {:else}
+ <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-warning btn-sm"
+ disabled={!layout.providerActive || resetting}
+ title={layout.providerActive
+ ? "Restore this workspace's default layout"
+ : "No layout plugin enabled"}
+ onclick={() => (resetOpen = true)}
+ >
+ Reset layout to default
+ </button>
+ </div>
+ {#if resetError}
+ <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p>
+ {/if}
+ </div>
+ {/if}
+
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick={onClose}>Close</button>
</div>
@@ -108,3 +160,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={onClose}></div>
</div>
+
+{#if resetOpen && layout}
+ <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} />
+{/if}
diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts
index 32e88d7..6bc7179 100644
--- a/src/features/plugins/ui/WorkspacePluginModal.test.ts
+++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { PluginsHttp } from "../adapter/http";
+import type { WorkspaceLayoutPort } from "../logic/types";
import { createPluginsStore } from "../store.svelte";
import WorkspacePluginModal from "./WorkspacePluginModal.svelte";
@@ -46,6 +47,14 @@ async function renderModal(http: PluginsHttp = fakeHttp()) {
return { store, http };
}
+/** A layout port (plan 04 §4.4) scripted per test. */
+function fakeLayoutPort(
+ providerActive = true,
+ reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null),
+): WorkspaceLayoutPort {
+ return { providerActive, reset };
+}
+
describe("WorkspacePluginModal", () => {
it("renders the workspace's plugin rows", async () => {
await renderModal();
@@ -122,3 +131,75 @@ describe("WorkspacePluginModal", () => {
expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument();
});
});
+
+describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => {
+ async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) {
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ ...(layout ? { layout } : {}),
+ onClose: vi.fn(),
+ },
+ });
+ return layout;
+ }
+
+ it("renders an ENABLED reset button when a layout provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(true));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeEnabled();
+ });
+
+ it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(false));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeDisabled();
+ expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i);
+ expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument();
+ });
+
+ it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => {
+ await renderWithLayout(undefined);
+ expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull();
+ });
+
+ it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ // The confirm dialog names the consequence exactly (§4.4 copy).
+ expect(
+ screen.getByText(
+ /layout returns to the default; plugin and dropdown customizations are lost/i,
+ ),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(reset).toHaveBeenCalledWith("ws");
+ });
+
+ it("cancelling the dialog never resets", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(reset).not.toHaveBeenCalled();
+ });
+
+ it("a failed reset surfaces the reason as an alert", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => "no layout plugin enabled");
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i);
+ });
+});