feat: start a project from another one's configuration

Creating a project can name one the creator owns or administers: its
statuses, transitions, types, areas, custom fields and automation rules
are copied once (paused rules stay paused; rules assigning to someone who
is not in the new project are left out). GraphQL templateProjectId, REST
template (id or key), MCP create_project template, and a select in the
web's new-project form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 10:25:00 +02:00
parent 21bc598590
commit e5f4e6c683
18 changed files with 342 additions and 72 deletions

View File

@@ -20,12 +20,15 @@ import {
PiCheckboxComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import { ProjectRole } from '@task-stream/web/tracker/domain';
import { AuthFacade } from '@task-stream/web/identity/application';
import { GoToComponent } from '../ui/go-to';
import { ProjectCardComponent } from '../ui/project-card';
@@ -46,6 +49,7 @@ import { MyWorkComponent } from './my-work';
PiTextFieldComponent,
PiTextareaComponent,
PiLabelDirective,
PiSelectFieldComponent,
],
template: `
<header class="ts-page-header">
@@ -125,6 +129,18 @@ import { MyWorkComponent } from './my-work';
>
<pi-label>Descripción</pi-label>
</pi-textarea>
@if (templateOptions().length > 1) {
<pi-select-field
[formField]="f.template"
[options]="templateOptions()"
>
<pi-label>Empezar con la configuración de</pi-label>
</pi-select-field>
<p class="ts-hint">
Copia sus estados, transiciones, tipos, áreas, campos y
automatizaciones. Después, cada proyecto cambia por su cuenta.
</p>
}
<div class="ts-public-check">
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
<p>
@@ -164,6 +180,11 @@ import { MyWorkComponent } from './my-work';
font-weight: 400;
}
}
.ts-hint {
margin: -0.5rem 0 0;
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-public-check {
p {
/* Under the checkbox's text, past the box. */
@@ -194,7 +215,28 @@ export class ProjectsPage implements OnInit {
name: '',
description: '',
isPublic: false,
template: '',
});
/** Projects I own or administer: the ones whose configuration I can copy. */
protected readonly templateOptions = computed<PiSelectFieldOption<string>[]>(
() => {
const me = this.auth.user()?.id;
return [
{ value: '', label: 'La de por defecto' },
...this.facade
.sorted()
.filter((p) =>
p.members.some(
(m) =>
m.user.id === me &&
(m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN),
),
)
.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` })),
];
},
);
protected readonly f = form(this.model, (p) => {
required(p.key);
pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, {
@@ -211,18 +253,25 @@ export class ProjectsPage implements OnInit {
}
protected openCreate(): void {
this.model.set({ key: '', name: '', description: '', isPublic: false });
this.model.set({
key: '',
name: '',
description: '',
isPublic: false,
template: '',
});
this.drawer.open('Nuevo proyecto', this.createForm());
}
protected create(): void {
if (this.f().invalid()) return;
const { key, name, description, isPublic } = this.model();
const { key, name, description, isPublic, template } = this.model();
this.facade.create({
key: key.toUpperCase(),
name,
description: description || null,
isPublic,
templateProjectId: template || null,
});
this.drawer.closeAll();
}