Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions client/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
},
"aliases": {
"components": "@/components",
"utils": "@/utils"
"utils": "@/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
}
44 changes: 43 additions & 1 deletion client/src/components/app/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import {
BriefcaseBusiness,
CircleUser,
Earth,
LibraryBig,
Expand All @@ -20,11 +21,25 @@ import { Link, useLocation } from "wouter";
import MenuLink from "../ui/menu-link";
import { Toaster } from "../ui/toaster";
import { TooltipProvider } from "../ui/tooltip";
import Routes from "./routes";
import { Routes } from "./routes";
import { OrgSelector } from "../ui/org-selector";
import { useContext, useState } from "react";
import { UserContext } from "@/userContext";

export function Dashboard() {
const [location] = useLocation();

const [isOrgDropDownOpen, setOrgDropDownOpen] = useState(false)
const { currentOrganization, userOrgsQuery } = useContext(UserContext);

const orgSelectorItems = userOrgsQuery?.data?.map(organization => {
return {
key: organization.orgs.id,
linkProps: { to: `~/org/${organization.orgs?.id}` },
text: organization.orgs.name,
}
});

return (
<TooltipProvider>
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
Expand Down Expand Up @@ -110,6 +125,33 @@ export function Dashboard() {
</SheetContent>
</Sheet>
<div className="w-full flex-1"></div>
{ orgSelectorItems?.length! > 1 && (
<DropdownMenu
open={isOrgDropDownOpen}
onOpenChange={(state) => setOrgDropDownOpen(state)}
>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="default"
className="rounded-full"
>
<BriefcaseBusiness className="h-5 w-5" />
<div className="w-[120px] overflow-hidden text-ellipsis text-xs">
{ currentOrganization?.orgs?.name }
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<OrgSelector
items={orgSelectorItems || []}
LinkComponent={Link}
density="dense"
onSelected={() => setOrgDropDownOpen(false)}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/app/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { API_URL } from "@/constants";
import UserContext from "@/userContext";
import { UserContext } from "@/userContext";
import { zodResolver } from "@hookform/resolvers/zod";
import { useContext } from "react";
import { useForm } from "react-hook-form";
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/app/logout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { API_URL } from "@/constants";
import UserContext from "@/userContext";
import { UserContext } from "@/userContext";
import { useContext, useEffect } from "react";
import { useLocation } from "wouter";

Expand Down
38 changes: 36 additions & 2 deletions client/src/components/app/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Route, Switch } from "wouter";
import { Route, Switch, useLocation } from "wouter";
import Catalogues from "./catalogues";
import CreateCatalogue from "./catalogues/create";
import CatalogueDetail from "./catalogues/detail";
Expand All @@ -20,8 +20,42 @@ import CreateUser from "./users/create";
import DeleteUser from "./users/delete";
import ResetUserPassword from "./users/reset-password";
import Welcome from "./welcome";
import { PropsWithChildren, useContext, useLayoutEffect } from "react";

export default function Routes() {
import { SelectOrganization } from "./selectOrganization";
import { UserContext } from "@/userContext";

export function Scaffold({ children }: PropsWithChildren) {
const [location, navigate] = useLocation();
const { setCurrentOrganizationId } = useContext(UserContext);

// useParams() should be used instead
// however it requires the router context provider which
// can only be when a child of a route and that requires
// a hefty restructuring of the app
const uriOrg = location.match(/(?:^|\/)org\/([^\/]+)/)?.[1];

useLayoutEffect(() => {
if (!uriOrg) {
console.debug({ goTo: 'orgs' });
navigate('~/organizations');
return;
}

setCurrentOrganizationId(Number(uriOrg))
}, [uriOrg, location])

return (
<Switch>
<Route path="/organizations" component={SelectOrganization} />
<Route path="/org/:uriOrg" nest>
{ children }
</Route>
</Switch>
);
}

export function Routes() {
return (
<Switch>
<Route path="/" component={Welcome} />
Expand Down
79 changes: 79 additions & 0 deletions client/src/components/app/selectOrganization.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { UserContext, UserContextProps } from "@/userContext";
import { useContext, useEffect, useState } from "react";
import { Link } from "wouter";
import { AlertCircle, LoaderIcon } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { Alert, AlertTitle, AlertDescription } from "../ui/alert";
import { OrgSelector } from "../ui/org-selector";

export const SelectOrganization = () => {
const { setCurrentOrganizationId, userOrgsQuery } = useContext(UserContext);
const [error, setError] = useState<Error | undefined>();

useEffect(
() => autoSelectOrgIfSingleMembership(userOrgsQuery, setCurrentOrganizationId, setError),
[userOrgsQuery?.status]
);

const orgSelectorItems = userOrgsQuery?.data?.map((item) => ({
key: item.orgs.id,
linkProps: { to: `/org/${item.orgs?.id}` },
text: item.orgs.name,
}));

const Items = (
<OrgSelector
items={orgSelectorItems || []}
LinkComponent={Link}
density="relaxed"
/>
);

return (
<div className="w-full h-dvh flex flex-col items-center justify-center">
<h1 className="m-4">Select organization</h1>
{error && (
<Card>
<CardContent>{}</CardContent>
</Card>
)}
{userOrgsQuery?.isLoading ? (
<LoaderIcon className="animate-spin mr-2 w-3.5" />
) : (
Items
)}
</div>
);
};

export function ErrorAlert({ error }: { error: Error }) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error?.message}</AlertDescription>
</Alert>
);
}

function autoSelectOrgIfSingleMembership(
query: UserContextProps["userOrgsQuery"],
onSelect: (id?: UserContextProps["currentOrganizationId"]) => void,
onError: (e: Error) => void
) {
if (query?.data?.length! > 1 || !query?.isFetched) {
return;
}

const [firstMembership] = query?.data || [];
if (!firstMembership) {
onError(
new Error(
"No organization assigned to the accoount. Please try signing in/out."
)
);
return;
}

onSelect(firstMembership.orgs.id);
}
4 changes: 4 additions & 0 deletions client/src/components/app/users/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export default function CreateUser() {
}
}

// Update component with organization list selection
// for the new user inferred from the current user
// org+roles

return (
<>
<h1 className="text-lg font-semibold md:text-2xl">Create user</h1>
Expand Down
37 changes: 37 additions & 0 deletions client/src/components/app/withUser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useContext, useEffect, useState, ReactNode } from "react";
import { API_URL } from "@/constants";
import { UserContext } from "@/userContext";

type WithUserProps = {
IfAuth: ReactNode,
Else: ReactNode,
}

export function WithUser({ IfAuth, Else }: WithUserProps) {
const [isLoading, setIsLoading] = useState(true);
const { user, setUser } = useContext(UserContext);

useEffect(() => {
setIsLoading(true);
fetch(`${API_URL}/me`, { credentials: "include" })
.then((r) => {
if (r.status != 200) {
throw new Error("Not authorized");
}
return r.json();
})
.then(setUser)
.then(() => setIsLoading(false))
.catch(() => {
setIsLoading(false);
});
}, []);

if (isLoading) {
return null;
}

return user
? IfAuth
: Else
}
59 changes: 59 additions & 0 deletions client/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/utils"

const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)

const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"

const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"

const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"

export { Alert, AlertTitle, AlertDescription }
52 changes: 52 additions & 0 deletions client/src/components/ui/org-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { PropsWithChildren } from "react"
import { Button } from "./button"
import { ChevronRight, LucideBriefcaseBusiness } from "lucide-react";

export interface OrgSelectorProps {
items: Array<{
iconUrl?: string,
key: React.Key,
text: string,
linkProps: {
to: string
}
}>;
density: keyof typeof DensityStyles;
LinkComponent: React.FC<PropsWithChildren & { to: string, onClick: () => void }>;
onSelected?: () => void
}

const DensityStyles = {
relaxed: 'm-2 max-h-[50vh] p-2',
dense: 'm-1 max-h-[30vh] p-2',
}

export function OrgSelector({ items, density, LinkComponent, onSelected }: OrgSelectorProps) {
return (
<div className={`${DensityStyles[density]} overflow-y-auto overflow-x-hidden flex flex-col items-center`}>
{
items.map(item =>
(
<Button
asChild
key={item?.key}
className={`w-80 ${DensityStyles[density]} flex flex-row flex-no-wrap justify-between`}
variant="outline"
>
<LinkComponent
onClick={() => { typeof onSelected === 'function' && onSelected() }}
{...item.linkProps}
>
{item.iconUrl
? <img src={item.iconUrl} />
: <LucideBriefcaseBusiness />
}
<div className="overflow-hidden text-ellipsis max-w-[80%]">{item.text}</div>
<ChevronRight />
</LinkComponent>
</Button>
))
}
</div>
)
}
Loading