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';
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 { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';

View File

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

View File

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

View File

@@ -2,17 +2,19 @@
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
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 { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { Pencil, Trash } from 'lucide-react';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
interface ListDashboardsProps {
dashboards: Awaited<ReturnType<typeof getDashboardsByProjectId>>;
dashboards: IServiceDashboards;
}
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 (
<>
<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"
>
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground text-sm">
{item.project.name}
</span>
</Link>
</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 { HeaderDashboards } from './header-dashboards';
@@ -7,15 +8,21 @@ import { ListDashboards } from './list-dashboards';
interface PageProps {
params: {
projectId: string;
organizationId: string;
};
}
export default async function Page({ params: { projectId } }: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
export default async function Page({
params: { projectId, organizationId },
}: PageProps) {
const [dashboards] = await Promise.all([
getDashboardsByProjectId(projectId),
await getExists(organizationId, projectId),
]);
return (
<PageLayout title="Dashboards">
<HeaderDashboards projectId={projectId} />
<PageLayout title="Dashboards" organizationSlug={organizationId}>
{dashboards.length > 0 && <HeaderDashboards />}
<ListDashboards dashboards={dashboards} />
</PageLayout>
);

View File

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

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';
interface PageProps {
params: {
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 (
<PageLayout title="Events">
<PageLayout title="Events" organizationSlug={organizationId}>
<ListEvents projectId={projectId} />
</PageLayout>
);

View File

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

View File

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

View File

@@ -5,16 +5,20 @@ import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps {
children: React.ReactNode;
title: React.ReactNode;
organizationSlug: string;
}
export default async function PageLayout({ children, title }: PageLayoutProps) {
const projects = await getCurrentProjects();
export default async function PageLayout({
children,
title,
organizationSlug,
}: PageLayoutProps) {
const projects = await getCurrentProjects(organizationSlug);
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="text-xl font-medium">{title}</div>
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
</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';
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 (
<PageLayout title="Overview">
<PageLayout title="Overview" organizationSlug={organizationId}>
<OverviewMetrics />
</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 { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { getExists } from '@/server/pageExists';
import {
getProfileById,
getProfilesByExternalId,
@@ -15,18 +16,23 @@ interface PageProps {
params: {
projectId: string;
profileId: string;
organizationId: string;
};
}
export default async function Page({
params: { projectId, profileId },
params: { projectId, profileId, organizationId },
}: PageProps) {
const profile = await getProfileById(profileId);
const [profile] = await Promise.all([
getProfileById(profileId),
getExists(organizationId, projectId),
]);
const profiles = (
await getProfilesByExternalId(profile.external_id, profile.project_id)
).filter((item) => item.id !== profile.id);
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />

View File

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

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';
@@ -8,11 +9,13 @@ interface PageProps {
projectId: string;
};
}
export default function Page({
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout title="Events">
<PageLayout title="Events" organizationSlug={organizationId}>
<ListProfiles projectId={projectId} organizationId={organizationId} />
</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 { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import ReportEditor from '../report-editor';
@@ -8,13 +11,25 @@ interface PageProps {
params: {
projectId: string;
reportId: string;
organizationId: string;
};
}
export default async function Page({ params: { reportId } }: PageProps) {
const report = await getReportById(reportId);
export default async function Page({
params: { reportId, organizationId, projectId },
}: PageProps) {
const [report] = await Promise.all([
getReportById(reportId),
getExists(organizationId, projectId),
]);
if (!report) {
return notFound();
}
return (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
{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 { notFound } from 'next/navigation';
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 (
<PageLayout
organizationSlug={organizationId}
title={
<div className="flex gap-2 items-center cursor-pointer">
Unnamed report
@@ -20,7 +28,7 @@ export default function Page({ params: { organizationId } }: PageProps) {
</div>
}
>
<ReportEditor reportId={null} />
<ReportEditor report={null} />
</PageLayout>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
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 { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';

View File

@@ -1,6 +1,6 @@
'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 { DataTable } from '@/components/DataTable';
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 ListClients from './list-clients';
@@ -10,10 +11,11 @@ interface PageProps {
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const clients = await getClientsByOrganizationId(organizationId);
return (
<PageLayout title="Clients">
<PageLayout title="Clients" organizationSlug={organizationId}>
<ListClients clients={clients} />
</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';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { signOut } from 'next-auth/react';
import { SignOutButton } from '@clerk/nextjs';
export function Logout() {
return (
@@ -14,14 +13,7 @@ export function Logout() {
<p className="mb-4">
Sometime&apos;s you need to go. See you next time
</p>
<Button
variant={'destructive'}
onClick={() => {
signOut();
}}
>
Logout 🤨
</Button>
<SignOutButton />
</WidgetBody>
</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 { auth } from '@clerk/nextjs';
import EditProfile from './edit-profile';
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();
await getExists(organizationId);
const profile = await getUserById(userId!);
return (
<PageLayout title={profile.lastName}>
<PageLayout title={profile.lastName} organizationSlug={organizationId}>
<div className="p-4 flex flex-col gap-4">
<EditProfile profile={profile} />
<Logout />

View File

@@ -1,6 +1,6 @@
'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 { columns } from '@/components/projects/table';
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 ListProjects from './list-projects';
@@ -10,10 +11,11 @@ interface PageProps {
}
export default async function Page({ params: { organizationId } }: PageProps) {
await getExists(organizationId);
const projects = await getProjectsByOrganizationSlug(organizationId);
return (
<PageLayout title="Projects">
<PageLayout title="Projects" organizationSlug={organizationId}>
<ListProjects projects={projects} />
</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 { redirect } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import PageLayout from '../page-layout';
import PageLayout from './[projectId]/page-layout';
interface PageProps {
params: {
@@ -10,14 +11,21 @@ interface 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) {
return redirect(`/${organizationId}/${project.id}`);
}
return (
<PageLayout title="Projects">
<PageLayout title="Projects" organizationSlug={organizationId}>
<div className="p-4">
<h1>Create your first project</h1>
</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 { redirect } from 'next/navigation';
export default async function Page() {
const organizations = await getOrganizations();
const organizations = await getCurrentOrganizations();
if (organizations.length === 0) {
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: {
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',
icon: 'h-10 w-10',
icon: 'h-8 w-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
size: 'sm',
},
}
);

View File

@@ -2,11 +2,19 @@
import * as React from 'react';
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 { Button } from './button';
import { Checkbox } from './checkbox';
import { Input } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
type IValue = any;
type IItem = Record<'value' | 'label', IValue>;
@@ -43,7 +51,6 @@ export function ComboboxAdvanced({
const checked = !!value.find((s) => s === item.value);
return (
<CommandItem
key={String(item.value)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -71,52 +78,40 @@ export function ComboboxAdvanced({
};
return (
<Command className="overflow-visible bg-white" ref={ref}>
<button
type="button"
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"
onClick={() => setOpen((prev) => !prev)}
>
<div className="flex gap-1 flex-wrap">
{value.length === 0 && placeholder}
{value.slice(0, 2).map((value) => {
const item = items.find((item) => item.value === value) ?? {
value,
label: value,
};
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>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant={'outline'} onClick={() => setOpen((prev) => !prev)}>
<div className="flex gap-1 flex-wrap">
{value.length === 0 && placeholder}
{value.slice(0, 2).map((value) => {
const item = items.find((item) => item.value === value) ?? {
value,
label: value,
};
return <Badge key={String(item.value)}>{item.label}</Badge>;
})}
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
</div>
</div>
)}
</Command>
</Button>
</PopoverTrigger>
<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,
icon: Icon,
size,
label,
}: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');

View File

@@ -13,7 +13,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
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,
!!error && 'border-destructive'
)}

View File

@@ -5,6 +5,7 @@ import { ButtonContainer } from '@/components/ButtonContainer';
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 { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
@@ -13,17 +14,14 @@ import { z } from 'zod';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface AddDashboardProps {
projectId: string;
}
const validator = z.object({
name: z.string().min(1, 'Required'),
});
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 { register, handleSubmit, formState } = useForm<IForm>({
@@ -54,6 +52,7 @@ export default function AddDashboard({ projectId }: AddDashboardProps) {
mutation.mutate({
name,
projectId,
organizationSlug,
});
})}
>

View File

@@ -1,5 +1,5 @@
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 { z } from 'zod';
@@ -61,12 +61,15 @@ export const dashboardRouter = createTRPCRouter({
z.object({
name: z.string(),
projectId: z.string(),
organizationSlug: z.string(),
})
)
.mutation(async ({ input: { projectId, name } }) => {
.mutation(async ({ input: { organizationSlug, projectId, name } }) => {
return db.dashboard.create({
data: {
id: await getId('dashboard', name),
project_id: projectId,
organization_slug: organizationSlug,
name,
},
});

View File

@@ -1,8 +1,6 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import {
getCurrentOrganization,
getOrganizationBySlug,
} from '@/server/services/organization.service';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { zInviteUser } from '@/utils/validation';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
@@ -10,7 +8,7 @@ export const organizationRouter = createTRPCRouter({
list: protectedProcedure.query(() => {
return clerkClient.organizations.getOrganizationList();
}),
first: protectedProcedure.query(() => getCurrentOrganization()),
// first: protectedProcedure.query(() => getCurrentOrganization()),
get: protectedProcedure
.input(
z.object({
@@ -32,4 +30,20 @@ export const organizationRouter = createTRPCRouter({
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);
}),
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 async function getId(
tableName: 'project' | 'organization' | 'dashboard',
name: string
) {
export async function getId(tableName: 'project' | 'dashboard', name: string) {
const newId = slug(name);
if (!db[tableName]) {
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';
export type IServiceRecentDashboards = Awaited<
ReturnType<typeof getRecentDashboardsByUserId>
>;
export type IServiceDashboard = Awaited<ReturnType<typeof getDashboardById>>;
export type IServiceDashboardWithProject = Awaited<
export type IServiceDashboards = Awaited<
ReturnType<typeof getDashboardsByProjectId>
>[number];
>;
export function getDashboardById(id: string) {
return db.dashboard.findUniqueOrThrow({
export async function getDashboardById(id: string) {
const dashboard = await db.dashboard.findUnique({
where: {
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 type { Organization } from '@clerk/nextjs/dist/types/server';
import type {
Organization,
OrganizationInvitation,
} from '@clerk/nextjs/dist/types/server';
import { db } from '../db';
export type IServiceOrganization = Awaited<
ReturnType<typeof getOrganizations>
ReturnType<typeof getCurrentOrganizations>
>[number];
export type IServiceInvites = Awaited<ReturnType<typeof getInvites>>;
function transformOrganization(org: Organization) {
return {
id: org.id,
@@ -15,28 +20,19 @@ function transformOrganization(org: Organization) {
};
}
export async function getOrganizations() {
const orgs = await clerkClient.organizations.getOrganizationList();
return orgs.map(transformOrganization);
}
export async function getCurrentOrganization() {
export async function getCurrentOrganizations() {
const session = auth();
if (!session?.orgSlug) {
return null;
}
const organization = await clerkClient.organizations.getOrganization({
slug: session.orgSlug,
const organizations = await clerkClient.users.getOrganizationMembershipList({
userId: session.userId!,
});
return transformOrganization(organization);
return organizations.map((item) => transformOrganization(item.organization));
}
export function getOrganizationBySlug(slug: string) {
return clerkClient.organizations
.getOrganization({ slug })
.then(transformOrganization);
.then(transformOrganization)
.catch(() => null);
}
export async function getOrganizationByProjectId(projectId: string) {
@@ -50,3 +46,22 @@ export async function getOrganizationByProjectId(projectId: string) {
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 { getCurrentOrganization } from './organization.service';
export type IServiceProject = Awaited<ReturnType<typeof getProjectById>>;
@@ -13,12 +10,10 @@ export function getProjectById(id: string) {
});
}
export async function getCurrentProjects() {
const organization = await getCurrentOrganization();
if (!organization?.slug) return [];
export async function getCurrentProjects(organizationSlug: string) {
return await db.project.findMany({
where: {
organization_slug: organization.slug,
organization_slug: organizationSlug,
},
});
}

View File

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

View File

@@ -30,7 +30,7 @@
--border: 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;
}

View File

@@ -73,3 +73,9 @@ export const zChartInput = z.object({
startDate: 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[]
clients Client[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
reports Report[]
dashboards Dashboard[]
RecentDashboards RecentDashboards[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
reports Report[]
dashboards Dashboard[]
@@map("projects")
}
@@ -67,15 +66,6 @@ model Profile {
@@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 {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
@@ -91,19 +81,6 @@ model Client {
@@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 {
hour
day
@@ -121,15 +98,15 @@ enum ChartType {
}
model Dashboard {
id String @id @default(dbgenerated("gen_random_uuid()"))
name String
project_id String
project Project @relation(fields: [project_id], references: [id])
reports Report[]
id String @id @default(dbgenerated("gen_random_uuid()"))
name String
organization_slug String
project_id String
project Project @relation(fields: [project_id], references: [id])
reports Report[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
RecentDashboards RecentDashboards[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("dashboards")
}