From 64844e54c5a8c5158d88e9a9c6963194a05162b3 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Thu, 28 Nov 2024 00:24:15 -0300 Subject: [PATCH] rewrite SelectOwner, SelectUser, SelectGroup --- packages/hub/src/app/api/find-owners/route.ts | 24 ++++ .../[slug]/members/AddUserToGroupAction.tsx | 2 +- packages/hub/src/components/SelectGroup.tsx | 54 +++----- packages/hub/src/components/SelectOwner.tsx | 71 +++------- packages/hub/src/components/SelectUser.tsx | 59 +++----- packages/hub/src/graphql/queries/users.ts | 41 ------ packages/hub/src/graphql/schema.ts | 1 - .../server/models/actions/moveModelAction.ts | 2 +- packages/hub/src/server/owners/data.ts | 131 ++++++++++++++++++ 9 files changed, 217 insertions(+), 168 deletions(-) create mode 100644 packages/hub/src/app/api/find-owners/route.ts delete mode 100644 packages/hub/src/graphql/queries/users.ts create mode 100644 packages/hub/src/server/owners/data.ts diff --git a/packages/hub/src/app/api/find-owners/route.ts b/packages/hub/src/app/api/find-owners/route.ts new file mode 100644 index 0000000000..ba41288cea --- /dev/null +++ b/packages/hub/src/app/api/find-owners/route.ts @@ -0,0 +1,24 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; + +import { findOwnersForSelect } from "@/server/owners/data"; + +// We're not calling this as a server actions because it'd be too slow (server actions are sequential). +// TODO: it'd be good to use tRPC for this. +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const { search, mode } = z + .object({ + search: z.string(), + mode: z.enum(["all", "all-users", "all-groups", "my", "my-groups"]), + }) + .parse(Object.fromEntries(searchParams.entries())); + + return Response.json( + await findOwnersForSelect({ + search, + mode, + }) + ); +} diff --git a/packages/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx b/packages/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx index 42ac97c2a6..0314466896 100644 --- a/packages/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx +++ b/packages/hub/src/app/groups/[slug]/members/AddUserToGroupAction.tsx @@ -63,7 +63,7 @@ export const AddUserToGroupAction: FC = ({ groupRef, close }) => { formDataToVariables={(data) => ({ input: { group: group.slug, - username: data.user.username, + username: data.user.slug, role: data.role, }, connections: [ diff --git a/packages/hub/src/components/SelectGroup.tsx b/packages/hub/src/components/SelectGroup.tsx index b630bfd2bc..3b99b16770 100644 --- a/packages/hub/src/components/SelectGroup.tsx +++ b/packages/hub/src/components/SelectGroup.tsx @@ -1,30 +1,13 @@ "use client"; import { FieldPathByValue, FieldValues } from "react-hook-form"; -import { useRelayEnvironment } from "react-relay"; -import { fetchQuery, graphql } from "relay-runtime"; +import { z } from "zod"; import { SelectFormField } from "@quri/ui"; -import { - SelectGroupQuery, - SelectGroupQuery$data, -} from "@/__generated__/SelectGroupQuery.graphql"; - -const Query = graphql` - query SelectGroupQuery($input: GroupsQueryInput!) { - groups(input: $input) { - edges { - node { - id - slug - } - } - } - } -`; - -export type SelectGroupOption = - SelectGroupQuery$data["groups"]["edges"][number]["node"]; +export type SelectGroupOption = { + id: string; + slug: string; +}; export function SelectGroup< TValues extends FieldValues, @@ -45,23 +28,26 @@ export function SelectGroup< required?: boolean; myOnly?: boolean; }) { - const environment = useRelayEnvironment(); - const loadOptions = async ( inputValue: string ): Promise => { - const result = await fetchQuery(environment, Query, { - input: { - slugContains: inputValue, - myOnly, - }, - }).toPromise(); + const result = await fetch( + `/api/find-owners?${new URLSearchParams({ + search: inputValue, + mode: myOnly ? "my-groups" : "all-groups", + })}` + ).then((r) => r.json()); - if (!result) { - return []; - } + const data = z + .array( + z.object({ + id: z.string(), + slug: z.string(), + }) + ) + .parse(result); - return result.groups.edges.map((edge) => edge.node); + return data; }; return ( diff --git a/packages/hub/src/components/SelectOwner.tsx b/packages/hub/src/components/SelectOwner.tsx index 3d27c81045..964e76e6f2 100644 --- a/packages/hub/src/components/SelectOwner.tsx +++ b/packages/hub/src/components/SelectOwner.tsx @@ -1,47 +1,12 @@ "use client"; import { FC } from "react"; import { FieldPathByValue, FieldValues } from "react-hook-form"; -import { useRelayEnvironment } from "react-relay"; -import { fetchQuery, graphql } from "relay-runtime"; +import { z } from "zod"; import { SelectFormField } from "@quri/ui"; import { ownerIcon } from "@/lib/ownerIcon"; -import { SelectOwnerQuery } from "@/__generated__/SelectOwnerQuery.graphql"; - -const Query = graphql` - query SelectOwnerQuery($search: String!, $myOnly: Boolean!) { - me @include(if: $myOnly) { - asUser { - __typename - id - slug - } - } - users(input: { usernameContains: $search }) @skip(if: $myOnly) { - edges { - node { - __typename - id - slug - } - } - } - groups(input: { slugContains: $search, myOnly: $myOnly }) { - edges { - node { - __typename - id - slug - } - } - } - } -`; - -// Note: we can't wrap this in a fragment because it's not possible to call `useFragment` -// in SelectFormField callbacks. export type SelectOwnerOption = { __typename: "User" | "Group"; id: string; @@ -75,29 +40,27 @@ export function SelectOwner< required?: boolean; myOnly?: boolean; }) { - const environment = useRelayEnvironment(); - const loadOptions = async ( inputValue: string ): Promise => { - const result = await fetchQuery(environment, Query, { - search: inputValue, - myOnly, - }).toPromise(); + const result = await fetch( + `/api/find-owners?${new URLSearchParams({ + search: inputValue, + mode: myOnly ? "my" : "all", + })}` + ).then((r) => r.json()); - if (!result) { - return []; - } + const data = z + .array( + z.object({ + __typename: z.enum(["User", "Group"]), + id: z.string(), + slug: z.string(), + }) + ) + .parse(result); - const options: SelectOwnerOption[] = []; - if (result.me) { - options.push(result.me.asUser); - } - if (result.users) { - options.push(...(result.users.edges.map((edge) => edge.node) ?? [])); - } - options.push(...result.groups.edges.map((edge) => edge.node)); - return options; + return data; }; return ( diff --git a/packages/hub/src/components/SelectUser.tsx b/packages/hub/src/components/SelectUser.tsx index 3e20e64a8f..c22a6da359 100644 --- a/packages/hub/src/components/SelectUser.tsx +++ b/packages/hub/src/components/SelectUser.tsx @@ -1,30 +1,13 @@ "use client"; import { FieldPathByValue, FieldValues } from "react-hook-form"; -import { useRelayEnvironment } from "react-relay"; -import { fetchQuery, graphql } from "relay-runtime"; +import { z } from "zod"; import { SelectFormField } from "@quri/ui"; -import { - SelectUserQuery, - SelectUserQuery$data, -} from "@/__generated__/SelectUserQuery.graphql"; - -const Query = graphql` - query SelectUserQuery($input: UsersQueryInput!) { - users(input: $input) { - edges { - node { - id - username - } - } - } - } -`; - -export type SelectUserOption = - SelectUserQuery$data["users"]["edges"][number]["node"]; +export type SelectUserOption = { + id: string; + slug: string; +}; export function SelectUser({ name, @@ -35,22 +18,26 @@ export function SelectUser({ label?: string; required?: boolean; }) { - const environment = useRelayEnvironment(); - const loadOptions = async ( inputValue: string ): Promise => { - const result = await fetchQuery(environment, Query, { - input: { - usernameContains: inputValue, - }, - }).toPromise(); - - if (!result) { - return []; - } - - return result.users.edges.map((edge) => edge.node); + const result = await fetch( + `/api/find-owners?${new URLSearchParams({ + search: inputValue, + mode: "all-users", + })}` + ).then((r) => r.json()); + + const data = z + .array( + z.object({ + id: z.string(), + slug: z.string(), + }) + ) + .parse(result); + + return data; }; return ( @@ -60,7 +47,7 @@ export function SelectUser({ required={required} async loadOptions={loadOptions} - renderOption={(user) => user.username} + renderOption={(user) => user.slug} /> ); } diff --git a/packages/hub/src/graphql/queries/users.ts b/packages/hub/src/graphql/queries/users.ts deleted file mode 100644 index 35620d1620..0000000000 --- a/packages/hub/src/graphql/queries/users.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Prisma } from "@prisma/client"; - -import { builder } from "@/graphql/builder"; -import { prisma } from "@/prisma"; - -import { User } from "../types/User"; - -const UsersQueryInput = builder.inputType("UsersQueryInput", { - fields: (t) => ({ - usernameContains: t.string(), - }), -}); - -builder.queryField("users", (t) => - t.prismaConnection({ - type: User, - cursor: "id", - args: { - input: t.arg({ type: UsersQueryInput }), - }, - resolve: async (query, _, { input }) => { - const where: Prisma.UserWhereInput = { - ownerId: { not: null }, - }; - - if (input?.usernameContains) { - where.asOwner = { - slug: { - contains: input.usernameContains, - mode: "insensitive", - }, - }; - } - - return await prisma.user.findMany({ - ...query, - where, - }); - }, - }) -); diff --git a/packages/hub/src/graphql/schema.ts b/packages/hub/src/graphql/schema.ts index a2d2646ddf..d806af2979 100644 --- a/packages/hub/src/graphql/schema.ts +++ b/packages/hub/src/graphql/schema.ts @@ -15,7 +15,6 @@ import "./queries/relativeValuesDefinitions"; import "./queries/runSquiggle"; import "./queries/search"; import "./queries/userByUsername"; -import "./queries/users"; import "./mutations/acceptReusableGroupInviteToken"; import "./mutations/adminUpdateModelVersion"; import "./mutations/adminRebuildSearchIndex"; diff --git a/packages/hub/src/server/models/actions/moveModelAction.ts b/packages/hub/src/server/models/actions/moveModelAction.ts index 880a4785b1..eccb32d081 100644 --- a/packages/hub/src/server/models/actions/moveModelAction.ts +++ b/packages/hub/src/server/models/actions/moveModelAction.ts @@ -17,7 +17,7 @@ export const moveModelAction = makeServerAction( async (input) => { const session = await getSessionOrRedirect(); - let model = await getWriteableModel({ + const model = await getWriteableModel({ owner: input.oldOwner, slug: input.slug, session, diff --git a/packages/hub/src/server/owners/data.ts b/packages/hub/src/server/owners/data.ts new file mode 100644 index 0000000000..ef017bc8bf --- /dev/null +++ b/packages/hub/src/server/owners/data.ts @@ -0,0 +1,131 @@ +import "server-only"; + +import { auth } from "@/auth"; +import { prisma } from "@/prisma"; + +type OwnerForSelect = { + __typename: "User" | "Group"; + id: string; + slug: string; +}; + +// See also: app/api/find-owners/route.ts +export async function findOwnersForSelect(params: { + search: string; + // "my" means "me and my groups" + mode: "all" | "all-users" | "all-groups" | "my" | "my-groups"; +}): Promise { + const result: OwnerForSelect[] = []; + const session = await auth(); + + const selectUsers = params.mode === "all-users" || params.mode === "all"; + + const selectGroups = + params.mode === "all" || + params.mode === "all-groups" || + params.mode === "my-groups" || + params.mode === "my"; + + const myOnly = params.mode === "my" || params.mode === "my-groups"; + + if (myOnly && !session?.user.email) { + return []; + } + + if ( + (selectUsers || params.mode === "my") && + session?.user.id && + session?.user?.username && + (!params.search || session.user.username.match(params.search)) + ) { + result.push({ + __typename: "User" as const, + id: session.user.id, + slug: session.user.username ?? "", + }); + } + + if (selectUsers) { + const rows = await prisma.user.findMany({ + where: { + ownerId: { not: null }, + ...(params.search && { + asOwner: { + slug: { + contains: params.search, + mode: "insensitive", + }, + }, + }), + }, + select: { + id: true, + asOwner: { + select: { + slug: true, + }, + }, + }, + take: 20, + }); + + for (const row of rows) { + if (!row.asOwner) { + continue; + } + if (row.id === session?.user.id) { + // already added above + continue; + } + + result.push({ + __typename: "User" as const, + id: row.id, + slug: row.asOwner.slug, + }); + } + } + + if (selectGroups) { + const rows = await prisma.group.findMany({ + where: { + ...(params.search && { + asOwner: { + slug: { + contains: params.search, + mode: "insensitive", + }, + }, + }), + ...(myOnly + ? { + memberships: { + some: { + user: { email: session!.user.email }, + }, + }, + } + : {}), + }, + select: { + id: true, + asOwner: { + select: { + slug: true, + }, + }, + }, + take: 20, + }); + + result.push( + ...rows.map((row) => ({ + __typename: "Group" as const, + id: row.id, + slug: row.asOwner?.slug ?? "", + })) + ); + } + + return result; +}