Query experiments and datasets by org

This commit is contained in:
David Corbitt
2023-08-07 14:10:32 -07:00
parent f8f855adf4
commit dc497dbd99
9 changed files with 172 additions and 107 deletions

View 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;

View File

@@ -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}>

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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 });
};