feat(items): support drag-and-drop reordering and contextual insert
Implement drag-and-drop sorting in the items grid Add right-click context menu to insert new items before or after Update backend to process insertion anchors during item creation
This commit is contained in:
@@ -216,6 +216,8 @@ type ItemPayload = {
|
||||
acquisitionMethodIds: number[];
|
||||
tagIds: number[];
|
||||
imagePath: string;
|
||||
insertBeforeItemId: number | null;
|
||||
insertAfterItemId: number | null;
|
||||
};
|
||||
|
||||
type AncientArtifactPayload = {
|
||||
@@ -6477,9 +6479,15 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
||||
? null
|
||||
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
|
||||
const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId);
|
||||
const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId);
|
||||
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
||||
const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');
|
||||
|
||||
if (insertBeforeItemId !== null && insertAfterItemId !== null) {
|
||||
throw validationError('server.validation.invalidField');
|
||||
}
|
||||
|
||||
return {
|
||||
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
||||
details: cleanOptionalText(payload.details),
|
||||
@@ -6495,10 +6503,28 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||
isEventItem: Boolean(payload.isEventItem),
|
||||
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
||||
tagIds: cleanIds(payload.tagIds),
|
||||
imagePath: cleanUploadImagePath(payload.imagePath, 'items')
|
||||
imagePath: cleanUploadImagePath(payload.imagePath, 'items'),
|
||||
insertBeforeItemId,
|
||||
insertAfterItemId
|
||||
};
|
||||
}
|
||||
|
||||
function cleanOptionalPositiveInteger(value: unknown): number | null {
|
||||
if (value === null || value === '' || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return requirePositiveInteger(value, 'server.validation.invalidField');
|
||||
}
|
||||
|
||||
async function orderedItemIds(client: DbClient, isEventItem: boolean): Promise<number[]> {
|
||||
const rows = await client.query<{ id: number }>(
|
||||
'SELECT id FROM items WHERE is_event_item = $1 ORDER BY sort_order, id',
|
||||
[isEventItem]
|
||||
);
|
||||
return rows.rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise<void> {
|
||||
if (!noRecipe) {
|
||||
return;
|
||||
@@ -6573,6 +6599,28 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
||||
await replaceItemRelations(client, itemId, cleanPayload);
|
||||
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
|
||||
|
||||
if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) {
|
||||
const targetId = cleanPayload.insertBeforeItemId ?? cleanPayload.insertAfterItemId;
|
||||
if (targetId === null) {
|
||||
throw validationError('server.validation.invalidField');
|
||||
}
|
||||
|
||||
const orderedIds = await orderedItemIds(client, cleanPayload.isEventItem);
|
||||
const targetIndex = orderedIds.indexOf(targetId);
|
||||
if (targetIndex < 0) {
|
||||
throw validationError('server.validation.recordMissing');
|
||||
}
|
||||
|
||||
const insertedIndex = orderedIds.indexOf(itemId);
|
||||
if (insertedIndex >= 0) {
|
||||
orderedIds.splice(insertedIndex, 1);
|
||||
}
|
||||
|
||||
orderedIds.splice(targetIndex + (cleanPayload.insertAfterItemId !== null ? 1 : 0), 0, itemId);
|
||||
await reorderTableRows(client, 'items', 'items', orderedIds, userId);
|
||||
}
|
||||
|
||||
await recordEditLog(client, 'items', itemId, 'create', userId);
|
||||
return itemId;
|
||||
});
|
||||
|
||||
@@ -1796,9 +1796,21 @@ app.get('/api/items/:id', async (request, reply) => {
|
||||
|
||||
app.post('/api/items', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'items.create', 'wikiWrite');
|
||||
return user
|
||||
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||
: undefined;
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = request.body as Record<string, unknown>;
|
||||
const hasInsertAnchor =
|
||||
(payload.insertBeforeItemId !== undefined && payload.insertBeforeItemId !== null && payload.insertBeforeItemId !== '') ||
|
||||
(payload.insertAfterItemId !== undefined && payload.insertAfterItemId !== null && payload.insertAfterItemId !== '');
|
||||
|
||||
if (hasInsertAnchor && !userHasPermission(user, 'items.order')) {
|
||||
reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return reply.code(201).send(await createItem(payload, user.id, requestLocale(request)));
|
||||
});
|
||||
|
||||
app.put('/api/items/:id', async (request, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user