Skip to content

Commit

Permalink
Add Item ordering (#132)
Browse files Browse the repository at this point in the history
* backend changes for new display order

* update frontend alter display order

* handle errors

* emit events

* fix issues

* hide reorder button for non-owners

* bit of cleanup

* fix lint issue
  • Loading branch information
cmintey authored Aug 27, 2024
1 parent 7032c2d commit a9d404e
Show file tree
Hide file tree
Showing 17 changed files with 380 additions and 108 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"pulltorefreshjs": "^0.1.22",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
"svelte-dnd-action": "^0.9.50",
"svelte-preprocess": "^6.0.2",
"tailwindcss": "^3.4.10",
"ts-node": "^10.9.2",
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "items" ADD COLUMN "displayOrder" INTEGER;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ model Item {
purchased Boolean @default(false)
group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
groupId String?
displayOrder Int?
@@index([userId])
@@index([pledgedById])
Expand Down
6 changes: 5 additions & 1 deletion src/lib/api/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class ItemAPI {
}

export class ItemsAPI {
_makeRequest = async (method: string, path: string, body?: Record<string, unknown>) => {
_makeRequest = async (method: string, path: string, body?: any) => {
const options: RequestInit = {
method,
headers: {
Expand All @@ -72,4 +72,8 @@ export class ItemsAPI {
if (claimed) searchParams.append("claimed", `${claimed}`);
return await this._makeRequest("DELETE", "?" + searchParams.toString());
};

updateMany = async (items: (Record<string, unknown> & { id: number })[]) => {
return await this._makeRequest("PATCH", "", items);
};
}
49 changes: 31 additions & 18 deletions src/lib/components/wishlists/ItemCard/ItemCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@
import ApprovalButtons from "./ApprovalButtons.svelte";
import ClaimButtons from "./ClaimButtons.svelte";
import { invalidateAll } from "$app/navigation";
import type { ItemVoidFunction } from "./ReorderButtons.svelte";
import ReorderButtons from "./ReorderButtons.svelte";
export let item: FullItem;
export let user: (PartialUser & { id: string }) | undefined = undefined;
export let showClaimedName = false;
export let showFor = false;
export let onPublicList = false;
export let reorderActions = false;
export let onIncreasePriority: ItemVoidFunction | undefined = undefined;
export let onDecreasePriority: ItemVoidFunction | undefined = undefined;
const modalStore = getModalStore();
const toastStore = getToastStore();
Expand Down Expand Up @@ -222,23 +227,31 @@
</div>
</div>

<footer class="card-footer flex flex-row justify-between">
<ClaimButtons
{item}
{onPublicList}
showName={showClaimedName}
{user}
on:claim={() => handleClaim()}
on:unclaim={() => handleClaim(true)}
on:purchase={(event) => handlePurchased(event.detail.purchased)}
/>

<ApprovalButtons
{item}
{user}
on:approve={() => handleApproval(true)}
on:deny={() => handleApproval(false)}
on:delete={handleDelete}
/>
<footer
class="card-footer flex flex-row"
class:justify-between={!reorderActions}
class:justify-center={reorderActions}
>
{#if reorderActions}
<ReorderButtons {item} {onDecreasePriority} {onIncreasePriority} />
{:else}
<ClaimButtons
{item}
{onPublicList}
showName={showClaimedName}
{user}
on:claim={() => handleClaim()}
on:unclaim={() => handleClaim(true)}
on:purchase={(event) => handlePurchased(event.detail.purchased)}
/>

<ApprovalButtons
{item}
{user}
on:approve={() => handleApproval(true)}
on:deny={() => handleApproval(false)}
on:delete={handleDelete}
/>
{/if}
</footer>
</button>
37 changes: 37 additions & 0 deletions src/lib/components/wishlists/ItemCard/ReorderButtons.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script context="module" lang="ts">
export type ItemVoidFunction = (itemId: number) => void;
</script>

<script lang="ts">
import { dragHandle } from "svelte-dnd-action";
import type { FullItem } from "./ItemCard.svelte";
export let item: FullItem;
export let onIncreasePriority: ItemVoidFunction | undefined = undefined;
export let onDecreasePriority: ItemVoidFunction | undefined = undefined;
</script>

<div class="w-max space-x-4">
<button
class="variant-outline-primary btn btn-icon btn-icon-sm md:btn-icon"
aria-label="lower priority for {item.name}"
on:click|stopPropagation={() => onDecreasePriority && onDecreasePriority(item.id)}
>
<iconify-icon icon="ion:arrow-down"></iconify-icon>
</button>
<button
class="variant-outline-primary btn md:btn-lg"
aria-label="drag-handle for {item.name}"
on:click|stopPropagation={() => {}}
use:dragHandle
>
<iconify-icon icon="ion:reorder-two"></iconify-icon>
</button>
<button
class="variant-outline-primary btn btn-icon btn-icon-sm md:btn-icon"
aria-label="increase priority for {item.name}"
on:click|stopPropagation={() => onIncreasePriority && onIncreasePriority(item.id)}
>
<iconify-icon icon="ion:arrow-up"></iconify-icon>
</button>
</div>
2 changes: 1 addition & 1 deletion src/lib/components/wishlists/ItemForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { env } from "$env/dynamic/public";
import { getToastStore } from "@skeletonlabs/skeleton";
export let data: Item;
export let data: Partial<Item>;
export let buttonText: string;
$: form = $page.form;
Expand Down
12 changes: 12 additions & 0 deletions src/lib/components/wishlists/chips/ReorderChip.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
export let reordering: boolean;
export let onFinalize: VoidFunction;
</script>

<div>
{#if reordering}
<button class="variant-ghost-secondary chip" on:click={onFinalize}>Finish</button>
{:else}
<button class="variant-filled-primary chip" on:click={() => (reordering = true)}>Reorder</button>
{/if}
</div>
3 changes: 3 additions & 0 deletions src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ export const SSEvents = {
update: "item_update",
create: "item_create",
delete: "item_delete"
},
items: {
update: "items_update"
}
};
52 changes: 52 additions & 0 deletions src/lib/server/api-common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Prisma } from "@prisma/client";

export const patchItem = (body: Record<string, unknown>) => {
const data: Prisma.ItemUpdateInput & { id?: number } = {
id: body.id as number
};
let deleteOldImage = false;

if (body.name && typeof body.name === "string") data.name = body.name;
if (body.price && typeof body.price === "string") data.price = body.price;
if (body.url && typeof body.url === "string") data.url = body.url;
if (body.note && typeof body.note === "string") data.note = body.note;
if (body.displayOrder !== null && typeof body.displayOrder === "number")
data.displayOrder = body.displayOrder as number;
if (body.image_url && typeof body.image_url === "string") {
data.imageUrl = body.image_url;
deleteOldImage = true;
}
if (body.pledgedById && typeof body.pledgedById === "string") {
if (body.pledgedById === "0") {
data.pledgedBy = {
disconnect: true
};
} else {
data.pledgedBy = {
connect: {
id: body.pledgedById
}
};
}
}
if (body.publicPledgedById && typeof body.publicPledgedById === "string") {
if (body.publicPledgedById === "0") {
data.publicPledgedBy = {
disconnect: true
};
} else {
data.publicPledgedBy = {
connect: {
id: body.publicPledgedById
}
};
}
}
if (Object.keys(body).includes("approved") && typeof body.approved === "boolean") data.approved = body.approved;
if (Object.keys(body).includes("purchased") && typeof body.purchased === "boolean") data.purchased = body.purchased;

return {
data,
deleteOldImage
};
};
91 changes: 91 additions & 0 deletions src/routes/api/items/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { RequestHandler } from "./$types";
import { _authCheck } from "../groups/[groupId]/auth";
import { tryDeleteImage } from "$lib/server/image-util";
import { itemEmitter } from "$lib/server/events/emitters";
import type { Prisma } from "@prisma/client";
import { patchItem } from "$lib/server/api-common";

export const DELETE: RequestHandler = async ({ locals, request }) => {
const groupId = new URL(request.url).searchParams.get("groupId");
Expand Down Expand Up @@ -55,3 +57,92 @@ export const DELETE: RequestHandler = async ({ locals, request }) => {
error(500, "Unable to delete items");
}
};

export const PATCH: RequestHandler = async ({ locals, request }) => {
if (!locals.user) error(401, "user is not authenticated");

const body = (await request.json()) as Record<string, unknown>[];
const itemIds = body.map((item) => {
if (!item.id) {
error(422, "One or more items missing an id");
}
return item.id as number;
});

const items = await client.item.findMany({
where: {
id: {
in: itemIds
}
},
orderBy: {
id: "asc"
}
});

const idsSet = new Set(itemIds);
items.forEach((item) => {
if (!idsSet.has(item.id)) {
error(404, `Item with id ${item.id} not found`);
}
});

const patches = body.map((bodyValue) => patchItem(bodyValue));
const imageUrlsToDelete = patches
.filter((patch) => patch.deleteOldImage)
.map((patch) => items.find((item) => item.id === patch.data.id))
.map((item) => item?.imageUrl)
.filter((url) => url !== null);
const itemsToUpdate = patches.map((patch) => {
const id = patch.data.id;
delete patch.data.id;
return client.item.update({
where: {
id
},
include: {
addedBy: {
select: {
username: true,
name: true
}
},
pledgedBy: {
select: {
username: true,
name: true
}
},
publicPledgedBy: {
select: {
username: true,
name: true
}
},
user: {
select: {
username: true,
name: true
}
}
},
data: patch.data as Prisma.ItemUpdateInput
});
});

try {
const updatedItems = await client.$transaction(itemsToUpdate);

if (imageUrlsToDelete.length > 0) {
for (const imageUrl of imageUrlsToDelete) {
if (imageUrl) await tryDeleteImage(imageUrl as string);
}
}

itemEmitter.emit(SSEvents.items.update);

return new Response(JSON.stringify(updatedItems), { status: 200 });
} catch {
error(404, "item id not found");
}
};
Loading

0 comments on commit a9d404e

Please sign in to comment.