Skip to content

Commit feafca6

Browse files
MatveyKkim
andauthored
feat(graasp-export): import and export the APP items (#1877)
* feat(graasp-export): import and export the APP items * feat(graasp-export): import and export the APP items * refactor: apply PR requested changes * refactor: fix test * refactor: remove console log * refactor: remove debug * refactor: apply PR requested changes --------- Co-authored-by: kim <kim.phanhoang@epfl.ch>
1 parent 5a1d3e4 commit feafca6

7 files changed

Lines changed: 108 additions & 26 deletions

File tree

src/services/item/plugins/app/appSetting/appSetting.repository.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export class AppSettingRepository {
1919
return res[0];
2020
}
2121

22+
async createMany(dbConnection: DBConnection, appSettings: AppSettingInsertDTO[]): Promise<void> {
23+
await dbConnection.insert(appSettingsTable).values(appSettings);
24+
}
25+
2226
async updateOne(
2327
dbConnection: DBConnection,
2428
appSettingId: string,

src/services/item/plugins/app/appSetting/appSetting.service.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,6 @@ export class AppSettingService {
1616
private readonly appSettingRepository: AppSettingRepository;
1717

1818
hooks = new HookManager<{
19-
post: {
20-
pre: {
21-
appSetting: Omit<AppSettingInsertDTO, 'itemId' | 'memberId'>;
22-
itemId: string;
23-
};
24-
post: { appSetting: AppSettingRaw; itemId: string };
25-
};
2619
patch: {
2720
pre: { appSetting: Partial<AppSettingRaw>; itemId: string };
2821
post: { appSetting: AppSettingRaw; itemId: string };
@@ -66,20 +59,12 @@ export class AppSettingService {
6659
permission: PermissionLevel.Admin,
6760
});
6861

69-
await this.hooks.runPreHooks('post', member, dbConnection, {
70-
appSetting: body,
71-
itemId,
72-
});
73-
7462
const appSetting = await this.appSettingRepository.addOne(dbConnection, {
7563
...body,
7664
itemId,
7765
creatorId: member.id,
7866
});
79-
await this.hooks.runPostHooks('post', member, dbConnection, {
80-
appSetting,
81-
itemId,
82-
});
67+
8368
return appSetting;
8469
}
8570

src/services/item/plugins/importExport/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { BaseLogger } from '../../../../logger';
1212
import { asDefined, assertIsDefined } from '../../../../utils/assertions';
1313
import { ActionService } from '../../../action/action.service';
1414
import { isAuthenticated, matchOne, optionalIsAuthenticated } from '../../../auth/plugins/passport';
15-
import { assertIsMember, isMember } from '../../../authentication';
15+
import { assertIsMember } from '../../../authentication';
1616
import { AuthorizedItemService } from '../../../authorizedItem.service';
1717
import { validatedMemberAccountRole } from '../../../member/strategies/validatedMemberAccountRole';
1818
import { WrongItemTypeError } from '../../errors';

src/services/item/plugins/importExport/service.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ import {
2020
} from '@graasp/sdk';
2121

2222
import { type DBConnection } from '../../../../drizzle/db';
23-
import { type ItemRaw } from '../../../../drizzle/types';
23+
import { AppSettingInsertDTO, AppSettingRaw, type ItemRaw } from '../../../../drizzle/types';
2424
import { BaseLogger } from '../../../../logger';
2525
import { MaybeUser, MinimalMember } from '../../../../types';
2626
import { AuthorizedItemService } from '../../../authorizedItem.service';
2727
import { UploadEmptyFileError } from '../../../file/utils/errors';
2828
import { isItemType } from '../../discrimination';
2929
import { ItemService } from '../../item.service';
30+
import { AppSettingRepository } from '../app/appSetting/appSetting.repository';
3031
import { EtherpadItemService } from '../etherpad/etherpad.service';
3132
import FileItemService from '../file/itemFile.service';
3233
import { H5PService } from '../html/h5p/h5p.service';
@@ -58,6 +59,7 @@ export type GraaspExportItem = {
5859
thumbnailFilename?: string;
5960
children?: GraaspExportItem[];
6061
mimetype?: string;
62+
appSettings?: Omit<AppSettingRaw, 'id'>[];
6163
};
6264

6365
@singleton()
@@ -68,6 +70,7 @@ export class ImportExportService {
6870
private readonly authorizedItemService: AuthorizedItemService;
6971
private readonly etherpadService: EtherpadItemService;
7072
private readonly itemThumbnailService: ItemThumbnailService;
73+
private readonly appSettingRepository: AppSettingRepository;
7174
private readonly log: BaseLogger;
7275

7376
constructor(
@@ -76,13 +79,17 @@ export class ImportExportService {
7679
h5pService: H5PService,
7780
etherpadService: EtherpadItemService,
7881
authorizedItemService: AuthorizedItemService,
82+
itemThumbnailService: ItemThumbnailService,
83+
appSettingRepository: AppSettingRepository,
7984
log: BaseLogger,
8085
) {
8186
this.fileItemService = fileItemService;
8287
this.h5pService = h5pService;
8388
this.itemService = itemService;
8489
this.etherpadService = etherpadService;
8590
this.authorizedItemService = authorizedItemService;
91+
this.itemThumbnailService = itemThumbnailService;
92+
this.appSettingRepository = appSettingRepository;
8693
this.log = log;
8794
}
8895

@@ -310,7 +317,12 @@ export class ImportExportService {
310317
h5pFileStream,
311318
);
312319

313-
extra = h5pFileInfo;
320+
extra = { [ItemType.H5P]: h5pFileInfo };
321+
}
322+
323+
// Handle the APP item extra
324+
if (item.type === ItemType.APP) {
325+
extra = item.extra;
314326
}
315327

316328
// Handle the file upload
@@ -343,6 +355,9 @@ export class ImportExportService {
343355
parentId: parentId,
344356
});
345357

358+
// Create the app settings for the APP items
359+
await this.insertAppSettings(dbConnection, actor, uploadedItems, items);
360+
346361
// Recursively handle the children items
347362
for (let i = 0; i < items.length; i++) {
348363
if (items[i].type === ItemType.FOLDER) {
@@ -359,6 +374,48 @@ export class ImportExportService {
359374
}
360375
}
361376

377+
/**
378+
* Extract the app settings from the export items and attach them to the existing items in the DB.
379+
*/
380+
private async insertAppSettings(
381+
dbConnection: DBConnection,
382+
actor: MinimalMember,
383+
uploadedItems: ItemRaw[],
384+
items: GraaspExportItem[],
385+
): Promise<void> {
386+
const appSettings = uploadedItems.reduce<Omit<AppSettingInsertDTO[], 'id'>>(
387+
(arr, uploadedItem, idx) => {
388+
const settings = items[idx].appSettings;
389+
if (uploadedItem.type !== ItemType.APP || !settings) {
390+
return arr;
391+
}
392+
393+
return arr.concat(
394+
settings.reduce<Omit<AppSettingInsertDTO[], 'id'>>((arr, appSetting) => {
395+
// ignore app setting file
396+
if (appSetting.data[ItemType.FILE]) {
397+
return arr;
398+
}
399+
400+
// Remove the id property from the imported app settings as a precaution. Remove this condition as soon as the id is stripped directly in the post function.
401+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
402+
// @ts-expect-error
403+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
404+
const { id, creatorId, itemId, ...appSettingData } = appSetting;
405+
406+
return arr.concat([
407+
{ creatorId: actor.id, itemId: uploadedItem.id, ...appSettingData },
408+
]);
409+
}, []),
410+
);
411+
},
412+
[],
413+
);
414+
if (appSettings.length) {
415+
await this.appSettingRepository.createMany(dbConnection, appSettings);
416+
}
417+
}
418+
362419
/**
363420
* Add item in archive, recursively add children in folder
364421
* @param actor
@@ -441,6 +498,16 @@ export class ImportExportService {
441498
archive,
442499
);
443500

501+
// Get the app settings if an item is an APP
502+
let appSettings: Omit<AppSettingRaw, 'id'>[] | undefined = undefined;
503+
if (isItemType(item, ItemType.APP)) {
504+
const itemAppSettings = await this.appSettingRepository.getForItem(dbConnection, item.id);
505+
506+
appSettings = itemAppSettings.map((appSetting) => {
507+
return { ...appSetting, id: undefined, itemId: exportItemId };
508+
});
509+
}
510+
444511
// TODO EXPORT treat the shortcut items correctly
445512
// ignore the shortcuts for now
446513
if (isItemType(item, ItemType.SHORTCUT)) {
@@ -484,6 +551,7 @@ export class ImportExportService {
484551
extra: item.extra,
485552
thumbnailFilename,
486553
mimetype,
554+
appSettings,
487555
});
488556
archive.addReadStream(stream, itemPath);
489557
return itemManifest;

src/services/item/plugins/importExport/specifications.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The export operation produces a single zip file. This file is a simple zip file
1616
- thumbnailFilename (item thumbnail in the original size, if present)
1717
- children (item children, in case of a folder item)
1818
- mimetype (item file mimetype, in case there's a file attached to the item)
19+
- appSettings (app settings for the APP items)
1920
```
2021

2122
### Files and thumbnails
@@ -30,7 +31,7 @@ The `description` field for all items is sanitized before item creation.
3031

3132
### Item type-specific treatment
3233

33-
- `APP` - Not currently supported.
34+
- `APP` - The app item extras are imported as-is. The app settings are also imported (ignoring files).
3435
- `DOCUMENT` - The `name` and `content` fields are sanitized.
3536
- `FOLDER` - The children are recursively imported, if present.
3637
- `LINK` - Not currently supported.
Binary file not shown.

src/services/item/plugins/importExport/test/index.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { seedFromJson } from '../../../../../../test/mocks/seed';
2828
import { resolveDependency } from '../../../../../di/utils';
2929
import { db } from '../../../../../drizzle/db';
3030
import { isDescendantOrSelf, isDirectChild } from '../../../../../drizzle/operations';
31-
import { itemsRawTable } from '../../../../../drizzle/schema';
31+
import { appSettingsTable, itemsRawTable } from '../../../../../drizzle/schema';
3232
import { MailerService } from '../../../../../plugins/mailer/mailer.service';
3333
import { assertIsDefined } from '../../../../../utils/assertions';
3434
import { ITEMS_ROUTE_PREFIX, THUMBNAILS_ROUTE_PREFIX } from '../../../../../utils/config';
@@ -78,13 +78,18 @@ const setupActorAndItems = async () => {
7878
order: i,
7979
};
8080
});
81+
const appItem = {
82+
type: ItemType.APP,
83+
name: `secondLevelItemApp`,
84+
appSettings: [{ creator: { name: 'bob' }, name: 'app-setting' }],
85+
};
8186
const firstLevelChildren = Array.from({ length: 15 }).map((_val, i) => {
8287
if (i === 0) {
8388
return {
8489
name: `folderItem1`,
8590
type: ItemType.FOLDER,
8691
order: i,
87-
children: secondLevelChildren,
92+
children: [...secondLevelChildren, appItem],
8893
};
8994
}
9095
return {
@@ -118,6 +123,7 @@ const setupActorAndItems = async () => {
118123
firstLevelFolderItem,
119124
firstLevelItems,
120125
secondLevelItems,
126+
appItem,
121127
};
122128
};
123129

@@ -435,6 +441,8 @@ describe('ZIP routes tests', () => {
435441
const pdfName = 'output.pdf';
436442
const pdfContent = 'This is a real document.';
437443
const h5pFilename = 'test-05-collage-54.h5p';
444+
const appUrl = 'https://justinjackson.ca/words.html';
445+
const appName = 'My App';
438446

439447
// Create the actor and the parent item
440448
const {
@@ -468,13 +476,15 @@ describe('ZIP routes tests', () => {
468476
const documentItem = itemsInDB[1];
469477
const fileItem = itemsInDB[2];
470478
const h5pItem = itemsInDB[3];
479+
const appItem = itemsInDB[4];
471480

472481
// Check that all the items have been imported and that their order is correct
473-
expect(itemsInDB.length).toEqual(4);
482+
expect(itemsInDB.length).toEqual(5);
474483
expect(folderItem.type).toEqual(ItemType.FOLDER);
475484
expect(documentItem.type).toEqual(ItemType.DOCUMENT);
476485
expect(fileItem.type).toEqual(ItemType.FILE);
477486
expect(h5pItem.type).toEqual(ItemType.H5P);
487+
expect(appItem.type).toEqual(ItemType.APP);
478488
expect(Number(itemsInDB[1].order)).toBeLessThan(Number(itemsInDB[2].order));
479489

480490
// Check that all the item properties have been assigned for the document type
@@ -495,15 +505,23 @@ describe('ZIP routes tests', () => {
495505

496506
// Check the the file item thumbnail has been imported correctly
497507
expect(fileItem.settings.hasThumbnail).toBeTruthy();
498-
499508
const thumbnailURLResponse = await app.inject({
500509
method: HttpMethod.Get,
501510
url: `${ITEMS_ROUTE_PREFIX}/${fileItem.id}${THUMBNAILS_ROUTE_PREFIX}/${ThumbnailSize.Original}`,
502511
});
503-
504512
expect(thumbnailURLResponse.statusCode).toBe(StatusCodes.OK);
505513

514+
// Check the H5P item
506515
expect(h5pItem.name).toEqual(h5pFilename);
516+
517+
// Check the APP item
518+
expect(appItem.extra[ItemType.APP]).toBeDefined();
519+
expect(appItem.extra[ItemType.APP].url).toEqual(appUrl);
520+
const appSetting = await db.query.appSettingsTable.findFirst({
521+
where: eq(appSettingsTable.itemId, appItem.id),
522+
});
523+
expect(appSetting).toBeDefined();
524+
expect(appSetting!.name).toEqual(appName);
507525
});
508526
});
509527
});
@@ -733,7 +751,8 @@ describe('ZIP routes tests', () => {
733751
});
734752

735753
it('Graasp export recreates the file structure', async () => {
736-
const { actor, folderItem, firstLevelItems, secondLevelItems } = await setupActorAndItems();
754+
const { actor, folderItem, firstLevelItems, secondLevelItems, appItem } =
755+
await setupActorAndItems();
737756
assertIsDefined(actor);
738757
mockAuthenticate(actor);
739758

@@ -761,6 +780,11 @@ describe('ZIP routes tests', () => {
761780
secondLevelItems.map((x) => x.name),
762781
);
763782

783+
const foundAppItem = foundSecondLevelChildren?.find((i) => i.type === ItemType.APP);
784+
expect(foundAppItem).toBeDefined();
785+
expect(foundAppItem!.appSettings).toBeDefined();
786+
expect(foundAppItem!.appSettings![0].name).toEqual(appItem.appSettings[0].name);
787+
764788
// delete the folder in which the files were unzipped
765789
fs.rmSync(targetFolder, { recursive: true });
766790
});

0 commit comments

Comments
 (0)