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,
|
PopoverContent,
|
||||||
Link,
|
Link,
|
||||||
type StackProps,
|
type StackProps,
|
||||||
|
Box,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { type Session } from "next-auth";
|
import { type Session } from "next-auth";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
@@ -16,7 +17,6 @@ import { BsBoxArrowRight, BsChevronRight, BsPersonCircle } from "react-icons/bs"
|
|||||||
import NavSidebarOption from "./NavSidebarOption";
|
import NavSidebarOption from "./NavSidebarOption";
|
||||||
|
|
||||||
export default function UserMenu({ user, ...rest }: { user: Session } & StackProps) {
|
export default function UserMenu({ user, ...rest }: { user: Session } & StackProps) {
|
||||||
|
|
||||||
const profileImage = user.user.image ? (
|
const profileImage = user.user.image ? (
|
||||||
<Image src={user.user.image} alt="profile picture" boxSize={8} borderRadius="50%" />
|
<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">
|
<Popover placement="right">
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<NavSidebarOption>
|
<Box>
|
||||||
<HStack
|
<NavSidebarOption>
|
||||||
// Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile
|
<HStack
|
||||||
py={2}
|
// Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile
|
||||||
px={1}
|
py={2}
|
||||||
spacing={3}
|
px={1}
|
||||||
{...rest}
|
spacing={3}
|
||||||
>
|
{...rest}
|
||||||
{profileImage}
|
>
|
||||||
<VStack spacing={0} align="start" flex={1} flexShrink={1}>
|
{profileImage}
|
||||||
<Text fontWeight="bold" fontSize="sm">
|
<VStack spacing={0} align="start" flex={1} flexShrink={1}>
|
||||||
{user.user.name}
|
<Text fontWeight="bold" fontSize="sm">
|
||||||
</Text>
|
{user.user.name}
|
||||||
<Text color="gray.500" fontSize="xs">
|
</Text>
|
||||||
{/* {user.user.email} */}
|
<Text color="gray.500" fontSize="xs">
|
||||||
</Text>
|
{/* {user.user.email} */}
|
||||||
</VStack>
|
</Text>
|
||||||
<Icon as={BsChevronRight} boxSize={4} color="gray.500" />
|
</VStack>
|
||||||
</HStack>
|
<Icon as={BsChevronRight} boxSize={4} color="gray.500" />
|
||||||
</NavSidebarOption>
|
</HStack>
|
||||||
|
</NavSidebarOption>
|
||||||
|
</Box>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent _focusVisible={{ boxShadow: "unset", outline: "unset" }} maxW="200px">
|
<PopoverContent _focusVisible={{ boxShadow: "unset", outline: "unset" }} maxW="200px">
|
||||||
<VStack align="stretch" spacing={0}>
|
<VStack align="stretch" spacing={0}>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import AppShell from "~/components/nav/AppShell";
|
import AppShell from "~/components/nav/AppShell";
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import { signIn, useSession } from "next-auth/react";
|
import { signIn, useSession } from "next-auth/react";
|
||||||
import { RiDatabase2Line } from "react-icons/ri";
|
import { RiDatabase2Line } from "react-icons/ri";
|
||||||
import {
|
import {
|
||||||
@@ -19,9 +18,10 @@ import {
|
|||||||
} from "~/components/datasets/DatasetCard";
|
} from "~/components/datasets/DatasetCard";
|
||||||
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
||||||
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
||||||
|
import { useDatasets } from "~/utils/hooks";
|
||||||
|
|
||||||
export default function DatasetsPage() {
|
export default function DatasetsPage() {
|
||||||
const datasets = api.datasets.list.useQuery();
|
const datasets = useDatasets();
|
||||||
|
|
||||||
const user = useSession().data;
|
const user = useSession().data;
|
||||||
const authLoading = useSession().status === "loading";
|
const authLoading = useSession().status === "loading";
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { RiFlaskLine } from "react-icons/ri";
|
import { RiFlaskLine } from "react-icons/ri";
|
||||||
import AppShell from "~/components/nav/AppShell";
|
import AppShell from "~/components/nav/AppShell";
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import {
|
import {
|
||||||
ExperimentCard,
|
ExperimentCard,
|
||||||
ExperimentCardSkeleton,
|
ExperimentCardSkeleton,
|
||||||
@@ -19,9 +18,10 @@ import {
|
|||||||
import { signIn, useSession } from "next-auth/react";
|
import { signIn, useSession } from "next-auth/react";
|
||||||
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
||||||
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
||||||
|
import { useExperiments } from "~/utils/hooks";
|
||||||
|
|
||||||
export default function ExperimentsPage() {
|
export default function ExperimentsPage() {
|
||||||
const experiments = api.experiments.list.useQuery();
|
const experiments = useExperiments();
|
||||||
|
|
||||||
const user = useSession().data;
|
const user = useSession().data;
|
||||||
const authLoading = useSession().status === "loading";
|
const authLoading = useSession().status === "loading";
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
import { Breadcrumb, BreadcrumbItem, Text } from "@chakra-ui/react";
|
import { Breadcrumb, BreadcrumbItem, Divider, Text, VStack } from "@chakra-ui/react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import AppShell from "~/components/nav/AppShell";
|
import AppShell from "~/components/nav/AppShell";
|
||||||
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
import PageHeaderContainer from "~/components/nav/PageHeaderContainer";
|
||||||
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
import ProjectBreadcrumbContents from "~/components/nav/ProjectBreadcrumbContents";
|
||||||
import { api } from "~/utils/api";
|
import { useExperiments, useSelectedOrg } from "~/utils/hooks";
|
||||||
import { useHandledAsyncCallback, useSelectedOrg } from "~/utils/hooks";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const utils = api.useContext();
|
|
||||||
const { data: selectedOrg } = useSelectedOrg();
|
const { data: selectedOrg } = useSelectedOrg();
|
||||||
|
|
||||||
const updateMutation = api.organizations.update.useMutation();
|
const experiments = useExperiments();
|
||||||
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 [name, setName] = useState(selectedOrg?.name);
|
|
||||||
useEffect(() => {
|
|
||||||
setName(selectedOrg?.name);
|
|
||||||
}, [selectedOrg?.name]);
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<PageHeaderContainer>
|
<PageHeaderContainer>
|
||||||
@@ -33,10 +18,31 @@ export default function HomePage() {
|
|||||||
<ProjectBreadcrumbContents />
|
<ProjectBreadcrumbContents />
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbItem isCurrentPage>
|
<BreadcrumbItem isCurrentPage>
|
||||||
<Text>Home</Text>
|
<Text>Homepage</Text>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</PageHeaderContainer>
|
</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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,35 +4,33 @@ import { prisma } from "~/server/db";
|
|||||||
import {
|
import {
|
||||||
requireCanModifyDataset,
|
requireCanModifyDataset,
|
||||||
requireCanViewDataset,
|
requireCanViewDataset,
|
||||||
|
requireCanViewOrganization,
|
||||||
requireNothing,
|
requireNothing,
|
||||||
} from "~/utils/accessControl";
|
} from "~/utils/accessControl";
|
||||||
import userOrg from "~/server/utils/userOrg";
|
import userOrg from "~/server/utils/userOrg";
|
||||||
|
|
||||||
export const datasetsRouter = createTRPCRouter({
|
export const datasetsRouter = createTRPCRouter({
|
||||||
list: protectedProcedure.query(async ({ ctx }) => {
|
list: protectedProcedure
|
||||||
// Anyone can list experiments
|
.input(z.object({ organizationId: z.string() }))
|
||||||
requireNothing(ctx);
|
.query(async ({ input, ctx }) => {
|
||||||
|
await requireCanViewOrganization(input.organizationId, ctx);
|
||||||
|
|
||||||
const datasets = await prisma.dataset.findMany({
|
const datasets = await prisma.dataset.findMany({
|
||||||
where: {
|
where: {
|
||||||
organization: {
|
organizationId: input.organizationId,
|
||||||
organizationUsers: {
|
},
|
||||||
some: { userId: ctx.session.user.id },
|
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 }) => {
|
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
||||||
await requireCanViewDataset(input.id, ctx);
|
await requireCanViewDataset(input.id, ctx);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
canModifyExperiment,
|
canModifyExperiment,
|
||||||
requireCanModifyExperiment,
|
requireCanModifyExperiment,
|
||||||
requireCanViewExperiment,
|
requireCanViewExperiment,
|
||||||
|
requireCanViewOrganization,
|
||||||
requireNothing,
|
requireNothing,
|
||||||
} from "~/utils/accessControl";
|
} from "~/utils/accessControl";
|
||||||
import userOrg from "~/server/utils/userOrg";
|
import userOrg from "~/server/utils/userOrg";
|
||||||
@@ -43,50 +44,47 @@ export const experimentsRouter = createTRPCRouter({
|
|||||||
testScenarioCount,
|
testScenarioCount,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
list: protectedProcedure.query(async ({ ctx }) => {
|
list: protectedProcedure
|
||||||
// Anyone can list experiments
|
.input(z.object({ organizationId: z.string() }))
|
||||||
requireNothing(ctx);
|
.query(async ({ input, ctx }) => {
|
||||||
|
await requireCanViewOrganization(input.organizationId, ctx)
|
||||||
|
|
||||||
const experiments = await prisma.experiment.findMany({
|
const experiments = await prisma.experiment.findMany({
|
||||||
where: {
|
where: {
|
||||||
organization: {
|
organizationId: input.organizationId,
|
||||||
organizationUsers: {
|
|
||||||
some: { userId: ctx.session.user.id },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
orderBy: {
|
||||||
orderBy: {
|
sortIndex: "desc",
|
||||||
sortIndex: "desc",
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: look for cleaner way to do this. Maybe aggregate?
|
// TODO: look for cleaner way to do this. Maybe aggregate?
|
||||||
const experimentsWithCounts = await Promise.all(
|
const experimentsWithCounts = await Promise.all(
|
||||||
experiments.map(async (experiment) => {
|
experiments.map(async (experiment) => {
|
||||||
const visibleTestScenarioCount = await prisma.testScenario.count({
|
const visibleTestScenarioCount = await prisma.testScenario.count({
|
||||||
where: {
|
where: {
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
visible: true,
|
visible: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const visiblePromptVariantCount = await prisma.promptVariant.count({
|
const visiblePromptVariantCount = await prisma.promptVariant.count({
|
||||||
where: {
|
where: {
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
visible: true,
|
visible: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...experiment,
|
...experiment,
|
||||||
testScenarioCount: visibleTestScenarioCount,
|
testScenarioCount: visibleTestScenarioCount,
|
||||||
promptVariantCount: visiblePromptVariantCount,
|
promptVariantCount: visiblePromptVariantCount,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return experimentsWithCounts;
|
return experimentsWithCounts;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
get: publicProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
||||||
await requireCanViewExperiment(input.id, ctx);
|
await requireCanViewExperiment(input.id, ctx);
|
||||||
|
|||||||
@@ -16,6 +16,26 @@ export const requireNothing = (ctx: TRPCContext) => {
|
|||||||
ctx.markAccessControlRun();
|
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) => {
|
export const requireCanModifyOrganization = async (organizationId: string, ctx: TRPCContext) => {
|
||||||
const userId = ctx.session?.user.id;
|
const userId = ctx.session?.user.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { api } from "~/utils/api";
|
|||||||
import { NumberParam, useQueryParam, withDefault } from "use-query-params";
|
import { NumberParam, useQueryParam, withDefault } from "use-query-params";
|
||||||
import { useAppStore } from "~/state/store";
|
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 = () => {
|
export const useExperiment = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const experiment = api.experiments.get.useQuery(
|
const experiment = api.experiments.get.useQuery(
|
||||||
@@ -18,6 +26,14 @@ export const useExperimentAccess = () => {
|
|||||||
return useExperiment().data?.access ?? { canView: false, canModify: false };
|
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 = () => {
|
export const useDataset = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dataset = api.datasets.get.useQuery(
|
const dataset = api.datasets.get.useQuery(
|
||||||
@@ -136,8 +152,5 @@ export const useVisibleScenarioIds = () => useScenarios().data?.scenarios.map((s
|
|||||||
|
|
||||||
export const useSelectedOrg = () => {
|
export const useSelectedOrg = () => {
|
||||||
const selectedOrgId = useAppStore((state) => state.selectedOrgId);
|
const selectedOrgId = useAppStore((state) => state.selectedOrgId);
|
||||||
return api.organizations.get.useQuery(
|
return api.organizations.get.useQuery({ id: selectedOrgId ?? "" }, { enabled: !!selectedOrgId });
|
||||||
{ id: selectedOrgId ?? "" },
|
|
||||||
{ enabled: !!selectedOrgId },
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user