feat: uuid ids everywhere, pickers for linked tickets, @pi/ui checkboxes
- Postgres: every table has a uuid `id` (gen_random_uuid()); the event store keeps its relay order in `seq`, and the lookup tables their uniqueness in constraints of their own. - MongoDB: `_id` is Mongo's ObjectId; `id` is the domain id (the same uuid as the aggregate's stream in Postgres), unique, and every query goes by it. Production starts from empty databases. - Web: linking tickets and hanging one from a parent are picked from a searchable select (links across projects, parents within the project) instead of typing a key; every checkbox is @pi/ui's pi-checkbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
IssueLink,
|
||||
IssuesGateway,
|
||||
IssueLinkType,
|
||||
IssueSummary,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
@@ -120,6 +121,20 @@ export class IssueFacade {
|
||||
}
|
||||
|
||||
/** Places the open ticket under another one by its key; null: back to the top. */
|
||||
/**
|
||||
* Tickets to link to or hang the open one from: the most recently updated
|
||||
* of all my projects (links may cross projects), the open one aside.
|
||||
*/
|
||||
candidates(): Observable<IssueSummary[]> {
|
||||
return this.issues
|
||||
.mine({ assignedToMe: false, limit: 200 })
|
||||
.pipe(
|
||||
map((page) =>
|
||||
page.items.filter((item) => item.id !== this.store.issue()?.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setParent(parentKey: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.parentRequested({ issueId, parentKey }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiCheckboxComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
@@ -34,6 +35,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
selector: 'ts-backlog-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
BulkBarComponent,
|
||||
RouterLink,
|
||||
CdkDrag,
|
||||
@@ -96,14 +98,15 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
[cdkDragData]="issue"
|
||||
[class.is-selected]="selected().has(issue.id)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<pi-checkbox
|
||||
class="ts-backlog__pick"
|
||||
[attr.aria-label]="'Seleccionar ' + issue.key"
|
||||
[disabled]="!project.canContribute()"
|
||||
[checked]="selected().has(issue.id)"
|
||||
(change)="toggle(issue.id, $any($event.target).checked)"
|
||||
/>
|
||||
(checkedChange)="toggle(issue.id, $event)"
|
||||
><span class="visually-hidden"
|
||||
>Seleccionar {{ issue.key }}</span
|
||||
></pi-checkbox
|
||||
>
|
||||
<span class="ts-backlog__pos ts-mono">{{ i + 1 }}</span>
|
||||
<span
|
||||
class="ts-backlog__type"
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { catchError, distinctUntilChanged, filter, of, switchMap } from 'rxjs';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
@@ -93,6 +96,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
@@ -404,8 +408,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(submit)="$event.preventDefault(); setParent()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
|
||||
<pi-searchable-select-field
|
||||
placeholder="Colgar de… (la épica o el ticket padre)"
|
||||
searchPlaceholder="Busca por clave o título"
|
||||
emptyLabel="Ningún ticket de este proyecto coincide"
|
||||
[options]="parentOptions()"
|
||||
[(value)]="parentKey"
|
||||
/>
|
||||
<pi-action-button
|
||||
@@ -413,7 +420,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Colgar"
|
||||
[disabled]="!parentKey().trim()"
|
||||
[disabled]="!parentKey()"
|
||||
(execute)="setParent()"
|
||||
/>
|
||||
</form>
|
||||
@@ -512,15 +519,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[options]="linkTypeOptions"
|
||||
[(value)]="linkType"
|
||||
/>
|
||||
<pi-text-field
|
||||
placeholder="Clave, p. ej. BE-3"
|
||||
<pi-searchable-select-field
|
||||
placeholder="Elige el ticket"
|
||||
searchPlaceholder="Busca por clave o título"
|
||||
emptyLabel="Ningún ticket coincide"
|
||||
[options]="linkOptions()"
|
||||
[(value)]="linkTarget"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Relacionar"
|
||||
[disabled]="!linkTarget().trim()"
|
||||
[disabled]="!linkTarget()"
|
||||
(execute)="link()"
|
||||
/>
|
||||
</form>
|
||||
@@ -1066,7 +1076,33 @@ export class IssuePage {
|
||||
|
||||
// Inline forms: plain signals (a required validator would flag them red right after submitting).
|
||||
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
||||
protected readonly linkTarget = signal('');
|
||||
protected readonly linkTarget = signal<string | null>(null);
|
||||
/** Tickets of my projects, to pick from instead of typing a key. */
|
||||
private readonly candidates = toSignal(
|
||||
toObservable(computed(() => this.facade.issue()?.id ?? null)).pipe(
|
||||
filter((id): id is string => !!id),
|
||||
distinctUntilChanged(),
|
||||
switchMap(() => this.facade.candidates().pipe(catchError(() => of([])))),
|
||||
),
|
||||
{ initialValue: [] },
|
||||
);
|
||||
/** Any of them: links may cross projects (FE ↔ BE ↔ QA). */
|
||||
protected readonly linkOptions = computed(() =>
|
||||
this.candidates().map((issue) => ({
|
||||
value: issue.key,
|
||||
label: `${issue.key} · ${issue.title}`,
|
||||
})),
|
||||
);
|
||||
/** Only this project's: a ticket hangs from one of its own. */
|
||||
protected readonly parentOptions = computed(() => {
|
||||
const projectId = this.facade.issue()?.projectId;
|
||||
return this.candidates()
|
||||
.filter((issue) => issue.projectId === projectId)
|
||||
.map((issue) => ({
|
||||
value: issue.key,
|
||||
label: `${issue.key} · ${issue.title}`,
|
||||
}));
|
||||
});
|
||||
protected readonly commentBody = signal('');
|
||||
/** The comment being answered, and the answer being written. */
|
||||
protected readonly replyingTo = signal<string | null>(null);
|
||||
@@ -1133,7 +1169,7 @@ export class IssuePage {
|
||||
protected readonly checklistText = signal('');
|
||||
protected readonly subtaskTitle = signal('');
|
||||
protected readonly subtaskType = signal<string | null>(null);
|
||||
protected readonly parentKey = signal('');
|
||||
protected readonly parentKey = signal<string | null>(null);
|
||||
/** Active types; subtasks start as tasks when the project has them. */
|
||||
protected readonly subtaskTypeOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
@@ -1227,10 +1263,10 @@ export class IssuePage {
|
||||
}
|
||||
|
||||
protected setParent(): void {
|
||||
const key = this.parentKey().trim();
|
||||
const key = this.parentKey();
|
||||
if (!key) return;
|
||||
this.facade.setParent(key);
|
||||
this.parentKey.set('');
|
||||
this.parentKey.set(null);
|
||||
}
|
||||
|
||||
protected move(to: string): void {
|
||||
@@ -1283,13 +1319,13 @@ export class IssuePage {
|
||||
}
|
||||
|
||||
protected link(): void {
|
||||
const targetKey = this.linkTarget().trim();
|
||||
const targetKey = this.linkTarget();
|
||||
if (!targetKey) return;
|
||||
this.facade.link(
|
||||
(this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType,
|
||||
targetKey,
|
||||
);
|
||||
this.linkTarget.set('');
|
||||
this.linkTarget.set(null);
|
||||
}
|
||||
|
||||
protected unlink(link: IssueLink): void {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiCheckboxComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiIconComponent,
|
||||
PiNoteComponent,
|
||||
@@ -78,6 +79,7 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
selector: 'ts-list-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
BlockedComponent,
|
||||
BulkBarComponent,
|
||||
VoteComponent,
|
||||
@@ -165,12 +167,13 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
<tr [class.is-selected]="selected().has(issue.id)">
|
||||
@if (project.canContribute()) {
|
||||
<td class="ts-list__pick">
|
||||
<input
|
||||
type="checkbox"
|
||||
[attr.aria-label]="'Seleccionar ' + issue.key"
|
||||
<pi-checkbox
|
||||
[checked]="selected().has(issue.id)"
|
||||
(change)="toggle(issue.id, $any($event.target).checked)"
|
||||
/>
|
||||
(checkedChange)="toggle(issue.id, $event)"
|
||||
><span class="visually-hidden"
|
||||
>Seleccionar {{ issue.key }}</span
|
||||
></pi-checkbox
|
||||
>
|
||||
</td>
|
||||
}
|
||||
<td class="ts-list__key">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@angular/forms/signals';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiSkeletonComponent,
|
||||
@@ -34,6 +35,7 @@ import { MyWorkComponent } from './my-work';
|
||||
selector: 'ts-projects-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
FormField,
|
||||
GoToComponent,
|
||||
ProjectCardComponent,
|
||||
@@ -123,14 +125,13 @@ import { MyWorkComponent } from './my-work';
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
<label class="ts-public-check">
|
||||
<input type="checkbox" [formField]="f.isPublic" />
|
||||
<span>
|
||||
<strong>Proyecto público</strong>
|
||||
<div class="ts-public-check">
|
||||
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
|
||||
<p>
|
||||
Cualquiera, también sin iniciar sesión, podrá verlo (solo lectura).
|
||||
Se puede cambiar después en Configuración.
|
||||
</span>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
@@ -164,22 +165,11 @@ import { MyWorkComponent } from './my-work';
|
||||
}
|
||||
}
|
||||
.ts-public-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-muted);
|
||||
cursor: pointer;
|
||||
input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.125rem;
|
||||
accent-color: var(--ts-brand);
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
color: var(--ts-ink);
|
||||
font-size: 0.875rem;
|
||||
p {
|
||||
/* Under the checkbox's text, past the box. */
|
||||
margin: 0 0 0 2.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-projects {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { PiCheckboxComponent, PiIconComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { TransferComponent } from '../ui/transfer';
|
||||
@@ -57,6 +57,7 @@ function idFrom(name: string): string {
|
||||
selector: 'ts-settings-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
RouterLink,
|
||||
GitIntegrationComponent,
|
||||
WebhooksComponent,
|
||||
@@ -263,18 +264,16 @@ function idFrom(name: string): string {
|
||||
})
|
||||
"
|
||||
/>
|
||||
<label class="ts-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="status.requiresReason"
|
||||
(change)="
|
||||
edit('statuses', i, {
|
||||
requiresReason: $any($event.target).checked,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span class="visually-hidden">Pide motivo</span>
|
||||
</label>
|
||||
<pi-checkbox
|
||||
class="ts-check"
|
||||
[checked]="status.requiresReason"
|
||||
(checkedChange)="
|
||||
edit('statuses', i, { requiresReason: $event })
|
||||
"
|
||||
><span class="visually-hidden"
|
||||
>Pide motivo</span
|
||||
></pi-checkbox
|
||||
>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="archive"
|
||||
[ngTemplateOutletContext]="{
|
||||
@@ -330,18 +329,15 @@ function idFrom(name: string): string {
|
||||
@for (to of activeStatuses(); track to.id) {
|
||||
<td>
|
||||
@if (from.id !== to.id) {
|
||||
<input
|
||||
type="checkbox"
|
||||
[attr.aria-label]="from.name + ' → ' + to.name"
|
||||
<pi-checkbox
|
||||
[checked]="allows(from.id, to.id)"
|
||||
(change)="
|
||||
toggleTransition(
|
||||
from.id,
|
||||
to.id,
|
||||
$any($event.target).checked
|
||||
)
|
||||
(checkedChange)="
|
||||
toggleTransition(from.id, to.id, $event)
|
||||
"
|
||||
/>
|
||||
><span class="visually-hidden"
|
||||
>{{ from.name }} → {{ to.name }}</span
|
||||
></pi-checkbox
|
||||
>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
@@ -29,21 +30,26 @@ const NOBODY = '__none__';
|
||||
selector: 'ts-bulk-bar',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiCheckboxComponent,
|
||||
PiActionButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="ts-bulk" role="toolbar" aria-label="Edición en bloque">
|
||||
<label class="ts-bulk__all">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="count() > 0 && !allSelected()"
|
||||
(change)="selectAll.emit($any($event.target).checked)"
|
||||
/>
|
||||
{{ count() ? count() + ' seleccionados' : 'Seleccionar todos' }}
|
||||
</label>
|
||||
<pi-checkbox
|
||||
class="ts-bulk__all"
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="count() > 0 && !allSelected()"
|
||||
[label]="
|
||||
count() === 1
|
||||
? '1 seleccionado'
|
||||
: count()
|
||||
? count() + ' seleccionados'
|
||||
: 'Seleccionar todos'
|
||||
"
|
||||
(checkedChange)="selectAll.emit($event)"
|
||||
/>
|
||||
@if (count()) {
|
||||
<pi-select-field [options]="fields" [(value)]="field" />
|
||||
@switch (field()) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { PiTextFieldComponent } from '@pi/ui';
|
||||
import { PiCheckboxComponent, PiTextFieldComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { Webhook, WebhooksGateway } from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from './copy';
|
||||
@@ -38,7 +38,7 @@ const EVENT_LABEL: Record<string, string> = {
|
||||
@Component({
|
||||
selector: 'ts-webhooks',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiTextFieldComponent],
|
||||
imports: [PiCheckboxComponent, PiTextFieldComponent],
|
||||
styleUrl: './settings-card.scss',
|
||||
template: `
|
||||
<section class="ts-panel ts-block ts-hooks" aria-labelledby="hooks-title">
|
||||
@@ -113,24 +113,18 @@ const EVENT_LABEL: Record<string, string> = {
|
||||
<pi-text-field placeholder="https://…" [(value)]="url" />
|
||||
<fieldset class="ts-hooks__picks">
|
||||
<legend>Eventos</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="picked().includes('*')"
|
||||
(change)="pick('*', $any($event.target).checked)"
|
||||
/>
|
||||
Todos
|
||||
</label>
|
||||
<pi-checkbox
|
||||
label="Todos"
|
||||
[checked]="picked().includes('*')"
|
||||
(checkedChange)="pick('*', $event)"
|
||||
/>
|
||||
@for (event of events(); track event) {
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[disabled]="picked().includes('*')"
|
||||
[checked]="picked().includes(event)"
|
||||
(change)="pick(event, $any($event.target).checked)"
|
||||
/>
|
||||
{{ label(event) }}
|
||||
</label>
|
||||
<pi-checkbox
|
||||
[label]="label(event)"
|
||||
[disabled]="picked().includes('*')"
|
||||
[checked]="picked().includes(event)"
|
||||
(checkedChange)="pick(event, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
<div>
|
||||
@@ -214,11 +208,6 @@ const EVENT_LABEL: Record<string, string> = {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user