diff --git a/core/app/components/ContextEditor/ContextEditorClient.tsx b/core/app/components/ContextEditor/ContextEditorClient.tsx index bf01ece4b..4a62053ca 100644 --- a/core/app/components/ContextEditor/ContextEditorClient.tsx +++ b/core/app/components/ContextEditor/ContextEditorClient.tsx @@ -5,13 +5,14 @@ import dynamic from "next/dynamic"; import type { PubsId, PubTypesId } from "db/public"; import { Skeleton } from "ui/skeleton"; -import { cn } from "utils"; -import type { GetPubsResult, GetPubTypesResult } from "~/lib/server"; +import type { GetPubTypesResult } from "~/lib/server"; import { ContextAtom } from "./AtomRenderer"; import "context-editor/style.css"; +import type { ContextEditorPub } from "./ContextEditorContext"; + const ContextEditor = dynamic(() => import("context-editor").then((mod) => mod.ContextEditor), { ssr: false, loading: () => , @@ -28,7 +29,7 @@ export const ContextEditorClient = ({ disabled, hideMenu, }: { - pubs: GetPubsResult; + pubs: ContextEditorPub[]; pubTypes: GetPubTypesResult; pubId: PubsId; pubTypeId: PubTypesId; diff --git a/core/app/components/ContextEditor/ContextEditorContext.tsx b/core/app/components/ContextEditor/ContextEditorContext.tsx index c4c1894fa..35e24f78e 100644 --- a/core/app/components/ContextEditor/ContextEditorContext.tsx +++ b/core/app/components/ContextEditor/ContextEditorContext.tsx @@ -7,11 +7,10 @@ import { createContext, useContext, useState } from "react"; import type { ProcessedPub } from "contracts"; import type { PubsId, PubTypesId } from "db/public"; -import type { GetPubsResult, GetPubTypesResult } from "~/lib/server"; -import { processedPubsToPubsResult } from "~/lib/pubs"; +import type { GetPubTypesResult } from "~/lib/server"; export type ContextEditorContext = { - pubs: GetPubsResult; + pubs: ContextEditorPub[]; pubTypes: GetPubTypesResult; pubId?: PubsId; pubTypeId?: PubTypesId; @@ -22,21 +21,23 @@ const ContextEditorContext = createContext({ pubTypes: [], }); -type InputPub = ProcessedPub<{ withStage: true; withLegacyAssignee: true; withPubType: true }>; +export type ContextEditorPub = ProcessedPub<{ + withStage: true; + withLegacyAssignee: true; + withPubType: true; +}>; type Props = PropsWithChildren< Omit & { - pubs: InputPub[]; + pubs: ContextEditorPub[]; } >; export const ContextEditorContextProvider = (props: Props) => { const [cachedPubId] = useState(props.pubId); - const { children, pubId, ...value } = props; - - const contextPubs = processedPubsToPubsResult(value.pubs); + const { children, pubId, pubs, ...value } = props; return ( - + {children} ); diff --git a/core/app/components/forms/AddRelatedPubsPanel.tsx b/core/app/components/forms/AddRelatedPubsPanel.tsx index 8f6bb257e..86219482f 100644 --- a/core/app/components/forms/AddRelatedPubsPanel.tsx +++ b/core/app/components/forms/AddRelatedPubsPanel.tsx @@ -5,12 +5,11 @@ import type { ColumnDef } from "@tanstack/react-table"; import { useRef, useState } from "react"; import type { PubsId } from "db/public"; -import { pubFieldsIdSchema, pubsIdSchema } from "db/public"; import { Button } from "ui/button"; import { Checkbox } from "ui/checkbox"; import { DataTableColumnHeader } from "ui/data-table"; -import type { GetPubsResult } from "~/lib/server"; +import type { ContextEditorPub } from "../ContextEditor/ContextEditorContext"; import { PanelHeader, SidePanel } from "~/app/components/SidePanel"; import { getPubTitle } from "~/lib/pubs"; import { DataTable } from "../DataTable/v2/DataTable"; @@ -51,7 +50,7 @@ const getColumns = () => ); }, }, - ] as const satisfies ColumnDef[]; + ] as const satisfies ColumnDef[]; export const AddRelatedPubsPanel = ({ title, @@ -61,8 +60,8 @@ export const AddRelatedPubsPanel = ({ }: { title: string; onCancel: () => void; - onAdd: (pubs: GetPubsResult) => void; - pubs: GetPubsResult; + onAdd: (pubs: ContextEditorPub[]) => void; + pubs: ContextEditorPub[]; }) => { const sidebarRef = useRef(null); const [selected, setSelected] = useState>({}); @@ -77,7 +76,7 @@ export const AddRelatedPubsPanel = ({ }; return ( - +
d.id} />
-
+
diff --git a/core/app/components/forms/elements/RelatedPubsElement.tsx b/core/app/components/forms/elements/RelatedPubsElement.tsx index 8678acc0f..dc34c97bf 100644 --- a/core/app/components/forms/elements/RelatedPubsElement.tsx +++ b/core/app/components/forms/elements/RelatedPubsElement.tsx @@ -1,9 +1,20 @@ "use client"; +import type { DragEndEvent } from "@dnd-kit/core"; import type { FieldErrors } from "react-hook-form"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Value } from "@sinclair/typebox/value"; +import mudder from "mudder"; import { useFieldArray, useFormContext } from "react-hook-form"; import { relationBlockConfigSchema } from "schemas"; @@ -11,14 +22,14 @@ import type { JsonValue } from "contracts"; import type { InputComponent, PubsId } from "db/public"; import { Button } from "ui/button"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; -import { Pencil, Plus, Trash, TriangleAlert } from "ui/icon"; +import { GripVertical, Pencil, Plus, Trash, TriangleAlert } from "ui/icon"; import { MultiBlock } from "ui/multiblock"; import { Popover, PopoverContent, PopoverTrigger } from "ui/popover"; import { cn } from "utils"; +import type { ContextEditorPub } from "../../ContextEditor/ContextEditorContext"; import type { PubFieldFormElementProps } from "../PubFieldFormElement"; import type { ElementProps } from "../types"; -import type { GetPubsResult } from "~/lib/server"; import { AddRelatedPubsPanel } from "~/app/components/forms/AddRelatedPubsPanel"; import { getPubTitle } from "~/lib/pubs"; import { useContextEditorContext } from "../../ContextEditor/ContextEditorContext"; @@ -26,26 +37,40 @@ import { useFormElementToggleContext } from "../FormElementToggleContext"; import { PubFieldFormElement } from "../PubFieldFormElement"; const RelatedPubBlock = ({ + id, pub, onRemove, valueComponentProps, slug, onBlur, }: { - pub: GetPubsResult[number]; + id: string; + pub: ContextEditorPub; onRemove: () => void; valueComponentProps: PubFieldFormElementProps; slug: string; onBlur?: () => void; }) => { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id, + }); + + const style = { + transform: CSS.Translate.toString(transform), + transition, + }; return ( -
+
{/* Max width to keep long 'value's truncated. 90% to leave room for the trash button */}
{getPubTitle(pub)}
-
+
+
+ +
); }; -type FieldValue = { value: JsonValue; relatedPubId: PubsId }; +type FieldValue = { value: JsonValue; relatedPubId: PubsId; rank: string }; type FormValue = { [slug: string]: FieldValue[]; }; @@ -165,7 +202,49 @@ export const RelatedPubsElement = ({ const formElementToggle = useFormElementToggleContext(); const isEnabled = formElementToggle.isEnabled(slug); - const { fields, append, remove } = useFieldArray({ control, name: slug }); + const { fields, append, remove, move, update } = useFieldArray({ control, name: slug }); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // Update ranks and rhf field array position when elements are dragged + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over?.id) { + // activeIndex is the position the element started at and over is where it was + // dropped + const activeIndex = active.data.current?.sortable?.index; + const overIndex = over.data.current?.sortable?.index; + if (activeIndex !== undefined && overIndex !== undefined) { + // "earlier" means towards the beginning of the list, or towards the top of the page + const isMovedEarlier = activeIndex > overIndex; + const activeElem = fields[activeIndex]; + + // When moving an element earlier in the array, find a rank between the rank of the + // element at the dropped position and the element before it. When moving an element + // later, instead find a rank between that element and the element after it + const aboveRank = + fields[isMovedEarlier ? overIndex : overIndex + 1]?.rank ?? ""; + const belowRank = + fields[isMovedEarlier ? overIndex - 1 : overIndex]?.rank ?? ""; + const [rank] = mudder.base62.mudder(belowRank, aboveRank, 1); + + // move doesn't trigger a rerender, so it's safe to chain these calls + move(activeIndex, overIndex); + update(overIndex, { + ...activeElem, + rank, + }); + } + } + }, + [fields] + ); Value.Default(relationBlockConfigSchema, config); if (!Value.Check(relationBlockConfigSchema, config)) { @@ -178,7 +257,7 @@ export const RelatedPubsElement = ({ acc[pub.id] = pub; return acc; }, - {} as Record + {} as Record ); }, [pubs]); @@ -193,12 +272,22 @@ export const RelatedPubsElement = ({ control={control} name={slug} render={({ field }) => { - const handleAddPubs = (newPubs: GetPubsResult) => { - const values = newPubs.map((p) => ({ relatedPubId: p.id, value: null })); + const handleAddPubs = (newPubs: ContextEditorPub[]) => { + const ranks = mudder.base62.mudder( + field.value[field.value.length - 1]?.rank, + "", + newPubs.length + ); + const values = newPubs.map((p, i) => ({ + relatedPubId: p.id, + value: null, + rank: ranks[i], + })); for (const value of values) { append(value); } }; + return ( {showPanel && ( @@ -219,25 +308,42 @@ export const RelatedPubsElement = ({ > {fields.length ? (
- {fields.map((item, index) => { - const handleRemovePub = () => { - remove(index); - }; - const innerSlug = - `${slug}.${index}.value` as const; - return ( - - ); - })} + + + {fields.map((item, index) => { + const handleRemovePub = () => { + remove(index); + }; + const innerSlug = + `${slug}.${index}.value` as const; + return ( + + ); + })} + +
) : null} diff --git a/core/lib/pubs.ts b/core/lib/pubs.ts index b1866a43a..3054b533c 100644 --- a/core/lib/pubs.ts +++ b/core/lib/pubs.ts @@ -1,6 +1,4 @@ -import type { JsonValue, ProcessedPub } from "contracts"; - -import type { GetPubsResult } from "./server"; +import type { ProcessedPub } from "contracts"; export type PubTitleProps = { title?: string | null; @@ -47,49 +45,6 @@ export const getPubTitle = (pub: PubTitleProps): string => { type InputPub = ProcessedPub<{ withStage: true; withLegacyAssignee: true; withPubType: true }>; -/** - * this is a bridge function for places where we still use the `{ slug: value }` pubvalues shape, rather than an array - * this is eg the case in the contexteditor at the time of writing (2024-12-18) - */ -export const processedPubToPubResult = (pub: T): GetPubsResult[number] => { - return { - ...pub, - values: pub.values.reduce( - (acc, value) => { - const existingValue = acc[value.fieldSlug] as JsonValue | JsonValue[] | undefined; - - if (!value?.relatedPubId) { - acc[value.fieldSlug] = value.value as JsonValue; - return acc; - } - - const existingVal = existingValue - ? Array.isArray(existingValue) - ? existingValue - : [existingValue] - : []; - - acc[value.fieldSlug] = [ - ...existingVal, - { relatedPubId: value.relatedPubId, value: value.value as JsonValue }, - ]; - - return acc; - }, - {} as GetPubsResult[number]["values"] - ), - stages: pub.stage ? [pub.stage] : [], - assigneeId: pub.assignee?.id ?? null, - assignee: (pub.assignee ?? null) as GetPubsResult[number]["assignee"], - pubType: pub.pubType as GetPubsResult[number]["pubType"], - children: pub.children.length ? pub.children.map(processedPubToPubResult) : [], - }; -}; - -export const processedPubsToPubsResult = (pubs: InputPub[]): GetPubsResult => { - return pubs.map(processedPubToPubResult); -}; - export const getTitleField = ( pub: T ): T["pubType"]["fields"][number] | undefined => pub.pubType.fields.find((field) => field.isTitle); diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 837778cdb..82bd09ab8 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -15,7 +15,6 @@ import mudder from "mudder"; import type { CreatePubRequestBodyWithNullsNew, FTSReturn, - GetPubResponseBody, Json, JsonValue, MaybePubOptions, @@ -42,7 +41,7 @@ import { Capabilities, CoreSchemaType, MemberRole, MembershipType, OperationType import { logger } from "logger"; import { assert, expect } from "utils"; -import type { DefinitelyHas, MaybeHas, Prettify, XOR } from "../types"; +import type { DefinitelyHas, MaybeHas, XOR } from "../types"; import type { SafeUser } from "./user"; import { db } from "~/kysely/database"; import { env } from "../env/env.mjs"; @@ -177,75 +176,6 @@ export const nestChildren = (pub: T): NestedPub => { return pubsMap.get(pub.id); }; -// TODO: make this usable in a subquery, possibly by turning it into a view -// Create a CTE ("children") with the pub's children and their values -const withPubChildren = ({ - pubId, - pubIdRef, - communityId, - stageId, -}: { - pubId?: PubsId; - pubIdRef?: StringReference; - communityId?: CommunitiesId; - stageId?: StagesId; -}) => { - const { ref } = db.dynamic; - - return db.withRecursive("children", (qc) => { - return qc - .selectFrom("pubs") - .select((eb) => [ - "id", - "parentId", - "pubTypeId", - "assigneeId", - pubValuesByRef("pubs.id"), - pubType({ eb, pubTypeIdRef: "pubs.pubTypeId" }), - ]) - .$if(!!pubId, (qb) => qb.where("pubs.parentId", "=", pubId!)) - .$if(!!pubIdRef, (qb) => qb.whereRef("pubs.parentId", "=", ref(pubIdRef!))) - .$if(!!communityId, (qb) => - qb.where("pubs.communityId", "=", communityId!).where("pubs.parentId", "is", null) - ) - .$if(!!stageId, (qb) => - qb - .innerJoin("PubsInStages", "pubs.id", "PubsInStages.pubId") - .where("PubsInStages.stageId", "=", stageId!) - ) - .unionAll((eb) => { - return eb - .selectFrom("pubs") - .innerJoin("children", "pubs.parentId", "children.id") - .select([ - "pubs.id", - "pubs.parentId", - "pubs.pubTypeId", - "pubs.assigneeId", - pubValuesByRef("pubs.id"), - pubType({ eb, pubTypeIdRef: "pubs.pubTypeId" }), - ]); - }); - }); -}; - -const pubAssignee = (eb: ExpressionBuilder) => - jsonObjectFrom( - eb - .selectFrom("users") - .whereRef("users.id", "=", "pubs.assigneeId") - .select([ - "users.id", - "slug", - "firstName", - "lastName", - "avatar", - "createdAt", - "email", - "communityId", - ]) - ).as("assignee"); - const pubColumns = [ "id", "communityId", @@ -258,73 +188,6 @@ const pubColumns = [ "title", ] as const satisfies SelectExpression[]; -export const getPubBase = ( - props: - | { pubId: PubsId; communityId?: never; stageId?: never } - | { pubId?: never; communityId: CommunitiesId; stageId?: never } - | { - pubId?: never; - communityId?: never; - stageId: StagesId; - } -) => - withPubChildren(props) - .selectFrom("pubs") - .select((eb) => [ - ...pubColumns, - pubType({ eb, pubTypeIdRef: "pubs.pubTypeId" }), - pubAssignee(eb), - jsonArrayFrom( - eb - .selectFrom("PubsInStages") - .select(["PubsInStages.stageId as id"]) - .whereRef("PubsInStages.pubId", "=", "pubs.id") - ).as("stages"), - jsonArrayFrom( - eb - .selectFrom("children") - .select((eb) => [ - ...pubColumns, - "children.values", - "children.pubType", - jsonArrayFrom( - eb - .selectFrom("PubsInStages") - .select(["PubsInStages.stageId as id"]) - .whereRef("PubsInStages.pubId", "=", "children.id") - ).as("stages"), - ]) - .$narrowType<{ values: PubValues }>() - ).as("children"), - ]) - .$if(!!props.pubId, (eb) => eb.select(pubValuesByVal(props.pubId!))) - .$if(!props.pubId, (eb) => eb.select(pubValuesByRef("pubs.id"))) - .$narrowType<{ values: PubValues }>(); - -export const _deprecated_getPub = async (pubId: PubsId): Promise => { - const pub = await getPubBase({ pubId }).where("pubs.id", "=", pubId).executeTakeFirst(); - - if (!pub) { - throw PubNotFoundError; - } - - return nestChildren(pub); -}; - -export const _deprecated_getPubCached = async (pubId: PubsId) => { - const pub = await autoCache( - getPubBase({ pubId }).where("pubs.id", "=", pubId) - ).executeTakeFirst(); - - if (!pub) { - throw PubNotFoundError; - } - - return nestChildren(pub); -}; - -export type GetPubResult = Prettify>>; - export type GetManyParams = { limit?: number; offset?: number; @@ -353,43 +216,6 @@ export const GET_MANY_DEFAULT = { onlyParents: true, } as const; -const GET_PUBS_DEFAULT = { - ...GET_MANY_DEFAULT, - select: pubColumns, -} as const; - -/** - * Get a nested array of pubs and their children - * - * Either per community, or per stage - */ -export const _deprecated_getPubs = async ( - props: XOR<{ communityId: CommunitiesId }, { stageId: StagesId }>, - params: GetManyParams = GET_PUBS_DEFAULT -) => { - const { limit, offset, orderBy, orderDirection } = { ...GET_PUBS_DEFAULT, ...params }; - - const pubs = await autoCache( - getPubBase(props) - .$if(Boolean(props.communityId), (eb) => - eb.where("pubs.communityId", "=", props.communityId!) - ) - .$if(Boolean(props.stageId), (eb) => - eb - .innerJoin("PubsInStages", "pubs.id", "PubsInStages.pubId") - .where("PubsInStages.stageId", "=", props.stageId!) - ) - .$if(Boolean(params.onlyParents), (eb) => eb.where("pubs.parentId", "is", null)) - .limit(limit) - .offset(offset) - .orderBy(orderBy, orderDirection) - ).execute(); - - return pubs.map(nestChildren); -}; - -export type GetPubsResult = Prettify>>; - const PubNotFoundError = new NotFoundError("Pub not found"); /** @@ -1919,6 +1745,7 @@ export async function getPubsWithRelatedValuesAndChildren< "pv.id as id", "pv.fieldId", "pv.value", + "pv.rank", "pv.relatedPubId", "pv.createdAt as createdAt", "pv.updatedAt as updatedAt",