Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

140 changes: 140 additions & 0 deletions app/src/features/account/ArchivedSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Settings, "Archived": the bots and crews the owner archived, which the
// rest of the app does not show, with a way to delete each for good
// (spec 7.6).

import { Users } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { fromNow } from "../../lib/format";
import type { Archive, Bot, Crew } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { BotAvatar } from "../bots/BotAvatar";
import { DeleteBot } from "../bots/DeleteBot";
import { DeleteCrew } from "../crews/DeleteCrew";
import { Section } from "./settingsParts";

type Asking = { bot: Bot } | { crew: Crew } | null;

export function ArchivedSettings() {
const s = useT().account.settings;
const api = useApi();
const active = useApp((state) => state.crews);
const [archive, setArchive] = useState<Archive | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const [asking, setAsking] = useState<Asking>(null);

useEffect(() => {
let alive = true;
api.call("archive.list").then(
(list) => alive && setArchive(list),
(error) => alive && setFailed(errorText(error)),
);
return () => {
alive = false;
};
}, [api]);

if (failed) {
return (
<Section title={s.archived}>
<Callout tone="danger" title={s.archivedLoadFailed}>
{failed}
</Callout>
</Section>
);
}
if (!archive) {
return <Section title={s.archived}>{null}</Section>;
}

// A bot of an archived crew goes with its crew; the others are listed.
const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id);
const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId));
const forget = (gone: Asking) =>
setArchive({
crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)),
bots: archive.bots.filter(
(bot) =>
!(gone && "bot" in gone && gone.bot.id === bot.id) &&
!(gone && "crew" in gone && gone.crew.id === bot.crewId),
),
});

