diff --git a/web-admin/src/features/projects/ProjectTabs.svelte b/web-admin/src/features/projects/ProjectTabs.svelte index a58b490823b..b9c32e3c9c0 100644 --- a/web-admin/src/features/projects/ProjectTabs.svelte +++ b/web-admin/src/features/projects/ProjectTabs.svelte @@ -46,9 +46,7 @@ hasPermission: projectPermissions.manageProject, }, { - // TODO: Change this back to `/${organization}/${project}/-/settings` - // Once project settings are implemented - route: `/${organization}/${project}/-/settings/environment-variables`, + route: `/${organization}/${project}/-/settings`, label: "Settings", hasPermission: projectPermissions.manageProject, }, diff --git a/web-admin/src/features/projects/settings/DeleteProject.svelte b/web-admin/src/features/projects/settings/DeleteProject.svelte new file mode 100644 index 00000000000..e9a18d1af98 --- /dev/null +++ b/web-admin/src/features/projects/settings/DeleteProject.svelte @@ -0,0 +1,60 @@ + + + + + Permanently remove all contents of this project. This action cannot be + undone. + + + + + + + + diff --git a/web-admin/src/features/projects/settings/HibernateProject.svelte b/web-admin/src/features/projects/settings/HibernateProject.svelte new file mode 100644 index 00000000000..2d296e6e1e6 --- /dev/null +++ b/web-admin/src/features/projects/settings/HibernateProject.svelte @@ -0,0 +1,57 @@ + + + + + Put this project into hibernation mode. Hibernated projects are paused and + do not consume resources. The project can be woken up at any time by + accessing it. + + + + + + + + diff --git a/web-admin/src/features/projects/settings/ProjectNameSettings.svelte b/web-admin/src/features/projects/settings/ProjectNameSettings.svelte new file mode 100644 index 00000000000..a89092820cc --- /dev/null +++ b/web-admin/src/features/projects/settings/ProjectNameSettings.svelte @@ -0,0 +1,170 @@ + + + +
+ + {#if $form.name && sanitizeProjectName($form.name) !== project} +
+ Renaming this project will invalidate all existing URLs and shared + links. +
+ {/if} + +
+ {#if error?.message} +
+ {error.message} +
+ {/if} + +
+ + diff --git a/web-admin/src/features/projects/settings/PublicVisibilitySettings.svelte b/web-admin/src/features/projects/settings/PublicVisibilitySettings.svelte new file mode 100644 index 00000000000..34146b98517 --- /dev/null +++ b/web-admin/src/features/projects/settings/PublicVisibilitySettings.svelte @@ -0,0 +1,119 @@ + + + + +
+

+ {#if isPublic} + This project is currently public. Anyone with the URL + can view this project. + {:else} + This project is currently private. Only members of + the organization can access this project. + {/if} +

+
+
+ + + {#if isPublic} + + {:else} + + + + + + {/if} + +
diff --git a/web-admin/src/features/projects/settings/errors.ts b/web-admin/src/features/projects/settings/errors.ts new file mode 100644 index 00000000000..afc09e32ee5 --- /dev/null +++ b/web-admin/src/features/projects/settings/errors.ts @@ -0,0 +1,13 @@ +import type { RpcStatus } from "@rilldata/web-admin/client"; +import type { AxiosError } from "axios"; + +export function parseUpdateProjectError(err: AxiosError | null) { + if (!err) return {}; + + const message = err.response?.data?.message ?? err.message; + + return { + duplicateProject: message?.includes("already exists"), + message, + }; +} diff --git a/web-admin/src/routes/[organization]/[project]/-/settings/+layout.svelte b/web-admin/src/routes/[organization]/[project]/-/settings/+layout.svelte index c6a0aba8a10..ebf5d2698fd 100644 --- a/web-admin/src/routes/[organization]/[project]/-/settings/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/settings/+layout.svelte @@ -10,6 +10,11 @@ $: basePage = `/${organization}/${project}/-/settings`; const navItems = [ + { + label: "General", + route: "", + hasPermission: true, + }, { label: "Environment Variables", route: "/environment-variables", diff --git a/web-admin/src/routes/[organization]/[project]/-/settings/+page.svelte b/web-admin/src/routes/[organization]/[project]/-/settings/+page.svelte index 183d98ab41d..dcecbe30531 100644 --- a/web-admin/src/routes/[organization]/[project]/-/settings/+page.svelte +++ b/web-admin/src/routes/[organization]/[project]/-/settings/+page.svelte @@ -1 +1,37 @@ - + + +
+ + +
+

Danger zone

+
+ + + +
+
+
+ + diff --git a/web-admin/tests/projects.spec.ts b/web-admin/tests/projects.spec.ts index 75a24e526f2..cd7f096bd25 100644 --- a/web-admin/tests/projects.spec.ts +++ b/web-admin/tests/projects.spec.ts @@ -1,5 +1,10 @@ import { expect } from "@playwright/test"; import { test } from "./setup/base"; +import { + RILL_ORG_NAME, + RILL_PROJECT_NAME, + RILL_PROJECT_DISPLAY_NAME, +} from "./setup/constants"; test.describe("Projects", () => { test("admins should see the admin-only pages", async ({ adminPage }) => { @@ -9,4 +14,172 @@ test.describe("Projects", () => { adminPage.getByRole("link", { name: "Settings" }), ).toBeVisible(); }); + + test.describe("Settings", () => { + const settingsUrl = `/${RILL_ORG_NAME}/${RILL_PROJECT_NAME}/-/settings`; + + test("should display the settings page with all sections", async ({ + adminPage, + }) => { + await adminPage.goto(settingsUrl); + + // Check that the page title sections are visible + await expect( + adminPage.getByText("Project", { exact: true }), + ).toBeVisible(); + await expect(adminPage.getByText("Danger zone")).toBeVisible(); + + // Check that all settings sections are visible + await expect(adminPage.getByText("Public visibility")).toBeVisible(); + await expect(adminPage.getByText("Hibernate project")).toBeVisible(); + await expect(adminPage.getByText("Delete project")).toBeVisible(); + }); + + test("should display current project name", async ({ adminPage }) => { + await adminPage.goto(settingsUrl); + + const nameInput = adminPage.locator("#name"); + await expect(nameInput).toHaveValue(RILL_PROJECT_DISPLAY_NAME); + }); + + test("should show warning when renaming project", async ({ adminPage }) => { + await adminPage.goto(settingsUrl); + + const nameInput = adminPage.locator("#name"); + await nameInput.fill("Test Project Rename"); + + await expect( + adminPage.getByText( + "Renaming this project will invalidate all existing URLs and shared links.", + ), + ).toBeVisible(); + }); + + test("should enable Save button when changes are made", async ({ + adminPage, + }) => { + await adminPage.goto(settingsUrl); + + const saveButton = adminPage.getByRole("button", { name: "Save" }); + await expect(saveButton).toBeDisabled(); + + const descriptionInput = adminPage.locator("#description"); + await descriptionInput.fill("Test description change"); + + await expect(saveButton).toBeEnabled(); + }); + + test("should update project description successfully", async ({ + adminPage, + }) => { + await adminPage.goto(settingsUrl); + + const descriptionInput = adminPage.locator("#description"); + const saveButton = adminPage.getByRole("button", { name: "Save" }); + + const originalDescription = await descriptionInput.inputValue(); + + const testDescription = `E2E test description - ${Date.now()}`; + await descriptionInput.fill(testDescription); + await saveButton.click(); + + await expect(adminPage.getByLabel("Notification")).toHaveText( + "Updated project", + ); + + // Restore the original description + await descriptionInput.fill(originalDescription); + await saveButton.click(); + + await expect(adminPage.getByLabel("Notification")).toHaveText( + "Updated project", + ); + }); + + test("should display current visibility status", async ({ adminPage }) => { + await adminPage.goto(settingsUrl); + + const isPublicText = adminPage.getByText("This project is currently", { + exact: false, + }); + await expect(isPublicText).toBeVisible(); + }); + + test("should show confirmation dialog when making project public", async ({ + adminPage, + }) => { + await adminPage.goto(settingsUrl); + + const makePublicButton = adminPage.getByRole("button", { + name: "Make public", + }); + + // Only run if project is private + if (await makePublicButton.isVisible()) { + await makePublicButton.click(); + + await expect( + adminPage.getByText("Make this project public?"), + ).toBeVisible(); + + await adminPage.getByRole("button", { name: "Cancel" }).click(); + } + }); + + test("should show hibernate confirmation dialog", async ({ adminPage }) => { + await adminPage.goto(settingsUrl); + + await adminPage + .getByRole("button", { name: "Hibernate project" }) + .click(); + + await expect( + adminPage.getByText("Hibernate this project?"), + ).toBeVisible(); + + await expect( + adminPage.getByText(`Type hibernate ${RILL_PROJECT_NAME}`), + ).toBeVisible(); + + await expect( + adminPage.getByRole("button", { name: "Continue" }), + ).toBeDisabled(); + + // Enter confirmation text + const confirmInput = adminPage.locator("#confirmation"); + await confirmInput.fill(`hibernate ${RILL_PROJECT_NAME}`); + + await expect( + adminPage.getByRole("button", { name: "Continue" }), + ).toBeEnabled(); + + await adminPage.getByRole("button", { name: "Cancel" }).click(); + }); + + test("should show delete confirmation dialog", async ({ adminPage }) => { + await adminPage.goto(settingsUrl); + + await adminPage.getByRole("button", { name: "Delete project" }).click(); + + await expect(adminPage.getByText("Delete this project?")).toBeVisible(); + + await expect( + adminPage.getByText(`Type delete ${RILL_PROJECT_NAME}`), + ).toBeVisible(); + + await expect( + adminPage.getByRole("button", { name: "Continue" }), + ).toBeDisabled(); + + // Enter confirmation text + const confirmInput = adminPage.locator("#confirmation"); + await confirmInput.fill(`delete ${RILL_PROJECT_NAME}`); + + await expect( + adminPage.getByRole("button", { name: "Continue" }), + ).toBeEnabled(); + + await adminPage.getByRole("button", { name: "Cancel" }).click(); + }); + }); }); diff --git a/web-common/src/components/button/Button.svelte b/web-common/src/components/button/Button.svelte index 921a31aa618..0c0b17c1262 100644 --- a/web-common/src/components/button/Button.svelte +++ b/web-common/src/components/button/Button.svelte @@ -2,6 +2,7 @@ export type ButtonType = | "primary" | "secondary" + | "secondary-destructive" | "tertiary" | "neutral" | "destructive" @@ -117,11 +118,17 @@ @apply cursor-not-allowed; } - /* button:focus { + button:focus, + a:focus { + @apply outline-none; + } + + button:focus-visible, + a:focus-visible { @apply outline-none; box-shadow: 0px 0px 4px 1px color-mix(in oklab, var(--focus-color) 50%, transparent); - } */ + } /* PRIMARY STYLES */ @@ -170,6 +177,25 @@ @apply opacity-50; } + /* SECONDARY DESTRUCTIVE STYLES */ + + .secondary-destructive { + --focus-color: var(--color-destructive-600); + @apply bg-transparent border border-destructive text-destructive; + } + + :global(.dark) .secondary-destructive { + @apply bg-transparent; + } + + .secondary-destructive:hover:not(:disabled) { + @apply bg-destructive/10 text-destructive; + } + + .secondary-destructive:disabled { + @apply opacity-50; + } + /* GHOST STYLES */ .ghost {