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:
@@ -118,7 +118,7 @@ Qué gana la página:
|
||||
| `MyViewsFacade` | `MyViewsStore` | ruta `/` (portada) | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
|
||||
| `ExploreFacade` | `ExploreStore` | ruta `explore` | Proyectos públicos («Explorar»), también sin sesión |
|
||||
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
|
||||
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
|
||||
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, prioridad, estimación y persona cambiadas desde la tarjeta, nuevo ticket, cambios en vivo |
|
||||
| `ListFacade` | `ListStore` | ruta `list` | Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros |
|
||||
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
||||
|
||||
@@ -304,7 +304,7 @@ visualización de la plataforma:
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
|
||||
@@ -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[]>(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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