feat: statuses and ticket types take any colour from a colour picker
Settings shows a native colour picker (the palette as suggestions) instead of the list of eleven names. The API takes a palette name, as before, or any #rrggbb; picking a palette colour still saves its name, so it keeps its hand-made dark version. A free colour gets one variant per theme, computed with the `color` library (moved towards white or black until it contrasts 3:1 with the surface) and applied with light-dark(): a pale yellow darkens on white, a navy lightens on dark. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,25 @@ describe('Project settings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('takes a palette colour or any #rrggbb, and nothing else', () => {
|
||||
const project = newProject();
|
||||
const settings = defaultProjectSettings();
|
||||
settings.statuses[0].color = '#7c3aed';
|
||||
settings.issueTypes[0].color = '#0EA5E9';
|
||||
project.updateSettings('owner', settings);
|
||||
expect(project.workflow.status(settings.statuses[0].id)?.color).toBe(
|
||||
'#7c3aed',
|
||||
);
|
||||
|
||||
for (const color of ['purple', '#abc', 'url(x)', '#7c3aedff']) {
|
||||
const bad = defaultProjectSettings();
|
||||
bad.statuses[0].color = color as never;
|
||||
expect(() => project.updateSettings('owner', bad)).toThrow(
|
||||
/Color no válido/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('archives values instead of deleting them', () => {
|
||||
const project = newProject();
|
||||
const settings = defaultProjectSettings();
|
||||
|
||||
@@ -32,7 +32,17 @@ export const SETTING_COLORS = [
|
||||
'red',
|
||||
'pink',
|
||||
] as const;
|
||||
export type SettingColor = (typeof SETTING_COLORS)[number];
|
||||
/** A colour: one of the palette's (they follow the theme) or any `#rrggbb`. */
|
||||
export type SettingColor = (typeof SETTING_COLORS)[number] | `#${string}`;
|
||||
|
||||
/** A palette name or a hex colour, as the API takes it. */
|
||||
export const SETTING_COLOR_PATTERN = new RegExp(
|
||||
`^(${SETTING_COLORS.join('|')}|#[0-9a-fA-F]{6})$`,
|
||||
);
|
||||
|
||||
export function isSettingColor(color: string): color is SettingColor {
|
||||
return SETTING_COLOR_PATTERN.test(color);
|
||||
}
|
||||
|
||||
/** Icons a ticket type can use (registered in the web app). */
|
||||
export const TYPE_ICONS = [
|
||||
@@ -513,7 +523,7 @@ export function validateProjectSettings(
|
||||
`Categoría no válida en el estado ${s.name}`,
|
||||
);
|
||||
assertThat(
|
||||
SETTING_COLORS.includes(s.color),
|
||||
isSettingColor(s.color),
|
||||
`Color no válido en el estado ${s.name}`,
|
||||
);
|
||||
assertThat(
|
||||
@@ -527,10 +537,7 @@ export function validateProjectSettings(
|
||||
TYPE_ICONS.includes(t.icon),
|
||||
`Icono no válido en el tipo ${t.name}`,
|
||||
);
|
||||
assertThat(
|
||||
SETTING_COLORS.includes(t.color),
|
||||
`Color no válido en el tipo ${t.name}`,
|
||||
);
|
||||
assertThat(isSettingColor(t.color), `Color no válido en el tipo ${t.name}`);
|
||||
}
|
||||
const active = statuses.filter((s) => !s.archived);
|
||||
assertThat(
|
||||
|
||||
@@ -661,7 +661,9 @@ export class WorkflowStatusInput {
|
||||
@Field(() => StatusCategory)
|
||||
@IsEnum(StatusCategory)
|
||||
category!: StatusCategory;
|
||||
@Field() @IsString() color!: string;
|
||||
@Field({ description: 'A palette colour (follows the theme) or #rrggbb' })
|
||||
@IsString()
|
||||
color!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
@Field(() => Int, {
|
||||
@@ -705,7 +707,9 @@ export class IssueTypeOptionInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
|
||||
@Field() @IsString() icon!: string;
|
||||
@Field() @IsString() color!: string;
|
||||
@Field({ description: 'A palette colour (follows the theme) or #rrggbb' })
|
||||
@IsString()
|
||||
color!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
@Field(() => IssueTemplateInput, { nullable: true })
|
||||
@IsOptional()
|
||||
|
||||
@@ -35,7 +35,11 @@ export class WorkflowStatusGql {
|
||||
id!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => StatusCategory) category!: StatusCategory;
|
||||
@Field({ description: 'Colour token: slate, violet, green…' }) color!: string;
|
||||
@Field({
|
||||
description:
|
||||
'A palette colour (slate, violet, green…, follows the theme) or #rrggbb',
|
||||
})
|
||||
color!: string;
|
||||
@Field({ description: 'Entering it needs a reason' })
|
||||
requiresReason!: boolean;
|
||||
@Field({ description: 'No new tickets or transitions into it' })
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
REACTIONS,
|
||||
SETTING_COLORS,
|
||||
SETTING_COLOR_PATTERN,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -153,8 +153,15 @@ export class WorkflowStatusBodyDto {
|
||||
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
|
||||
@IsEnum(StatusCategory)
|
||||
category!: StatusCategory;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
@IsIn(SETTING_COLORS)
|
||||
@ApiProperty({
|
||||
description:
|
||||
'A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb',
|
||||
example: '#7c3aed',
|
||||
pattern: SETTING_COLOR_PATTERN.source,
|
||||
})
|
||||
@Matches(SETTING_COLOR_PATTERN, {
|
||||
message: 'color must be a palette colour or #rrggbb',
|
||||
})
|
||||
color!: string;
|
||||
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||
@IsOptional()
|
||||
@@ -212,8 +219,15 @@ export class IssueTypeOptionBodyDto {
|
||||
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' })
|
||||
@IsIn(TYPE_ICONS)
|
||||
icon!: string;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
@IsIn(SETTING_COLORS)
|
||||
@ApiProperty({
|
||||
description:
|
||||
'A palette colour (slate, stone, violet, blue, sky, teal, green, amber, orange, red, pink), which follows the theme, or any #rrggbb',
|
||||
example: '#7c3aed',
|
||||
pattern: SETTING_COLOR_PATTERN.source,
|
||||
})
|
||||
@Matches(SETTING_COLOR_PATTERN, {
|
||||
message: 'color must be a palette colour or #rrggbb',
|
||||
})
|
||||
color!: string;
|
||||
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||
@IsOptional()
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
SETTING_COLORS,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -34,7 +33,10 @@ export class WorkflowStatusDto {
|
||||
@ApiProperty({ example: 'En revisión' }) name!: string;
|
||||
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
|
||||
category!: StatusCategory;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
@ApiProperty({
|
||||
description: 'A palette colour (follows the theme) or #rrggbb',
|
||||
example: 'violet',
|
||||
})
|
||||
color!: string;
|
||||
@ApiProperty({ description: 'Entering it needs a reason' })
|
||||
requiresReason!: boolean;
|
||||
@@ -63,7 +65,10 @@ export class IssueTypeOptionDto {
|
||||
@ApiProperty({ example: 'BUG' }) id!: string;
|
||||
@ApiProperty({ example: 'Bug' }) name!: string;
|
||||
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
@ApiProperty({
|
||||
description: 'A palette colour (follows the theme) or #rrggbb',
|
||||
example: 'violet',
|
||||
})
|
||||
color!: string;
|
||||
@ApiProperty() archived!: boolean;
|
||||
@ApiPropertyOptional({
|
||||
|
||||
Reference in New Issue
Block a user