From a0ec2af7d7538df2004d93a000edcd03f567dd50 Mon Sep 17 00:00:00 2001 From: Daniel von Atzigen Date: Mon, 28 Oct 2024 10:54:34 +0100 Subject: [PATCH] Add new favorite api Add favorite filter to search query Fix `AssetInfoRepo.list` Add favorites to search stats Add favorties filter to asset view Add favorites page Use `favorites` as default filter Reset zoom when search is reset Make favorite items link to their asset Show asset viewer on favourite page Fix tests fix error with empty buckets Reset favorites filter when navigating to home Fix hover color of active favorites Cleanup code --- .../menu-bar/menu-bar.component.html | 15 +- .../components/menu-bar/menu-bar.component.ts | 11 +- apps/client-asset-sg/src/app/i18n/de.ts | 3 + apps/client-asset-sg/src/app/i18n/en.ts | 3 + apps/client-asset-sg/src/app/i18n/fr.ts | 3 + apps/client-asset-sg/src/app/i18n/it.ts | 3 + apps/client-asset-sg/src/app/i18n/rm.ts | 3 + apps/server-asset-sg/src/app.logger.ts | 24 ++- apps/server-asset-sg/src/app.module.ts | 2 +- .../features/asset-edit/asset-edit.fake.ts | 4 +- .../src/features/assets/asset-info.repo.ts | 18 +- .../src/features/assets/asset.repo.ts | 4 +- .../assets/search/asset-search.controller.ts | 4 +- .../search/asset-search.service.spec.ts | 56 +++--- .../assets/search/asset-search.service.ts | 162 ++++++++++-------- .../src/features/favorites/favorite.repo.ts | 29 ++-- .../favorites/favorites.controller.ts | 66 +++++-- .../src/features/favorites/favorites.http | 10 +- .../src/features/users/user.repo.ts | 2 +- apps/server-asset-sg/src/main.ts | 2 + .../mappings/swissgeol_asset_asset.json | 3 + .../asset-editor-launch.component.ts | 8 +- .../src/lib/asset-viewer.module.ts | 6 +- .../asset-search-filter.component.html} | 0 .../asset-search-filter.component.scss} | 0 .../asset-search-filter.component.ts} | 8 +- .../asset-search-refine.component.html | 12 +- .../asset-search-refine.component.ts | 1 + .../asset-search-results.component.html | 6 +- .../asset-search-results.component.scss | 6 - .../asset-search-results.component.ts | 2 +- .../src/lib/components/map/map-controller.ts | 11 +- .../src/lib/components/map/map.component.ts | 13 +- .../asset-search/asset-search.effects.ts | 26 +-- .../asset-search/asset-search.reducer.ts | 12 +- .../asset-search/asset-search.selector.ts | 6 +- .../lib/icons/{favourite.ts => favorite.ts} | 4 +- libs/client-shared/src/lib/icons/index.ts | 4 +- .../src/lib/utils/app-matchers.ts | 2 +- libs/{favourite => favorites}/README.md | 4 +- .../{favourite => favorites}/eslint.config.js | 0 libs/{favourite => favorites}/jest.config.ts | 4 +- libs/{favourite => favorites}/ng-package.json | 2 +- libs/{favourite => favorites}/package.json | 2 +- libs/{favourite => favorites}/project.json | 16 +- libs/favorites/src/index.ts | 2 + .../favorite-button.component.html | 1 + .../favorite-button.component.scss | 20 +++ .../favorite-button.component.ts | 47 +++++ libs/favorites/src/lib/favorites.module.ts | 23 +++ .../lib/services/favorites.service.spec.ts} | 25 +-- .../src/lib/services/favorites.service.ts | 21 +++ .../src/lib/state/favorites.actions.ts | 8 + .../src/lib/state/favorites.effect.ts | 54 ++++++ .../src/lib/state/favorites.reducer.ts | 38 ++++ .../src/lib/state/favorites.selector.ts | 7 + libs/favorites/src/lib/state/index.ts | 3 + libs/favorites/src/lib/styles/_mixins.scss | 1 + libs/favorites/src/lib/styles/_variables.scss | 1 + .../src/test-setup.ts | 0 libs/{favourite => favorites}/tsconfig.json | 0 .../tsconfig.lib.json | 0 .../tsconfig.lib.prod.json | 0 .../tsconfig.spec.json | 0 libs/favourite/src/index.ts | 1 - .../src/lib/services/favourite.service.ts | 15 -- .../migration.sql | 28 +++ libs/persistence/prisma/schema.prisma | 40 ++--- .../asset-search/asset-search-query.dto.ts | 6 +- .../models/asset-search/asset-search-query.ts | 1 + .../asset-search/asset-search-result.dto.ts | 1 + .../src/lib/models/elastic-search-asset.ts | 1 + libs/shared/v2/src/index.ts | 1 + libs/shared/v2/src/lib/models/asset.fake.ts | 33 ++++ .../shared/v2/src/lib/policies/base/policy.ts | 4 +- .../v2/src/lib/policies/favorite.policy.ts | 18 ++ libs/shared/v2/src/test-helpers.ts | 1 + test/data/contact-kind-item.ts | 2 +- tsconfig.base.json | 3 +- 79 files changed, 720 insertions(+), 268 deletions(-) rename libs/asset-viewer/src/lib/components/{asset-search-filter-list/asset-search-filter-list.component.html => asset-search-filter/asset-search-filter.component.html} (100%) rename libs/asset-viewer/src/lib/components/{asset-search-filter-list/asset-search-filter-list.component.scss => asset-search-filter/asset-search-filter.component.scss} (100%) rename libs/asset-viewer/src/lib/components/{asset-search-filter-list/asset-search-filter-list.component.ts => asset-search-filter/asset-search-filter.component.ts} (80%) rename libs/client-shared/src/lib/icons/{favourite.ts => favorite.ts} (87%) rename libs/{favourite => favorites}/README.md (55%) rename libs/{favourite => favorites}/eslint.config.js (100%) rename libs/{favourite => favorites}/jest.config.ts (88%) rename libs/{favourite => favorites}/ng-package.json (75%) rename libs/{favourite => favorites}/package.json (83%) rename libs/{favourite => favorites}/project.json (64%) create mode 100644 libs/favorites/src/index.ts create mode 100644 libs/favorites/src/lib/components/favorite-button/favorite-button.component.html create mode 100644 libs/favorites/src/lib/components/favorite-button/favorite-button.component.scss create mode 100644 libs/favorites/src/lib/components/favorite-button/favorite-button.component.ts create mode 100644 libs/favorites/src/lib/favorites.module.ts rename libs/{favourite/src/lib/services/favourite.service.spec.ts => favorites/src/lib/services/favorites.service.spec.ts} (50%) create mode 100644 libs/favorites/src/lib/services/favorites.service.ts create mode 100644 libs/favorites/src/lib/state/favorites.actions.ts create mode 100644 libs/favorites/src/lib/state/favorites.effect.ts create mode 100644 libs/favorites/src/lib/state/favorites.reducer.ts create mode 100644 libs/favorites/src/lib/state/favorites.selector.ts create mode 100644 libs/favorites/src/lib/state/index.ts create mode 100644 libs/favorites/src/lib/styles/_mixins.scss create mode 100644 libs/favorites/src/lib/styles/_variables.scss rename libs/{favourite => favorites}/src/test-setup.ts (100%) rename libs/{favourite => favorites}/tsconfig.json (100%) rename libs/{favourite => favorites}/tsconfig.lib.json (100%) rename libs/{favourite => favorites}/tsconfig.lib.prod.json (100%) rename libs/{favourite => favorites}/tsconfig.spec.json (100%) delete mode 100644 libs/favourite/src/index.ts delete mode 100644 libs/favourite/src/lib/services/favourite.service.ts create mode 100644 libs/persistence/prisma/migrations/20241028082002_add_favorites/migration.sql create mode 100644 libs/shared/v2/src/lib/models/asset.fake.ts create mode 100644 libs/shared/v2/src/lib/policies/favorite.policy.ts create mode 100644 libs/shared/v2/src/test-helpers.ts diff --git a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html index d8c069ab..b719c2e3 100644 --- a/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html +++ b/apps/client-asset-sg/src/app/components/menu-bar/menu-bar.component.html @@ -5,12 +5,23 @@ asset-sg-menu-bar-item icon="assets" [link]="isActive ? null : [translateService.currentLang]" - [isActive]="isActive && ((isFiltersOpen$ | async) ?? false)" + [isActive]="isActive" (click)="isActive ? toggleAssetDrawer() : null" > menuBar.filters -
  • menuBar.favourites
  • + @if (userExists$ | async) { +
  • + menuBar.favourites +
  • + }
  • = this.router.events.pipe( filter((event) => event instanceof NavigationEnd), + startWith(() => undefined), map((): MenuItem | null => { - const segments = this.router.parseUrl(this.router.url).root.children['primary'].segments; - if (segments.length === 1) { + const segments = (this.router.getCurrentNavigation() ?? this.router.lastSuccessfulNavigation)?.finalUrl?.root + .children['primary'].segments; + if (segments == null || segments.length === 1) { return 'home'; } const path = segments.slice(1).join('/'); @@ -43,6 +45,9 @@ export class MenuBarComponent { if (isPath('asset-admin/new')) { return 'create-asset'; } + if (isPath('favorites')) { + return 'favorites'; + } if (path == 'asset-admin' || isPath('admin')) { return 'options'; } @@ -58,4 +63,4 @@ export class MenuBarComponent { protected readonly AssetEditPolicy = AssetEditPolicy; } -type MenuItem = 'home' | 'create-asset' | 'options'; +type MenuItem = 'home' | 'favorites' | 'create-asset' | 'options'; diff --git a/apps/client-asset-sg/src/app/i18n/de.ts b/apps/client-asset-sg/src/app/i18n/de.ts index ab467b6a..76c96ffa 100644 --- a/apps/client-asset-sg/src/app/i18n/de.ts +++ b/apps/client-asset-sg/src/app/i18n/de.ts @@ -26,6 +26,9 @@ export const deAppTranslations = { nameTaken: "Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", }, }, + favorites: { + title: 'Favoriten', + }, menuBar: { filters: 'Filter', admin: 'Verwaltung', diff --git a/apps/client-asset-sg/src/app/i18n/en.ts b/apps/client-asset-sg/src/app/i18n/en.ts index 25516501..a99c0250 100644 --- a/apps/client-asset-sg/src/app/i18n/en.ts +++ b/apps/client-asset-sg/src/app/i18n/en.ts @@ -28,6 +28,9 @@ export const enAppTranslations: AppTranslations = { nameTaken: "The name '{{name}}' is already taken by another workgroup.", }, }, + favorites: { + title: 'Favorites', + }, menuBar: { filters: 'Filters', admin: 'Administration', diff --git a/apps/client-asset-sg/src/app/i18n/fr.ts b/apps/client-asset-sg/src/app/i18n/fr.ts index f758d055..6ffc8d05 100644 --- a/apps/client-asset-sg/src/app/i18n/fr.ts +++ b/apps/client-asset-sg/src/app/i18n/fr.ts @@ -28,6 +28,9 @@ export const frAppTranslations: AppTranslations = { nameTaken: "Le nom '{{name}}' est déjà utilisé par un autre groupe de travail.", }, }, + favorites: { + title: 'Favorites', + }, menuBar: { filters: 'Filtres', admin: 'Administration', diff --git a/apps/client-asset-sg/src/app/i18n/it.ts b/apps/client-asset-sg/src/app/i18n/it.ts index cd12fb43..53383c97 100644 --- a/apps/client-asset-sg/src/app/i18n/it.ts +++ b/apps/client-asset-sg/src/app/i18n/it.ts @@ -28,6 +28,9 @@ export const itAppTranslations: AppTranslations = { nameTaken: "IT Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", }, }, + favorites: { + title: 'IT Favoriten', + }, menuBar: { filters: 'IT Filter', admin: 'IT Verwaltung', diff --git a/apps/client-asset-sg/src/app/i18n/rm.ts b/apps/client-asset-sg/src/app/i18n/rm.ts index 45bfb3f9..3d118ac1 100644 --- a/apps/client-asset-sg/src/app/i18n/rm.ts +++ b/apps/client-asset-sg/src/app/i18n/rm.ts @@ -28,6 +28,9 @@ export const rmAppTranslations: AppTranslations = { nameTaken: "RM Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", }, }, + favorites: { + title: 'RM Favoriten', + }, menuBar: { filters: 'RM Filter', admin: 'RM Verwaltung', diff --git a/apps/server-asset-sg/src/app.logger.ts b/apps/server-asset-sg/src/app.logger.ts index bd5b924b..840f596a 100644 --- a/apps/server-asset-sg/src/app.logger.ts +++ b/apps/server-asset-sg/src/app.logger.ts @@ -60,14 +60,30 @@ export class AppLogger implements LoggerService { if (!(message instanceof Error)) { output += level.color(`${message}`); } + const suffix = []; if (params.length !== 0 && !(params.length === 1 && params[0] === undefined)) { - output += ' ' + stringify(params, level); + let i = 0; + while (typeof params[i] === 'string') { + const line = params[i] as string; + suffix.push(line); + i += 1; + if (i >= params.length || line.endsWith('\n')) { + break; + } + } + params = params.slice(i); + if (params.length !== 0) { + output += ' ' + stringify(params, level); + } } + const args: unknown[] = [`${prefix} ${output}`]; if (message instanceof Error) { - console.log(`${prefix} ${output}`, message); - } else { - console.log(`${prefix} ${output}`); + args.push(message); } + if (suffix.length !== 0) { + args.push(level.color(`\n${suffix.join('\n')}`)); + } + console.log(...args); } } diff --git a/apps/server-asset-sg/src/app.module.ts b/apps/server-asset-sg/src/app.module.ts index 2512ceb7..68666afe 100644 --- a/apps/server-asset-sg/src/app.module.ts +++ b/apps/server-asset-sg/src/app.module.ts @@ -35,12 +35,12 @@ import { WorkgroupsController } from '@/features/workgroups/workgroups.controlle @Module({ controllers: [ AppController, + FavoritesController, AssetEditController, AssetSearchController, AssetSyncController, AssetsController, ContactsController, - FavoritesController, FilesController, StudiesController, UsersController, diff --git a/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts index 76e25576..d7fa57c9 100644 --- a/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts +++ b/apps/server-asset-sg/src/features/asset-edit/asset-edit.fake.ts @@ -8,7 +8,7 @@ import { fakeAssetFormatItemCode } from '../../../../../test/data/asset-format-i // eslint-disable-next-line @nx/enforce-module-boundaries import { fakeAssetKindItemCode } from '../../../../../test/data/asset-kind-item'; // eslint-disable-next-line @nx/enforce-module-boundaries -import { fakeContactKindItem } from '../../../../../test/data/contact-kind-item'; +import { fakeContactKindItemCode } from '../../../../../test/data/contact-kind-item'; import { define } from '@/utils/define'; @@ -35,7 +35,7 @@ export const fakeUser = () => { export const fakeContact = () => define>({ - contactKindItemCode: fakeContactKindItem(), + contactKindItemCode: fakeContactKindItemCode(), name: faker.company.name(), street: faker.location.street(), houseNumber: faker.location.buildingNumber(), diff --git a/apps/server-asset-sg/src/features/assets/asset-info.repo.ts b/apps/server-asset-sg/src/features/assets/asset-info.repo.ts index f174d8bb..c75f6853 100644 --- a/apps/server-asset-sg/src/features/assets/asset-info.repo.ts +++ b/apps/server-asset-sg/src/features/assets/asset-info.repo.ts @@ -1,8 +1,10 @@ -import { AssetId, AssetInfo } from '@asset-sg/shared/v2'; +import { AssetId, AssetInfo, UserId } from '@asset-sg/shared/v2'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from '@/core/prisma.service'; import { ReadRepo, RepoListOptions } from '@/core/repo'; import { assetInfoSelection, parseAssetInfoFromPrisma } from '@/features/assets/prisma-asset'; +@Injectable() export class AssetInfoRepo implements ReadRepo { constructor(private readonly prisma: PrismaService) {} @@ -28,4 +30,18 @@ export class AssetInfoRepo implements ReadRepo { }); return entries.map(parseAssetInfoFromPrisma); } + + async listFavorites(userId: UserId): Promise { + const entries = await this.prisma.asset.findMany({ + where: { + favorites: { + some: { + userId: userId, + }, + }, + }, + select: assetInfoSelection, + }); + return entries.map(parseAssetInfoFromPrisma); + } } diff --git a/apps/server-asset-sg/src/features/assets/asset.repo.ts b/apps/server-asset-sg/src/features/assets/asset.repo.ts index 4ad86e6e..f3a196e8 100644 --- a/apps/server-asset-sg/src/features/assets/asset.repo.ts +++ b/apps/server-asset-sg/src/features/assets/asset.repo.ts @@ -14,8 +14,8 @@ import { handlePrismaMutationError } from '@/utils/prisma'; export class AssetRepo implements FindRepo, MutateRepo { constructor(private readonly prisma: PrismaService) {} - async count() { - return await this.prisma.asset.count(); + async count(): Promise { + return this.prisma.asset.count(); } async find(id: AssetId): Promise { diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts index f3505308..a1262e2f 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.controller.ts @@ -35,7 +35,7 @@ export class AssetSearchController { limit = limit == null ? limit : Number(limit); offset = offset == null ? offset : Number(offset); restrictQueryForUser(query, user); - const result = await this.assetSearchService.search(query, { limit, offset, decode: false }); + const result = await this.assetSearchService.search(query, user, { limit, offset, decode: false }); return plainToInstance(AssetSearchResultDTO, result); } @@ -48,7 +48,7 @@ export class AssetSearchController { @CurrentUser() user: User ): Promise { restrictQueryForUser(query, user); - const stats = await this.assetSearchService.aggregate(query); + const stats = await this.assetSearchService.aggregate(query, user); return plainToInstance(AssetSearchStatsDTO, stats); } } diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts index 3a9aa4c3..a6fccd39 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.service.spec.ts @@ -145,7 +145,7 @@ describe(AssetSearchService, () => { await create({ patch: fakeAssetPatch(), user }); // When - const result = await service.search({ text: `${text}` }); + const result = await service.search({ text: `${text}` }, user); // Then assertSingleResult(result, asset); @@ -194,11 +194,14 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ - createDate: { - min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), + const result = await service.search( + { + createDate: { + min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), + }, }, - }); + user + ); // Then assertSingleResult(result, asset); @@ -217,11 +220,14 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ - createDate: { - max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + const result = await service.search( + { + createDate: { + max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + }, }, - }); + user + ); // Then assertSingleResult(result, asset); @@ -244,14 +250,18 @@ describe(AssetSearchService, () => { }, user: fakeUser(), }); + const user = fakeUser(); // When - const result = await service.search({ - createDate: { - min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), - max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + const result = await service.search( + { + createDate: { + min: new Date(dateFromDateId(asset.createDate).getTime() - millisPerDay), + max: new Date(dateFromDateId(asset.createDate).getTime() + millisPerDay), + }, }, - }); + user + ); // Then assertSingleResult(result, asset); @@ -274,9 +284,10 @@ describe(AssetSearchService, () => { patch: { ...fakeAssetPatch(), assetLanguages: [{ languageItemCode: code3 }] }, user: fakeUser(), }); + const user = fakeUser(); // When - const result = await service.search({ languageItemCodes: [code1] }); + const result = await service.search({ languageItemCodes: [code1] }, user); // Then assertSingleResult(result, asset); @@ -293,7 +304,7 @@ describe(AssetSearchService, () => { await create({ patch: { ...fakeAssetPatch(), assetKindItemCode: code3 }, user }); // When - const result = await service.search({ assetKindItemCodes: [code1] }); + const result = await service.search({ assetKindItemCodes: [code1] }, user); // Then assertSingleResult(result, asset); @@ -310,7 +321,7 @@ describe(AssetSearchService, () => { await create({ patch: { ...fakeAssetPatch(), manCatLabelRefs: [code3] }, user }); // When - const result = await service.search({ manCatLabelItemCodes: [code1] }); + const result = await service.search({ manCatLabelItemCodes: [code1] }, user); // Then assertSingleResult(result, asset); @@ -346,7 +357,7 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ usageCodes: [usageCode] }); + const result = await service.search({ usageCodes: [usageCode] }, user); // Then assertSingleResult(result, asset); @@ -371,7 +382,7 @@ describe(AssetSearchService, () => { }); // When - const result = await service.search({ authorId: contact1.contactId }); + const result = await service.search({ authorId: contact1.contactId }, user); // Then assertSingleResult(result, asset); @@ -417,8 +428,11 @@ describe(AssetSearchService, () => { }; it('returns empty stats when no assets are present', async () => { + // Given + const user = fakeUser(); + // When - const result = await service.aggregate({}); + const result = await service.aggregate({}, user); // Then expect(result.total).toEqual(0); @@ -436,7 +450,7 @@ describe(AssetSearchService, () => { const asset = await create({ patch: fakeAssetPatch(), user }); // When - const result = await service.aggregate({}); + const result = await service.aggregate({}, user); // Then assertSingleStats(result, asset); diff --git a/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts b/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts index 40d4f4a5..8a75f910 100644 --- a/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts +++ b/apps/server-asset-sg/src/features/assets/search/asset-search.service.ts @@ -19,9 +19,10 @@ import { UsageCode, ValueCount, } from '@asset-sg/shared'; -import { AssetId, StudyId } from '@asset-sg/shared/v2'; +import { AssetId, StudyId, User } from '@asset-sg/shared/v2'; import { Client as ElasticsearchClient } from '@elastic/elasticsearch'; import { + AggregationsAggregationContainer, BulkOperationContainer, QueryDslNumberRangeQuery, QueryDslQueryContainer, @@ -175,6 +176,7 @@ export class AssetSearchService { * Searches for assets using a {@link AssetSearchQuery}. * * @param query The query to match with. + * @param user The user that is executing the query. * @param limit The maximum amount of assets to load. Defaults to `100`. * @param offset The amount of assets being skipped before loading the assets. * @param decode Whether to decode the assets. If this is set to `false`, the assets should be decoded via `AssetEditDetail` before accessing them. @@ -182,10 +184,11 @@ export class AssetSearchService { */ async search( query: AssetSearchQuery, + user: User, { limit = 100, offset = 0, decode = true }: PageOptions & { decode?: boolean } = {} ): Promise { // Apply the query to find all matching ids. - const [serializedAssets, total] = await this.searchAssetsByQuery(query, { limit, offset }); + const [serializedAssets, total] = await this.searchAssetsByQuery(query, user, { limit, offset }); // Load the matched assets from the database. const data: AssetEditDetail[] = []; @@ -209,8 +212,15 @@ export class AssetSearchService { * Aggregates the stats over all assets matching a specific {@link AssetSearchQuery}. * * @param query The query to match with. + * @param user The user that is executing the query. */ - async aggregate(query: AssetSearchQuery): Promise { + async aggregate(query: AssetSearchQuery, user: User): Promise { + type NestedAggResult = { + [K in keyof T]: { + a: T[K]; + }; + }; + interface Result { minCreateDate: { value: DateId }; maxCreateDate: { value: DateId }; @@ -242,36 +252,37 @@ export class AssetSearchService { doc_count: number; } - const aggregateGroup = async ( - query: AssetSearchQuery, + const makeAggregation = ( operator: 'terms' | 'min' | 'max', groupName: string, fieldName?: string - ) => { + ): AggregationsAggregationContainer => { const NUMBER_OF_BUCKETS = 10_000; - const elasticDslQuery = mapQueryToElasticDsl({ ...query, [groupName]: undefined }); + const { filter } = mapQueryToElasticDslParts({ ...query, [groupName]: undefined }, user); const field: { field: string; size?: number } = { field: fieldName ?? groupName, size: NUMBER_OF_BUCKETS }; if (operator !== 'terms') { delete field.size; } - return ( - await this.elastic.search({ - index: INDEX, - size: 0, - query: elasticDslQuery, - track_total_hits: true, - aggregations: { - agg: { [operator]: field }, - }, - }) - ).aggregations?.agg; + return { aggs: { a: { [operator]: field } }, filter: { bool: { filter } } }; + }; + + const { must, filter } = mapQueryToElasticDslParts(query, user); + + const aggregateByQuery = async (aggs: Record) => { + return await this.elastic.search({ + index: INDEX, + size: 0, + query: { bool: { must } }, + track_total_hits: true, + aggregations: aggs, + filter_path: ['aggregations.*.a.buckets.*', 'aggregations.*.a.value'], + }); }; - const elasticQuery = mapQueryToElasticDsl(query); const response = await this.elastic.search({ index: INDEX, size: 0, - query: elasticQuery, + query: { bool: { must, filter } }, track_total_hits: true, }); const total = (response.hits.total as SearchTotalHits).value; @@ -289,38 +300,19 @@ export class AssetSearchService { }; } - const [ - assetKindItemCodes, - authorIds, - languageItemCodes, - geometryCodes, - manCatLabelItemCodes, - usageCodes, - workgroupIds, - minCreateDate, - maxCreateDate, - ] = await Promise.all([ - aggregateGroup(query, 'terms', 'assetKindItemCodes', 'assetKindItemCode'), - aggregateGroup(query, 'terms', 'authorIds'), - aggregateGroup(query, 'terms', 'languageItemCodes'), - aggregateGroup(query, 'terms', 'geometryCodes'), - aggregateGroup(query, 'terms', 'manCatLabelItemCodes'), - aggregateGroup(query, 'terms', 'usageCodes', 'usageCode'), - aggregateGroup(query, 'terms', 'workgroupIds', 'workgroupId'), - aggregateGroup(query, 'min', 'minCreateDate', 'createDate'), - aggregateGroup(query, 'max', 'maxCreateDate', 'createDate'), - ]); - const aggs = { - assetKindItemCodes, - authorIds, - languageItemCodes, - geometryCodes, - manCatLabelItemCodes, - usageCodes, - workgroupIds, - minCreateDate, - maxCreateDate, - } as unknown as Result; + const result = await aggregateByQuery({ + assetKindItemCodes: makeAggregation('terms', 'assetKindItemCodes', 'assetKindItemCode'), + authorIds: makeAggregation('terms', 'authorIds'), + languageItemCodes: makeAggregation('terms', 'languageItemCodes'), + geometryCodes: makeAggregation('terms', 'geometryCodes'), + manCatLabelItemCodes: makeAggregation('terms', 'manCatLabelItemCodes'), + usageCodes: makeAggregation('terms', 'usageCodes', 'usageCode'), + workgroupIds: makeAggregation('terms', 'workgroupIds', 'workgroupId'), + minCreateDate: makeAggregation('min', 'minCreateDate', 'createDate'), + maxCreateDate: makeAggregation('max', 'maxCreateDate', 'createDate'), + }); + + const aggs = result.aggregations as unknown as NestedAggResult; const mapBucket = (bucket: AggregationBucket): ValueCount => ({ value: bucket.key, @@ -328,16 +320,16 @@ export class AssetSearchService { }); return { total, - assetKindItemCodes: aggs.assetKindItemCodes.buckets.map(mapBucket), - authorIds: aggs.authorIds.buckets.map(mapBucket), - languageItemCodes: aggs.languageItemCodes.buckets.map(mapBucket), - geometryCodes: aggs.geometryCodes.buckets.map(mapBucket), - manCatLabelItemCodes: aggs.manCatLabelItemCodes.buckets.map(mapBucket), - usageCodes: aggs.usageCodes.buckets.map(mapBucket), - workgroupIds: aggs.workgroupIds.buckets.map(mapBucket), + assetKindItemCodes: aggs.assetKindItemCodes?.a?.buckets?.map(mapBucket) ?? [], + authorIds: aggs.authorIds?.a?.buckets?.map(mapBucket) ?? [], + languageItemCodes: aggs.languageItemCodes?.a?.buckets?.map(mapBucket) ?? [], + geometryCodes: aggs.geometryCodes?.a?.buckets?.map(mapBucket) ?? [], + manCatLabelItemCodes: aggs.manCatLabelItemCodes?.a?.buckets?.map(mapBucket) ?? [], + usageCodes: aggs.usageCodes?.a?.buckets?.map(mapBucket) ?? [], + workgroupIds: aggs.workgroupIds?.a?.buckets?.map(mapBucket) ?? [], createDate: { - min: dateFromDateId(aggs.minCreateDate.value), - max: dateFromDateId(aggs.maxCreateDate.value), + min: dateFromDateId(aggs.minCreateDate.a.value), + max: dateFromDateId(aggs.maxCreateDate.a.value), }, }; } @@ -379,11 +371,12 @@ export class AssetSearchService { private async searchAssetsByQuery( query: AssetSearchQuery, + user: User, page: PageOptions = {} ): Promise<[Map, number]> { const BATCH_SIZE = 10_000; - const elasticQuery = mapQueryToElasticDsl(query); + const elasticQuery = mapQueryToElasticDsl(query, user); const matchedAssets = new Map(); let lastAssetId: number | null = null; let totalCount: number | null = null; @@ -677,6 +670,20 @@ export class AssetSearchService { const languageItemCodes = asset.assetLanguages.length === 0 ? ['None'] : asset.assetLanguages.map((it) => it.languageItemCode); + + const favoredByUsers = await this.prisma.assetUser.findMany({ + select: { + id: true, + }, + where: { + favorites: { + some: { + assetId: asset.assetId, + }, + }, + }, + }); + return { assetId: asset.assetId, titlePublic: asset.titlePublic, @@ -692,6 +699,7 @@ export class AssetSearchService { geometryCodes: geometryCodes.length > 0 ? [...new Set(geometryCodes)] : ['None'], studyLocations, workgroupId: asset.workgroupId, + favoredByUserIds: favoredByUsers.map(({ id }) => id), data: JSON.stringify(AssetEditDetail.encode(asset)), }; } @@ -706,7 +714,20 @@ const mapLv95ToElastic = (lv95: LV95): ElasticPoint => { return { lat: wgs[1], lon: wgs[0] }; }; -const mapQueryToElasticDsl = (query: AssetSearchQuery): QueryDslQueryContainer => { +const mapQueryToElasticDsl = (query: AssetSearchQuery, user: User): QueryDslQueryContainer => { + const { must, filter } = mapQueryToElasticDslParts(query, user); + return { + bool: { + must, + filter, + }, + }; +}; + +const mapQueryToElasticDslParts = ( + query: AssetSearchQuery, + user: User +): { must: QueryDslQueryContainer[]; filter: QueryDslQueryContainer[] } => { const scope = ['titlePublic', 'titleOriginal', 'contactNames', 'sgsId']; const queries: QueryDslQueryContainer[] = []; const filters: QueryDslQueryContainer[] = []; @@ -767,6 +788,14 @@ const mapQueryToElasticDsl = (query: AssetSearchQuery): QueryDslQueryContainer = }, }); } + if (query.favoritesOnly) { + filters.push({ + terms: { + favoredByUserIds: [user.id], + }, + }); + } + if (query.polygon != null) { queries.push({ geo_polygon: { @@ -776,12 +805,9 @@ const mapQueryToElasticDsl = (query: AssetSearchQuery): QueryDslQueryContainer = }, }); } - return { - bool: { - must: queries, - filter: filters, - }, + must: queries, + filter: filters, }; }; diff --git a/apps/server-asset-sg/src/features/favorites/favorite.repo.ts b/apps/server-asset-sg/src/features/favorites/favorite.repo.ts index 6b3220ba..6d2d78f7 100644 --- a/apps/server-asset-sg/src/features/favorites/favorite.repo.ts +++ b/apps/server-asset-sg/src/features/favorites/favorite.repo.ts @@ -13,9 +13,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { - const entry = await this.prisma.assetUserFavourite.findUnique({ + const entry = await this.prisma.favorite.findUnique({ where: { - assetUserId_assetId: mapFavoriteToId(favorite), + userId_assetId: mapFavoriteToId(favorite), }, select: favoriteSelection, }); @@ -23,7 +23,7 @@ export class FavoriteRepo implements ReadRepo, CreateRepo = {}): Promise { - const entries = await this.prisma.assetUserFavourite.findMany({ + const entries = await this.prisma.favorite.findMany({ where: ids == null ? undefined @@ -38,9 +38,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { - const entries = await this.prisma.assetUserFavourite.findMany({ + const entries = await this.prisma.favorite.findMany({ where: { - assetUserId: userId, + userId: userId, }, select: favoriteSelection, }); @@ -49,10 +49,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { try { - const entry = await this.prisma.assetUserFavourite.create({ + const entry = await this.prisma.favorite.create({ data: { ...mapFavoriteToId(favorite), - created_at: new Date(), }, select: favoriteSelection, }); @@ -69,9 +68,9 @@ export class FavoriteRepo implements ReadRepo, CreateRepo { try { - await this.prisma.assetUserFavourite.delete({ + await this.prisma.favorite.delete({ where: { - assetUserId_assetId: mapFavoriteToId(favorite), + userId_assetId: mapFavoriteToId(favorite), }, }); return true; @@ -81,19 +80,19 @@ export class FavoriteRepo implements ReadRepo, CreateRepo()({ - assetUserId: true, +const favoriteSelection = satisfy()({ + userId: true, assetId: true, }); -type SelectedFavorite = Prisma.AssetUserFavouriteGetPayload<{ select: typeof favoriteSelection }>; +type SelectedFavorite = Prisma.FavoriteGetPayload<{ select: typeof favoriteSelection }>; const parse = (data: SelectedFavorite): Favorite => ({ assetId: data.assetId, - userId: data.assetUserId, + userId: data.userId, }); -const mapFavoriteToId = (favorite: Favorite): Prisma.AssetUserFavouriteAssetUserIdAssetIdCompoundUniqueInput => ({ - assetUserId: favorite.userId, +const mapFavoriteToId = (favorite: Favorite): Prisma.FavoriteUserIdAssetIdCompoundUniqueInput => ({ + userId: favorite.userId, assetId: favorite.assetId, }); diff --git a/apps/server-asset-sg/src/features/favorites/favorites.controller.ts b/apps/server-asset-sg/src/features/favorites/favorites.controller.ts index f1356277..42594ee9 100644 --- a/apps/server-asset-sg/src/features/favorites/favorites.controller.ts +++ b/apps/server-asset-sg/src/features/favorites/favorites.controller.ts @@ -1,40 +1,70 @@ -import { SearchAssetResult, SearchAssetResultEmpty } from '@asset-sg/shared'; -import { User } from '@asset-sg/shared/v2'; -import { Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Put } from '@nestjs/common'; +import { AssetId, AssetInfo, FavoritePolicy, User } from '@asset-sg/shared/v2'; +import { + Controller, + Delete, + Get, + HttpCode, + HttpException, + HttpStatus, + Param, + ParseIntPipe, + Post, +} from '@nestjs/common'; +import { authorize } from '@/core/authorize'; import { Authorize } from '@/core/decorators/authorize.decorator'; import { CurrentUser } from '@/core/decorators/current-user.decorator'; +import { AssetEditRepo } from '@/features/asset-edit/asset-edit.repo'; +import { AssetInfoRepo } from '@/features/assets/asset-info.repo'; import { AssetSearchService } from '@/features/assets/search/asset-search.service'; import { FavoriteRepo } from '@/features/favorites/favorite.repo'; -import { define } from '@/utils/define'; -@Controller('/users/current/favorites') +@Controller('/assets/favorites') export class FavoritesController { - constructor(private readonly favoriteRepo: FavoriteRepo, private readonly assetSearchService: AssetSearchService) {} + constructor( + private readonly favoriteRepo: FavoriteRepo, + private readonly assetInfoRepo: AssetInfoRepo, + private readonly assetEditRepo: AssetEditRepo, + private readonly assetSearchService: AssetSearchService + ) {} - // TODO make an alternative, new endpoint for this that does not use fp-ts. @Get('/') @Authorize.User() - async list(@CurrentUser() user: User): Promise { - const favorites = await this.favoriteRepo.listByUserId(user.id); - if (favorites.length === 0) { - return define({ _tag: 'SearchAssetResultEmpty' }); - } - const assetIds = favorites.map((it) => it.assetId); - return await this.assetSearchService.searchOld('', { scope: ['titlePublic'], assetIds }); + async list(@CurrentUser() user: User): Promise { + return await this.assetInfoRepo.listFavorites(user.id); + } + + @Get('/ids') + @Authorize.User() + async listIds(@CurrentUser() user: User): Promise { + const assets = await this.assetInfoRepo.listFavorites(user.id); + return assets.map(({ id }) => id); } - @Put('/:assetId') + @Post('/:assetId') @Authorize.User() @HttpCode(HttpStatus.NO_CONTENT) - async add(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { + async create(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { + authorize(FavoritePolicy, user).canCreate(); + const asset = await this.assetEditRepo.find(assetId); + if (asset == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } await this.favoriteRepo.create({ userId: user.id, assetId }); + await this.assetSearchService.register(asset); } @Delete('/:assetId') @Authorize.User() @HttpCode(HttpStatus.NO_CONTENT) - async remove(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { - await this.favoriteRepo.delete({ userId: user.id, assetId }); + async delete(@Param('assetId', ParseIntPipe) assetId: number, @CurrentUser() user: User): Promise { + const asset = await this.assetEditRepo.find(assetId); + if (asset == null) { + throw new HttpException('not found', HttpStatus.NOT_FOUND); + } + const favorite = { userId: user.id, assetId }; + authorize(FavoritePolicy, user).canDelete(favorite); + await this.favoriteRepo.delete(favorite); + await this.assetSearchService.register(asset); } } diff --git a/apps/server-asset-sg/src/features/favorites/favorites.http b/apps/server-asset-sg/src/features/favorites/favorites.http index f7246395..e9dd6243 100644 --- a/apps/server-asset-sg/src/features/favorites/favorites.http +++ b/apps/server-asset-sg/src/features/favorites/favorites.http @@ -1,11 +1,13 @@ -### Show favourites -GET {{host}}/api/users/current/favorites +@assetId = 12 + +### List favourites +GET {{host}}/api/assets/favorites Authorization: Impersonate {{user}} ### Add favourite -PUT {{host}}/api/users/current/favorites/5 +POST {{host}}/api/assets/favorites/{{assetId}} Authorization: Impersonate {{user}} ### Remove favourite -DELETE {{host}}/api/users/current/favorites/5 +DELETE {{host}}/api/assets/favorites/{{assetId}} Authorization: Impersonate {{user}} diff --git a/apps/server-asset-sg/src/features/users/user.repo.ts b/apps/server-asset-sg/src/features/users/user.repo.ts index e7bbf11f..3e1fda49 100644 --- a/apps/server-asset-sg/src/features/users/user.repo.ts +++ b/apps/server-asset-sg/src/features/users/user.repo.ts @@ -92,7 +92,7 @@ export class UserRepo implements Repo { // The schema does not define how to handle deletes on users with existing favourites, // so we need to delete them manually. - await this.prisma.assetUserFavourite.deleteMany({ where: { assetUserId: id } }); + await this.prisma.favorite.deleteMany({ where: { userId: id } }); await this.prisma.assetUser.delete({ where: { id } }); }); return true; diff --git a/apps/server-asset-sg/src/main.ts b/apps/server-asset-sg/src/main.ts index b79fe2d7..128e38fe 100644 --- a/apps/server-asset-sg/src/main.ts +++ b/apps/server-asset-sg/src/main.ts @@ -11,6 +11,8 @@ export * from '@prisma/client'; const API_PREFIX = 'api'; const API_PORT = process.env.PORT || 3333; +process.on('warning', (e) => console.warn(e.stack)); + async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { logger: new AppLogger(), diff --git a/development/init/elasticsearch/mappings/swissgeol_asset_asset.json b/development/init/elasticsearch/mappings/swissgeol_asset_asset.json index b4810b82..e0f5f259 100644 --- a/development/init/elasticsearch/mappings/swissgeol_asset_asset.json +++ b/development/init/elasticsearch/mappings/swissgeol_asset_asset.json @@ -59,6 +59,9 @@ }, "studyLocations": { "type": "geo_point" + }, + "favoredByUserIds": { + "type": "keyword" } } } diff --git a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts index 2832aa27..d343019f 100644 --- a/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts +++ b/libs/asset-editor/src/lib/components/asset-editor-launch/asset-editor-launch.component.ts @@ -4,6 +4,7 @@ import { ChangeDetectorRef, Component, inject, + OnDestroy, TemplateRef, ViewChild, ViewContainerRef, @@ -26,7 +27,7 @@ import { asyncScheduler, observeOn } from 'rxjs'; hostDirectives: [LifecycleHooksDirective], styleUrls: ['./asset-editor-launch.component.scss'], }) -export class AssetEditorLaunchComponent { +export class AssetEditorLaunchComponent implements OnDestroy { @ViewChild('templateDrawerPortalContent') _templateDrawerPortalContent!: TemplateRef; private _lc = inject(LifecycleHooks); @@ -46,4 +47,9 @@ export class AssetEditorLaunchComponent { this._store.dispatch(appSharedStateActions.openPanel()); }); } + + ngOnDestroy(): void { + this._appPortalService.setAppBarPortalContent(null); + this._appPortalService.setDrawerPortalContent(null); + } } diff --git a/libs/asset-viewer/src/lib/asset-viewer.module.ts b/libs/asset-viewer/src/lib/asset-viewer.module.ts index a215d0cf..a5b31206 100644 --- a/libs/asset-viewer/src/lib/asset-viewer.module.ts +++ b/libs/asset-viewer/src/lib/asset-viewer.module.ts @@ -40,6 +40,7 @@ import { ValueItemNamePipe, ZoomControlsComponent, } from '@asset-sg/client-shared'; +import { FavoritesModule } from '@asset-sg/favorites'; import { SvgIconComponent } from '@ngneat/svg-icon'; import { EffectsModule } from '@ngrx/effects'; import { StoreModule } from '@ngrx/store'; @@ -51,7 +52,7 @@ import { de } from 'date-fns/locale/de'; import { AssetPickerComponent } from './components/asset-picker'; import { AssetSearchDetailComponent } from './components/asset-search-detail'; -import { AssetSearchFilterListComponent } from './components/asset-search-filter-list/asset-search-filter-list.component'; +import { AssetSearchFilterComponent } from './components/asset-search-filter/asset-search-filter.component'; import { AssetSearchRefineComponent } from './components/asset-search-refine'; import { AssetSearchResultsComponent } from './components/asset-search-results'; import { AssetViewerFilesComponent } from './components/asset-viewer-files/asset-viewer-files.component'; @@ -69,13 +70,14 @@ import { mapControlReducer } from './state/map-control/map-control.reducer'; MapControlsComponent, AssetSearchDetailComponent, AssetSearchRefineComponent, - AssetSearchFilterListComponent, + AssetSearchFilterComponent, AssetSearchResultsComponent, AssetViewerFilesComponent, AssetPickerComponent, ], imports: [ CommonModule, + FavoritesModule, RouterModule.forChild([ { path: '', diff --git a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.html b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.html similarity index 100% rename from libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.html rename to libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.html diff --git a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.scss b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.scss similarity index 100% rename from libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.scss rename to libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.scss diff --git a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.ts similarity index 80% rename from libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts rename to libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.ts index bc03ba71..897b2564 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-filter-list/asset-search-filter-list.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-filter/asset-search-filter.component.ts @@ -5,11 +5,11 @@ import { AppStateWithAssetSearch } from '../../state/asset-search/asset-search.r import { Filter } from '../../state/asset-search/asset-search.selector'; @Component({ - selector: 'asset-sg-asset-search-filter-list', - templateUrl: './asset-search-filter-list.component.html', - styleUrl: './asset-search-filter-list.component.scss', + selector: 'asset-sg-asset-search-filter', + templateUrl: './asset-search-filter.component.html', + styleUrl: './asset-search-filter.component.scss', }) -export class AssetSearchFilterListComponent { +export class AssetSearchFilterComponent { @Input({ required: true }) filters!: Array>; diff --git a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html index 341041c6..ed724854 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html +++ b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.html @@ -20,27 +20,27 @@

    search.searchControl

    search.refineSearch

    search.workgroup

    - +

    search.usage

    - +

    search.geometry

    - +

    search.language

    - +

    search.kind

    - +

    search.topic

    - +

    search.author

    diff --git a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts index 5d0e25db..2458901e 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-refine/asset-search-refine.component.ts @@ -55,6 +55,7 @@ export class AssetSearchRefineComponent implements OnInit, OnDestroy, AfterViewI readonly languageFilters$ = this.store.select(selectLanguageFilters); readonly assetKindFilters$ = this.store.select(selectAssetKindFilters); readonly workgroupFilters$ = this.store.select(selectWorkgroupFilters); + readonly isDrawActive$ = this.store.select(selectMapControlIsDrawing); private readonly subscriptions: Subscription = new Subscription(); diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html index 48a41684..7e007814 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.html @@ -4,7 +4,7 @@ {{ "search.searchResults" | translate }}: - @@ -16,9 +16,7 @@ - + diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss index 6808d8e9..d820c8d8 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.scss @@ -25,12 +25,6 @@ height: 400px; } -.asset-favourite { - height: 2.5rem; - width: 2.5rem; - color: variables.$cyan-09; -} - .mat-mdc-header-row .mat-mdc-cell { background-color: variables.$grey-01; } diff --git a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts index 3f19ad40..8428892b 100644 --- a/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts +++ b/libs/asset-viewer/src/lib/components/asset-search-results/asset-search-results.component.ts @@ -68,7 +68,7 @@ export class AssetSearchResultsComponent implements OnInit, OnDestroy { this._store.dispatch(actions.assetClicked({ assetId })); } - public toggleResultsOpen(isCurrentlyOpen: boolean) { + public toggleResultsOpen() { this._store.dispatch(actions.manualToggleResult()); } diff --git a/libs/asset-viewer/src/lib/components/map/map-controller.ts b/libs/asset-viewer/src/lib/components/map/map-controller.ts index 14964b9b..01520ab8 100644 --- a/libs/asset-viewer/src/lib/components/map/map-controller.ts +++ b/libs/asset-viewer/src/lib/components/map/map-controller.ts @@ -56,6 +56,8 @@ export class MapController { private showHeatmap = true; + private isInitialized = false; + constructor(element: HTMLElement) { const view = new View({ projection: 'EPSG:3857', @@ -97,6 +99,7 @@ export class MapController { this.assetsHover$ = this.makeAssetsHover$(); this.map.once('loadend', () => { + this.isInitialized = true; if (this.activeAsset === null) { const zoom = view.getZoom(); if (zoom != null) { @@ -106,6 +109,10 @@ export class MapController { }); } + resetZoom(): void { + fitToSwitzerland(this.map.getView(), true); + } + setShowHeatmap(showHeatmap: boolean): void { this.showHeatmap = showHeatmap; this.layers.heatmap.setVisible(showHeatmap); @@ -184,7 +191,9 @@ export class MapController { this.sources.assets.clear(); this.sources.assets.addFeatures(features); this.sources.picker.clear(); - zoomToStudies(this.map, studies); + if (this.isInitialized) { + zoomToStudies(this.map, studies); + } }); } diff --git a/libs/asset-viewer/src/lib/components/map/map.component.ts b/libs/asset-viewer/src/lib/components/map/map.component.ts index 33a7051f..fe08f5ad 100644 --- a/libs/asset-viewer/src/lib/components/map/map.component.ts +++ b/libs/asset-viewer/src/lib/components/map/map.component.ts @@ -21,7 +21,7 @@ import { selectAssetSearchPolygon, selectAssetSearchResultData, selectCurrentAssetDetail, - selectAssetSearchNoActiveFilters, + selectHasDefaultFilters, selectStudies, } from '../../state/asset-search/asset-search.selector'; import { AppStateWithMapControl } from '../../state/map-control/map-control.reducer'; @@ -128,6 +128,14 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { this.controller.addControl(this.controls.zoom); this.controller.addControl(this.controls.draw); + this.subscription.add( + this.store.select(selectHasDefaultFilters).subscribe((hasDefaultFilters) => { + if (hasDefaultFilters) { + this.controller.resetZoom(); + } + }) + ); + this.controls.draw.isDrawing$.subscribe((isDrawing) => { this.controller.setClickEnabled(!isDrawing); }); @@ -142,13 +150,12 @@ export class MapComponent implements AfterViewInit, OnChanges, OnDestroy { this.initializeStoreBindings(); this.handleHighlightedAssetIdChange(); }); - this.initializeEnd.emit(); } private initializeStoreBindings() { this.subscription.add( - this.store.select(selectAssetSearchNoActiveFilters).subscribe((showStudies) => { + this.store.select(selectHasDefaultFilters).subscribe((showStudies) => { this.controller.setShowHeatmap(showStudies); }) ); diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts index 2d886f9a..2683c717 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.effects.ts @@ -6,20 +6,20 @@ import { AssetSearchQuery, AssetSearchResult, LV95, Polygon } from '@asset-sg/sh import * as RD from '@devexperts/remote-data-ts'; import { UntilDestroy } from '@ngneat/until-destroy'; import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; -import { ROUTER_NAVIGATED } from '@ngrx/router-store'; +import { ROUTER_NAVIGATED, RouterNavigatedPayload } from '@ngrx/router-store'; import { Store } from '@ngrx/store'; import * as D from 'io-ts/Decoder'; -import { EMPTY, filter, map, merge, of, shareReplay, switchMap, takeUntil, withLatestFrom } from 'rxjs'; +import { EMPTY, filter, identity, map, merge, of, shareReplay, switchMap, withLatestFrom } from 'rxjs'; import { AllStudyService } from '../../services/all-study.service'; import { AssetSearchService } from '../../services/asset-search.service'; import * as actions from './asset-search.actions'; import { - selectAssetSearchNoActiveFilters, selectAssetSearchQuery, selectAssetSearchResultData, selectCurrentAssetDetail, + selectHasDefaultFilters, selectStudies, } from './asset-search.selector'; @@ -117,17 +117,16 @@ export class AssetSearchEffects { public openSearchResults$ = createEffect(() => { return this.actions$.pipe( ofType(actions.updateSearchResults), - takeUntil(this.actions$.pipe(ofType(actions.manualToggleResult))), - withLatestFrom(this.store.select(selectAssetSearchNoActiveFilters)), - map(([_, showStudies]) => (showStudies ? actions.closeResults() : actions.openResults())) + switchMap(() => this.store.select(selectHasDefaultFilters)), + map((showStudies) => (showStudies ? actions.closeResults() : actions.openResults())) ); }); public closeSearchResults$ = createEffect(() => { return this.actions$.pipe( ofType(actions.updateSearchResults), - withLatestFrom(this.store.select(selectAssetSearchNoActiveFilters)), - filter(([_, showStudies]) => showStudies), + switchMap(() => this.store.select(selectHasDefaultFilters)), + filter(identity), map(() => actions.closeResults()) ); }); @@ -147,8 +146,13 @@ export class AssetSearchEffects { */ public queryParams$ = this.actions$.pipe( ofType(ROUTER_NAVIGATED), - filter((x) => assetsPageMatcher(x.payload.routerState.root.firstChild.url) !== null), + filter( + ({ payload }: { payload: RouterNavigatedPayload }) => + assetsPageMatcher(payload.routerState.root.firstChild?.url ?? []) !== null + ), map(({ payload }) => { + const routerState = payload.routerState; + const url = routerState.root.firstChild?.url ?? []; const params = payload.routerState.root.queryParams; const query: AssetSearchQuery = {}; const assetId: number | undefined = readNumberParam(params, QUERY_PARAM_MAPPING.assetId); @@ -164,6 +168,7 @@ export class AssetSearchEffects { query.geometryCodes = readArrayParam(params, QUERY_PARAM_MAPPING.geometryCodes); query.languageItemCodes = readArrayParam(params, QUERY_PARAM_MAPPING.languageItemCodes); query.workgroupIds = readArrayParam(params, QUERY_PARAM_MAPPING.workgroupIds); + query.favoritesOnly = url.length === 2 && url[1].path === 'favorites'; return { query, assetId }; }), shareReplay() @@ -250,9 +255,10 @@ const QUERY_PARAM_MAPPING = { languageItemCodes: 'search[lang]', assetId: 'assetId', workgroupIds: 'search[workgroup]', + categories: 'search[categories]', }; -const updatePlainParam = (params: Params, name: string, value: string | number | undefined): void => { +const updatePlainParam = (params: Params, name: string, value: string | number | boolean | undefined): void => { if (value == null) { delete params[name]; return; diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts index f8b518fe..3e9e5ea6 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.reducer.ts @@ -29,17 +29,7 @@ export interface AppStateWithAssetSearch extends AppState { } const initialState: AssetSearchState = { - query: { - text: undefined, - polygon: undefined, - authorId: undefined, - createDate: undefined, - manCatLabelItemCodes: undefined, - assetKindItemCodes: undefined, - usageCodes: undefined, - geometryCodes: undefined, - languageItemCodes: undefined, - }, + query: {}, results: { page: { size: 0, diff --git a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts index c9ac9849..0e4413e6 100644 --- a/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts +++ b/libs/asset-viewer/src/lib/state/asset-search/asset-search.selector.ts @@ -57,8 +57,10 @@ export const selectAssetSearchTotalResults = createSelector(assetSearchFeature, export const selectCurrentAssetDetail = createSelector(assetSearchFeature, (state) => state.currentAsset); export const selectStudies = createSelector(assetSearchFeature, (state) => state.studies); -export const selectAssetSearchNoActiveFilters = createSelector(assetSearchFeature, ({ query }) => - Object.values(query).every((value) => value === undefined) + +export const selectHasDefaultFilters = createSelector( + assetSearchFeature, + ({ query, currentAsset }) => currentAsset == null && Object.values(query).every((value) => value === undefined) ); export const selectCurrentAssetDetailVM = createSelector( diff --git a/libs/client-shared/src/lib/icons/favourite.ts b/libs/client-shared/src/lib/icons/favorite.ts similarity index 87% rename from libs/client-shared/src/lib/icons/favourite.ts rename to libs/client-shared/src/lib/icons/favorite.ts index 2662e8ec..12b23a53 100644 --- a/libs/client-shared/src/lib/icons/favourite.ts +++ b/libs/client-shared/src/lib/icons/favorite.ts @@ -1,4 +1,4 @@ -export const favouriteIcon = { +export const favoriteIcon = { data: ` @@ -6,5 +6,5 @@ export const favouriteIcon = { `, - name: 'favourite' as const, + name: 'favorite' as const, }; diff --git a/libs/client-shared/src/lib/icons/index.ts b/libs/client-shared/src/lib/icons/index.ts index 67d54ae1..45a0354e 100644 --- a/libs/client-shared/src/lib/icons/index.ts +++ b/libs/client-shared/src/lib/icons/index.ts @@ -15,7 +15,7 @@ import { errorIcon } from './error'; import { errorFilledIcon } from './error-filled'; import { extLinkIcon } from './ext-link'; import { failureIcon } from './failure'; -import { favouriteIcon } from './favourite'; +import { favoriteIcon } from './favorite'; import { helpIcon } from './help'; import { infoIcon } from './info'; import { infoFilledIcon } from './info-filled'; @@ -52,7 +52,7 @@ export const icons = [ errorFilledIcon, extLinkIcon, failureIcon, - favouriteIcon, + favoriteIcon, helpIcon, infoFilledIcon, optionsIcon, diff --git a/libs/client-shared/src/lib/utils/app-matchers.ts b/libs/client-shared/src/lib/utils/app-matchers.ts index 52ff1cf1..f28dc425 100644 --- a/libs/client-shared/src/lib/utils/app-matchers.ts +++ b/libs/client-shared/src/lib/utils/app-matchers.ts @@ -2,7 +2,7 @@ import { UrlMatchResult, UrlSegment } from '@angular/router'; import { isSupportedLang } from '../i18n'; -const validSegments = ['assets', 'favourites']; +const validSegments = ['assets', 'favorites']; export function assetsPageMatcher(segments: UrlSegment[]): UrlMatchResult | null { if (segments.length === 0) { diff --git a/libs/favourite/README.md b/libs/favorites/README.md similarity index 55% rename from libs/favourite/README.md rename to libs/favorites/README.md index a12e0b06..6413092e 100644 --- a/libs/favourite/README.md +++ b/libs/favorites/README.md @@ -1,7 +1,7 @@ -# favourite +# favorites This library was generated with [Nx](https://nx.dev). ## Running unit tests -Run `nx test favourite` to execute the unit tests. +Run `nx test favorites` to execute the unit tests. diff --git a/libs/favourite/eslint.config.js b/libs/favorites/eslint.config.js similarity index 100% rename from libs/favourite/eslint.config.js rename to libs/favorites/eslint.config.js diff --git a/libs/favourite/jest.config.ts b/libs/favorites/jest.config.ts similarity index 88% rename from libs/favourite/jest.config.ts rename to libs/favorites/jest.config.ts index 63bd2c59..e509beb9 100644 --- a/libs/favourite/jest.config.ts +++ b/libs/favorites/jest.config.ts @@ -1,10 +1,10 @@ /* eslint-disable */ export default { - displayName: 'favourite', + displayName: 'favorites', preset: '../../jest.preset.js', setupFilesAfterEnv: ['/src/test-setup.ts'], globals: {}, - coverageDirectory: '../../coverage/libs/favourite', + coverageDirectory: '../../coverage/libs/favorites', transform: { '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', diff --git a/libs/favourite/ng-package.json b/libs/favorites/ng-package.json similarity index 75% rename from libs/favourite/ng-package.json rename to libs/favorites/ng-package.json index a7fb1483..642364f5 100644 --- a/libs/favourite/ng-package.json +++ b/libs/favorites/ng-package.json @@ -1,6 +1,6 @@ { "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", - "dest": "../../dist/libs/favourite", + "dest": "../../dist/libs/favorites", "lib": { "entryFile": "src/index.ts" } diff --git a/libs/favourite/package.json b/libs/favorites/package.json similarity index 83% rename from libs/favourite/package.json rename to libs/favorites/package.json index 8d292c7a..2d9d7505 100644 --- a/libs/favourite/package.json +++ b/libs/favorites/package.json @@ -1,5 +1,5 @@ { - "name": "@asset-sg/favourite", + "name": "@asset-sg/favorites", "version": "0.0.1", "peerDependencies": { "@angular/common": "^15.0.0", diff --git a/libs/favourite/project.json b/libs/favorites/project.json similarity index 64% rename from libs/favourite/project.json rename to libs/favorites/project.json index 6ef240aa..a8612b85 100644 --- a/libs/favourite/project.json +++ b/libs/favorites/project.json @@ -1,8 +1,8 @@ { - "name": "favourite", + "name": "favorites", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "library", - "sourceRoot": "libs/favourite/src", + "sourceRoot": "libs/favorites/src", "prefix": "asset-sg", "tags": [], "targets": { @@ -10,15 +10,15 @@ "executor": "@nx/angular:ng-packagr-lite", "outputs": ["{workspaceRoot}/dist/{projectRoot}"], "options": { - "project": "libs/favourite/ng-package.json", - "tsConfig": "libs/favourite/tsconfig.lib.json" + "project": "libs/favorites/ng-package.json", + "tsConfig": "libs/favorites/tsconfig.lib.json" }, "configurations": { "production": { - "tsConfig": "libs/favourite/tsconfig.lib.prod.json" + "tsConfig": "libs/favorites/tsconfig.lib.prod.json" }, "development": { - "tsConfig": "libs/favourite/tsconfig.lib.json" + "tsConfig": "libs/favorites/tsconfig.lib.json" } }, "defaultConfiguration": "production" @@ -27,14 +27,14 @@ "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { - "jestConfig": "libs/favourite/jest.config.ts" + "jestConfig": "libs/favorites/jest.config.ts" } }, "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], "options": { - "eslintConfig": "libs/favourite/eslint.config.js" + "eslintConfig": "libs/favorites/eslint.config.js" } } } diff --git a/libs/favorites/src/index.ts b/libs/favorites/src/index.ts new file mode 100644 index 00000000..86c5524f --- /dev/null +++ b/libs/favorites/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/components/favorite-button/favorite-button.component'; +export * from './lib/favorites.module'; diff --git a/libs/favorites/src/lib/components/favorite-button/favorite-button.component.html b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.html new file mode 100644 index 00000000..c6e49d72 --- /dev/null +++ b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.html @@ -0,0 +1 @@ + diff --git a/libs/favorites/src/lib/components/favorite-button/favorite-button.component.scss b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.scss new file mode 100644 index 00000000..9c272a4b --- /dev/null +++ b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.scss @@ -0,0 +1,20 @@ +:host { + --stroke-color: #295969; + --fill-color: transparent; + + color: var(--stroke-color); + cursor: pointer; +} + +:host > ::ng-deep svg-icon svg { + fill: var(--fill-color); +} + +:host:hover, +:host.is-active:hover { + --fill-color: #{lighten(#295969, 35%)}; +} + +:host.is-active { + --fill-color: var(--stroke-color); +} diff --git a/libs/favorites/src/lib/components/favorite-button/favorite-button.component.ts b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.ts new file mode 100644 index 00000000..9e0bb3f2 --- /dev/null +++ b/libs/favorites/src/lib/components/favorite-button/favorite-button.component.ts @@ -0,0 +1,47 @@ +import { Component, HostBinding, HostListener, inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { AssetId } from '@asset-sg/shared/v2'; +import { Store } from '@ngrx/store'; +import { Subscription } from 'rxjs'; +import { actions, selectFavoriteAssetIds } from '../../state'; + +@Component({ + selector: 'button[asset-sg-favorite]', + templateUrl: './favorite-button.component.html', + styleUrl: './favorite-button.component.scss', +}) +export class FavoriteButtonComponent implements OnInit, OnDestroy { + @Input({ required: true }) + assetId!: number; + + private readonly store = inject(Store); + + private assetIds = new Set(); + + private readonly subscription = new Subscription(); + + @HostBinding('class.is-active') + get isActive(): boolean { + return this.assetIds.has(this.assetId); + } + + @HostListener('click') + handleClick(): void { + if (this.isActive) { + this.store.dispatch(actions.remove({ assetId: this.assetId })); + } else { + this.store.dispatch(actions.add({ assetId: this.assetId })); + } + } + + ngOnInit(): void { + this.subscription.add( + this.store.select(selectFavoriteAssetIds).subscribe((assetIds) => { + this.assetIds = assetIds; + }) + ); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/libs/favorites/src/lib/favorites.module.ts b/libs/favorites/src/lib/favorites.module.ts new file mode 100644 index 00000000..9f14061a --- /dev/null +++ b/libs/favorites/src/lib/favorites.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SvgIconComponent } from '@ngneat/svg-icon'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { FavoriteButtonComponent } from './components/favorite-button/favorite-button.component'; +import { FavoritesEffect } from './state/favorites.effect'; +import { favoritesReducer } from './state/favorites.reducer'; + +@NgModule({ + declarations: [FavoriteButtonComponent], + imports: [ + CommonModule, + StoreModule.forFeature('favorites', favoritesReducer), + EffectsModule.forFeature(FavoritesEffect), + TranslateModule.forChild(), + SvgIconComponent, + ], + providers: [], + exports: [FavoriteButtonComponent], +}) +export class FavoritesModule {} diff --git a/libs/favourite/src/lib/services/favourite.service.spec.ts b/libs/favorites/src/lib/services/favorites.service.spec.ts similarity index 50% rename from libs/favourite/src/lib/services/favourite.service.spec.ts rename to libs/favorites/src/lib/services/favorites.service.spec.ts index 99906ded..4556213c 100644 --- a/libs/favourite/src/lib/services/favourite.service.spec.ts +++ b/libs/favorites/src/lib/services/favorites.service.spec.ts @@ -1,19 +1,20 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { Favourite, FavouriteService } from './favourite.service'; +import { AssetId } from '@asset-sg/shared/v2'; +import { FavoritesService } from './favorites.service'; -describe('FavouriteService', () => { - let service: FavouriteService; +describe(FavoritesService, () => { + let service: FavoritesService; let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [FavouriteService], + providers: [FavoritesService], }); - service = TestBed.inject(FavouriteService); + service = TestBed.inject(FavoritesService); httpMock = TestBed.inject(HttpTestingController); }); @@ -25,16 +26,16 @@ describe('FavouriteService', () => { expect(service).toBeTruthy(); }); - it('should retrieve favourites from API via GET', () => { - const dummyFavourites: Favourite[] = [{}, {}, {}]; + it('should retrieve favorites from API via GET', () => { + const ids: Array = [234, 34, 29]; - service.getFavourites().subscribe((favourites) => { - expect(favourites.length).toBe(3); - expect(favourites).toEqual(dummyFavourites); + service.fetchIds().subscribe((favorites) => { + expect(favorites.length).toBe(3); + expect(favorites).toEqual(ids); }); - const request = httpMock.expectOne(`/api/users/current/favorites`); + const request = httpMock.expectOne(`/api/assets/favorites/ids`); expect(request.request.method).toBe('GET'); - request.flush(dummyFavourites); + request.flush(ids); }); }); diff --git a/libs/favorites/src/lib/services/favorites.service.ts b/libs/favorites/src/lib/services/favorites.service.ts new file mode 100644 index 00000000..29a1dbe1 --- /dev/null +++ b/libs/favorites/src/lib/services/favorites.service.ts @@ -0,0 +1,21 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { AssetId } from '@asset-sg/shared/v2'; +import { map, Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class FavoritesService { + private readonly httpClient = inject(HttpClient); + + fetchIds(): Observable { + return this.httpClient.get(`/api/assets/favorites/ids`); + } + + create(assetId: AssetId): Observable { + return this.httpClient.post(`/api/assets/favorites/${assetId}`, null).pipe(map(() => undefined)); + } + + delete(assetId: AssetId): Observable { + return this.httpClient.delete(`/api/assets/favorites/${assetId}`).pipe(map(() => undefined)); + } +} diff --git a/libs/favorites/src/lib/state/favorites.actions.ts b/libs/favorites/src/lib/state/favorites.actions.ts new file mode 100644 index 00000000..74b4219a --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.actions.ts @@ -0,0 +1,8 @@ +import { AssetId } from '@asset-sg/shared/v2'; +import { createAction, props } from '@ngrx/store'; + +export const initialize = createAction('[Favorites] Initialize'); +export const load = createAction('[Favorites] Load'); +export const set = createAction('[Favorites] Set', props<{ assetIds: AssetId[] }>()); +export const add = createAction('[Favorites] Add', props<{ assetId: AssetId }>()); +export const remove = createAction('[Favorites] Remove', props<{ assetId: AssetId }>()); diff --git a/libs/favorites/src/lib/state/favorites.effect.ts b/libs/favorites/src/lib/state/favorites.effect.ts new file mode 100644 index 00000000..c40a6891 --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.effect.ts @@ -0,0 +1,54 @@ +import { inject, Injectable } from '@angular/core'; +import { Actions, createEffect, ofType, OnInitEffects } from '@ngrx/effects'; +import { Action, Store } from '@ngrx/store'; +import { filter, map, switchMap } from 'rxjs'; +import { FavoritesService } from '../services/favorites.service'; +import * as actions from './favorites.actions'; +import { FavoritesState } from './favorites.reducer'; +import { selectIsInitialized } from './favorites.selector'; + +@Injectable() +export class FavoritesEffect implements OnInitEffects { + private readonly store = inject(Store); + private readonly actions$ = inject(Actions); + private readonly favoritesService = inject(FavoritesService); + + ngrxOnInitEffects(): Action { + return actions.initialize(); + } + + public loadInitial$ = createEffect(() => + this.actions$.pipe( + ofType(actions.initialize), + switchMap(() => this.store.select(selectIsInitialized)), + filter((isInitialized) => !isInitialized), + map(() => actions.load()) + ) + ); + + public load$ = createEffect(() => + this.actions$.pipe( + ofType(actions.load), + switchMap(() => this.favoritesService.fetchIds()), + map((assetIds) => actions.set({ assetIds })) + ) + ); + + public create$ = createEffect( + () => + this.actions$.pipe( + ofType(actions.add), + switchMap(({ assetId }) => this.favoritesService.create(assetId)) + ), + { dispatch: false } + ); + + public delete$ = createEffect( + () => + this.actions$.pipe( + ofType(actions.remove), + switchMap(({ assetId }) => this.favoritesService.delete(assetId)) + ), + { dispatch: false } + ); +} diff --git a/libs/favorites/src/lib/state/favorites.reducer.ts b/libs/favorites/src/lib/state/favorites.reducer.ts new file mode 100644 index 00000000..bdc28e28 --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.reducer.ts @@ -0,0 +1,38 @@ +import { AppState } from '@asset-sg/client-shared'; +import { AssetId } from '@asset-sg/shared/v2'; +import { createReducer, on } from '@ngrx/store'; +import * as actions from './favorites.actions'; + +export interface FavoritesState { + isInitialized: boolean; + assetIds: Set; +} + +const initialState: FavoritesState = { + isInitialized: false, + assetIds: new Set(), +}; + +export interface AppStateWithFavorites extends AppState { + favorites: FavoritesState; +} + +export const favoritesReducer = createReducer( + initialState, + on(actions.set, (state, { assetIds }) => ({ + ...state, + isInitialized: true, + assetIds: new Set(assetIds), + })), + on(actions.add, (state, { assetId }) => { + return { + ...state, + assetIds: new Set([...state.assetIds, assetId]), + }; + }), + on(actions.remove, (state, { assetId }) => { + const assetIds = new Set(state.assetIds); + assetIds.delete(assetId); + return { ...state, assetIds }; + }) +); diff --git a/libs/favorites/src/lib/state/favorites.selector.ts b/libs/favorites/src/lib/state/favorites.selector.ts new file mode 100644 index 00000000..239cd085 --- /dev/null +++ b/libs/favorites/src/lib/state/favorites.selector.ts @@ -0,0 +1,7 @@ +import { createSelector } from '@ngrx/store'; +import { AppStateWithFavorites } from './favorites.reducer'; + +const favoritesFeature = (state: AppStateWithFavorites) => state.favorites; + +export const selectFavoriteAssetIds = createSelector(favoritesFeature, (state) => state.assetIds); +export const selectIsInitialized = createSelector(favoritesFeature, (state) => state.isInitialized); diff --git a/libs/favorites/src/lib/state/index.ts b/libs/favorites/src/lib/state/index.ts new file mode 100644 index 00000000..7a1cdd79 --- /dev/null +++ b/libs/favorites/src/lib/state/index.ts @@ -0,0 +1,3 @@ +export * as actions from './favorites.actions'; +export { FavoritesState, AppStateWithFavorites } from './favorites.reducer'; +export * from './favorites.selector'; diff --git a/libs/favorites/src/lib/styles/_mixins.scss b/libs/favorites/src/lib/styles/_mixins.scss new file mode 100644 index 00000000..5f3c36b1 --- /dev/null +++ b/libs/favorites/src/lib/styles/_mixins.scss @@ -0,0 +1 @@ +@import "../../../../client-shared/src/lib/styles/mixins"; diff --git a/libs/favorites/src/lib/styles/_variables.scss b/libs/favorites/src/lib/styles/_variables.scss new file mode 100644 index 00000000..1e965d1c --- /dev/null +++ b/libs/favorites/src/lib/styles/_variables.scss @@ -0,0 +1 @@ +@import "../../../../client-shared/src/lib/styles/variables"; diff --git a/libs/favourite/src/test-setup.ts b/libs/favorites/src/test-setup.ts similarity index 100% rename from libs/favourite/src/test-setup.ts rename to libs/favorites/src/test-setup.ts diff --git a/libs/favourite/tsconfig.json b/libs/favorites/tsconfig.json similarity index 100% rename from libs/favourite/tsconfig.json rename to libs/favorites/tsconfig.json diff --git a/libs/favourite/tsconfig.lib.json b/libs/favorites/tsconfig.lib.json similarity index 100% rename from libs/favourite/tsconfig.lib.json rename to libs/favorites/tsconfig.lib.json diff --git a/libs/favourite/tsconfig.lib.prod.json b/libs/favorites/tsconfig.lib.prod.json similarity index 100% rename from libs/favourite/tsconfig.lib.prod.json rename to libs/favorites/tsconfig.lib.prod.json diff --git a/libs/favourite/tsconfig.spec.json b/libs/favorites/tsconfig.spec.json similarity index 100% rename from libs/favourite/tsconfig.spec.json rename to libs/favorites/tsconfig.spec.json diff --git a/libs/favourite/src/index.ts b/libs/favourite/src/index.ts deleted file mode 100644 index 2b9fd58f..00000000 --- a/libs/favourite/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './lib/services/favourite.service'; diff --git a/libs/favourite/src/lib/services/favourite.service.ts b/libs/favourite/src/lib/services/favourite.service.ts deleted file mode 100644 index 53c78e5c..00000000 --- a/libs/favourite/src/lib/services/favourite.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface Favourite {} - -@Injectable({ providedIn: 'root' }) -export class FavouriteService { - constructor(private http: HttpClient) {} - - getFavourites(): Observable { - return this.http.get(`/api/users/current/favorites`); - } -} diff --git a/libs/persistence/prisma/migrations/20241028082002_add_favorites/migration.sql b/libs/persistence/prisma/migrations/20241028082002_add_favorites/migration.sql new file mode 100644 index 00000000..ab879582 --- /dev/null +++ b/libs/persistence/prisma/migrations/20241028082002_add_favorites/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the `asset_user_favourite` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "asset_user_favourite" DROP CONSTRAINT "asset_user_favourite_asset_id_fkey"; + +-- DropForeignKey +ALTER TABLE "asset_user_favourite" DROP CONSTRAINT "asset_user_favourite_asset_user_id_fkey"; + +-- DropTable +DROP TABLE "asset_user_favourite"; + +-- CreateTable +CREATE TABLE "favorite" ( + "user_id" UUID NOT NULL, + "asset_id" INTEGER NOT NULL, + + CONSTRAINT "favorite_pkey" PRIMARY KEY ("user_id","asset_id") +); + +-- AddForeignKey +ALTER TABLE "favorite" ADD CONSTRAINT "favorite_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "asset_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "favorite" ADD CONSTRAINT "favorite_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "asset"("asset_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/libs/persistence/prisma/schema.prisma b/libs/persistence/prisma/schema.prisma index 04557e90..0353d6ca 100644 --- a/libs/persistence/prisma/schema.prisma +++ b/libs/persistence/prisma/schema.prisma @@ -67,7 +67,7 @@ model Asset { assetInternalProjects AssetInternalProject[] subordinateAssets Asset[] @relation("asset") - AssetUserFavourite AssetUserFavourite[] + favorites Favorite[] allStudies AllStudy[] @@map("asset") @@ -636,30 +636,17 @@ model StatusWorkItem { } model AssetUser { - id String @id @db.Uuid - email String - lang String - oidcId String - AssetUserFavourite AssetUserFavourite[] - workgroups WorkgroupsOnUsers[] - isAdmin Boolean @default(false) @map("is_admin") + id String @id @db.Uuid + email String + lang String + oidcId String + favorites Favorite[] + workgroups WorkgroupsOnUsers[] + isAdmin Boolean @default(false) @map("is_admin") @@map("asset_user") } -model AssetUserFavourite { - assetUserId String @map("asset_user_id") @db.Uuid - AssetUser AssetUser @relation(fields: [assetUserId], references: [id], onDelete: Cascade) - - assetId Int @map("asset_id") - Asset Asset @relation(fields: [assetId], references: [assetId], onDelete: Cascade) - - created_at DateTime @db.Timestamptz(6) - - @@id([assetUserId, assetId]) - @@map("asset_user_favourite") -} - model Workgroup { id Int @id @default(autoincrement()) name String @unique @@ -688,6 +675,17 @@ enum Role { MasterEditor @map("master-editor") } +model Favorite { + userId String @map("user_id") @db.Uuid + user AssetUser @relation(fields: [userId], references: [id]) + + assetId Int @map("asset_id") + Asset Asset @relation(fields: [assetId], references: [assetId]) + + @@id([userId, assetId]) + @@map("favorite") +} + view AllStudy { assetId Int @map("asset_id") asset Asset @relation(fields: [assetId], references: [assetId], onDelete: Cascade) diff --git a/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts b/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts index 9f5d360a..15ee0ab4 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-query.dto.ts @@ -1,5 +1,5 @@ import { Type } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; import { PartialDateRangeDTO } from '../date-range.dto'; import { UsageCode } from '../usage'; @@ -42,6 +42,10 @@ export class AssetSearchQueryDTO implements AssetSearchQuery { @IsOptional() workgroupIds?: number[]; + @IsBoolean() + @IsOptional() + favoritesOnly?: boolean; + @IsOptional() @ValidateNested() @Type(() => PartialDateRangeDTO) diff --git a/libs/shared/src/lib/models/asset-search/asset-search-query.ts b/libs/shared/src/lib/models/asset-search/asset-search-query.ts index 38e59060..4e55d4a8 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-query.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-query.ts @@ -13,6 +13,7 @@ export interface AssetSearchQuery { geometryCodes?: Array; languageItemCodes?: string[]; workgroupIds?: number[]; + favoritesOnly?: boolean; } export enum GeometryCode { diff --git a/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts b/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts index cd1696a2..ad08e45b 100644 --- a/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts +++ b/libs/shared/src/lib/models/asset-search/asset-search-result.dto.ts @@ -21,6 +21,7 @@ export class AssetSearchStatsDTO implements AssetSearchStats { manCatLabelItemCodes!: ValueCount[]; usageCodes!: ValueCount[]; workgroupIds!: ValueCount[]; + categories!: ValueCount<'favorites'>[]; @Type(() => DateRangeDTO) createDate!: DateRangeDTO | null; diff --git a/libs/shared/src/lib/models/elastic-search-asset.ts b/libs/shared/src/lib/models/elastic-search-asset.ts index be654d1e..d1dbf1d5 100644 --- a/libs/shared/src/lib/models/elastic-search-asset.ts +++ b/libs/shared/src/lib/models/elastic-search-asset.ts @@ -18,6 +18,7 @@ export interface ElasticSearchAsset { geometryCodes: GeometryCode[] | ['None']; studyLocations: ElasticPoint[]; workgroupId: number; + favoredByUserIds: string[]; data: SerializedAssetEditDetail; } diff --git a/libs/shared/v2/src/index.ts b/libs/shared/v2/src/index.ts index c9036f77..8f23ea4d 100644 --- a/libs/shared/v2/src/index.ts +++ b/libs/shared/v2/src/index.ts @@ -12,6 +12,7 @@ export * from './lib/policies/base/policy'; export * from './lib/policies/asset.policy'; export * from './lib/policies/asset-edit.policy'; export * from './lib/policies/contact.policy'; +export * from './lib/policies/favorite.policy'; export * from './lib/policies/workgroup.policy'; export * from './lib/schemas/base/schema'; diff --git a/libs/shared/v2/src/lib/models/asset.fake.ts b/libs/shared/v2/src/lib/models/asset.fake.ts new file mode 100644 index 00000000..9df31b03 --- /dev/null +++ b/libs/shared/v2/src/lib/models/asset.fake.ts @@ -0,0 +1,33 @@ +import { fakerDE_CH as faker } from '@faker-js/faker'; + +// @ts-expect-error: import file from outside rootDir +import { fakeAssetFormatItemCode } from './/test/data/asset-format-item'; +// @ts-expect-error: import file from outside rootDir +import { fakeContactKindItemCode } from './/test/data/contact-kind-item'; +import { AssetInfo } from './asset'; +import { LocalDate } from './base/local-date'; + +let nextUniqueId = 0; +const fakeIdNumber = (): number => nextUniqueId++; + +export const fakeAssetInfo = (): AssetInfo => ({ + id: fakeIdNumber(), + title: faker.commerce.product(), + originalTitle: faker.commerce.product(), + kindCode: fakeContactKindItemCode(), + formatCode: fakeAssetFormatItemCode(), + identifiers: [], + languageCodes: [], + contactAssignments: [], + manCatLabelCodes: [], + natRelCodes: [], + links: { + parent: null, + children: [], + siblings: [], + }, + files: [], + createdAt: LocalDate.fromDate(faker.date.past()), + receivedAt: LocalDate.fromDate(faker.date.past()), + lastProcessedAt: faker.date.past(), +}); diff --git a/libs/shared/v2/src/lib/policies/base/policy.ts b/libs/shared/v2/src/lib/policies/base/policy.ts index ed748851..c1c86040 100644 --- a/libs/shared/v2/src/lib/policies/base/policy.ts +++ b/libs/shared/v2/src/lib/policies/base/policy.ts @@ -46,7 +46,7 @@ export abstract class Policy { return this.canCreate(); } - canDelete(_value: T): boolean { - return this.canCreate(); + canDelete(value: T): boolean { + return this.canUpdate(value); } } diff --git a/libs/shared/v2/src/lib/policies/favorite.policy.ts b/libs/shared/v2/src/lib/policies/favorite.policy.ts new file mode 100644 index 00000000..fed71c61 --- /dev/null +++ b/libs/shared/v2/src/lib/policies/favorite.policy.ts @@ -0,0 +1,18 @@ +import { Favorite } from '../models/favorite'; +import { Policy } from './base/policy'; + +export class FavoritePolicy extends Policy { + canShow(value: Favorite): boolean { + // A user can see their own favorites. + return value.userId == this.user.id; + } + + override canCreate(): boolean { + // Every user can add to their own favorites. + return true; + } + + override canUpdate(value: Favorite): boolean { + return this.canShow(value); + } +} diff --git a/libs/shared/v2/src/test-helpers.ts b/libs/shared/v2/src/test-helpers.ts new file mode 100644 index 00000000..e9e06f80 --- /dev/null +++ b/libs/shared/v2/src/test-helpers.ts @@ -0,0 +1 @@ +export * from './lib/models/asset.fake'; diff --git a/test/data/contact-kind-item.ts b/test/data/contact-kind-item.ts index 50225b02..fe39fdb3 100644 --- a/test/data/contact-kind-item.ts +++ b/test/data/contact-kind-item.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; -export const fakeContactKindItem = (): string => +export const fakeContactKindItemCode = (): string => faker.helpers.arrayElement(contactKindItems.map((item) => item.contactKindItemCode)); export const contactKindItems = [ diff --git a/tsconfig.base.json b/tsconfig.base.json index a05295c2..c6817194 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,10 +22,11 @@ "@asset-sg/auth": ["libs/auth/src/index.ts"], "@asset-sg/client-shared": ["libs/client-shared/src/index.ts"], "@asset-sg/core": ["libs/core/src/index.ts"], - "@asset-sg/favourite": ["libs/favourite/src/index.ts"], + "@asset-sg/favorites": ["libs/favorites/src/index.ts"], "@asset-sg/profile": ["libs/profile/src/index.ts"], "@asset-sg/shared": ["libs/shared/src/index.ts"], "@asset-sg/shared/v2": ["libs/shared/v2/src/index.ts"], + "@asset-sg/shared/v2/test-helpers": ["libs/shared/v2/src/test-helpers.ts"], "ngx-kobalte": ["libs/ngx-kobalte/src/index.ts"], "persistence": ["libs/persistence/src/index.ts"] }