mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 18:33:01 +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 { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
import Account from "@/Account";
|
|
||||||
import { Auth } from "@/components/auth-provider";
|
import { Auth } from "@/components/auth-provider";
|
||||||
import NotFound from "@/components/NotFound";
|
import NotFound from "@/components/NotFound";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import Index from "@/Index";
|
import Index from "@/Index";
|
||||||
import Organisations from "@/Organisations";
|
|
||||||
import Test from "@/Test";
|
import Test from "@/Test";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -14,8 +12,6 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/settings/account" element={<Account />} />
|
|
||||||
<Route path="/settings/organisations" element={<Organisations />} />
|
|
||||||
<Route path="/test" element={<Test />} />
|
<Route path="/test" element={<Test />} />
|
||||||
<Route path={"*"} element={<NotFound />} />
|
<Route path={"*"} element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
||||||
import type { IssueResponse, OrganisationResponse, ProjectResponse, UserRecord } from "@issue/shared";
|
import type { IssueResponse, OrganisationResponse, ProjectResponse, UserRecord } from "@issue/shared";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import AccountDialog from "@/components/account-dialog";
|
||||||
import { CreateIssue } from "@/components/create-issue";
|
import { CreateIssue } from "@/components/create-issue";
|
||||||
import Header from "@/components/header";
|
|
||||||
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
||||||
import { IssuesTable } from "@/components/issues-table";
|
import { IssuesTable } from "@/components/issues-table";
|
||||||
|
import LogOutButton from "@/components/log-out-button";
|
||||||
import { OrganisationSelect } from "@/components/organisation-select";
|
import { OrganisationSelect } from "@/components/organisation-select";
|
||||||
|
import OrganisationsDialog from "@/components/organisations-dialog";
|
||||||
import { ProjectSelect } from "@/components/project-select";
|
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 { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
||||||
import { issue, organisation, project } from "@/lib/server";
|
import { issue, organisation, project } from "@/lib/server";
|
||||||
|
|
||||||
@@ -157,7 +169,7 @@ function Index() {
|
|||||||
return (
|
return (
|
||||||
<main className="w-full h-screen flex flex-col">
|
<main className="w-full h-screen flex flex-col">
|
||||||
{/* header area */}
|
{/* header area */}
|
||||||
<Header user={user}>
|
<div className="flex gap-12 items-center justify-between p-1">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
{/* organisation selection */}
|
{/* organisation selection */}
|
||||||
<OrganisationSelect
|
<OrganisationSelect
|
||||||
@@ -201,7 +213,45 @@ function Index() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* main body */}
|
||||||
<div className="w-full h-full flex items-start justify-between p-1 gap-1">
|
<div className="w-full h-full flex items-start justify-between p-1 gap-1">
|
||||||
{selectedProject && issues.length > 0 && (
|
{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);
|
setError(null);
|
||||||
setSubmitAttempted(true);
|
setSubmitAttempted(true);
|
||||||
|
|
||||||
if (name.trim() === "" || slug.trim() === "") {
|
if (name.trim() === "" || name.trim().length > 16) return;
|
||||||
return;
|
if (slug.trim() === "" || slug.trim().length > 16) return;
|
||||||
}
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
setError("you must be logged in to create an organisation");
|
setError("you must be logged in to create an organisation");
|
||||||
@@ -122,7 +121,13 @@ export function CreateOrganisation({
|
|||||||
setSlug(slugify(nextName));
|
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}
|
submitAttempted={submitAttempted}
|
||||||
placeholder="Demo Organisation"
|
placeholder="Demo Organisation"
|
||||||
/>
|
/>
|
||||||
@@ -133,7 +138,13 @@ export function CreateOrganisation({
|
|||||||
setSlug(slugify(e.target.value));
|
setSlug(slugify(e.target.value));
|
||||||
setSlugManuallyEdited(true);
|
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}
|
submitAttempted={submitAttempted}
|
||||||
placeholder="demo-organisation"
|
placeholder="demo-organisation"
|
||||||
/>
|
/>
|
||||||
@@ -166,8 +177,10 @@ export function CreateOrganisation({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
submitting ||
|
submitting ||
|
||||||
(name.trim() === "" && submitAttempted) ||
|
name.trim() === "" ||
|
||||||
(slug.trim() === "" && submitAttempted)
|
name.trim().length > 16 ||
|
||||||
|
slug.trim() === "" ||
|
||||||
|
slug.trim().length > 16
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{submitting ? "Creating..." : "Create"}
|
{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,
|
onSelectedOrganisationChange,
|
||||||
onCreateOrganisation,
|
onCreateOrganisation,
|
||||||
placeholder = "Select Organisation",
|
placeholder = "Select Organisation",
|
||||||
|
contentClass,
|
||||||
}: {
|
}: {
|
||||||
organisations: OrganisationResponse[];
|
organisations: OrganisationResponse[];
|
||||||
selectedOrganisation: OrganisationResponse | null;
|
selectedOrganisation: OrganisationResponse | null;
|
||||||
onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void;
|
onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void;
|
||||||
onCreateOrganisation?: (organisationId: number) => void | Promise<void>;
|
onCreateOrganisation?: (organisationId: number) => void | Promise<void>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
contentClass?: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ export function OrganisationSelect({
|
|||||||
<SelectTrigger className="text-sm" isOpen={open}>
|
<SelectTrigger className="text-sm" isOpen={open}>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="bottom" position="popper">
|
<SelectContent side="bottom" position="popper" className={contentClass}>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>Organisations</SelectLabel>
|
<SelectLabel>Organisations</SelectLabel>
|
||||||
{organisations.map((organisation) => (
|
{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 * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function Field({
|
|||||||
submitAttempted,
|
submitAttempted,
|
||||||
placeholder,
|
placeholder,
|
||||||
error,
|
error,
|
||||||
|
tabIndex,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -20,12 +21,13 @@ export function Field({
|
|||||||
submitAttempted?: boolean;
|
submitAttempted?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
tabIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const [internalTouched, setInternalTouched] = useState(false);
|
const [internalTouched, setInternalTouched] = useState(false);
|
||||||
const isTouched = submitAttempted || internalTouched;
|
const isTouched = submitAttempted || internalTouched;
|
||||||
|
|
||||||
const invalidMessage = useMemo(() => {
|
const invalidMessage = useMemo(() => {
|
||||||
if (!isTouched) {
|
if (!isTouched && value === "") {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return validate?.(value) ?? "";
|
return validate?.(value) ?? "";
|
||||||
@@ -42,11 +44,15 @@ export function Field({
|
|||||||
id={label}
|
id={label}
|
||||||
placeholder={placeholder ?? label}
|
placeholder={placeholder ?? label}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={(e) => {
|
||||||
|
onChange(e);
|
||||||
|
setInternalTouched(true);
|
||||||
|
}}
|
||||||
onBlur={() => setInternalTouched(true)}
|
onBlur={() => setInternalTouched(true)}
|
||||||
name={label}
|
name={label}
|
||||||
aria-invalid={error !== undefined || invalidMessage !== ""}
|
aria-invalid={error !== undefined || invalidMessage !== ""}
|
||||||
type={hidden ? "password" : "text"}
|
type={hidden ? "password" : "text"}
|
||||||
|
tabIndex={tabIndex}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
|
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
|
||||||
{error || invalidMessage !== "" ? (
|
{error || invalidMessage !== "" ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user