fix(web): add workspace context to domain and project creation
All checks were successful
ci/woodpecker/push/web Pipeline was successful

- Implement create domain dialog on settings/domains page with name,
  slug (auto-generated from name), and description fields
- Add workspaceId parameter to createDomain, updateDomain, and
  deleteDomain API functions so the X-Workspace-Id header is sent
- Use useWorkspaceId hook in domains page so the workspace context
  is available for all domain mutations

Project creation (SS-WS-002) already passes workspaceId via
useWorkspaceId hook in projects/page.tsx — no changes needed there.

Fixes #534

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 04:18:05 -06:00
parent 8964226163
commit 2abb405e5f
2 changed files with 393 additions and 12 deletions

View File

@@ -1,18 +1,374 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, type SyntheticEvent } from "react";
import type { ReactElement } from "react";
import type { Domain } from "@mosaic/shared";
import { DomainList } from "@/components/domains/DomainList";
import { fetchDomains, deleteDomain } from "@/lib/api/domains";
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
import type { CreateDomainDto } from "@/lib/api/domains";
import { useWorkspaceId } from "@/lib/hooks";
export default function DomainsPage(): React.ReactElement {
/* ---------------------------------------------------------------------------
Slug generation helper
--------------------------------------------------------------------------- */
function generateSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 100);
}
/* ---------------------------------------------------------------------------
Create Domain Dialog
--------------------------------------------------------------------------- */
interface CreateDomainDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateDomainDto) => Promise<void>;
isSubmitting: boolean;
}
function CreateDomainDialog({
open,
onOpenChange,
onSubmit,
isSubmitting,
}: CreateDomainDialogProps): ReactElement | null {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [description, setDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
function resetForm(): void {
setName("");
setSlug("");
setSlugTouched(false);
setDescription("");
setFormError(null);
}
function handleNameChange(value: string): void {
setName(value);
if (!slugTouched) {
setSlug(generateSlug(value));
}
}
function handleSlugChange(value: string): void {
setSlugTouched(true);
setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
}
async function handleSubmit(e: SyntheticEvent): Promise<void> {
e.preventDefault();
setFormError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setFormError("Domain name is required.");
return;
}
const trimmedSlug = slug.trim();
if (!trimmedSlug) {
setFormError("Slug is required.");
return;
}
if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
return;
}
try {
const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
const trimmedDesc = description.trim();
if (trimmedDesc) {
payload.description = trimmedDesc;
}
await onSubmit(payload);
resetForm();
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : "Failed to create domain.");
}
}
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-domain-title"
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* Backdrop */}
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
}}
onClick={() => {
if (!isSubmitting) {
resetForm();
onOpenChange(false);
}
}}
/>
{/* Dialog */}
<div
style={{
position: "relative",
background: "var(--surface, #fff)",
borderRadius: "8px",
border: "1px solid var(--border, #e5e7eb)",
padding: 24,
width: "100%",
maxWidth: 480,
zIndex: 1,
}}
>
<h2
id="create-domain-title"
style={{
fontSize: "1.125rem",
fontWeight: 600,
color: "var(--text, #111)",
margin: "0 0 8px",
}}
>
New Domain
</h2>
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
Domains help you organize tasks, projects, and events by life area.
</p>
<form
onSubmit={(e) => {
void handleSubmit(e);
}}
>
{/* Name */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="domain-name"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<input
id="domain-name"
type="text"
value={name}
onChange={(e) => {
handleNameChange(e.target.value);
}}
placeholder="e.g. Personal Finance"
maxLength={255}
autoFocus
style={{
width: "100%",
padding: "8px 12px",
background: "var(--bg, #f9fafb)",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text, #111)",
fontSize: "0.9rem",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Slug */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="domain-slug"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Slug <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<input
id="domain-slug"
type="text"
value={slug}
onChange={(e) => {
handleSlugChange(e.target.value);
}}
placeholder="e.g. personal-finance"
maxLength={100}
style={{
width: "100%",
padding: "8px 12px",
background: "var(--bg, #f9fafb)",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text, #111)",
fontSize: "0.9rem",
outline: "none",
boxSizing: "border-box",
fontFamily: "var(--mono, monospace)",
}}
/>
<p
style={{
fontSize: "0.75rem",
color: "var(--muted, #6b7280)",
margin: "4px 0 0",
}}
>
Lowercase letters, numbers, and hyphens only.
</p>
</div>
{/* Description */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="domain-description"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Description
</label>
<textarea
id="domain-description"
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
placeholder="A brief summary of this domain..."
rows={3}
maxLength={10000}
style={{
width: "100%",
padding: "8px 12px",
background: "var(--bg, #f9fafb)",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text, #111)",
fontSize: "0.9rem",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
</div>
{/* Form error */}
{formError !== null && (
<p
style={{
color: "var(--danger, #ef4444)",
fontSize: "0.85rem",
margin: "0 0 12px",
}}
>
{formError}
</p>
)}
{/* Buttons */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<button
type="button"
onClick={() => {
resetForm();
onOpenChange(false);
}}
disabled={isSubmitting}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text-2, #374151)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim() || !slug.trim()}
style={{
padding: "8px 16px",
background: "var(--primary, #111827)",
border: "none",
borderRadius: "6px",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: isSubmitting || !name.trim() || !slug.trim() ? "not-allowed" : "pointer",
opacity: isSubmitting || !name.trim() || !slug.trim() ? 0.6 : 1,
}}
>
{isSubmitting ? "Creating..." : "Create Domain"}
</button>
</div>
</form>
</div>
</div>
);
}
/* ---------------------------------------------------------------------------
Domains Page
--------------------------------------------------------------------------- */
export default function DomainsPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [domains, setDomains] = useState<Domain[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Create dialog state
const [createOpen, setCreateOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
void loadDomains();
}, []);
}, []); // loadDomains is defined in this scope and stable
async function loadDomains(): Promise<void> {
try {
@@ -38,13 +394,24 @@ export default function DomainsPage(): React.ReactElement {
}
try {
await deleteDomain(domain.id);
await deleteDomain(domain.id, workspaceId ?? undefined);
await loadDomains();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete domain");
}
}
async function handleCreate(data: CreateDomainDto): Promise<void> {
setIsCreating(true);
try {
await createDomain(data, workspaceId ?? undefined);
setCreateOpen(false);
await loadDomains();
} finally {
setIsCreating(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6">
<div className="mb-6">
@@ -60,7 +427,7 @@ export default function DomainsPage(): React.ReactElement {
<button
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
onClick={() => {
console.log("TODO: Open create modal");
setCreateOpen(true);
}}
>
Create Domain
@@ -73,6 +440,13 @@ export default function DomainsPage(): React.ReactElement {
onEdit={handleEdit}
onDelete={handleDelete}
/>
<CreateDomainDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={handleCreate}
isSubmitting={isCreating}
/>
</div>
);
}

View File

@@ -73,20 +73,27 @@ export async function fetchDomain(id: string): Promise<DomainWithCounts> {
/**
* Create a new domain
*/
export async function createDomain(data: CreateDomainDto): Promise<Domain> {
return apiPost<Domain>("/api/domains", data);
export async function createDomain(data: CreateDomainDto, workspaceId?: string): Promise<Domain> {
return apiPost<Domain>("/api/domains", data, workspaceId);
}
/**
* Update a domain
*/
export async function updateDomain(id: string, data: UpdateDomainDto): Promise<Domain> {
return apiPatch<Domain>(`/api/domains/${id}`, data);
export async function updateDomain(
id: string,
data: UpdateDomainDto,
workspaceId?: string
): Promise<Domain> {
return apiPatch<Domain>(`/api/domains/${id}`, data, workspaceId);
}
/**
* Delete a domain
*/
export async function deleteDomain(id: string): Promise<Record<string, never>> {
return apiDelete<Record<string, never>>(`/api/domains/${id}`);
export async function deleteDomain(
id: string,
workspaceId?: string
): Promise<Record<string, never>> {
return apiDelete<Record<string, never>>(`/api/domains/${id}`, workspaceId);
}