@kernhq/module-tracker 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract/models.d.ts +1185 -136
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +131 -29
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/router.d.ts +1098 -356
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +6 -25
- package/dist/contract/router.js.map +1 -1
- package/dist/kql/fields.d.ts.map +1 -1
- package/dist/kql/fields.js +4 -1
- package/dist/kql/fields.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +39 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/kql/compile.d.ts.map +1 -1
- package/dist/server/kql/compile.js +9 -1
- package/dist/server/kql/compile.js.map +1 -1
- package/dist/server/router.d.ts +1104 -384
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +3 -15
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +1 -136
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +3 -13
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/config.d.ts +1 -11
- package/dist/server/services/config.d.ts.map +1 -1
- package/dist/server/services/config.js +28 -64
- package/dist/server/services/config.js.map +1 -1
- package/dist/server/services/db.d.ts +2 -4
- package/dist/server/services/db.d.ts.map +1 -1
- package/dist/server/services/db.js +4 -12
- package/dist/server/services/db.js.map +1 -1
- package/dist/server/services/imports.d.ts.map +1 -1
- package/dist/server/services/imports.js +50 -4
- package/dist/server/services/imports.js.map +1 -1
- package/dist/server/services/index.d.ts +2 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +3 -0
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/issues.d.ts +7 -18
- package/dist/server/services/issues.d.ts.map +1 -1
- package/dist/server/services/issues.js +34 -18
- package/dist/server/services/issues.js.map +1 -1
- package/dist/server/services/layout.d.ts +25 -0
- package/dist/server/services/layout.d.ts.map +1 -0
- package/dist/server/services/layout.js +97 -0
- package/dist/server/services/layout.js.map +1 -0
- package/dist/server/services/planning.d.ts.map +1 -1
- package/dist/server/services/planning.js +4 -1
- package/dist/server/services/planning.js.map +1 -1
- package/dist/server/services/projects.d.ts.map +1 -1
- package/dist/server/services/projects.js +0 -1
- package/dist/server/services/projects.js.map +1 -1
- package/dist/server/services/transitions.d.ts.map +1 -1
- package/dist/server/services/transitions.js +36 -0
- package/dist/server/services/transitions.js.map +1 -1
- package/dist/server/services/values.d.ts +37 -0
- package/dist/server/services/values.d.ts.map +1 -0
- package/dist/server/services/values.js +172 -0
- package/dist/server/services/values.js.map +1 -0
- package/migrations/0002_field_keys_unique.sql +35 -0
- package/migrations/0003_drop_field_schemes.sql +9 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/contract/models.ts +157 -33
- package/src/contract/router.ts +6 -27
- package/src/kql/fields.ts +5 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { KernError } from '@kernhq/kernel';
|
|
2
|
+
/**
|
|
3
|
+
* A source that is allowed to produce an incomplete issue.
|
|
4
|
+
*
|
|
5
|
+
* A customer replying to a support address does not know the workspace made "Impact" required. If
|
|
6
|
+
* required fields were enforced on that path, the mail would bounce and the request would be lost —
|
|
7
|
+
* so inbound sources record the gap and carry on, and a human completes the issue afterwards.
|
|
8
|
+
*/
|
|
9
|
+
const LENIENT_SOURCES = new Set(['email', 'intake', 'import', 'automation']);
|
|
10
|
+
const isBlank = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
|
|
11
|
+
const asArray = (v) => (Array.isArray(v) ? v : [v]);
|
|
12
|
+
/**
|
|
13
|
+
* Validates one value against its field definition. Pure: every lookup it needs is passed in, so
|
|
14
|
+
* it can be unit-tested and called in a loop without touching the database.
|
|
15
|
+
*/
|
|
16
|
+
export function checkValue(def, value, ctx = {}) {
|
|
17
|
+
if (isBlank(value))
|
|
18
|
+
return null;
|
|
19
|
+
const cfg = def.config ?? {};
|
|
20
|
+
switch (def.type) {
|
|
21
|
+
case 'number':
|
|
22
|
+
case 'formula': {
|
|
23
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
24
|
+
return 'Expected a number';
|
|
25
|
+
if (cfg.min !== undefined && value < cfg.min)
|
|
26
|
+
return `Must be at least ${cfg.min}`;
|
|
27
|
+
if (cfg.max !== undefined && value > cfg.max)
|
|
28
|
+
return `Must be at most ${cfg.max}`;
|
|
29
|
+
if (cfg.precision !== undefined) {
|
|
30
|
+
const scaled = value * 10 ** cfg.precision;
|
|
31
|
+
if (Math.abs(scaled - Math.round(scaled)) > 1e-9)
|
|
32
|
+
return cfg.precision === 0
|
|
33
|
+
? 'Must be a whole number'
|
|
34
|
+
: `Must have at most ${cfg.precision} decimal places`;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
case 'text':
|
|
39
|
+
case 'textarea': {
|
|
40
|
+
if (typeof value !== 'string')
|
|
41
|
+
return 'Expected text';
|
|
42
|
+
if (cfg.maxLength !== undefined && value.length > cfg.maxLength)
|
|
43
|
+
return `Must be ${cfg.maxLength} characters or fewer`;
|
|
44
|
+
if (cfg.pattern) {
|
|
45
|
+
// The pattern is validated when the field is saved, so a bad one cannot reach here. If one
|
|
46
|
+
// somehow does, refuse the value rather than throwing an unhandled SyntaxError on a write.
|
|
47
|
+
try {
|
|
48
|
+
if (!new RegExp(cfg.pattern).test(value))
|
|
49
|
+
return 'Does not match the required format';
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return 'The field has an invalid pattern — ask an administrator to correct it';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
case 'date':
|
|
58
|
+
case 'datetime': {
|
|
59
|
+
if (typeof value !== 'string' || Number.isNaN(Date.parse(value)))
|
|
60
|
+
return 'Expected a date in ISO format';
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
case 'checkbox':
|
|
64
|
+
return typeof value === 'boolean' ? null : 'Expected true or false';
|
|
65
|
+
case 'url': {
|
|
66
|
+
if (typeof value !== 'string')
|
|
67
|
+
return 'Expected a URL';
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = new URL(value);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return 'Expected a valid URL';
|
|
74
|
+
}
|
|
75
|
+
// Anything else — `javascript:`, `data:` — becomes a click target in the interface.
|
|
76
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
77
|
+
return 'Only http and https links are allowed';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
case 'select':
|
|
81
|
+
case 'multiselect':
|
|
82
|
+
case 'label': {
|
|
83
|
+
const values = def.type === 'select' ? [value] : asArray(value);
|
|
84
|
+
const allowed = ctx.optionIds ?? new Set(def.options.filter((o) => !o.archived).map((o) => o.id));
|
|
85
|
+
for (const v of values) {
|
|
86
|
+
if (typeof v !== 'string')
|
|
87
|
+
return 'Expected an option id';
|
|
88
|
+
if (!allowed.has(v))
|
|
89
|
+
return 'Not one of the available options';
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
case 'user':
|
|
94
|
+
case 'multiuser': {
|
|
95
|
+
const values = def.type === 'user' ? [value] : asArray(value);
|
|
96
|
+
for (const v of values) {
|
|
97
|
+
if (typeof v !== 'string')
|
|
98
|
+
return 'Expected a user id';
|
|
99
|
+
if (ctx.memberIds && !ctx.memberIds.has(v))
|
|
100
|
+
return 'Not a member of this workspace';
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
case 'relation': {
|
|
105
|
+
// Always an array, whatever `relationMultiple` says — see the KQL compiler, which relies on it.
|
|
106
|
+
const values = asArray(value);
|
|
107
|
+
if (cfg.relationMultiple === false && values.length > 1)
|
|
108
|
+
return 'Only one item may be linked';
|
|
109
|
+
for (const v of values)
|
|
110
|
+
if (typeof v !== 'string')
|
|
111
|
+
return 'Expected an issue id';
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Turns a submitted `custom` patch into the object to store.
|
|
120
|
+
*
|
|
121
|
+
* On create it starts empty and applies defaults; on update it merges into what is already there,
|
|
122
|
+
* where an explicit `null` deletes a key. Unknown keys are refused either way — silently dropping
|
|
123
|
+
* one makes a typo look like a field that does not save.
|
|
124
|
+
*/
|
|
125
|
+
export async function normaliseCustom(args) {
|
|
126
|
+
const byKey = new Map(args.fields.map((f) => [f.key, f]));
|
|
127
|
+
const out = args.mode === 'create' ? {} : { ...(args.current ?? {}) };
|
|
128
|
+
const problems = [];
|
|
129
|
+
let members;
|
|
130
|
+
let membersLoaded = false;
|
|
131
|
+
for (const [key, value] of Object.entries(args.patch ?? {})) {
|
|
132
|
+
const def = byKey.get(key);
|
|
133
|
+
if (!def)
|
|
134
|
+
throw KernError.badRequest(`Unknown custom field "${key}"`, { field: `cf.${key}` });
|
|
135
|
+
if (value === null) {
|
|
136
|
+
delete out[key];
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if ((def.type === 'user' || def.type === 'multiuser') && args.memberIds && !membersLoaded) {
|
|
140
|
+
members = await args.memberIds();
|
|
141
|
+
membersLoaded = true;
|
|
142
|
+
}
|
|
143
|
+
const problem = checkValue(def, value, { memberIds: members });
|
|
144
|
+
if (problem)
|
|
145
|
+
throw KernError.badRequest(problem, { field: `cf.${key}` });
|
|
146
|
+
out[key] = value;
|
|
147
|
+
}
|
|
148
|
+
if (args.mode === 'create')
|
|
149
|
+
for (const def of args.fields)
|
|
150
|
+
if (out[def.key] === undefined && def.defaultValue != null)
|
|
151
|
+
out[def.key] = def.defaultValue;
|
|
152
|
+
const lenient = LENIENT_SOURCES.has(args.source);
|
|
153
|
+
// On update, judge only the fields this patch touched. An issue created leniently — a customer
|
|
154
|
+
// email with no "Impact" — can still be edited; what cannot happen is *clearing* a required
|
|
155
|
+
// field, which touches it and so is caught.
|
|
156
|
+
const touched = new Set(Object.keys(args.patch ?? {}));
|
|
157
|
+
for (const def of args.fields) {
|
|
158
|
+
const fieldId = `cf.${def.key}`;
|
|
159
|
+
if (args.mode === 'update' && !touched.has(def.key))
|
|
160
|
+
continue;
|
|
161
|
+
const required = args.requiredFieldIds ? args.requiredFieldIds.has(fieldId) : def.required;
|
|
162
|
+
if (!required || !isBlank(out[def.key]))
|
|
163
|
+
continue;
|
|
164
|
+
const message = `"${def.name}" is required`;
|
|
165
|
+
if (lenient)
|
|
166
|
+
problems.push({ fieldId, message });
|
|
167
|
+
else
|
|
168
|
+
throw KernError.badRequest(message, { field: fieldId });
|
|
169
|
+
}
|
|
170
|
+
return { custom: out, skipped: problems };
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=values.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"values.js","sourceRoot":"","sources":["../../../src/server/services/values.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- A custom field's key is the key it writes into `issues.custom`, so two field definitions that
|
|
2
|
+
-- share a key share a value — whatever their project scope. Until now two partial unique indexes
|
|
3
|
+
-- allowed exactly that: a workspace-level `severity` and a project-scoped `severity` could coexist
|
|
4
|
+
-- and then silently overwrite each other on every issue in that project.
|
|
5
|
+
--
|
|
6
|
+
-- The fix is one unique constraint over (workspace_id, key). Project scope still decides where a
|
|
7
|
+
-- field is *visible*; it no longer decides what it is *called*.
|
|
8
|
+
--
|
|
9
|
+
-- Refuse to run if the data already contains a collision. Merging two fields means choosing which
|
|
10
|
+
-- definition wins and what happens to the values under the loser, and that is not a decision a
|
|
11
|
+
-- migration may take on an operator's behalf.
|
|
12
|
+
do $$
|
|
13
|
+
declare
|
|
14
|
+
conflict text;
|
|
15
|
+
begin
|
|
16
|
+
select string_agg(format('%s (%s definitions)', key, n), ', ' order by key)
|
|
17
|
+
into conflict
|
|
18
|
+
from (
|
|
19
|
+
select key, count(*) as n
|
|
20
|
+
from "mod_tracker"."field_defs"
|
|
21
|
+
group by workspace_id, key
|
|
22
|
+
having count(*) > 1
|
|
23
|
+
) dupes;
|
|
24
|
+
|
|
25
|
+
if conflict is not null then
|
|
26
|
+
raise exception
|
|
27
|
+
'tracker: cannot apply 0002_field_keys_unique — duplicate field keys exist: %', conflict
|
|
28
|
+
using hint =
|
|
29
|
+
'Rename or delete the duplicates so each key appears once per workspace, then migrate again.';
|
|
30
|
+
end if;
|
|
31
|
+
end $$;
|
|
32
|
+
--> statement-breakpoint
|
|
33
|
+
DROP INDEX IF EXISTS "mod_tracker"."field_defs_ws_project_key_uq";--> statement-breakpoint
|
|
34
|
+
DROP INDEX IF EXISTS "mod_tracker"."field_defs_ws_key_uq";--> statement-breakpoint
|
|
35
|
+
CREATE UNIQUE INDEX "field_defs_ws_key_uq" ON "mod_tracker"."field_defs" USING btree ("workspace_id","key");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Field schemes are gone. A project used to gate its custom fields twice: once through a field
|
|
2
|
+
-- scheme, and once through the per-work-item-type field layout that this release makes real. With
|
|
3
|
+
-- both in place a field disappears when *either* gate says so, and nobody can predict which one did
|
|
4
|
+
-- it. The layout is the more useful of the two — it also orders fields and marks them required — so
|
|
5
|
+
-- the scheme goes.
|
|
6
|
+
--
|
|
7
|
+
-- Type schemes and workflow schemes stay: they answer different questions.
|
|
8
|
+
ALTER TABLE "mod_tracker"."projects" DROP COLUMN IF EXISTS "field_scheme_id";--> statement-breakpoint
|
|
9
|
+
DROP TABLE IF EXISTS "mod_tracker"."field_schemes";
|
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
"when": 1787397066443,
|
|
16
16
|
"tag": "0001_rls",
|
|
17
17
|
"breakpoints": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"idx": 2,
|
|
21
|
+
"version": "7",
|
|
22
|
+
"when": 1787397066444,
|
|
23
|
+
"tag": "0002_field_keys_unique",
|
|
24
|
+
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1787397066445,
|
|
30
|
+
"tag": "0003_drop_field_schemes",
|
|
31
|
+
"breakpoints": true
|
|
18
32
|
}
|
|
19
33
|
]
|
|
20
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kernhq/module-tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Kern tracker module: projects, work item types, custom fields, workflows, issues, KQL, cycles, views, reports, intake, time tracking (server + client skeleton)",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
package/src/contract/models.ts
CHANGED
|
@@ -124,7 +124,6 @@ export const Project = z.object({
|
|
|
124
124
|
defaultAssignee: z.enum(['unassigned', 'lead']),
|
|
125
125
|
workflowSchemeId: Id.nullable(),
|
|
126
126
|
typeSchemeId: Id.nullable(),
|
|
127
|
-
fieldSchemeId: Id.nullable(),
|
|
128
127
|
settings: ProjectSettings,
|
|
129
128
|
/** public intake form token (null = intake disabled) */
|
|
130
129
|
intakeToken: z.string().nullable(),
|
|
@@ -141,6 +140,14 @@ export const Project = z.object({
|
|
|
141
140
|
})
|
|
142
141
|
export type Project = z.infer<typeof Project>
|
|
143
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The built-in project templates. `software`, `support`, `marketing` and `simple` are the four
|
|
145
|
+
* team shapes the tracker ships with; `kanban` and `blank` are kept because existing projects were
|
|
146
|
+
* created from them.
|
|
147
|
+
*/
|
|
148
|
+
export const ProjectTemplateId = z.enum(['software', 'support', 'marketing', 'simple', 'kanban', 'blank'])
|
|
149
|
+
export type ProjectTemplateId = z.infer<typeof ProjectTemplateId>
|
|
150
|
+
|
|
144
151
|
export const CreateProject = z.object({
|
|
145
152
|
key: ProjectKey,
|
|
146
153
|
name: z.string().min(1).max(120),
|
|
@@ -150,8 +157,8 @@ export const CreateProject = z.object({
|
|
|
150
157
|
leadId: UserId.optional(),
|
|
151
158
|
visibility: ProjectVisibility.default('workspace'),
|
|
152
159
|
defaultAssignee: z.enum(['unassigned', 'lead']).default('unassigned'),
|
|
153
|
-
/** seed types/workflow from a built-in template */
|
|
154
|
-
template:
|
|
160
|
+
/** seed types/workflow/fields from a built-in template */
|
|
161
|
+
template: ProjectTemplateId.default('software'),
|
|
155
162
|
/** or from a saved project template (overrides `template`) */
|
|
156
163
|
templateId: Id.optional(),
|
|
157
164
|
settings: ProjectSettings.partial().optional(),
|
|
@@ -169,7 +176,6 @@ export const UpdateProject = z.object({
|
|
|
169
176
|
defaultAssignee: z.enum(['unassigned', 'lead']).optional(),
|
|
170
177
|
workflowSchemeId: Id.nullable().optional(),
|
|
171
178
|
typeSchemeId: Id.nullable().optional(),
|
|
172
|
-
fieldSchemeId: Id.nullable().optional(),
|
|
173
179
|
settings: ProjectSettings.partial().optional(),
|
|
174
180
|
})
|
|
175
181
|
export type UpdateProject = z.infer<typeof UpdateProject>
|
|
@@ -186,21 +192,6 @@ export const ProjectMember = z.object({
|
|
|
186
192
|
})
|
|
187
193
|
export type ProjectMember = z.infer<typeof ProjectMember>
|
|
188
194
|
|
|
189
|
-
/** Reusable project blueprint (types, workflow, fields, labels, sample views). */
|
|
190
|
-
export const ProjectTemplate = z.object({
|
|
191
|
-
id: Id,
|
|
192
|
-
workspaceId: WorkspaceId.nullable(),
|
|
193
|
-
key: MachineKey,
|
|
194
|
-
name: z.string().min(1).max(120),
|
|
195
|
-
description: z.string().max(1000).nullable(),
|
|
196
|
-
icon: z.string().max(64).nullable(),
|
|
197
|
-
/** a full ProjectTemplateBody JSON */
|
|
198
|
-
body: z.record(z.string(), z.unknown()),
|
|
199
|
-
builtin: z.boolean(),
|
|
200
|
-
createdAt: Timestamp,
|
|
201
|
-
})
|
|
202
|
-
export type ProjectTemplate = z.infer<typeof ProjectTemplate>
|
|
203
|
-
|
|
204
195
|
// =====================================================================================
|
|
205
196
|
// work item types & hierarchy
|
|
206
197
|
// =====================================================================================
|
|
@@ -209,16 +200,66 @@ export type ProjectTemplate = z.infer<typeof ProjectTemplate>
|
|
|
209
200
|
export const HierarchyLevel = z.number().int().min(-1).max(2)
|
|
210
201
|
export type HierarchyLevel = z.infer<typeof HierarchyLevel>
|
|
211
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The system fields a work item type may lay out, in their default order.
|
|
205
|
+
*
|
|
206
|
+
* `pinned` fields are always visible: an issue without a title, a status or a type is not an
|
|
207
|
+
* issue. The settings editor does not offer the control, and the resolver ignores a stored
|
|
208
|
+
* instruction that tries to hide one.
|
|
209
|
+
*/
|
|
210
|
+
export const SYSTEM_LAYOUT_FIELDS = [
|
|
211
|
+
{ id: 'title', section: 'main', pinned: true },
|
|
212
|
+
{ id: 'description', section: 'main', pinned: false },
|
|
213
|
+
{ id: 'status', section: 'sidebar', pinned: true },
|
|
214
|
+
{ id: 'type', section: 'sidebar', pinned: true },
|
|
215
|
+
{ id: 'assignees', section: 'sidebar', pinned: false },
|
|
216
|
+
{ id: 'priority', section: 'sidebar', pinned: false },
|
|
217
|
+
{ id: 'labels', section: 'sidebar', pinned: false },
|
|
218
|
+
{ id: 'components', section: 'sidebar', pinned: false },
|
|
219
|
+
{ id: 'versions', section: 'sidebar', pinned: false },
|
|
220
|
+
{ id: 'estimate', section: 'sidebar', pinned: false },
|
|
221
|
+
{ id: 'startDate', section: 'sidebar', pinned: false },
|
|
222
|
+
{ id: 'dueDate', section: 'sidebar', pinned: false },
|
|
223
|
+
{ id: 'cycle', section: 'sidebar', pinned: false },
|
|
224
|
+
{ id: 'milestone', section: 'sidebar', pinned: false },
|
|
225
|
+
{ id: 'parent', section: 'sidebar', pinned: false },
|
|
226
|
+
{ id: 'reporter', section: 'sidebar', pinned: false },
|
|
227
|
+
] as const satisfies ReadonlyArray<{
|
|
228
|
+
id: string
|
|
229
|
+
section: 'main' | 'sidebar'
|
|
230
|
+
pinned: boolean
|
|
231
|
+
}>
|
|
232
|
+
|
|
233
|
+
export const SystemFieldId = z.enum(SYSTEM_LAYOUT_FIELDS.map((f) => f.id) as [string, ...string[]])
|
|
234
|
+
export type SystemFieldId = (typeof SYSTEM_LAYOUT_FIELDS)[number]['id']
|
|
235
|
+
|
|
236
|
+
/** System field ids that may never be hidden, whatever a stored layout says. */
|
|
237
|
+
export const PINNED_FIELD_IDS: readonly string[] = SYSTEM_LAYOUT_FIELDS.filter((f) => f.pinned).map(
|
|
238
|
+
(f) => f.id,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A layout entry's `fieldId` is a *namespaced* name: a system field id (`priority`, `dueDate`) or
|
|
243
|
+
* `cf.<key>` for a custom field. That is the same string KQL, `ViewDisplay.columns` and workflow
|
|
244
|
+
* post-functions already use, so one resolver serves all four.
|
|
245
|
+
*/
|
|
246
|
+
export const LayoutFieldId = z
|
|
247
|
+
.string()
|
|
248
|
+
.min(1)
|
|
249
|
+
.max(80)
|
|
250
|
+
.regex(/^(?:[a-zA-Z][a-zA-Z0-9_]*|cf\.[a-z][a-z0-9_]*)$/, 'Expected a system field id or `cf.<key>`')
|
|
251
|
+
export type LayoutFieldId = z.infer<typeof LayoutFieldId>
|
|
252
|
+
|
|
212
253
|
export const FieldLayoutItem = z.object({
|
|
213
|
-
/** system field
|
|
214
|
-
fieldId:
|
|
254
|
+
/** system field id (`priority`, `dueDate`…) or `cf.<key>` for a custom field */
|
|
255
|
+
fieldId: LayoutFieldId,
|
|
215
256
|
section: z.enum(['main', 'sidebar', 'hidden']).default('sidebar'),
|
|
257
|
+
/** required on this type, over and above the field definition's own `required` */
|
|
216
258
|
required: z.boolean().default(false),
|
|
217
259
|
hidden: z.boolean().default(false),
|
|
218
260
|
order: z.number().int().default(0),
|
|
219
261
|
})
|
|
220
262
|
export type FieldLayoutItem = z.infer<typeof FieldLayoutItem>
|
|
221
|
-
|
|
222
263
|
export const WorkItemType = z.object({
|
|
223
264
|
id: Id,
|
|
224
265
|
workspaceId: WorkspaceId,
|
|
@@ -377,15 +418,37 @@ export const UpsertFieldDef = z.object({
|
|
|
377
418
|
})
|
|
378
419
|
export type UpsertFieldDef = z.infer<typeof UpsertFieldDef>
|
|
379
420
|
|
|
380
|
-
/**
|
|
381
|
-
export const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
421
|
+
/** One field as an interface should render it, system and custom fields alike. */
|
|
422
|
+
export const ResolvedField = z.object({
|
|
423
|
+
fieldId: LayoutFieldId,
|
|
424
|
+
/** `system` fields are rendered by a built-in component, `custom` ones by their field type */
|
|
425
|
+
kind: z.enum(['system', 'custom']),
|
|
426
|
+
label: z.string().min(1).max(120),
|
|
427
|
+
section: z.enum(['main', 'sidebar']),
|
|
428
|
+
order: z.number().int(),
|
|
429
|
+
required: z.boolean(),
|
|
430
|
+
pinned: z.boolean(),
|
|
431
|
+
showInCards: z.boolean(),
|
|
432
|
+
/** present when `kind` is `custom` — everything needed to render and validate the value */
|
|
433
|
+
field: FieldDef.nullable(),
|
|
434
|
+
})
|
|
435
|
+
export type ResolvedField = z.infer<typeof ResolvedField>
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The fields of one work item type in one project, already ordered and merged.
|
|
439
|
+
*
|
|
440
|
+
* An **empty stored layout means the default layout** — everything visible. A field the stored
|
|
441
|
+
* layout does not name appends to `sidebar` rather than disappearing, so a newly created field
|
|
442
|
+
* shows up instead of looking broken.
|
|
443
|
+
*/
|
|
444
|
+
export const ResolvedLayout = z.object({
|
|
445
|
+
typeId: Id,
|
|
446
|
+
projectId: Id.nullable(),
|
|
447
|
+
main: z.array(ResolvedField),
|
|
448
|
+
sidebar: z.array(ResolvedField),
|
|
449
|
+
hidden: z.array(ResolvedField),
|
|
387
450
|
})
|
|
388
|
-
export type
|
|
451
|
+
export type ResolvedLayout = z.infer<typeof ResolvedLayout>
|
|
389
452
|
|
|
390
453
|
// =====================================================================================
|
|
391
454
|
// workflows
|
|
@@ -1053,6 +1116,13 @@ export const GroupBy = z.enum([
|
|
|
1053
1116
|
])
|
|
1054
1117
|
export type GroupBy = z.infer<typeof GroupBy>
|
|
1055
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* A group key that may also name a custom field. `GroupBy` stays as it was so nothing that accepts
|
|
1121
|
+
* only the built-in keys has to change; views and `issues.query` accept this wider one.
|
|
1122
|
+
*/
|
|
1123
|
+
export const GroupByValue = z.union([GroupBy, z.string().regex(/^cf\.[a-z][a-z0-9_]*$/)])
|
|
1124
|
+
export type GroupByValue = z.infer<typeof GroupByValue>
|
|
1125
|
+
|
|
1056
1126
|
export const OrderBy = z.object({
|
|
1057
1127
|
/** KQL field name (`priority`, `updated`, `rank`, `cf.severity`…) */
|
|
1058
1128
|
field: z.string().min(1),
|
|
@@ -1071,7 +1141,7 @@ export const BoardColumn = z.object({
|
|
|
1071
1141
|
export type BoardColumn = z.infer<typeof BoardColumn>
|
|
1072
1142
|
|
|
1073
1143
|
export const ViewDisplay = z.object({
|
|
1074
|
-
groupBy:
|
|
1144
|
+
groupBy: GroupByValue.default('none'),
|
|
1075
1145
|
subGroupBy: GroupBy.optional(),
|
|
1076
1146
|
orderBy: z.array(OrderBy).default([{ field: 'rank', dir: 'asc' }]),
|
|
1077
1147
|
/** visible columns (list/spreadsheet): system field names or `cf.<key>` */
|
|
@@ -1084,8 +1154,13 @@ export const ViewDisplay = z.object({
|
|
|
1084
1154
|
/** board columns (null → one column per status of the project's workflows) */
|
|
1085
1155
|
boardColumns: z.array(BoardColumn).nullable().default(null),
|
|
1086
1156
|
wipLimits: z.record(z.string(), z.number().int().positive()).default({}),
|
|
1087
|
-
/** calendar: which date field positions issues */
|
|
1088
|
-
calendarField: z
|
|
1157
|
+
/** calendar: which date field positions issues — a system date field or `cf.<key>` */
|
|
1158
|
+
calendarField: z
|
|
1159
|
+
.union([
|
|
1160
|
+
z.enum(['dueDate', 'startDate', 'createdAt', 'updatedAt', 'resolvedAt']),
|
|
1161
|
+
z.string().regex(/^cf\.[a-z][a-z0-9_]*$/),
|
|
1162
|
+
])
|
|
1163
|
+
.default('dueDate'),
|
|
1089
1164
|
/** timeline: show dependency arrows */
|
|
1090
1165
|
showDependencies: z.boolean().default(true),
|
|
1091
1166
|
density: z.enum(['compact', 'comfortable']).default('comfortable'),
|
|
@@ -1420,3 +1495,52 @@ export const IssueApproval = z.object({
|
|
|
1420
1495
|
updatedAt: Timestamp,
|
|
1421
1496
|
})
|
|
1422
1497
|
export type IssueApproval = z.infer<typeof IssueApproval>
|
|
1498
|
+
|
|
1499
|
+
// =====================================================================================
|
|
1500
|
+
// project templates
|
|
1501
|
+
// =====================================================================================
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Everything a template seeds into a new project. This is the *only* description of a template's
|
|
1505
|
+
* contents: the built-in four are values of this type in code, and a template saved from an
|
|
1506
|
+
* existing project snapshots into the same shape, so one applier serves both.
|
|
1507
|
+
*
|
|
1508
|
+
* Ids inside a body are template-local. Workflows are named by index, types name their workflow by
|
|
1509
|
+
* that index, and layouts name fields by `cf.<key>` — nothing here refers to a database id, which
|
|
1510
|
+
* is what lets a body created in one workspace apply in another.
|
|
1511
|
+
*/
|
|
1512
|
+
export const ProjectTemplateBody = z.object({
|
|
1513
|
+
version: z.literal(1).default(1),
|
|
1514
|
+
settings: ProjectSettings.partial().optional(),
|
|
1515
|
+
workflows: z
|
|
1516
|
+
.array(z.object({ name: z.string().min(1).max(120), definition: WorkflowDefinition }))
|
|
1517
|
+
.default([]),
|
|
1518
|
+
fields: z.array(UpsertFieldDef).default([]),
|
|
1519
|
+
types: z
|
|
1520
|
+
.array(
|
|
1521
|
+
UpsertWorkItemType.omit({ workflowId: true }).extend({
|
|
1522
|
+
/** index into `workflows`; null → the project's default workflow */
|
|
1523
|
+
workflowIndex: z.number().int().nonnegative().nullable().default(null),
|
|
1524
|
+
}),
|
|
1525
|
+
)
|
|
1526
|
+
.default([]),
|
|
1527
|
+
labels: z
|
|
1528
|
+
.array(z.object({ name: z.string().min(1).max(60), color: Color.nullable().default(null) }))
|
|
1529
|
+
.default([]),
|
|
1530
|
+
views: z.array(UpsertView).default([]),
|
|
1531
|
+
})
|
|
1532
|
+
export type ProjectTemplateBody = z.infer<typeof ProjectTemplateBody>
|
|
1533
|
+
|
|
1534
|
+
/** Reusable project blueprint (types, workflow, fields, labels, sample views). */
|
|
1535
|
+
export const ProjectTemplate = z.object({
|
|
1536
|
+
id: Id,
|
|
1537
|
+
workspaceId: WorkspaceId.nullable(),
|
|
1538
|
+
key: MachineKey,
|
|
1539
|
+
name: z.string().min(1).max(120),
|
|
1540
|
+
description: z.string().max(1000).nullable(),
|
|
1541
|
+
icon: z.string().max(64).nullable(),
|
|
1542
|
+
body: ProjectTemplateBody,
|
|
1543
|
+
builtin: z.boolean(),
|
|
1544
|
+
createdAt: Timestamp,
|
|
1545
|
+
})
|
|
1546
|
+
export type ProjectTemplate = z.infer<typeof ProjectTemplate>
|
package/src/contract/router.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
Cycle,
|
|
17
17
|
DateOnly,
|
|
18
18
|
FieldDef,
|
|
19
|
-
FieldScheme,
|
|
20
19
|
HierarchyRules,
|
|
21
20
|
ImportJob,
|
|
22
21
|
ImportSource,
|
|
@@ -42,6 +41,7 @@ import {
|
|
|
42
41
|
RecurringIssue,
|
|
43
42
|
RelationType,
|
|
44
43
|
RelationView,
|
|
44
|
+
ResolvedLayout,
|
|
45
45
|
RichDoc,
|
|
46
46
|
StatusHistoryEntry,
|
|
47
47
|
StatusInfo,
|
|
@@ -173,6 +173,11 @@ export const trackerContract = {
|
|
|
173
173
|
.route({ method: 'POST', path: '/types/{id}/archive', ...t('types') })
|
|
174
174
|
.input(ws.extend({ id: Id, archived: z.boolean().default(true) }))
|
|
175
175
|
.output(WorkItemType),
|
|
176
|
+
/** the fields of one type in one project, ordered and merged — what a form should render */
|
|
177
|
+
layout: baseContract
|
|
178
|
+
.route({ method: 'GET', path: '/types/{id}/layout', ...t('types') })
|
|
179
|
+
.input(ws.extend({ id: Id, projectId: Id.nullable().optional() }))
|
|
180
|
+
.output(ResolvedLayout),
|
|
176
181
|
hierarchyRules: baseContract
|
|
177
182
|
.route({ method: 'GET', path: '/types/hierarchy-rules', ...t('types') })
|
|
178
183
|
.input(ws)
|
|
@@ -238,32 +243,6 @@ export const trackerContract = {
|
|
|
238
243
|
.route({ method: 'DELETE', path: '/fields/{id}', ...t('fields') })
|
|
239
244
|
.input(ws.extend({ id: Id }))
|
|
240
245
|
.output(Ok),
|
|
241
|
-
schemes: {
|
|
242
|
-
list: baseContract
|
|
243
|
-
.route({ method: 'GET', path: '/field-schemes', ...t('fields') })
|
|
244
|
-
.input(ws)
|
|
245
|
-
.output(z.array(FieldScheme)),
|
|
246
|
-
create: baseContract
|
|
247
|
-
.route({ method: 'POST', path: '/field-schemes', ...t('fields') })
|
|
248
|
-
.input(ws.extend({ name: z.string().min(1).max(120), fieldIds: z.array(Id) }))
|
|
249
|
-
.output(FieldScheme),
|
|
250
|
-
update: baseContract
|
|
251
|
-
.route({ method: 'PATCH', path: '/field-schemes/{id}', ...t('fields') })
|
|
252
|
-
.input(
|
|
253
|
-
ws.extend({
|
|
254
|
-
id: Id,
|
|
255
|
-
patch: z.object({
|
|
256
|
-
name: z.string().min(1).max(120).optional(),
|
|
257
|
-
fieldIds: z.array(Id).optional(),
|
|
258
|
-
}),
|
|
259
|
-
}),
|
|
260
|
-
)
|
|
261
|
-
.output(FieldScheme),
|
|
262
|
-
delete: baseContract
|
|
263
|
-
.route({ method: 'DELETE', path: '/field-schemes/{id}', ...t('fields') })
|
|
264
|
-
.input(ws.extend({ id: Id }))
|
|
265
|
-
.output(Ok),
|
|
266
|
-
},
|
|
267
246
|
},
|
|
268
247
|
|
|
269
248
|
// ------------------------------------------------------------------ workflows
|
package/src/kql/fields.ts
CHANGED
|
@@ -143,7 +143,11 @@ export function customKqlField(key: string, fieldType: FieldType, label: string)
|
|
|
143
143
|
name: `cf.${key}`,
|
|
144
144
|
kind,
|
|
145
145
|
label,
|
|
146
|
-
array:
|
|
146
|
+
array:
|
|
147
|
+
fieldType === 'multiselect' ||
|
|
148
|
+
fieldType === 'multiuser' ||
|
|
149
|
+
fieldType === 'label' ||
|
|
150
|
+
fieldType === 'relation',
|
|
147
151
|
sortable: kind === 'number' || kind === 'date' || kind === 'datetime' || kind === 'text',
|
|
148
152
|
custom: { key, fieldType },
|
|
149
153
|
}
|