diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ce2a012..de4ccef92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Restricted the creation of tags to unique names in the tags selector component - Changed the redaction of the monetary values in impersonation mode to be based on the scopes of the access - Deprecated the `permissions` attribute of the access in favor of the scopes - Extended the `GET api/v1/access` endpoint by the scopes diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index abcf349d9..0f23b03ea 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -10,9 +10,13 @@ export class TagService { public constructor(private readonly prismaService: PrismaService) {} public async createTag(data: Prisma.TagCreateInput) { - return this.prismaService.tag.create({ - data - }); + try { + return await this.prismaService.tag.create({ + data + }); + } catch (error) { + throw this.getExceptionForError(error); + } } public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { @@ -121,10 +125,14 @@ export class TagService { data: Prisma.TagUpdateInput; where: Prisma.TagWhereUniqueInput; }): Promise { - return this.prismaService.tag.update({ - data, - where - }); + try { + return await this.prismaService.tag.update({ + data, + where + }); + } catch (error) { + throw this.getExceptionForError(error); + } } public async validateTagIds({ @@ -180,4 +188,18 @@ export class TagService { return this.validateTagIds({ tagIds, userId }); } + + private getExceptionForError(error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return new HttpException( + getReasonPhrase(StatusCodes.CONFLICT), + StatusCodes.CONFLICT + ); + } + + return error; + } } diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.html b/libs/ui/src/lib/tags-selector/tags-selector.component.html index 9bede09a7..b8a2c372d 100644 --- a/libs/ui/src/lib/tags-selector/tags-selector.component.html +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.html @@ -55,12 +55,12 @@ } - @if (hasPermissionToCreateTag && tagInputControl.value) { - + @if (hasPermissionToCreateTag && tagNameToCreate()) { + Create "{{ - tagInputControl.value.trim() + tagNameToCreate() }}" diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.ts b/libs/ui/src/lib/tags-selector/tags-selector.component.ts index 0e97e12fd..acd0d8d3b 100644 --- a/libs/ui/src/lib/tags-selector/tags-selector.component.ts +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.ts @@ -72,6 +72,7 @@ export class GfTagsSelectorComponent ); public readonly separatorKeysCodes: number[] = [COMMA, ENTER]; public readonly tagInputControl = new FormControl(''); + public readonly tagNameToCreate = signal(null); public readonly tagsSelected = signal([]); private readonly tagInput = @@ -80,8 +81,8 @@ export class GfTagsSelectorComponent public constructor() { this.tagInputControl.valueChanges .pipe(takeUntilDestroyed()) - .subscribe((value) => { - this.filteredOptions.next(this.filterTags(value ?? '')); + .subscribe(() => { + this.updateFilters(); }); addIcons({ addCircleOutline, closeOutline }); @@ -161,9 +162,8 @@ export class GfTagsSelectorComponent this.updateFilters(); } - private filterTags(query: string = ''): SelectedTag[] { - const tags = this.tagsSelected() ?? []; - const tagIds = [...tags, ...(this.tagsReadOnly ?? [])].map(({ id }) => { + private filterTags(query: string): SelectedTag[] { + const tagIds = this.getTagsSelectedAndReadOnly().map(({ id }) => { return id; }); @@ -179,6 +179,27 @@ export class GfTagsSelectorComponent }); } + private getTagNameToCreate(query: string): string | null { + const name = query.trim(); + + if (!name) { + return null; + } + + const isExistingTagName = [ + ...(this.tagsAvailable ?? []), + ...this.getTagsSelectedAndReadOnly() + ].some((tag) => { + return tag.name.toLowerCase() === name.toLowerCase(); + }); + + return isExistingTagName ? null : name; + } + + private getTagsSelectedAndReadOnly(): SelectedTag[] { + return [...this.tagsSelected(), ...(this.tagsReadOnly ?? [])]; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars private onChange = (_value: SelectedTag[]): void => { // ControlValueAccessor onChange callback @@ -189,6 +210,9 @@ export class GfTagsSelectorComponent }; private updateFilters() { - this.filteredOptions.next(this.filterTags()); + const query = this.tagInputControl.value ?? ''; + + this.filteredOptions.next(this.filterTags(query)); + this.tagNameToCreate.set(this.getTagNameToCreate(query)); } }