refactoring and more work with clerk

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-07 23:28:55 +01:00
parent a9cbff2306
commit 86d2d0750f
61 changed files with 703 additions and 727 deletions

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { LazyChart } from '@/components/report/chart/LazyChart'; import { LazyChart } from '@/components/report/chart/LazyChart';
import { ReportRange } from '@/components/report/ReportRange'; import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1,11 +1,8 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { import { getExists } from '@/server/pageExists';
createRecentDashboard, import { getDashboardById } from '@/server/services/dashboard.service';
getDashboardById,
} from '@/server/services/dashboard.service';
import { getReportsByDashboardId } from '@/server/services/reports.service'; import { getReportsByDashboardId } from '@/server/services/reports.service';
import { auth } from '@clerk/nextjs'; import { notFound } from 'next/navigation';
import { revalidateTag } from 'next/cache';
import { ListReports } from './list-reports'; import { ListReports } from './list-reports';
@@ -20,22 +17,18 @@ interface PageProps {
export default async function Page({ export default async function Page({
params: { organizationId, projectId, dashboardId }, params: { organizationId, projectId, dashboardId },
}: PageProps) { }: PageProps) {
const { userId } = auth(); const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId),
getReportsByDashboardId(dashboardId),
getExists(organizationId, projectId),
]);
const dashboard = await getDashboardById(dashboardId); if (!dashboard) {
const reports = await getReportsByDashboardId(dashboardId); return notFound();
if (userId && dashboard) {
await createRecentDashboard({
userId,
organizationId,
projectId,
dashboardId,
});
revalidateTag(`recentDashboards__${userId}`);
} }
return ( return (
<PageLayout title={dashboard.name}> <PageLayout title={dashboard.name} organizationSlug={organizationId}>
<ListReports reports={reports} /> <ListReports reports={reports} />
</PageLayout> </PageLayout>
); );

View File

