Skip to content

Commit

Permalink
Merge pull request #78 from pawcoding/fix/duplicate-color-names
Browse files Browse the repository at this point in the history
Prevent duplicate color names
  • Loading branch information
pawcoding authored Aug 24, 2024
2 parents a005df3 + 4272690 commit 0f5aaeb
Show file tree
Hide file tree
Showing 10 changed files with 169 additions and 4 deletions.
7 changes: 6 additions & 1 deletion src/app/shared/data-access/palette.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Value } from '../model';
import { Color } from '../model/color.model';
import { Palette } from '../model/palette.model';
import { Shade } from '../model/shade.model';
import { deduplicateName } from '../utils/deduplicate-name';
import { ColorNameService } from './color-name.service';
import { ColorService } from './color.service';
import { ListService } from './list.service';
Expand Down Expand Up @@ -120,7 +121,11 @@ export class PaletteService {

for (const color of palette.colors) {
// Get the color name
color.name = await this._colorNameService.getColorName(color.shades[0]);
const generatedName = await this._colorNameService.getColorName(color.shades[0]);

// Deduplicate the name
const existingNames = palette.colors.map((c) => c.name);
color.name = deduplicateName(generatedName, existingNames);

// Regenerate the shades
await this._colorService.regenerateShades(color);
Expand Down
6 changes: 6 additions & 0 deletions src/app/shared/types/dialog-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ValidatorFn } from '@angular/forms';

export type AlertConfig = {
type: 'alert';
title: string;
Expand All @@ -19,6 +21,10 @@ export type PromptConfig = {
label?: string;
placeholder?: string;
initialValue?: string;
validation?: {
validators: Array<ValidatorFn>;
errorMessageKeys: Record<string, string>;
};
};

export type DialogConfig = AlertConfig | ConfirmConfig | PromptConfig;
9 changes: 8 additions & 1 deletion src/app/shared/ui/dialog/dialog.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ <h1 class="text-xl font-semibold">
}

<input
[class]="invalid() ? 'border-red-700 dark:border-red-400' : ''"
[formControl]="input"
[placeholder]="config.placeholder ?? '' | translate"
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"
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"
id="dialog-input"
type="text"
/>

@if (validationError(); as validationError) {
<p class="mt-2 text-sm text-red-700 dark:text-red-400">
{{ validationError }}
</p>
}
</section>
}

Expand Down
64 changes: 63 additions & 1 deletion src/app/shared/ui/dialog/dialog.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { map, skip } from 'rxjs';
import { DialogConfig } from '../../types/dialog-config';

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

