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

241 dca 5 create the content upload and editing interface #269

Merged
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// ContentSpaceSheet,
// ContentSpaceSheetSkeleton,
// } from '@features/back-office/content-spaces';
import { ContentSpaceSheet } from '@features/back-office/content-spaces';
import { getContentSpaceWithPassesOrganizer } from '@features/back-office/content-spaces-api';
import { getCurrentUser } from '@next/next-auth/user';
import { SheetContent } from '@ui/components';
Expand All @@ -24,11 +25,10 @@ async function ContentSpaceSheetPageContent({
});
if (!contentSpace) return notFound();
return (
<div>ContentSpaceSheetPageContent</div>
// <ContentSpaceSheet
// contentSpace={contentSpace}
// organizerId={user.role.organizerId}
// />
<ContentSpaceSheet
contentSpace={contentSpace}
organizerId={user.role.organizerId}
/>
);
}

Expand Down
16 changes: 15 additions & 1 deletion libs/features/back-office/content-spaces-types/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import type { GetContentSpacesFromOrganizerIdTableQuery } from '@gql/admin/types';
import { FileSummary } from '@bytescale/sdk';
import type {
GetContentSpacesFromOrganizerIdTableQuery,
GetContentSpaceWithPassesOrganizerQuery,
} from '@gql/admin/types';

export type ContentSpaceFromOrganizerTable = NonNullable<
NonNullable<
GetContentSpacesFromOrganizerIdTableQuery['organizer']
>['contentSpaces']
>[number];

export type ContentSpaceFromOrganizerWithPasses = NonNullable<
GetContentSpaceWithPassesOrganizerQuery['contentSpace']
>;

export type EventPass = ContentSpaceFromOrganizerWithPasses['eventPasses'][0];

export interface ContentSpaceFileWithName extends FileSummary {
fileName: string;
}
3 changes: 2 additions & 1 deletion libs/features/back-office/content-spaces/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { ContentSpaceSheet } from './lib/pages/ContentSpaceSheet/ContentSpaceSheet';
export {
ContentSpaceTableSkeleton,
ContentSpacesPage,
} from './lib/pages/ContentSpacesPage';
} from './lib/pages/ContentSpacesPage/ContentSpacesPage';
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { FileWrapper } from '@file-upload/admin';
import { revalidateTag } from 'next/cache';
import {
deleteContentSpaceFile,
DeleteContentSpaceFileProps,
} from './deleteContentSpaceFile';

jest.mock('@file-upload/admin');
jest.mock('next/cache');