@@ -4,13 +4,9 @@ import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react'; import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../../../layout-sticky-below-header'; import { StickyBelowHeader } from '../layout-sticky-below-header';
interface HeaderDashboardsProps { export function HeaderDashboards() {
projectId: string;
}
export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
return ( return (
<StickyBelowHeader> <StickyBelowHeader>
<div className="p-4 flex justify-between items-center"> <div className="p-4 flex justify-between items-center">
@@ -18,9 +14,7 @@ export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
<Button <Button
icon={PlusIcon} icon={PlusIcon}
onClick={() => { onClick={() => {
pushModal('AddDashboard', { pushModal('AddDashboard');
projectId,
});
}} }}
> >
<span className="max-sm:hidden">Create dashboard</span> <span className="max-sm:hidden">Create dashboard</span>

View File

@@ -2,17 +2,19 @@
import { api, handleErrorToastOptions } from '@/app/_trpc/client'; import { api, handleErrorToastOptions } from '@/app/_trpc/client';
import { Card, CardActions, CardActionsItem } from '@/components/Card'; import { Card, CardActions, CardActionsItem } from '@/components/Card';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Button } from '@/components/ui/button';
import { ToastAction } from '@/components/ui/toast'; import { ToastAction } from '@/components/ui/toast';
import { toast } from '@/components/ui/use-toast'; import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import type { getDashboardsByProjectId } from '@/server/services/dashboard.service'; import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { Pencil, Trash } from 'lucide-react'; import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
interface ListDashboardsProps { interface ListDashboardsProps {
dashboards: Awaited<ReturnType<typeof getDashboardsByProjectId>>; dashboards: IServiceDashboards;
} }
export function ListDashboards({ dashboards }: ListDashboardsProps) { export function ListDashboards({ dashboards }: ListDashboardsProps) {
@@ -46,6 +48,21 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
}, },
}); });
if (dashboards.length === 0) {
return (
<FullPageEmptyState title="No dashboards" icon={LayoutPanelTopIcon}>
<p>You have not created any dashboards for this project yet</p>
<Button
onClick={() => pushModal('AddDashboard')}
className="mt-14"
icon={PlusIcon}
>
Create dashboard
</Button>
</FullPageEmptyState>
);
}
return ( return (
<> <>
<div className="grid sm:grid-cols-2 gap-4 p-4"> <div className="grid sm:grid-cols-2 gap-4 p-4">
@@ -57,9 +74,6 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
className="block p-4 flex flex-col" className="block p-4 flex flex-col"
> >
<span className="font-medium">{item.name}</span> <span className="font-medium">{item.name}</span>
<span className="text-muted-foreground text-sm">
{item.project.name}
</span>
</Link> </Link>
</div> </div>

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service'; import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { HeaderDashboards } from './header-dashboards'; import { HeaderDashboards } from './header-dashboards';
@@ -7,15 +8,21 @@ import { ListDashboards } from './list-dashboards';
interface PageProps { interface PageProps {
params: { params: {
projectId: string; projectId: string;
organizationId: string;
}; };
} }
export default async function Page({ params: { projectId } }: PageProps) { export default async function Page({
const dashboards = await getDashboardsByProjectId(projectId); params: { projectId, organizationId },
}: PageProps) {
const [dashboards] = await Promise.all([
getDashboardsByProjectId(projectId),
await getExists(organizationId, projectId),
]);
return ( return (
<PageLayout title="Dashboards"> <PageLayout title="Dashboards" organizationSlug={organizationId}>
<HeaderDashboards projectId={projectId} /> {dashboards.length > 0 && <HeaderDashboards />}
<ListDashboards dashboards={dashboards} /> <ListDashboards dashboards={dashboards} />
</PageLayout> </PageLayout>
); );

View File

@@ -2,9 +2,11 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { api } from '@/app/_trpc/client'; import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { GanttChartIcon } from 'lucide-react';
import { EventListItem } from './event-list-item'; import { EventListItem } from './event-list-item';
@@ -47,14 +49,26 @@ export function ListEvents({ projectId }: ListEventsProps) {
</div> </div>
</StickyBelowHeader> </StickyBelowHeader>
<div className="p-4"> <div className="p-4">
<div className="flex flex-col gap-4"> {events.length === 0 ? (
{events.map((item) => ( <FullPageEmptyState title="No events here" icon={GanttChartIcon}>
<EventListItem key={item.id} {...item} /> {eventFilters.length ? (
))} <p>Could not find any events with your filter</p>
</div> ) : (
<div className="mt-2"> <p>We have not recieved any events yet</p>
<Pagination {...pagination} /> )}
</div> </FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
)}
</div> </div>
</> </>
); );

View File

@@ -1,15 +1,21 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { ListEvents } from './list-events'; import { ListEvents } from './list-events';
interface PageProps { interface PageProps {
params: { params: {
projectId: string; projectId: string;
organizationId: string;
}; };
} }
export default function Page({ params: { projectId } }: PageProps) { export default async function Page({
params: { projectId, organizationId },
}: PageProps) {
await getExists(organizationId, projectId);
return ( return (
<PageLayout title="Events"> <PageLayout title="Events" organizationSlug={organizationId}>
<ListEvents projectId={projectId} /> <ListEvents projectId={projectId} />
</PageLayout> </PageLayout>
); );

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service'; import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useUser } from '@clerk/nextjs';
import { import {
BuildingIcon, BuildingIcon,
CogIcon, CogIcon,
@@ -24,49 +26,56 @@ function LinkWithIcon({
icon: Icon, icon: Icon,
label, label,
active: overrideActive, active: overrideActive,
className,
}: { }: {
href: string; href: string;
icon: React.ElementType<LucideProps>; icon: React.ElementType<LucideProps>;
label: React.ReactNode; label: React.ReactNode;
active?: boolean; active?: boolean;
className?: string;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const active = overrideActive || href === pathname; const active = overrideActive || href === pathname;
return ( return (
<Link <Link
className={cn( className={cn(
'text-slate-600 flex gap-2 items-center px-3 py-3 transition-colors hover:text-white hover:bg-blue-700 leading-none rounded-lg', 'text-slate-600 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
active && 'bg-blue-600 text-white' active && 'bg-blue-50',
className
)} )}
href={href} href={href}
> >
<Icon size={20} /> <Icon size={20} />
<div className="flex-1">{label}</div> <div className="flex-1">{label}</div>
<DotIcon
size={20}
className={cn(
'transition-opacity',
active ? 'opacity-100' : 'opacity-0'
)}
/>
</Link> </Link>
); );
} }
interface LayoutMenuProps { interface LayoutMenuProps {
recentDashboards: IServiceRecentDashboards; dashboards: IServiceDashboards;
fallbackProjectId: string | null;
} }
export default function LayoutMenu({ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
recentDashboards, const { user } = useUser();
fallbackProjectId,
}: LayoutMenuProps) {
const pathname = usePathname(); const pathname = usePathname();
const params = useAppParams(); const params = useAppParams();
const projectId = const hasProjectId =
!params.projectId || params.projectId === 'undefined' params.projectId &&
? fallbackProjectId params.projectId !== 'null' &&
: params.projectId; params.projectId !== 'undefined';
const projectId = hasProjectId
? params.projectId
: (user?.unsafeMetadata.projectId as string);
useEffect(() => {
if (hasProjectId) {
user?.update({
unsafeMetadata: {
projectId: params.projectId,
},
});
}
}, [params.projectId, hasProjectId]);
return ( return (
<> <>
@@ -93,50 +102,51 @@ export default function LayoutMenu({
<LinkWithIcon <LinkWithIcon
icon={CogIcon} icon={CogIcon}
label="Settings" label="Settings"
href={`/${params.organizationId}/settings/organization`} href={`/${params.organizationId}/${projectId}/settings/organization`}
/> />
{pathname?.includes('/settings/') && ( {pathname?.includes('/settings/') && (
<div className="pl-7"> <div className="pl-7 flex flex-col gap-1">
<LinkWithIcon <LinkWithIcon
icon={BuildingIcon} icon={BuildingIcon}
label="Organization" label="Organization"
href={`/${params.organizationId}/settings/organization`} href={`/${params.organizationId}/${projectId}/settings/organization`}
/> />
<LinkWithIcon <LinkWithIcon
icon={WarehouseIcon} icon={WarehouseIcon}
label="Projects" label="Projects"
href={`/${params.organizationId}/settings/projects`} href={`/${params.organizationId}/${projectId}/settings/projects`}
/> />
<LinkWithIcon <LinkWithIcon
icon={KeySquareIcon} icon={KeySquareIcon}
label="Clients" label="Clients"
href={`/${params.organizationId}/settings/clients`} href={`/${params.organizationId}/${projectId}/settings/clients`}
/> />
<LinkWithIcon <LinkWithIcon
icon={UserIcon} icon={UserIcon}
label="Profile (yours)" label="Profile (yours)"
href={`/${params.organizationId}/settings/profile`} href={`/${params.organizationId}/${projectId}/settings/profile`}
/> />
</div> </div>
)} )}
{recentDashboards.length > 0 && ( {dashboards.length > 0 && (
<div className="mt-8"> <div className="mt-8">
<div className="font-medium mb-2">Recent dashboards</div> <div className="font-medium mb-2 text-sm">Your dashboards</div>
{recentDashboards.map((item) => ( <div className="flex flex-col gap-2">
<LinkWithIcon {dashboards.map((item) => (
key={item.id} <LinkWithIcon
icon={LayoutPanelTopIcon} className="py-1"
label={ key={item.id}
<div className="flex flex-col"> icon={LayoutPanelTopIcon}
<span>{item.dashboard.name}</span> label={
<span className="text-xs text-muted-foreground"> <div className="flex flex-col gap-0.5">
{item.project.name} <span>{item.name}</span>
</span> <span className="text-xs">{item.project.name}</span>
</div> </div>
} }
href={`/${item.organization_slug}/${item.project_id}/${item.dashboard_id}`} href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
/> />
))} ))}
</div>
</div> </div>
)} )}
</> </>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Logo } from '@/components/Logo'; import { Logo } from '@/components/Logo';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import type { IServiceOrganization } from '@/server/services/organization.service'; import type { IServiceOrganization } from '@/server/services/organization.service';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react'; import { Rotate as Hamburger } from 'hamburger-react';
@@ -13,14 +14,19 @@ import LayoutOrganizationSelector from './layout-organization-selector';
interface LayoutSidebarProps { interface LayoutSidebarProps {
organizations: IServiceOrganization[]; organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
} }
export function LayoutSidebar({ organizations }: LayoutSidebarProps) { export function LayoutSidebar({
organizations,
dashboards,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const fallbackProjectId = null;
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
setActive(false); setActive(false);
}, [pathname]); }, [pathname]);
return ( return (
<> <>
<button <button
@@ -48,10 +54,7 @@ export function LayoutSidebar({ organizations }: LayoutSidebarProps) {
</Link> </Link>
</div> </div>
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto"> <div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
<LayoutMenu <LayoutMenu dashboards={dashboards} />
recentDashboards={[]}
fallbackProjectId={fallbackProjectId}
/>
{/* Placeholder for LayoutOrganizationSelector */} {/* Placeholder for LayoutOrganizationSelector */}
<div className="h-16 block shrink-0"></div> <div className="h-16 block shrink-0"></div>
</div> </div>

View File

@@ -0,0 +1,28 @@
import { getDashboardsByOrganization } from '@/server/services/dashboard.service';
import { getCurrentOrganizations } from '@/server/services/organization.service';
import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationId: string;
};
}
export default async function AppLayout({
children,
params: { organizationId },
}: AppLayoutProps) {
const [organizations, dashboards] = await Promise.all([
getCurrentOrganizations(),
getDashboardsByOrganization(organizationId),
]);
return (
<div id="dashboard">
<LayoutSidebar {...{ organizations, dashboards }} />
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);
}

View File

@@ -26,7 +26,7 @@ import { cn } from '@/utils/cn';
import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react'; import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { StickyBelowHeader } from '../../layout-sticky-below-header'; import { StickyBelowHeader } from './layout-sticky-below-header';
export default function OverviewMetrics() { export default function OverviewMetrics() {
const { previous, range, setRange, interval, metric, setMetric, filters } = const { previous, range, setRange, interval, metric, setMetric, filters } =

View File

@@ -5,16 +5,20 @@ import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps { interface PageLayoutProps {
children: React.ReactNode; children: React.ReactNode;
title: React.ReactNode; title: React.ReactNode;
organizationSlug: string;
} }
export default async function PageLayout({ children, title }: PageLayoutProps) { export default async function PageLayout({
const projects = await getCurrentProjects(); children,
title,
organizationSlug,
}: PageLayoutProps) {
const projects = await getCurrentProjects(organizationSlug);
return ( return (
<> <>
<div className="h-16 border-b border-border flex-shrink-0 sticky top-0 bg-white px-4 flex items-center justify-between z-20 pl-12 lg:pl-4"> <div className="h-16 border-b border-border flex-shrink-0 sticky top-0 bg-white px-4 flex items-center justify-between z-20 pl-12 lg:pl-4">
<div className="text-xl font-medium">{title}</div> <div className="text-xl font-medium">{title}</div>
{projects.length > 0 && <LayoutProjectSelector projects={projects} />} {projects.length > 0 && <LayoutProjectSelector projects={projects} />}
</div> </div>
<div>{children}</div> <div>{children}</div>

View File

@@ -1,10 +1,21 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import OverviewMetrics from './overview-metrics'; import OverviewMetrics from './overview-metrics';
export default function Page() { interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return ( return (
<PageLayout title="Overview"> <PageLayout title="Overview" organizationSlug={organizationId}>
<OverviewMetrics /> <OverviewMetrics />
</PageLayout> </PageLayout>
); );

View File

@@ -1,7 +1,8 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { ListProperties } from '@/components/events/ListProperties'; import { ListProperties } from '@/components/events/ListProperties';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { getExists } from '@/server/pageExists';
import { import {
getProfileById, getProfileById,
getProfilesByExternalId, getProfilesByExternalId,
@@ -15,18 +16,23 @@ interface PageProps {
params: { params: {
projectId: string; projectId: string;
profileId: string; profileId: string;
organizationId: string;
}; };
} }
export default async function Page({ export default async function Page({
params: { projectId, profileId }, params: { projectId, profileId, organizationId },
}: PageProps) { }: PageProps) {
const profile = await getProfileById(profileId); const [profile] = await Promise.all([
getProfileById(profileId),
getExists(organizationId, projectId),
]);
const profiles = ( const profiles = (
await getProfilesByExternalId(profile.external_id, profile.project_id) await getProfilesByExternalId(profile.external_id, profile.project_id)
).filter((item) => item.id !== profile.id); ).filter((item) => item.id !== profile.id);
return ( return (
<PageLayout <PageLayout
organizationSlug={organizationId}
title={ title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" /> <ProfileAvatar {...profile} size="sm" className="hidden sm:block" />

View File

@@ -2,9 +2,11 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { api } from '@/app/_trpc/client'; import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination, usePagination } from '@/components/Pagination'; import { Pagination, usePagination } from '@/components/Pagination';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { UsersIcon } from 'lucide-react';
import { useQueryState } from 'nuqs'; import { useQueryState } from 'nuqs';
import { ProfileListItem } from './profile-list-item'; import { ProfileListItem } from './profile-list-item';
@@ -16,7 +18,7 @@ interface ListProfilesProps {
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) { export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q'); const [query, setQuery] = useQueryState('q');
const pagination = usePagination(); const pagination = usePagination();
const eventsQuery = api.profile.list.useQuery( const profilesQuery = api.profile.list.useQuery(
{ {
projectId, projectId,
query, query,
@@ -26,7 +28,7 @@ export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
keepPreviousData: true, keepPreviousData: true,
} }
); );
const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery]);
return ( return (
<> <>
@@ -38,14 +40,28 @@ export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
/> />
</StickyBelowHeader> </StickyBelowHeader>
<div className="p-4"> <div className="p-4">
<div className="flex flex-col gap-4"> {profiles.length === 0 ? (
{profiles.map((item) => ( <FullPageEmptyState title="No profiles" icon={UsersIcon}>
<ProfileListItem key={item.id} {...item} /> {query ? (
))} <p>
</div> No match for <strong>"{query}"</strong>
<div className="mt-2"> </p>
<Pagination {...pagination} /> ) : (
</div> <p>We could not find any profiles on this project</p>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{profiles.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
)}
</div> </div>
</> </>
); );

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { ListProfiles } from './list-profiles'; import { ListProfiles } from './list-profiles';
@@ -8,11 +9,13 @@ interface PageProps {
projectId: string; projectId: string;
}; };
} }
export default function Page({ export default async function Page({
params: { organizationId, projectId }, params: { organizationId, projectId },
}: PageProps) { }: PageProps) {
await getExists(organizationId, projectId);
return ( return (
<PageLayout title="Events"> <PageLayout title="Events" organizationSlug={organizationId}>
<ListProfiles projectId={projectId} organizationId={organizationId} /> <ListProfiles projectId={projectId} organizationId={organizationId} />
</PageLayout> </PageLayout>
); );

View File

@@ -1,6 +1,9 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getReportById } from '@/server/services/reports.service'; import { getReportById } from '@/server/services/reports.service';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import ReportEditor from '../report-editor'; import ReportEditor from '../report-editor';
@@ -8,13 +11,25 @@ interface PageProps {
params: { params: {
projectId: string; projectId: string;
reportId: string; reportId: string;
organizationId: string;
}; };
} }
export default async function Page({ params: { reportId } }: PageProps) { export default async function Page({
const report = await getReportById(reportId); params: { reportId, organizationId, projectId },
}: PageProps) {
const [report] = await Promise.all([
getReportById(reportId),
getExists(organizationId, projectId),
]);
if (!report) {
return notFound();
}
return ( return (
<PageLayout <PageLayout
organizationSlug={organizationId}
title={ title={
<div className="flex gap-2 items-center cursor-pointer"> <div className="flex gap-2 items-center cursor-pointer">
{report.name} {report.name}

View File

@@ -1,5 +1,8 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import ReportEditor from './report-editor'; import ReportEditor from './report-editor';
@@ -10,9 +13,14 @@ interface PageProps {
}; };
} }
export default function Page({ params: { organizationId } }: PageProps) { export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return ( return (
<PageLayout <PageLayout
organizationSlug={organizationId}
title={ title={
<div className="flex gap-2 items-center cursor-pointer"> <div className="flex gap-2 items-center cursor-pointer">
Unnamed report Unnamed report
@@ -20,7 +28,7 @@ export default function Page({ params: { organizationId } }: PageProps) {
</div> </div>
} }
> >
<ReportEditor reportId={null} /> <ReportEditor report={null} />
</PageLayout> </PageLayout>
); );
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { Chart } from '@/components/report/chart'; import { Chart } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType'; import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval'; import { ReportInterval } from '@/components/report/ReportInterval';

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { columns } from '@/components/clients/table'; import { columns } from '@/components/clients/table';
import { DataTable } from '@/components/DataTable'; import { DataTable } from '@/components/DataTable';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getClientsByOrganizationId } from '@/server/services/clients.service'; import { getClientsByOrganizationId } from '@/server/services/clients.service';
import ListClients from './list-clients'; import ListClients from './list-clients';
@@ -10,10 +11,11 @@ interface PageProps {
} }
export default async function Page({ params: { organizationId } }: PageProps) { export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const clients = await getClientsByOrganizationId(organizationId); const clients = await getClientsByOrganizationId(organizationId);
return ( return (
<PageLayout title="Clients"> <PageLayout title="Clients" organizationSlug={organizationId}>
<ListClients clients={clients} /> <ListClients clients={clients} />
</PageLayout> </PageLayout>
); );

View File

@@ -0,0 +1,60 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { zInviteUser } from '@/utils/validation';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
type IForm = z.infer<typeof zInviteUser>;
export function InviteUser() {
const router = useRouter();
const { organizationId: organizationSlug } = useAppParams();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
resolver: zodResolver(zInviteUser),
defaultValues: {
organizationSlug,
email: '',
role: 'org:member',
},
});
const mutation = api.organization.inviteUser.useMutation({
onSuccess() {
toast({
title: 'User invited!',
description: 'The user has been invited to the organization.',
});
reset();
router.refresh();
},
});
return (
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex items-end gap-4"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
placeholder="Who do you want to invite?"
{...register('email')}
/>
<Button
icon={SendIcon}
type="submit"
disabled={!formState.isDirty}
loading={mutation.isLoading}
>
Invite user
</Button>
</form>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { IServiceInvites } from '@/server/services/organization.service';
import { InviteUser } from './invite-user';
interface InvitedUsersProps {
invites: IServiceInvites;
}
export default function InvitedUsers({ invites }: InvitedUsersProps) {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
</WidgetHead>
<WidgetBody>
<InviteUser />
<div className="font-medium mt-8 mb-2">Invited users</div>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell>{item.role}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
</TableRow>
);
})}
{invites.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="italic">
No invites
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import {
getInvites,
getOrganizationBySlug,
} from '@/server/services/organization.service';
import { clerkClient } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
import EditOrganization from './edit-organization';
import InvitedUsers from './invited-users';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const organization = await getOrganizationBySlug(organizationId);
if (!organization) {
return notFound();
}
const invites = await getInvites(organization.id);
return (
<PageLayout title={organization.name} organizationSlug={organizationId}>
<div className="p-4 grid grid-cols-1 gap-4">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} />
</div>
</PageLayout>
);
}

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { signOut } from 'next-auth/react'; import { SignOutButton } from '@clerk/nextjs';
export function Logout() { export function Logout() {
return ( return (
@@ -14,14 +13,7 @@ export function Logout() {
<p className="mb-4"> <p className="mb-4">
Sometime&apos;s you need to go. See you next time Sometime&apos;s you need to go. See you next time
</p> </p>
<Button <SignOutButton />
variant={'destructive'}
onClick={() => {
signOut();
}}
>
Logout 🤨
</Button>
</WidgetBody> </WidgetBody>
</Widget> </Widget>
); );

View File

@@ -1,16 +1,23 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getUserById } from '@/server/services/user.service'; import { getUserById } from '@/server/services/user.service';
import { auth } from '@clerk/nextjs'; import { auth } from '@clerk/nextjs';
import EditProfile from './edit-profile'; import EditProfile from './edit-profile';
import { Logout } from './logout'; import { Logout } from './logout';
export default async function Page() { interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const { userId } = auth(); const { userId } = auth();
await getExists(organizationId);
const profile = await getUserById(userId!); const profile = await getUserById(userId!);
return ( return (
<PageLayout title={profile.lastName}> <PageLayout title={profile.lastName} organizationSlug={organizationId}>
<div className="p-4 flex flex-col gap-4"> <div className="p-4 flex flex-col gap-4">
<EditProfile profile={profile} /> <EditProfile profile={profile} />
<Logout /> <Logout />

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/DataTable'; import { DataTable } from '@/components/DataTable';
import { columns } from '@/components/projects/table'; import { columns } from '@/components/projects/table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/page-layout'; import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getProjectsByOrganizationSlug } from '@/server/services/project.service'; import { getProjectsByOrganizationSlug } from '@/server/services/project.service';
import ListProjects from './list-projects'; import ListProjects from './list-projects';
@@ -10,10 +11,11 @@ interface PageProps {
} }
export default async function Page({ params: { organizationId } }: PageProps) { export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const projects = await getProjectsByOrganizationSlug(organizationId); const projects = await getProjectsByOrganizationSlug(organizationId);
return ( return (
<PageLayout title="Projects"> <PageLayout title="Projects" organizationSlug={organizationId}>
<ListProjects projects={projects} /> <ListProjects projects={projects} />
</PageLayout> </PageLayout>
); );

View File

@@ -1,49 +0,0 @@
'use client';
import { Card } from '@/components/Card';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
import { Plus } from 'lucide-react';
import Link from 'next/link';
interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
}
export function ListProjects({ projects }: ListProjectsProps) {
const params = useAppParams();
const organizationId = params.organizationId;
return (
<>
<div className="grid sm:grid-cols-2 gap-4 p-4">
{projects.map((item) => (
<Card key={item.id} hover>
<div>
<Link
href={`/${organizationId}/${item.id}`}
className="block p-4 flex flex-col"
>
<span className="font-medium">{item.name}</span>
</Link>
</div>
</Card>
))}
<Card hover className="border-dashed">
<button
className="flex items-center justify-between w-full p-4 font-medium leading-none"
onClick={() => {
pushModal('AddProject', {
organizationId,
});
}}
>
Create new project
<Plus size={16} />
</button>
</Card>
</div>
</>
);
}

View File

@@ -1,7 +1,8 @@
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getProjectWithMostEvents } from '@/server/services/project.service'; import { getProjectWithMostEvents } from '@/server/services/project.service';
import { redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import PageLayout from '../page-layout'; import PageLayout from './[projectId]/page-layout';
interface PageProps { interface PageProps {
params: { params: {
@@ -10,14 +11,21 @@ interface PageProps {
} }
export default async function Page({ params: { organizationId } }: PageProps) { export default async function Page({ params: { organizationId } }: PageProps) {
const project = await getProjectWithMostEvents(organizationId); const [organization, project] = await Promise.all([
getOrganizationBySlug(organizationId),
getProjectWithMostEvents(organizationId),
]);
if (!organization) {
return notFound();
}
if (project) { if (project) {
return redirect(`/${organizationId}/${project.id}`); return redirect(`/${organizationId}/${project.id}`);
} }
return ( return (
<PageLayout title="Projects"> <PageLayout title="Projects" organizationSlug={organizationId}>
<div className="p-4"> <div className="p-4">
<h1>Create your first project</h1> <h1>Create your first project</h1>
</div> </div>

View File

@@ -1,111 +0,0 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from '@/components/ui/use-toast';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { IServiceInvite } from '@/server/services/user.service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validator = z.object({
email: z.string().email(),
});
type IForm = z.infer<typeof validator>;
interface InvitedUsersProps {
invites: IServiceInvite[];
organizationId: string;
}
export default function InvitedUsers({
invites,
organizationId,
}: InvitedUsersProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
email: '',
},
});
const mutation = api.user.invite.useMutation({
onSuccess() {
toast({
title: 'User invited',
description: "We have sent an invitation to the user's email",
});
reset();
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate({
...values,
organizationId,
});
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Invite
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Email"
placeholder="Who do you want to invite?"
{...register('email')}
/>
<div className="font-medium mt-8 mb-2">Invited users</div>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Accepted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell>{item.accepted ? 'Yes' : 'No'}</TableCell>
</TableRow>
);
})}
{invites.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="italic">
No invites
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -1,25 +0,0 @@
import PageLayout from '@/app/(app)/page-layout';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import EditOrganization from './edit-organization';
import InvitedUsers from './invited-users';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const organization = await getOrganizationBySlug(organizationId);
const invites = [];
return (
<PageLayout title={organization.name}>
<div className="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} organizationId={organizationId} />
</div>
</PageLayout>
);
}

View File

@@ -1,18 +0,0 @@
import { getOrganizations } from '@/server/services/organization.service';
import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps {
children: React.ReactNode;
}
export default async function AppLayout({ children }: AppLayoutProps) {
const organizations = await getOrganizations();
return (
<div id="dashboard">
<LayoutSidebar {...{ organizations }} />
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
interface ListOrganizationsProps {
organizations: any[];
}
export function ListOrganizations({ organizations }: ListOrganizationsProps) {
return (
<>
<div className="flex flex-col gap-4 -mx-6">
{organizations.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
className="block px-6 py-3 flex items-center justify-between border-b border-border last:border-b-0 hover:bg-slate-100"
>
<span className="font-medium">{item.name}</span>
<ChevronRight size={20} />
</Link>
))}
</div>
</>
);
}

View File

@@ -1,9 +1,9 @@
import { getOrganizations } from '@/server/services/organization.service'; import { getCurrentOrganizations } from '@/server/services/organization.service';
import { CreateOrganization } from '@clerk/nextjs'; import { CreateOrganization } from '@clerk/nextjs';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export default async function Page() { export default async function Page() {
const organizations = await getOrganizations(); const organizations = await getCurrentOrganizations();
if (organizations.length === 0) { if (organizations.length === 0) {
return <CreateOrganization />; return <CreateOrganization />;

View File

@@ -0,0 +1,27 @@
import type { LucideIcon } from 'lucide-react';
interface FullPageEmptyStateProps {
icon: LucideIcon;
title: string;
children: React.ReactNode;
}
export function FullPageEmptyState({
icon: Icon,
title,
children,
}: FullPageEmptyStateProps) {
return (
<div className="p-4 flex items-center justify-center">
<div className="p-8 w-full max-w-xl flex flex-col items-center justify-center">
<div className="w-24 h-24 bg-white shadow-sm rounded-full flex justify-center items-center mb-6">
<Icon size={60} strokeWidth={1} />
</div>
<h1 className="text-xl font-medium mb-1">{title}</h1>
{children}
</div>
</div>
);
}

View File

@@ -26,14 +26,14 @@ const buttonVariants = cva(
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-8 rounded-md px-2',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10', icon: 'h-8 w-8',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default', size: 'sm',
}, },
} }
); );

View File

@@ -2,11 +2,19 @@
import * as React from 'react'; import * as React from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandItem } from '@/components/ui/command'; import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@/components/ui/command';
import { useOnClickOutside } from 'usehooks-ts'; import { useOnClickOutside } from 'usehooks-ts';
import { Button } from './button';
import { Checkbox } from './checkbox'; import { Checkbox } from './checkbox';
import { Input } from './input'; import { Input } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
type IValue = any; type IValue = any;
type IItem = Record<'value' | 'label', IValue>; type IItem = Record<'value' | 'label', IValue>;
@@ -43,7 +51,6 @@ export function ComboboxAdvanced({
const checked = !!value.find((s) => s === item.value); const checked = !!value.find((s) => s === item.value);
return ( return (
<CommandItem <CommandItem
key={String(item.value)}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -71,52 +78,40 @@ export function ComboboxAdvanced({
}; };
return ( return (
<Command className="overflow-visible bg-white" ref={ref}> <Popover open={open} onOpenChange={setOpen}>
<button <PopoverTrigger asChild>
type="button" <Button variant={'outline'} onClick={() => setOpen((prev) => !prev)}>
className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" <div className="flex gap-1 flex-wrap">
onClick={() => setOpen((prev) => !prev)} {value.length === 0 && placeholder}
> {value.slice(0, 2).map((value) => {
<div className="flex gap-1 flex-wrap"> const item = items.find((item) => item.value === value) ?? {
{value.length === 0 && placeholder} value,
{value.slice(0, 2).map((value) => { label: value,
const item = items.find((item) => item.value === value) ?? { };
value, return <Badge key={String(item.value)}>{item.label}</Badge>;
label: value, })}
}; {value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
return (
<Badge key={String(item.value)} variant="secondary">
{item.label}
</Badge>
);
})}
{value.length > 2 && (
<Badge variant="secondary">+{value.length - 2} more</Badge>
)}
</div>
</button>
{open && (
<div className="relative top-2">
<div className="max-h-80 min-w-[300px] absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
<CommandGroup className="max-h-80 overflow-auto">
<div className="p-1 mb-2">
<Input
placeholder="Type to search"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
/>
</div>
{inputValue === ''
? value.map(renderUnknownItem)
: renderItem({
value: inputValue,
label: `Pick "${inputValue}"`,
})}
{selectables.map(renderItem)}
</CommandGroup>
</div> </div>
</div> </Button>
)} </PopoverTrigger>
</Command> <PopoverContent className="w-full max-w-md p-0" align="start">
<Command>
<CommandInput
placeholder="Search"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandGroup>
{inputValue === ''
? value.map(renderUnknownItem)
: renderItem({
value: inputValue,
label: `Pick "${inputValue}"`,
})}
{selectables.map(renderItem)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
); );
} }

View File

@@ -1,125 +0,0 @@
'use client';
import * as React from 'react';
import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandItem } from '@/components/ui/command';
import { Command as CommandPrimitive } from 'cmdk';
import { X } from 'lucide-react';
type Item = Record<'value' | 'label', string>;
interface ComboboxMultiProps {
selected: Item[];
setSelected: React.Dispatch<React.SetStateAction<Item[]>>;
items: Item[];
placeholder: string;
}
export function ComboboxMulti({
items,
selected,
setSelected,
placeholder,
...props
}: ComboboxMultiProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const handleUnselect = React.useCallback((item: Item) => {
setSelected((prev) => prev.filter((s) => s.value !== item.value));
}, []);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '') {
setSelected((prev) => {
const newSelected = [...prev];
newSelected.pop();
return newSelected;
});
}
}
// This is not a default behaviour of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[]
);
const selectables = items.filter(
(item) => !selected.find((s) => s.value === item.value)
);
return (
<Command onKeyDown={handleKeyDown} className="overflow-visible bg-white">
<div className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<div className="flex gap-1 flex-wrap">
{selected.map((item) => {
return (
<Badge key={item.value} variant="secondary">
{item.label}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(item);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(item)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={placeholder}
className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1"
/>
</div>
</div>
<div className="relative mt-2">
{open && selectables.length > 0 ? (
<div className="absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
<CommandGroup className="h-full overflow-auto">
{selectables.map((item) => {
return (
<CommandItem
key={item.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue('');
setSelected((prev) => [...prev, item]);
}}
className={'cursor-pointer'}
>
{item.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
) : null}
</div>
</Command>
);
}

View File

@@ -55,7 +55,6 @@ export function Combobox<T extends string>({
searchable, searchable,
icon: Icon, icon: Icon,
size, size,
label,
}: ComboboxProps<T>) { }: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState(''); const [search, setSearch] = React.useState('');

View File

@@ -13,7 +13,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
className, className,
!!error && 'border-destructive' !!error && 'border-destructive'
)} )}

View File

@@ -5,6 +5,7 @@ import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel'; import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast'; import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -13,17 +14,14 @@ import { z } from 'zod';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
interface AddDashboardProps {
projectId: string;
}
const validator = z.object({ const validator = z.object({
name: z.string().min(1, 'Required'), name: z.string().min(1, 'Required'),
}); });
type IForm = z.infer<typeof validator>; type IForm = z.infer<typeof validator>;
export default function AddDashboard({ projectId }: AddDashboardProps) { export default function AddDashboard() {
const { projectId, organizationId: organizationSlug } = useAppParams();
const router = useRouter(); const router = useRouter();
const { register, handleSubmit, formState } = useForm<IForm>({ const { register, handleSubmit, formState } = useForm<IForm>({
@@ -54,6 +52,7 @@ export default function AddDashboard({ projectId }: AddDashboardProps) {
mutation.mutate({ mutation.mutate({
name, name,
projectId, projectId,
organizationSlug,
}); });
})} })}
> >

View File

@@ -1,5 +1,5 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db'; import { db, getId } from '@/server/db';
import { PrismaError } from 'prisma-error-enum'; import { PrismaError } from 'prisma-error-enum';
import { z } from 'zod'; import { z } from 'zod';
@@ -61,12 +61,15 @@ export const dashboardRouter = createTRPCRouter({
z.object({ z.object({
name: z.string(), name: z.string(),
projectId: z.string(), projectId: z.string(),
organizationSlug: z.string(),
}) })
) )
.mutation(async ({ input: { projectId, name } }) => { .mutation(async ({ input: { organizationSlug, projectId, name } }) => {
return db.dashboard.create({ return db.dashboard.create({
data: { data: {
id: await getId('dashboard', name),
project_id: projectId, project_id: projectId,
organization_slug: organizationSlug,
name, name,
}, },
}); });

View File

@@ -1,8 +1,6 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { import { getOrganizationBySlug } from '@/server/services/organization.service';
getCurrentOrganization, import { zInviteUser } from '@/utils/validation';
getOrganizationBySlug,
} from '@/server/services/organization.service';
import { clerkClient } from '@clerk/nextjs'; import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod'; import { z } from 'zod';
@@ -10,7 +8,7 @@ export const organizationRouter = createTRPCRouter({
list: protectedProcedure.query(() => { list: protectedProcedure.query(() => {
return clerkClient.organizations.getOrganizationList(); return clerkClient.organizations.getOrganizationList();
}), }),
first: protectedProcedure.query(() => getCurrentOrganization()), // first: protectedProcedure.query(() => getCurrentOrganization()),
get: protectedProcedure get: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -32,4 +30,20 @@ export const organizationRouter = createTRPCRouter({
name: input.name, name: input.name,
}); });
}), }),
inviteUser: protectedProcedure
.input(zInviteUser)
.mutation(async ({ input, ctx }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
if (!organization) {
throw new Error('Organization not found');
}
return clerkClient.organizations.createOrganizationInvitation({
organizationId: organization.id,
emailAddress: input.email,
role: input.role,
inviterUserId: ctx.session.userId,
});
}),
}); });

View File

@@ -28,19 +28,4 @@ export const userRouter = createTRPCRouter({
}) })
.then(transformUser); .then(transformUser);
}), }),
invite: protectedProcedure
.input(
z.object({
email: z.string().email(),
organizationId: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
await db.invite.create({
data: {
organization_id: input.organizationId,
email: input.email,
},
});
}),
}); });

View File

@@ -4,10 +4,7 @@ import { db } from '@mixan/db';
export { db } from '@mixan/db'; export { db } from '@mixan/db';
export async function getId( export async function getId(tableName: 'project' | 'dashboard', name: string) {
tableName: 'project' | 'organization' | 'dashboard',
name: string
) {
const newId = slug(name); const newId = slug(name);
if (!db[tableName]) { if (!db[tableName]) {
throw new Error('Table does not exists'); throw new Error('Table does not exists');

View File

@@ -0,0 +1,25 @@
import { notFound } from 'next/navigation';
import { getOrganizationBySlug } from './services/organization.service';
import { getProjectById } from './services/project.service';
export async function getExists(organizationSlug: string, projectId?: string) {
const promises: Promise<any>[] = [getOrganizationBySlug(organizationSlug)];
if (projectId) {
promises.push(getProjectById(projectId));
}
const results = await Promise.all(promises);
if (results.some((res) => !res)) {
return notFound();
}
return {
organization: results[0] as Awaited<
ReturnType<typeof getOrganizationBySlug>
>,
project: results[1] as Awaited<ReturnType<typeof getProjectById>>,
};
}

View File

@@ -1,20 +1,40 @@
import { unstable_cache } from 'next/cache';
import { db } from '../db'; import { db } from '../db';
export type IServiceRecentDashboards = Awaited<
ReturnType<typeof getRecentDashboardsByUserId>
>;
export type IServiceDashboard = Awaited<ReturnType<typeof getDashboardById>>; export type IServiceDashboard = Awaited<ReturnType<typeof getDashboardById>>;
export type IServiceDashboardWithProject = Awaited< export type IServiceDashboards = Awaited<
ReturnType<typeof getDashboardsByProjectId> ReturnType<typeof getDashboardsByProjectId>
>[number]; >;
export function getDashboardById(id: string) { export async function getDashboardById(id: string) {
return db.dashboard.findUniqueOrThrow({ const dashboard = await db.dashboard.findUnique({
where: { where: {
id, id,
}, },
include: {
project: true,
},
});
if (!dashboard) {
return null;
}
return dashboard;
}
export async function getDashboardsByOrganization(organizationSlug: string) {
return db.dashboard.findMany({
where: {
organization_slug: organizationSlug,
},
include: {
project: true,
},
orderBy: {
reports: {
_count: 'desc',
},
},
}); });
} }
@@ -28,59 +48,3 @@ export function getDashboardsByProjectId(projectId: string) {
}, },
}); });
} }
export async function getRecentDashboardsByUserId(userId: string) {
const tag = `recentDashboards_${userId}`;
return unstable_cache(
async (userId: string) => {
return db.recentDashboards.findMany({
where: {
user_id: userId,
},
orderBy: {
createdAt: 'desc',
},
include: {
project: true,
dashboard: true,
},
take: 5,
});
},
tag.split('_'),
{
revalidate: 3600,
tags: [tag],
}
)(userId);
}
export async function createRecentDashboard({
organizationId,
projectId,
dashboardId,
userId,
}: {
organizationId: string;
projectId: string;
dashboardId: string;
userId: string;
}) {
await db.recentDashboards.deleteMany({
where: {
user_id: userId,
project_id: projectId,
dashboard_id: dashboardId,
organization_slug: organizationId,
},
});
return db.recentDashboards.create({
data: {
user_id: userId,
organization_slug: organizationId,
project_id: projectId,
dashboard_id: dashboardId,
},
});
}

View File

@@ -1,12 +1,17 @@
import { auth, clerkClient } from '@clerk/nextjs'; import { auth, clerkClient } from '@clerk/nextjs';
import type { Organization } from '@clerk/nextjs/dist/types/server'; import type {
Organization,
OrganizationInvitation,
} from '@clerk/nextjs/dist/types/server';
import { db } from '../db'; import { db } from '../db';
export type IServiceOrganization = Awaited< export type IServiceOrganization = Awaited<
ReturnType<typeof getOrganizations> ReturnType<typeof getCurrentOrganizations>
>[number]; >[number];
export type IServiceInvites = Awaited<ReturnType<typeof getInvites>>;
function transformOrganization(org: Organization) { function transformOrganization(org: Organization) {
return { return {
id: org.id, id: org.id,
@@ -15,28 +20,19 @@ function transformOrganization(org: Organization) {
}; };
} }
export async function getOrganizations() { export async function getCurrentOrganizations() {
const orgs = await clerkClient.organizations.getOrganizationList();
return orgs.map(transformOrganization);
}
export async function getCurrentOrganization() {
const session = auth(); const session = auth();
if (!session?.orgSlug) { const organizations = await clerkClient.users.getOrganizationMembershipList({
return null; userId: session.userId!,
}
const organization = await clerkClient.organizations.getOrganization({
slug: session.orgSlug,
}); });
return organizations.map((item) => transformOrganization(item.organization));
return transformOrganization(organization);
} }
export function getOrganizationBySlug(slug: string) { export function getOrganizationBySlug(slug: string) {
return clerkClient.organizations return clerkClient.organizations
.getOrganization({ slug }) .getOrganization({ slug })
.then(transformOrganization); .then(transformOrganization)
.catch(() => null);
} }
export async function getOrganizationByProjectId(projectId: string) { export async function getOrganizationByProjectId(projectId: string) {
@@ -50,3 +46,22 @@ export async function getOrganizationByProjectId(projectId: string) {
slug: project.organization_slug, slug: project.organization_slug,
}); });
} }
export function transformInvite(invite: OrganizationInvitation) {
return {
id: invite.id,
email: invite.emailAddress,
role: invite.role,
status: invite.status,
createdAt: invite.createdAt,
updatedAt: invite.updatedAt,
};
}
export async function getInvites(organizationId: string) {
return await clerkClient.organizations
.getOrganizationInvitationList({
organizationId,
})
.then((invites) => invites.map(transformInvite));
}

View File

@@ -1,7 +1,4 @@
import { unstable_cache } from 'next/cache';
import { db } from '../db'; import { db } from '../db';
import { getCurrentOrganization } from './organization.service';
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>; export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>;
@@ -13,12 +10,10 @@ export function getProjectById(id: string) {
}); });
} }
export async function getCurrentProjects() { export async function getCurrentProjects(organizationSlug: string) {
const organization = await getCurrentOrganization();
if (!organization?.slug) return [];
return await db.project.findMany({ return await db.project.findMany({
where: { where: {
organization_slug: organization.slug, organization_slug: organizationSlug,
}, },
}); });
} }

View File

@@ -70,12 +70,16 @@ export function getReportsByDashboardId(dashboardId: string) {
.then((reports) => reports.map(transformReport)); .then((reports) => reports.map(transformReport));
} }
export function getReportById(id: string) { export async function getReportById(id: string) {
return db.report const report = await db.report.findUnique({
.findUniqueOrThrow({ where: {
where: { id,
id, },
}, });
})
.then(transformReport); if (!report) {
return null;
}
return transformReport(report);
} }

View File

@@ -30,7 +30,7 @@
--border: 214.3 31.8% 91.4%; --border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%; --ring: 212.73deg 26.83% 83.92%;
--radius: 0.5rem; --radius: 0.5rem;
} }

View File

@@ -73,3 +73,9 @@ export const zChartInput = z.object({
startDate: z.string().nullish(), startDate: z.string().nullish(),
endDate: z.string().nullish(), endDate: z.string().nullish(),
}); });
export const zInviteUser = z.object({
email: z.string().email(),
organizationSlug: z.string(),
role: z.enum(['admin', 'org:member']),
});

View File

@@ -0,0 +1,22 @@
/*
Warnings:
- You are about to drop the `event_failed` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `recent_dashboards` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `organization_slug` to the `dashboards` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "recent_dashboards" DROP CONSTRAINT "recent_dashboards_dashboard_id_fkey";
-- DropForeignKey
ALTER TABLE "recent_dashboards" DROP CONSTRAINT "recent_dashboards_project_id_fkey";
-- AlterTable
ALTER TABLE "dashboards" ADD COLUMN "organization_slug" TEXT NOT NULL;
-- DropTable
DROP TABLE "event_failed";
-- DropTable
DROP TABLE "recent_dashboards";

View File

@@ -19,11 +19,10 @@ model Project {
profiles Profile[] profiles Profile[]
clients Client[] clients Client[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
reports Report[] reports Report[]
dashboards Dashboard[] dashboards Dashboard[]
RecentDashboards RecentDashboards[]
@@map("projects") @@map("projects")
} }
@@ -67,15 +66,6 @@ model Profile {
@@map("profiles") @@map("profiles")
} }
model EventFailed {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
data Json
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("event_failed")
}
model Client { model Client {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String
@@ -91,19 +81,6 @@ model Client {
@@map("clients") @@map("clients")
} }
model RecentDashboards {
id String @id @default(dbgenerated("gen_random_uuid()"))
project_id String
project Project @relation(fields: [project_id], references: [id])
organization_slug String
dashboard_id String
dashboard Dashboard @relation(fields: [dashboard_id], references: [id])
user_id String
createdAt DateTime @default(now())
@@map("recent_dashboards")
}
enum Interval { enum Interval {
hour hour
day day
@@ -121,15 +98,15 @@ enum ChartType {
} }
model Dashboard { model Dashboard {
id String @id @default(dbgenerated("gen_random_uuid()")) id String @id @default(dbgenerated("gen_random_uuid()"))
name String name String
project_id String organization_slug String
project Project @relation(fields: [project_id], references: [id]) project_id String
reports Report[] project Project @relation(fields: [project_id], references: [id])
reports Report[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
RecentDashboards RecentDashboards[]
@@map("dashboards") @@map("dashboards")
} }