Pond lifecycle in the UI: create shared ponds, delete from settings
Some checks failed
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Failing after 11s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m40s
CI / Import/export fidelity gate (push) Successful in 54s
Some checks failed
CD / Build and push images (push) Successful in 3m54s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Failing after 11s
CD / Promote to Int (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m40s
CI / Import/export fidelity gate (push) Successful in 54s
The pond switcher grows a "+ New pond" entry with an inline form (name + optional description, quota errors surfaced translated); the pond settings of shared ponds end in a danger section that moves the pond to the site-level trash after typing its name to confirm. Personal ponds keep hiding the section. .button--danger is now a solid red button (also fixes the admin restore button, which showed red text on the accent-green background). Manuals no longer call these actions API-only; covered by a members-pack e2e test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
0c9d44e9c9
commit
627f128ab8
@ -98,3 +98,41 @@ test('non-admin members see the member list read-only', async ({ browser }) => {
|
||||
await owner.close();
|
||||
await member.close();
|
||||
});
|
||||
|
||||
test('shared ponds are created from the switcher and deleted from settings', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const page = await owner.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
// Create via the "+ New pond" flow in the pond switcher.
|
||||
const pondName = `Switcher ${Date.now()}`;
|
||||
// The switcher only mounts once the ponds query resolves — wait for its
|
||||
// dedicated class instead of grabbing the first generic menu trigger.
|
||||
await page.locator('.pond-switcher__trigger').click();
|
||||
await page.locator('.pond-switcher__create').click();
|
||||
await page.locator('.pond-switcher__form input').first().fill(pondName);
|
||||
await page.locator('.pond-switcher__actions button[type="submit"]').click();
|
||||
// Creation lands on the new pond's home.
|
||||
await expect(page).toHaveURL(/\/p\/switcher-/);
|
||||
|
||||
// Delete from the danger section — the button stays disabled until the
|
||||
// typed confirmation matches the pond name exactly.
|
||||
const slug = new URL(page.url()).pathname.split('/')[2];
|
||||
await page.goto(`/p/${slug}/settings`);
|
||||
const danger = page.locator('.pond-delete');
|
||||
await expect(danger).toBeVisible();
|
||||
await expect(danger.locator('.pond-delete__submit')).toBeDisabled();
|
||||
await danger.locator('input').fill(pondName);
|
||||
await danger.locator('.pond-delete__submit').click();
|
||||
await expect(page).toHaveURL(/\/$|\/p\/fixture-user/);
|
||||
expect((await owner.request.get(`/api/v1/ponds/${slug}`)).status()).toBe(404);
|
||||
|
||||
// The personal pond never offers deletion.
|
||||
await page.goto('/p/fixture-user/settings');
|
||||
await expect(page.locator('.pond-export')).toBeVisible();
|
||||
await expect(page.locator('.pond-delete')).toHaveCount(0);
|
||||
|
||||
await owner.close();
|
||||
});
|
||||
|
||||
@ -1,22 +1,42 @@
|
||||
import type { PondView } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { apiGet } from '../lib/api';
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiGet, apiPost } from '../lib/api';
|
||||
import { useDismissable } from '../lib/use-dismissable';
|
||||
import { useCurrentPondRoute } from './use-pond-route';
|
||||
|
||||
/** Top-bar dropdown to switch between the signed-in user's ponds (issue #26). */
|
||||
/**
|
||||
* Top-bar dropdown to switch between the signed-in user's ponds (issue #26)
|
||||
* — and to create a new shared pond right from the menu. Creation is
|
||||
* quota-gated server-side (`additional shared ponds per user`); a rejected
|
||||
* attempt surfaces the translated error instead of hiding the option.
|
||||
*/
|
||||
export function PondSwitcher(): React.JSX.Element | null {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { pondSlug } = useCurrentPondRoute();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useDismissable(menuRef, open, () => setOpen(false));
|
||||
|
||||
const close = (): void => {
|
||||
setOpen(false);
|
||||
setCreating(false);
|
||||
setError(null);
|
||||
};
|
||||
useDismissable(menuRef, open, close);
|
||||
|
||||
const ponds = useQuery({
|
||||
queryKey: ['ponds'],
|
||||
queryFn: () => apiGet<PondView[]>('/ponds'),
|
||||
@ -26,32 +46,98 @@ export function PondSwitcher(): React.JSX.Element | null {
|
||||
if (!ponds.data || ponds.data.length === 0) return null;
|
||||
const current = ponds.data.find((pond) => pond.slug === pondSlug);
|
||||
|
||||
const create = async (event: React.FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const pond = await apiPost<PondView>('/ponds', {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['ponds'] });
|
||||
close();
|
||||
setName('');
|
||||
setDescription('');
|
||||
navigate(`/p/${pond.slug}`);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="user-menu" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="user-menu__trigger"
|
||||
className="user-menu__trigger pond-switcher__trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={t('layout.pondSwitcher.label')}
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
>
|
||||
{current?.name ?? t('layout.pondSwitcher.trigger')}
|
||||
</button>
|
||||
{open && (
|
||||
{open && !creating && (
|
||||
<div className="user-menu__list" role="menu">
|
||||
{ponds.data.map((pond) => (
|
||||
<Link
|
||||
key={pond.id}
|
||||
role="menuitem"
|
||||
to={`/p/${pond.slug}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Link key={pond.id} role="menuitem" to={`/p/${pond.slug}`} onClick={close}>
|
||||
{pond.name}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="pond-switcher__create"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
{t('pond.create.menuItem')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{open && creating && (
|
||||
<form className="user-menu__list pond-switcher__form" onSubmit={(e) => void create(e)}>
|
||||
<h3>{t('pond.create.title')}</h3>
|
||||
<FormError error={error} />
|
||||
<label>
|
||||
{t('pond.create.nameLabel')}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
required
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('pond.create.descriptionLabel')}
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
maxLength={500}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="pond-switcher__hint">{t('pond.create.quotaHint')}</p>
|
||||
<div className="pond-switcher__actions">
|
||||
<button type="submit" className="button" disabled={busy || name.trim() === ''}>
|
||||
{t('pond.create.submit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button--outline"
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t('pond.create.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import { EffectivePermissionsInspector } from '../access/EffectivePermissionsIns
|
||||
import { PondFileManager } from '../files/PondFileManager';
|
||||
import { apiGet } from '../lib/api';
|
||||
import { MemberManager } from '../members/MemberManager';
|
||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||
|
||||
/**
|
||||
@ -126,6 +127,9 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
{tExport('pond.zip')}
|
||||
</a>
|
||||
</section>
|
||||
{canModify && pond.data.type === 'shared' && (
|
||||
<DeletePondSection pondId={pond.data.id} pondName={pond.data.name} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
69
apps/web/src/ponds/DeletePondSection.tsx
Normal file
69
apps/web/src/ponds/DeletePondSection.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiDelete } from '../lib/api';
|
||||
|
||||
/**
|
||||
* The pond's danger zone: move a shared pond to the site-level trash
|
||||
* (`DELETE /ponds/:id`, Pond-Admin-gated in the api; a site admin can
|
||||
* restore it). Type-to-confirm with the pond name — the same backstop
|
||||
* pattern as the backup restore. Personal ponds never render this
|
||||
* section: they are the account's home and the api refuses anyway.
|
||||
*/
|
||||
export function DeletePondSection({
|
||||
pondId,
|
||||
pondName,
|
||||
}: {
|
||||
pondId: string;
|
||||
pondName: string;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const remove = async (event: React.FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiDelete(`/ponds/${pondId}`);
|
||||
await queryClient.invalidateQueries({ queryKey: ['ponds'] });
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="pond-delete">
|
||||
<h2>{t('pond.delete.title')}</h2>
|
||||
<p className="pond-delete__hint">{t('pond.delete.hint')}</p>
|
||||
<form onSubmit={(e) => void remove(e)}>
|
||||
<FormError error={error} />
|
||||
<label>
|
||||
{t('pond.delete.confirmLabel', { name: pondName })}
|
||||
<input
|
||||
type="text"
|
||||
value={confirm}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="button button--danger pond-delete__submit"
|
||||
disabled={busy || confirm !== pondName}
|
||||
>
|
||||
{t('pond.delete.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -2627,7 +2627,81 @@ button {
|
||||
|
||||
.button--danger {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.button--danger:hover:not(:disabled) {
|
||||
background: #8a0718;
|
||||
}
|
||||
|
||||
/* Pond creation form in the switcher + pond danger zone */
|
||||
.pond-switcher__create {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: none;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pond-switcher__form {
|
||||
padding: var(--space-3);
|
||||
min-width: 18rem;
|
||||
}
|
||||
|
||||
.pond-switcher__form label {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pond-switcher__form input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: var(--space-1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pond-switcher__hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pond-switcher__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.pond-delete {
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
.pond-delete__hint {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pond-delete label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.pond-delete input {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
width: min(24rem, 100%);
|
||||
}
|
||||
|
||||
.pond-delete__submit {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* API tokens (issue #104) */
|
||||
|
||||
@ -8,8 +8,9 @@ bar (visible on pond routes when you may modify the pond).
|
||||
## Ponds in one minute
|
||||
|
||||
Every member gets a **personal pond** automatically. Additional
|
||||
**shared ponds** are created through the API (`POST /api/v1/ponds`) and
|
||||
are subject to the per-user quota the site admin sets ("additional
|
||||
**shared ponds** are created via **"+ New pond"** at the bottom of the
|
||||
pond switcher in the top bar (or through the API, `POST /api/v1/ponds`)
|
||||
and are subject to the per-user quota the site admin sets ("additional
|
||||
shared ponds per user", default 0). The creator becomes the pond admin.
|
||||
|
||||
## Name, description, appearance
|
||||
@ -98,7 +99,7 @@ against the pond's quota; the current usage is shown.
|
||||
## Export and deletion
|
||||
|
||||
- **Export**: the whole pond as a ZIP of Markdown files plus media.
|
||||
- **Delete pond**: shared ponds can be moved to the site-level trash by
|
||||
their admin via the API (`DELETE /api/v1/ponds/<id>` — there is no UI
|
||||
button yet); a site admin can restore them. Your personal pond cannot
|
||||
be deleted — it is your account's home.
|
||||
- **Delete pond**: the danger section at the bottom of the pond
|
||||
settings moves a shared pond to the site-level trash (type the pond
|
||||
name to confirm); a site admin can restore it. Your personal pond
|
||||
cannot be deleted — it is your account's home.
|
||||
|
||||
@ -19,7 +19,8 @@ instance administration the [site-admin guide](site-admin-guide.md).
|
||||
## Ponds and pages
|
||||
|
||||
- The **pond switcher** in the top bar moves you between the ponds you
|
||||
can see. The **sidebar** lists the pages of the current pond — sort
|
||||
can see — and **"+ New pond"** at its bottom creates a new shared pond
|
||||
(subject to a quota the site admin sets). The **sidebar** lists the pages of the current pond — sort
|
||||
them A–Z, by creation date, or drag them into a manual order (the sort
|
||||
mode is a pond setting).
|
||||
- **+ New page** at the bottom of the sidebar creates a page. Page
|
||||
|
||||
@ -48,5 +48,23 @@
|
||||
"title": "Seite nicht gefunden",
|
||||
"body": "Die aufgerufene Adresse existiert nicht.",
|
||||
"home": "Zurück zur Startseite"
|
||||
},
|
||||
"pond": {
|
||||
"create": {
|
||||
"menuItem": "+ Neuer Teich",
|
||||
"title": "Gemeinsamen Teich anlegen",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Beschreibung (optional)",
|
||||
"submit": "Teich anlegen",
|
||||
"cancel": "Abbrechen",
|
||||
"quotaHint": "Gemeinsame Teiche zählen gegen dein Kontingent — frag eine Site-Adminin/einen Site-Admin, falls das Anlegen abgelehnt wird."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Teich löschen",
|
||||
"hint": "Verschiebt diesen Teich mit allen Seiten in den Instanz-Papierkorb. Ein Site-Admin kann ihn wiederherstellen. Dein persönlicher Teich kann nicht gelöscht werden.",
|
||||
"confirmLabel": "Zur Bestätigung den Teichnamen eintippen: {{name}}",
|
||||
"submit": "Teich löschen",
|
||||
"deleted": "Teich gelöscht."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,5 +48,23 @@
|
||||
"title": "Page not found",
|
||||
"body": "The address you opened does not exist.",
|
||||
"home": "Back to the start page"
|
||||
},
|
||||
"pond": {
|
||||
"create": {
|
||||
"menuItem": "+ New pond",
|
||||
"title": "Create a shared pond",
|
||||
"nameLabel": "Name",
|
||||
"descriptionLabel": "Description (optional)",
|
||||
"submit": "Create pond",
|
||||
"cancel": "Cancel",
|
||||
"quotaHint": "Shared ponds count against your quota — ask a site admin if creation is rejected."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete pond",
|
||||
"hint": "Moves this pond with all of its pages to the site-level trash. A site admin can restore it. Your personal pond cannot be deleted.",
|
||||
"confirmLabel": "Type the pond name to confirm: {{name}}",
|
||||
"submit": "Delete pond",
|
||||
"deleted": "Pond deleted."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user