feat: teams within a project
Projects keep teams of members in their settings (settings.teams; people outside the project are dropped, teams are archived, not removed; absent when saving means unchanged). Tickets go to a team (`team`, also filtered in the list and in MCP search_issues), and a comment mentioning @[Frontend](team-FRONT) tells each of its members. Settings has the teams with their members, the ticket a «Equipo» select, the list a team filter, and «@» offers the teams after the people. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -477,9 +477,7 @@ export class BoardPage {
|
||||
protected readonly board = inject(BoardFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
/** Who `@` offers in the new ticket's description. */
|
||||
protected readonly mentionable = computed(() =>
|
||||
this.project.members().map((m) => m.user),
|
||||
);
|
||||
protected readonly mentionable = this.project.mentionable;
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly auth = inject(AuthFacade);
|
||||
|
||||
@@ -854,6 +854,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
@if (teamOptions().length > 1) {
|
||||
<pi-select-field
|
||||
[options]="teamOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.team ?? ''"
|
||||
(valueChange)="update({ team: $any($event || null) })"
|
||||
>
|
||||
<pi-label>Equipo</pi-label>
|
||||
</pi-select-field>
|
||||
}
|
||||
@for (field of customFields(); track field.id) {
|
||||
<ts-custom-field
|
||||
[field]="field"
|
||||
@@ -1076,6 +1086,18 @@ export class IssuePage {
|
||||
.map((t) => ({ value: t.id, label: t.name }));
|
||||
},
|
||||
);
|
||||
protected readonly teamOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => {
|
||||
const current = this.facade.issue()?.team;
|
||||
return [
|
||||
{ value: NONE, label: 'Sin equipo' },
|
||||
...this.project
|
||||
.workflow()
|
||||
.settings.teams.filter((t) => !t.archived || t.id === current)
|
||||
.map((t) => ({ value: t.id, label: t.name })),
|
||||
];
|
||||
},
|
||||
);
|
||||
protected readonly areaOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => {
|
||||
const workflow = this.project.workflow();
|
||||
@@ -1164,9 +1186,7 @@ export class IssuePage {
|
||||
protected readonly pasteImage: ImageUploader = (file) =>
|
||||
this.facade.uploadImage(file);
|
||||
/** Who `@` offers in what is written here. */
|
||||
protected readonly mentionable = computed(() =>
|
||||
this.project.members().map((m) => m.user),
|
||||
);
|
||||
protected readonly mentionable = this.project.mentionable;
|
||||
/** Pull requests first, then the newest. */
|
||||
protected readonly devLinks = computed(() =>
|
||||
[...(this.facade.issue()?.devLinks ?? [])].sort(
|
||||
@@ -1332,6 +1352,7 @@ export class IssuePage {
|
||||
type?: string;
|
||||
priority?: IssuePriority;
|
||||
area?: string | null;
|
||||
team?: string | null;
|
||||
storyPoints?: number | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
|
||||
@@ -112,6 +112,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
[options]="releaseFilterOptions()"
|
||||
[(value)]="releaseFilter"
|
||||
/>
|
||||
@if (teamFilterOptions().length > 1) {
|
||||
<pi-select-field [options]="teamFilterOptions()" [(value)]="team" />
|
||||
}
|
||||
@for (field of listFields(); track field.id) {
|
||||
<pi-select-field
|
||||
[options]="field.options"
|
||||
@@ -527,6 +530,18 @@ export class ListPage {
|
||||
protected readonly priority = signal<string | null>(ALL);
|
||||
protected readonly assignee = signal<string | null>(ALL);
|
||||
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||
protected readonly team = signal<string | null>(ALL);
|
||||
protected readonly teamFilterOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
>(() => {
|
||||
const teams = this.project.workflow().activeTeams();
|
||||
return teams.length
|
||||
? [
|
||||
{ value: ALL, label: 'Todos los equipos' },
|
||||
...teams.map((t) => ({ value: t.id, label: t.name })),
|
||||
]
|
||||
: [];
|
||||
});
|
||||
private readonly releaseFromUrl = effect(() => {
|
||||
const id = this.release();
|
||||
if (id) untracked(() => this.releaseFilter.set(id));
|
||||
@@ -626,6 +641,7 @@ export class ListPage {
|
||||
unassigned: assignee === NOBODY || undefined,
|
||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
teams: this.team() ? [this.team() as string] : undefined,
|
||||
noEstimate: this.gap() === 'estimate' || undefined,
|
||||
noArea: this.gap() === 'area' || undefined,
|
||||
updatedBefore: this.stale()
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
|
||||
|
||||
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields';
|
||||
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields' | 'teams';
|
||||
|
||||
const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
|
||||
{ id: CustomFieldType.TEXT, label: 'Texto' },
|
||||
@@ -530,6 +530,63 @@ function idFrom(name: string): string {
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Teams -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="teams-title">
|
||||
<header class="ts-block__head">
|
||||
<div>
|
||||
<h3 id="teams-title">Equipos</h3>
|
||||
<p class="ts-muted">
|
||||
Grupos de miembros: los tickets se pueden asignar a uno y
|
||||
«@equipo» avisa a todos sus miembros.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="ts-add" (click)="addTeam()">
|
||||
+ Añadir equipo
|
||||
</button>
|
||||
</header>
|
||||
@if (!d.teams.length) {
|
||||
<p class="ts-muted ts-empty-fields">Ningún equipo todavía.</p>
|
||||
}
|
||||
<ol class="ts-rows">
|
||||
@for (team of d.teams; track $index; let i = $index) {
|
||||
<li
|
||||
class="ts-row ts-field-grid"
|
||||
[class.is-archived]="team.archived"
|
||||
>
|
||||
<span class="ts-name">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="40"
|
||||
[attr.aria-label]="'Nombre del equipo ' + (i + 1)"
|
||||
[value]="team.name"
|
||||
(input)="setName('teams', i, $any($event.target).value)"
|
||||
/>
|
||||
<code class="ts-id">{{ team.id }}</code>
|
||||
</span>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="archive"
|
||||
[ngTemplateOutletContext]="{
|
||||
section: 'teams',
|
||||
index: i,
|
||||
item: team,
|
||||
}"
|
||||
/>
|
||||
<div class="ts-team-members ts-field-type">
|
||||
@for (member of facade.members(); track member.user.id) {
|
||||
<pi-checkbox
|
||||
[label]="member.user.name"
|
||||
[checked]="team.memberIds.includes(member.user.id)"
|
||||
(checkedChange)="
|
||||
setTeamMember(i, member.user.id, $event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Definition of done -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="dod-title">
|
||||
<header class="ts-block__head">
|
||||
@@ -1035,6 +1092,11 @@ function idFrom(name: string): string {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
.ts-team-members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
.ts-empty-fields {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1277,7 +1339,7 @@ export class SettingsPage {
|
||||
const d = this.draft();
|
||||
if (!d) return [];
|
||||
const problems: string[] = [];
|
||||
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields];
|
||||
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields, d.teams];
|
||||
if (lists.some((list) => list.some((item) => !item.name.trim())))
|
||||
problems.push('Hay nombres vacíos');
|
||||
if (
|
||||
@@ -1449,6 +1511,26 @@ export class SettingsPage {
|
||||
);
|
||||
}
|
||||
|
||||
protected addTeam(): void {
|
||||
this.patch((d) =>
|
||||
d.teams.push({
|
||||
id: this.uniqueId(d.teams, 'NUEVO_EQUIPO'),
|
||||
name: 'Nuevo equipo',
|
||||
memberIds: [],
|
||||
archived: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected setTeamMember(index: number, userId: string, on: boolean): void {
|
||||
this.patch((d) => {
|
||||
const team = d.teams[index];
|
||||
team.memberIds = on
|
||||
? [...new Set([...team.memberIds, userId])]
|
||||
: team.memberIds.filter((id) => id !== userId);
|
||||
});
|
||||
}
|
||||
|
||||
protected setChecklistBeforeDone(on: boolean): void {
|
||||
this.patch((d) => {
|
||||
d.checklistBeforeDone = on;
|
||||
|
||||
Reference in New Issue
Block a user