Query experiments and datasets by org
This commit is contained in:
28
app/src/components/StatsCard.tsx
Normal file
28
app/src/components/StatsCard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { VStack, HStack, type StackProps, Text, Divider } from "@chakra-ui/react";
|
||||
import Link, { type LinkProps } from "next/link";
|
||||
|
||||
const StatsCard = ({
|
||||
title,
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: { title: string; href: string } & StackProps & LinkProps) => {
|
||||
return (
|
||||
<VStack flex={1} borderWidth={1} padding={4} borderRadius={4} borderColor="gray.300" {...rest}>
|
||||
<HStack w="full" justifyContent="space-between">
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
<Link href={href}>
|
||||
<Text color="blue">
|
||||
View all
|
||||
</Text>
|
||||
</Link>
|
||||
</HStack>
|
||||
<Divider />
|
||||
{children}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCard;
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PopoverContent,
|
||||
Link,
|
||||
type StackProps,
|
||||
Box,
|
||||
} from "@chakra-ui/react";
|
||||
import { type Session } from "next-auth";
|
||||
import { signOut } from "next-auth/react";
|
||||
@@ -16,7 +17,6 @@ import { BsBoxArrowRight, BsChevronRight, BsPersonCircle } from "react-icons/bs"
|
||||
import NavSidebarOption from "./NavSidebarOption";
|
||||
|
||||
export default function UserMenu({ user, ...rest }: { user: Session } & StackProps) {
|
||||
|
||||
const profileImage = user.user.image ? (
|
||||
<Image src={user.user.image} alt="profile picture" boxSize={8} borderRadius="50%" />
|
||||
) : (
|
||||
@@ -27,26 +27,28 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro
|
||||
<>
|
||||
<Popover placement="right">
|
||||
<PopoverTrigger>
|
||||
<NavSidebarOption>
|
||||
<HStack
|
||||
// Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile
|
||||
py={2}
|
||||
px={1}
|
||||
spacing={3}
|
||||
{...rest}
|
||||
>
|
||||
{profileImage}
|
||||
<VStack spacing={0} align="start" flex={1} flexShrink={1}>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{user.user.name}
|
||||
</Text>
|
||||
<Text color="gray.500" fontSize="xs">
|
||||
{/* {user.user.email} */}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Icon as={BsChevronRight} boxSize={4} color="gray.500" />
|
||||
</HStack>
|
||||
</NavSidebarOption>
|
||||
<Box>
|
||||
<NavSidebarOption>
|
||||
<HStack
|
||||
// Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile
|
||||
py={2}
|
||||
px={1}
|
||||
spacing={3}
|
||||
{...rest}
|
||||
>
|
||||
{profileImage}
|
||||
<VStack spacing={0} align="start" flex={1} flexShrink={1}>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{user.user.name}
|
||||
</Text>
|
||||
<Text color="gray.500" fontSize="xs">
|
||||
{/* {user.user.email} */}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Icon as={BsChevronRight} boxSize={4} color="gray.500" />
|
||||
</HStack>
|
||||
</NavSidebarOption>
|
||||
</Box>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent _focusVisible={{ boxShadow: "unset", outline: "unset" }} maxW="200px">
|
||||
<VStack align="stretch" spacing={0}>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Link,
|
||||
} from "@chakra-ui/react";
|
||||
import AppShell from "~/components/nav/AppShell";
|
||||
import { api } from "~/utils/api";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import { RiDatabase2Line } from "react-icons/ri";
|
||||
import {
|
||||
@@ -19,9 +18,10 @@ import {
|
||||
} from "~/components/datasets/DatasetCard";
|
||||
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
||||
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
||||
import { useDatasets } from "~/utils/hooks";
|
||||
|
||||
export default function DatasetsPage() {
|
||||
const datasets = api.datasets.list.useQuery();
|
||||
const datasets = useDatasets();
|
||||
|
||||
const user = useSession().data;
|
||||
const authLoading = useSession().status === "loading";
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { RiFlaskLine } from "react-icons/ri";
|
||||
import AppShell from "~/components/nav/AppShell";
|
||||
import { api } from "~/utils/api";
|
||||
import {
|
||||
ExperimentCard,
|
||||
ExperimentCardSkeleton,
|
||||
@@ -19,9 +18,10 @@ import {
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
||||
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
||||
import { useExperiments } from "~/utils/hooks";
|
||||
|
||||
export default function ExperimentsPage() {
|
||||
const experiments = api.experiments.list.useQuery();
|
||||
const experiments = useExperiments();
|
||||
|
||||
const user = useSession().data;
|
||||
const authLoading = useSession().status === "loading";
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import { Breadcrumb, BreadcrumbItem, Text } from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Breadcrumb, BreadcrumbItem, Divider, Text, VStack } from "@chakra-ui/react";
|
||||
|
||||
import AppShell from "~/components/nav/AppShell";
|
||||
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
||||
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
||||
import { api } from "~/utils/api";
|
||||
import { useHandledAsyncCallback, useSelectedOrg } from "~/utils/hooks";
|
||||
import { useExperiments, useSelectedOrg } from "~/utils/hooks";
|
||||
|
||||
export default function HomePage() {
|
||||
const utils = api.useContext();
|
||||
const { data: selectedOrg } = useSelectedOrg();
|
||||
|
||||
const updateMutation = api.organizations.update.useMutation();
|
||||
const [onSaveName] = useHandledAsyncCallback(async () => {
|
||||
if (name && name !== selectedOrg?.name && selectedOrg?.id) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: selectedOrg.id,
|
||||
updates: { name },
|
||||
});
|
||||
await Promise.all([utils.organizations.get.invalidate({ id: selectedOrg.id })]);
|
||||
}
|
||||
}, [updateMutation, selectedOrg]);
|
||||
const experiments = useExperiments();
|
||||
|
||||
const [name, setName] = useState(selectedOrg?.name);
|
||||
useEffect(() => {
|
||||
setName(selectedOrg?.name);
|
||||
}, [selectedOrg?.name]);
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeaderContainer>
|
||||
@@ -33,10 +18,31 @@ export default function HomePage() {
|
||||
<ProjectBreadcrumbContents />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<Text>Home</Text>
|
||||
<Text>Homepage</Text>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</PageHeaderContainer>
|
||||
<VStack px={8} pt={4} alignItems="flex-start" spacing={4}>
|
||||
<Text fontSize="2xl" fontWeight="bold">
|
||||
{selectedOrg?.name}
|
||||
</Text>
|
||||
<Divider />
|
||||
{/* TODO: Add more dashboard cards (one looks weird) */}
|
||||
{/* <HStack w="full">
|
||||
<StatsCard title="Recent Experiments" href="/experiments">
|
||||
<VStack alignItems="flex-start" w="full">
|
||||
{experiments.data?.slice(0, 5).map((exp) => (
|
||||
<Link key={exp.id} href={{ pathname: "/experiments/[id]", query: { id: exp.id } }}>
|
||||
<VStack key={exp.id} alignItems="flex-start" spacing={0}>
|
||||
<Text fontWeight="bold">{exp.label}</Text>
|
||||
<Text flex={1}>Last updated {formatTimePast(exp.updatedAt)}</Text>
|
||||
</VStack>
|
||||
</Link>
|
||||
))}
|
||||
</VStack>
|
||||
</StatsCard>
|
||||
</HStack> */}
|
||||
</VStack>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,35 +4,33 @@ import { prisma } from "~/server/db";
|
||||
import {
|
||||
requireCanModifyDataset,
|
||||
requireCanViewDataset,
|
||||
requireCanViewOrganization,
|
||||
requireNothing,
|
||||
} from "~/utils/accessControl";
|
||||
import userOrg from "~/server/utils/userOrg";
|
||||
|
||||
export const datasetsRouter = createTRPCRouter({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Anyone can list experiments
|
||||
requireNothing(ctx);
|
||||
list: protectedProcedure
|
||||
.input(z.object({ organizationId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await requireCanViewOrganization(input.organizationId, ctx);
|
||||
|
||||
const datasets = await prisma.dataset.findMany({
|
||||
where: {
|
||||
organization: {
|
||||
organizationUsers: {
|
||||
some: { userId: ctx.session.user.id },
|
||||
const datasets = await prisma.dataset.findMany({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { datasetEntries: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { datasetEntries: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return datasets;
|
||||
}),
|
||||
return datasets;
|
||||
}),
|
||||
|
||||
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
||||
await requireCanViewDataset(input.id, ctx);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
canModifyExperiment,
|
||||
requireCanModifyExperiment,
|
||||
requireCanViewExperiment,
|
||||
requireCanViewOrganization,
|
||||
requireNothing,
|
||||
} from "~/utils/accessControl";
|
||||
import userOrg from "~/server/utils/userOrg";
|
||||
@@ -43,50 +44,47 @@ export const experimentsRouter = createTRPCRouter({
|
||||
testScenarioCount,
|
||||
};
|
||||
}),
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
// Anyone can list experiments
|
||||
requireNothing(ctx);
|
||||
list: protectedProcedure
|
||||
.input(z.object({ organizationId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
await requireCanViewOrganization(input.organizationId, ctx)
|
||||
|
||||
const experiments = await prisma.experiment.findMany({
|
||||
where: {
|
||||
organization: {
|
||||
organizationUsers: {
|
||||
some: { userId: ctx.session.user.id },
|
||||
},
|
||||
const experiments = await prisma.experiment.findMany({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sortIndex: "desc",
|
||||
},
|
||||
});
|
||||
orderBy: {
|
||||
sortIndex: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: look for cleaner way to do this. Maybe aggregate?
|
||||
const experimentsWithCounts = await Promise.all(
|
||||
experiments.map(async (experiment) => {
|
||||
const visibleTestScenarioCount = await prisma.testScenario.count({
|
||||
where: {
|
||||
experimentId: experiment.id,
|
||||
visible: true,
|
||||
},
|
||||
});
|
||||
// TODO: look for cleaner way to do this. Maybe aggregate?
|
||||
const experimentsWithCounts = await Promise.all(
|
||||
experiments.map(async (experiment) => {
|
||||
const visibleTestScenarioCount = await prisma.testScenario.count({
|
||||
where: {
|
||||
experimentId: experiment.id,
|
||||
visible: true,
|
||||
},
|
||||
});
|
||||
|
||||
const visiblePromptVariantCount = await prisma.promptVariant.count({
|
||||
where: {
|
||||
experimentId: experiment.id,
|
||||
visible: true,
|
||||
},
|
||||
});
|
||||
const visiblePromptVariantCount = await prisma.promptVariant.count({
|
||||
where: {
|
||||
experimentId: experiment.id,
|
||||
visible: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...experiment,
|
||||
testScenarioCount: visibleTestScenarioCount,
|
||||
promptVariantCount: visiblePromptVariantCount,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...experiment,
|
||||
testScenarioCount: visibleTestScenarioCount,
|
||||
promptVariantCount: visiblePromptVariantCount,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return experimentsWithCounts;
|
||||
}),
|
||||
return experimentsWithCounts;
|
||||
}),
|
||||
|
||||
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
||||
await requireCanViewExperiment(input.id, ctx);
|
||||
|
||||
@@ -16,6 +16,26 @@ export const requireNothing = (ctx: TRPCContext) => {
|
||||
ctx.markAccessControlRun();
|
||||
};
|
||||
|
||||
export const requireCanViewOrganization = async (organizationId: string, ctx: TRPCContext) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const canView = await prisma.organizationUser.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!canView) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
ctx.markAccessControlRun();
|
||||
};
|
||||
|
||||
export const requireCanModifyOrganization = async (organizationId: string, ctx: TRPCContext) => {
|
||||
const userId = ctx.session?.user.id;
|
||||
if (!userId) {
|
||||
|
||||
@@ -4,6 +4,14 @@ import { api } from "~/utils/api";
|
||||
import { NumberParam, useQueryParam, withDefault } from "use-query-params";
|
||||
import { useAppStore } from "~/state/store";
|
||||
|
||||
export const useExperiments = () => {
|
||||
const selectedOrgId = useAppStore((state) => state.selectedOrgId);
|
||||
return api.experiments.list.useQuery(
|
||||
{ organizationId: selectedOrgId ?? "" },
|
||||
{ enabled: !!selectedOrgId },
|
||||
);
|
||||
};
|
||||
|
||||
export const useExperiment = () => {
|
||||
const router = useRouter();
|
||||
const experiment = api.experiments.get.useQuery(
|
||||
@@ -18,6 +26,14 @@ export const useExperimentAccess = () => {
|
||||
return useExperiment().data?.access ?? { canView: false, canModify: false };
|
||||
};
|
||||
|
||||
export const useDatasets = () => {
|
||||
const selectedOrgId = useAppStore((state) => state.selectedOrgId);
|
||||
return api.datasets.list.useQuery(
|
||||
{ organizationId: selectedOrgId ?? "" },
|
||||
{ enabled: !!selectedOrgId },
|
||||
);
|
||||
};
|
||||
|
||||
export const useDataset = () => {
|
||||
const router = useRouter();
|
||||
const dataset = api.datasets.get.useQuery(
|
||||
@@ -136,8 +152,5 @@ export const useVisibleScenarioIds = () => useScenarios().data?.scenarios.map((s
|
||||
|
||||
export const useSelectedOrg = () => {
|
||||
const selectedOrgId = useAppStore((state) => state.selectedOrgId);
|
||||
return api.organizations.get.useQuery(
|
||||
{ id: selectedOrgId ?? "" },
|
||||
{ enabled: !!selectedOrgId },
|
||||
);
|
||||
return api.organizations.get.useQuery({ id: selectedOrgId ?? "" }, { enabled: !!selectedOrgId });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user