converted account and organisation pages to dialogs

This commit is contained in:
Oliver Bryan
2026-01-03 12:42:39 +00:00
parent 33da8bde85
commit 5160a6a554
11 changed files with 441 additions and 431 deletions

View File

@@ -1,109 +0,0 @@
import type { UserRecord } from "@issue/shared";
import { useEffect, useState } from "react";
import { SettingsPageLayout } from "@/components/settings-page-layout";
import { Button } from "@/components/ui/button";
import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label";
import { UploadAvatar } from "@/components/upload-avatar";
import { user } from "@/lib/server";
function Account() {
const [name, setName] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false);
const [userId, setUserId] = useState<number | null>(null);
useEffect(() => {
const userStr = localStorage.getItem("user");
if (userStr) {
const user = JSON.parse(userStr) as UserRecord;
setName(user.name);
setUsername(user.username);
setUserId(user.id);
setAvatarUrl(user.avatarURL || null);
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitAttempted(true);
if (name.trim() === "") {
return;
}
if (!userId) {
setError("User not found");
return;
}
await user.update({
id: userId,
name: name.trim(),
password: password.trim(),
avatarURL: avatarURL,
onSuccess: (data) => {
setError("");
localStorage.setItem("user", JSON.stringify(data));
setPassword("");
window.location.reload();
},
onError: (errorMessage) => {
setError(errorMessage);
},
});
};
return (
<SettingsPageLayout title="Account">
<form onSubmit={handleSubmit} className="flex flex-col p-4 gap-2 w-sm border">
<h2 className="text-xl font-600 mb-2 text-center">Account Details</h2>
<UploadAvatar
name={name}
username={username}
avatarURL={avatarURL}
onAvatarUploaded={setAvatarUrl}
/>
{avatarURL && (
<Button
variant={"dummy"}
type={"button"}
onClick={() => {
setAvatarUrl(null);
}}
className="-mt-2 hover:underline"
>
Remove Avatar
</Button>
)}
<Field
label="Full Name"
value={name}
onChange={(e) => setName(e.target.value)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
submitAttempted={submitAttempted}
/>
<Field
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Leave empty to keep current password"
hidden={true}
/>
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
<div className="flex justify-end">
<Button variant={"outline"} type={"submit"} className="px-12">
Save
</Button>
</div>
</form>
</SettingsPageLayout>
);
}
export default Account;

View File

@@ -1,10 +1,8 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Account from "@/Account";
import { Auth } from "@/components/auth-provider";
import NotFound from "@/components/NotFound";
import { ThemeProvider } from "@/components/theme-provider";
import Index from "@/Index";
import Organisations from "@/Organisations";
import Test from "@/Test";
function App() {
@@ -14,8 +12,6 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/settings/account" element={<Account />} />
<Route path="/settings/organisations" element={<Organisations />} />
<Route path="/test" element={<Test />} />
<Route path={"*"} element={<NotFound />} />
</Routes>

View File

@@ -1,12 +1,24 @@
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
import type { IssueResponse, OrganisationResponse, ProjectResponse, UserRecord } from "@issue/shared";
import { useEffect, useRef, useState } from "react";
import AccountDialog from "@/components/account-dialog";
import { CreateIssue } from "@/components/create-issue";
import Header from "@/components/header";
import { IssueDetailPane } from "@/components/issue-detail-pane";
import { IssuesTable } from "@/components/issues-table";
import LogOutButton from "@/components/log-out-button";
import { OrganisationSelect } from "@/components/organisation-select";
import OrganisationsDialog from "@/components/organisations-dialog";
import { ProjectSelect } from "@/components/project-select";
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
import { issue, organisation, project } from "@/lib/server";
@@ -157,7 +169,7 @@ function Index() {
return (
<main className="w-full h-screen flex flex-col">
{/* header area */}
<Header user={user}>
<div className="flex gap-12 items-center justify-between p-1">
<div className="flex gap-1 items-center">
{/* organisation selection */}
<OrganisationSelect
@@ -201,7 +213,45 @@ function Index() {
/>
)}
</div>
</Header>
<div className="flex gap-1 items-center">
<DropdownMenu>
<DropdownMenuTrigger className="text-sm">
<SmallUserDisplay user={user} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
<DropdownMenuItem asChild className="flex items-end justify-end">
<AccountDialog />
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<OrganisationsDialog
organisations={organisations}
selectedOrganisation={selectedOrganisation}
setSelectedOrganisation={setSelectedOrganisation}
refetchOrganisations={refetchOrganisations}
/>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<ServerConfigurationDialog
trigger={
<Button
variant="ghost"
className="flex w-full gap-2 items-center justify-end text-end px-2 py-1 m-0 h-auto"
title="Server Configuration"
>
Server Configuration
</Button>
}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex items-end justify-end p-0 m-0">
<LogOutButton noStyle className={"flex w-full justify-end"} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* main body */}
<div className="w-full h-full flex items-start justify-between p-1 gap-1">
{selectedProject && issues.length > 0 && (

View File

@@ -1,242 +0,0 @@
import type { OrganisationMemberResponse, OrganisationResponse, UserRecord } from "@issue/shared";
import { Plus, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { AddMemberDialog } from "@/components/add-member-dialog";
import { OrganisationSelect } from "@/components/organisation-select";
import { SettingsPageLayout } from "@/components/settings-page-layout";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { organisation } from "@/lib/server";
function Organisations() {
const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord;
const [organisations, setOrganisations] = useState<OrganisationResponse[]>([]);
const [selectedOrganisation, setSelectedOrganisation] = useState<OrganisationResponse | null>(null);
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
memberUserId: number;
memberName: string;
}>({ open: false, memberUserId: 0, memberName: "" });
const refetchOrganisations = useCallback(
async (options?: { selectOrganisationId?: number }) => {
try {
await organisation.byUser({
userId: user.id,
onSuccess: (data) => {
const organisations = data as OrganisationResponse[];
setOrganisations(organisations);
let selected: OrganisationResponse | null = null;
if (options?.selectOrganisationId) {
const created = organisations.find(
(o) => o.Organisation.id === options.selectOrganisationId,
);
if (created) {
selected = created;
}
} else {
const savedId = localStorage.getItem("selectedOrganisationId");
if (savedId) {
const saved = organisations.find(
(o) => o.Organisation.id === Number(savedId),
);
if (saved) {
selected = saved;
}
}
}
if (!selected) {
selected = organisations[0] || null;
}
setSelectedOrganisation(selected);
},
onError: (error) => {
console.error(error);
setOrganisations([]);
setSelectedOrganisation(null);
},
});
} catch (err) {
console.error("error fetching organisations:", err);
setOrganisations([]);
setSelectedOrganisation(null);
}
},
[user.id],
);
const refetchMembers = useCallback(async () => {
if (!selectedOrganisation) return;
try {
await organisation.members({
organisationId: selectedOrganisation.Organisation.id,
onSuccess: (data) => {
const members = data as OrganisationMemberResponse[];
members.sort((a, b) => {
const nameCompare = a.User.name.localeCompare(b.User.name);
return nameCompare !== 0
? nameCompare
: b.OrganisationMember.role.localeCompare(a.OrganisationMember.role);
});
setMembers(members);
},
onError: (error) => {
console.error(error);
setMembers([]);
},
});
} catch (err) {
console.error("error fetching members:", err);
setMembers([]);
}
}, [selectedOrganisation]);
const handleRemoveMember = async (memberUserId: number, memberName: string) => {
setConfirmDialog({ open: true, memberUserId, memberName });
};
const confirmRemoveMember = async () => {
if (!selectedOrganisation) return;
try {
await organisation.removeMember({
organisationId: selectedOrganisation.Organisation.id,
userId: confirmDialog.memberUserId,
onSuccess: () => {
setConfirmDialog({ open: false, memberUserId: 0, memberName: "" });
void refetchMembers();
},
onError: (error) => {
console.error(error);
},
});
} catch (err) {
console.error(err);
}
};
useEffect(() => {
void refetchOrganisations();
}, [refetchOrganisations]);
useEffect(() => {
void refetchMembers();
}, [refetchMembers]);
return (
<SettingsPageLayout title="Organisations">
<div className="flex flex-col p-1 gap-2">
<div className="flex gap-2 items-center">
<OrganisationSelect
organisations={organisations}
selectedOrganisation={selectedOrganisation}
onSelectedOrganisationChange={(org) => {
setSelectedOrganisation(org);
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
}}
onCreateOrganisation={async (organisationId) => {
await refetchOrganisations({ selectOrganisationId: organisationId });
}}
/>
</div>
{selectedOrganisation ? (
<div className="flex gap-2 flex-wrap">
<div className="w-xs border p-2">
<h2 className="text-xl font-600 mb-2">
{selectedOrganisation.Organisation.name}
</h2>
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
Slug: {selectedOrganisation.Organisation.slug}
</p>
<p className="text-sm text-muted-foreground">
Role: {selectedOrganisation.OrganisationMember.role}
</p>
{selectedOrganisation.Organisation.description ? (
<p className="text-sm">{selectedOrganisation.Organisation.description}</p>
) : (
<p className="text-sm text-muted-foreground">No description</p>
)}
</div>
</div>
<div className="border p-2">
<h2 className="text-xl font-600 mb-2">
{members.length} Member{members.length !== 1 ? "s" : ""}
</h2>
<div className="flex flex-col gap-2 w-sm">
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
{members.map((member) => (
<div
key={member.OrganisationMember.id}
className="flex items-center justify-between p-2 border"
>
<div className="flex items-center gap-2">
<SmallUserDisplay user={member.User} />
<span className="text-sm text-muted-foreground">
{member.OrganisationMember.role}
</span>
</div>
{(selectedOrganisation.OrganisationMember.role === "owner" ||
selectedOrganisation.OrganisationMember.role === "admin") &&
member.OrganisationMember.role !== "owner" &&
member.User.id !== user.id && (
<Button
variant="dummy"
size="none"
onClick={() =>
handleRemoveMember(
member.User.id,
member.User.name,
)
}
>
<X className="size-5 text-destructive" />
</Button>
)}
</div>
))}
</div>
{(selectedOrganisation.OrganisationMember.role === "owner" ||
selectedOrganisation.OrganisationMember.role === "admin") && (
<AddMemberDialog
organisationId={selectedOrganisation.Organisation.id}
existingMembers={members.map((m) => m.User.username)}
onSuccess={refetchMembers}
trigger={
<Button variant="outline">
Add user <Plus className="size-4" />
</Button>
}
/>
)}
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No organisations yet.</p>
)}
<ConfirmDialog
open={confirmDialog.open}
onOpenChange={(open) => setConfirmDialog({ ...confirmDialog, open })}
onConfirm={confirmRemoveMember}
title="Remove Member"
processingText="Removing..."
message={`Are you sure you want to remove ${confirmDialog.memberName} from this organisation?`}
confirmText="Remove"
variant="destructive"
/>
</div>
</SettingsPageLayout>
);
}
export default Organisations;

View File

@@ -0,0 +1,130 @@
import type { UserRecord } from "@issue/shared";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field";
import { Label } from "@/components/ui/label";
import { UploadAvatar } from "@/components/upload-avatar";
import { user } from "@/lib/server";
function AccountDialog({ trigger }: { trigger?: ReactNode }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false);
const [userId, setUserId] = useState<number | null>(null);
useEffect(() => {
if (!open) return;
const userStr = localStorage.getItem("user");
if (userStr) {
const user = JSON.parse(userStr) as UserRecord;
setName(user.name);
setUsername(user.username);
setUserId(user.id);
setAvatarUrl(user.avatarURL || null);
}
setPassword("");
setError("");
setSubmitAttempted(false);
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitAttempted(true);
if (name.trim() === "") {
return;
}
if (!userId) {
setError("User not found");
return;
}
await user.update({
id: userId,
name: name.trim(),
password: password.trim(),
avatarURL,
onSuccess: (data) => {
setError("");
localStorage.setItem("user", JSON.stringify(data));
setPassword("");
window.location.reload();
},
onError: (errorMessage) => {
setError(errorMessage);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" className="flex w-full justify-end px-2 py-1 m-0 h-auto">
My Account
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Account</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<UploadAvatar
name={name}
username={username}
avatarURL={avatarURL}
onAvatarUploaded={setAvatarUrl}
/>
{avatarURL && (
<Button
variant={"dummy"}
type={"button"}
onClick={() => {
setAvatarUrl(null);
}}
className="-mt-2 hover:underline"
>
Remove Avatar
</Button>
)}
<Field
label="Full Name"
value={name}
onChange={(e) => setName(e.target.value)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
submitAttempted={submitAttempted}
/>
<Field
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Leave empty to keep current password"
hidden={true}
/>
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
<div className="flex justify-end">
<Button variant={"outline"} type={"submit"} className="px-12">
Save
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
export default AccountDialog;

View File

@@ -61,9 +61,8 @@ export function CreateOrganisation({
setError(null);
setSubmitAttempted(true);
if (name.trim() === "" || slug.trim() === "") {
return;
}
if (name.trim() === "" || name.trim().length > 16) return;
if (slug.trim() === "" || slug.trim().length > 16) return;
if (!userId) {
setError("you must be logged in to create an organisation");
@@ -122,7 +121,13 @@ export function CreateOrganisation({
setSlug(slugify(nextName));
}
}}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
validate={(v) =>
v.trim() === ""
? "Cannot be empty"
: v.trim().length > 16
? "Too long (16 character limit)"
: undefined
}
submitAttempted={submitAttempted}
placeholder="Demo Organisation"
/>
@@ -133,7 +138,13 @@ export function CreateOrganisation({
setSlug(slugify(e.target.value));
setSlugManuallyEdited(true);
}}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
validate={(v) =>
v.trim() === ""
? "Cannot be empty"
: v.trim().length > 16
? "Too long (16 character limit)"
: undefined
}
submitAttempted={submitAttempted}
placeholder="demo-organisation"
/>
@@ -166,8 +177,10 @@ export function CreateOrganisation({
type="submit"
disabled={
submitting ||
(name.trim() === "" && submitAttempted) ||
(slug.trim() === "" && submitAttempted)
name.trim() === "" ||
name.trim().length > 16 ||
slug.trim() === "" ||
slug.trim().length > 16
}
>
{submitting ? "Creating..." : "Create"}

View File

@@ -1,62 +0,0 @@
import type { UserRecord } from "@issue/shared";
import { Link } from "react-router-dom";
import LogOutButton from "@/components/log-out-button";
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function Header({ user, children }: { user: UserRecord; children?: React.ReactNode }) {
return (
<div className="flex gap-12 items-center justify-between p-1">
{children}
<div className="flex gap-1 items-center">
<DropdownMenu>
<DropdownMenuTrigger className="text-sm">
<SmallUserDisplay user={user} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
<DropdownMenuItem asChild className="flex items-end justify-end">
<Link to="/" className="p-0 text-end">
Home
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<Link to="/settings/account" className="p-0 text-end">
My Account
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<Link to="/settings/organisations" className="p-0 text-end">
My Organisations
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<ServerConfigurationDialog
trigger={
<Button
variant="ghost"
className="flex w-full gap-2 items-center justify-end text-end px-2 py-1 m-0 h-auto"
title="Server Configuration"
>
Server Configuration
</Button>
}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex items-end justify-end p-0 m-0">
<LogOutButton noStyle className={"flex w-full justify-end"} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -19,12 +19,14 @@ export function OrganisationSelect({
onSelectedOrganisationChange,
onCreateOrganisation,
placeholder = "Select Organisation",
contentClass,
}: {
organisations: OrganisationResponse[];
selectedOrganisation: OrganisationResponse | null;
onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void;
onCreateOrganisation?: (organisationId: number) => void | Promise<void>;
placeholder?: string;
contentClass?: string;
}) {
const [open, setOpen] = useState(false);
@@ -44,7 +46,7 @@ export function OrganisationSelect({
<SelectTrigger className="text-sm" isOpen={open}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent side="bottom" position="popper">
<SelectContent side="bottom" position="popper" className={contentClass}>
<SelectGroup>
<SelectLabel>Organisations</SelectLabel>
{organisations.map((organisation) => (

View File

@@ -0,0 +1,227 @@
import type { OrganisationMemberResponse, OrganisationResponse, UserRecord } from "@issue/shared";
import { Plus, X } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { AddMemberDialog } from "@/components/add-member-dialog";
import { OrganisationSelect } from "@/components/organisation-select";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { organisation } from "@/lib/server";
function OrganisationsDialog({
trigger,
organisations,
selectedOrganisation,
setSelectedOrganisation,
refetchOrganisations,
}: {
trigger?: ReactNode;
organisations: OrganisationResponse[];
selectedOrganisation: OrganisationResponse | null;
setSelectedOrganisation: (organisation: OrganisationResponse | null) => void;
refetchOrganisations: (options?: { selectOrganisationId?: number }) => Promise<void>;
}) {
const user = JSON.parse(localStorage.getItem("user") || "{}") as UserRecord;
const [open, setOpen] = useState(false);
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
memberUserId: number;
memberName: string;
}>({ open: false, memberUserId: 0, memberName: "" });
const refetchMembers = useCallback(async () => {
if (!selectedOrganisation) return;
try {
await organisation.members({
organisationId: selectedOrganisation.Organisation.id,
onSuccess: (data) => {
const members = data as OrganisationMemberResponse[];
members.sort((a, b) => {
const nameCompare = a.User.name.localeCompare(b.User.name);
return nameCompare !== 0
? nameCompare
: b.OrganisationMember.role.localeCompare(a.OrganisationMember.role);
});
setMembers(members);
},
onError: (error) => {
console.error(error);
setMembers([]);
},
});
} catch (err) {
console.error("error fetching members:", err);
setMembers([]);
}
}, [selectedOrganisation]);
const handleRemoveMember = async (memberUserId: number, memberName: string) => {
setConfirmDialog({ open: true, memberUserId, memberName });
};
const confirmRemoveMember = async () => {
if (!selectedOrganisation) return;
try {
await organisation.removeMember({
organisationId: selectedOrganisation.Organisation.id,
userId: confirmDialog.memberUserId,
onSuccess: () => {
setConfirmDialog({ open: false, memberUserId: 0, memberName: "" });
void refetchMembers();
},
onError: (error) => {
console.error(error);
},
});
} catch (err) {
console.error(err);
}
};
// useEffect(() => {
// if (!open) return;
// void refetchOrganisations();
// }, [open, refetchOrganisations]);
useEffect(() => {
if (!open) return;
void refetchMembers();
}, [open, refetchMembers]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" className="flex w-full justify-end px-2 py-1 m-0 h-auto">
My Organisations
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md w-full max-w-[calc(100vw-2rem)]">
<DialogHeader>
<DialogTitle>Organisations</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<OrganisationSelect
organisations={organisations}
selectedOrganisation={selectedOrganisation}
onSelectedOrganisationChange={(org) => {
setSelectedOrganisation(org);
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
}}
onCreateOrganisation={async (organisationId) => {
await refetchOrganisations({ selectOrganisationId: organisationId });
}}
contentClass={
"data-[side=bottom]:translate-y-2 data-[side=bottom]:translate-x-0.25"
}
/>
</div>
{selectedOrganisation ? (
<div className="flex flex-col gap-2 min-w-0">
<div className="w-full border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2 break-all">
{selectedOrganisation.Organisation.name}
</h2>
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground break-all">
Slug: {selectedOrganisation.Organisation.slug}
</p>
<p className="text-sm text-muted-foreground break-all">
Role: {selectedOrganisation.OrganisationMember.role}
</p>
{selectedOrganisation.Organisation.description ? (
<p className="text-sm break-words">
{selectedOrganisation.Organisation.description}
</p>
) : (
<p className="text-sm text-muted-foreground break-words">
No description
</p>
)}
</div>
</div>
<div className="border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2">
{members.length} Member{members.length !== 1 ? "s" : ""}
</h2>
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
{members.map((member) => (
<div
key={member.OrganisationMember.id}
className="flex items-center justify-between p-2 border"
>
<div className="flex items-center gap-2">
<SmallUserDisplay user={member.User} />
<span className="text-sm text-muted-foreground">
{member.OrganisationMember.role}
</span>
</div>
{(selectedOrganisation.OrganisationMember.role === "owner" ||
selectedOrganisation.OrganisationMember.role ===
"admin") &&
member.OrganisationMember.role !== "owner" &&
member.User.id !== user.id && (
<Button
variant="dummy"
size="none"
onClick={() =>
handleRemoveMember(
member.User.id,
member.User.name,
)
}
>
<X className="size-5 text-destructive" />
</Button>
)}
</div>
))}
</div>
{(selectedOrganisation.OrganisationMember.role === "owner" ||
selectedOrganisation.OrganisationMember.role === "admin") && (
<AddMemberDialog
organisationId={selectedOrganisation.Organisation.id}
existingMembers={members.map((m) => m.User.username)}
onSuccess={refetchMembers}
trigger={
<Button variant="outline">
Add user <Plus className="size-4" />
</Button>
}
/>
)}
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No organisations yet.</p>
)}
<ConfirmDialog
open={confirmDialog.open}
onOpenChange={(open) => setConfirmDialog({ ...confirmDialog, open })}
onConfirm={confirmRemoveMember}
title="Remove Member"
processingText="Removing..."
message={`Are you sure you want to remove ${confirmDialog.memberName} from this organisation?`}
confirmText="Remove"
variant="destructive"
/>
</div>
</DialogContent>
</Dialog>
);
}
export default OrganisationsDialog;

View File

@@ -1,7 +1,6 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {

View File

@@ -11,6 +11,7 @@ export function Field({
submitAttempted,
placeholder,
error,
tabIndex,
}: {
label: string;
value?: string;
@@ -20,12 +21,13 @@ export function Field({
submitAttempted?: boolean;
placeholder?: string;
error?: string;
tabIndex?: number;
}) {
const [internalTouched, setInternalTouched] = useState(false);
const isTouched = submitAttempted || internalTouched;
const invalidMessage = useMemo(() => {
if (!isTouched) {
if (!isTouched && value === "") {
return "";
}
return validate?.(value) ?? "";
@@ -42,11 +44,15 @@ export function Field({
id={label}
placeholder={placeholder ?? label}
value={value}
onChange={onChange}
onChange={(e) => {
onChange(e);
setInternalTouched(true);
}}
onBlur={() => setInternalTouched(true)}
name={label}
aria-invalid={error !== undefined || invalidMessage !== ""}
type={hidden ? "password" : "text"}
tabIndex={tabIndex}
/>
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
{error || invalidMessage !== "" ? (