Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow ordering of related pub values #1014

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions core/app/components/ContextEditor/ContextEditorClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <Skeleton className="h-16 w-full" />,
Expand All @@ -28,7 +29,7 @@ export const ContextEditorClient = ({
disabled,
hideMenu,
}: {
pubs: GetPubsResult;
pubs: ContextEditorPub[];
pubTypes: GetPubTypesResult;
pubId: PubsId;
pubTypeId: PubTypesId;
Expand Down
19 changes: 10 additions & 9 deletions core/app/components/ContextEditor/ContextEditorContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,21 +21,23 @@ const ContextEditorContext = createContext<ContextEditorContext>({
pubTypes: [],
});

type InputPub = ProcessedPub<{ withStage: true; withLegacyAssignee: true; withPubType: true }>;
export type ContextEditorPub = ProcessedPub<{
withStage: true;
withLegacyAssignee: true;
withPubType: true;
}>;
type Props = PropsWithChildren<
Omit<ContextEditorContext, "pubs"> & {
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 (
<ContextEditorContext.Provider value={{ ...value, pubs: contextPubs, pubId: cachedPubId }}>
<ContextEditorContext.Provider value={{ ...value, pubs, pubId: cachedPubId }}>
{children}
</ContextEditorContext.Provider>
);
Expand Down
13 changes: 6 additions & 7 deletions core/app/components/forms/AddRelatedPubsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,7 +50,7 @@ const getColumns = () =>
);
},
},
] as const satisfies ColumnDef<GetPubsResult[number], unknown>[];
] as const satisfies ColumnDef<ContextEditorPub, unknown>[];

export const AddRelatedPubsPanel = ({
title,
Expand All @@ -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<Record<string, boolean>>({});
Expand All @@ -77,7 +76,7 @@ export const AddRelatedPubsPanel = ({
};

return (
<SidePanel ref={sidebarRef} className="justify-between">
<SidePanel ref={sidebarRef}>
<div className="flex flex-col gap-2">
<PanelHeader title={title} showCancel onCancel={onCancel} />
<DataTable
Expand All @@ -88,7 +87,7 @@ export const AddRelatedPubsPanel = ({
getRowId={(d) => d.id}
/>
</div>
<div className="flex w-full justify-between gap-2">
<div className="mt-auto flex w-full justify-between gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={onCancel}>
Cancel
</Button>
Expand Down
166 changes: 136 additions & 30 deletions core/app/components/forms/elements/RelatedPubsElement.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,76 @@
"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";

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";
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 (
<div className="flex items-center justify-between rounded border border-l-[12px] border-l-emerald-100 p-3">
<div
ref={setNodeRef}
style={style}
className="flex items-center justify-start rounded border border-l-[12px] border-l-emerald-100 p-3"
>
{/* Max width to keep long 'value's truncated. 90% to leave room for the trash button */}
<div className="flex max-w-[90%] flex-col items-start gap-1 text-sm">
<span className="font-semibold">{getPubTitle(pub)}</span>
<ConfigureRelatedValue {...valueComponentProps} slug={slug} onBlur={onBlur} />
</div>
<div>
<div className="ml-auto">
<Button
type="button"
variant="ghost"
Expand All @@ -56,11 +81,23 @@ const RelatedPubBlock = ({
<Trash size={24} />
</Button>
</div>
<div>
<Button
type="button"
aria-label="Drag handle"
variant="ghost"
className="p-2"
{...listeners}
{...attributes}
>
<GripVertical size={24} className="text-neutral-400" />
</Button>
</div>
</div>
);
};

type FieldValue = { value: JsonValue; relatedPubId: PubsId };
type FieldValue = { value: JsonValue; relatedPubId: PubsId; rank: string };
type FormValue = {
[slug: string]: FieldValue[];
};
Expand Down Expand Up @@ -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)) {
Expand All @@ -178,7 +257,7 @@ export const RelatedPubsElement = ({
acc[pub.id] = pub;
return acc;
},
{} as Record<string, GetPubsResult[number]>
{} as Record<string, ContextEditorPub>
);
}, [pubs]);

Expand All @@ -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 (
<FormItem data-testid={`related-pubs-${label}`}>
{showPanel && (
Expand All @@ -219,25 +308,42 @@ export const RelatedPubsElement = ({
>
{fields.length ? (
<div className="flex flex-col gap-2">
{fields.map((item, index) => {
const handleRemovePub = () => {
remove(index);
};
const innerSlug =
`${slug}.${index}.value` as const;
return (
<RelatedPubBlock
key={item.id}
pub={pubsById[item.relatedPubId]}
onRemove={handleRemovePub}
slug={innerSlug}
valueComponentProps={
valueComponentProps
}
onBlur={field.onBlur}
/>
);
})}
<DndContext
modifiers={[
restrictToVerticalAxis,
restrictToParentElement,
]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={fields}
strategy={verticalListSortingStrategy}
>
{fields.map((item, index) => {
const handleRemovePub = () => {
remove(index);
};
const innerSlug =
`${slug}.${index}.value` as const;
return (
<RelatedPubBlock
key={item.id}
id={item.id}
pub={
pubsById[item.relatedPubId]
}
onRemove={handleRemovePub}
slug={innerSlug}
valueComponentProps={
valueComponentProps
}
onBlur={field.onBlur}
/>
);
})}
</SortableContext>
</DndContext>
</div>
) : null}
</MultiBlock>
Expand Down
Loading
Loading