feat: custom fields per project
Text, number, date or option-list fields defined in the project settings (archived, never removed; their type is fixed once saved). Tickets hold them, edited in the side panel, listed under the title and filtered by option in the list view; also over GraphQL, REST (?field=ID:value) and MCP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,16 @@ export enum StatusCategory {
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
/** What a custom field holds. */
|
||||
export enum CustomFieldType {
|
||||
TEXT = 'TEXT',
|
||||
NUMBER = 'NUMBER',
|
||||
/** `YYYY-MM-DD`. */
|
||||
DATE = 'DATE',
|
||||
/** One of its options. */
|
||||
SELECT = 'SELECT',
|
||||
}
|
||||
|
||||
export enum IssuePriority {
|
||||
LOWEST = 'LOWEST',
|
||||
LOW = 'LOW',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomFieldValue } from './workflow';
|
||||
import {
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
@@ -49,6 +50,8 @@ export interface CreateIssueInput {
|
||||
storyPoints?: number | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
/** Only the fields to change; null or '' clears one. */
|
||||
customFields?: { fieldId: string; value: string | null }[];
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
@@ -97,6 +100,8 @@ export interface IssueListFilter {
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
/** Only tickets holding these custom values. */
|
||||
customFields?: CustomFieldValue[];
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from './enums';
|
||||
import { ProjectSettings } from './workflow';
|
||||
import { CustomFieldValue, ProjectSettings } from './workflow';
|
||||
|
||||
export interface UserRef {
|
||||
id: string;
|
||||
@@ -126,6 +126,8 @@ export interface IssueSummary {
|
||||
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
/** Values of the project's custom fields (only those set). */
|
||||
customFields: CustomFieldValue[];
|
||||
/** In the trash since then (null: not in it). */
|
||||
trashedAt: string | null;
|
||||
commentCount: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IssuePriority, StatusCategory } from './enums';
|
||||
import { CustomFieldType, IssuePriority, StatusCategory } from './enums';
|
||||
|
||||
/** Colour tokens a status or ticket type can use (`--ts-c-*`). */
|
||||
export const SETTING_COLORS = [
|
||||
@@ -74,12 +74,29 @@ export interface AreaOption {
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** Per-project configuration: statuses (board order), transitions, types and areas. */
|
||||
/** A field the project adds to its tickets; its type never changes once saved. */
|
||||
export interface CustomFieldDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
/** The choices of a SELECT field. */
|
||||
options: string[];
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** A ticket's value of a custom field. */
|
||||
export interface CustomFieldValue {
|
||||
fieldId: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Per-project configuration: statuses (board order), transitions, types, areas and custom fields. */
|
||||
export interface ProjectSettings {
|
||||
statuses: WorkflowStatus[];
|
||||
transitions: StatusTransitions[];
|
||||
issueTypes: IssueTypeOption[];
|
||||
areas: AreaOption[];
|
||||
customFields: CustomFieldDefinition[];
|
||||
}
|
||||
|
||||
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
||||
@@ -114,6 +131,7 @@ const EMPTY: ProjectSettings = {
|
||||
transitions: [],
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
customFields: [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,6 +185,15 @@ export class Workflow {
|
||||
return this.settings.areas.filter((a) => !a.archived);
|
||||
}
|
||||
|
||||
/** Custom fields to fill in (archived ones only show where they have a value). */
|
||||
activeCustomFields(): CustomFieldDefinition[] {
|
||||
return (this.settings.customFields ?? []).filter((f) => !f.archived);
|
||||
}
|
||||
|
||||
customField(id: string): CustomFieldDefinition | undefined {
|
||||
return this.settings.customFields?.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
/** Most tickets the status should hold (null: no limit). */
|
||||
wipLimit(id: string): number | null {
|
||||
return this.status(id).wipLimit ?? null;
|
||||
|
||||
@@ -98,5 +98,12 @@ export function toSettingsInput(
|
||||
name: a.name,
|
||||
archived: a.archived,
|
||||
})),
|
||||
customFields: (settings.customFields ?? []).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
options: f.options,
|
||||
archived: f.archived,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ fragment ProjectFields on Project {
|
||||
name
|
||||
archived
|
||||
}
|
||||
customFields {
|
||||
id
|
||||
name
|
||||
type
|
||||
options
|
||||
archived
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +102,10 @@ fragment IssueSummaryFields on Issue {
|
||||
storyPoints
|
||||
startDate
|
||||
dueDate
|
||||
customFields {
|
||||
fieldId
|
||||
value
|
||||
}
|
||||
trashedAt
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ export type CreateIssueInput = {
|
||||
/** An area id */
|
||||
area?: string | null | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
/** Only the fields to change */
|
||||
customFields?: Array<CustomFieldValueInput> | null | undefined;
|
||||
description?: string | null | undefined;
|
||||
dueDate?: string | null | undefined;
|
||||
/** Figma frame for UI changes */
|
||||
@@ -141,6 +143,24 @@ export type CreateWebhookInput = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type CustomFieldDefinitionInput = {
|
||||
archived?: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
/** The choices of a SELECT field */
|
||||
options?: Array<string>;
|
||||
type: CustomFieldType;
|
||||
};
|
||||
|
||||
/** What a custom field holds (DATE: YYYY-MM-DD) */
|
||||
export type CustomFieldType = 'DATE' | 'NUMBER' | 'SELECT' | 'TEXT';
|
||||
|
||||
/** A custom field's value; null or '' clears it */
|
||||
export type CustomFieldValueInput = {
|
||||
fieldId: string;
|
||||
value?: string | null | undefined;
|
||||
};
|
||||
|
||||
export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
|
||||
|
||||
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
|
||||
@@ -149,6 +169,8 @@ export type IssueFilterInput = {
|
||||
areas?: Array<string> | null | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
categories?: Array<StatusCategory> | null | undefined;
|
||||
/** Only tickets holding these custom values */
|
||||
customFields?: Array<CustomFieldValueInput> | null | undefined;
|
||||
/** Due on or after this day (YYYY-MM-DD) */
|
||||
dueFrom?: string | null | undefined;
|
||||
/** Due on or before this day (YYYY-MM-DD) */
|
||||
@@ -231,6 +253,8 @@ export type ProjectRole = 'ADMIN' | 'MEMBER' | 'OWNER' | 'VIEWER';
|
||||
/** The whole configuration (replaces the previous one). Statuses go in board order. */
|
||||
export type ProjectSettingsInput = {
|
||||
areas: Array<AreaOptionInput>;
|
||||
/** Absent: unchanged. Fields cannot be removed: archive them. */
|
||||
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
|
||||
issueTypes: Array<IssueTypeOptionInput>;
|
||||
statuses: Array<WorkflowStatusInput>;
|
||||
transitions: Array<StatusTransitionsInput>;
|
||||
@@ -298,6 +322,8 @@ export type UpdateChecklistItemInput = {
|
||||
export type UpdateIssueInput = {
|
||||
/** An area id */
|
||||
area?: string | null | undefined;
|
||||
/** Only the fields to change */
|
||||
customFields?: Array<CustomFieldValueInput> | null | undefined;
|
||||
description?: string | null | undefined;
|
||||
dueDate?: string | null | undefined;
|
||||
/** Figma frame for UI changes */
|
||||
@@ -498,6 +524,13 @@ export type ProjectFieldsFragment = {
|
||||
template: { description: string | null; checklist: Array<string> } | null;
|
||||
}>;
|
||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -527,6 +560,7 @@ export type IssueSummaryFieldsFragment = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
};
|
||||
|
||||
export type IssueFieldsFragment = {
|
||||
@@ -588,6 +622,7 @@ export type IssueFieldsFragment = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
};
|
||||
|
||||
export type CommentFieldsFragment = {
|
||||
@@ -736,6 +771,7 @@ export type BoardQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
@@ -805,6 +841,7 @@ export type IssueQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -840,6 +877,7 @@ export type SearchIssuesQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -1008,6 +1046,7 @@ export type MyIssuesQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -1044,6 +1083,7 @@ export type IssueChildrenQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -1080,6 +1120,7 @@ export type EpicsQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
};
|
||||
progress: {
|
||||
total: number;
|
||||
@@ -1158,6 +1199,7 @@ export type BacklogQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -1228,6 +1270,7 @@ export type TrashQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -1263,6 +1306,7 @@ export type DueIssuesQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -1301,6 +1345,7 @@ export type IssueListQuery = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -1466,6 +1511,13 @@ export type MyProjectsQuery = {
|
||||
} | null;
|
||||
}>;
|
||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -1509,6 +1561,13 @@ export type ProjectQuery = {
|
||||
} | null;
|
||||
}>;
|
||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1662,6 +1721,13 @@ export type PublicProjectsQuery = {
|
||||
} | null;
|
||||
}>;
|
||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -2010,6 +2076,7 @@ export type IssueChangedSubscription = {
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2115,6 +2182,13 @@ export type ProjectChangedSubscription = {
|
||||
} | null;
|
||||
}>;
|
||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2161,6 +2235,13 @@ export type MyProjectsChangedSubscription = {
|
||||
} | null;
|
||||
}>;
|
||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||
customFields: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2524,6 +2605,26 @@ export const ProjectFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2638,6 +2739,17 @@ export const IssueSummaryFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -2886,6 +2998,17 @@ export const IssueFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -4600,6 +4723,17 @@ export const BoardDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -4761,6 +4895,17 @@ export const IssueDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -5057,6 +5202,17 @@ export const SearchIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -6375,6 +6531,17 @@ export const MyIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -6550,6 +6717,17 @@ export const IssueChildrenDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -6743,6 +6921,17 @@ export const EpicsDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -7240,6 +7429,17 @@ export const BacklogDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -7674,6 +7874,17 @@ export const TrashDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -7849,6 +8060,17 @@ export const DueIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -8039,6 +8261,17 @@ export const IssueListDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -8957,6 +9190,26 @@ export const MyProjectsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -9180,6 +9433,26 @@ export const ProjectDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -10254,6 +10527,26 @@ export const PublicProjectsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -12224,6 +12517,17 @@ export const IssueChangedDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
@@ -12776,6 +13080,26 @@ export const ProjectChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -12986,6 +13310,26 @@ export const MyProjectsChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'archived' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Attachment,
|
||||
Comment,
|
||||
DevLink,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
MarkdownComponent,
|
||||
} from '../ui/markdown';
|
||||
import { RichEditorComponent } from '../ui/rich-editor';
|
||||
import { CustomFieldComponent } from '../ui/custom-field';
|
||||
|
||||
const NONE = '';
|
||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||
@@ -80,6 +82,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
BlockedComponent,
|
||||
CustomFieldComponent,
|
||||
ReactionsComponent,
|
||||
MarkdownComponent,
|
||||
RichEditorComponent,
|
||||
@@ -843,6 +846,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
@for (field of customFields(); track field.id) {
|
||||
<ts-custom-field
|
||||
[field]="field"
|
||||
[value]="customValue(issue, field.id)"
|
||||
[disabled]="!project.canContribute() || field.archived"
|
||||
(valueChange)="
|
||||
update({ customFields: [{ fieldId: field.id, value: $event }] })
|
||||
"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!issue.trashedAt && canTrash(issue)) {
|
||||
<pi-action-button
|
||||
@@ -1314,10 +1327,26 @@ export class IssuePage {
|
||||
storyPoints?: number | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
customFields?: { fieldId: string; value: string | null }[];
|
||||
}): void {
|
||||
this.facade.update(changes);
|
||||
}
|
||||
|
||||
/** The project's active fields, plus archived ones this ticket still has. */
|
||||
protected readonly customFields = computed(() => {
|
||||
const issue = this.facade.issue();
|
||||
return (this.project.workflow().settings.customFields ?? []).filter(
|
||||
(f) =>
|
||||
!f.archived || !!issue?.customFields?.some((v) => v.fieldId === f.id),
|
||||
);
|
||||
});
|
||||
|
||||
protected customValue(issue: Issue, fieldId: string): string | null {
|
||||
return (
|
||||
issue.customFields?.find((v) => v.fieldId === fieldId)?.value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
protected link(): void {
|
||||
const targetKey = this.linkTarget();
|
||||
if (!targetKey) return;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
BulkChange,
|
||||
CustomFieldType,
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
StatusCategory,
|
||||
@@ -110,6 +111,13 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
[options]="releaseFilterOptions()"
|
||||
[(value)]="releaseFilter"
|
||||
/>
|
||||
@for (field of listFields(); track field.id) {
|
||||
<pi-select-field
|
||||
[options]="field.options"
|
||||
[value]="fieldFilters()[field.id] ?? ''"
|
||||
(valueChange)="setFieldFilter(field.id, $event)"
|
||||
/>
|
||||
}
|
||||
@if (facade.loaded()) {
|
||||
<span class="ts-muted ts-mono ts-list__count">
|
||||
{{ facade.total() }} tickets{{
|
||||
@@ -204,6 +212,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
↑ {{ parent.key }}</span
|
||||
>
|
||||
}
|
||||
@if (fieldsOf(issue); as fields) {
|
||||
<span class="ts-list__fields ts-muted">{{ fields }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="ts-list__type">
|
||||
<span
|
||||
@@ -384,6 +395,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
}
|
||||
.ts-list__title {
|
||||
min-width: 16rem;
|
||||
.ts-list__fields {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@@ -513,9 +528,44 @@ export class ListPage {
|
||||
: `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`,
|
||||
})),
|
||||
]);
|
||||
/** Custom list fields filter too: field id → the option picked. */
|
||||
protected readonly fieldFilters = signal<Record<string, string>>({});
|
||||
protected readonly listFields = computed(() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.activeCustomFields()
|
||||
.filter((f) => f.type === CustomFieldType.SELECT)
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
options: [
|
||||
{ value: ALL, label: `${f.name}: todos` },
|
||||
...f.options.map((o) => ({ value: o, label: `${f.name}: ${o}` })),
|
||||
] as PiSelectFieldOption<string>[],
|
||||
})),
|
||||
);
|
||||
|
||||
/** «Navegador: Safari · Coste: 120», in the project's order of fields. */
|
||||
protected fieldsOf(issue: IssueSummary): string {
|
||||
const values = new Map(
|
||||
(issue.customFields ?? []).map((v) => [v.fieldId, v.value]),
|
||||
);
|
||||
return (this.project.workflow().settings.customFields ?? [])
|
||||
.filter((f) => values.has(f.id))
|
||||
.map((f) => `${f.name}: ${values.get(f.id)}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
protected setFieldFilter(fieldId: string, value: string | null): void {
|
||||
this.fieldFilters.update((current) => {
|
||||
const { [fieldId]: _previous, ...rest } = current;
|
||||
return value ? { ...rest, [fieldId]: value } : rest;
|
||||
});
|
||||
}
|
||||
|
||||
private readonly criteria = computed<ListCriteria>(() => {
|
||||
const scope = this.scope();
|
||||
const assignee = this.assignee();
|
||||
const fields = Object.entries(this.fieldFilters());
|
||||
return {
|
||||
search: this.search() || undefined,
|
||||
categories:
|
||||
@@ -527,6 +577,9 @@ export class ListPage {
|
||||
unassigned: assignee === NOBODY || undefined,
|
||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
customFields: fields.length
|
||||
? fields.map(([fieldId, value]) => ({ fieldId, value }))
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TransferComponent } from '../ui/transfer';
|
||||
import { AutomationsComponent } from '../ui/automations';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
CustomFieldType,
|
||||
IssueTypeOption,
|
||||
ProjectSettings,
|
||||
SETTING_COLORS,
|
||||
@@ -31,7 +32,14 @@ import {
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
|
||||
|
||||
type Section = 'statuses' | 'issueTypes' | 'areas';
|
||||
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields';
|
||||
|
||||
const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
|
||||
{ id: CustomFieldType.TEXT, label: 'Texto' },
|
||||
{ id: CustomFieldType.NUMBER, label: 'Número' },
|
||||
{ id: CustomFieldType.DATE, label: 'Fecha' },
|
||||
{ id: CustomFieldType.SELECT, label: 'Lista de opciones' },
|
||||
];
|
||||
|
||||
const CATEGORIES = Object.values(StatusCategory);
|
||||
|
||||
@@ -75,8 +83,9 @@ function idFrom(name: string): string {
|
||||
<div>
|
||||
<h2>Configuración</h2>
|
||||
<p>
|
||||
Estados, transiciones, tipos de ticket y áreas de este proyecto. Lo
|
||||
que ya se usa no se borra: se archiva y deja de ofrecerse.
|
||||
Estados, transiciones, tipos de ticket, áreas y campos de este
|
||||
proyecto. Lo que ya se usa no se borra: se archiva y deja de
|
||||
ofrecerse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,6 +529,92 @@ function idFrom(name: string): string {
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Custom fields -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
||||
<header class="ts-block__head">
|
||||
<div>
|
||||
<h3 id="cf-title">Campos personalizados</h3>
|
||||
<p class="ts-muted">
|
||||
Datos propios de este proyecto en cada ticket (navegador,
|
||||
cliente, coste…). El tipo no cambia una vez guardado.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-add"
|
||||
[disabled]="d.customFields.length >= 30"
|
||||
(click)="addField()"
|
||||
>
|
||||
+ Añadir campo
|
||||
</button>
|
||||
</header>
|
||||
@if (!d.customFields.length) {
|
||||
<p class="ts-muted ts-empty-fields">Ningún campo todavía.</p>
|
||||
}
|
||||
<ol class="ts-rows">
|
||||
@for (field of d.customFields; track $index; let i = $index) {
|
||||
<li
|
||||
class="ts-row ts-field-grid"
|
||||
[class.is-archived]="field.archived"
|
||||
>
|
||||
<span class="ts-name">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="40"
|
||||
[attr.aria-label]="'Nombre del campo ' + (i + 1)"
|
||||
[value]="field.name"
|
||||
(input)="
|
||||
setName('customFields', i, $any($event.target).value)
|
||||
"
|
||||
/>
|
||||
<code class="ts-id">{{ field.id }}</code>
|
||||
</span>
|
||||
<select
|
||||
class="ts-input ts-field-type"
|
||||
aria-label="Tipo de campo"
|
||||
[disabled]="!isNew('customFields', field.id)"
|
||||
[attr.title]="
|
||||
isNew('customFields', field.id)
|
||||
? null
|
||||
: 'Ya guardado: el tipo no se puede cambiar'
|
||||
"
|
||||
(change)="setFieldType(i, $any($event.target).value)"
|
||||
>
|
||||
@for (t of fieldTypes; track t.id) {
|
||||
<option [value]="t.id" [selected]="t.id === field.type">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="archive"
|
||||
[ngTemplateOutletContext]="{
|
||||
section: 'customFields',
|
||||
index: i,
|
||||
item: field,
|
||||
}"
|
||||
/>
|
||||
@if (field.type === 'SELECT') {
|
||||
<label class="ts-field-options">
|
||||
<span>Opciones (una por línea)</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[attr.aria-label]="'Opciones de ' + field.name"
|
||||
[value]="
|
||||
field.options.join(
|
||||
'
|
||||
'
|
||||
)
|
||||
"
|
||||
(input)="setOptions(i, $any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -860,6 +955,36 @@ function idFrom(name: string): string {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
/* Name and archive on top; the type (and options) below. */
|
||||
.ts-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 1fr) 6.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
.ts-field-type {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
.ts-field-options {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-muted);
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: var(--ts-ink);
|
||||
background: var(--ts-surface);
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
.ts-empty-fields {
|
||||
margin: 0;
|
||||
}
|
||||
.ts-row {
|
||||
--tone: var(--ts-muted);
|
||||
padding: 0.375rem 0.5rem;
|
||||
@@ -1074,6 +1199,7 @@ export class SettingsPage {
|
||||
protected readonly categories = CATEGORIES;
|
||||
protected readonly colors = SETTING_COLORS;
|
||||
protected readonly icons = TYPE_ICONS;
|
||||
protected readonly fieldTypes = FIELD_TYPES;
|
||||
|
||||
/** What the API has; the draft is compared against it. */
|
||||
private readonly saved = computed(
|
||||
@@ -1098,7 +1224,7 @@ export class SettingsPage {
|
||||
const d = this.draft();
|
||||
if (!d) return [];
|
||||
const problems: string[] = [];
|
||||
const lists = [d.statuses, d.issueTypes, d.areas];
|
||||
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields];
|
||||
if (lists.some((list) => list.some((item) => !item.name.trim())))
|
||||
problems.push('Hay nombres vacíos');
|
||||
if (
|
||||
@@ -1117,6 +1243,12 @@ export class SettingsPage {
|
||||
problems.push('Falta un estado activo «Hecho»');
|
||||
if (!d.issueTypes.some((t) => !t.archived))
|
||||
problems.push('Hace falta al menos un tipo de ticket activo');
|
||||
for (const field of d.customFields)
|
||||
if (
|
||||
field.type === CustomFieldType.SELECT &&
|
||||
!field.options.some((o) => o.trim())
|
||||
)
|
||||
problems.push(`El campo «${field.name}» necesita alguna opción`);
|
||||
return problems;
|
||||
});
|
||||
|
||||
@@ -1264,6 +1396,33 @@ export class SettingsPage {
|
||||
);
|
||||
}
|
||||
|
||||
protected addField(): void {
|
||||
this.patch((d) =>
|
||||
d.customFields.push({
|
||||
id: this.uniqueId(d.customFields, 'NUEVO_CAMPO'),
|
||||
name: 'Nuevo campo',
|
||||
type: CustomFieldType.TEXT,
|
||||
options: [],
|
||||
archived: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected setFieldType(index: number, type: CustomFieldType): void {
|
||||
this.patch((d) => {
|
||||
const field = d.customFields[index];
|
||||
field.type = type;
|
||||
if (type !== CustomFieldType.SELECT) field.options = [];
|
||||
});
|
||||
}
|
||||
|
||||
/** One per line; blank lines and repeats go when saved. */
|
||||
protected setOptions(index: number, text: string): void {
|
||||
this.patch((d) => {
|
||||
d.customFields[index].options = text.split('\n').slice(0, 50);
|
||||
});
|
||||
}
|
||||
|
||||
/** Only for values never saved; saved ones are archived instead. */
|
||||
protected remove(section: Section, index: number): void {
|
||||
this.patch((d) => {
|
||||
@@ -1284,10 +1443,19 @@ export class SettingsPage {
|
||||
protected save(): void {
|
||||
const draft = this.draft();
|
||||
if (!draft || !this.dirty() || this.problems().length) return;
|
||||
this.facade.saveSettings({
|
||||
// Options as the API keeps them, so the draft is clean once saved.
|
||||
const cleaned: ProjectSettings = {
|
||||
...draft,
|
||||
customFields: draft.customFields.map((f) => ({
|
||||
...f,
|
||||
options: [...new Set(f.options.map((o) => o.trim()).filter(Boolean))],
|
||||
})),
|
||||
};
|
||||
this.draft.set(cleaned);
|
||||
this.facade.saveSettings({
|
||||
...cleaned,
|
||||
// Only rows with targets; unknown ids would be rejected anyway.
|
||||
transitions: draft.transitions.filter((t) => t.to.length),
|
||||
transitions: cleaned.transitions.filter((t) => t.to.length),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
125
libs/web/tracker/presenters/src/lib/ui/custom-field.ts
Normal file
125
libs/web/tracker/presenters/src/lib/ui/custom-field.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiDateFieldComponent,
|
||||
PiLabelDirective,
|
||||
PiNumberFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
CustomFieldDefinition,
|
||||
CustomFieldType,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** `YYYY-MM-DD` of a local date. */
|
||||
const dayOf = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
|
||||
/**
|
||||
* One custom field of a ticket, with the control its type asks for. Lists
|
||||
* and dates are saved when picked; text and numbers when leaving the field.
|
||||
* Emits the new value (null clears it) only when it changed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-custom-field',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiDateFieldComponent,
|
||||
PiLabelDirective,
|
||||
PiNumberFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
@switch (field().type) {
|
||||
@case (types.SELECT) {
|
||||
<pi-select-field
|
||||
[options]="options()"
|
||||
[disabled]="disabled()"
|
||||
[value]="value() ?? ''"
|
||||
(valueChange)="commit($event || null)"
|
||||
>
|
||||
<pi-label>{{ field().name }}</pi-label>
|
||||
</pi-select-field>
|
||||
}
|
||||
@case (types.DATE) {
|
||||
<pi-date-field
|
||||
[disabled]="disabled()"
|
||||
[value]="date()"
|
||||
(valueChange)="commit($event ? dayOf($event) : null)"
|
||||
>
|
||||
<pi-label>{{ field().name }}</pi-label>
|
||||
</pi-date-field>
|
||||
}
|
||||
@case (types.NUMBER) {
|
||||
<pi-number-field
|
||||
[disabled]="disabled()"
|
||||
[(value)]="number"
|
||||
(touch)="commit(number() === null ? null : '' + number())"
|
||||
>
|
||||
<pi-label>{{ field().name }}</pi-label>
|
||||
</pi-number-field>
|
||||
}
|
||||
@default {
|
||||
<pi-text-field
|
||||
[disabled]="disabled()"
|
||||
[(value)]="text"
|
||||
(touch)="commit(text().trim() || null)"
|
||||
>
|
||||
<pi-label>{{ field().name }}</pi-label>
|
||||
</pi-text-field>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CustomFieldComponent {
|
||||
readonly field = input.required<CustomFieldDefinition>();
|
||||
readonly value = input<string | null>(null);
|
||||
readonly disabled = input(false);
|
||||
readonly valueChange = output<string | null>();
|
||||
|
||||
protected readonly types = CustomFieldType;
|
||||
protected readonly dayOf = dayOf;
|
||||
|
||||
/** What is being typed; follows the saved value when it changes. */
|
||||
protected readonly text = linkedSignal(() => this.value() ?? '');
|
||||
protected readonly number = linkedSignal(() => {
|
||||
const value = this.value();
|
||||
return value === null || value === '' ? null : Number(value);
|
||||
});
|
||||
|
||||
protected readonly date = computed(() => {
|
||||
const day = this.value();
|
||||
return day ? new Date(`${day.slice(0, 10)}T00:00:00`) : null;
|
||||
});
|
||||
|
||||
/** The choices; one no longer offered still shows while the ticket has it. */
|
||||
protected readonly options = computed<PiSelectFieldOption<string>[]>(() => {
|
||||
const current = this.value();
|
||||
const choices = this.field().options;
|
||||
return [
|
||||
{ value: '', label: 'Sin valor' },
|
||||
...choices.map((o) => ({ value: o, label: o })),
|
||||
...(current && !choices.includes(current)
|
||||
? [{ value: current, label: current }]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
|
||||
protected commit(next: string | null): void {
|
||||
if (next !== (this.value() || null)) this.valueChange.emit(next);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user