/**
* Label for confirm button.
*/
protected get confirmLabel(): string {
if (this.config.type === 'alert') {
return 'common.ok';
Expand All @@ -23,13 +29,69 @@ export class DialogComponent {
return this.config.confirmLabel;
}

/**
* Input control for prompt dialog.
*/
protected readonly input = new FormControl('', {
nonNullable: true,
validators: [Validators.required]
});

/**
* Subject that emits true if input is invalid after the first change.
*/
readonly #invalid$ = this.input.valueChanges.pipe(
// Skip the first value since it's the initial value
skip(1),
// Check if input is invalid
map(() => this.input.invalid)
);

/**
* Signal containing the invalid state of the input.
*/
protected readonly invalid = toSignal(this.#invalid$, { initialValue: false });

/**
* Signal containing the validation error message.
*/
protected readonly validationError = toSignal<string, string>(
this.#invalid$.pipe(
map((invalid) => {
// Check if input is valid or dialog is not prompt
if (!invalid || this.config.type !== 'prompt') {
return '';
}

// Use default error message if no custom validation is set
if (!this.config.validation) {
return this._translate.instant('common.required');
}

// Find the first error key
const errorKey = Object.keys(this.config.validation.errorMessageKeys).find((key) => this.input.hasError(key));
if (!errorKey) {
return '';
}

// Get the error message
const error = this.input.getError(errorKey);
return this._translate.instant(this.config.validation.errorMessageKeys[errorKey], { value: error.value });
})
),
{
initialValue: ''
}
);

public constructor() {
if (this.config.type === 'prompt') {
// Set validators to input
if (this.config.validation) {
this.input.setValidators(this.config.validation.validators);
}

// Set initial value to input
this.input.setValue(this.config.initialValue ?? '');
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/app/shared/utils/deduplicate-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { normalizeName } from './normalize-name';

/**
* Deduplicate a name by adding a number to the end if it already exists.
*/
export function deduplicateName(name: string, existingNames: Array<string>): string {
const normalizedNames = existingNames.map((n) => normalizeName(n));

// Check if name already exists
while (normalizedNames.includes(normalizeName(name))) {
// Check if the name already has a number
const lastNumber = name.match(/\d+$/);
if (lastNumber) {
// Increment the number
const number = parseInt(lastNumber[0], 10);
name = name.replace(/\d+$/, '') + (number + 1);
} else {
// Add a number to the color name
name += ' 2';
}
}

// Return the deduplicated name
return name;
}
6 changes: 6 additions & 0 deletions src/app/shared/utils/normalize-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Normalize a string by removing whitespace and converting to lowercase.
*/
export function normalizeName(value: string): string {
return value.trim().replace(/\s+/g, '').toLowerCase();
}
24 changes: 24 additions & 0 deletions src/app/view/utils/duplicate.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ValidatorFn } from '@angular/forms';
import { normalizeName } from '../../shared/utils/normalize-name';

/**
* Validator that checks if the value is already in the array.
*/
export function duplicateValidator(values: Array<string>): ValidatorFn {
// Normalize the values
const normalizedValues = values.map((value) => normalizeName(value));

return (control) => {
const normalizedValue = normalizeName(control.value);

// Check if the value is already in the array
const duplicate = normalizedValues.findIndex((value) => value === normalizedValue);
if (duplicate === -1) {
// No duplicate found
return null;
}

// Return duplicate
return { duplicate: { value: values.at(duplicate) } };
};
}
28 changes: 27 additions & 1 deletion src/app/view/view.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component, HostListener, OnInit, computed, inject, input, signal } from '@angular/core';
import { Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { NgIconComponent } from '@ng-icons/core';
import {
Expand All @@ -22,9 +23,11 @@ import { ToastService } from '../shared/data-access/toast.service';
import { TrackingEventAction, TrackingEventCategory } from '../shared/enums/tracking-event';
import { Color, Shade } from '../shared/model';
import { NoPaletteComponent } from '../shared/ui/no-palette/no-palette.component';
import { deduplicateName } from '../shared/utils/deduplicate-name';
import { IS_RUNNING_TEST } from '../shared/utils/is-running-test';
import { sleep } from '../shared/utils/sleep';
import { ViewPaletteComponent } from './ui/view-palette/view-palette.component';
import { duplicateValidator } from './utils/duplicate.validator';
import { UnsavedChangesComponent } from './utils/unsaved-changes.guard';

@Component({
Expand Down Expand Up @@ -157,13 +160,26 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent {
}

public async renameColor(color: Color): Promise<void> {
// Get all color names except the current one
const colorNames =
this.palette()
?.colors.filter((c) => c !== color)
.map((c) => c.name) ?? [];

const newName = await this._dialogService.prompt({
title: 'common.rename',
message: 'view.color.rename',
confirmLabel: 'common.rename',
initialValue: color.name,
label: 'common.name',
placeholder: 'common.name'
placeholder: 'common.name',
validation: {
validators: [Validators.required, duplicateValidator(colorNames)],
errorMessageKeys: {
required: 'common.required',
duplicate: 'view.color.duplicate-name'
}
}
});

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

public async addColor(): Promise<void> {
// Check if a palette exists
const palette = this.palette();
if (!palette) {
return;
}

// Create a new random color
const color = await this._colorService.randomColor();

// Check if color name already exists
const colorNames = palette.colors.map((c) => c.name);
color.name = deduplicateName(color.name, colorNames);

// Add the color to the palette
palette.addColor(color);

// Set unsaved changes
this._hasUnsavedChanges.set(true);
}

Expand Down
2 changes: 2 additions & 0 deletions src/assets/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ok": "OK",
"remove": "Entfernen",
"rename": "Umbenennen",
"required": "Dieses Feld kann nicht leer sein.",
"saturation": "Sättigung",
"save": "Speichern",
"saving": "Speichern...",
Expand Down Expand Up @@ -286,6 +287,7 @@
"add-tooltip": "Füge deiner Palette eine neue Farbe hinzu",
"click": "Click, um die Schattierung anzupassen\nRechtsclick, um den Hex-Code zu kopieren",
"copy": "\"{{ color }}\" wurde in deine Zwischenablage kopiert.",
"duplicate-name": "Deine Palette enthält bereits eine Farbe mit dem Namen \"{{ value }}\". Bitte wähle einen anderen Namen.",
"remove": "Bist du sicher, dass du die Farbe \"{{ color }}\" entfernen möchtest?",
"remove-tooltip": "Entferne diese Farbe aus deiner Palette",
"removed": "Die Farbe \"{{ color }}\" wurde entfernt.",
Expand Down
2 changes: 2 additions & 0 deletions src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ok": "OK",
"remove": "Remove",
"rename": "Rename",
"required": "This field is cannot be empty.",
"saturation": "Saturation",
"save": "Save",
"saving": "Saving...",
Expand Down Expand Up @@ -287,6 +288,7 @@
"add-tooltip": "Add a new color to your palette",
"click": "Click to tune this shade\nRight-click to copy hex code to clipboard",
"copy": "\"{{ color }}\" has been copied to your clipboard.",
"duplicate-name": "A color with the name \"{{ value }}\" already exists in your palette. Please choose a different name.",
"remove": "Are you sure you want to remove the color \"{{ color }}\"?",
"remove-tooltip": "Remove the color from your palette",
"removed": "The color \"{{ color }}\" has been removed.",
Expand Down

0 comments on commit 0f5aaeb

Please sign in to comment.