fix(web): add workspace context to domain and project creation
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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:
@@ -1,18 +1,374 @@
|
|||||||
"use client";
|
"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 type { Domain } from "@mosaic/shared";
|
||||||
import { DomainList } from "@/components/domains/DomainList";
|
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 [domains, setDomains] = useState<Domain[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Create dialog state
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDomains();
|
void loadDomains();
|
||||||
}, []);
|
}, []); // loadDomains is defined in this scope and stable
|
||||||
|
|
||||||
async function loadDomains(): Promise<void> {
|
async function loadDomains(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -38,13 +394,24 @@ export default function DomainsPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteDomain(domain.id);
|
await deleteDomain(domain.id, workspaceId ?? undefined);
|
||||||
await loadDomains();
|
await loadDomains();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-6">
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -60,7 +427,7 @@ export default function DomainsPage(): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("TODO: Open create modal");
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create Domain
|
Create Domain
|
||||||
@@ -73,6 +440,13 @@ export default function DomainsPage(): React.ReactElement {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateDomainDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isSubmitting={isCreating}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,20 +73,27 @@ export async function fetchDomain(id: string): Promise<DomainWithCounts> {
|
|||||||
/**
|
/**
|
||||||
* Create a new domain
|
* Create a new domain
|
||||||
*/
|
*/
|
||||||
export async function createDomain(data: CreateDomainDto): Promise<Domain> {
|
export async function createDomain(data: CreateDomainDto, workspaceId?: string): Promise<Domain> {
|
||||||
return apiPost<Domain>("/api/domains", data);
|
return apiPost<Domain>("/api/domains", data, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a domain
|
* Update a domain
|
||||||
*/
|
*/
|
||||||
export async function updateDomain(id: string, data: UpdateDomainDto): Promise<Domain> {
|
export async function updateDomain(
|
||||||
return apiPatch<Domain>(`/api/domains/${id}`, data);
|
id: string,
|
||||||
|
data: UpdateDomainDto,
|
||||||
|
workspaceId?: string
|
||||||
|
): Promise<Domain> {
|
||||||
|
return apiPatch<Domain>(`/api/domains/${id}`, data, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a domain
|
* Delete a domain
|
||||||
*/
|
*/
|
||||||
export async function deleteDomain(id: string): Promise<Record<string, never>> {
|
export async function deleteDomain(
|
||||||
return apiDelete<Record<string, never>>(`/api/domains/${id}`);
|
id: string,
|
||||||
|
workspaceId?: string
|
||||||
|
): Promise<Record<string, never>> {
|
||||||
|
return apiDelete<Record<string, never>>(`/api/domains/${id}`, workspaceId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user