feat(web): priority, estimate and assignee change right on the board card
The card's title is now the link (stretched over the card), and the three chips carry an invisible native select each, so they work by keyboard, on a phone and with a screen reader without opening the ticket. Dragging still works from anywhere on the card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -312,6 +312,9 @@ const NOBODY = '__nobody__';
|
||||
[cdkDragData]="issue"
|
||||
[issue]="issue"
|
||||
[workflow]="project.workflow()"
|
||||
[editable]="project.canContribute()"
|
||||
[members]="cardMembers()"
|
||||
(edited)="board.editIssue(issue.id, $event)"
|
||||
/>
|
||||
} @empty {
|
||||
<p class="ts-column__empty">Nada por aquí</p>
|
||||
@@ -613,6 +616,11 @@ export class BoardPage {
|
||||
...options(PRIORITY),
|
||||
];
|
||||
|
||||
/** Who a card can be assigned to. */
|
||||
protected readonly cardMembers = computed(() =>
|
||||
this.project.members().map((m) => ({ id: m.user.id, name: m.user.name })),
|
||||
);
|
||||
|
||||
/* Options of the new ticket form: the empty value means nobody / none. */
|
||||
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
|
||||
@@ -4,16 +4,35 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiIconComponent } from '@pi/ui';
|
||||
import {
|
||||
DueState,
|
||||
dueState,
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
POINT_SCALE,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
import { CardChange } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
initials,
|
||||
pointOptions,
|
||||
PRIORITY,
|
||||
PRIORITY_LEVEL,
|
||||
typeLook,
|
||||
} from './copy';
|
||||
|
||||
/** Most urgent first, as the menus list them. */
|
||||
const PRIORITY_ORDER = [
|
||||
IssuePriority.CRITICAL,
|
||||
IssuePriority.HIGH,
|
||||
IssuePriority.MEDIUM,
|
||||
IssuePriority.LOW,
|
||||
IssuePriority.LOWEST,
|
||||
];
|
||||
|
||||
/** A ticket on the board / in lists: type stripe, key, title, signals. */
|
||||
@Component({
|
||||
@@ -21,9 +40,8 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiIconComponent, BlockedComponent],
|
||||
template: `
|
||||
<a
|
||||
<div
|
||||
class="ts-card"
|
||||
[routerLink]="['..', 'issues', issue().key]"
|
||||
[style.--type]="type().tone"
|
||||
[class.ts-card--critical]="issue().priority === 'CRITICAL'"
|
||||
>
|
||||
@@ -33,21 +51,45 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
</span>
|
||||
<span class="ts-card__key">{{ issue().key }}</span>
|
||||
@if (issue().blockedBy.length) {
|
||||
<ts-blocked [blockers]="issue().blockedBy" [compact]="true" />
|
||||
<ts-blocked
|
||||
class="ts-card__above"
|
||||
[blockers]="issue().blockedBy"
|
||||
[compact]="true"
|
||||
/>
|
||||
}
|
||||
@if (issue().area; as area) {
|
||||
<span class="ts-card__area">{{ areaLabel(area) }}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="ts-card__title">{{ issue().title }}</p>
|
||||
<!-- The title is the link; it covers the card (the selects stay above it). -->
|
||||
<a class="ts-card__title" [routerLink]="['..', 'issues', issue().key]">{{
|
||||
issue().title
|
||||
}}</a>
|
||||
<div class="ts-card__bottom">
|
||||
<span
|
||||
class="ts-signal"
|
||||
[attr.data-level]="level()"
|
||||
[attr.title]="'Prioridad ' + priority().label.toLowerCase()"
|
||||
[attr.aria-label]="'Prioridad ' + priority().label.toLowerCase()"
|
||||
>
|
||||
<i></i><i></i><i></i><i></i>
|
||||
<span class="ts-card__edit" [class.is-editable]="editable()">
|
||||
<span
|
||||
class="ts-signal"
|
||||
[attr.data-level]="level()"
|
||||
[attr.title]="'Prioridad ' + priority().label.toLowerCase()"
|
||||
[attr.aria-label]="'Prioridad ' + priority().label.toLowerCase()"
|
||||
>
|
||||
<i></i><i></i><i></i><i></i>
|
||||
</span>
|
||||
@if (editable()) {
|
||||
<select
|
||||
[attr.aria-label]="'Prioridad de ' + issue().key"
|
||||
(change)="pick('priority', $any($event.target).value)"
|
||||
>
|
||||
@for (option of priorities; track option.value) {
|
||||
<option
|
||||
[value]="option.value"
|
||||
[selected]="option.value === issue().priority"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</span>
|
||||
@if (issue().parent; as parent) {
|
||||
<span class="ts-card__meta" [attr.title]="'Cuelga de ' + parent.title"
|
||||
@@ -62,10 +104,32 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
>{{ dueLabel(state) }}</span
|
||||
>
|
||||
}
|
||||
@if (issue().storyPoints !== null) {
|
||||
<span class="ts-card__points" title="Estimación">{{
|
||||
issue().storyPoints
|
||||
}}</span>
|
||||
@if (issue().storyPoints !== null || editable()) {
|
||||
<span class="ts-card__edit" [class.is-editable]="editable()">
|
||||
<span
|
||||
class="ts-card__points"
|
||||
[class.is-empty]="issue().storyPoints === null"
|
||||
title="Estimación"
|
||||
>{{ issue().storyPoints ?? '–' }}</span
|
||||
>
|
||||
@if (editable()) {
|
||||
<select
|
||||
[attr.aria-label]="'Estimación de ' + issue().key"
|
||||
(change)="pick('storyPoints', $any($event.target).value)"
|
||||
>
|
||||
@for (option of points(); track option.value) {
|
||||
<option
|
||||
[value]="option.value"
|
||||
[selected]="
|
||||
option.value === (issue().storyPoints ?? '') + ''
|
||||
"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@if (issue().release; as release) {
|
||||
<span class="ts-card__meta" title="Release"
|
||||
@@ -79,17 +143,37 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
</span>
|
||||
}
|
||||
<span class="ts-card__spacer"></span>
|
||||
@if (issue().assignee; as assignee) {
|
||||
<span class="ts-card__who" [attr.title]="assignee.name">{{
|
||||
initialsOf(assignee.name)
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="ts-card__who ts-card__who--none" title="Sin asignar"
|
||||
>—</span
|
||||
>
|
||||
}
|
||||
<span class="ts-card__edit" [class.is-editable]="editable()">
|
||||
@if (issue().assignee; as assignee) {
|
||||
<span class="ts-card__who" [attr.title]="assignee.name">{{
|
||||
initialsOf(assignee.name)
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="ts-card__who ts-card__who--none" title="Sin asignar"
|
||||
>—</span
|
||||
>
|
||||
}
|
||||
@if (editable()) {
|
||||
<select
|
||||
[attr.aria-label]="'Persona asignada a ' + issue().key"
|
||||
(change)="pick('assigneeId', $any($event.target).value)"
|
||||
>
|
||||
<option value="" [selected]="!issue().assignee">
|
||||
Sin asignar
|
||||
</option>
|
||||
@for (member of members(); track member.id) {
|
||||
<option
|
||||
[value]="member.id"
|
||||
[selected]="member.id === issue().assignee?.id"
|
||||
>
|
||||
{{ member.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
@@ -156,10 +240,6 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
color-mix(in srgb, var(--type) 70%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--type);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
.ts-card__top,
|
||||
.ts-card__bottom {
|
||||
@@ -198,6 +278,51 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--ts-ink);
|
||||
text-decoration: none;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
.ts-card:has(.ts-card__title:focus-visible) {
|
||||
outline: 2px solid var(--type);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Above the stretched link: what can be clicked on its own. */
|
||||
.ts-card__above,
|
||||
.ts-card__edit.is-editable {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
/* A native select laid invisibly over the chip: keyboard, phone and
|
||||
screen reader for free, never clipped by the card. */
|
||||
.ts-card__edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
select {
|
||||
position: absolute;
|
||||
inset: -0.25rem;
|
||||
width: calc(100% + 0.5rem);
|
||||
height: calc(100% + 0.5rem);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
&.is-editable:hover > :first-child,
|
||||
&.is-editable:focus-within > :first-child {
|
||||
outline: 2px solid color-mix(in srgb, var(--ts-brand) 60%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
.ts-card__points.is-empty {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--ts-line-strong);
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-card__meta {
|
||||
display: inline-flex;
|
||||
@@ -274,6 +399,30 @@ export class IssueCardComponent {
|
||||
return day.toLocaleDateString('es', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
readonly issue = input.required<IssueSummary>();
|
||||
/** Priority, estimate and assignee change right on the card. */
|
||||
readonly editable = input(false);
|
||||
/** Who it can be assigned to. */
|
||||
readonly members = input<{ id: string; name: string }[]>([]);
|
||||
/** A change picked on the card. */
|
||||
readonly edited = output<CardChange>();
|
||||
protected readonly priorities = PRIORITY_ORDER.map((p) => ({
|
||||
value: p,
|
||||
label: PRIORITY[p].label,
|
||||
}));
|
||||
protected readonly points = computed(() =>
|
||||
pointOptions(POINT_SCALE, this.issue().storyPoints),
|
||||
);
|
||||
|
||||
protected pick(
|
||||
field: 'priority' | 'storyPoints' | 'assigneeId',
|
||||
value: string,
|
||||
): void {
|
||||
if (field === 'priority')
|
||||
this.edited.emit({ priority: value as IssuePriority });
|
||||
else if (field === 'storyPoints')
|
||||
this.edited.emit({ storyPoints: value === '' ? null : Number(value) });
|
||||
else this.edited.emit({ assigneeId: value || null });
|
||||
}
|
||||
/** The project's settings: names, icons and colours of types and areas. */
|
||||
readonly workflow = input.required<Workflow>();
|
||||
protected readonly type = computed(() =>
|
||||
|
||||
Reference in New Issue
Block a user