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:
@@ -86,6 +86,7 @@ const FIELD: Record<string, string> = {
|
||||
description: 'la descripción',
|
||||
type: 'el tipo',
|
||||
area: 'el área',
|
||||
team: 'el equipo',
|
||||
priority: 'la prioridad',
|
||||
labels: 'las etiquetas',
|
||||
figmaUrl: 'el enlace de Figma',
|
||||
|
||||
@@ -41,6 +41,7 @@ export class IssueCreatedEventHandler
|
||||
key: issue.key,
|
||||
type: issue.type,
|
||||
area: issue.area,
|
||||
team: issue.team ?? null,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
|
||||
@@ -350,6 +350,7 @@ export class IssuesService {
|
||||
title: `${issue.title} (copia)`.slice(0, 255),
|
||||
description: issue.description,
|
||||
area: issue.area,
|
||||
team: issue.team,
|
||||
priority: issue.priority,
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
mentionedUserIds,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
ProjectWorkflow,
|
||||
TEAM_MENTION_PREFIX,
|
||||
NotificationKind,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectRole,
|
||||
@@ -195,7 +198,12 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
if (event instanceof CommentAddedEvent) {
|
||||
const said = `«${excerpt(event.comment.body)}»`;
|
||||
// Only members: a mention cannot reach someone who cannot read the ticket.
|
||||
for (const userId of mentionedUserIds(event.comment.body)) {
|
||||
// A team's mention (`team-FRONTEND`) reaches each of its members.
|
||||
const workflow = new ProjectWorkflow(
|
||||
project.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
);
|
||||
const named = mentionedUserIds(event.comment.body);
|
||||
for (const userId of named) {
|
||||
if (!memberIds.has(userId)) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
@@ -203,6 +211,19 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
for (const id of named.filter((n) => n.startsWith(TEAM_MENTION_PREFIX))) {
|
||||
const team = workflow.teams.find(
|
||||
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
|
||||
);
|
||||
for (const userId of workflow.mentionedPeople([id])) {
|
||||
if (!memberIds.has(userId)) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.MENTIONED,
|
||||
summary: `${actor} mencionó a tu equipo ${team?.name ?? ''} en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const answered = event.comment.replyTo
|
||||
? await this.findComments.findById(event.comment.replyTo)
|
||||
: null;
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface IssueFilter {
|
||||
categories?: StatusCategory[];
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
/** Team ids of the project's settings. */
|
||||
teams?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
|
||||
@@ -44,6 +44,7 @@ export class IssueFactory {
|
||||
const details = normalizeIssueDetails(props);
|
||||
workflow.assertActiveType(props.type);
|
||||
workflow.assertActiveArea(props.area);
|
||||
workflow.assertActiveTeam(props.team);
|
||||
const title = details.title ?? '';
|
||||
assertThat(title.length > 0, 'Issue title is required');
|
||||
if (details.startDate && details.dueDate)
|
||||
@@ -60,6 +61,7 @@ export class IssueFactory {
|
||||
key: `${props.projectKey}-${props.number}`,
|
||||
type: props.type,
|
||||
area: props.area ?? null,
|
||||
team: props.team ?? null,
|
||||
title,
|
||||
description: details.description ?? null,
|
||||
status: startingStatus(props.status, workflow),
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface IssueDetails {
|
||||
type?: string;
|
||||
/** Id of one of the project's areas. */
|
||||
area?: string | null;
|
||||
/** Id of one of the project's teams. */
|
||||
team?: string | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
@@ -83,6 +85,8 @@ export interface IssueProps {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
/** Absent in tickets created before teams. */
|
||||
team?: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
/** Id of a status of the project's workflow. */
|
||||
@@ -113,6 +117,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public key!: string;
|
||||
public type!: string;
|
||||
public area: string | null = null;
|
||||
public team: string | null = null;
|
||||
public title!: string;
|
||||
public description: string | null = null;
|
||||
public status!: string;
|
||||
@@ -190,6 +195,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
assertThat(patch.title.length > 0, 'Issue title is required');
|
||||
if (patch.type !== undefined) workflow.assertActiveType(patch.type);
|
||||
if (patch.area !== undefined) workflow.assertActiveArea(patch.area);
|
||||
if (patch.team !== undefined) workflow.assertActiveTeam(patch.team);
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
|
||||
}
|
||||
|
||||
@@ -403,4 +403,55 @@ describe('Project settings', () => {
|
||||
});
|
||||
expect(project.workflow.checklistBeforeDone).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps teams of members, assigns tickets to them and expands their mentions', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', {
|
||||
...defaultProjectSettings(),
|
||||
teams: [
|
||||
{
|
||||
id: 'front',
|
||||
name: ' Frontend ',
|
||||
memberIds: ['dev', 'dev', 'stranger'],
|
||||
archived: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Normalised, and only people of the project.
|
||||
expect(project.workflow.teams).toEqual([
|
||||
{ id: 'FRONT', name: 'Frontend', memberIds: ['dev'], archived: false },
|
||||
]);
|
||||
// Absent: unchanged; removing one: refused (archive it instead).
|
||||
project.updateSettings('owner', defaultProjectSettings());
|
||||
expect(project.workflow.teams).toHaveLength(1);
|
||||
expect(() =>
|
||||
project.updateSettings('owner', {
|
||||
...defaultProjectSettings(),
|
||||
teams: [],
|
||||
}),
|
||||
).toThrow(/archívalo/);
|
||||
|
||||
expect(
|
||||
project.workflow.mentionedPeople(['owner', 'team-FRONT', 'team-NOPE']),
|
||||
).toEqual(['owner', 'dev']);
|
||||
|
||||
const issue = new IssueFactory().create(
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: 'WEB',
|
||||
number: 1,
|
||||
title: 'Botón',
|
||||
type: 'TASK',
|
||||
reporterId: 'dev',
|
||||
team: 'FRONT',
|
||||
},
|
||||
project.workflow,
|
||||
);
|
||||
expect(issue.team).toBe('FRONT');
|
||||
expect(() =>
|
||||
issue.updateDetails('dev', { team: 'NOPE' }, project.workflow),
|
||||
).toThrow(ValidationError);
|
||||
issue.updateDetails('dev', { team: null }, project.workflow);
|
||||
expect(issue.team).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,22 @@ export interface AreaOption {
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of the project's members (Frontend, QA…): tickets can go to it and
|
||||
* `@[Frontend](team-FRONTEND)` tells all of them.
|
||||
*/
|
||||
export interface TeamDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
/** User ids; people who leave the project are dropped. */
|
||||
memberIds: string[];
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** Prefix of a team's id in a mention: `@[Frontend](team-FRONTEND)`. */
|
||||
export const TEAM_MENTION_PREFIX = 'team-';
|
||||
export const MAX_TEAMS = 30;
|
||||
|
||||
/** What a custom field holds. */
|
||||
export enum CustomFieldType {
|
||||
TEXT = 'TEXT',
|
||||
@@ -140,6 +156,8 @@ export interface ProjectSettings {
|
||||
* off; when saving, absent means unchanged.
|
||||
*/
|
||||
checklistBeforeDone?: boolean;
|
||||
/** Absent in projects that never defined any; when saving, absent means unchanged. */
|
||||
teams?: TeamDefinition[];
|
||||
}
|
||||
|
||||
const status = (
|
||||
@@ -369,6 +387,34 @@ export class ProjectWorkflow {
|
||||
);
|
||||
}
|
||||
|
||||
/** The project's teams, archived ones included. */
|
||||
get teams(): TeamDefinition[] {
|
||||
return this.settings.teams ?? [];
|
||||
}
|
||||
|
||||
assertActiveTeam(id: string | null | undefined): void {
|
||||
if (id === null || id === undefined) return;
|
||||
const team = this.teams.find((t) => t.id === id);
|
||||
if (!team) throw new ValidationError(`Unknown team ${id}`);
|
||||
if (team.archived)
|
||||
throw new BusinessRuleError(`El equipo ${team.name} está archivado`);
|
||||
}
|
||||
|
||||
/** Who a mention id reaches: the person, or every member of the team. */
|
||||
mentionedPeople(mentionIds: string[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
mentionIds.flatMap((id) =>
|
||||
id.startsWith(TEAM_MENTION_PREFIX)
|
||||
? (this.teams.find(
|
||||
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
|
||||
)?.memberIds ?? [])
|
||||
: [id],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
assertActiveArea(id: string | null | undefined): void {
|
||||
if (id === null || id === undefined) return;
|
||||
const area = this.settings.areas.find((a) => a.id === id);
|
||||
@@ -414,6 +460,20 @@ export function validateProjectSettings(
|
||||
name: normName(a.name),
|
||||
archived: !!a.archived,
|
||||
}));
|
||||
const teams = (next.teams ?? previous.teams ?? []).map((t) => ({
|
||||
id: normId(t.id),
|
||||
name: normName(t.name),
|
||||
memberIds: [
|
||||
...new Set(
|
||||
(t.memberIds ?? []).map((m) => String(m).trim()).filter(Boolean),
|
||||
),
|
||||
].slice(0, 200),
|
||||
archived: !!t.archived,
|
||||
}));
|
||||
assertThat(
|
||||
teams.length <= MAX_TEAMS,
|
||||
`Un proyecto tiene como mucho ${MAX_TEAMS} equipos`,
|
||||
);
|
||||
const customFields = (next.customFields ?? previous.customFields ?? []).map(
|
||||
normCustomField,
|
||||
);
|
||||
@@ -434,6 +494,7 @@ export function validateProjectSettings(
|
||||
['tipo', issueTypes, previous.issueTypes],
|
||||
['área', areas, previous.areas],
|
||||
['campo', customFields, previous.customFields ?? []],
|
||||
['equipo', teams, previous.teams ?? []],
|
||||
] as const) {
|
||||
const ids = list.map((item) => item.id);
|
||||
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
@@ -517,6 +578,7 @@ export function validateProjectSettings(
|
||||
...((next.checklistBeforeDone ?? previous.checklistBeforeDone)
|
||||
? { checklistBeforeDone: true }
|
||||
: {}),
|
||||
...(teams.length ? { teams } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,17 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
/** Owners and admins customise statuses, transitions, types and areas. */
|
||||
updateSettings(actorId: string, next: ProjectSettings): void {
|
||||
this.assertCanManage(actorId);
|
||||
const settings = validateProjectSettings(next, this.settings);
|
||||
const validated = validateProjectSettings(next, this.settings);
|
||||
// A team only holds people of the project.
|
||||
const settings = validated.teams
|
||||
? {
|
||||
...validated,
|
||||
teams: validated.teams.map((t) => ({
|
||||
...t,
|
||||
memberIds: t.memberIds.filter((id) => this.isMember(id)),
|
||||
})),
|
||||
}
|
||||
: validated;
|
||||
if (JSON.stringify(settings) === JSON.stringify(this.settings)) return;
|
||||
this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings));
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export class IssueReadModel {
|
||||
/** Ids from the project's settings. */
|
||||
type!: string;
|
||||
area!: string | null;
|
||||
/** A team id of the project's settings. */
|
||||
team!: string | null;
|
||||
title!: string;
|
||||
description!: string | null;
|
||||
status!: string;
|
||||
|
||||
@@ -451,6 +451,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
if (filter.types?.length) query['type'] = { $in: filter.types };
|
||||
if (filter.areas?.length) query['area'] = { $in: filter.areas };
|
||||
if (filter.teams?.length) query['team'] = { $in: filter.teams };
|
||||
if (filter.priorities?.length) query['priority'] = { $in: filter.priorities };
|
||||
if (filter.labels?.length)
|
||||
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
|
||||
|
||||
@@ -428,6 +428,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
startDate: rest.startDate ?? null,
|
||||
team: rest.team ?? null,
|
||||
customFields: rest.customFields ?? {},
|
||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||
// Links recorded before projects had several repositories name none.
|
||||
|
||||
@@ -27,6 +27,13 @@ export interface SettingsPayload {
|
||||
template?: { description?: string | null; checklist?: string[] } | null;
|
||||
}[];
|
||||
areas: { id: string; name: string; archived: boolean }[];
|
||||
/** Absent when saving: unchanged. */
|
||||
teams?: {
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: string[];
|
||||
archived: boolean;
|
||||
}[];
|
||||
/** Definition of done. Absent when saving: unchanged. */
|
||||
checklistBeforeDone?: boolean;
|
||||
/** Absent when saving: unchanged. */
|
||||
@@ -44,6 +51,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
|
||||
...settings,
|
||||
customFields: settings.customFields ?? [],
|
||||
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||
teams: settings.teams ?? [],
|
||||
transitions: settings.statuses.map((s) => ({
|
||||
from: s.id,
|
||||
to: settings.transitions[s.id] ?? [],
|
||||
@@ -75,6 +83,7 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
||||
: {}),
|
||||
})),
|
||||
areas: payload.areas,
|
||||
...(payload.teams ? { teams: payload.teams } : {}),
|
||||
...(payload.checklistBeforeDone != null
|
||||
? { checklistBeforeDone: payload.checklistBeforeDone }
|
||||
: {}),
|
||||
|
||||
@@ -128,6 +128,11 @@ abstract class IssueFieldsInput {
|
||||
@Matches(SETTING_ID)
|
||||
area?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'A team id' })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID)
|
||||
team?: string | null;
|
||||
|
||||
@Field(() => IssuePriority, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority)
|
||||
@@ -515,6 +520,10 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
areas?: string[];
|
||||
@Field(() => [String], { nullable: true, description: 'Team ids' })
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
teams?: string[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
@@ -712,6 +721,18 @@ export class AreaOptionInput {
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class TeamInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
|
||||
@Field(() => [ID], { defaultValue: [] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
memberIds!: string[];
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CustomFieldDefinitionInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@@ -773,6 +794,17 @@ export class ProjectSettingsInput {
|
||||
@Type(() => CustomFieldDefinitionInput)
|
||||
customFields?: CustomFieldDefinitionInput[];
|
||||
|
||||
@Field(() => [TeamInput], {
|
||||
nullable: true,
|
||||
description: 'Absent: unchanged. Teams cannot be removed: archive them.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(30)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamInput)
|
||||
teams?: TeamInput[];
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
|
||||
@@ -104,6 +104,17 @@ export class CustomFieldValueGql {
|
||||
@Field() value!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Team', {
|
||||
description:
|
||||
'A group of members: tickets go to it; @team mentions tell them all',
|
||||
})
|
||||
export class TeamGql {
|
||||
@Field() id!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => [ID]) memberIds!: string[];
|
||||
@Field() archived!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectSettings', {
|
||||
description:
|
||||
'Statuses (board order), transitions, ticket types, areas and custom fields',
|
||||
@@ -120,6 +131,7 @@ export class ProjectSettingsGql {
|
||||
'Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)',
|
||||
})
|
||||
checklistBeforeDone!: boolean;
|
||||
@Field(() => [TeamGql]) teams!: TeamGql[];
|
||||
}
|
||||
|
||||
@ObjectType('Project')
|
||||
@@ -366,6 +378,8 @@ export class IssueGql {
|
||||
type!: string;
|
||||
@Field(() => String, { nullable: true, description: 'An area id' })
|
||||
area!: string | null;
|
||||
@Field(() => String, { nullable: true, description: 'A team id' })
|
||||
team!: string | null;
|
||||
@Field() title!: string;
|
||||
@Field(() => String, { nullable: true }) description!: string | null;
|
||||
@Field({ description: "A status id of the project's settings" })
|
||||
|
||||
@@ -243,6 +243,28 @@ export class AreaOptionBodyDto {
|
||||
archived = false;
|
||||
}
|
||||
|
||||
export class TeamBodyDto {
|
||||
@ApiProperty({ example: 'FRONTEND' }) @Matches(SETTING_ID) id!: string;
|
||||
@ApiProperty({ example: 'Frontend', maxLength: 40 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Pireon user ids of project members (others are dropped)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
memberIds: string[] = [];
|
||||
@ApiPropertyOptional({ type: Boolean, default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived = false;
|
||||
}
|
||||
|
||||
export class CustomFieldDefinitionBodyDto {
|
||||
@ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string;
|
||||
@ApiProperty({ example: 'Navegador', maxLength: 40 })
|
||||
@@ -319,6 +341,17 @@ export class ProjectSettingsBodyDto {
|
||||
@Type(() => CustomFieldDefinitionBodyDto)
|
||||
customFields?: CustomFieldDefinitionBodyDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [TeamBodyDto],
|
||||
description: 'Absent: unchanged. Teams cannot be removed: archive them.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(30)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TeamBodyDto)
|
||||
teams?: TeamBodyDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged',
|
||||
@@ -584,6 +617,12 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
area?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Team ids' })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
team?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
@@ -729,6 +768,16 @@ class IssueFieldsDto {
|
||||
@Matches(SETTING_ID)
|
||||
area?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'FRONTEND',
|
||||
description: "A team id of the project's settings",
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID)
|
||||
team?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority)
|
||||
|
||||
@@ -79,6 +79,13 @@ export class AreaOptionDto {
|
||||
@ApiProperty() archived!: boolean;
|
||||
}
|
||||
|
||||
export class TeamDto {
|
||||
@ApiProperty({ example: 'FRONTEND' }) id!: string;
|
||||
@ApiProperty({ example: 'Frontend' }) name!: string;
|
||||
@ApiProperty({ type: [String] }) memberIds!: string[];
|
||||
@ApiProperty() archived!: boolean;
|
||||
}
|
||||
|
||||
export class CustomFieldDefinitionDto {
|
||||
@ApiProperty({ example: 'BROWSER' }) id!: string;
|
||||
@ApiProperty({ example: 'Navegador' }) name!: string;
|
||||
@@ -108,6 +115,7 @@ export class ProjectSettingsDto {
|
||||
'Definition of done: tickets reach DONE only with their checklist ticked',
|
||||
})
|
||||
checklistBeforeDone!: boolean;
|
||||
@ApiProperty({ type: [TeamDto] }) teams!: TeamDto[];
|
||||
}
|
||||
|
||||
export class ProjectDto {
|
||||
@@ -422,6 +430,8 @@ export class IssueDto {
|
||||
type!: string;
|
||||
@ApiProperty({ type: String, nullable: true, example: 'FRONTEND' })
|
||||
area!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, description: 'A team id' })
|
||||
team!: string | null;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' })
|
||||
|
||||
@@ -165,6 +165,7 @@ export class IssuesController {
|
||||
categories: query.category,
|
||||
types: query.type,
|
||||
areas: query.area,
|
||||
teams: query.team,
|
||||
priorities: query.priority,
|
||||
assigneeId: query.assigneeId,
|
||||
unassigned: query.unassigned,
|
||||
|
||||
@@ -234,6 +234,7 @@ describe('ProjectFacade', () => {
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
customFields: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
project.saveSettings(settings);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
TEAM_MENTION_PREFIX,
|
||||
CreateReleaseInput,
|
||||
CreateSprintInput,
|
||||
ProjectRole,
|
||||
@@ -25,6 +26,20 @@ export class ProjectFacade {
|
||||
|
||||
readonly project = this.store.project;
|
||||
readonly members = this.store.members;
|
||||
/**
|
||||
* Who `@` offers: the members, then the active teams (a team's mention,
|
||||
* `team-<id>`, tells each of its members).
|
||||
*/
|
||||
readonly mentionable = computed(() => [
|
||||
...this.store.members().map((m) => m.user),
|
||||
...this.store
|
||||
.workflow()
|
||||
.activeTeams()
|
||||
.map((t) => ({
|
||||
id: `${TEAM_MENTION_PREFIX}${t.id}`,
|
||||
name: `${t.name} · equipo`,
|
||||
})),
|
||||
]);
|
||||
readonly stats = this.store.stats;
|
||||
readonly releases = this.store.releases;
|
||||
/** Under way, planned, then closed; with their progress. */
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface CreateIssueInput {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
area?: string | null;
|
||||
team?: string | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
@@ -98,6 +99,7 @@ export interface IssueListFilter {
|
||||
search?: string;
|
||||
categories?: StatusCategory[];
|
||||
types?: string[];
|
||||
teams?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface IssueSummary {
|
||||
/** Ids of the project's settings. */
|
||||
type: string;
|
||||
area: string | null;
|
||||
/** A team id of the project's settings. */
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
|
||||
@@ -84,6 +84,17 @@ export interface CustomFieldDefinition {
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** A group of the project's members; `@[Name](team-ID)` tells them all. */
|
||||
export interface TeamDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: string[];
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** Prefix of a team's id in a mention. */
|
||||
export const TEAM_MENTION_PREFIX = 'team-';
|
||||
|
||||
/** A ticket's value of a custom field. */
|
||||
export interface CustomFieldValue {
|
||||
fieldId: string;
|
||||
@@ -99,6 +110,7 @@ export interface ProjectSettings {
|
||||
customFields: CustomFieldDefinition[];
|
||||
/** Definition of done: a DONE status only with the checklist ticked. */
|
||||
checklistBeforeDone?: boolean;
|
||||
teams: TeamDefinition[];
|
||||
}
|
||||
|
||||
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
||||
@@ -134,6 +146,7 @@ const EMPTY: ProjectSettings = {
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
customFields: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -187,6 +200,15 @@ export class Workflow {
|
||||
return this.settings.areas.filter((a) => !a.archived);
|
||||
}
|
||||
|
||||
/** Teams to pick (archived ones only show where a ticket still has them). */
|
||||
activeTeams(): TeamDefinition[] {
|
||||
return (this.settings.teams ?? []).filter((t) => !t.archived);
|
||||
}
|
||||
|
||||
team(id: string): TeamDefinition | undefined {
|
||||
return this.settings.teams?.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
/** Custom fields to fill in (archived ones only show where they have a value). */
|
||||
activeCustomFields(): CustomFieldDefinition[] {
|
||||
return (this.settings.customFields ?? []).filter((f) => !f.archived);
|
||||
|
||||
@@ -113,5 +113,11 @@ export function toSettingsInput(
|
||||
archived: f.archived,
|
||||
})),
|
||||
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||
teams: (settings.teams ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
memberIds: t.memberIds,
|
||||
archived: t.archived,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ fragment ProjectFields on Project {
|
||||
archived
|
||||
}
|
||||
checklistBeforeDone
|
||||
teams {
|
||||
id
|
||||
name
|
||||
memberIds
|
||||
archived
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +75,7 @@ fragment IssueSummaryFields on Issue {
|
||||
key
|
||||
type
|
||||
area
|
||||
team
|
||||
title
|
||||
status
|
||||
statusCategory
|
||||
|
||||
@@ -104,6 +104,8 @@ export type CreateIssueInput = {
|
||||
/** When work should start (roadmap), YYYY-MM-DD */
|
||||
startDate?: string | null | undefined;
|
||||
storyPoints?: number | null | undefined;
|
||||
/** A team id */
|
||||
team?: string | null | undefined;
|
||||
title: string;
|
||||
/** A ticket type id */
|
||||
type: string;
|
||||
@@ -194,6 +196,8 @@ export type IssueFilterInput = {
|
||||
sort?: IssueSort | null | undefined;
|
||||
sprintId?: string | null | undefined;
|
||||
statuses?: Array<string> | null | undefined;
|
||||
/** Team ids */
|
||||
teams?: Array<string> | null | undefined;
|
||||
types?: Array<string> | null | undefined;
|
||||
unassigned?: boolean | null | undefined;
|
||||
/** Only tickets not updated since this instant (ISO) */
|
||||
@@ -279,6 +283,8 @@ export type ProjectSettingsInput = {
|
||||
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
|
||||
issueTypes: Array<IssueTypeOptionInput>;
|
||||
statuses: Array<WorkflowStatusInput>;
|
||||
/** Absent: unchanged. Teams cannot be removed: archive them. */
|
||||
teams?: Array<TeamInput> | null | undefined;
|
||||
transitions: Array<StatusTransitionsInput>;
|
||||
};
|
||||
|
||||
@@ -339,6 +345,13 @@ export type StatusTransitionsInput = {
|
||||
to: Array<string>;
|
||||
};
|
||||
|
||||
export type TeamInput = {
|
||||
archived?: boolean;
|
||||
id: string;
|
||||
memberIds?: Array<string>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UpdateChecklistItemInput = {
|
||||
done?: boolean | null | undefined;
|
||||
issueId: string;
|
||||
@@ -361,6 +374,8 @@ export type UpdateIssueInput = {
|
||||
/** When work should start (roadmap), YYYY-MM-DD */
|
||||
startDate?: string | null | undefined;
|
||||
storyPoints?: number | null | undefined;
|
||||
/** A team id */
|
||||
team?: string | null | undefined;
|
||||
title?: string | null | undefined;
|
||||
type?: string | null | undefined;
|
||||
};
|
||||
@@ -560,6 +575,12 @@ export type ProjectFieldsFragment = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -569,6 +590,7 @@ export type IssueSummaryFieldsFragment = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -602,6 +624,7 @@ export type IssueFieldsFragment = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -780,6 +803,7 @@ export type BoardQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -821,6 +845,7 @@ export type IssueQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -886,6 +911,7 @@ export type SearchIssuesQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1059,6 +1085,7 @@ export type MyIssuesQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1096,6 +1123,7 @@ export type IssueChildrenQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1133,6 +1161,7 @@ export type EpicsQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1218,6 +1247,7 @@ export type BacklogQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1289,6 +1319,7 @@ export type TrashQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1325,6 +1356,7 @@ export type DueIssuesQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1364,6 +1396,7 @@ export type IssueListQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1559,6 +1592,12 @@ export type MyProjectsQuery = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -1611,6 +1650,12 @@ export type ProjectQuery = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1773,6 +1818,12 @@ export type PublicProjectsQuery = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -2331,6 +2382,7 @@ export type IssueChangedSubscription = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -2466,6 +2518,12 @@ export type ProjectChangedSubscription = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2521,6 +2579,12 @@ export type MyProjectsChangedSubscription = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2909,6 +2973,25 @@ export const ProjectFieldsFragmentDoc = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'teams' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'memberIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2950,6 +3033,7 @@ export const IssueSummaryFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -3209,6 +3293,7 @@ export const IssueFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -4986,6 +5071,7 @@ export const BoardDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -5158,6 +5244,7 @@ export const IssueDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -5465,6 +5552,7 @@ export const SearchIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -6873,6 +6961,7 @@ export const MyIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -7059,6 +7148,7 @@ export const IssueChildrenDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -7288,6 +7378,7 @@ export const EpicsDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -7796,6 +7887,7 @@ export const BacklogDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -8241,6 +8333,7 @@ export const TrashDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -8427,6 +8520,7 @@ export const DueIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -8628,6 +8722,7 @@ export const IssueListDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -9655,6 +9750,25 @@ export const MyProjectsDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'teams' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'memberIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -9903,6 +10017,25 @@ export const ProjectDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'teams' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'memberIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -11002,6 +11135,25 @@ export const PublicProjectsDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'teams' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'memberIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -13868,6 +14020,7 @@ export const IssueChangedDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -14529,6 +14682,25 @@ export const ProjectChangedDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'teams' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'memberIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -14764,6 +14936,25 @@ export const MyProjectsChangedDocument = {
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'teams' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'memberIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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