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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user