return (
<Section title={s.archived}>
<p className="text-ink-soft text-sm leading-relaxed">{s.archivedIntro}</p>
{archive.crews.length + bots.length === 0 ? (
<p className="text-muted text-sm">{s.archivedEmpty}</p>
) : (
<ul aria-label={s.archived} className="flex flex-col divide-y divide-line">
{archive.crews.map((crew) => (
<Row
key={crew.id}
icon={
<span className="grid size-7 place-items-center rounded-full bg-sunken text-muted">
<Users aria-hidden size={14} />
</span>
}
name={crew.name}
line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))}
onDelete={() => setAsking({ crew })}
/>
))}
{bots.map((bot) => (
<Row
key={bot.id}
icon={<BotAvatar color={bot.color} size={28} />}
name={bot.name}
line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))}
onDelete={() => setAsking({ bot })}
/>
))}
</ul>
)}
{asking && "bot" in asking && (
<DeleteBot
bot={asking.bot}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
{asking && "crew" in asking && (
<DeleteCrew
crew={asking.crew}
bots={botsOf(asking.crew).length}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
</Section>
);
}

function Row({
icon,
name,
line,
onDelete,
}: {
icon: ReactNode;
name: string;
line: string;
onDelete(): void;
}) {
const s = useT().account.settings;
return (
<li className="flex items-center gap-3 py-2">
{icon}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{name}</p>
<p className="truncate text-muted text-xs">{line}</p>
</div>
<Button variant="danger" size="sm" label={s.deleteArchivedOne(name)} onClick={onDelete}>
{s.deleteArchived}
</Button>
</li>
);
}
9 changes: 7 additions & 2 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Settings (spec 15.1), in parts listed on the left: general (what
// Botloft does in the background, the language), the chat, notifications,
// the look, and the versions for when something needs reporting.
// the look, what was archived, and the versions for when something needs
// reporting.

import {
Archive,
Bell,
Info,
type LucideIcon,
Expand All @@ -16,16 +18,18 @@ import { Dialog } from "../../ui/Dialog";
import { AboutSettings } from "./AboutSettings";
import { AlertsSettings } from "./AlertsSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { ArchivedSettings } from "./ArchivedSettings";
import { ChatSettings } from "./ChatSettings";
import { GeneralSettings } from "./GeneralSettings";

type Page = "general" | "chat" | "alerts" | "appearance" | "about";
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about";

const PAGES: { id: Page; icon: LucideIcon }[] = [
{ id: "general", icon: SlidersHorizontal },
{ id: "chat", icon: MessageSquare },
{ id: "alerts", icon: Bell },
{ id: "appearance", icon: Palette },
{ id: "archived", icon: Archive },
{ id: "about", icon: Info },
];

Expand Down Expand Up @@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
{page === "chat" && <ChatSettings />}
{page === "alerts" && <AlertsSettings />}
{page === "appearance" && <AppearanceSettings />}
{page === "archived" && <ArchivedSettings />}
{page === "about" && <AboutSettings />}
</div>
</div>
Expand Down
90 changes: 90 additions & 0 deletions app/src/features/account/archived.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { RpcError } from "../../lib/rpc";
import { crewOpened, renderApp } from "../../test/app";

afterEach(cleanup);

/** Opens Settings on its "Archived" part. */
async function openArchived() {
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
const dialog = screen.getByRole("dialog", { name: "Settings" });
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
return dialog;
}

/** Ops with Writer and an archived Scout, and an archived crew Docs of one bot. */
async function someArchived() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const docs = fake.addCrew("Docs");
const scout = fake.addBot(ops.id, "Scout");
fake.addBot(ops.id, "Writer");
const editor = fake.addBot(docs.id, "Editor");
await fake.call("bots.archive", { botId: scout.id });
await fake.call("crews.archive", { crewId: docs.id });
renderApp(fake);
await crewOpened("Ops");
return { fake, ops, docs, scout, editor };
}

describe("settings, archived", () => {
test("lists what was archived, which the app shows nowhere else", async () => {
await someArchived();
const dialog = await openArchived();
const list = await within(dialog).findByRole("list", { name: "Archived" });
const rows = within(list).getAllByRole("listitem");
expect(rows.map((row) => row.textContent)).toEqual([
"DocsCrew · 1 bot · archived nowDelete",
"ScoutBot in Ops · archived nowDelete",
]);
// A bot of an archived crew goes with its crew, not on its own.
expect(within(list).queryByText("Editor")).toBeNull();
expect(dialog.textContent).toContain("Botloft still keeps their conversations");
});

test("deletes an archived bot, saying it leaves rather than stops", async () => {
const { fake, scout } = await someArchived();
const dialog = await openArchived();
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Scout" }));
const confirm = screen.getByRole("dialog", { name: "Delete Scout?" });
expect(confirm.textContent).toContain("Scout leaves Botloft for good");
expect(confirm.textContent).not.toContain("stops now");
expect(within(confirm).getByText(scout.workspace)).toBeDefined();
fireEvent.click(within(confirm).getByRole("button", { name: "Delete bot" }));
await waitFor(() => expect(within(dialog).queryByText("Scout")).toBeNull());
expect(fake.bots.has(scout.id)).toBe(false);
expect(within(dialog).getByText("Docs")).toBeDefined();
});

test("deletes an archived crew with its bots, then has nothing left", async () => {
const { fake, docs, scout, editor } = await someArchived();
await fake.call("bots.delete", { botId: scout.id });
const dialog = await openArchived();
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Docs" }));
const confirm = screen.getByRole("dialog", { name: "Delete Docs?" });
expect(confirm.textContent).toContain("Docs and its bot leave Botloft for good");
expect(within(confirm).getByText(docs.workFolder)).toBeDefined();
fireEvent.click(within(confirm).getByRole("button", { name: "Delete crew" }));
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();
expect(fake.crews.has(docs.id)).toBe(false);
expect(fake.bots.has(editor.id)).toBe(false);
});

test("says so when nothing is archived, or when the list cannot be read", async () => {
const fake = new FakeBotloft();
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");
const dialog = await openArchived();
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();

fake.failNext("archive.list", new RpcError(-32603, "internal error"));
fireEvent.click(within(dialog).getByRole("tab", { name: "About" }));
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
const alert = await within(dialog).findByRole("alert");
expect(alert.textContent).toContain("Could not load what is archived");
});
});
55 changes: 55 additions & 0 deletions app/src/features/bots/DeleteBot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Confirm } from "../../ui/Confirm";
import { KeptFolder } from "../../ui/KeptFolder";
import { attempt } from "../../ui/toast";

/**
* Asks before deleting a bot for good (spec 7.6), saying what goes and
* what stays, with the choice to send its folder to the Recycle Bin.
* `chiefOf` is the crew it leads, if it leads one.
*/
export function DeleteBot({
bot,
chiefOf,
onClose,
onDeleted,
}: {
bot: Bot;
chiefOf?: string | undefined;
onClose(): void;
onDeleted?(): void;
}) {
const words = useT().bots.header;
const api = useApi();
const dropBot = useApp((state) => state.dropBot);
// The folder stays unless the owner says otherwise.
const [recycle, setRecycle] = useState(false);
return (
<Confirm
title={words.deleteConfirm.title(bot.name)}
confirmLabel={words.deleteConfirm.confirm}
onClose={onClose}
onConfirm={() =>
attempt(words.failed.delete, async () => {
const asked = recycle ? { botId: bot.id, recycleFolder: true } : { botId: bot.id };
dropBot((await api.call("bots.delete", asked)).botId);
onDeleted?.();
})
}
>
<p>
{words.deleteConfirm.removed(bot.name, bot.archivedAt === null)}
{chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`}
</p>
<KeptFolder
path={bot.workspace}
recycle={{ label: words.deleteConfirm.recycle, checked: recycle, onChange: setRecycle }}
>
{recycle ? words.deleteConfirm.recycled(bot.name) : words.deleteConfirm.kept(bot.name)}
</KeptFolder>
</Confirm>
);
}
8 changes: 6 additions & 2 deletions app/src/features/bots/botActions.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Archive, Crown, FolderOpen, Pencil, RefreshCcw } from "lucide-react";
import { Archive, Crown, FolderOpen, Pencil, RefreshCcw, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
Expand All @@ -8,8 +8,9 @@ import type { MenuItem } from "../../ui/Menu";
import { attempt } from "../../ui/toast";
import { BotDialog } from "./BotDialog";
import { isChief } from "./ChiefBadge";
import { DeleteBot } from "./DeleteBot";

type Open = "edit" | "archive" | "fresh" | null;
type Open = "edit" | "archive" | "delete" | "fresh" | null;

/**
* What the owner can do with a bot from a menu, and the dialogs those
Expand Down Expand Up @@ -54,6 +55,7 @@ export function useBotActions(
onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)),
},
{ label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") },
{ label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") },
];

const dialogs =
Expand Down Expand Up @@ -85,6 +87,8 @@ export function useBotActions(
>
{words.archiveConfirm.body}
</Confirm>
) : open === "delete" ? (
<DeleteBot bot={bot} chiefOf={chief ? crew.name : undefined} onClose={close} />
) : null;

return { items, dialogs };
Expand Down
Loading
Loading