diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index 1c9679c..59302d6 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -120,10 +120,11 @@ classDiagram class ProjectDB { -repo ProjectRepo -byID - +Get +Create +Rename +List + +Get +Create +Update +Rename +List } class Project { +ID +Name +Org +Created + +Description +Icon } class ShareDB { diff --git a/internal/webapp/admin.go b/internal/webapp/admin.go index dc8e9dd..5f3e025 100644 --- a/internal/webapp/admin.go +++ b/internal/webapp/admin.go @@ -24,8 +24,11 @@ func (s *Server) projectOwner(r *http.Request, projectID string) bool { return s.Dir.Role(org, s.requestUser(r).Email) == RoleOwner } -// handleProjectRename renames a project. Owner of its org only. -func (s *Server) handleProjectRename(w http.ResponseWriter, r *http.Request) { +// handleProjectUpdate edits a project's name, description and icon. Owner of +// its org only. It's a partial update: every field is a pointer, so only the +// keys actually present in the body change — {"description":""} clears the +// description, omitting the key leaves it alone. +func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("project") if s.Projects == nil { http.Error(w, "this server does not host projects", http.StatusNotFound) @@ -40,13 +43,15 @@ func (s *Server) handleProjectRename(w http.ResponseWriter, r *http.Request) { return } var req struct { - Name string `json:"name"` + Name *string `json:"name"` + Description *string `json:"description"` + Icon *string `json:"icon"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } - if err := s.Projects.Rename(id, req.Name); err != nil { + if err := s.Projects.Update(id, req.Name, req.Description, req.Icon); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } diff --git a/internal/webapp/db_conformance_test.go b/internal/webapp/db_conformance_test.go index 8b0548a..684fbcc 100644 --- a/internal/webapp/db_conformance_test.go +++ b/internal/webapp/db_conformance_test.go @@ -129,6 +129,10 @@ func TestMetaStoreConformance(t *testing.T) { if err := projects.Rename(p1.ID, "handbook"); err != nil { t.Fatal(err) } + desc, icon := "everything support needs", "book-open" + if err := projects.Update(p1.ID, nil, &desc, &icon); err != nil { + t.Fatal(err) + } p2, _, _ := projects.GetOrCreate("scratch", "o-1") if err := projects.Delete(p2.ID); err != nil { t.Fatal(err) @@ -221,6 +225,12 @@ func TestMetaStoreConformance(t *testing.T) { if !ok || hb.Name != "handbook" { t.Fatalf("rename lost across reload: %+v", hb) } + // Description/icon are the columns migrate() has to ADD to an + // already-created projects table; the reopen above already ran + // migrate() a second time, so surviving here proves it's a no-op. + if hb.Description != "everything support needs" || hb.Icon != "book-open" { + t.Fatalf("description/icon lost across reload: %+v", hb) + } orgs2, _ := NewOrgDB(st2.Orgs()) ro, ok := orgs2.Get(org.ID) diff --git a/internal/webapp/db_sql.go b/internal/webapp/db_sql.go index 100d64f..1fa0103 100644 --- a/internal/webapp/db_sql.go +++ b/internal/webapp/db_sql.go @@ -167,6 +167,40 @@ func (s *sqlMetaStore) migrate() error { return fmt.Errorf("migrate: %w", err) } } + // Columns added after the tables shipped. CREATE TABLE IF NOT EXISTS does + // nothing for an existing table, so these need a real (idempotent) ALTER. + return s.addColumns("projects", map[string]string{ + "description": `TEXT NOT NULL DEFAULT ''`, + "icon": `TEXT NOT NULL DEFAULT ''`, + }) +} + +// addColumns adds any of cols that the table doesn't already have. The live +// column set comes from an empty result set's metadata, which both drivers +// (modernc/sqlite and pgx) report the same way — no engine-specific catalog +// query, and safe to run on every start. +func (s *sqlMetaStore) addColumns(table string, cols map[string]string) error { + rows, err := s.db.Query(`SELECT * FROM ` + table + ` LIMIT 0`) + if err != nil { + return fmt.Errorf("migrate %s: %w", table, err) + } + names, err := rows.Columns() + rows.Close() + if err != nil { + return fmt.Errorf("migrate %s: %w", table, err) + } + have := make(map[string]bool, len(names)) + for _, n := range names { + have[strings.ToLower(n)] = true + } + for col, spec := range cols { + if have[col] { + continue + } + if _, err := s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + col + ` ` + spec); err != nil { + return fmt.Errorf("migrate %s.%s: %w", table, col, err) + } + } return nil } @@ -259,7 +293,7 @@ func (r *sqlAccountRepo) PutPolicy(p authPolicy) error { type sqlProjectRepo struct{ s *sqlMetaStore } func (r *sqlProjectRepo) Load() ([]Project, error) { - rows, err := r.s.db.Query(`SELECT id, name, org, created FROM projects`) + rows, err := r.s.db.Query(`SELECT id, name, org, created, description, icon FROM projects`) if err != nil { return nil, err } @@ -268,7 +302,7 @@ func (r *sqlProjectRepo) Load() ([]Project, error) { for rows.Next() { var p Project var created string - if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created, &p.Description, &p.Icon); err != nil { return nil, err } p.Created = tdec(created) @@ -278,9 +312,10 @@ func (r *sqlProjectRepo) Load() ([]Project, error) { } func (r *sqlProjectRepo) Put(p Project) error { - return r.s.exec(`INSERT INTO projects (id,name,org,created) VALUES (?,?,?,?) - ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created`, - p.ID, p.Name, p.Org, tenc(p.Created)) + return r.s.exec(`INSERT INTO projects (id,name,org,created,description,icon) VALUES (?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created, + description=excluded.description, icon=excluded.icon`, + p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon) } func (r *sqlProjectRepo) Delete(id string) error { diff --git a/internal/webapp/frontend/e2e/admin.spec.ts b/internal/webapp/frontend/e2e/admin.spec.ts index 184de0b..b5f3001 100644 --- a/internal/webapp/frontend/e2e/admin.spec.ts +++ b/internal/webapp/frontend/e2e/admin.spec.ts @@ -92,20 +92,27 @@ test("org admin: public share audit lists and revokes", async ({ page }) => { await expect(page.locator(".admin-item", { hasText: "index.md" })).toHaveCount(0); }); -test("org admin: project rename (delete lives on project settings)", async ({ page }) => { +test("org admin: the project list is read-only; rename lives on project settings", async ({ + page, +}) => { await login(page); const made = await (await page.request.post("/api/projects", { data: { name: "doomed" } })).json(); await page.reload(); // pick up the new project await openOrgSettings(page); const row = page.locator(".admin-item", { hasText: "doomed" }); - await row.locator(".ai-btn", { hasText: "Rename" }).click(); - await page.fill(".modal-input", "doomed-2"); - await page.click(".modal .pbtn"); - await expectToast(page, "Renamed"); - const row2 = page.locator(".admin-item", { hasText: "doomed-2" }); - await expect(row2).toBeVisible(); - // The one-click delete is gone: Settings' type-the-name flow is the only way. - await expect(row2.locator(".ai-del")).toHaveCount(0); + await expect(row).toBeVisible(); + // Neither affordance lives here any more: renaming and deleting a project + // both happen on the project's own Settings page. + await expect(row.locator(".ai-btn", { hasText: "Rename" })).toHaveCount(0); + await expect(row.locator(".ai-del")).toHaveCount(0); + + // …and renaming there works, showing up in the nav. + await page.goto(`/${made.project.id}/settings`); + await page.fill("#ps-name", "doomed-2"); + await page.click("#ps-save"); + await expectToast(page, "Saved"); + await expect(page.locator("#projects .proj-trigger")).toContainText("doomed-2"); + await page.request.delete("/api/projects/" + made.project.id); // clean up }); diff --git a/internal/webapp/frontend/e2e/home.spec.ts b/internal/webapp/frontend/e2e/home.spec.ts index b316f72..1754bbe 100644 --- a/internal/webapp/frontend/e2e/home.spec.ts +++ b/internal/webapp/frontend/e2e/home.spec.ts @@ -209,12 +209,68 @@ test("project settings: danger zone is owner-only", async ({ page }) => { await expect(page.locator(".ps-danger .danger-btn")).toHaveText("Delete project"); }); -test("project settings: a member sees no danger zone", async ({ page }) => { +test("project settings: a member sees no danger zone and cannot edit", async ({ page }) => { await login(page, MEMBER); const pid = await wikiId(page); await page.goto(`/${pid}/settings`); await expect(page.locator(".project-settings h2")).toHaveText("wiki"); // page rendered await expect(page.locator(".ps-danger")).toHaveCount(0); + // The General card is shown, disabled — not hidden, and with no way to submit. + await expect(page.locator("#ps-name")).toBeDisabled(); + await expect(page.locator("#ps-desc")).toBeDisabled(); + await expect(page.locator("#ps-icon-btn")).toBeDisabled(); + await expect(page.locator("#ps-save")).toHaveCount(0); +}); + +test("project settings: icon + description save, and show in nav and dashboard", async ({ + page, +}) => { + await login(page); + const made = await (await page.request.post("/api/projects", { data: { name: "dressed" } })).json(); + const pid = made.project.id; + await page.goto(`/${pid}/settings`); + + // Nothing dirty yet → nothing to save. + await expect(page.locator("#ps-save")).toBeDisabled(); + // Placeholder until an icon is picked. + await expect(page.locator(".ps-icon-row .proj-mark svg")).toHaveCount(1); + + await page.click("#ps-icon-btn"); + await page.click('.ps-icon-grid [aria-label="book-open"]'); + await page.fill("#ps-desc", "everything support needs"); + await expect(page.locator(".ps-count")).toHaveText("24 / 280"); + await expect(page.locator("#ps-save")).toBeEnabled(); + await page.click("#ps-save"); + await expectToast(page, "Saved"); + await expect(page.locator("#ps-save")).toBeDisabled(); // clean again + + // Both surfaces pick it up without a reload: the nav mark right here, and + // the project header on the next SPA navigation (same header component the + // project home renders). + await expect(page.locator("#projects .proj-trigger .proj-mark svg")).toHaveCount(1); + await page.click("#nav-install"); + await expect(page.locator(".in-desc")).toHaveText("everything support needs"); + await expect(page.locator(".gd-head .proj-mark svg")).toHaveCount(1); + + // …and it survives a reload, i.e. it really was persisted — on the project + // home header, and back in the form. + await page.goto(`/${pid}`); + await expect(page.locator(".in-desc")).toHaveText("everything support needs"); + await expect(page.locator(".gd-head .proj-mark svg")).toHaveCount(1); + await page.goto(`/${pid}/settings`); + await expect(page.locator("#ps-desc")).toHaveValue("everything support needs"); + + await page.request.delete("/api/projects/" + pid); // clean up +}); + +test("project settings: an over-long description is refused inline", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.goto(`/${pid}/settings`); + await page.fill("#ps-desc", "x".repeat(281)); + await page.click("#ps-save"); + await expect(page.locator("#ps-desc-err")).toBeVisible(); + await expect(page.locator("#ps-desc")).toHaveValue("x".repeat(281)); // form not cleared }); test("project settings: delete needs the exact name typed, then navigates away", async ({ page }) => { diff --git a/internal/webapp/frontend/src/api/types.ts b/internal/webapp/frontend/src/api/types.ts index 5e69a28..fe297ac 100644 --- a/internal/webapp/frontend/src/api/types.ts +++ b/internal/webapp/frontend/src/api/types.ts @@ -25,6 +25,9 @@ export interface Project { name: string; org?: string; created?: string; + description?: string; + /** lucide icon name (kebab-case); unknown or absent → the folder placeholder */ + icon?: string; } export interface ProjectList { diff --git a/internal/webapp/frontend/src/apps/HubApp.tsx b/internal/webapp/frontend/src/apps/HubApp.tsx index 8499363..bf1344e 100644 --- a/internal/webapp/frontend/src/apps/HubApp.tsx +++ b/internal/webapp/frontend/src/apps/HubApp.tsx @@ -165,7 +165,6 @@ export default function HubApp({ config }: { config: ServerConfig }) { org={routeOrg} projects={projects} myEmail={config.me?.email || ""} - onProjectsChanged={refresh} /> ), } diff --git a/internal/webapp/frontend/src/components/ConnectGuide.tsx b/internal/webapp/frontend/src/components/ConnectGuide.tsx index 651e3ba..1878d41 100644 --- a/internal/webapp/frontend/src/components/ConnectGuide.tsx +++ b/internal/webapp/frontend/src/components/ConnectGuide.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import type { Project } from "../api/types"; import { copyText } from "../util"; +import { ProjectIcon } from "./shell"; +import { projColor } from "./ProjectNav"; /* ---- project home guide ---- How to mount the project as a local folder and connect a coding agent, @@ -149,7 +151,17 @@ export function ConnectGuide({ project }: { project: Project }) { return (
-

{project.name}

+

+ + {project.name} +

+ {project.description &&

{project.description}

}

Mount this project as a folder on any machine and connect your coding agent: files sync both ways in the background, every change is journaled with who made it, and agent reads feed diff --git a/internal/webapp/frontend/src/components/OrgAdmin.tsx b/internal/webapp/frontend/src/components/OrgAdmin.tsx index a662d9c..92602b4 100644 --- a/internal/webapp/frontend/src/components/OrgAdmin.tsx +++ b/internal/webapp/frontend/src/components/OrgAdmin.tsx @@ -13,7 +13,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { api, getJSON, postJSON } from "../api/http"; import type { Org, OrgInviteInfo, OrgShareInfo, Project } from "../api/types"; -import { modalConfirm, modalPrompt } from "../modal"; +import { modalConfirm } from "../modal"; import { toast } from "../toast"; import { copyText } from "../util"; import { Button } from "@/components/ui/button"; @@ -59,12 +59,10 @@ export function OrgAdmin({ org, projects, myEmail, - onProjectsChanged, }: { org: Org; projects: Project[]; myEmail: string; - onProjectsChanged: () => Promise; }) { const qc = useQueryClient(); const owner = org.role === "owner"; @@ -145,51 +143,20 @@ export function OrgAdmin({

Members

- {!owner && ( - <> -

Projects

-
- {orgProjects.length === 0 &&
No projects yet.
} - {orgProjects.map((p) => ( -
- {p.name} -
- ))} + {/* Read-only for everybody: a project is renamed (and deleted) from its + own Settings page, so this list is the same whatever your role. */} +

Projects

+
+ {orgProjects.length === 0 &&
No projects yet.
} + {orgProjects.map((p) => ( +
+ {p.name}
- - )} + ))} +
{owner && ( <> -

Projects

-
- {orgProjects.length === 0 &&
No projects yet.
} - {orgProjects.map((p) => ( -
- {p.name} - - {/* Delete lives on the project's own Settings page, behind - type-the-name — one way to delete, and it's the hard one. */} -
- ))} -
-

Invite links

+ + + {/* Real menu items, so the grid closes on pick and works + from the keyboard like every other menu in the app. */} + form.setValue("icon", "", { shouldDirty: true })} + > + + + {Object.keys(PROJECT_ICONS).map((name) => ( + form.setValue("icon", name, { shouldDirty: true })} + > + + + ))} + + +
+
+ +
+ + + {form.formState.errors.name && ( + + {form.formState.errors.name.message} + + )} +
+ +
+ +