Show selected org

This commit is contained in:
David Corbitt
2023-08-06 23:23:20 -07:00
parent a53d70d8b2
commit 6b304f8456
23 changed files with 380 additions and 97 deletions

View File

@@ -14,6 +14,7 @@ import {
import { useRouter } from "next/router";
import { useRef } from "react";
import { BsTrash } from "react-icons/bs";
import { useAppStore } from "~/state/store";
import { api } from "~/utils/api";
import { useExperiment, useHandledAsyncCallback } from "~/utils/hooks";
@@ -23,6 +24,8 @@ export const DeleteButton = () => {
const utils = api.useContext();
const router = useRouter();
const closeDrawer = useAppStore((s) => s.closeDrawer);
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef<HTMLButtonElement>(null);
@@ -31,6 +34,8 @@ export const DeleteButton = () => {
await mutation.mutateAsync({ id: experiment.data.id });
await utils.experiments.list.invalidate();
await router.push({ pathname: "/experiments" });
closeDrawer();
onClose();
}, [mutation, experiment.data?.id, router]);

View File

@@ -7,48 +7,21 @@ import {
Image,
Text,
Box,
type BoxProps,
Link as ChakraLink,
Flex,
} from "@chakra-ui/react";
import Head from "next/head";
import Link, { type LinkProps } from "next/link";
import Link from "next/link";
import { BsGithub, BsPersonCircle } from "react-icons/bs";
import { useRouter } from "next/router";
import { type IconType } from "react-icons";
import { RiDatabase2Line, RiFlaskLine } from "react-icons/ri";
import { signIn, useSession } from "next-auth/react";
import UserMenu from "./UserMenu";
import { env } from "~/env.mjs";
import ProjectMenu from "./ProjectMenu";
import NavSidebarOption from "./NavSidebarOption";
import IconLink from "./IconLink";
type IconLinkProps = BoxProps & LinkProps & { label?: string; icon: IconType; href: string };
const IconLink = ({ icon, label, href, color, ...props }: IconLinkProps) => {
const router = useRouter();
const isActive = href && router.pathname.startsWith(href);
return (
<Link href={href} style={{ width: "100%" }}>
<HStack
w="full"
p={4}
color={color}
as={ChakraLink}
bgColor={isActive ? "gray.200" : "transparent"}
_hover={{ bgColor: "gray.300", textDecoration: "none" }}
justifyContent="start"
cursor="pointer"
{...props}
>
<Icon as={icon} boxSize={6} mr={2} />
<Text fontWeight="bold" fontSize="sm">
{label}
</Text>
</HStack>
</Link>
);
};
const Divider = () => <Box h="1px" bgColor="gray.200" />;
const Divider = () => <Box h="1px" bgColor="gray.300" w="full" />;
const NavSidebar = () => {
const user = useSession().data;
@@ -56,22 +29,28 @@ const NavSidebar = () => {
return (
<VStack
align="stretch"
bgColor="gray.100"
bgColor="gray.50"
py={2}
px={2}
pb={0}
height="100%"
w={{ base: "56px", md: "200px" }}
w={{ base: "56px", md: "240px" }}
overflow="hidden"
borderRightWidth={1}
borderColor="gray.300"
>
<HStack as={Link} href="/" _hover={{ textDecoration: "none" }} spacing={0} px={4} py={2}>
<HStack as={Link} href="/" _hover={{ textDecoration: "none" }} spacing={0} px={2} py={2}>
<Image src="/logo.svg" alt="" boxSize={6} mr={4} />
<Heading size="md" fontFamily="inconsolata, monospace">
OpenPipe
</Heading>
</HStack>
<VStack spacing={0} align="flex-start" overflowY="auto" overflowX="hidden" flex={1}>
<Divider />
<VStack align="flex-start" overflowY="auto" overflowX="hidden" flex={1}>
{user != null && (
<>
<ProjectMenu />
<Divider />
<IconLink icon={RiFlaskLine} label="Experiments" href="/experiments" />
{env.NEXT_PUBLIC_SHOW_DATA && (
<IconLink icon={RiDatabase2Line} label="Data" href="/data" />
@@ -79,29 +58,26 @@ const NavSidebar = () => {
</>
)}
{user === null && (
<HStack
w="full"
p={4}
as={ChakraLink}
_hover={{ bgColor: "gray.300", textDecoration: "none" }}
justifyContent="start"
cursor="pointer"
onClick={() => {
signIn("github").catch(console.error);
}}
>
<Icon as={BsPersonCircle} boxSize={6} mr={2} />
<Text fontWeight="bold" fontSize="sm">
Sign In
</Text>
</HStack>
<NavSidebarOption>
<HStack
w="full"
p={4}
as={ChakraLink}
justifyContent="start"
onClick={() => {
signIn("github").catch(console.error);
}}
>
<Icon as={BsPersonCircle} boxSize={6} mr={2} />
<Text fontWeight="bold" fontSize="sm">
Sign In
</Text>
</HStack>
</NavSidebarOption>
)}
</VStack>
{user ? (
<UserMenu user={user} borderColor={"gray.200"} borderTopWidth={1} borderBottomWidth={1} />
) : (
<Divider />
)}
{user && <UserMenu user={user} borderColor={"gray.200"} />}
<Divider />
<VStack spacing={0} align="center">
<ChakraLink
href="https://github.com/openpipe/openpipe"

View File

@@ -0,0 +1,23 @@
import { Icon, HStack, Text, type BoxProps } from "@chakra-ui/react";
import Link, { type LinkProps } from "next/link";
import { type IconType } from "react-icons";
import NavSidebarOption from "./NavSidebarOption";
type IconLinkProps = BoxProps & LinkProps & { label?: string; icon: IconType; href: string };
const IconLink = ({ icon, label, href, color, ...props }: IconLinkProps) => {
return (
<Link href={href} style={{ width: "100%" }}>
<NavSidebarOption activeHrefPattern={href}>
<HStack w="full" p={2} color={color} justifyContent="start" {...props}>
<Icon as={icon} boxSize={6} mr={2} />
<Text fontSize="sm">
{label}
</Text>
</HStack>
</NavSidebarOption>
</Link>
);
};
export default IconLink;

View File

@@ -0,0 +1,26 @@
import { Box, type BoxProps } from "@chakra-ui/react";
import { useRouter } from "next/router";
const NavSidebarOption = ({
activeHrefPattern,
...props
}: { activeHrefPattern?: string } & BoxProps) => {
const router = useRouter();
const isActive = activeHrefPattern && router.pathname.startsWith(activeHrefPattern);
return (
<Box
w="full"
fontWeight={isActive ? "bold" : "500"}
bgColor={isActive ? "gray.200" : "transparent"}
_hover={{ bgColor: "gray.200", textDecoration: "none" }}
justifyContent="start"
cursor="pointer"
borderRadius={4}
{...props}
>
{props.children}
</Box>
);
};
export default NavSidebarOption;

View File

@@ -0,0 +1,74 @@
import {
HStack,
VStack,
Text,
Popover,
PopoverTrigger,
PopoverContent,
Flex,
} from "@chakra-ui/react";
import { useEffect } from "react";
import Link from "next/link";
import { useAppStore } from "~/state/store";
import { api } from "~/utils/api";
import NavSidebarOption from "./NavSidebarOption";
import { useSelectedOrg } from "~/utils/hooks";
export default function ProjectMenu() {
const selectedOrgId = useAppStore((s) => s.selectedOrgId);
const setSelectedOrgId = useAppStore((s) => s.setSelectedOrgId);
const { data } = api.organizations.list.useQuery();
useEffect(() => {
if (data && data[0] && (!selectedOrgId || !data.find((org) => org.id === selectedOrgId))) {
setSelectedOrgId(data[0].id);
}
}, [selectedOrgId, setSelectedOrgId, data]);
const { data: selectedOrg } = useSelectedOrg();
return (
<>
<Popover placement="right">
<PopoverTrigger>
<VStack w="full" alignItems="flex-start" spacing={0}>
<Text
pl={2}
pb={2}
fontSize="xs"
fontWeight="bold"
color="gray.500"
display={{ base: "none", md: "flex" }}
>
PROJECT
</Text>
<NavSidebarOption activeHrefPattern="/home">
<Link href="/home">
<HStack w="full">
<Flex
p={1}
borderRadius={4}
backgroundColor="orange.100"
minW={{ base: 10, md: 8 }}
minH={{ base: 10, md: 8 }}
m={{ base: 0, md: 1 }}
alignItems="center"
justifyContent="center"
>
<Text>{selectedOrg?.name[0]?.toUpperCase()}</Text>
</Flex>
<Text fontSize="sm" display={{ base: "none", md: "block" }} py={1}>
{selectedOrg?.name}
</Text>
</HStack>
</Link>
</NavSidebarOption>
</VStack>
</PopoverTrigger>
<PopoverContent _focusVisible={{ boxShadow: "unset", outline: "unset" }}></PopoverContent>
</Popover>
</>
);
}

View File

@@ -14,6 +14,7 @@ import {
import { type Session } from "next-auth";
import { signOut } from "next-auth/react";
import { BsBoxArrowRight, BsChevronRight, BsPersonCircle } from "react-icons/bs";
import NavSidebarOption from "./NavSidebarOption";
export default function UserMenu({ user, ...rest }: { user: Session } & StackProps) {
const { colorMode } = useColorMode();
@@ -27,30 +28,39 @@ export default function UserMenu({ user, ...rest }: { user: Session } & StackPro
return (
<>
<Popover placement="right">
<PopoverTrigger>
<HStack
// Weird values to make mobile look right; can clean up when we make the sidebar disappear on mobile
px={3}
spacing={3}
py={2}
{...rest}
cursor="pointer"
_hover={{
bgColor: colorMode === "light" ? "gray.200" : "gray.700",
}}
<VStack w="full" alignItems="flex-start" spacing={0} {...rest}>
<Text
pl={2}
pb={2}
fontSize="xs"
fontWeight="bold"
color="gray.500"
display={{ base: "none", md: "flex" }}
>
{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>
</PopoverTrigger>
ACCOUNT
</Text>
<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}
>
{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>
</PopoverTrigger>
</VStack>
<PopoverContent _focusVisible={{ boxShadow: "unset", outline: "unset" }} maxW="200px">
<VStack align="stretch" spacing={0}>
{/* sign out */}

View File

@@ -56,8 +56,7 @@ export default function Dataset() {
<AppShell title={dataset.data?.name}>
<VStack h="full">
<Flex
pl={4}
pr={8}
px={8}
py={2}
w="full"
direction={{ base: "column", sm: "row" }}
@@ -90,7 +89,7 @@ export default function Dataset() {
</Breadcrumb>
<DatasetHeaderButtons />
</Flex>
<Box w="full" overflowX="auto" flex={1} pl={4} pr={8} pt={8} pb={16}>
<Box w="full" overflowX="auto" flex={1} px={8} pt={8} pb={16}>
{datasetId && <DatasetEntriesTable />}
</Box>
</VStack>

View File

@@ -50,7 +50,7 @@ export default function DatasetsPage() {
return (
<AppShell title="Data">
<VStack alignItems={"flex-start"} px={4} py={2}>
<VStack alignItems={"flex-start"} px={8} py={2}>
<HStack minH={8} align="center" pt={2}>
<Breadcrumb flex={1}>
<BreadcrumbItem>
@@ -60,7 +60,7 @@ export default function DatasetsPage() {
</BreadcrumbItem>
</Breadcrumb>
</HStack>
<SimpleGrid w="full" columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={8} p="4">
<SimpleGrid w="full" columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={8} py="4">
<NewDatasetCard />
{datasets.data && !datasets.isLoading ? (
datasets?.data?.map((dataset) => (

View File

@@ -105,7 +105,8 @@ export default function Experiment() {
<AppShell title={experiment.data?.label}>
<VStack h="full">
<Flex
px={4}
pl={8}
pr={4}
py={2}
w="full"
direction={{ base: "column", sm: "row" }}

View File

@@ -50,7 +50,7 @@ export default function ExperimentsPage() {
return (
<AppShell title="Experiments">
<VStack alignItems={"flex-start"} px={4} py={2}>
<VStack alignItems={"flex-start"} px={8} py={2}>
<HStack minH={8} align="center" pt={2}>
<Breadcrumb flex={1}>
<BreadcrumbItem>
@@ -60,7 +60,7 @@ export default function ExperimentsPage() {
</BreadcrumbItem>
</Breadcrumb>
</HStack>
<SimpleGrid w="full" columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={8} p="4">
<SimpleGrid w="full" columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing={8} py="4">
<NewExperimentCard />
{experiments.data && !experiments.isLoading ? (
experiments?.data?.map((exp) => <ExperimentCard key={exp.id} exp={exp} />)

View File

@@ -0,0 +1,56 @@
import { Breadcrumb, BreadcrumbItem, HStack, Input } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import AppShell from "~/components/nav/AppShell";
import { api } from "~/utils/api";
import { useHandledAsyncCallback, 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 [name, setName] = useState(selectedOrg?.name);
useEffect(() => {
setName(selectedOrg?.name);
}, [selectedOrg?.name]);
return (
<AppShell>
<HStack
px={4}
py={2}
w="full"
direction={{ base: "column", sm: "row" }}
alignItems={{ base: "flex-start", sm: "center" }}
>
<Breadcrumb flex={1}>
<BreadcrumbItem isCurrentPage>
<Input
size="sm"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={onSaveName}
borderWidth={1}
borderColor="transparent"
fontSize={16}
px={0}
minW={{ base: 100, lg: 300 }}
flex={1}
_hover={{ borderColor: "gray.300" }}
_focus={{ borderColor: "blue.500", outline: "none" }}
/>
</BreadcrumbItem>
</Breadcrumb>
</HStack>
</AppShell>
);
}

View File

@@ -4,7 +4,7 @@ import { type GetServerSideProps } from "next";
export const getServerSideProps: GetServerSideProps = async () => {
return {
redirect: {
destination: "/experiments",
destination: "/home",
permanent: false,
},
};

View File

@@ -9,6 +9,7 @@ import { worldChampsRouter } from "./routers/worldChamps.router";
import { datasetsRouter } from "./routers/datasets.router";
import { datasetEntries } from "./routers/datasetEntries.router";
import { externalApiRouter } from "./routers/externalApi.router";
import { organizationsRouter } from "./routers/organizations.router";
/**
* This is the primary router for your server.
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
worldChamps: worldChampsRouter,
datasets: datasetsRouter,
datasetEntries: datasetEntries,
organizations: organizationsRouter,
externalApi: externalApiRouter,
});

View File

@@ -0,0 +1,71 @@
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { prisma } from "~/server/db";
import { requireCanModifyOrganization, requireNothing } from "~/utils/accessControl";
export const organizationsRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
requireNothing(ctx);
if (!userId) {
return null;
}
const organizations = await prisma.organization.findMany({
where: {
organizationUsers: {
some: { userId: ctx.session.user.id },
},
},
orderBy: {
createdAt: "desc",
},
});
if (!organizations.length) {
const newOrgId = uuidv4();
const [newOrg] = await prisma.$transaction([
prisma.organization.create({
data: {
id: newOrgId,
personalOrgUserId: userId,
},
}),
prisma.organizationUser.create({
data: {
userId,
organizationId: newOrgId,
role: "ADMIN",
},
}),
]);
organizations.push(newOrg);
}
return organizations;
}),
get: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
requireNothing(ctx);
return await prisma.organization.findUnique({
where: {
id: input.id,
},
});
}),
update: protectedProcedure
.input(z.object({ id: z.string(), updates: z.object({ name: z.string() }) }))
.mutation(async ({ input, ctx }) => {
await requireCanModifyOrganization(input.id, ctx);
return await prisma.organization.update({
where: {
id: input.id,
},
data: {
name: input.updates.name,
},
});
}),
});

View File

@@ -17,7 +17,7 @@ const taskList = registeredTasks.reduce((acc, task) => {
// Run a worker to execute jobs:
const runner = await run({
connectionString: env.DATABASE_URL,
concurrency: 50,
concurrency: 10,
// Install signal handlers for graceful shutdown on SIGINT, SIGTERM, etc
noHandleSignals: false,
pollInterval: 1000,

View File

@@ -1,10 +1,7 @@
import { env } from "~/env.mjs";
// import OpenAI from "openai";
// import { OpenPipe } from "../../../../client-libs/js/openai/index";
import { OpenAI } from "openpipe";
// Set a dummy key so it doesn't fail at build time
export const openai = new OpenAI.OpenPipe({ apiKey: env.OPENAI_API_KEY ?? "dummy-key" });
export const openai = new OpenAI.OpenAI({ apiKey: env.OPENAI_API_KEY ?? "dummy-key" });

View File

@@ -14,6 +14,8 @@ export type State = {
api: APIClient | null;
setApi: (api: APIClient) => void;
sharedVariantEditor: SharedVariantEditorSlice;
selectedOrgId: string | null;
setSelectedOrgId: (orgId: string) => void;
};
export type SliceCreator<T> = StateCreator<State, [["zustand/immer", never]], [], T>;
@@ -39,6 +41,11 @@ const useBaseStore = create<State, [["zustand/immer", never]]>(
state.drawerOpen = false;
}),
sharedVariantEditor: createVariantEditorSlice(set, get, ...rest),
selectedOrgId: null,
setSelectedOrgId: (orgId: string) =>
set((state) => {
state.selectedOrgId = orgId;
}),
})),
);

View File

@@ -16,6 +16,27 @@ export const requireNothing = (ctx: TRPCContext) => {
ctx.markAccessControlRun();
};
export const requireCanModifyOrganization = async (organizationId: string, ctx: TRPCContext) => {
const userId = ctx.session?.user.id;
if (!userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const canModify = await prisma.organizationUser.findFirst({
where: {
userId,
organizationId,
role: { in: [OrganizationUserRole.ADMIN, OrganizationUserRole.MEMBER] },
},
});
if (!canModify) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
ctx.markAccessControlRun();
}
export const requireCanViewDataset = async (datasetId: string, ctx: TRPCContext) => {
const dataset = await prisma.dataset.findFirst({
where: {

View File

@@ -2,6 +2,7 @@ import { useRouter } from "next/router";
import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/utils/api";
import { NumberParam, useQueryParam, withDefault } from "use-query-params";
import { useAppStore } from "~/state/store";
export const useExperiment = () => {
const router = useRouter();
@@ -132,3 +133,11 @@ export const useScenario = (scenarioId: string) => {
};
export const useVisibleScenarioIds = () => useScenarios().data?.scenarios.map((s) => s.id) ?? [];
export const useSelectedOrg = () => {
const selectedOrgId = useAppStore((state) => state.selectedOrgId);
return api.organizations.get.useQuery(
{ id: selectedOrgId ?? "" },
{ enabled: !!selectedOrgId },
);
};