Skip to content

Commit 0f5aaeb

Browse files
authored
Merge pull request #78 from pawcoding/fix/duplicate-color-names
Prevent duplicate color names
2 parents a005df3 + 4272690 commit 0f5aaeb

File tree

10 files changed

+169
-4
lines changed

10 files changed

+169
-4
lines changed

src/app/shared/data-access/palette.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Value } from '../model';
55
import { Color } from '../model/color.model';
66
import { Palette } from '../model/palette.model';
77
import { Shade } from '../model/shade.model';
8+
import { deduplicateName } from '../utils/deduplicate-name';
89
import { ColorNameService } from './color-name.service';
910
import { ColorService } from './color.service';
1011
import { ListService } from './list.service';
@@ -120,7 +121,11 @@ export class PaletteService {
120121

121122
for (const color of palette.colors) {
122123
// Get the color name
123-
color.name = await this._colorNameService.getColorName(color.shades[0]);
124+
const generatedName = await this._colorNameService.getColorName(color.shades[0]);
125+
126+
// Deduplicate the name
127+
const existingNames = palette.colors.map((c) => c.name);
128+
color.name = deduplicateName(generatedName, existingNames);
124129

125130
// Regenerate the shades
126131
await this._colorService.regenerateShades(color);

src/app/shared/types/dialog-config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ValidatorFn } from '@angular/forms';
2+
13
export type AlertConfig = {
24
type: 'alert';
35
title: string;
@@ -19,6 +21,10 @@ export type PromptConfig = {
1921
label?: string;
2022
placeholder?: string;
2123
initialValue?: string;
24+
validation?: {
25+
validators: Array<ValidatorFn>;
26+
errorMessageKeys: Record<string, string>;
27+
};
2228
};
2329

2430
export type DialogConfig = AlertConfig | ConfirmConfig | PromptConfig;

src/app/shared/ui/dialog/dialog.component.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,19 @@ <h1 class="text-xl font-semibold">
2323
}
2424

2525
<input
26+
[class]="invalid() ? 'border-red-700 dark:border-red-400' : ''"
2627
[formControl]="input"
2728
[placeholder]="config.placeholder ?? '' | translate"
28-
class="w-full min-w-0 grow rounded-md border-transparent bg-neutral-200 px-4 py-2 invalid:border-red-600 focus:outline-none focus:ring-0 dark:bg-neutral-600"
29+
class="w-full min-w-0 grow rounded-md border-transparent bg-neutral-200 px-4 py-2 focus:outline-none focus:ring-0 dark:bg-neutral-600"
2930
id="dialog-input"
3031
type="text"
3132
/>
33+
34+
@if (validationError(); as validationError) {
35+
<p class="mt-2 text-sm text-red-700 dark:text-red-400">
36+
{{ validationError }}
37+
</p>
38+
}
3239
</section>
3340
}
3441

src/app/shared/ui/dialog/dialog.component.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
22
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
3+
import { toSignal } from '@angular/core/rxjs-interop';
34
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
4-
import { TranslateModule } from '@ngx-translate/core';
5+
import { TranslateModule, TranslateService } from '@ngx-translate/core';
6+
import { map, skip } from 'rxjs';
57
import { DialogConfig } from '../../types/dialog-config';
68

