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:
2026-10-14 19:16:43 +02:00
parent 10c8f21ff4
commit 2c501a50d0
30 changed files with 292 additions and 207 deletions

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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>
}