Skip to content

Commit 37b7e5f

Browse files
committed
feat: contributors per milestone ordering
1 parent 53d57df commit 37b7e5f

8 files changed

Lines changed: 246 additions & 14 deletions

File tree

src/features/abstract-resource-form/components/arf-relation-input.tsx

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ReactNode } from "react";
12
import { get } from "react-hook-form";
23
import type { Control } from "react-hook-form";
34
import { toast } from "sonner";
@@ -36,11 +37,13 @@ import { sanitizeId, toTitleCase } from "@/utils";
3637
import { useArfRelation } from "../hooks/use-arf-relation";
3738
import { useArfRelationMutation } from "../hooks/use-arf-relation-mutation";
3839
import { useArfSheet } from "../hooks/use-arf-sheet";
40+
import { getItemPivotOrder } from "../utils/get-item-pivot-order";
3941
import { getMutationConfig } from "../utils/get-mutation-config";
4042
import { isExistingItem } from "../utils/is-existing-item";
4143
import { ArfInput } from "./arf-input";
4244
import { ArfPivotData } from "./arf-pivot-data";
4345
import { OrderableMultiSelect } from "./orderable-multi-select";
46+
import { OrderablePivotMultiSelect } from "./orderable-pivot-multi-select";
4447

4548
export function ArfRelationInput<
4649
T extends Resource,
@@ -145,11 +148,23 @@ export function ArfRelationInput<
145148
const isRelationOrderable =
146149
isOrderableResource(resourceRelation) &&
147150
relationDefinition.type !== RelationType.ManyToMany;
151+
const isPivotOrderable =
152+
relationDefinition.type === RelationType.ManyToMany &&
153+
relationDefinition.orderable === true;
148154
const sortedQueriedRelations = isRelationOrderable
149155
? [...queriedRelations].toSorted((a, b) =>
150156
"order" in a && "order" in b ? a.order - b.order : 1,
151157
)
152-
: queriedRelations;
158+
: isPivotOrderable
159+
? [...queriedRelations].toSorted((a, b) => {
160+
const orderA = getItemPivotOrder(a);
161+
const orderB = getItemPivotOrder(b);
162+
if (orderA !== undefined && orderB !== undefined) {
163+
return orderA - orderB;
164+
}
165+
return 1;
166+
})
167+
: queriedRelations;
153168
const selectedValues = sortedQueriedRelations.flatMap((item) => {
154169
const value = get(item, primaryKeyField, null) as ResourcePk | null;
155170
return value == null ? [] : String(value);
@@ -264,16 +279,29 @@ export function ArfRelationInput<
264279
defaultValue: [...new Set(selectedValues)],
265280
} satisfies React.ComponentProps<typeof MultiSelect>;
266281

267-
const multiselect = isRelationOrderable ? (
268-
<OrderableMultiSelect
269-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- type guard doesn't narrow generics
270-
resourceRelation={resourceRelation as OrderableResource}
271-
items={sortedQueriedRelations as ResourceDataType<OrderableResource>[]}
272-
multiSelectProps={multiSelectProps}
273-
/>
274-
) : (
275-
<MultiSelect {...multiSelectProps} />
276-
);
282+
let multiselect: ReactNode;
283+
if (isRelationOrderable) {
284+
multiselect = (
285+
<OrderableMultiSelect
286+
resourceRelation={resourceRelation as OrderableResource}
287+
items={sortedQueriedRelations as ResourceDataType<OrderableResource>[]}
288+
multiSelectProps={multiSelectProps}
289+
/>
290+
);
291+
} else if (isPivotOrderable && relationDefinition.pivotData != null) {
292+
multiselect = (
293+
<OrderablePivotMultiSelect
294+
resource={resource}
295+
resourceRelation={resourceRelation}
296+
endpoint={endpoint}
297+
pivotData={relationDefinition.pivotData}
298+
items={sortedQueriedRelations}
299+
multiSelectProps={multiSelectProps}
300+
/>
301+
);
302+
} else {
303+
multiselect = <MultiSelect {...multiSelectProps} />;
304+
}
277305
return relationDefinition.type === RelationType.ManyToMany ? (
278306
// TODO: allow m:n relation inputs to be immutable
279307
<Label className="flex-col items-start">

src/features/abstract-resource-form/components/orderable-multi-select.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

3-
import { useRef } from "react";
3+
import { arrayMove } from "@dnd-kit/sortable";
4+
import { useEffect, useRef } from "react";
45

56
import { MultiSelect } from "@/components/ui/multi-select";
67
import { calculateNewSortValue } from "@/features/abstract-resource-list";
@@ -23,7 +24,10 @@ export function OrderableMultiSelect<T extends OrderableResource>({
2324
multiSelectProps,
2425
}: OrderableMultiSelectProps<T>) {
2526
const itemsRef = useRef(items);
26-
itemsRef.current = items;
27+
28+
useEffect(() => {
29+
itemsRef.current = items;
30+
}, [items]);
2731

2832
const { mutateOrder } = useRelationOrderMutation({ resourceRelation });
2933

@@ -33,6 +37,9 @@ export function OrderableMultiSelect<T extends OrderableResource>({
3337
oldIndex,
3438
newIndex,
3539
);
40+
41+
itemsRef.current = arrayMove(itemsRef.current, oldIndex, newIndex);
42+
3643
void mutateOrder(id, order);
3744
};
3845

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client";
2+
3+
import { arrayMove } from "@dnd-kit/sortable";
4+
import { useEffect, useRef } from "react";
5+
import type { ComponentProps } from "react";
6+
7+
import { MultiSelect } from "@/components/ui/multi-select";
8+
import { calculateNewSortValue } from "@/features/abstract-resource-list";
9+
import type { Resource } from "@/features/resources";
10+
import type {
11+
OrderableResource,
12+
PivotDataDefinition,
13+
ResourceDataType,
14+
ResourcePk,
15+
ResourceRelation,
16+
} from "@/features/resources/types";
17+
18+
import { usePivotRelationOrderMutation } from "../hooks/use-pivot-relation-order-mutation";
19+
import { getItemPivotOrder, hasMeta } from "../utils/get-item-pivot-order";
20+
21+
export function OrderablePivotMultiSelect<
22+
T extends Resource,
23+
L extends ResourceRelation<T>,
24+
>({
25+
resource,
26+
resourceRelation,
27+
endpoint,
28+
pivotData,
29+
items,
30+
multiSelectProps,
31+
}: {
32+
resource: T;
33+
resourceRelation: L;
34+
endpoint: string;
35+
pivotData: PivotDataDefinition;
36+
items: ResourceDataType<L>[];
37+
multiSelectProps: ComponentProps<typeof MultiSelect>;
38+
}) {
39+
const itemsRef = useRef(items);
40+
41+
useEffect(() => {
42+
itemsRef.current = items;
43+
}, [items]);
44+
45+
const { mutateOrder } = usePivotRelationOrderMutation({
46+
resource,
47+
resourceRelation,
48+
endpoint,
49+
});
50+
51+
const handleReorder = (id: string, oldIndex: number, newIndex: number) => {
52+
const mappedItems = itemsRef.current.map((item) => ({
53+
...item,
54+
order: getItemPivotOrder(item) ?? 0,
55+
})) as ResourceDataType<OrderableResource>[];
56+
57+
const order = calculateNewSortValue(mappedItems, oldIndex, newIndex);
58+
59+
const originalItem = itemsRef.current.find(
60+
(item) => String(item.id) === id,
61+
);
62+
if (originalItem == null) {
63+
return;
64+
}
65+
66+
const meta = hasMeta(originalItem) ? originalItem.meta : undefined;
67+
const pivotKey = `pivot_${pivotData.field}`;
68+
const pivotValue = meta?.[pivotKey];
69+
70+
if (pivotValue == null) {
71+
return;
72+
}
73+
74+
const reorderedItems = arrayMove(itemsRef.current, oldIndex, newIndex);
75+
76+
itemsRef.current = reorderedItems.map((item) => {
77+
if (String(item.id) === id) {
78+
return {
79+
...item,
80+
meta: {
81+
...(hasMeta(item) ? item.meta : {}),
82+
pivot_order: order,
83+
},
84+
};
85+
}
86+
return item;
87+
});
88+
89+
void mutateOrder(id as ResourcePk, order, {
90+
[pivotData.field]: pivotValue,
91+
});
92+
};
93+
94+
return <MultiSelect {...multiSelectProps} onReorder={handleReorder} />;
95+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"use client";
2+
3+
import { toast } from "sonner";
4+
5+
import { fetchMutation, useMutationWrapper } from "@/features/backend";
6+
import type { ModifyResourceResponse } from "@/features/backend/types";
7+
import { getResourceQueryName } from "@/features/resources";
8+
import type { Resource } from "@/features/resources";
9+
import type {
10+
ResourcePk,
11+
ResourceRelation,
12+
XToManyResource,
13+
} from "@/features/resources/types";
14+
import { getToastMessages } from "@/lib/get-toast-messages";
15+
import { camelToSnakeCase, sanitizeId } from "@/utils";
16+
17+
import { useArfRelation } from "./use-arf-relation";
18+
19+
interface PivotOrderMutationVariables {
20+
id: ResourcePk;
21+
order: number;
22+
pivotKeys: Record<string, unknown>;
23+
}
24+
25+
export function usePivotRelationOrderMutation<T extends Resource>({
26+
resource,
27+
resourceRelation,
28+
endpoint,
29+
}: {
30+
resource: T;
31+
resourceRelation: ResourceRelation<T>;
32+
endpoint: string;
33+
}) {
34+
const relationContext = useArfRelation();
35+
36+
const mutation = useMutationWrapper<
37+
ModifyResourceResponse<T>,
38+
PivotOrderMutationVariables
39+
>(
40+
`update__${resource}__relation_order__${relationContext?.childResource ?? "unknown"}`,
41+
async ({ id, order, pivotKeys }) => {
42+
const queryName = getResourceQueryName(
43+
resourceRelation as XToManyResource,
44+
);
45+
const pathSegment = camelToSnakeCase(queryName);
46+
const response = await fetchMutation<ModifyResourceResponse<T>>(
47+
`${endpoint}/${pathSegment}/${sanitizeId(id)}`,
48+
{
49+
method: "PATCH",
50+
resource,
51+
body: {
52+
query: pivotKeys,
53+
update: { order },
54+
},
55+
},
56+
);
57+
return response;
58+
},
59+
);
60+
61+
const mutateOrder = async (
62+
id: ResourcePk,
63+
order: number,
64+
pivotKeys: Record<string, unknown>,
65+
) =>
66+
toast
67+
.promise(
68+
mutation.mutateAsync({ id, order, pivotKeys }),
69+
getToastMessages.resource(resourceRelation).modify,
70+
)
71+
.unwrap();
72+
73+
return { mutateOrder, mutation };
74+
}

src/features/abstract-resource-form/types/internal.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,9 @@ export interface PersistedFormData<T extends Resource> {
8080
values: ResourceFormValues<T>;
8181
timestamp: number;
8282
}
83+
84+
export interface ItemWithPivotOrder {
85+
meta: {
86+
pivot_order: number;
87+
};
88+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ItemWithPivotOrder } from "../types/internal";
2+
3+
export const hasMeta = (
4+
item: unknown,
5+
): item is { meta: Record<string, unknown> } =>
6+
typeof item === "object" &&
7+
item !== null &&
8+
"meta" in item &&
9+
typeof item.meta === "object" &&
10+
item.meta !== null;
11+
12+
export const hasPivotOrder = (item: unknown): item is ItemWithPivotOrder =>
13+
hasMeta(item) && "pivot_order" in item.meta;
14+
15+
export const getItemPivotOrder = (item: unknown): number | undefined => {
16+
if (hasPivotOrder(item) && typeof item.meta.pivot_order === "number") {
17+
return item.meta.pivot_order;
18+
}
19+
20+
return undefined;
21+
};

src/features/resources/data/resource-metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,6 @@ export const RESOURCE_METADATA = {
386386
[Resource.Contributors]: {
387387
queryName: "contributors",
388388
apiPath: "contributors",
389-
orderable: true,
390389
itemMapper: (item) => ({
391390
name: item.name,
392391
}),
@@ -722,6 +721,7 @@ export const RESOURCE_METADATA = {
722721
relationInputs: {
723722
[Resource.Contributors]: {
724723
type: RelationType.ManyToMany,
724+
orderable: true,
725725
pivotData: {
726726
field: "role_id",
727727
relatedResource: Resource.Roles,

src/features/resources/types/relations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface PivotRelationDefinition {
5454
foreignKey?: never;
5555
label?: DeclinableNoun;
5656
pivotData?: PivotDataDefinition;
57+
orderable?: boolean;
5758
}
5859
/** Relation definitions between T and L, where T is the main resource and L is the related resource. */
5960
export type RelationDefinition<T extends Resource, L extends Resource> =

0 commit comments

Comments
 (0)