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, 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,6 +27,7 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro
<> <>
<Popover placement="right"> <Popover placement="right">
<PopoverTrigger> <PopoverTrigger>
<Box>
<NavSidebarOption> <NavSidebarOption>
<HStack <HStack
// Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile // Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile
@@ -47,6 +48,7 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro
<Icon as={BsChevronRight} boxSize={4} color="gray.500" /> <Icon as={BsChevronRight} boxSize={4} color="gray.500" />
</HStack> </HStack>
</NavSidebarOption> </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}>

View File

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

View File

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

View File

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

View File

@@ -4,22 +4,20 @@ 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: { orderBy: {
createdAt: "desc", createdAt: "desc",

View File

@@ -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,17 +44,14 @@ 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",

View File

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

View File

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