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",