mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
converted account and organisation pages to dialogs
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
130
packages/frontend/src/components/account-dialog.tsx
Normal file
130
packages/frontend/src/components/account-dialog.tsx
Normal 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;
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
227
packages/frontend/src/components/organisations-dialog.tsx
Normal file
227
packages/frontend/src/components/organisations-dialog.tsx
Normal 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;
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 !== "" ? (
|
||||
|
||||
Reference in New Issue
Block a user