describe('deleteContentSpaceFile', () => {
const mockDeleteFile = jest.fn();
const mockRevalidateTag = jest.fn();
beforeAll(() => {
// Mock the FileWrapper instance method
FileWrapper.prototype.deleteFile = mockDeleteFile;

// Mock the revalidateTag function
(revalidateTag as jest.Mock) = mockRevalidateTag;
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should call deleteFile and revalidateTag with correct arguments', async () => {
const props: DeleteContentSpaceFileProps = {
organizerId: 'testOrganizerId',
contentSpaceId: 'testContentSpaceId',
filePath:
'/local/organizers/testOrganizerId/content-spaces/testContentSpaceId/testFile',
};

await deleteContentSpaceFile(props);

expect(mockDeleteFile).toHaveBeenCalledWith({
accountId: expect.any(String), // UPLOAD_ACCOUNT_ID
filePath:
'/local/organizers/testOrganizerId/content-spaces/testContentSpaceId/testFile',
});

expect(mockRevalidateTag).toHaveBeenCalledWith(
`${props.organizerId}-${props.contentSpaceId}-getContentSpaceFiles`,
);
});

it('should handle error from deleteFile', async () => {
const props: DeleteContentSpaceFileProps = {
organizerId: 'testOrganizerId',
contentSpaceId: 'testContentSpaceId',
filePath:
'/local/organizers/testOrganizerId/content-spaces/testContentSpaceId/testFile',
};

mockDeleteFile.mockRejectedValueOnce(new Error('Test error'));

await expect(deleteContentSpaceFile(props)).rejects.toThrow('Test error');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use server';

import env from '@env/server';
import { GetContentSpaceFolderPath } from '@features/content-space-common';
import { FileWrapper } from '@file-upload/admin';
import { revalidateTag } from 'next/cache';

export type DeleteContentSpaceFileProps = GetContentSpaceFolderPath & {
filePath: string;
};

export async function deleteContentSpaceFile({
organizerId,
contentSpaceId,
filePath,
}: DeleteContentSpaceFileProps) {
const fileApi = new FileWrapper();
await fileApi.deleteFile({ accountId: env.UPLOAD_ACCOUNT_ID, filePath });
revalidateTag(`${organizerId}-${contentSpaceId}-getContentSpaceFiles`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import env from '@env/server';
import { FileWrapper } from '@file-upload/admin';
import { revalidateTag } from 'next/cache';
import {
deleteContentSpaceFiles,
DeleteContentSpaceFilesProps,
} from './deleteContentSpaceFiles';

jest.mock('@file-upload/admin');
jest.mock('next/cache');

describe('deleteContentSpaceFiles', () => {
const mockDeleteFilesBatchWithRetry = jest.fn();
const mockRevalidateTag = jest.fn();
beforeAll(() => {
// Mock the FileWrapper instance method
FileWrapper.prototype.deleteFilesBatchWithRetry =
mockDeleteFilesBatchWithRetry;

// Mock the revalidateTag function
(revalidateTag as jest.Mock) = mockRevalidateTag;
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should call deleteFilesBatchWithRetry and revalidateTag with correct arguments', async () => {
const props: DeleteContentSpaceFilesProps = {
organizerId: 'testOrganizerId',
contentSpaceId: 'testContentSpaceId',
filesSelected: { file1: true, file2: false },
};

await deleteContentSpaceFiles(props);

expect(mockDeleteFilesBatchWithRetry).toHaveBeenCalledWith(
expect.any(String), // UPLOAD_ACCOUNT_ID
[
`/${env.UPLOAD_PATH_PREFIX}/organizers/testOrganizerId/content-spaces/testContentSpaceId/file1`,
], // filesToDelete
);

expect(mockRevalidateTag).toHaveBeenCalledWith(
`${props.organizerId}-${props.contentSpaceId}-getContentSpaceFiles`,
);
});
it('should call deleteFilesBatchWithRetry with all files when all are selected', async () => {
const props: DeleteContentSpaceFilesProps = {
organizerId: 'testOrganizerId',
contentSpaceId: 'testContentSpaceId',
filesSelected: { file1: true, file2: true },
};

await deleteContentSpaceFiles(props);

expect(mockDeleteFilesBatchWithRetry).toHaveBeenCalledWith(
expect.any(String),
[
`/${env.UPLOAD_PATH_PREFIX}/organizers/testOrganizerId/content-spaces/testContentSpaceId/file1`,
`/${env.UPLOAD_PATH_PREFIX}/organizers/testOrganizerId/content-spaces/testContentSpaceId/file2`,
],
);
});
it('should throw an error when no files are selected', async () => {
const props: DeleteContentSpaceFilesProps = {
organizerId: 'testOrganizerId',
contentSpaceId: 'testContentSpaceId',
filesSelected: {},
};

await expect(deleteContentSpaceFiles(props)).rejects.toThrow(
'No files to delete selected',
);
expect(mockDeleteFilesBatchWithRetry).not.toHaveBeenCalled();
expect(mockRevalidateTag).not.toHaveBeenCalled();
});

it('should not call deleteFilesBatchWithRetry when filesSelected only contains false', async () => {
const props: DeleteContentSpaceFilesProps = {
organizerId: 'testOrganizerId',
contentSpaceId: 'testContentSpaceId',
filesSelected: { file1: false, file2: false },
};

await expect(deleteContentSpaceFiles(props)).rejects.toThrow(
'No files to delete selected',
);
expect(mockDeleteFilesBatchWithRetry).not.toHaveBeenCalled();
expect(mockRevalidateTag).not.toHaveBeenCalled();
});

it('should handle error from deleteFilesBatchWithRetry', async () => {
const props: DeleteContentSpaceFilesProps = {
organizerId: 'testOrganizerId',
contentSpaceId: 'testContentSpaceId',
filesSelected: { file1: true },
};

mockDeleteFilesBatchWithRetry.mockRejectedValueOnce(
new Error('Test error'),
);

await expect(deleteContentSpaceFiles(props)).rejects.toThrow('Test error');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use server';

import env from '@env/server';
import {
getContentSpaceFolderPath,
type GetContentSpaceFolderPath,
} from '@features/content-space-common';
import { FileWrapper } from '@file-upload/admin';
import { RowSelectionState } from '@tanstack/react-table';
import { revalidateTag } from 'next/cache';

export type DeleteContentSpaceFilesProps = GetContentSpaceFolderPath & {
filesSelected: RowSelectionState;
};

export async function deleteContentSpaceFiles({
organizerId,
contentSpaceId,
filesSelected,
}: DeleteContentSpaceFilesProps) {
const folderPath = getContentSpaceFolderPath({
organizerId,
contentSpaceId,
});
const fileApi = new FileWrapper();
const filesToDelete = Object.entries(filesSelected)
.filter(([_fileName, selected]) => selected)
.map(([fileName, _selected]) => `${folderPath}/${fileName}`);
if (!filesToDelete.length) throw new Error('No files to delete selected');
await fileApi.deleteFilesBatchWithRetry(env.UPLOAD_ACCOUNT_ID, filesToDelete);
revalidateTag(`${organizerId}-${contentSpaceId}-getContentSpaceFiles`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FileSummary } from '@bytescale/sdk';
import env from '@env/server';
import {
GetContentSpaceFolderPath,
getContentSpaceFolderPath,
} from '@features/content-space-common';
import { FolderWrapper } from '@file-upload/admin';
import { cacheWithDynamicKeys } from '@next/cache';

export type GetContentSpaceFilesProps = GetContentSpaceFolderPath;

export const getContentSpaceFiles = cacheWithDynamicKeys(
async (props: GetContentSpaceFilesProps) => {
const folder = new FolderWrapper();
const folderPath = getContentSpaceFolderPath(props);
const list = await folder.listFolder({
accountId: env.UPLOAD_ACCOUNT_ID,
folderPath,
});
return list.items.filter((item): item is FileSummary => 'filePath' in item);
},
(props: [GetContentSpaceFilesProps]) => [
`${props[0].organizerId}-${props[0].contentSpaceId}-getContentSpaceFiles`,
],
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ContentSpaceFromOrganizerWithPasses } from '@features/back-office/content-spaces-types';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableSkeleton,
} from '@ui/components';
import { useTranslations } from 'next-intl';

export interface ContentSpaceEventPassesTableProps
extends Pick<ContentSpaceFromOrganizerWithPasses, 'eventPasses'> {}

export function ContentSpaceEventPassesTable({
eventPasses,
}: ContentSpaceEventPassesTableProps) {
const eventPassesByEvent = eventPasses.reduce(
(acc, eventPass) => {
const event = eventPass.event;
if (!event) return acc;
if (!acc[event.slug]) acc[event.slug] = [];
acc[event.slug].push(eventPass);
return acc;
},
{} as Record<string, ContentSpaceFromOrganizerWithPasses['eventPasses']>,
);

const t = useTranslations(
'OrganizerContentSpaces.Sheet.ContentSpaceEventPassesTable',
);

return (
<>
{Object.entries(eventPassesByEvent).map(([slug, eventPasses]) => (
<Table key={slug}>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">
{t('event-pass-for-event', { slug })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eventPasses.map((eventPass) => (
<TableRow key={eventPass.id}>
<TableCell>{eventPass.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
))}
</>
);
}

export function ContentSpaceEventPassesTableSkeleton() {
return <TableSkeleton rows={4} cols={2} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { EventPass } from '@features/back-office/content-spaces-types';

export const eventPassWorldCupFinale2023VIP: EventPass = {
id: '1',
name: 'VIP Pass',
event: {
slug: 'world-cup-finale-2023',
title: 'World Cup Finale 2023',
},
};

export const eventPassWorldCupFinale2023Premium: EventPass = {
id: '2',
name: 'Premium Pass',
event: {
slug: 'world-cup-finale-2023',
title: 'World Cup Finale 2023',
},
};

export const eventPassWorldCupFinale2023MeetAndGreet: EventPass = {
id: '3',
name: 'Meet and Greet',
event: {
slug: 'world-cup-finale-2023',
title: 'World Cup Finale 2023',
},
};

export const eventPassesWorldCupFinale: EventPass[] = [
eventPassWorldCupFinale2023VIP,
eventPassWorldCupFinale2023Premium,
eventPassWorldCupFinale2023MeetAndGreet,
];
Loading
Loading