Skip to content

Commit

Permalink
feat: add sheet archival
Browse files Browse the repository at this point in the history
  • Loading branch information
nihalgonsalves committed Oct 29, 2023
1 parent 331d899 commit 7a93bc2
Show file tree
Hide file tree
Showing 9 changed files with 269 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "sheets"
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ model Sheet {
type SheetType
name String
currencyCode String @map("currency_code")
isArchived Boolean @default(false) @map("is_archived")
participants SheetMemberships[]
transactions Transaction[]
Expand Down
51 changes: 51 additions & 0 deletions packages/backend/src/service/sheet/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('createPersonalSheet', () => {
type: 'PERSONAL',
name: 'Personal Expenses',
currencyCode: 'EUR',
isArchived: false,
});
});
});
Expand All @@ -50,6 +51,7 @@ describe('createGroupSheet', () => {
type: 'GROUP',
name: 'WG Expenses',
currencyCode: 'EUR',
isArchived: false,
participants: expect.arrayContaining([
{
id: user.id,
Expand Down Expand Up @@ -98,6 +100,7 @@ describe('personalSheetById', () => {
type: 'PERSONAL',
name: personalSheet.name,
currencyCode: personalSheet.currencyCode,
isArchived: false,
});
});

Expand Down Expand Up @@ -149,6 +152,7 @@ describe('groupSheetById', () => {
type: 'GROUP',
name: groupSheet.name,
currencyCode: groupSheet.currencyCode,
isArchived: false,
participants: [
{
id: user.id,
Expand Down Expand Up @@ -283,6 +287,53 @@ describe('deleteSheet', () => {
});
});

describe('archiveSheet', () => {
describe.each([
['personalSheet', personalSheetFactory],
['groupSheet', groupSheetFactory],
])('%s', (sheetType, factory) => {
it(`archives a ${sheetType}`, async () => {
const user = await userFactory(prisma);
const caller = useProtectedCaller(user);
const sheet = await factory(prisma, {
withOwnerId: user.id,
});

await caller.sheet.archiveSheet(sheet.id);

expect(
await prisma.sheet.findUnique({ where: { id: sheet.id } }),
).toMatchObject({ isArchived: true });
});

it.todo(`archives a ${sheetType} with transactions`);

it('returns a 404 if the participant has no access', async () => {
const user = await userFactory(prisma);
const caller = useProtectedCaller(user);
const sheet = await factory(prisma);

await expect(caller.sheet.archiveSheet(sheet.id)).rejects.toThrow(
'Sheet not found',
);
});

if (sheetType === 'groupSheet') {
it('returns a 403 if the participant is not an admin', async () => {
const user = await userFactory(prisma);
const caller = useProtectedCaller(user);
const sheet = await factory(prisma, {
withParticipantIds: [user.id],
});

await expect(caller.sheet.archiveSheet(sheet.id)).rejects.toThrow(
'Only admins can archive sheets',
);
});
}
});
});

describe('addGroupSheetMember', () => {
it('adds a member', async () => {
const user = await userFactory(prisma);
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/src/service/sheet/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ export const sheetRouter = router({
};
}),

archiveSheet: protectedProcedure
.input(z.string().nonempty())
.output(z.void())
.mutation(async ({ input, ctx }) => {
const { role } = await ctx.sheetService.ensureSheetMembership(
input,
ctx.user.id,
);

if (role !== SheetParticipantRole.ADMIN) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only admins can archive sheets',
});
}

await ctx.sheetService.archiveSheet(input);
}),

deleteSheet: protectedProcedure
.input(z.string().nonempty())
.output(z.void())
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/service/sheet/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ export class SheetService {
});
}

async archiveSheet(id: string) {
return this.prismaClient.sheet.update({
where: {
id,
},
data: {
isArchived: true,
},
});
}

async addGroupSheetMember(groupSheet: Sheet, participantEmail: string) {
try {
const member = await this.prismaClient.sheetMemberships.create({
Expand Down
140 changes: 115 additions & 25 deletions packages/frontend/src/components/SheetsList.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,124 @@
import { AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import { useMemo, useState } from 'react';
import { MdGroup, MdListAlt } from 'react-icons/md';
import { Link } from 'react-router-dom';

import type { SheetsResponse } from '@nihalgonsalves/expenses-shared/types/sheet';

import { collapse } from '../utils/framer';
import { clsxtw } from '../utils/utils';

import { Avatar } from './Avatar';
import { ExpandMoreButton } from './ExpandMoreButton';

export const SheetsList = ({ sheets }: { sheets: SheetsResponse }) => (
<div className="flex flex-col gap-4">
{sheets.length === 0 && <div className="alert">No sheets</div>}
{sheets.map((sheet) => (
<div key={sheet.id} className="flex items-center gap-4 h-14">
<Link
className="btn btn-ghost no-animation flex-grow justify-start gap-4 text-start text-xl normal-case text-primary"
to={
sheet.type === 'PERSONAL'
? `/sheets/${sheet.id}`
: `/groups/${sheet.id}`
}
>
{sheet.type === 'PERSONAL' ? <MdListAlt /> : <MdGroup />}
{sheet.name}
</Link>
{sheet.type === 'GROUP' && (
<div className="avatar-group -space-x-6">
{sheet.participants.map((participant) => (
<Avatar key={participant.id} name={participant.name} />
const partitionSheets = (sheets: SheetsResponse) => {
const personal: SheetsResponse = [];
const group: SheetsResponse = [];
const archived: SheetsResponse = [];

for (const sheet of sheets) {
if (sheet.isArchived) {
archived.push(sheet);
} else if (sheet.type === 'PERSONAL') {
personal.push(sheet);
} else {
group.push(sheet);
}
}

return { personal, group, archived };
};

const SheetItem = ({ sheet }: { sheet: SheetsResponse[0] }) => {
const link =
sheet.type === 'PERSONAL' ? `/sheets/${sheet.id}` : `/groups/${sheet.id}`;

return (
<div key={sheet.id} className="flex items-center gap-4 h-14">
<Link
className="btn btn-ghost no-animation flex-grow justify-start gap-4 text-start text-xl normal-case text-primary"
to={link}
>
{sheet.type === 'PERSONAL' ? <MdListAlt /> : <MdGroup />}
{sheet.name}
</Link>
{sheet.type === 'GROUP' && (
<div className="avatar-group -space-x-6">
{sheet.participants.map((participant) => (
<Avatar key={participant.id} name={participant.name} />
))}
</div>
)}
</div>
);
};

export const SheetsList = ({ sheets }: { sheets: SheetsResponse }) => {
const { personal, group, archived } = useMemo(
() => partitionSheets(sheets),
[sheets],
);

const [showArchived, setShowArchived] = useState(false);

return sheets.length === 0 ? (
<div className="flex flex-col gap-4">
<div className="alert">No sheets</div>
</div>
) : (
<div className="flex flex-col md:grid md:grid-cols-2 gap-4">
<div className="flex flex-col flex-grow gap-4 card card-compact card-bordered">
<div className="card-body">
<h2 className="card-title">Personal Sheets</h2>
{personal.map((sheet) => (
<SheetItem key={sheet.id} sheet={sheet} />
))}
</div>
</div>

{group.length > 0 && (
<div className="flex flex-col flex-grow gap-4 card card-compact card-bordered">
<div className="card-body">
<h2 className="card-title">Group Sheets</h2>

{group.map((sheet) => (
<SheetItem key={sheet.id} sheet={sheet} />
))}
</div>
)}
</div>
))}
</div>
);
</div>
)}

{archived.length > 0 && (
<div
className={clsxtw(
'flex flex-col flex-grow gap-4 card card-compact card-bordered',
showArchived ? '' : 'opacity-50',
)}
>
<div className="card-body">
<h2 className="card-title flex justify-between">
Archived Sheets
<ExpandMoreButton
expand={showArchived}
onClick={() => {
setShowArchived((prev) => !prev);
}}
/>
</h2>

<AnimatePresence initial={false}>
{showArchived && (
<motion.div {...collapse}>
{archived.map((sheet) => (
<SheetItem key={sheet.id} sheet={sheet} />
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)}
</div>
);
};
54 changes: 41 additions & 13 deletions packages/frontend/src/pages/groups/GroupDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useCallback, useMemo } from 'react';
import { MdDeleteOutline, MdMoreVert, MdPlaylistAdd } from 'react-icons/md';
import {
MdDeleteOutline,
MdMoreVert,
MdOutlineArchive,
MdPlaylistAdd,
} from 'react-icons/md';
import { useNavigate } from 'react-router-dom';

import { trpc } from '../../api/trpc';
Expand All @@ -23,6 +28,7 @@ export const GroupDetailPage = () => {

const { mutateAsync: deleteGroupSheet } =
trpc.sheet.deleteSheet.useMutation();
const { mutateAsync: archiveSheet } = trpc.sheet.archiveSheet.useMutation();

const handleDelete = useCallback(async () => {
await deleteGroupSheet(groupSheetId);
Expand All @@ -33,6 +39,13 @@ export const GroupDetailPage = () => {
navigate('/groups');
}, [deleteGroupSheet, groupSheetId, navigate, utils]);

const handleArchive = useCallback(async () => {
await archiveSheet(groupSheetId);
void utils.sheet.groupSheetById.invalidate(groupSheetId);
void utils.sheet.mySheets.invalidate();
navigate('/sheets');
}, [archiveSheet, groupSheetId, navigate, utils]);

const actorInfo: ActorInfo | undefined = useMemo(
() =>
me && result.data
Expand Down Expand Up @@ -64,18 +77,33 @@ export const GroupDetailPage = () => {
<ExportGroupTransactionsDropdown groupSheet={result.data} />
)}
{actorInfo?.isAdmin && (
<ConfirmDialog
confirmLabel="Confirm Delete"
description="Are you sure you want to delete this group? This action is irreversible."
onConfirm={handleDelete}
renderButton={(onClick) => (
<li>
<a onClick={onClick}>
<MdDeleteOutline /> Delete Group
</a>
</li>
)}
/>
<>
<ConfirmDialog
confirmLabel="Confirm Delete"
description="Are you sure you want to delete this group? This action is irreversible."
onConfirm={handleDelete}
renderButton={(onClick) => (
<li>
<a onClick={onClick}>
<MdDeleteOutline /> Delete Group
</a>
</li>
)}
/>

<ConfirmDialog
confirmLabel="Confirm Archive"
description="Are you sure you want to archive this sheet?"
onConfirm={handleArchive}
renderButton={(onClick) => (
<li>
<a onClick={onClick}>
<MdOutlineArchive /> Archive Sheet
</a>
</li>
)}
/>
</>
)}
</DropdownMenu>
}
Expand Down
Loading

0 comments on commit 7a93bc2

Please sign in to comment.