Paginate scenarios

Show 10 scenarios at a time and let the user paginate through them to keep the interface responsive with potentially 1000s of scenarios.
This commit is contained in:
Kyle Corbitt
2023-07-22 15:51:52 -07:00
parent 4b06d05908
commit 2e395e4d39
14 changed files with 233 additions and 59 deletions

View File

@@ -1,6 +1,3 @@
{ {
"eslint.format.enable": true, "eslint.format.enable": true
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
} }

View File

@@ -59,6 +59,7 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"next": "^13.4.2", "next": "^13.4.2",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-query-params": "^4.2.3",
"nextjs-routes": "^2.0.1", "nextjs-routes": "^2.0.1",
"openai": "4.0.0-beta.2", "openai": "4.0.0-beta.2",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
@@ -79,6 +80,7 @@
"superjson": "1.12.2", "superjson": "1.12.2",
"tsx": "^3.12.7", "tsx": "^3.12.7",
"type-fest": "^4.0.0", "type-fest": "^4.0.0",
"use-query-params": "^2.2.1",
"vite-tsconfig-paths": "^4.2.0", "vite-tsconfig-paths": "^4.2.0",
"zod": "^3.21.4", "zod": "^3.21.4",
"zustand": "^4.3.9" "zustand": "^4.3.9"

41
pnpm-lock.yaml generated
View File