79
@Component({
@@ -14,7 +16,11 @@ import { DialogConfig } from '../../types/dialog-config';
1416
export class DialogComponent {
1517
protected readonly config = inject<DialogConfig>(DIALOG_DATA);
1618
private readonly _dialogRef = inject(DialogRef);
19+
private readonly _translate = inject(TranslateService);
1720

21+
/**
22+
* Label for confirm button.
23+
*/
1824
protected get confirmLabel(): string {
1925
if (this.config.type === 'alert') {
2026
return 'common.ok';
@@ -23,13 +29,69 @@ export class DialogComponent {
2329
return this.config.confirmLabel;
2430
}
2531

32+
/**
33+
* Input control for prompt dialog.
34+
*/
2635
protected readonly input = new FormControl('', {
2736
nonNullable: true,
2837
validators: [Validators.required]
2938
});
3039

40+
/**
41+
* Subject that emits true if input is invalid after the first change.
42+
*/
43+
readonly #invalid$ = this.input.valueChanges.pipe(
44+
// Skip the first value since it's the initial value
45+
skip(1),
46+
// Check if input is invalid
47+
map(() => this.input.invalid)
48+
);
49+
50+
/**
51+
* Signal containing the invalid state of the input.
52+
*/
53+
protected readonly invalid = toSignal(this.#invalid$, { initialValue: false });
54+
55+
/**
56+
* Signal containing the validation error message.
57+
*/
58+
protected readonly validationError = toSignal<string, string>(
59+
this.#invalid$.pipe(
60+
map((invalid) => {
61+
// Check if input is valid or dialog is not prompt
62+
if (!invalid || this.config.type !== 'prompt') {
63+
return '';
64+
}
65+
66+
// Use default error message if no custom validation is set
67+
if (!this.config.validation) {
68+
return this._translate.instant('common.required');
69+
}
70+
71+
// Find the first error key
72+
const errorKey = Object.keys(this.config.validation.errorMessageKeys).find((key) => this.input.hasError(key));
73+
if (!errorKey) {
74+
return '';
75+
}
76+
77+
// Get the error message
78+
const error = this.input.getError(errorKey);
79+
return this._translate.instant(this.config.validation.errorMessageKeys[errorKey], { value: error.value });
80+
})
81+
),
82+
{
83+
initialValue: ''
84+
}
85+
);
86+
3187
public constructor() {
3288
if (this.config.type === 'prompt') {
89+
// Set validators to input
90+
if (this.config.validation) {
91+
this.input.setValidators(this.config.validation.validators);
92+
}
93+
94+
// Set initial value to input
3395
this.input.setValue(this.config.initialValue ?? '');
3496
}
3597
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { normalizeName } from './normalize-name';
2+
3+
/**
4+
* Deduplicate a name by adding a number to the end if it already exists.
5+
*/
6+
export function deduplicateName(name: string, existingNames: Array<string>): string {
7+
const normalizedNames = existingNames.map((n) => normalizeName(n));
8+
9+
// Check if name already exists
10+
while (normalizedNames.includes(normalizeName(name))) {
11+
// Check if the name already has a number
12+
const lastNumber = name.match(/\d+$/);
13+
if (lastNumber) {
14+
// Increment the number
15+
const number = parseInt(lastNumber[0], 10);
16+
name = name.replace(/\d+$/, '') + (number + 1);
17+
} else {
18+
// Add a number to the color name
19+
name += ' 2';
20+
}
21+
}
22+
23+
// Return the deduplicated name
24+
return name;
25+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Normalize a string by removing whitespace and converting to lowercase.
3+
*/
4+
export function normalizeName(value: string): string {
5+
return value.trim().replace(/\s+/g, '').toLowerCase();
6+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ValidatorFn } from '@angular/forms';
2+
import { normalizeName } from '../../shared/utils/normalize-name';
3+
4+
/**
5+
* Validator that checks if the value is already in the array.
6+
*/
7+
export function duplicateValidator(values: Array<string>): ValidatorFn {
8+
// Normalize the values
9+
const normalizedValues = values.map((value) => normalizeName(value));
10+
11+
return (control) => {
12+
const normalizedValue = normalizeName(control.value);
13+
14+
// Check if the value is already in the array
15+
const duplicate = normalizedValues.findIndex((value) => value === normalizedValue);
16+
if (duplicate === -1) {
17+
// No duplicate found
18+
return null;
19+
}
20+
21+
// Return duplicate
22+
return { duplicate: { value: values.at(duplicate) } };
23+
};
24+
}

src/app/view/view.component.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Component, HostListener, OnInit, computed, inject, input, signal } from '@angular/core';
2+
import { Validators } from '@angular/forms';
23
import { Router, RouterLink } from '@angular/router';
34
import { NgIconComponent } from '@ng-icons/core';
45
import {
@@ -22,9 +23,11 @@ import { ToastService } from '../shared/data-access/toast.service';
2223
import { TrackingEventAction, TrackingEventCategory } from '../shared/enums/tracking-event';
2324
import { Color, Shade } from '../shared/model';
2425
import { NoPaletteComponent } from '../shared/ui/no-palette/no-palette.component';
26+
import { deduplicateName } from '../shared/utils/deduplicate-name';
2527
import { IS_RUNNING_TEST } from '../shared/utils/is-running-test';
2628
import { sleep } from '../shared/utils/sleep';
2729
import { ViewPaletteComponent } from './ui/view-palette/view-palette.component';
30+
import { duplicateValidator } from './utils/duplicate.validator';
2831
import { UnsavedChangesComponent } from './utils/unsaved-changes.guard';
2932

3033
@Component({
@@ -157,13 +160,26 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent {
157160
}
158161

159162
public async renameColor(color: Color): Promise<void> {
163+
// Get all color names except the current one
164+
const colorNames =
165+
this.palette()
166+
?.colors.filter((c) => c !== color)
167+
.map((c) => c.name) ?? [];
168+
160169
const newName = await this._dialogService.prompt({
161170
title: 'common.rename',
162171
message: 'view.color.rename',
163172
confirmLabel: 'common.rename',
164173
initialValue: color.name,
165174
label: 'common.name',
166-
placeholder: 'common.name'
175+
placeholder: 'common.name',
176+
validation: {
177+
validators: [Validators.required, duplicateValidator(colorNames)],
178+
errorMessageKeys: {
179+
required: 'common.required',
180+
duplicate: 'view.color.duplicate-name'
181+
}
182+
}
167183
});
168184

169185
if (!newName || newName === color.name) {
@@ -216,13 +232,23 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent {
216232
}
217233

218234
public async addColor(): Promise<void> {
235+
// Check if a palette exists
219236
const palette = this.palette();
220237
if (!palette) {
221238
return;
222239
}
223240

241+
// Create a new random color
224242
const color = await this._colorService.randomColor();
243+
244+
// Check if color name already exists
245+
const colorNames = palette.colors.map((c) => c.name);
246+
color.name = deduplicateName(color.name, colorNames);
247+
248+
// Add the color to the palette
225249
palette.addColor(color);
250+
251+
// Set unsaved changes
226252
this._hasUnsavedChanges.set(true);
227253
}
228254

src/assets/i18n/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"ok": "OK",
1818
"remove": "Entfernen",
1919
"rename": "Umbenennen",
20+
"required": "Dieses Feld kann nicht leer sein.",
2021
"saturation": "Sättigung",
2122
"save": "Speichern",
2223
"saving": "Speichern...",
@@ -286,6 +287,7 @@
286287
"add-tooltip": "Füge deiner Palette eine neue Farbe hinzu",
287288
"click": "Click, um die Schattierung anzupassen\nRechtsclick, um den Hex-Code zu kopieren",
288289
"copy": "\"{{ color }}\" wurde in deine Zwischenablage kopiert.",
290+
"duplicate-name": "Deine Palette enthält bereits eine Farbe mit dem Namen \"{{ value }}\". Bitte wähle einen anderen Namen.",
289291
"remove": "Bist du sicher, dass du die Farbe \"{{ color }}\" entfernen möchtest?",
290292
"remove-tooltip": "Entferne diese Farbe aus deiner Palette",
291293
"removed": "Die Farbe \"{{ color }}\" wurde entfernt.",

src/assets/i18n/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"ok": "OK",
1818
"remove": "Remove",
1919
"rename": "Rename",
20+
"required": "This field is cannot be empty.",
2021
"saturation": "Saturation",
2122
"save": "Save",
2223
"saving": "Saving...",
@@ -287,6 +288,7 @@
287288
"add-tooltip": "Add a new color to your palette",
288289
"click": "Click to tune this shade\nRight-click to copy hex code to clipboard",
289290
"copy": "\"{{ color }}\" has been copied to your clipboard.",
291+
"duplicate-name": "A color with the name \"{{ value }}\" already exists in your palette. Please choose a different name.",
290292
"remove": "Are you sure you want to remove the color \"{{ color }}\"?",
291293
"remove-tooltip": "Remove the color from your palette",
292294
"removed": "The color \"{{ color }}\" has been removed.",

0 commit comments

Comments
 (0)