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:
2026-10-17 09:41:23 +02:00
parent a32c937765
commit 423f82c1a3
6 changed files with 225 additions and 33 deletions

View File

@@ -6,9 +6,16 @@ import {
CommandResult,
CreateIssueInput,
IssueChanged,
IssuePriority,
SavedFilter,
} from '@task-stream/web/tracker/domain';
/** What a card can change without opening the ticket. */
export type CardChange =
| { priority: IssuePriority }
| { storyPoints: number | null }
| { assigneeId: string | null };
export const boardPageEvents = eventGroup({
source: 'Board Page',
events: {
@@ -21,6 +28,8 @@ export const boardPageEvents = eventGroup({
reason?: string | null;
}>(),
issueCreateRequested: type<CreateIssueInput>(),
/** From the card: priority, estimate or assignee (the live update repaints it). */
issueEditRequested: type<{ issueId: string; change: CardChange }>(),
/** Save the current filter under a name. */
viewSaveRequested: type<{ name: string; shared: boolean }>(),
viewDeleteRequested: type<{ id: string }>(),
@@ -33,6 +42,7 @@ export const boardApiEvents = eventGroup({
loaded: type<BoardColumn[]>(),
loadFailed: type<string>(),
issueMoveFailed: type<string>(),
issueEditFailed: type<string>(),
issueCreated: type<CommandResult>(),
issueCreateFailed: type<string>(),
viewsLoaded: type<SavedFilter[]>(),

View File

@@ -6,7 +6,7 @@ import {
CreateIssueInput,
sameCriteria,
} from '@task-stream/web/tracker/domain';
import { boardPageEvents } from '../events/board.events';
import { boardPageEvents, CardChange } from '../events/board.events';
import { BoardStore } from '../stores/board.store';
/** Kanban board of a project, live. */
@@ -46,6 +46,11 @@ export class BoardFacade {
this.dispatch.issueMoved({ issueId, to, reason });
}
/** A change from the card itself, without opening the ticket. */
editIssue(issueId: string, change: CardChange): void {
this.dispatch.issueEditRequested({ issueId, change });
}
createIssue(input: CreateIssueInput): void {
this.dispatch.issueCreateRequested(input);
}
@@ -65,3 +70,6 @@ export class BoardFacade {
export function provideBoardFacade(): Provider[] {
return [BoardStore, BoardFacade];
}
/** What a card can change by itself (see BoardFacade.editIssue). */
export type { CardChange } from '../events/board.events';

View File

@@ -186,6 +186,23 @@ export const BoardStore = signalStore(
),
),
),
edit$: events.on(boardPageEvents.issueEditRequested).pipe(
mergeMap(({ payload: { issueId, change } }) =>
('assigneeId' in change
? issues.assign(issueId, change.assigneeId)
: issues.update({ issueId, ...change })
).pipe(
mapResponse({
next: () => null,
error: (error) =>
boardApiEvents.issueEditFailed(describeError(error)),
}),
),
),
),
editFailed$: events
.on(boardApiEvents.issueEditFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),
/** The API refused the move: tell the user and put the card back. */
rollback$: events.on(boardApiEvents.issueMoveFailed).pipe(
tap(({ payload }) => notifications.error(payload)),

View File

@@ -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>[]>(
() => [

View File

@@ -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(() =>