@@ -119,6 +119,9 @@ dependencies:
next-auth: next-auth:
specifier: ^4.22.1 specifier: ^4.22.1
version: 4.22.1(next@13.4.2)(react-dom@18.2.0)(react@18.2.0) version: 4.22.1(next@13.4.2)(react-dom@18.2.0)(react@18.2.0)
next-query-params:
specifier: ^4.2.3
version: 4.2.3(next@13.4.2)(react@18.2.0)(use-query-params@2.2.1)
nextjs-routes: nextjs-routes:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1(next@13.4.2) version: 2.0.1(next@13.4.2)
@@ -179,6 +182,9 @@ dependencies:
type-fest: type-fest:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
use-query-params:
specifier: ^2.2.1
version: 2.2.1(react-dom@18.2.0)(react@18.2.0)
vite-tsconfig-paths: vite-tsconfig-paths:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0(typescript@5.0.4) version: 4.2.0(typescript@5.0.4)
@@ -6037,6 +6043,19 @@ packages:
uuid: 8.3.2 uuid: 8.3.2
dev: false dev: false
/next-query-params@4.2.3(next@13.4.2)(react@18.2.0)(use-query-params@2.2.1):
resolution: {integrity: sha512-hGNCYRH8YyA5ItiBGSKrtMl21b2MAqfPkdI1mvwloNVqSU142IaGzqHN+OTovyeLIpQfonY01y7BAHb/UH4POg==}
peerDependencies:
next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
use-query-params: ^2.0.0
dependencies:
next: 13.4.2(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
tslib: 2.6.0
use-query-params: 2.2.1(react-dom@18.2.0)(react@18.2.0)
dev: false
/next-tick@1.1.0: /next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
dev: false dev: false
@@ -7147,6 +7166,10 @@ packages:
randombytes: 2.1.0 randombytes: 2.1.0
dev: true dev: true
/serialize-query-params@2.0.2:
resolution: {integrity: sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q==}
dev: false
/serve-static@1.15.0: /serve-static@1.15.0:
resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -7824,6 +7847,24 @@ packages:
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.6)(react@18.2.0) use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.6)(react@18.2.0)
dev: false dev: false
/use-query-params@2.2.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q==}
peerDependencies:
'@reach/router': ^1.2.1
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-router-dom: '>=5'
peerDependenciesMeta:
'@reach/router':
optional: true
react-router-dom:
optional: true
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
serialize-query-params: 2.0.2
dev: false
/use-sidecar@1.1.2(@types/react@18.2.6)(react@18.2.0): /use-sidecar@1.1.2(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
engines: {node: '>=10'} engines: {node: '>=10'}

View File

@@ -7,9 +7,13 @@ const defaultId = "11111111-1111-1111-1111-111111111111";
await prisma.organization.deleteMany({ await prisma.organization.deleteMany({
where: { id: defaultId }, where: { id: defaultId },
}); });
await prisma.organization.create({
data: { id: defaultId }, // If there's an existing org, just seed into it
}); const org =
(await prisma.organization.findFirst({})) ??
(await prisma.organization.create({
data: { id: defaultId },
}));
await prisma.experiment.deleteMany({ await prisma.experiment.deleteMany({
where: { where: {
@@ -21,7 +25,7 @@ await prisma.experiment.create({
data: { data: {
id: defaultId, id: defaultId,
label: "Country Capitals Example", label: "Country Capitals Example",
organizationId: defaultId, organizationId: org.id,
}, },
}); });
@@ -103,30 +107,41 @@ await prisma.testScenario.deleteMany({
}, },
}); });
const countries = [
"Afghanistan",
"Albania",
"Algeria",
"Andorra",
"Angola",
"Antigua and Barbuda",
"Argentina",
"Armenia",
"Australia",
"Austria",
"Austrian Empire",
"Azerbaijan",
"Baden",
"Bahamas, The",
"Bahrain",
"Bangladesh",
"Barbados",
"Bavaria",
"Belarus",
"Belgium",
"Belize",
"Benin (Dahomey)",
"Bolivia",
"Bosnia and Herzegovina",
"Botswana",
];
await prisma.testScenario.createMany({ await prisma.testScenario.createMany({
data: [ data: countries.map((country, i) => ({
{ experimentId: defaultId,
experimentId: defaultId, sortIndex: i,
sortIndex: 0, variableValues: {
variableValues: { country: country,
country: "Spain",
},
}, },
{ })),
experimentId: defaultId,
sortIndex: 1,
variableValues: {
country: "USA",
},
},
{
experimentId: defaultId,
sortIndex: 2,
variableValues: {
country: "Chile",
},
},
],
}); });
const variants = await prisma.promptVariant.findMany({ const variants = await prisma.promptVariant.findMany({

View File

@@ -0,0 +1,74 @@
import { Box, HStack, IconButton } from "@chakra-ui/react";
import {
BsChevronDoubleLeft,
BsChevronDoubleRight,
BsChevronLeft,
BsChevronRight,
} from "react-icons/bs";
import { usePage, useScenarios } from "~/utils/hooks";
const ScenarioPaginator = () => {
const [page, setPage] = usePage();
const { data } = useScenarios();
if (!data) return null;
const { scenarios, startIndex, lastPage, count } = data;
const nextPage = () => {
if (page < lastPage) {
setPage(page + 1, "replace");
}
};
const prevPage = () => {
if (page > 1) {
setPage(page - 1, "replace");
}
};
const goToLastPage = () => setPage(lastPage, "replace");
const goToFirstPage = () => setPage(1, "replace");
return (
<HStack pt={4}>
<IconButton
variant="ghost"
size="sm"
onClick={goToFirstPage}
isDisabled={page === 1}
aria-label="Go to first page"
icon={<BsChevronDoubleLeft />}
/>
<IconButton
variant="ghost"
size="sm"
onClick={prevPage}
isDisabled={page === 1}
aria-label="Previous page"
icon={<BsChevronLeft />}
/>
<Box>
{startIndex}-{startIndex + scenarios.length - 1} / {count}
</Box>
<IconButton
variant="ghost"
size="sm"
onClick={nextPage}
isDisabled={page === lastPage}
aria-label="Next page"
icon={<BsChevronRight />}
/>
<IconButton
variant="ghost"
size="sm"
onClick={goToLastPage}
isDisabled={page === lastPage}
aria-label="Go to last page"
icon={<BsChevronDoubleRight />}
/>
</HStack>
);
};
export default ScenarioPaginator;

View File

@@ -12,7 +12,12 @@ import {
Spinner, Spinner,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { cellPadding } from "../constants"; import { cellPadding } from "../constants";
import { useExperiment, useExperimentAccess, useHandledAsyncCallback } from "~/utils/hooks"; import {
useExperiment,
useExperimentAccess,
useHandledAsyncCallback,
useScenarios,
} from "~/utils/hooks";
import { BsGear, BsPencil, BsPlus, BsStars } from "react-icons/bs"; import { BsGear, BsPencil, BsPlus, BsStars } from "react-icons/bs";
import { useAppStore } from "~/state/store"; import { useAppStore } from "~/state/store";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -21,9 +26,10 @@ export const ActionButton = (props: ButtonProps) => (
<Button size="sm" variant="ghost" color="gray.600" {...props} /> <Button size="sm" variant="ghost" color="gray.600" {...props} />
); );
export const ScenariosHeader = (props: { numScenarios: number }) => { export const ScenariosHeader = () => {
const openDrawer = useAppStore((s) => s.openDrawer); const openDrawer = useAppStore((s) => s.openDrawer);
const { canModify } = useExperimentAccess(); const { canModify } = useExperimentAccess();
const scenarios = useScenarios();
const experiment = useExperiment(); const experiment = useExperiment();
const createScenarioMutation = api.scenarios.create.useMutation(); const createScenarioMutation = api.scenarios.create.useMutation();
@@ -44,7 +50,7 @@ export const ScenariosHeader = (props: { numScenarios: number }) => {
return ( return (
<HStack w="100%" pb={cellPadding.y} pt={0} align="center" spacing={0}> <HStack w="100%" pb={cellPadding.y} pt={0} align="center" spacing={0}>
<Text fontSize={16} fontWeight="bold"> <Text fontSize={16} fontWeight="bold">
Scenarios ({props.numScenarios}) Scenarios ({scenarios.data?.count})
</Text> </Text>
{canModify && ( {canModify && (
<Menu> <Menu>

View File

@@ -7,6 +7,8 @@ import VariantHeader from "../VariantHeader/VariantHeader";
import VariantStats from "./VariantStats"; import VariantStats from "./VariantStats";
import { ScenariosHeader } from "./ScenariosHeader"; import { ScenariosHeader } from "./ScenariosHeader";
import { borders } from "./styles"; import { borders } from "./styles";
import { useScenarios } from "~/utils/hooks";
import ScenarioPaginator from "./ScenarioPaginator";
export default function OutputsTable({ experimentId }: { experimentId: string | undefined }) { export default function OutputsTable({ experimentId }: { experimentId: string | undefined }) {
const variants = api.promptVariants.list.useQuery( const variants = api.promptVariants.list.useQuery(
@@ -14,17 +16,17 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
{ enabled: !!experimentId }, { enabled: !!experimentId },
); );
const scenarios = api.scenarios.list.useQuery( const scenarios = useScenarios();
{ experimentId: experimentId as string },
{ enabled: !!experimentId },
);
if (!variants.data || !scenarios.data) return null; if (!variants.data || !scenarios.data) return null;
const allCols = variants.data.length + 2; const allCols = variants.data.length + 2;
const variantHeaderRows = 3; const variantHeaderRows = 3;
const scenarioHeaderRows = 1; const scenarioHeaderRows = 1;
const allRows = variantHeaderRows + scenarioHeaderRows + scenarios.data.length; const scenarioFooterRows = 1;
const visibleScenariosCount = scenarios.data.scenarios.length;
const allRows =
variantHeaderRows + scenarioHeaderRows + visibleScenariosCount + scenarioFooterRows;
return ( return (
<Grid <Grid
@@ -76,18 +78,25 @@ export default function OutputsTable({ experimentId }: { experimentId: string |
{...borders} {...borders}
borderRightWidth={0} borderRightWidth={0}
> >
<ScenariosHeader numScenarios={scenarios.data.length} /> <ScenariosHeader />
</GridItem> </GridItem>
{scenarios.data.map((scenario, i) => ( {scenarios.data.scenarios.map((scenario, i) => (
<ScenarioRow <ScenarioRow
rowStart={i + variantHeaderRows + scenarioHeaderRows + 2} rowStart={i + variantHeaderRows + scenarioHeaderRows + 2}
key={scenario.uiId} key={scenario.uiId}
scenario={scenario} scenario={scenario}
variants={variants.data} variants={variants.data}
canHide={scenarios.data.length > 1} canHide={visibleScenariosCount > 1}
/> />
))} ))}
<GridItem
rowStart={variantHeaderRows + scenarioHeaderRows + visibleScenariosCount + 2}
colStart={1}
colSpan={allCols}
>
<ScenarioPaginator />
</GridItem>
{/* Add some extra padding on the right, because when the table is too wide to fit in the viewport `pr` on the Grid isn't respected. */} {/* Add some extra padding on the right, because when the table is too wide to fit in the viewport `pr` on the Grid isn't respected. */}
<GridItem rowStart={1} colStart={allCols} rowSpan={allRows} w={4} borderBottomWidth={0} /> <GridItem rowStart={1} colStart={allCols} rowSpan={allRows} w={4} borderBottomWidth={0} />

View File

@@ -2,4 +2,4 @@ import { type RouterOutputs } from "~/utils/api";
export type PromptVariant = NonNullable<RouterOutputs["promptVariants"]["list"]>[0]; export type PromptVariant = NonNullable<RouterOutputs["promptVariants"]["list"]>[0];
export type Scenario = NonNullable<RouterOutputs["scenarios"]["list"]>[0]; export type Scenario = NonNullable<RouterOutputs["scenarios"]["list"]>["scenarios"][0];

View File

@@ -27,8 +27,6 @@ export async function getCompletion(
input: rest, input: rest,
}); });
console.log("stream?", onStream);
const interval = onStream const interval = onStream
? // eslint-disable-next-line @typescript-eslint/no-misused-promises ? // eslint-disable-next-line @typescript-eslint/no-misused-promises
setInterval(async () => { setInterval(async () => {

View File

@@ -7,6 +7,8 @@ import "~/utils/analytics";
import Head from "next/head"; import Head from "next/head";
import { ChakraThemeProvider } from "~/theme/ChakraThemeProvider"; import { ChakraThemeProvider } from "~/theme/ChakraThemeProvider";
import { SyncAppStore } from "~/state/sync"; import { SyncAppStore } from "~/state/sync";
import NextAdapterApp from "next-query-params/app";
import { QueryParamProvider } from "use-query-params";
const MyApp: AppType<{ session: Session | null }> = ({ const MyApp: AppType<{ session: Session | null }> = ({
Component, Component,
@@ -24,7 +26,9 @@ const MyApp: AppType<{ session: Session | null }> = ({
<SyncAppStore /> <SyncAppStore />
<Favicon /> <Favicon />
<ChakraThemeProvider> <ChakraThemeProvider>
<Component {...pageProps} /> <QueryParamProvider adapter={NextAdapterApp}>
<Component {...pageProps} />
</QueryParamProvider>
</ChakraThemeProvider> </ChakraThemeProvider>
</SessionProvider> </SessionProvider>
</> </>

View File

@@ -7,21 +7,39 @@ import { runAllEvals } from "~/server/utils/evaluations";
import { generateNewCell } from "~/server/utils/generateNewCell"; import { generateNewCell } from "~/server/utils/generateNewCell";
import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl"; import { requireCanModifyExperiment, requireCanViewExperiment } from "~/utils/accessControl";
const PAGE_SIZE = 10;
export const scenariosRouter = createTRPCRouter({ export const scenariosRouter = createTRPCRouter({
list: publicProcedure list: publicProcedure
.input(z.object({ experimentId: z.string() })) .input(z.object({ experimentId: z.string(), page: z.number() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await requireCanViewExperiment(input.experimentId, ctx); await requireCanViewExperiment(input.experimentId, ctx);
return await prisma.testScenario.findMany({ const { experimentId, page } = input;
const scenarios = await prisma.testScenario.findMany({
where: { where: {
experimentId: input.experimentId, experimentId,
visible: true, visible: true,
}, },
orderBy: { orderBy: { sortIndex: "asc" },
sortIndex: "asc", skip: (page - 1) * PAGE_SIZE,
take: PAGE_SIZE,
});
const count = await prisma.testScenario.count({
where: {
experimentId,
visible: true,
}, },
}); });
return {
scenarios,
startIndex: (page - 1) * PAGE_SIZE + 1,
lastPage: Math.ceil(count / PAGE_SIZE),
count,
};
}), }),
create: protectedProcedure create: protectedProcedure

View File

@@ -8,9 +8,9 @@ export const editorBackground = "#fafafa";
export type SharedVariantEditorSlice = { export type SharedVariantEditorSlice = {
monaco: null | ReturnType<typeof loader.__getMonacoInstance>; monaco: null | ReturnType<typeof loader.__getMonacoInstance>;
loadMonaco: () => Promise<void>; loadMonaco: () => Promise<void>;
scenarios: RouterOutputs["scenarios"]["list"]; scenarios: RouterOutputs["scenarios"]["list"]["scenarios"];
updateScenariosModel: () => void; updateScenariosModel: () => void;
setScenarios: (scenarios: RouterOutputs["scenarios"]["list"]) => void; setScenarios: (scenarios: RouterOutputs["scenarios"]["list"]["scenarios"]) => void;
}; };
export const createVariantEditorSlice: SliceCreator<SharedVariantEditorSlice> = (set, get) => ({ export const createVariantEditorSlice: SliceCreator<SharedVariantEditorSlice> = (set, get) => ({

View File

@@ -1,17 +1,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { useExperiment } from "~/utils/hooks"; import { useScenarios } from "~/utils/hooks";
import { useAppStore } from "./store"; import { useAppStore } from "./store";
export function useSyncVariantEditor() { export function useSyncVariantEditor() {
const experiment = useExperiment(); const scenarios = useScenarios();
const scenarios = api.scenarios.list.useQuery(
{ experimentId: experiment.data?.id ?? "" },
{ enabled: !!experiment.data?.id },
);
useEffect(() => { useEffect(() => {
if (scenarios.data) { if (scenarios.data) {
useAppStore.getState().sharedVariantEditor.setScenarios(scenarios.data); useAppStore.getState().sharedVariantEditor.setScenarios(scenarios.data.scenarios);
} }
}, [scenarios.data]); }, [scenarios.data]);
} }

View File

@@ -1,6 +1,7 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { NumberParam, useQueryParam, withDefault } from "use-query-params";
export const useExperiment = () => { export const useExperiment = () => {
const router = useRouter(); const router = useRouter();
@@ -93,3 +94,15 @@ export const useElementDimensions = (): [RefObject<HTMLElement>, Dimensions | un
return [ref, dimensions]; return [ref, dimensions];
}; };
export const usePage = () => useQueryParam("page", withDefault(NumberParam, 1));
export const useScenarios = () => {
const experiment = useExperiment();
const [page] = usePage();
return api.scenarios.list.useQuery(
{ experimentId: experiment.data?.id ?? "", page },
{ enabled: experiment.data?.id != null },
);
};