fix(webapp): new-project dialog preselects the RECOMMENDED row (BEA-72) (#123)

The RECOMMENDED badge renders on options[0]; the initial selection was
hard-wired to "" (the last row, Empty project), so on any hub shipping
templates the dialog contradicted its own advice — and the default was the
option that produces a project with nothing to look at.

Hoist the options array above the state and seed `template` from
options[0].value: badge row and checked row are now the same element by
construction, not by coincidence. On a template-less hub options[0] is
"I already have a folder", which still creates an empty project.

The e2e test that pinned the old default now asserts the invariant (badged
row === checked row) rather than a title, and checks that creating without
touching the radios actually seeds the template.
This commit is contained in:
Snow Lee (Sungwon)
2026-08-05 17:25:53 +09:00
committed by GitHub
parent 5d399c3d31
commit 27cc558ac7
5 changed files with 46 additions and 34 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ classDiagram
OrgAdmin HubSettings ProjectSettings
Palette shell AccountBar ...
}
note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; 'Empty project' (value: the empty string) stays preselected so an unpicked create behaves exactly as it did before templates. modal.tsx keeps its one-field API — teaching it about choices would tax every other caller"
note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; the initial selection is options[0].value the same array element the RECOMMENDED badge indexes, so the badged row and the checked row are one row by construction (on a template-less hub that row is 'I already have a folder', which still creates an empty project). modal.tsx keeps its one-field API — teaching it about choices would tax every other caller"
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded"
note for components "FileView's transformHTML now drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row"
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
+14 -4
View File
@@ -116,17 +116,27 @@ test("the create dialog does not open itself when projects exist", async ({ page
await expect(page.locator(".modal-input")).toHaveCount(0);
});
test("new project via the sidebar + modal", async ({ page }) => {
test("new project via the sidebar + modal seeds the recommended option", async ({ page }) => {
await login(page);
await page.click("#projects .nav-add");
await page.fill(".modal-input", "scratch");
// The starting point defaults to an empty project, so this path still
// describes exactly what it did before templates existed.
// The badged row IS the checked row — asserted as an invariant, not by
// name, so reordering the options can't quietly split them apart again.
await expect(page.locator(".start-points")).toBeVisible();
await expect(page.locator(".start-point.on")).toContainText("Empty project");
const on = page.locator(".start-point.on");
const rec = page.locator(".start-point:has(.sp-rec)");
await expect(on).toHaveCount(1);
await expect(rec).toHaveCount(1);
expect(await on.textContent()).toBe(await rec.textContent());
await page.click(".modal .pbtn");
await page.waitForURL(/\/[0-9a-f-]{36}$/);
await expect(page.locator("#project-select")).toContainText("scratch");
// Creating without touching the radios seeds that template. Asserted on the
// file tree, not #content: a brand-new project's dashboard paints no
// treemap cells (#93).
for (const name of ["docs", "decisions", "AGENTS.md"]) {
await expect(page.locator("#sidebar").getByText(name, { exact: true }).first()).toBeVisible();
}
// Open the switcher: both projects listed; picking one navigates.
await page.click("#project-select");
await expect(page.getByRole("option", { name: "wiki" })).toBeVisible();
@@ -17,9 +17,11 @@ export const EXISTING = "__existing__";
// shape. This is a local useState over the Dialog we already have.
//
// The options come from /api/config, so a hub that ships another template
// needs no change here. "Empty project" is the synthetic first-class option
// (value "") and stays preselected: creating a project without picking
// anything must behave exactly as it did before templates existed.
// needs no change here. The first option is both the badged one and the
// preselected one — by construction, since the initial state is read off the
// same array the badge indexes into. On a hub shipping no templates that
// first option is "I already have a folder", which still creates an empty
// project.
export function NewProjectDialog({
templates,
onCreate,
@@ -29,10 +31,24 @@ export function NewProjectDialog({
onCreate: (name: string, template: string) => Promise<void>;
onClose: () => void;
}) {
// Recommended first, then the rest, then the two that seed nothing. The
// divider before them is doing real work: everything above answers "what
// should we put in it", everything below answers "nothing".
const options = [
...templates.map((t) => ({ value: t.name, title: t.title, blurb: t.blurb, rule: false })),
{
value: EXISTING,
title: "I already have a folder",
blurb: "nothing is seeded — connect it and your files stay as they are",
rule: true,
},
{ value: "", title: "Empty project", blurb: "just the folder", rule: false },
];
const [name, setName] = useState("");
// "" is an empty project; EXISTING is also an empty project — same artifact,
// different intent, and the intent is what the next screen needs to know.
const [template, setTemplate] = useState("");
const [template, setTemplate] = useState(options[0].value);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
@@ -50,20 +66,6 @@ export function NewProjectDialog({
}
};
// Recommended first, then the rest, then the two that seed nothing. The
// divider before them is doing real work: everything above answers "what
// should we put in it", everything below answers "nothing".
const options = [
...templates.map((t) => ({ value: t.name, title: t.title, blurb: t.blurb, rule: false })),
{
value: EXISTING,
title: "I already have a folder",
blurb: "nothing is seeded — connect it and your files stay as they are",
rule: true,
},
{ value: "", title: "Empty project", blurb: "just the folder", rule: false },
];
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="modal" showCloseButton={false}>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-C_yRHCCD.js"></script>
<script type="module" crossorigin src="/assets/index-ClP6ITud.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C52IQv2y.css">
</head>
<body>