diff --git a/ARCHITECTURE_OVERVIEW.md b/ARCHITECTURE_OVERVIEW.md index a59098e5d8..0e21309437 100644 --- a/ARCHITECTURE_OVERVIEW.md +++ b/ARCHITECTURE_OVERVIEW.md @@ -390,6 +390,8 @@ sequenceDiagram | analytics | platformcollective/analytics-collector | 4017 | Analytics collection | account, stats | | process | platformcollective/process | - | Workflow automation | redpanda, account | | rating | platformcollective/rating | - | Content rating | cockroach, redpanda, account | +| **AI** | | | | | +| mcp | platformcollective/mcp | 4090 | Model Context Protocol server (Streamable HTTP) | account, transactor, collaborator | | **Backup** | | | | | | backup | platformcollective/backup | - | Automated backup | cockroach, minio, account | | backup-api | platformcollective/backup-api | 4039 | Backup REST API | minio, account | @@ -433,6 +435,18 @@ sequenceDiagram - `QUEUE_CONFIG`: `cockroach|http://redpanda:9092` - Region-based event routing - `HULY_KAFKA_BOOTSTRAP`: `redpanda:9092` - Kafka bootstrap servers +### MCP Configuration (mcp only) +- `HULY_TOKEN`: Huly API token for the pod's own identity. Setting it selects self-hosted mode, where MCP clients need no Huly credential. +- `HULY_EMAIL` / `HULY_PASSWORD`: Login used when no `HULY_TOKEN` is set +- `HULY_WORKSPACE`: Workspace URL slug or ID the configured account is pinned/scoped to +- `COLLABORATOR_URL`: Collaborator service URL; enables writing rich-text descriptions +- `MCP_ALLOW_DEFAULT_SECRET`: `false` - Dev stacks only; permits `SECRET=secret` +- `MCP_READONLY`: `false` - Refuse every write tool +- `MCP_ALLOWED_TOKENS`: Comma-separated token allowlist for multi-tenant mode +- `MCP_RATE_LIMIT` / `MCP_RATE_WINDOW_MS`: `300` / `60000` - Per-client rate limit + +See `pods/mcp/README.md` for the full list. + ### Service URLs (Internal) - `ACCOUNTS_URL`: `http://huly.local:3000` - `TRANSACTOR_URL`: `ws://huly.local:3332` diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b091421b53..58f7402074 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -31043,6 +31043,136 @@ importers: specifier: ^5.9.3 version: 5.9.3 + ../../pods/mcp: + dependencies: + '@hcengineering/account-client': + specifier: workspace:^0.7.25 + version: link:../../foundations/core/packages/account-client + '@hcengineering/analytics': + specifier: workspace:^0.7.19 + version: link:../../foundations/core/packages/analytics + '@hcengineering/analytics-service': + specifier: workspace:^0.7.19 + version: link:../../foundations/core/packages/analytics-service + '@hcengineering/api-client': + specifier: workspace:^0.7.25 + version: link:../../foundations/core/packages/api-client + '@hcengineering/chunter': + specifier: workspace:^0.7.0 + version: link:../../plugins/chunter + '@hcengineering/collaborator-client': + specifier: workspace:^0.7.18 + version: link:../../foundations/core/packages/collaborator-client + '@hcengineering/contact': + specifier: workspace:^0.7.0 + version: link:../../plugins/contact + '@hcengineering/core': + specifier: workspace:^0.7.26 + version: link:../../foundations/core/packages/core + '@hcengineering/document': + specifier: workspace:^0.7.0 + version: link:../../plugins/document + '@hcengineering/drive': + specifier: workspace:^0.7.0 + version: link:../../plugins/drive + '@hcengineering/platform': + specifier: workspace:^0.7.20 + version: link:../../foundations/core/packages/platform + '@hcengineering/rank': + specifier: workspace:^0.7.18 + version: link:../../foundations/core/packages/rank + '@hcengineering/server-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/server/packages/core + '@hcengineering/server-token': + specifier: workspace:^0.7.18 + version: link:../../foundations/core/packages/token + '@hcengineering/tags': + specifier: workspace:^0.7.0 + version: link:../../plugins/tags + '@hcengineering/task': + specifier: workspace:^0.7.0 + version: link:../../plugins/task + '@hcengineering/text-core': + specifier: workspace:^0.7.19 + version: link:../../foundations/core/packages/text-core + '@hcengineering/text-markdown': + specifier: workspace:^0.7.21 + version: link:../../foundations/core/packages/text-markdown + '@hcengineering/tracker': + specifier: workspace:^0.7.0 + version: link:../../plugins/tracker + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../foundations/utils/packages/platform-rig + '@types/cors': + specifier: ^2.8.12 + version: 2.8.19 + '@types/express': + specifier: ^4.17.13 + version: 4.17.25 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^24.13.3 + version: 24.13.6 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + esbuild: + specifier: ^0.25.10 + version: 0.25.12 + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-node: + specifier: ^11.1.0 + version: 11.1.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.13.6)(ts-node@10.9.2(@types/node@24.13.6)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@24.13.6)(ts-node@10.9.2(@types/node@24.13.6)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.13.6)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../pods/media: dependencies: '@hcengineering/account-client': @@ -35622,6 +35752,9 @@ importers: '@hcengineering/view': specifier: workspace:^0.7.0 version: link:../../plugins/view + '@hcengineering/view-resources': + specifier: workspace:^0.7.0 + version: link:../../plugins/view-resources '@hcengineering/workbench': specifier: workspace:^0.7.0 version: link:../../plugins/workbench @@ -35632,6 +35765,9 @@ importers: '@types/jest': specifier: ^29.5.5 version: 29.5.14 + '@types/node': + specifier: ^24.13.3 + version: 24.13.6 '@typescript-eslint/eslint-plugin': specifier: ^6.21.0 version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -61133,7 +61269,7 @@ snapshots: node-loader@2.0.0(webpack@5.102.1): dependencies: loader-utils: 2.0.4 - webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) + webpack: 5.102.1 node-localstorage@2.2.1: dependencies: diff --git a/common/scripts/docker.sh b/common/scripts/docker.sh index aa3326de40..c007215913 100755 --- a/common/scripts/docker.sh +++ b/common/scripts/docker.sh @@ -51,6 +51,7 @@ else --to @hcengineering/pod-media \ --to @hcengineering/pod-preview \ --to @hcengineering/pod-link-preview \ + --to @hcengineering/pod-mcp \ --to @hcengineering/pod-external \ --to @hcengineering/pod-backup \ --to @hcengineering/backup-api-pod \ diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 7bd59d7873..402013808a 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -51,6 +51,27 @@ services: ports: - 4041:4041 restart: unless-stopped + mcp: + image: 'platformcollective/mcp' + extra_hosts: + - 'huly.local:host-gateway' + container_name: mcp + environment: + - SECRET=secret + # The dev stack uses the default platform secret everywhere. pod-mcp refuses it + # unless told this is a throwaway development stack. + - MCP_ALLOW_DEFAULT_SECRET=true + - PORT=4090 + - ACCOUNTS_URL=http://huly.local:3000 + - COLLABORATOR_URL=http://collaborator:3078 + # Set HULY_TOKEN (or HULY_EMAIL + HULY_PASSWORD) to run in self-hosted + # mode. Without it the server expects each MCP client to send its own + # Huly API token, which is not practical from Claude Desktop. + # - HULY_TOKEN=${HULY_TOKEN} + # - HULY_WORKSPACE=${HULY_WORKSPACE} + ports: + - 4090:4090 + restart: unless-stopped cockroach: image: cockroachdb/cockroach:latest-v24.3 extra_hosts: diff --git a/docs/agentlog.md b/docs/agentlog.md new file mode 100644 index 0000000000..3ef0672628 --- /dev/null +++ b/docs/agentlog.md @@ -0,0 +1,105 @@ +# Agent log + +Append-only. Newest entries at the bottom. Read only the tail if you are continuing work. + +## 2026-10-03 — Tracker → GitHub Projects parity: discovery + plan + +- Created branch `feat/tracker-projects-parity` off `d1a668cb2`. +- Mapped the tracker feature. Key correction: `plugins/tracker` is declarations only; + all UI lives in `plugins/tracker-resources`, model in `models/tracker`, + generic view infra in `plugins/view` + `plugins/view-resources`. +- Verified the biggest gap: Huly model is **TxModel-fixed**, so user-defined custom + fields cannot be real `core.class.Attribute`s. The `custom*` convention in + `ViewletSetting.svelte:230` is about derived/exported column *labels*, not runtime + attributes. Plan therefore uses a `ProjectField` definition doc + a + `Record` shadow prop on `Issue`. +- Verified `ViewOptions.groupBy` is already `string[]` and `groupDepth` exists in + `ViewOptionsModel` but is hardcoded to 1 — nested grouping needs only a real builder. +- Verified `view.class.FilteredView` is already a server-persisted saved view with + `filters` + `viewOptions` + `sharable` + `users`, but is scoped to the workspace + alias, not a project, and cannot carry a column set. +- Plan written to `docs/tracker-projects-parity/plan.md` (8 phases, 7 architecture + decisions, risk table). + +## 2026-10-03 — Reconciled GitHub Projects research into the plan (plan REWRITTEN) + +Research agent came back with verified corrections (official docs + live GraphQL schema ++ REST + `gh` CLI v2.98.0). Rewrote `plan.md` from scratch. Scope got **smaller and +sharper**, not bigger: + +- GitHub has exactly **3 layouts** (Table, Board, Roadmap). No calendar, no gantt-timeline, + no workload. Insights is a separate top-level section, not a layout. +- The view names in the screenshot (Current iteration, Next iteration, Prioritized + backlog, In review, My items) are **NOT GitHub defaults** — GitHub ships none. They are + community/template convention. We ship them as a seeded template. +- **No custom trigger→action automation builder** in GitHub. Only 2 built-in workflows + + auto-add + auto-archive. Recommending a field-change webhook over a rule engine. +- Exactly **6 custom field types**: TEXT, NUMBER, DATE, SINGLE_SELECT, MULTI_SELECT, + ITERATION. No checkbox, no people, no URL. Max 50 fields/project, 50 options/select. +- No "stack by" / nested grouping. No "Health"/"Priority"/"Estimate"/"Due date" built-ins. + No pie charts (6 layouts: Bar/Column/Stacked bar/Stacked column/Stacked area/Line). +- Export is `.tsv`, not CSV. +- Views are **project-wide and shared** — GitHub has **no personal views**. Dropped the + personal/shared split from the design. + +**New highest-leverage decision added — D3: adopt GitHub's filter grammar** (field:/has:/no:/ +is:/@me/@today/@current/@next/@previous/ranges a..b/wildcards/AND/OR+parens) as a pure +tokenizer+parser+compiler module, with TWO consumers: server-side string→DocumentQuery, and +client-side evaluation for custom fields the server cannot index. Sharing the tokenizer means +they cannot drift. + +**Honest scope cut recorded — D6:** historical Insights charts (X-axis=Time, Open/Completed +series, "Burn up" default) need field-value history that Huly does not have. Options are +replay `DOMAIN_TX` (degrades), snapshot collection (correct, costly), or ship current +charts only. Recommendation: current charts only in v1. Do NOT fake burn-up with TX replay. + +Phase list went from 8 to 12 and is now ordered by real dependency: fields(0-1) → views(2) +→ grammar(3) → bulk edit(4) → iterations(5) → roadmap(6) → board(7) → hierarchy/slice/sum(8) +→ insights(9) → workflows(10) → project-level roles(11). + +Next: get answers on the 7 open questions in plan.md §8, then time-box Phase 0 as a +2-3 day spike to validate the custom-field-on-record design end to end. + +## 2026-10-03 — Phases 0-14 implemented; PR #48 raised + +Author resolved the open questions (recorded in plan.md §8): the four dropped extras +(Calendar, Workload, nested grouping, automation builder) **stay in scope** as phases 12-15 +because the goal is "the tracker alone is as good as a GitHub board". Roles stay +workspace-level. Historical Insights / burn-up is out. One large PR. + +Phases **0-14** are now implemented and committed on `feat/tracker-projects-parity` +(20 commits). Phase 15 (automation rule builder) is deliberately not started — the phase 10 +field-change webhook is the parity-faithful route and lands first. + +Validation run locally across the 5 changed packages before raising the PR: + +``` +plugins/tracker-resources tsc --noEmit clean 1215 tests pass (86 suites) +plugins/view-resources tsc --noEmit clean 313 tests pass (16 suites) +models/tracker tsc --noEmit clean 4 tests pass +plugins/view tsc --noEmit clean +plugins/view-assets lang key-parity test passes (14 locales) +``` + +1533 tests, 0 failures. No formatter run (repo rule). + +## 2026-10-03 — Phase 14: nested grouping (the last commit on the branch) + +- Table groups up to 3 levels, Board 2 (lane + sub-lane), Roadmap 2. `groupDepth` per viewlet, + and since `builder.createDoc` does not update already-stored viewlet docs, the upgrade step + `set-nested-group-depth` of `model-tracker` patches the existing `IssueList` / `IssueKanban` / + `IssueRoadmap` viewlets. **If a new viewlet is added later, it needs the same treatment.** +- A drop on a board sub-lane writes column + lane + sub-lane in ONE update. Getting that wrong + silently loses the lane value — do not "simplify" it into three writes. +- Collapsed-group state is per viewer AND per saved view, but **only when the view groups on two + or more levels** (single level keeps the legacy key so every other list and the sub-issues of an + issue are unaffected). +- Plan correction recorded in plan.md: `plugins/view-resources/src/utils/nested-groups.ts` listed in + plan section 5 never existed. The real files are `view-resources/src/nestedGroups.ts` (shared list) + and `tracker-resources/src/grouping/` (tracker layouts). +- The old single-level `roadmap/grouping.ts` was deleted; its ordering rules are now covered by the + nested builder tests. + +Next: PR #48 is open against `develop`. It is **stacked on `feat/mcp-http-server`**, which has no +PR of its own, so the diff also shows 11 MCP commits that are not part of this effort. Either merge +the MCP work first or review only `git log d1a668cb2..HEAD`. Next work item is phase 15. \ No newline at end of file diff --git a/docs/tracker-projects-parity/plan.md b/docs/tracker-projects-parity/plan.md new file mode 100644 index 0000000000..e0b64e1a5f --- /dev/null +++ b/docs/tracker-projects-parity/plan.md @@ -0,0 +1,1450 @@ +# Plan: Bring `/tracker/` to GitHub Projects feature parity + +Branch: `feat/tracker-projects-parity` +Status: **PLAN ONLY** — no implementation code written yet. +Research basis: official GitHub docs + live GraphQL schema + REST API + `gh` CLI v2.98.0. + +--- + +## 0. Corrections to the naive reading of the screenshot + +The reference screenshot is a **Huly-shaped wishlist**, not a literal GitHub default. Verified +against official sources: + +| Common assumption | Reality (verified) | +|---|---| +| GitHub has list / calendar / gantt-timeline / workload layouts | **No — exactly 3 layouts**: `Table`, `Board`, `Roadmap`. Confirmed by the `ProjectV2ViewLayout` enum and the REST `layout` param. Insights is a **separate top-level section**, not a layout. | +| `Current iteration`, `Next iteration`, `Prioritized backlog`, `In review`, `My items` are GitHub default views | **UNCONFIRMED — GitHub ships none of these.** A blank project gets one view of the layout you picked. Those names are community/template convention. We will ship them as a **template**, which is legitimate — GitHub has templates, it just doesn't document the view names. | +| Users can build custom trigger→action automations | **No.** Only 2 fixed built-in workflows + auto-add + auto-archive. `ProjectV2Workflow` exposes only `id/number/name/enabled`. Automation otherwise goes through **GitHub Actions** via the `project_v2_item` webhook. | +| CSV export | **`.tsv`**, called "Export view data". | +| "Health", "Priority", "Estimate", "Due date" are built-in fields | **No.** `Estimate`/`Priority`/`Health` are *custom* fields users create. There is no `DUE_DATE` in `ProjectV2FieldType`. | +| "Stack by" / nested grouping / collapsed groups | **Not documented.** GraphQL has `groupByFields` + `verticalGroupByFields` but no nesting semantics. Grouping is single-level. | +| Health/insights charts include pie/donut/quadrant | **No.** Exactly 6 layouts: Bar, Column, Stacked bar, Stacked column, Stacked area, Line. | + +**Consequence:** the scope below is *smaller and sharper* than a naive "all GitHub features" list. + +--- + +## 1. Where the code actually is (correction to the premise) + +`plugins/tracker` is **declarations only** (3 files, 925 lines). It is not where the UI is. + +| Package | What it holds | +|---|---| +| `plugins/tracker` | `Issue`/`Project`/`Milestone`/`Component` interfaces + `plugin()` id registry | +| `plugins/tracker-assets` | Icon sprite + 14 locale JSONs | +| **`plugins/tracker-resources`** | **All real UI** — ~250 files, Kanban + Gantt + item drawer | +| `models/tracker` | `TIssue` model, viewlet/column registry, filters, actions, migrations | +| `plugins/view`, `plugins/view-resources` | Generic viewlet host, `FilterBar`, `ViewletSetting`, `ViewOptions` | +| `packages/kanban`, `packages/gantt`, `packages/panel` | Reusable primitives | + +--- + +## 2. Verified gap analysis + +✅ present · ⚠️ partial · ❌ absent + +### 2.1 Views + +| Capability | Status | Where / note | +|---|---|---| +| Table layout | ⚠️ `view.viewlet.Table` exists but bound only to `Project` | register it for `Issue` | +| Board layout | ✅ | `KanbanView.svelte` (570 lines) | +| **Roadmap layout** | ❌ | Huly has a Gantt instead — different thing, no Start/Target field pairing, no markers, no Month/Quarter/Year zoom | +| **View tabs** | ❌ | `ViewletSelector` renders *viewlet types*, not saved views | +| `+ New view` | ❌ `tracker.component.Views` id declared but never registered | `plugin.ts:535` | +| Per-view column set | ❌ one global `ViewletPreference` per viewlet | `ViewletSetting.svelte:455` | +| Per-view filter as a **string** | ❌ stored as JSON `Filter[]` blob | `FilteredView.filters: string` (JSON, not a grammar) | +| **Unsaved-changes dot + Save changes** | ❌ | | +| Duplicate / Rename / Delete / Reorder view | ❌ | | +| Per-view layout switch | ⚠️ layout is bound to the viewlet | | +| Views shared project-wide | ⚠️ `FilteredView` has `sharable` + `users[]` — GitHub has **no personal views** at all | simplify | +| Views scoped to a project | ❌ `attachedTo` is the workspace alias | | + +### 2.2 Fields + +| Capability | Status | Note | +|---|---|---| +| Fixed `Issue` schema | ✅ | `models/tracker/src/types.ts:186-296` | +| **User-defined custom fields** | ❌ **biggest gap** | no `FieldDefinition` doc anywhere in the repo | +| TEXT / NUMBER / DATE custom fields | ❌ | | +| SINGLE_SELECT custom (max 50 options, colors, description) | ❌ | | +| MULTI_SELECT custom | ❌ | | +| ITERATION custom field | ❌ | no `Iteration` class. Dead `{sprint:1}` index at `index.ts:812` | +| Sub-items (parent issue + sub-issue progress) | ✅ strong | `attachedTo` + `parents` + `childInfo` | +| **Hierarchy display** (tree, expand/collapse ≤8 levels, state persists) | ❌ Huly has sub-issue *editing* but no tree *display* with persisted expansion | GA 2026-03-19, default ON for new views | +| Field limits (50 fields/project, 50 options/select) | ❌ | must enforce | +| Field default values for new items | ❌ | shipped 2026-04-09 | +| Org-level shared fields | ❌ | | +| Field reorder + row reorder | ⚠️ `sortable: true` on 1 of 11 viewlets | `viewlets.ts:956` | +| **Field sum** (Σ of number fields, per group) | ❌ | | +| Per-field notifications | ⚠️ hardcoded array | `models/tracker/src/index.ts:194-200` | + +### 2.3 Board + +| Capability | Status | Note | +|---|---|---| +| Column field = any single-select | ⚠️ column = group value of `groupBy[0]` | needs a dedicated `columnField` option | +| Column field = **iteration** field | ❌ | | +| **Swimlanes** (`group by` on a board = horizontal sections) | ❌ | shipped 2023-07-27 | +| Per-column **card limits** (advisory, per view) | ❌ | | +| Show/hide columns | ⚠️ | | +| Card fields governed by the view's field list | ❌ card body hardcoded | `KanbanView.svelte:414-509` | +| "Sorted board ⇒ no manual reorder in column" | ⚠️ Huly writes `rank` only when `orderBy === Manual` | already close | + +### 2.4 Filtering + +| Capability | Status | Note | +|---|---|---| +| Filter bar + chips + 7 per-type editors | ✅ | | +| Keyword search + scope + highlight | ✅ | | +| Filter **grammar**: `field:`, `has:`, `no:`, `is:`, `label:`, `assignee:`, `@me`, `@today`, `@current`, `@next`, `@previous`, `@k±n`, ranges `a..b`, `*` wildcards, `AND`/`OR` + parentheses | ❌ | advanced search GA 2026-07-16 | +| Click a card value to filter | ❌ | | +| `(N) matching items` counter | ⚠️ `resultIssueCountStore` exists | | +| Slice-by panel | ❌ | | + +### 2.5 Item editing + +| Capability | Status | Note | +|---|---|---| +| Item detail drawer | ✅ rich (439 + 268 lines) | | +| Inline cell editors per field | ✅ good | | +| **Spreadsheet-style bulk edit**: copy/paste cells, fill handle, multi-select, `Shift`+arrows, `Delete` to clear, Undo toast | ❌ | Huly bulk actions are ~12 fixed action types | +| Draft items (real project items until converted) | ❌ Huly's `IssueDraft` is transient popup state | | +| Archive (restorable) | ⚠️ `hideArchived` exists | | +| Row height / density | ❌ | shipped 2026-06-25 | +| Item limit 50,000 | ❌ | | + +### 2.6 Iterations + +| Capability | Status | Note | +|---|---|---| +| Iteration field | ❌ | | +| 3 auto-created iterations on field creation | ❌ | | +| Duration in days/weeks, editable name, breaks | ❌ | | +| `@current` / `@next` / `@previous` filter keywords | ❌ | | +| Bulk "Move items to…" | ❌ | | +| Rollups (count, done count, estimate sum) | ⚠️ `IAggregationManager` exists, unused for this | | +| Project **status updates** (`ON_TRACK`/`AT_RISK`/… + body) | ❌ | separate feature | + +### 2.7 Insights + +| Capability | Status | Note | +|---|---|---| +| Charts over project items | ❌ | | +| 6 layouts (Bar, Column, Stacked bar/column/area, Line) | ❌ | | +| Config: X-axis field, Group-by field, Y-axis (Count or Sum/Avg/Min/Max of a number field), filter | ❌ | | +| **Historical charts** (X-axis = Time; Open / Completed / Closed PRs / Not planned; default "Burn up") | ❌ | **blocked — needs a field-value history model; Huly has none** | +| Archived items excluded | ⚠️ | | + +### 2.8 Workflows + +| Capability | Status | Note | +|---|---|---| +| Built-in: issue closed → status Done | ⚠️ Huly has status categories but no such rule | | +| Built-in: PR merged → status Done | ❌ no PR model | stretch | +| Auto-add (repo + filter) | ❌ n/a — no repo concept. Huly analogue: auto-add sub-issues of a type into a project | | +| Auto-archive (filter) | ❌ | | +| `ProjectV2Workflow` doc (name, enabled) | ❌ | | +| `@github-project-automation` attribution | ❌ | `tx.modifiedBy` analogue | +| `project_v2_item` webhook with `changes.field_value` | ❌ | Huly has `TxUpdateDoc` in `DOMAIN_TX` — reusable | +| Custom trigger→action builder | ❌ **and out of scope** — GitHub does not have it | | + +### 2.9 Project-level + +| Capability | Status | Note | +|---|---|---| +| **Per-project roles** (base No access/Read/Write/Admin + collaborators) | ❌ Huly is a flat `members: AccountUuid[]` | | +| Project settings page with field sidebar | ❌ | | +| Project description + Markdown README | ❌ | | +| Project templates (carry views, fields, workflows, insights) | ❌ | | +| Project visibility public/private | ❌ | | +| Tracker-specific permissions | ⚠️ exactly 1 (`ForbidCreateProject`) | | + +### 2.10 What we already have — do not rebuild + +`packages/gantt` (time scale, zoom, viewport, drag, working-days calendar) · +`packages/kanban` (drag columns, rank) · `view.mixin.Groupping` + `IAggregationManager` · +`view.class.FilteredView` (server-persisted; extend it) · `view.mixin.ClassFilters` + 7 filter +components · `ViewletSetting`/`ViewletClassSettings` (column UI) · sub-issue hierarchy + +`ControlPanel` attribute loop · `gantt/lib/scheduler.ts` · 33 Gantt test files as the house +testing pattern · `tests/sanity/tests/tracker/*` for e2e. + +**Huly already exceeds GitHub** (not parity work, protect these from regression): per-item Gantt +rows, dependency arrows (FS/SS/FF/SF + lag), critical path, cascade scheduling with undo, working +days + HR holidays, time-spend reports, PDF/PNG export, kanban swimlane zoom, estimation reports. + +--- + +## 3. Architecture — the seven decisions + +### D1 — Custom fields: registry doc + shadow record on the item + +**Constraint:** the model is **TxModel-fixed**. `core.class.Attribute` docs are generated by the +model build and pushed via `TxModel` at server upgrade — you cannot create an attribute at +runtime. So a user-defined field can never be a real `Attribute`. (The `custom*` convention in +`ViewletSetting.svelte:230` and `converter-resources/*` concerns derived/exported column *labels* +and is not a runtime-attribute mechanism.) + +**Design:** + +``` +tracker.class.ProjectField (DOMAIN_TRACKER, space = the Project) + label: string + key: string // generated slug, unique per project, e.g. "storyPoints" + type: ProjectFieldType // Text | Number | Date | SingleSelect | MultiSelect | Iteration + position: number + project: Ref + description?: string + defaultValue?: any // Text/Number/SingleSelect only — Date does NOT support it (GitHub parity) + // SingleSelect / MultiSelect: + options?: ProjectFieldOption[] // { value, label, color, description } max 50 + +tracker.class.Issue += @Prop(TypeRecord()) customFields: Record +``` + +Exactly the **6** `ProjectV2CustomFieldType` values. No checkbox, no people, no URL — those do not +exist in GitHub Projects. + +**Options considered** + +| Option | Pros | Cons | Verdict | +|---|---|---|---| +| **A. `Record` prop on `Issue`** (chosen) | 1 prop, 1 migration, values ride every query, notifications/aggregators read them, presenter is trivial | not server-indexable per key → custom-field filter/sort/group runs client-side | ✅ v1 | +| B. `IssueFieldValue` AttachedDoc collection | server-indexable, scales past 100k | 1 query per field read, N per row to hydrate, breaks the single-fetch query model | ⏳ scale path | +| C. Shadow scalars `custom_` on `Issue` | Mongo-indexable | one global index per key, impossible to create at runtime; unbounded prop sprawl | ❌ | + +**Mitigation for A's limit:** a configured ceiling `TRACKER_CUSTOM_FIELD_SCAN_LIMIT` (default +5000). Above it, custom-field filter/sort/group is **disabled with an explicit UI error**, never +silently truncated. Fail fast, not a silent throttle. + +**Enforce at the model level:** ≤50 fields per project, ≤50 options per select field. +Where? A server trigger on `ProjectField` create — the last line of defence against a bad client. + +**Plugs in at** +1. `models/tracker/src/projectField.ts` — `ProjectField` + `ProjectFieldOption` (new file, keeps + `types.ts` from bloating) +2. `TIssue.customFields` prop + migration in `models/tracker/src/migration.ts` +3. `tracker.aggregation.ProjectFieldRegistry` — an `IAggregationManager`-shaped resource so + presenters resolve key→definition without each re-querying +4. `CustomFieldPresenter.svelte` reads `issue.customFields?.[field.key]` +5. Per-type editors: `Text`, `Number`, `Date`, `SingleSelect`, `MultiSelect`, `Iteration` +6. Detail panel: the generic attribute loop at `ControlPanel.svelte:236-266` renders it free +7. `CustomFieldFilter.svelte` + `tracker.function.BuildCustomFieldQuery` → client-side predicate + +### D2 — Saved views: extend `FilteredView`, do not build a parallel concept + +GitHub views are **project-wide and shared** — "when a view is saved, anyone who opens the project +will see the saved view". There are **no personal views**. So drop the personal/shared split. + +```ts +// plugins/view/src/types.ts:110 — additive fields only +export interface FilteredView extends Doc { + // ...existing: name, location, filters, viewOptions, filterClass, viewletId, sharable, users, createdBy, attachedTo + project?: Ref // NEW — scope to a project space + config?: (BuildModelKey | string)[] // NEW — ordered visible field list (GitHub: visibleFieldIds) + cardProperties?: string[] // NEW — board: which fields show on cards + descriptor?: Ref // NEW — the layout (table | board | roadmap) + columnField?: string // NEW — board column field + order?: number // NEW — tab order (GraphQL: POSITION) + icon?: Asset // NEW + filterText?: string // NEW — GitHub's view filter is a STRING (see D3) + groupBy?: string[] // NEW — GitHub stores grouping on the view, not in localStorage + sortBy?: [string, SortingOrder][] // NEW + fieldSum?: string[] // NEW + sliceField?: string // NEW + hierarchy?: boolean // NEW — show sub-issue tree + rowHeight?: RowHeight // NEW — Single|Medium|Tall|ExtraTall + columnLimits?: Record // NEW — advisory per-column caps + dirty?: boolean // NEW — unsaved-changes dot +} +``` + +**Deliberate removal:** personal/shared distinction. Keep the fields for back-compat but stop +using them; new views are always shared. Do not add per-view ACLs — GitHub has none. + +**Two-sources-of-truth problem (critical):** `ViewOptions` is persisted in **localStorage** +(`view-resources/src/viewOptions.ts:47-62`) while `FilteredView` is a **server doc**. +Rule: **an active saved view is authoritative**; localStorage is the fallback for ad-hoc state. +Expose exactly one accessor, `getEffectiveViewConfig()`, that all views read. Otherwise this +will produce drift bugs that are very hard to debug. + +**UI:** `plugins/view-resources/src/components/view/SavedViewBar.svelte` (tab bar + `+ New view` + +per-tab kebab: rename / duplicate / delete / drag-to-reorder + unsaved dot) and +`CreateViewPopup.svelte`. + +**Template:** seed GitHub-style views (`Current iteration`, `Next iteration`, `Prioritized +backlog`, `Roadmap`, `In review`, `My items`) via an `OnProjectCreate` trigger. Be honest in the +code comment: these are *our* template names, not GitHub defaults (GitHub ships none). + +### D3 — Adopt GitHub's filter grammar, compile to `DocumentQuery` + +This is the highest-leverage decision. GitHub stores a view's filter as a **string** in a real +grammar; Huly stores a JSON blob of `Filter[]`. Porting the grammar gives us, for free: +`field:` · `has:` · `no:` · `is:` · `label:` · `assignee:` · `milestone:` · `type:` · +`sub-issues.is:` · `@me` · `@today[-Nd]` · `@current` · `@next` · `@previous` · `@k±n` +arithmetic · `>` `>=` `<` `<=` · inclusive ranges `a..b` with `*` wildcards · `title:"..."` · +comma = OR, repeat = AND, quoted values · and `AND`/`OR` + parentheses (GA 2026-07-16). + +Design: **one grammar, two consumers** — a client compiler string → `DocumentQuery` +for server-side fetch, and a client-side evaluator for custom fields (D1) which the server cannot +index. Both share the tokenizer and the operator semantics, so they cannot drift. + +Deliver as a small, well-tested module: `plugins/view-resources/src/filter/grammar/{tokenizer,parser,compile,evaluate}.ts` +with jest tests. Reusable by every future view — this is the DRY seam. + +Note the documented GitHub limitation we should *beat*: OR across fields was unsupported until +2026-07-16. We should support it from day 1. + +### D4 — Iterations + +``` +tracker.class.Iteration (DOMAIN_TRACKER) + project: Ref + field: Ref // owning iteration field + label: string + number: number + startDate: Timestamp + duration: number // days (GitHub: duration + startDay, same idea) + status: IterationStatus // Planned | Active | Closed + isBreak?: boolean +``` + +Plus `@Prop(TypeRef(Iteration)) iteration?: Ref | null` + `@Index(IndexKind.Indexed)` +on `TIssue` and an index entry `{iteration: 1}`. + +- Creating an Iteration **field** auto-creates **3 iterations** (GitHub parity). +- `@current` / `@next` / `@previous` resolve against `startDate` + `duration`. +- Breaks supported (a break is an iteration with `isBreak`). +- Iteration is usable as a **board column field** and as the **Roadmap Start/Target date source**. +- Rollups via the existing `IAggregationManager` seam. + +Iteration fields are self-contained (no Gantt/Kanban dependency) so Insights and Workload can use +them too. Do **not** subclass `Milestone` — Milestone has different semantics. + +### D5 — Roadmap as the third layout + +GitHub's Roadmap is **not** a Gantt. It is a single high-level bar chart: +- **Start date** + **Target date** fields chosen per view; each may be a **date field or an + iteration field** +- **Markers**: vertical lines for iterations, item dates, milestones (togglable) +- **Zoom**: Month / Quarter / Year +- Drag an item → changes its dates or its iteration +- Group by / Sort / Slice by / Field sum all behave as in Table +- **No per-item rows, no dependency arrows** + +Build it as a new `ViewletDescriptor` + component. **Reuse** `packages/gantt/time-scale.ts` for +the Month/Quarter/Year math and viewport; **do not** reuse `GanttView.svelte` (4893 lines — do not +grow it) and do not reuse `lib/build-rows.ts` (it builds per-item rows, the opposite of what +Roadmap wants). New files only. + +The existing Gantt stays as a Huly-only extra view. Roadmap and Gantt are separate descriptors +with separate icons so users can tell them apart. + +### D6 — Insights, and the history problem + +Chart model: 6 layouts (Bar, Column, Stacked bar, Stacked column, Stacked area, Line). Config: +layout, X-axis field, Group-by field, Y-axis (`Count` or `Sum`/`Average`/`Minimum`/`Maximum` of a +number field), filter string (D3 grammar). Archived items excluded. + +**The blocker:** *historical* charts set X-axis = Time and need series `Open` / `Completed` / +`Closed PRs` / `Not planned` (default "Burn up"). Huly has **no field-value history** — `TxUpdateDoc` +lives in `DOMAIN_TX` and is not designed to be queried as a time series over arbitrary fields. + +Options: +- **A.** Query `DOMAIN_TX` for `TxUpdateDoc` on `tracker.class:Issue` and replay. Cheap to build, + but `DOMAIN_TX` is the transaction log — queryable but unindexed for this shape, and long + retention is not guaranteed. Fine for MVP; will degrade. +- **B.** Snapshot model: a nightly/rolling `tracker.class.IssueFieldSnapshot` collection + (issue × date × field values). Correct, bounded, queryable, but adds real storage and a + backfill story. +- **C.** Skip historical charts in v1; ship current charts only, honestly. + +**Recommendation:** C for the first ship, then B if the burn-up chart proves valuable. Do **not** +ship A pretending it is B. This is the one place where honest scope reduction beats a clever +implementation. + +UI: `InsightsPanel` reached from a graph icon in the project header (matching the screenshot's +`Insights` button). Note GitHub has **no API for charts** — charts are UI-only. We get to choose; +persisting chart configs as docs is strictly better and cheap. + +### D7 — Workflows: built-ins only, not a builder + +GitHub ships no custom trigger→action builder. So: + +``` +tracker.class.Workflow (DOMAIN_TRACKER) + project, name, enabled: boolean + kind: WorkflowKind // SetStatusDoneOnClose | MoveSubIssuesIntoProject | AutoArchive + config?: Record +``` + +- **`SetStatusDoneOnClose`** — when an Issue's `status` category becomes `done`/`canceled`, ensure + the Done status option is set. Semantically near-trivial in Huly (status *is* the field), so this + reduces to "optionally auto-set the Done status when the underlying issue is closed". +- **`AutoArchive`** — a filter string (D3 grammar) + cadence; archives matching items. Archived + items keep all field values and stay restorable (`hideArchived` already exists). +- **`AutoAddFromQuery`** — Huly's analogue of GitHub's repo-scoped auto-add: a filter string that + pulls matching issues into the project. No repo concept needed. +- Attribution: automation tx carry a marker so timelines show the change as automated + (`tx.modifiedBy` analogue of `@github-project-automation`). + +**Explicitly out of scope:** a general trigger→action builder. If we want it, GitHub Actions + +a `project_v2_item`-equivalent webhook is the parity-faithful route — Huly already writes +`TxUpdateDoc` into `DOMAIN_TX`, so a webhook on field-value change is a much smaller lift than +a rule engine, and it gives users the full power of code. **Recommend the webhook over the +builder**, and say so in the plan. + +--- + +## 4. Phasing + +Each phase ships independently and ends green. **No phase starts before the previous phase's +`rushx build` + `rushx _phase:validate` + unit tests pass.** + +| # | Phase | Ships | Why this order | +|---|---|---|---| +| **0** | **Custom-field foundation** | `ProjectField` doc, `customFields` record, registry resource, CRUD UI, editors, detail-panel rendering, limits enforced | Load-bearing. Insights, Workload, field-sum, per-view columns and workflows all aggregate over fields. Nothing else is worth building first. | +| **1** | **Custom fields as columns** | Optional columns, filter, client-side sort, group-by-select, `sortable: true` for column drag-reorder | Makes fields usable in views | +| **2** | **Saved views + tabs** | `FilteredView` extension, `SavedViewBar`, `+ New view`, per-view columns, unsaved dot, rename/duplicate/delete/reorder, `getEffectiveViewConfig()`, default-view template | The single biggest UX jump; unblocks per-view everything | +| **3** | **Filter grammar** | Tokenizer/parser/compiler/evaluator, `@`-keywords, ranges, wildcards, AND/OR + parens, click-value-to-filter, `field:` addressing | Unblocks per-view filters and auto-archive | +| **4** | **Spreadsheet-style bulk edit** | Cell multi-select, `Shift`+arrows, copy/paste, fill handle, `Delete` to clear, Undo toast, row + column drag-reorder, row height | Table becomes genuinely spreadsheet-like | +| **5** | **Iterations** | Iteration field, 3 auto-created, duration/breaks, `@current/@next/@previous`, board column field, rollups, bulk move | Powers `Current iteration` / `Next iteration` views | +| **6** | **Roadmap layout** | New descriptor + component, Start/Target field pairing, markers, Month/Quarter/Year zoom, drag-to-reschedule | Completes the 3-layout parity set | +| **7** | **Board parity** | Column field (select *or* iteration), swimlanes via group-by, per-column advisory limits, show/hide columns, card fields from the view's field list | Depends on 2 + 5 | +| **8** | **Hierarchy + slice + field sum** | Sub-issue tree display ≤8 levels with persisted expansion (default on), slice-by panel, Σ number fields per group | Depends on 2 | +| **9** | **Insights (current charts only)** | 6 layouts, config panel, persisted chart docs, archived excluded | Honest v1; historical/burn-up is a separate decision (D6) | +| **10** | **Workflows (built-ins)** | `Workflow` doc, Done-on-close, auto-archive, auto-add-by-query, automation attribution, field-change webhook | Deliberately last — it writes data | +| **11** | **Project-level** | Settings page with field sidebar, description + README, templates, status updates | Workspace-level roles only (decision 5) | + +**Recommended first slice: Phases 0 + 1.** Time-box Phase 0 to a 2–3 day spike to validate D1 end +to end (create field → set value → read in detail panel → render as column) before committing to +the roadmap. + +**Scope extras (reinstated by the user, 2026-10-03):** Calendar view, Workload view, nested/stacked +grouping ("stack by"), and a custom trigger->action automation builder were first dropped because +GitHub Projects does not have them. The user decided they **stay in scope**: the tracker should be +at least as good as the GitHub board. They are scheduled as phases 12-15 *after* GitHub parity +(phases 0-11) so they never compete with parity work. Still out of scope: per-field permissions, +personal views, pie/donut charts, historical insights / burn-up (see D6). + +| # | Extra phase | Notes | +|---|---|---| +| **12** | Calendar layout | Date-field driven, reuses saved-view infrastructure from phase 2 | +| **13** | Workload layout | Per-assignee capacity vs. Estimate/Iteration, depends on phases 0 and 5 | +| **14** | Nested grouping | Multi-level group-by in Table and Board; extends the group-by from phases 1 and 7 | +| **15** | Automation builder | Trigger->action rules on top of the phase 10 workflow doc; field-change webhook ships in phase 10 | + +--- + +## 5. Files this touches + +**New packages:** none. `models/tracker` additions only, unless Phase 6/9 forces a split. + +**New files (representative)** + +``` +models/tracker/src/projectField.ts # ProjectField + ProjectFieldOption +models/tracker/src/iteration.ts # Iteration +models/tracker/src/workflow.ts # Workflow + kinds +plugins/tracker/src/projectField.ts # interfaces + ProjectFieldType enum (6 values) +plugins/tracker/src/iteration.ts +plugins/tracker/src/workflow.ts +plugins/tracker-resources/src/projectFields/{registry,CrudPopup,optionsEditor}.ts|svelte +plugins/tracker-resources/src/projectFields/editors/{Text,Number,Date,SingleSelect,MultiSelect,Iteration}Editor.svelte +plugins/tracker-resources/src/components/roadmap/{RoadmapView,RoadmapMarkers,RoadmapToolbar}.svelte +plugins/tracker-resources/src/components/insights/{InsightsPanel,ChartConfigPanel,charts/*}.svelte +plugins/tracker-resources/src/components/workflows/{WorkflowsPopup,WorkflowEditor}.svelte +plugins/tracker-resources/src/components/iterations/{IterationBrowser,IterationEditor,IterationSelector,IterationRollupPresenter}.svelte +plugins/view-resources/src/components/view/{SavedViewBar,CreateViewPopup,ViewTab,CardFieldRenderer}.svelte +plugins/view-resources/src/filter/grammar/{tokenizer,parser,compile,evaluate}.ts # D3 — pure, heavily tested +plugins/view-resources/src/utils/nested-groups.ts # keep (harmless) or drop +server-plugins/tracker-resources/src/project/on-project-create.ts # seed default views +server-plugins/tracker-resources/src/workflow/{on-workflow-evaluate,on-field-change}.ts +``` + +**Modified (hot spots — expect conflict risk)** + +- `models/tracker/src/types.ts` — `+customFields`, `+iteration`, `ProjectField`/`Iteration`/`Workflow` classes +- `models/tracker/src/viewlets.ts` — Roadmap descriptor, `issueConfig`, group/sort/filter whitelists +- `models/tracker/src/migration.ts` — `customFields` backfill, `iteration` backfill +- `models/tracker/src/index.ts` — indexes, notification types, permissions, limits trigger +- `models/tracker/src/plugin.ts` — ids + `IntlString`s +- `models/view/src/index.ts` + `plugins/view/src/types.ts` — `FilteredView` +- `plugins/view-resources/src/viewOptions.ts` — `getEffectiveViewConfig()` precedence rule +- `plugins/tracker-resources/src/components/issues/KanbanView.svelte` — column field, swimlanes, card refactor +- `plugins/tracker-resources/src/components/issues/IssuesView.svelte` — `SavedViewBar` in header +- `plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte` — custom-field rows +- `plugins/tracker-assets/lang/*.json` — 14 locales; key parity enforced by an existing test + +--- + +## 6. Risks + +| # | Risk | Sev | Mitigation | +|---|---|---|---| +| 1 | **TxModel-fixed schema** blocks runtime attributes | 🔴 blocking | D1 record-on-item; field *definitions* are data | +| 2 | Custom-field filter/sort/group must be client-side | 🔴 high | Configurable `TRACKER_CUSTOM_FIELD_SCAN_LIMIT`; fail fast above it | +| 3 | `ViewOptions` in **localStorage** vs `FilteredView` on the **server** = two sources of truth | 🔴 high | Explicit precedence + one `getEffectiveViewConfig()` accessor | +| 4 | Scope creep into non-GitHub features (calendar, workload, rule engine) | 🔴 high | §0 lists what was dropped and why; Phase budget table has none of them | +| 5 | Historical Insights charts need a model that does not exist | 🟠 med-high | D6: ship current charts, decide B (snapshots) separately. Never fake it with `DOMAIN_TX` replay | +| 6 | Modifying `models/view` (`FilteredView`) touches every plugin | 🔴 high | Additive fields + migration; full `rush build` before merge | +| 7 | `KanbanView.svelte` card body hardcoded | 🟡 med | Refactor in Phase 2 before card config lands in Phase 7 | +| 8 | 14-locale key parity enforced by tests | 🟡 med | Every `IntlString` added to all locales in the same change | +| 9 | `IssuesView.svelte` deliberately never unmounts the viewlet (virtual-scroller starvation) | 🟡 med | `SavedViewBar` must swap via the existing `viewlet` binding — no overlay mount | +| 10 | `GanttView.svelte` is 4893 lines | 🟡 med | Do not grow it. Roadmap is new files; only time-scale moves to `packages/gantt` | +| 11 | Automations write data | 🟠 med-high | Built-ins only, attribute to a system actor, cap per run, dry-run flag in config | +| 12 | ~~Per-project roles~~ dropped (workspace-level only) | - | No `core` permission changes | +| 13 | Unmeasured phase estimates | 🟡 med | Phase 0 is a time-boxed spike; re-estimate after | + +--- + +## 7. Validation strategy (per phase) + +```bash +cd && rushx test # unit tests +cd && rushx build && rushx _phase:validate # the required gate +rush build --to # cross-package type check before PR +# NEVER run rushx format automatically — repo rule, it can corrupt files +``` + +- Pure logic (grammar, nested groups, query compiler, rollups, limit guards) gets a jest file + next to it, matching `gantt/lib/__tests__/`. +- New layouts get an e2e spec in `tests/sanity/tests/tracker/`. +- Locale parity is already covered by `plugins/tracker-assets/src/__tests__/lang*.test.ts` — it + fails the build if a new `IntlString` misses a locale. Use it as a checklist. +- Model changes need a `@Migration` step in `models/tracker/src/migration.ts` **and** a test in + `models/tracker/src/__tests__/migration.test.ts` (existing pattern to copy). + +--- + +## 8. Decisions (resolved by the user, 2026-10-03) + +1. **Scope** - Calendar, Workload, nested grouping and the automation builder **stay in scope** + (phases 12-15, after parity). Goal: the tracker alone is as good as a GitHub board. +2. **Default view names** - follow GitHub Projects behaviour (a new project starts with one view of + the chosen layout). Named views (`Current iteration`, etc.) are an optional template only. +3. **Project size** - follow GitHub Projects limits (50,000 items per project, 50 custom fields, + Insights/field limits as in GitHub). Option B (separate collection) is not needed at launch; + enforce these limits and set `TRACKER_CUSTOM_FIELD_SCAN_LIMIT` accordingly. +4. **Historical insights / burn-up** - out of scope (maybe later). +5. **Roles** - workspace-level only. Per-project roles are dropped from phase 11. +6. **Automation** - field-change webhook first (phase 10); rule builder later (phase 15). +7. **Merge strategy** - one large PR. Phases are still built and validated sequentially on this + branch (build + `_phase:validate` + tests green before the next phase starts). + +--- + +*Append-only. Next entry should record the Phase 0 spike result.* + +--- + +## Phase 5 implementation notes (Iterations) + +**Deviations from D4** + +- **No `TIssue.iteration` prop.** An issue's iteration is the iteration id stored in + `issue.customFields[field.key]`, like every other custom field (D1). A project can therefore have several + Iteration fields, each with its own iterations, and no model migration or index is needed. The cost is the + same as for all custom fields: filter/sort/group run on the client over the scanned issues. +- **`tracker.class.Iteration` has no `project` prop**: `space` is the project (D4 says the same). `field` + references the owning `ProjectField`. `number` is the ordinal for default titles (`Iteration 4`), 0 for breaks. +- **No stored status.** `getIterationState(iteration, now)` derives `planned | current | completed` from + `startDate` + `duration` (whole local days; `end` is the last millisecond of the last day). +- Default duration of a new iteration is not stored on the field: it is the duration of the last iteration + (a week when there is none). The creation form asks for the duration (days or weeks) and the first start date + (default today) and creates three consecutive iterations together with the field in one `client.apply` batch + (`generateInitialIterations`). + +**Behaviour** + +- Changing the duration or start date of an iteration moves all later iterations (and breaks) by the same + number of days, so gaps stay; a start that would run into the previous iteration is rejected (`planIterationChange`). + Adding appends after the last item; "Insert break after" inserts a break (`isBreak`, not assignable, not + offered in editors/groups/filters, ignored by `@current/@next/@previous`) and shifts what follows. + Deleting leaves the other dates alone; the server (`OnIterationRemove`) strips the id from issues. Removing + the field or the project removes its iterations. Completed iterations are listed in their own collapsible section. +- Editor: single-select popup, current iteration marked, `No iteration`; presenter shows the title with the date + range as tooltip. Cell paste accepts titles and `@current/@next/@previous[+-N]`. +- Filter: `iteration:@current`, `@next`, `@previous`, `+/-N`, `>@current`, titles (grammar callback is wired to the + project's iterations); the legacy custom-field filter has an any-of rule with the three keywords plus iterations. +- Sort by iteration start date; group by iteration (calendar order, `No ` last). The group header shows the + date range, count, done count (status category Won) and estimation sum (`computeIterationRollups`), plus a menu + `Move items to...`. +- View extension additions (`ClientViewExtension`): optional `extraProjection` and `getGroupExtras`. + +**Remaining gaps / not done** + +- "Move items to..." moves the items of the group as currently shown (respects the active filter), not every + item of the iteration in the project. +- Board column field and Roadmap date source from iterations are Phase 7 / 12 (the data and pure helpers exist). +- Iteration settings are saved per edit, not with the form's Save button; no undo. +- Not exercised in a running UI or e2e (no sanity spec added); verified by jest, `rush validate`, `svelte-check`. +- `rush validate --from @hcengineering/tracker` fails only in `@hcengineering/prod` on type errors in earlier + phase files (`view-resources` filter grammar parser/compile and `tableEdit`), unrelated to this phase. + + +--- + +## Phase 6 implementation notes (Roadmap layout) + +**What was built** + +- Third layout `Roadmap` (`tracker.viewlet.Roadmap` descriptor, `tracker.viewlet.IssueRoadmap` viewlet, icon + `tracker.icon.Roadmap`, component `tracker.component.RoadmapView`). It is offered by `SavedViewBar` next to Table and + Board (`viewLayouts` of `IssuesView`), and appears in the generic viewlet selector like the Gantt does. +- New files only; `GanttView.svelte` is untouched. Pure logic is in `plugins/tracker-resources/src/roadmap/` + (`timeScale`, `dates`, `layout`, `markers`, `reschedule`, `config`, `label`, `grouping`, `customValue`) with jest tests + next to it (also run under four time zones). UI is in `components/roadmap/` (`RoadmapView`, `RoadmapToolbar`, + `RoadmapOptionsPopup`). +- Date sources (stored as ids in the view): `issue:startDate|dueDate|deadline`, `milestone:startDate|targetDate` + (read only), `field:` (custom Date field), `iteration:` (an Iteration field: start = first day of the + iteration, target = its last day). Each view picks a start and a target source (default start date / due date). +- Item rendering: both dates -> bar (an inverted start/target is drawn from the earlier to the later day with a hatch); + one date -> marker; none -> listed in a collapsible "Unscheduled" section at the bottom, with a "Set dates" button + (date picker) and click-on-timeline to put the item on a day. +- Zoom Month / Quarter / Year (header: months over weeks, quarters over months, years over quarters), today line, + Today button, horizontal and vertical scroll with a sticky title column and sticky header, rows are windowed. +- Markers menu: milestones (flag at the target date), iterations of chosen Iteration fields (line at the start, shaded + span), dates of items of chosen date sources (busiest 100 days). Fields menu: what the label of an item contains + (identifier, title, status, assignee, priority, labels, component, milestone, estimation, custom fields). +- Drag the bar = move both dates keeping the duration (timestamps keep their time of day); drag an edge = write only + that date, clamped so the range keeps at least one day. Iteration sources snap to the iteration whose first (start) + or last (target) day is closest, so dragging an item over an iteration field moves it to another iteration. + Milestone dates are read only: an item that takes a date from its milestone cannot be moved (a toast says so), the + other edge can still be resized. Markers can be moved, not resized. Esc cancels a drag. Every change is one + `EditJournal` batch of Phase 4 (`tableEdit.updateOp` + `runIssueOps`) and shows an "Undo" toast. +- Group by and Sort by reuse the generic view options (Customize View): model keys `status, kind, assignee, priority, + component, milestone` plus custom/iteration keys through the existing `clientViewExtension` (category order comes from + the extension). The view starts ungrouped: `ViewOptionsModel.defaultGroupBy` is a new optional field (additive) that + `getViewletDefaultOptions` and `ViewletSettingButton` honour. + +**Per-view config and dirty tracking** + +- The roadmap settings (`start`, `target`, `zoom`, `markers`, `fields`) live in the view options under the key + `roadmap`. The saved view already stores, restores and diffs `viewOptions`, so nothing in `SavedViewBar` had to change + for saving, "unsaved changes" or discarding. The default config is not stored (`withRoadmapConfig`), so a view that + was never customized stays clean and changing a setting back clears the dot (covered by a test through + `isViewDirty`). Group-by and sort-by are the normal `viewOptions.groupBy/orderBy` of the same object. +- Settings that point at something deleted (a removed field) are dropped from the displayed config + (`sanitizeConfig`) but are not rewritten in the saved view. + +**Deviations / decisions** + +- `packages/gantt` `time-scale.ts` was not reused: it only knows Day/Week/Month/Quarter zooms and computes in UTC, + while the roadmap needs Month/Quarter/Year and has to agree with iterations and Date fields, which are local days. + The roadmap has its own small local-day axis (`timeScale.ts`). +- The table "config" columns do not apply to a roadmap; "Configure columns" is hidden for this layout and the Fields + menu of the roadmap replaces it. +- No `rank`-based manual ordering and no slice-by / field-sum (Phase 8). +- GitHub's docs do not describe single-date items, unscheduled items or resizing; the behaviour above (marker, + Unscheduled section, edge resize) follows the task brief. + +**Not done / limits** + +- At most 5000 items are loaded; above that a notice is shown (not silently cut). No auto-scroll while dragging near + the edge of the viewport; no keyboard rescheduling. +- Group headers are plain text (no avatars/status icons); item labels are plain text. +- Drag to reschedule of a group header value (changing the group) is not supported. +- Not exercised in a running UI or e2e (no sanity spec added): verified by jest, `rush validate` and `svelte-check`. + + +--- + +## Phase 7 implementation notes (Board parity) + +**What was built** + +- Pure logic in `plugins/tracker-resources/src/board/` with jest tests next to it: `config` (the `board` settings block, + column field resolution), `columns` (columns and swimlanes of a custom field), `swimlanes` (the swimlane x column grid), + `limits` (advisory column limits), `move` (what a drop writes), `cardFields` (what a card shows). +- UI in `components/board/`: `CardFieldRenderer` (the card body, replaces the hardcoded body of `KanbanView.svelte`), + `BoardColumnHeader` (count/limit, column menu), `BoardLanes` (swimlanes), `HiddenColumnsPanel`, `DimensionTitle`, + `BoardOptionsSection` (board rows of the Customize View popup). +- `KanbanView.svelte` was reworked around a column axis and an optional swimlane axis; without swimlanes it still renders + the shared `packages/kanban` board (keyboard navigation and manual rank are unchanged), with swimlanes it renders `BoardLanes`. + +**Behaviour (GitHub Projects board)** + +- Column field: Status (default), Assignee, Priority, Component, Milestone, any single-select custom field or any Iteration + field. Chosen in Customize View ("Column field"). Custom/iteration columns list every option (every iteration, in calendar + order, breaks left out) even when empty, and "No " is the first column. A drop writes the status / the attribute / + `customFields[key]` (an option id or an iteration id; "No " clears it). The "+" of a column creates the issue with + the column's value (`CreateIssue` got an optional `customFields` prop for that). +- Swimlanes = the "Group by" of the view (Customize View > Grouping, same as GitHub). Single level; a built-in attribute or + a custom field (the client view extension keys are now offered for every layout, they used to be offered to the table only). + Lanes without cards are dropped unless "show empty groups" is on; "No " is the last lane. A lane can be collapsed. + Dropping a card on a cell writes the column value and the lane value in one update (`resolveDropUpdate`). Group by the same + field as the columns is ignored. A new board starts without swimlanes (`defaultGroupBy: '#no_category'` on the Kanban viewlet). +- Column menu (header "..."): Hide column, Set / Edit / Remove column limit. The limit is advisory and per column: the header + shows `count/limit`, and a column over its limit is tinted red; nothing stops a drop. Limits count the cards of the column + over all swimlanes. Typing 0 removes a limit; closing the popup without a number leaves it alone. +- Hidden columns leave the board and are listed in a "Hidden columns" area at its right edge with a Show button; Customize View + has "Show hidden columns (n)" and "Clear column limits (n)". +- Card fields follow the view's field list (Configure columns, saved per view): assignee, sub-issues, priority, component, + milestone, due date, labels, estimation, attachments, comments and every custom/iteration field (shown when the issue has a + value). The chips follow the order of the list. The title, identifier, status marker, parent and notification marker are + always shown. The default list is the old look (`assignee` was added to the Kanban viewlet config for this). +- Sorting: manual rank is written only when the sort is Manual (unchanged); in swimlane mode there is no manual order inside a cell. + +**Per-view config and dirty tracking** + +- `columnField`, `columnLimits` and `hiddenColumns` live in the view options under the key `board` (the same precedent as + `roadmap`; limits and hidden columns are kept per column field). The saved view already stores, restores and diffs + `viewOptions`, so the effective config, the unsaved dot and discard work with no change in `SavedViewBar`; the default config + is not stored, so a changed-and-changed-back board is clean again (covered by a test through `isViewDirty`). The swimlane + field is the normal `viewOptions.groupBy` of the same object. +- A column field that no longer exists (a deleted custom field) shows the status columns; the saved value is kept. + +**Shared code touched (additive)** + +- `packages/kanban`: `KanbanRow`/`Kanban` take an optional `getGroupQuery` (full documents of a column by ids, for columns that + are not a plain attribute); `KanbanRow` is exported. +- `view-resources`: `ViewOptions` / `ViewOptionsButton` / `ViewletSettingButton` take an optional `extra` / `extraOptions` + component shown after the generic rows of the Customize View popup (it reports changes with `update`, like the rows do); an + option set to `undefined` is deleted from the options instead of stored. The client view extension keys are offered to any layout. + +**Deviations / decisions** + +- Swimlanes reuse the generic Group by instead of a second control, so on a board Grouping no longer means "columns". A + board whose localStorage options hold an old group-by other than the status now gets swimlanes by that field. +- `columnField` / `columnLimits` are not new `FilteredView` props (D2 lists them): the view options block carries them like the + roadmap settings, which keeps one place for per-view layout state. +- In swimlane mode the whole board scrolls, column headers and lane headers are sticky; there is no per-column scroll. +- "No assignee/component/milestone" columns and lanes now accept drops (they write `null`); before, such a drop was refused. +- Collapsed swimlanes are not persisted (session only). +- The hook for column totals (`columnTotal` in `KanbanView.svelte`, shown by `BoardColumnHeader` as `total`) is unset; Phase 8 + will provide the sum of a number field. + +**Not done / limits** + +- Not exercised in a running UI or e2e (no sanity spec added): verified by jest, `rush validate --to @hcengineering/prod` + and `svelte-check` of tracker-resources, view-resources and kanban. +- The legacy `ViewletPreference` of the Kanban viewlet (a user's own column choice) does not contain `assignee`, so those users + see cards without the avatar until they switch it on. +- A drop on a lane/column whose category has no value for the project of the card (status of another project) is refused. + + +--- + +## Phase 8 implementation notes (Hierarchy, Slice by, Field sum) + +**What was built** + +- Pure logic with jest tests next to it. `plugins/view-resources/src/hierarchy.ts` (generic tree rows: nesting, expansion, depth + cap, cycles, large lists) and in `plugins/tracker-resources/src/`: `hierarchy/` (`config`, `expansion`, `progress`; + `expansionStore.ts` is the svelte store on top of `expansion`), `slice/` (`config`, `fields`, `values`, `sums`) and `fieldSum/` + (`config`, `sum`; `load.ts` only translates the estimation label). +- UI: `components/slice/{SlicePanel,IconSlice}`, `components/fieldSum/{FieldSumGroupSummary,FieldSumFooter}`, + `components/view/ProjectViewOptionsSection` (the rows that `IssuesView` adds to the "Customize view" popup: board section, + Hierarchy toggle, Field sum toggles). `IssuesView`, `KanbanView`, `RoadmapView` and `BoardColumnHeader` were extended. +- Strings (10) were added to `tracker.string` and to all 14 locale files of `tracker-assets` by plain text insertion (ru + translated, the others English); `view-assets` needed none (the expander tooltip text comes from the host). + +**Behaviour** + +- Hierarchy (Table only). Sub-issues are nested under their parent with a chevron (up to 8 levels, deeper issues follow as + top level rows right after their tree). Collapsed by default; the expanded rows are kept per viewer and per view in local + storage (`tracker.hierarchy.expanded..`, at most 2000 ids). The tree is built per group over the issues the + view shows: an issue whose parent is not in that group (filtered out, other group, other page of the filter) is a top level + row and keeps showing the parent name, so nothing is lost; an issue is shown once. Sort order is kept among siblings. The + chevron tooltip and the existing sub-issues button of the row show the progress `done of total` (direct sub-issues, all of + them, whether the view shows them or not; done = status category Won or Lost, the same rule as the button). The tree needs + the whole group, so the list loads up to the scan limit per group in this mode. Drag-to-rank is off while the tree is on. + It needs the "Sub-issues" view option to be on (root-only lists have nothing to nest). +- Opt-in per saved view: `viewOptions.hierarchy === true`. Absent = off, so views saved before and the unsaved default view + stay flat. A new Table view (and a view whose layout is switched to Table) starts with it on (`SavedViewBar` got the optional + props `newViewOptions` and `activeViewId`). Off is not stored, so on-then-off leaves the view clean. +- Slice by (Table and Roadmap; no button on Board and Gantt). Toolbar button next to the filter button opens a left panel + with a field menu and the values of the field with counts, `All items` and `No `. A click chooses a value (a second + click on the only chosen one goes back to All), Cmd/Ctrl-click adds or removes values. Fields: status, priority, assignee, + labels, component, milestone and the single select, multi select and iteration custom fields. The counts are over what the + view shows without the slice itself (filter string and chips applied), the slice narrows the result on top of it (AND). The + values of custom fields are all listed (also with 0), built-in ones only when in use, and a chosen value is always listed. + Stored as `viewOptions.slice = { field, value: string[] }` (`field` is the filter name of a built-in or `customFields.`, + `value` empty = All, `__none__` = "No "; the key exists while the panel is open). Deleted options are dropped from the + effective choice, a deleted field falls back to the first one; neither rewrites the saved view. +- Field sum. The "Customize view" popup lists Estimation and every Number custom field with a toggle; stored as + `viewOptions.fieldSums: string[]` (`estimation`, `customFields.`). Shown: in every group header of the Table (not on + an ungrouped table, as on GitHub), in the group headers of the Roadmap, in the board column headers (`columnTotal` hook of + Phase 7), in the slice panel next to the counts, and as a footer below Table and Roadmap with the total of the whole view. + Only the issues the view shows are summed (filter, search and slice applied); values that are not finite numbers are + ignored (`NaN`, text, empty), the sum is compensated and printed with at most 2 decimals. +- Scan limit (`TRACKER_CUSTOM_FIELD_SCAN_LIMIT`). Slice counts and the narrowing use the same bounded scan as the custom field + filters; above the limit the panel says so and the slice is not applied. The hierarchy checks the size of the view with a + count query and is turned off above the limit with a banner; the footer shows a note instead of a total. Group header sums + and board totals read the documents the list already holds. + +**Shared code touched (additive, opt-in)** + +- `ClientViewExtension` got the optional `hierarchy` and `groupSummary`; the list reads them only when the view options of the + list turn the feature on (`hierarchy` / `fieldSums` keys), so the sub-issues list of an issue, related issues and every other + embedded list are unaffected although the extension store is global. +- `ListCategory` builds the tree rows, `ListItem` has an optional `tree` prop (chevron + indent, emits `toggle-tree`), `ListHeader` + renders the optional summary, `List` projects the properties the summary reads. `SavedViewBar` has the two props above. + +**Deviations / decisions** + +- GitHub's docs do not describe grouping together with the hierarchy beyond "preserved"; here a child whose parent is in another + group is shown flat in its own group. +- Slice and field sums are stored in the view options (the precedent of `roadmap` / `board`), not as `FilteredView.sliceField` / + `fieldSum` props of D2: one place for the per-view layout state, and saving, restoring and the unsaved dot work unchanged. +- The columns of the table shift right by one rem per level of the tree (the chevron cell is the first cell of the row); the + identifier/title columns are not re-aligned. +- Progress in the row is the existing sub-issues button; the chevron adds the tooltip. No separate progress column was added. + +**Not done / limits** + +- No expand all / collapse all, no keyboard expand/collapse, no drag to re-parent. Arrow-key navigation follows the existing + order logic of the list, which is not aware of the tree rows. +- Hierarchy on the Roadmap and Board is not offered. +- The slice panel cannot be resized, has no search box, and slices by custom Text, Number and Date fields are not offered. +- Not exercised in a running UI or e2e (no sanity spec added): verified by jest (tracker-resources, view-resources, + tracker-assets), `svelte-check` of view-resources and tracker-resources and `rush validate --to @hcengineering/prod`. The layout + of the slice panel and of the footer in the page flex chain is by reasoning, not by eye. + + +--- + +## Phase 9 implementation notes (Insights, current charts) + +**What was built** + +- Model: `tracker.class.InsightChart` (`DOMAIN_TRACKER`, `space` = project; `name`, `layout`, `xField`, `xBucket?`, `groupField?`, + `yAggregate {type, field?}`, `filter`, `position`). Types in `plugins/tracker/src/insightChart.ts`, class `TInsightChart` in + `models/tracker`, the class id is declared once in `plugins/tracker` (`tracker.class.InsightChart`), strings in the tracker-resources + `plugin.ts`. `xBucket` is an addition to the brief: it is the day/week/month size of a date X-axis. `OnProjectRemove` + (`server-plugins/tracker-resources`) removes the charts of a removed project (jest test next to the existing trigger tests). No + `@Migration` step: a new class in an existing domain has nothing to migrate. +- Pure logic with jest tests in `plugins/tracker-resources/src/insights/`: `config` (layouts, normalization, dirty tracking, edits, + resolving a config against the project's fields), `fields` (what each project field can be: category / date / number), `aggregate` + (buckets, series, Count/Sum/Average/Minimum/Maximum, "No ", ordering, date buckets), `build` (fields -> request for the + aggregation), `drill` (filter string behind a bar), `layout` (scales, ticks, bars, lines, areas in pixels), `colors`. Tests also run + under four time zones (dates are local days, like iterations). +- UI in `components/insights/`: `InsightsPanel` (page: charts sidebar, filter bar, chart, configuration), `ChartConfigPanel`, + `charts/ChartCanvas` (SVG, written in-repo; no chart library exists in the repo), `charts/ChartLegend`, `IconInsights`. `IssuesView` + got an "Insights" button in the project header (`header-tools` slot). +- Strings (50) were added to `tracker.string` and to all 14 locale files by plain text insertion (ru translated, the others English). + +**Behaviour (GitHub Projects Insights, current charts)** + +- The page covers the project view (its own header has the way back); the view keeps its state, so unsaved view changes survive + opening Insights. The page lists the charts of the project; a project without charts shows the default chart "Issues by status" + (Column, X = Status, Count). The default chart is virtual and is stored the first time it is saved, renamed, duplicated or when a + chart is added. (The GitHub default "Burn up" is a historical chart and is out of scope, D6 option C.) +- Chart menu (the "..." of a chart or right-click): rename, duplicate (`X (copy)`), delete (with confirmation). `+ New chart` adds + `Chart N` with the default configuration. The selected chart is remembered per viewer and project (local storage). +- Configuration panel: Layout (exactly Bar, Column, Stacked bar, Stacked column, Stacked area, Line), X-axis, Group by (optional), + Y-axis (Count of items, or Sum / Average / Minimum / Maximum of a number field: the estimation or a Number custom field), a bucket + size (Day / Week / Month) when the X-axis is a date, and the filter string bar above the chart (the Phase 3 `FilterQueryBar`). + X-axis and Group by: status, priority, assignee, labels, component, milestone and the single select, multi select and iteration + fields (text and number fields are not offered). A date field (due date, start date, deadline, Date custom fields) can be the + X-axis of a Line or Stacked area only; switching to another layout moves the X-axis back to the first category field. +- Bar and Column with a group-by draw the series side by side, the stacked layouts stack them (negative values stack downwards), Line + draws a line per series, Stacked area stacks the areas. Labels: items with several values (labels, multi select) count in each of + them; items without a value are in a last "No " bucket / series (neutral color); the options of the project's own fields are + all listed (also with 0), the options of the others (status of another type, people, labels) only when used. Statuses follow the + workflow order, people / labels / components / milestones are alphabetical, iterations calendar order. Weeks start on Monday. +- Y aggregates: the count counts every item; the others use the items that have a finite number in the field (anything else is + ignored); a bucket with no numbers has no value for average / minimum / maximum (a gap, not a 0), 0 for the sum. +- Date axis: every bucket from the first item to the last is drawn (empty ones are 0 / a gap); beyond 1000 buckets only the buckets + that have items are drawn. Items without a date are the last "No " point: on a Line it is a separate marker, on a Stacked area + a stacked column next to the areas (they cannot be joined to the dates). +- Unsaved changes: the draft is compared with the saved chart (`isChartDirty`; filter whitespace and an unused bucket are not changes, + a change that is changed back is clean again). A dot shows on the chart in the sidebar and the toolbar offers Discard changes, + Save to new chart and Save changes. A saved chart that changes elsewhere is taken unless the draft has changes of its own. +- A field that was removed from the project (X-axis, group-by, Y number field) falls back (first category field / no grouping / count) + with a notice; the saved chart is not rewritten. A filter that no longer parses is not applied and the chart is not drawn. +- Scan limit: the chart is computed over a scan of the project's issues (server-narrowed by the indexable part of the filter, the rest + evaluated on the client) capped at `TRACKER_CUSTOM_FIELD_SCAN_LIMIT` + 1. Above the limit an explicit message is shown and nothing is + drawn (never a truncated chart). Archived projects' issues are not scanned (the usual `findAll` rule). +- Hover shows a tooltip (category, series, value, item count); the marks are focusable (Enter / Space activate) and carry a text + label; the chart has a text description. Click a bar, point or segment: the project view opens with that bucket's filter (the + chart's filter AND `field:value`, or `no:field`, or a day range for a date bucket); view chips, legacy custom field rules and the + slice are cleared for that, so the view shows exactly the issues behind the number. The result is an unsaved change of the active + saved view (Discard brings the old filter back). + +**Deviations / decisions** + +- Group by also accepts the same field set as the X-axis (category fields only; a date cannot be a series), and cannot repeat the + X field. GitHub's docs do not say which layouts support Group by; here all six do. +- The "popup list of the filtered items" (optional in the brief) was not built; the click goes to the project view instead. +- Charts are not scoped per user: any member who can edit the project can save, rename and delete them (like saved views); a + read-only viewer (`restrictionStore.readonly`) sees the controls disabled / hidden. +- The configuration panel is on the right, the filter above the chart (as on GitHub); below 60rem the three columns stack. + +**Not done / limits** + +- Historical charts (X-axis = time, burn up) are not built and not faked (D6). +- No pie / donut, no chart export, no reordering of charts by drag (they follow `position`; duplicate / new append). +- Not exercised in a running UI or e2e (no sanity spec added): verified by jest (tracker-resources, tracker-assets, + server-tracker-resources, model-tracker), `svelte-check` of tracker-resources and `rush validate --to @hcengineering/prod`, + `--to @hcengineering/model-tracker`. Layout of the page in the panel (absolute overlay), the tooltip position and the colors are by + reasoning, not by eye. + + +--- + +## Phase 10 implementation notes (Built-in workflows, archive, field-change webhook) + +**What was built** + +- Archive. `Issue.archivedAt?: Timestamp | null` (hidden `TIssue` prop; `null` after a restore). Huly's `hideArchived` is about archived + *projects*, so it was not reusable. Pure helpers in `plugins/tracker/src/archive.ts`. Actions `Archive` / `Restore` + (`tracker.action.ArchiveIssue` / `RestoreIssue`, `input: 'any'`, so they work on the context menu and on a bulk selection; Archive is shown + for issues without `archivedAt`, Restore for archived ones). "Archived items" popup (restore / delete one, restore all / delete all, first 200 + rows, count of the rest) from the project header and from the project settings. Archived items keep every value. +- Grammar (`view-resources/src/filter/grammar`): `is:archived` (and `-is:archived`), `mentionsArchived`, `archiveScopeQuery`. GitHub's rule is + applied: archived items are left out of the project views, slice, counts, field sums, hierarchy and Insights unless the filter mentions + archived items, in which case the filter alone decides. `IssuesView` adds `archivedAt: null` to the server query (everything that scans, counts + or sums derives from it); `InsightsPanel` does the same for the chart scan. `updated:` (the time of the last change, `modifiedOn`) was + missing and was added to the filter schema (`updated:<@today-2w`); it is not offered as a chart axis. +- Filter schema moved: `buildIssueFilterSchema` / `fieldFilterName` now live in `plugins/tracker/src/issueFilterSchema.ts` (types repeat the + shape of the grammar's `FieldSpec`); `tracker-resources/issueFilter.ts` re-exports them. The server needs the same schema for workflow + filters. `buildWorkflowFilterSchema` (same file family, `workflow.ts`) is that schema without labels and with English priority names. +- Workflow doc `tracker.class.Workflow` (`DOMAIN_TRACKER`, `space` = project): `name`, `enabled`, `kind`, `filter?`, `config?` (`target`), + `runRequestedAt?`. Kinds: `setStatusDoneOnClose`, `itemReopened`, `itemAdded`, `autoArchive`, `autoAddFromQuery`. One doc per kind (the + oldest wins if two clients created one at once). A kind without a doc has its defaults (virtual, like the default Insights chart), so there + is no migration and no project-create trigger. +- Server (`server-plugins/tracker-resources`): sync trigger `OnIssueWorkflow` (item workflows), async trigger `OnWorkflowEvaluate` (filter + workflows), async trigger `OnProjectItemWebhook`, sync trigger `OnProjectWebhookRemove`; `OnProjectRemove` also removes workflows, webhooks + and the secrets of the webhooks. UI: `WorkflowsPopup` (toggle per workflow, field / value pickers, filter bar with the Phase 3 + `FilterQueryBar` and a preview count) and `WebhooksPopup`, both opened from the project settings (the edit-project popup). +- Strings (62) were added to `tracker.string` and to all 14 locale files by plain text insertion (ru translated, the others English). + +**Workflow semantics and defaults (GitHub parity, with the Huly deviations)** + +- Item closed / Item reopened / Item added to project. GitHub writes its separate Status field. Huly's issue status already *is* the + open/closed state, so these workflows write a **single-select custom field** (the Status field analogue): closed = the issue status + moves from an open category to done or canceled (Won / Lost category) -> field = Done; reopened = it moves from a closed to an open + status -> field = the default open value (`Todo`, `To do` or `Open`); added = a new issue gets `Todo` unless it already has a value. The + target is automatic (the first single-select field called `Status` that has such an option) or chosen in the form (`config.target`); + a chosen target that disappears makes the workflow do nothing, never fall back. Without a target nothing happens. Defaults like GitHub: + these three are **on**, Auto-archive and Auto-add are **off** and need a filter before they can be switched on. The previous status is + rebuilt from the transaction history (`history.ts`), a move between two closed statuses is not a close. +- Auto-archive. Archives the not archived issues that match the filter. Default text for a new workflow: `is:closed updated:<@today-2w`. + Items that already match are archived when it is switched on (GitHub does the same). A filter that is empty, does not parse, is over 1000 + characters or mentions archived items is skipped (and the form says so), never guessed at. Restoring an item bumps `modifiedOn`, so an + `updated:` filter does not archive it again at once. +- Auto-add. GitHub pulls matching issues of a repository into the project. Huly has no repository, an issue belongs to exactly one project and + moving it renumbers it (identifier, rank, status mapping), which would have to be redone on the server and is destructive. So the workflow + **never moves or copies anything**. Its Huly analogue restores archived items of the project that match the filter (the inverse of + Auto-archive). To keep the two from undoing each other, an item that the project's Auto-archive would archive again is not restored + ("archive wins"). Documented in the form. +- Caps and idempotency. A filter workflow reads at most 2000 issues per run (oldest first) and changes at most 100 per run per workflow + (`MAX_WORKFLOW_ITEMS_PER_RUN`); a backlog is worked off over several runs. The filter is always evaluated again on the scanned issues on the + server (the database query is only a narrowing), so the result does not depend on how much of the filter the database can take. Every + write is skipped when the value is already there, so repeating a run changes nothing. +- Loop protection and attribution. Every write of a workflow is authored by the system account (`core.account.System`, passed as + `modifiedBy` of the transaction) and a workflow never reacts to a transaction authored by it (`isAutomationAuthor`). The activity timeline + shows an unknown author as "System"; there is no separate "Project automation" label in the timeline (the webhook payload does say + `sender.type = Automation`, `name = Project automation`). +- When filter workflows run. The server has no periodic runner (triggers are transaction driven; the periodic code lives in separate + services), so they are evaluated (1) when a workflow doc is created or changed, (2) after an issue of the project changes, at most once a + minute per project (`control.cache`), (3) when a client opens the project: it touches `runRequestedAt` of the stored, enabled filter + workflows once per session. **Limitation:** a project in which nothing happens and nobody opens does not age items into a filter until one of + those happens. +- Workflow filters use the vocabulary of the project views minus labels (a label is a separate document per issue). Priorities are named in + English whatever the language of the viewer (`priority:urgent`), because the server does not translate. `@me` has no meaning on the server. + +**Field-change webhook (parity with `projects_v2_item`)** + +- No webhook facility existed (the love / payment / github hits are inbound webhooks), so a minimal one was built: docs + `tracker.class.ProjectWebhook` (`space` = project: `url`, `enabled`, `events`, `hasSecret`) and `tracker.class.ProjectWebhookSecret`. + At most 20 per project (UI check, and the server only uses the 20 oldest). +- Events (the `action` of GitHub's event): `created`, `edited`, `archived`, `restored`, `deleted`. `edited` is one delivery per changed field + with `changes.field_value = { field_node_id, field_name, field_type, from, to }` (`from` rebuilt from the history; select-like values are + `{ id, name }`, dates ISO strings). Watched: title, status, priority, assignee, component, milestone, estimation, start / due date, + deadline and every custom field (by type: text, number, date, single_select, multi_select, iteration). Payload keys: `action`, + `project_item`, `changes`, `project`, `workspace`, `sender`, `delivery`. Headers: `X-Huly-Event: project_item`, `X-Huly-Delivery`, + `X-Huly-Signature-256: sha256=` (only when a secret is set; verified against GitHub's published + reference vector in the tests). +- Delivery. Async trigger, so it never delays the transaction, and the deliveries are not even awaited by it: they run in the background + (5 at a time, 5 s timeout, one retry after 1 s for a network error, timeout, 5xx or 429, none after another answer). Changes made by the + workflows are delivered too (they are in the operation's transactions). At most 100 events per run, payload bounded to 64 KiB (values are + dropped first, the field stays named). There is **no persistent queue**: a delivery in flight when the server stops is lost, and there is no + delivery log or redelivery UI. +- Security model. + - SSRF: https only, no credentials in the URL, internal host names and private / loopback / link-local (cloud metadata) / CGNAT / + multicast / reserved IPv4 and IPv6 ranges are refused, IPv6 addresses that embed IPv4 (mapped, NAT64, 6to4) are judged by the IPv4 part. + The same pure validator runs in the form and on the server. On the server the name is resolved by the socket's own `lookup`, every + address must be public, and the connection goes to the address that was checked (no second resolution, so no DNS rebinding); an IP + in the URL is checked directly; redirects are never followed; the answer body is never read. A refused target is never retried. + `TRACKER_WEBHOOK_ALLOW_PRIVATE=true` (development only) allows http and local targets. This uses Node's `http(s).request` with a custom + `lookup`, not `fetch` (a `fetch` call cannot be pinned to a checked address); the tests mock the transport. + - Secret: it is typed once, stored as `ProjectWebhookSecret` in the **personal space** of the person who typed it (no other member of the + project can read it, the server can), the webhook doc only carries `hasSecret`, and the form never shows a secret again (an empty + field keeps the current one; a new one is a new doc and the newest wins, so setting one rotates it). The secret is not in any payload + or header. Residual risks: the author can read their own secret doc through the API, and the old secret docs of a rotation stay in the + author's personal space until the webhook is removed (removing a webhook or a project removes all of its secrets). + - Who can configure: any member who can write the project's docs (same as saved views and charts). The data a webhook receives is data + its configurer can read anyway. + +**Shared code touched** + +- `server-plugins/tracker-resources` now depends on `@hcengineering/view-resources` (deep import of the pure filter grammar, + `@hcengineering/view-resources/src/filter/grammar`, no svelte is loaded; the same pattern as `model-tracker` importing + `tracker-resources/src/types`), on `@types/node`, and uses the `node` tsconfig profile (node modules are used for crypto / dns / http). + `pnpm-lock.yaml` was edited by hand for these two (no network install was possible); run `rush update` to confirm it is stable. + +**Verified / not verified** + +- Verified: jest (tracker 112, tracker-resources 922, view-resources 290, server-tracker-resources 101, tracker-assets 15, model-tracker 4), + `svelte-check` of tracker-resources and view-resources, `rush validate --to @hcengineering/prod` (also `model-server-tracker` and + `server-tracker-resources`). +- Not verified: nothing was run against a real server or in a browser (no sanity spec). In particular the trigger registration in a running + pipeline, the notifications that a System authored issue update may produce, the layout of the three popups, delivery over a real + network, and the bundling of the server with the deep grammar import. The `archivedAt` / workflow queries were checked only against the + in-memory test double. + +**Not done / limits** + +- No rule builder (phase 15) and no per-workflow history or run log. +- Archived items still show in places that do not use the project issues view: the sub-issue list of a parent, the lists of a component or + a milestone, search, links, notifications, and the hierarchy progress count (it counts every sub-issue, archived or not). +- A parent that is archived does not archive its sub-issues. +- The number of webhooks per project is also checked only by the form and the delivery (no server guard that removes the extra ones). + + +--- + +## Phase 11 implementation notes (Project-level features) + +**What was built** + +- Pure logic in `plugins/tracker/src` with jest tests: `projectStatus` (status enum, `ProjectStatusUpdate`, validation, latest update, + who may edit), `projectSettings` (limits, `isProjectItemLimitReached`, `itemsOverProjectLimit`, short description / README validation, + `canMakeProjectPrivate`, `isDeleteConfirmed`, `projectShortDescription`), `projectCopy` (`buildProjectCopyPlan`). +- Resource logic in `plugins/tracker-resources/src/projectDetails` (tests next to it): `copy` (read the source, apply the plan to a batch), + `lifecycle` (close, reopen, visibility, template, delete with name), `status` (label and colour of a status), `identifier` (free + identifier for a copy). +- Model: `Project.shortDescription?`, `Project.readme?` (Markup), `Project.isTemplate?` (all hidden props of `TProject`, optional, so no + migration), new class `tracker.class.ProjectStatusUpdate` (`DOMAIN_TRACKER`, `space` = project; `status`, `startDate?`, `targetDate?`, + `body`; the author is `createdBy`). Ids declared once (`plugins/tracker` for the class, `tracker-resources/plugin.ts` for strings and the + `ProjectStatusPresenter` component). +- Server (`server-plugins/tracker-resources`): `OnProjectRemove` also removes the status updates; new sync trigger `OnProjectItemLimit` + (model-server-tracker registers it). Jest: `project-item-limit.test.ts`, extended `project-field-trigger.test.ts`, `mockControl` supports `total`. +- UI (`plugins/tracker-resources/src/components/projects`): `ProjectSettings` (popup with a left sidebar: General, Custom fields, Workflows, + Webhooks, Archived items, Danger zone), `ProjectGeneralSettings` + `ReadmeEditor` (Write / Preview), `ProjectDangerZone`, + `ProjectDetailsPanel` (About panel), `ProjectStatusUpdatePopup`, `ProjectStatusPill`, `ProjectStatusPresenter` (column of the project list + viewlets), `ProjectSettingsCard`. 60 strings in all 14 locale files by plain text insertion (ru translated, the others English). +- Project header (`IssuesView`): the latest status as a pill (opens the About panel), an About (info) button that toggles the panel, a "..." + menu (Settings, Add status update, Copy project, Make / Unmake template). The edit-project popup keeps Fields, Workflows, Webhooks and + Archived items and got a "Project settings" button. + +**Behaviour and GitHub mapping** + +- Settings sections reuse the existing editors, not copies: `ProjectFieldsPopup`, `WorkflowsPopup`, `WebhooksPopup` and + `ArchivedItemsPopup` take an `embedded` prop and draw their frame with `ProjectSettingsCard` (the old `Card` when they are popups). + In the settings the field list is a sidebar: selecting a field shows its editor next to it (key and type stay immutable). +- Short description (<= 256 characters, GitHub) is the new `shortDescription`; the old `Space.description` is not touched and is shown as + the fallback in the About panel for projects that have no short description. README is stored as Markup (the platform editor takes Markdown + shortcuts and pasted Markdown; the preview is the platform message viewer), limited to 100,000 characters of markup (our limit). +- Status updates: five statuses with the GitHub names (`INACTIVE`, `ON_TRACK`, `AT_RISK`, `OFF_TRACK`, `COMPLETE`), optional start and target + date (target before start is refused), Markdown body (<= 65,536 characters, our limit). The newest update is the project status. The author + or someone who can edit the space may edit and delete an update. Added from the About panel or the "..." menu. +- Close / Reopen maps on `Space.archived` (hidden from the navigator, not offered when creating issues; reopen restores it). Visibility + maps on `Space.private` (a private project needs an owner among its members). Make / Unmake template is `isTemplate`. Delete asks for the + project name and removes the project (the existing `OnProjectRemove` cleans up everything that belongs to it). Every action uses + `canEditSpace`, `canArchiveSpace` and `canDeleteSpace` (workspace roles and space owners, no per-project roles, decision 5). +- Templates and copies: "Copy project" (menu) opens the create form pre-filled from the project, a new project can pick a template in + "Create from template" (only non archived templates are listed). The plan copies fields (new ids, same keys and option values), iteration + fields with their iterations, saved views, workflows (enabled state, filter and target as they are) and Insights charts, plus short + description, README and working days. Iterations are recreated shifted by whole days so the first of each field starts today (durations, + gaps and breaks stay). Everything the views mention that is a document id (iteration ids in column limits and filters, the project in the + location) is rewritten. Fields, iterations, views, workflows, charts and the project itself are created in one `client.apply` batch. + The "Include issues" switch is shown off and disabled: issues are never copied (Huly has no draft items; an issue belongs to exactly one + project and carries numbering, status and parent links). The copy keeps the project type of the source (the template choice fixes it). +- Item limit: 50,000 items per project (`MAX_PROJECT_ITEMS`). The create-issue form counts the issues of the project and refuses with an + explicit error; `OnProjectItemLimit` (sync trigger on issue creation) removes the newest created issues that exceed the limit, never + older ones, like the field limit trigger. The limit counts all issues of the project, archived ones included (GitHub counts them + separately). Moving an issue into a project is not checked. + +**Deviations** + +- No new IntlString for the status column label beyond `ProjectStatus`; the pill is the same component everywhere. +- The README and status update body are Markup, not raw Markdown text. +- "Make template" is a flag on the project, there is no separate template list page; the template picker is a dropdown in the create form. +- The settings page is a popup (Modal with a sidebar), not a route. + +**Verified / not verified** + +- Verified: jest (tracker, tracker-resources, server-tracker-resources, tracker-assets), `svelte-check` of tracker-resources, `rush validate + --to @hcengineering/prod`. +- Not verified: nothing was run in a browser or against a server. Layout of the settings popup and the About panel, the sidebar of the field + editor, trigger registration in a running pipeline, the project list column and the apply batch of a copy against a real client are untested. + +**Not done / limits** + +- Closing a project does not make the server reject writes into it; it relies on the existing archive behaviour of Huly. +- The status pill is not shown in the navigator tree, only in the header and the project list. +- No separate "Use this template" button on a template, the create form is the entry point. + + +--- + +## Phase 11b implementation notes (Draft items, Add item row, Export view data, view links) + +**What was built** + +- Pure logic in `plugins/tracker/src` with jest tests: `draft` (`isIssueDraft`, `draftQuery`, `draftIdentifier`, `convertDraftUpdate`, `parseAddItemInput`, + link segments `draftLinkSegment` / `parseDraftLinkSegment` / `issueLinkSegment`) and `viewExport` (`buildViewTsv`, `formatTsvCell`, `orderRowsByGroups`, + `viewExportFileName`, `MAX_EXPORT_ROWS`). +- Resource logic in `plugins/tracker-resources/src/draft` (`create`, `convert`, `target`, `addItem`, `itemCount`, `actions`) and `src/viewExport` (`columns`, `rows`, + `groups`, `download`), tests next to it. Svelte: `components/draft/AddItemRow` + `AddItemSearch`, `components/issues/DraftBadge`. +- Model: `Issue.isDraft?: boolean` (hidden prop of `TIssue`, optional, so no migration), action `tracker.action.ConvertDraftToIssue` (context menu and board, `query: { isDraft: true }`). +- `view-resources`: grammar `is:draft`, `-is:draft`, `is:issue` / `-is:issue`; `SavedViewBar` got "Copy link to view", the `?view=` deep link and a `tabActions` hook; + `savedViews.ts` got `viewIdFromQuery`, `viewLinkQuery`, `viewFromLink`. +- 16 new strings in `tracker.string` and 2 in the view plugin, in all 14 locale files by plain text insertion (ru translated, the others English). + +**Draft items (GitHub "draft issue")** + +- A draft is an issue document with `isDraft: true`, so it takes part in every view, filter, field, board, roadmap, slice, Insights chart and workflow of the project + unchanged. It is created with `number: 0` (the project sequence starts at 1, so no issue has it), `identifier: "-Draft"` (a placeholder that does not match the + issue id pattern `PROJ-12`, so nothing resolves it as an issue) and the first creatable task type of the project type. Status is the project default status, else the first + status of the task type. The project sequence is **not** touched when a draft is created. There is no default assignee on a draft (it would send an assignment notification + for an idea). +- The identifier column (`IssuePresenter`, also the board card) shows a "Draft" badge, the panel shows it in the title, and a draft has no sub-issue list. +- Convert to issue: context menu / board action and a button in the detail panel. `convertDraftsToIssues` takes the next number of the project the way the create issue form + does (`$inc` on the project sequence, which has to return the new value), then updates all drafts in **one `client.apply` batch** that is guarded with + `match(Issue, { _id, isDraft: true })`: when somebody converted the draft in the meantime the batch is refused and nothing is renumbered. The number taken is then unused, + like after a failed create. At most 500 drafts per conversion. +- Links: a draft is opened by its document id (`.../tracker/draft-`, resolved by `resolveLocation`; panel URIs carry the id). `getTitle`, the server link providers + (`issueLinkIdProvider`, `issueHTMLPresenter`, the webhook url) use `issueLinkSegment`. Webhook payloads say `content_type: "DraftIssue"` for drafts. +- Drafts can be deleted and archived like any item (the normal actions). They count against the 50,000 item limit. +- Filter: `is:draft` / `-is:draft`, `is:issue` leaves drafts out (`-is:issue` = only drafts). Compiled to `isDraft: true` / `isDraft: { $ne: true }`, evaluated on the client + (`isDraftDoc`) with the same result. `is:` is completed with `draft`. +- Left out of lists that span projects: the issue lists with no project (My issues and similar, `IssuesView` without a `space`) and the parent / sub-issue pickers. + +**Add item row** + +- Table: a row below the table (a footer under the list, like the field sum footer, not a row inside the virtual list). Typing a title and pressing Enter creates a draft and keeps + the focus; Esc clears. Titles are limited to 256 characters (GitHub). Board: "Add item" at the bottom of every column, and of every swimlane cell when the board has swimlanes; + the draft starts with the value of the column and of the swimlane (`resolveDropUpdate` of the cell, so status, assignee, component, milestone, priority and custom / iteration + fields). A cell whose value does not exist for the project has no row. +- `#` opens a search of existing issues of every project the user can see (full text, plus an identifier lookup for `PROJ-12`; recent issues for a bare `#`; drafts are not offered). + Arrow keys and Enter pick, the list sits above the table input and below a board input. What picking does: an issue of this project is "Already in this project" (nothing); + an **archived** item of this project is restored; an issue of **another project of the same type** opens the existing tracker move dialog (`Move.svelte`, new optional `target` + prop) with this project preselected, so components and milestones are mapped and the issue is renumbered by the existing `moveIssueToSpace`; anything else is shown as + "Cannot be moved here" (other project type, closed project, archived issue elsewhere, no permission to change the issue). GitHub ties an issue to many projects; in Huly an issue + belongs to one, so adding it is a move, and the hint under the list says so. +- Guards: the row is not shown for read-only viewers (`restrictionStore`, `ReadOnlyGuest`, no create permission in the project); at the item limit it is disabled with the + existing "project holds the most items" message. A shared per-project count query (`sharedItemCountStore`) feeds the limit. + +**Export view data** + +- "Export view data" in the menu of the active view tab and in the project "..." menu. The file is `.tsv` named ` - .tsv`: header row of the field names, then the + columns Title, Identifier ("Draft" for a draft) and URL, then the visible fields of the view in the order of the view (priority, status, assignee, labels joined with `, `, + component, milestone, due date as `YYYY-MM-DD`, estimation as a number, task type, comments, attachments, parent, modified / created as ISO timestamps and every custom or + iteration field as the same text the table copies). Spacers, the extension area, the sub-issue counter and predecessors are not exported. +- Rows are what the view shows (filter string, chips, search, slice, archived scope, the view options query), loaded again with `findAll` (not read from the list), sorted by the + sort of the view (server sort, or the client comparator for a custom field) and then put in group order with the same categories the list uses (`getCategories` for built-in keys, + the client extension for custom fields), several levels deep. A board exports lane after lane, column after column inside it. Rows are flat: a table that nests sub-issues + exports every issue on its own row (the Parent column has the parent). +- Escaping: a tab or a line break inside a value is replaced by a space (no quoting), lists are joined with `, `, `null` is empty. **Spreadsheet formula injection**: a text cell that + starts with `=`, `+`, `-` or `@` gets a leading `'`. GitHub does not do this; it is on by default here (`escapeFormulas`), numbers are never prefixed. No BOM is written. +- Limit: 50,000 rows (the item limit of a project); the rest of a bigger view is cut off. + +**Links to a view** + +- "Copy link to view" in the tab menu and the project "..." menu copies `///tracker//issues?view=` (the default view that is not + stored yet is the plain project link). `SavedViewBar` reads `?view=` on load and whenever the location changes and selects that tab (and remembers it as the active one); a view the + project does not have (deleted, or the query survived a switch of the project) is ignored. Selecting a tab does not write the id back into the URL. +- Reset view is the existing Discard of the unsaved dot. + +**Deviations** + +- The "Add item" row of the table is a footer below the list, not the last row of the virtualised list (the shared `List` is untouched). +- A draft has one assignee, not several (Huly's issue has one), and no body field in the add row (the body is edited in the panel, like the description of an issue). +- No `converted` webhook event (GitHub has one); a conversion only changes `number`, `identifier` and `isDraft`, none of which is a watched field. +- The placeholder identifier is stored in English (`PROJ-Draft`); the views always show the localized badge instead. + +**Verified / not verified** + +- Verified: jest (tracker 174, tracker-resources 981, view-resources 298, server-tracker-resources 111, tracker-assets and view-assets lang tests, model-tracker), `svelte-check` of + tracker-resources and view-resources, `rush validate --to @hcengineering/prod` (also model-tracker, model-server-tracker, server-tracker-resources). +- Not verified: nothing was run in a browser or against a server. Untested: the layout and keyboard behaviour of the add row and its search list (table and board cells), the `Move` + dialog opened from the search, the `$search` query of the live query for issues, `$ne: true` on `isDraft` on the real database, the `apply` + `match` batch of a conversion against a + real client, the download in a browser and in the desktop app, the location query `?view=` surviving the workbench navigation, trigger behaviour for drafts in a running pipeline. + +**Not done / limits** + +- Drafts are not excluded from notifications (an assignee set on a draft is notified like for an issue) nor from the global full text search and the `#` mention completion; the pipeline + has no hook for that without touching the notification and indexing services. Sub-issue lists of a component or a milestone show drafts of that component / milestone. +- A draft is created with an empty rank, like a new issue from the create form. +- A draft cannot be the parent of issues (the sub-issue list is hidden in its panel, it is left out of the parent pickers) but nothing on the server refuses a parent link to it. +- The export does not include the hierarchy depth, the predecessors or the sub-issue counter, and it is not streamed: the whole view is built in memory. + + +--- + +## Phase 12 implementation notes (Calendar layout) + +**What was built** + +- Fourth layout `Calendar` (`tracker.viewlet.Calendar` descriptor, `tracker.viewlet.IssueCalendar` viewlet, icon `tracker.icon.Calendar`, component + `tracker.component.CalendarView`). `SavedViewBar` offers it next to Table, Board and Roadmap (`viewLayouts` of `IssuesView`), the ids are declared once + (`tracker-resources/plugin.ts`, `models/tracker/plugin.ts`, `plugins/tracker`), the viewlet is registered in `models/tracker/src/viewlets.ts` + (`calendarViewOptions`). +- Pure logic in `plugins/tracker-resources/src/calendar/` with jest tests next to it (also run under UTC, Los Angeles, Berlin, Auckland, Kolkata, Kiritimati + and Sao Paulo): `grid` (month and week grids, week start, ranges, navigation, keyboard moves, labels), `events` (items to events, split of an event + across week rows, lane packing, overflow), `agenda` (grouping by day), `drag` (days moved by a drag), `addItem` (what a draft added to a day starts + with), `config` (the `calendar` settings block). The date sources, the schedule of an item and the reschedule plans are the Roadmap ones + (`roadmap/dates`, `roadmap/reschedule`), not copies. +- UI in `components/calendar/`: `CalendarView` (data, drag, popups), `CalendarToolbar`, `CalendarGrid` (month grid and week view), `CalendarEventChip`, + `CalendarAgenda`, `CalendarUnscheduled`, `CalendarDayPopup` ("+N more"), `CalendarAddItemPopup`. The date popup reuses `RoadmapOptionsPopup`. +- 16 new strings in `tracker.string` and in all 14 locale files of `tracker-assets` by plain text insertion (ru translated, the others English); + strings of the Roadmap that fit (Today, Unscheduled, Set dates, Undo, Dates updated, the truncation notice, the start / target field titles) are reused. + +**Behaviour** + +- Modes: Month grid (default; only the weeks the month needs, 4 to 6), Week and Agenda (the days of the month that have items). Previous / Today / Next + and a title (`October 2026`, `Oct 5 – 11, 2026`) in the toolbar; Previous / Next move a month (a week in the week mode). Weeks start on the day of + the platform setting (`deviceOptionsStore.firstDayOfWeek`, Settings > General; the system value by default), Monday when it is not a valid weekday. + Today is a red badge on the day number, Saturday and Sunday are shaded, days of the neighbouring months are dimmed. All maths is on whole local + days (the day index of `roadmap/timeScale`), so a month never gets a day more or less around a daylight saving change. +- Dates: like the Roadmap, a start and an end source per view (the dates of the issue, a custom Date field, an Iteration field, the milestone dates). + The defaults are Start date and Due date. Each of the two can also be `None`, so a view with start = None and end = Due date is a plain due date + calendar; two `None` fall back to the defaults. Items with both dates are bars over the days (an iteration is a bar over its days), items with one + date, or with both on the same day, are chips, items without a day are listed in the **Unscheduled** side panel (toggle with the count in the + toolbar, `Set dates` button, rows are drawn up to 300 and the rest is counted). An inverted range is drawn hatched, like in the Roadmap. +- Bars spanning weeks are split at the week edges (arrows mark the continuation) and packed into lanes: bars first (earliest start, then longest), + then the chips of every day in the order of the view; a segment takes the first lane that is free in all of its columns. When the cell has no room + for all lanes the last row shows `+N more` per day (a hidden bar counts on each of its days); clicking it opens a popup with all items of the day as + cards. The week view has no limit, its cells grow with their items. +- Reschedule: drag a chip or a bar to another day (the day under the pointer minus the day grabbed gives the shift, so the duration is kept and the + time of day stays), drag the left / right edge of a range to write only that date (an edge cannot pass the other one), drag a row of the Unscheduled + panel onto a day to give it the day. The item is drawn where it would land while dragging and the drop target day is outlined; Esc cancels. Every + change is one `EditJournal` batch of Phase 4 with the same `Undo` toast as the Roadmap. Milestone dates and read-only viewers cannot be written + (a toast says so for the milestone; for a read-only viewer nothing is draggable and no handle is drawn), Iteration sources snap to the iteration + of the day. +- Click an item (or press Enter on it) opens the issue panel, right-click opens the context menu. Click an empty part of a day cell (or Enter on the + focused day) with write access opens the quick **Add item** popup: it is the `AddItemRow` of Phase 11b (a typed title creates a draft, `#` + searches issues, the item limit message and the read-only / permission guard come with it) and the draft starts with the day in the date fields of + the view (`draftValuesForDay`; `DraftValues` got optional `startDate`, `dueDate` and `deadline`). A view whose dates both come from the milestone + cannot add on a day (toast). +- Fields on an item come from the fields of the view (Configure columns, saved per view, default `assignee, priority, dueDate, labels`): `planCardFields` + of Phase 7 decides. The chips of the grid always show the identifier (a Draft badge for a draft) and the title and, when switched on, the assignee + avatar and the priority; the **agenda, the `+N more` popup** show every item as the `CardFieldRenderer` card of the board with all fields. +- Group by is not offered (`hideGrouping` of the Customize View popup, new optional prop of `ViewletSettingButton` / `ViewOptionsButton` / + `ViewOptions`; Sort by stays and orders the chips of a day, default Manual (rank)). Filter string, chips, search, slice, archived scope, view + options and field sums behave as in the Roadmap because they are applied by `IssuesView` to the query the layout receives; the slice button and the + field sum footer were enabled for the calendar. Above 5000 items the same truncation banner as the Roadmap is shown. +- Keyboard: a day cell is focusable (roving tabindex); arrows move by a day or a week, Home / End to the ends of the week, Page Up / Page Down by a + month, the grid moves to another month or week when the focus leaves it; Enter or Space opens Add item on the day. + +**Per-view config and dirty tracking** + +- `mode`, `start` and `target` live in the view options under the key `calendar` (the precedent of `roadmap`, `board`). The saved view already stores, + restores and diffs `viewOptions`, so saving, the unsaved dot and discard work with no change in `SavedViewBar`; the default config is not stored, + so a changed-and-changed-back calendar is clean again (covered by a test through `isViewDirty`). Which month or week is open is navigation and is + not stored. A deleted date field falls back to the default in the displayed settings (`sanitizeCalendarConfig`) and is not rewritten in the saved view. + +**Deviations / decisions** + +- The brief says "one date source per view" and also that items with a start and a target span days; the Roadmap pair (start + end, either can be + `None`) satisfies both. +- The chips of the grid are one compact line, so the other fields of the view (component, milestone, custom fields, labels, estimation) are on the cards + of the agenda and of `+N more`, not on the chips. The tooltip of a chip has the identifier, the title and the dates. +- Dragging uses pointer events (like the Roadmap) and finds the day cell under the pointer with `elementsFromPoint`, so it works over bars of other + items and over the Unscheduled panel. +- `AddItemRow` got the optional `autofocus` prop (additive) for the popup. + +**Verified / not verified** + +- Verified: jest (tracker-resources 1057 incl. 75 new calendar tests, also under 7 time zones for `src/calendar` and `src/draft`; tracker-assets lang tests; + view-resources 298; model-tracker), `svelte-check` of tracker-resources and view-resources, `rush validate --to @hcengineering/model-tracker` and + `--to @hcengineering/prod`. +- Not verified: nothing was run in a browser or against a server (no sanity spec). The layout of the grid in the page flex chain, the lane capacity of a + month cell (measured from the height of the weeks area), the look of the chips, the positions of the popups, the drag over the Unscheduled panel and the + quick add popup are by reasoning, not by eye. The custom date field and iteration sources are covered by the pure tests only. + +**Not done / limits** + +- No auto-scroll or month change while dragging (use the week view, or drag in two steps); no keyboard rescheduling. +- The month grid shows only the whole weeks of the month, so a bar is clipped at the edges of that grid; the neighbouring month shows the rest. +- The `+N more` popup is a snapshot of the day when it was opened, it does not follow later changes. +- Picking an issue with `#` in the quick add popup brings the issue into the project like in the table; it does not get the day. +- Weekend days are Saturday and Sunday (the working days of the project are not used). + + +--- + +## Phase 13 implementation notes (Workload layout) + +An extra beyond GitHub parity (the tracker alone should be at least as good as a GitHub board): a team workload planner in the style of the Linear / Jira / +Asana workload views, with the toolbar and the per-view settings of the Roadmap and the Calendar. + +**What was built** + +- Fifth layout `Workload` (`tracker.viewlet.Workload` descriptor, `tracker.viewlet.IssueWorkload` viewlet, icon `tracker.icon.Workload`, component + `tracker.component.WorkloadView`). `SavedViewBar` offers it after Table, Board, Roadmap and Calendar (`viewLayouts` of `IssuesView`); ids are declared once + (`tracker-resources/plugin.ts`, `models/tracker/plugin.ts`, `plugins/tracker`), the viewlet is registered in `models/tracker/src/viewlets.ts` + (`workloadViewOptions`). +- Pure logic in `plugins/tracker-resources/src/workload/` with jest tests next to it (also run under UTC, Los Angeles, Berlin, Auckland, Kolkata, Kiritimati and + Sao Paulo): `config` (the `workload` settings block), `axis` (Day / Week / Month buckets, header groups, viewport windowing), `workdays` (working day index on + the Gantt rule), `load` (the load of an item per measure), `compute` (distribution over working days, aggregation, capacity, states, utilization, the items + behind a cell), `rows` (row keys and order), `reassign` (what a drop writes), `format`. The date sources and the schedule of an item are the Roadmap ones + (`roadmap/dates`), week starts and month arithmetic are the Calendar ones (`calendar/grid`), `toSummable` is the one of the field sums. +- UI in `components/workload/`: `WorkloadView` (data, working calendar, drag, journal, toast), `WorkloadToolbar`, `WorkloadGrid` (windowed grid with sticky + headers and sticky person and Unscheduled columns), `WorkloadPanel` (the items of a cell), `types.ts`. +- 23 new strings in `tracker.string` and in all 14 locale files of `tracker-assets` by plain text insertion (ru translated, the others English); the strings of + the Roadmap and the Calendar that fit (Today, Unscheduled, Dates, start / target field titles, Undo, the truncation notice, Estimation, Remaining time, Unassigned, + Total, Custom fields) are reused. +- `IssuesView` treats the layout like the Calendar: slice and field sum footer are available, the Customize View popup hides Group by / Sort by (the rows are the + assignees) and the column list. + +**Behaviour** + +- Rows are people: every assignee of the items the view shows, every member of the project (so free capacity and drop targets are visible) and an `Unassigned` + row last. People are ordered by name. Rows are always assignees, grouping by iteration or team is not offered. +- Columns are buckets on a horizontal axis: Day, Week (default) or Month. Weeks start on the platform's first day of the week (like the Calendar). The axis covers + all scheduled items and today, is at least 8 weeks (day), 26 weeks (week) or 12 months (month) long with more room ahead of today, is made of whole buckets and + is cut to 366 / 156 / 72 buckets around today if the items need more; items that end up outside it are counted in a notice, never dropped silently. A column + of today is highlighted and a line marks today. The grid scrolls to today when it opens, on Today and on a change of the zoom; it follows today while nobody + scrolled it (the axis grows to the left when the items load). Only the visible columns are drawn. +- Dates: the Roadmap source pair (start + target, either can be `None`; defaults start date and due date), including custom Date fields, iteration spans and + milestone dates. An item with both dates spans its days (an inverted pair is read from the earlier to the later day), an item with one date puts all of its load + on that day, an item with none goes to the `Unscheduled` column of its person (total load and a count; sticky next to the name). +- Load measure (per view): Estimate (`Issue.estimation`, man hours), Remaining time (`remainingTime`), Item count (every item is 1) or a Number custom field. A + value that is not a finite number above zero is no load (0), so `NaN`, text, negatives and empty fields cannot poison a sum; such items are still listed in + the cell with a share of 0 and the person's header says how many items have no value. Closed items are counted like any other (filter them out with + `is:open` if they should not count). +- Distribution: the load of an item is spread evenly over the working days of its span; a cell holds the share of the days that fall into its bucket + (`distribute`, the shares of an item add up to its load). A span without any working day (a weekend only, holidays only) is planned on its calendar days so the + load is not lost (and shows as over capacity, there is none). Days more than about 11 years from today are not planned. +- Working days reuse the Gantt: `isWorkingDay` of `@hcengineering/gantt` (weekday mask bit 0 = Monday) and the holidays of the HR calendar resolved by the Gantt's + `CalendarStateMachine` for the project's `workingDaysConfig` (the project's department plus its ancestors). A project without `workingDaysConfig` is planned on + **Monday to Friday**, not on every day as the Gantt does: a person has no capacity on a weekend. The calendar days are the local day indexes of the layouts, so the + UTC-midnight holidays of the Gantt and the days of the layout are the same frame. +- Capacity (toolbar field `Capacity per day`, default 8, stored per view): what one person can take per working day, in the unit of the measure (hours for the + estimate and the remaining time, items or the field's unit otherwise). The capacity of a bucket is that times the working days of the bucket (a holiday week has + less, a weekend column none). A cell is under (< 80 % of the capacity), near (80 % up to the capacity) or over capacity: tinted green, amber, red; over cells carry a + visible `!`, a border and a tooltip, and every cell has a visually hidden text with person, period, load, capacity, percent and state (also read by the tooltip), so + the state is never color only. A bucket with load and no capacity is over. The legend in the toolbar names the three states. A load that is over by only + floating point noise is not over. +- Row header: total load inside the axis, a utilization percentage and `N over capacity` when a cell is over. The utilization is measured over the span in which the + person has load (first to last loaded bucket), so someone booked full for two weeks reads 100 % and not a share of the whole axis; the header is flagged over as soon + as one bucket is. +- Click a cell (or press Enter on it; arrow keys move between cells) to dock the **panel of items** on the right: the items that hold working days of that bucket with + their share of the load, the biggest first, or all unscheduled items of the person with their whole load. Click an item to open it, right-click for its menu. +- Reassign: drag an item of the panel onto another person's row (the row is outlined, Esc cancels) to write `assignee` (a drop on `Unassigned` clears it). The panel + also has a `Reassign` button per item that opens a list of the rows, for keyboard users. Every change is one `EditJournal` batch of Phase 4 with the same `Undo` toast + as the Roadmap and the Calendar. A read-only viewer (`restrictionStore.readonly`) gets no drag and no Reassign button, a press only opens the item. Dragging to + reschedule is not offered (not required). +- Filter string, chips, search, slice, archived scope, drafts and field sums are applied by `IssuesView` to the query the layout receives, so they behave as in the + Roadmap and the Calendar. Above 5000 items the same truncation banner as the Roadmap is shown. + +**Per-view config and dirty tracking** + +- `start`, `target`, `zoom`, `measure`, `field` (the Number field key, only with the measure Number field) and `capacity` live in the view options under the key `workload` + (the precedent of `roadmap`, `board`, `calendar`). The saved view already stores, restores and diffs `viewOptions`, so saving, the unsaved dot and discard work with no + change in `SavedViewBar`; the default config is not stored, so a changed-and-changed-back view is clean again (covered by a test through `isViewDirty`). A deleted date + field falls back to the default and a deleted Number field to the Estimate measure in the displayed settings (`sanitizeWorkloadConfig`), the saved view is not rewritten. + +**Deviations / decisions** + +- The items behind a cell are shown in a docked panel, not in a modal popup: a popup closes on the first click outside it, which would make dragging an item to another + row impossible. Behaviour is otherwise the one asked for (click a cell, see the contributing items with their share, open them). +- One capacity for everybody (per view), no per-person override: the `per-person capacity default` of the brief is read as the same default for every person. +- `Item count` and `Number field` use the same capacity number as the time measures (the unit is the measure's): change the capacity when switching the measure. +- Items without a load value are not counted in totals but are listed (share 0) so they are not invisible. +- Rows are not limited to people who have items: project members are added (accounts that have no person yet, and the read-only anonymous guest, are left out). + +**Verified / not verified** + +- Verified: jest (tracker-resources 1172 incl. 115 new workload tests, also under 7 time zones for `src/workload`; performance case 5000 items x 52 weeks and 5000 items at + day zoom each under 2 s with margin; tracker-assets lang tests; model-tracker), `svelte-check` of tracker-resources, `rush validate --to @hcengineering/model-tracker` and + `--to @hcengineering/prod`. +- Not verified: nothing was run in a browser or against a server (no sanity spec). The layout of the grid in the page flex chain (sticky header and the two sticky columns, + the absolutely positioned windowed cells, row heights), the look of the heat colors in the light and dark themes, the position of the today line, the scroll-to-today + behaviour, the docked panel width, the drag over the rows, the HR holiday loading through the Gantt state machine in a real workspace and the `Avatar` in the row header are by + reasoning, not by eye. + +**Not done / limits** + +- No rescheduling by dragging, no per-person capacity or leave, no grouping of rows by team or iteration, no weekend toggle (the project's working days are the only source). +- The axis is cut to 366 days / 156 weeks / 72 months around today; items beyond it are only counted in the out of range notice. Totals and utilization are over the axis. +- A marker item (one date) puts its whole load on that day, which makes a single estimated item over capacity on its own when it is bigger than the capacity of the day. +- The `Reassign` list offers the rows of the view (assignees with items and members of the project), not every person of the workspace. + + +--- + +## Phase 14 implementation notes (Nested grouping) + +**What was found first** + +- The shared list of the view plugin already nests groups: `viewOptions.groupBy` is an array, `ListCategories` recurses one level per entry + (`level`, `lastLevel`), headers, counts, the group summary of Phase 8 and the hierarchy of Phase 8 (only at the last level) all work per level, and the + Customize View popup already offered "Then" rows for any viewlet without `groupDepth`. The tracker table never used it only because its stored + viewlet had no depth cap and the rows said "Then". So the Table work is making that machinery correct, bounded and per view, not a new renderer. +- `plugins/view-resources/src/utils/nested-groups.ts` listed in section 5 never existed. It is dropped: the pure helpers of the shared list are + `plugins/view-resources/src/nestedGroups.ts`, those of the tracker layouts `plugins/tracker-resources/src/grouping/`. + +**What was built** + +- Pure logic with jest tests next to it. `grouping/nested.ts` (tracker-resources): the nested bucket builder (`buildNestedGroups`: up to any number of levels, + order by an explicit option/iteration order, then by a comparator such as the label, then first seen; "No " last on every level; `includeEmpty` per level; + path ids that stay stable; `flattenNestedGroups`, `visibleNestedItems`, `summarizeGroups` (sums per group at every level), `toggleGroupCollapsed`), tested including 50k + items on three levels. `grouping/levels.ts`: `resolveGroupLevels` (caps, de-duplicates, skips the columns' field and deleted custom fields, "No grouping" ends the list), + the layout caps and the storage keys of the collapsed groups. `view-resources/src/nestedGroups.ts`: rows of the group-by controls of the popup, `isEmptyCategory`, + `emptyCategoryLast`, `categoriesWithDocs`, tested together with `isViewDirty`. +- Table (shared list, additive): "Group by" + "Then by" + "Then by" (`groupDepth` 3 on the tracker Table viewlet, new string `view.ThenBy` in all 14 locales of `view-assets`). + The popup logic moved into `nestedGroups.ts` and fixes an off-by-one: the old code let the popup grow a fourth row in the same session. Changing a level keeps the levels + below it (a key that becomes a duplicate is dropped) instead of throwing them away. +- Board: swimlanes in two levels (lane, then sub-lane) taken from "Group by" and "Then by" (`groupDepth` 2 on the board viewlet); `buildBoardGrid` got optional `subLanes` + / `bucketSubLanes`, `BoardLane` got `path`, `depth`, `subLanes`; `BoardLanes.svelte` draws a swimlane with sub-lanes as a header over its sub-lanes, each with the cells. + A drop on a sub-lane writes column + lane + sub-lane in one update, the "+" row of a cell starts with all three values. The field of the columns is skipped as a level. +- Roadmap: rows in two levels (`groupDepth` 2 on the roadmap viewlet). `buildRows` takes optional `children` per group and puts `depth` on a group row (indent in the + header); `RoadmapView` builds its groups with `buildNestedGroups`, the old single level `roadmap/grouping.ts` was removed (same ordering rules, now covered by the builder tests). + Field sums are shown on every level. +- View export: the board export lists the sub-lane keys between the lane and the column keys (`exportGroupKeys`). +- Model: `groupDepth` of `issuesOptions` is 3 for the Table and 2 for the Board, the Roadmap has 2 (`TABLE_/BOARD_/ROADMAP_GROUP_DEPTH`). The stored viewlet docs are not updated by + `builder.createDoc`, so the upgrade step `set-nested-group-depth` of `model-tracker` sets the depth on the existing `IssueList`, `IssueKanban` and `IssueRoadmap` viewlets. + +**Behaviour** + +- Group headers on every level show their own count and, when "Field sum" is on, their own sums. "No " is the last group of every level of a nested view. + Hierarchy (Phase 8) applies inside the deepest group only. "Show empty groups" lists the empty groups of the first level only; a level below lists what is in its parent + group (so a custom field with every option does not repeat all options under every parent). Same on the board (empty sub-lanes are always dropped) and the roadmap. +- Collapsed groups are kept per viewer and per saved view in local storage. Table: only when the view groups on two or more levels (the key is the old key prefixed with + `.`, a single level keeps the legacy key, so every other list and the sub-issues of an issue are unaffected; `ClientViewExtension.groupStateScope`). + Roadmap and Board: set of collapsed group ids under `tracker.groups.collapsed...` (the same store as the Phase 8 expansion). The board used to forget + its collapsed lanes on every reload, the roadmap its groups; both now remember them. Without a saved view the state lasts for the session. +- A stored single `groupBy` behaves as before: `resolveGroupLevels` gives the same single level, the Table list code path for one level is unchanged (`emptyCategoryLast` and the + per-view key are applied for two or more levels only). + +**Per-view config and dirty tracking** + +- Nothing new is stored: the levels are the existing `viewOptions.groupBy` array, saved with the view like any view option. The unsaved dot works through the existing + `isViewDirty` deep compare (tests: adding a level makes the view dirty, removing it again makes it clean, a single stored group-by set again stays clean, the order of the + levels is a change). The popup stores `["status"]`, not `["status", "#no_category"]`, so choosing nothing for the next row leaves the view clean. + +**Shared code touched (additive, opt-in)** + +- `view-resources`: `ListCategories` (arranges "No " last and drops empty groups of a client key below the first level, only when the view has two or more levels), + `List` (the per-view key for nested views), `ViewOptions` (rows through the pure helpers, label `ThenBy`), `ClientViewExtension.groupStateScope`, `index` exports. + The GitHub integration model reuses `issuesOptions(false)`, so its issue list also gets the three levels. + +**Deviations / decisions** + +- The Table is not rendered from the new builder: the shared list already renders nested groups (virtual limits, drag to rank, keyboard, selection, hierarchy rows), rewriting that + would put every other list at risk. The builder serves the layouts that draw their own groups (Roadmap now, Board sub-lanes use the same bucket idea in `buildBoardGrid`). +- The popup label changed from "Then" to "Then by" for every list that nests (all of them use the same popup). + +**Verified / not verified** + +- Verified: jest (view-resources 313 -> incl. new `nestedGroups`, tracker-resources incl. nested builder, levels, board sub-lanes, roadmap nested rows, export keys), + tracker-assets / view-assets lang tests, `svelte-check` of view-resources and tracker-resources, `rush validate --to @hcengineering/model-tracker` and `--to @hcengineering/prod`. +- Not verified: nothing was run in a browser or against a server (no sanity spec). Keyboard navigation across nested table groups is the existing recursive logic of + `ListCategories.select` and was read, not exercised; the look of the indented sub-lane and roadmap sub-group headers, the sticky offsets of the lane headers and the + upgrade step against a real workspace are by reasoning, not by eye. + +**Not done / limits** + +- Keyboard navigation inside the board's sub-lanes follows the order of the cards lane by lane (sub-lane by sub-lane); sideways moves stay inside a sub-lane's row. +- No "collapse all / expand all" for groups, no drag of a group to reorder levels, and no third level on the Board or the Roadmap (two, as decided). +- The popup does not offer the same field twice in one chain, but a stored view that does has the repeated key skipped by the board and the roadmap and rendered as is by the Table. +- A Table view stored with more than three levels (possible before the cap) keeps rendering all of them; only the popup is capped. + diff --git a/models/server-tracker/src/index.ts b/models/server-tracker/src/index.ts index 0c508d4243..98f9d9f3c4 100644 --- a/models/server-tracker/src/index.ts +++ b/models/server-tracker/src/index.ts @@ -73,6 +73,39 @@ export function createModel (builder: Builder): void { } }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnProjectFieldCreate, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: tracker.class.ProjectField + } + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnProjectFieldRemove, + txMatch: { + _class: core.class.TxRemoveDoc, + objectClass: tracker.class.ProjectField + } + }) + + // At most 50,000 items per project (GitHub parity) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnProjectItemLimit, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: tracker.class.Issue + } + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnIterationRemove, + txMatch: { + _class: core.class.TxRemoveDoc, + objectClass: tracker.class.Iteration + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverTracker.trigger.OnDependencyShiftRequest, txMatch: { @@ -81,6 +114,43 @@ export function createModel (builder: Builder): void { } }) + // The "set a field" workflows run with the change that causes them + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnIssueWorkflow, + txMatch: { + _class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc] }, + objectClass: tracker.class.Issue + } + }) + + // Auto-archive and auto-add are evaluated after the fact + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnWorkflowEvaluate, + isAsync: true, + txMatch: { + _class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc] }, + objectClass: { $in: [tracker.class.Issue, tracker.class.Workflow] } + } + }) + + // Webhooks are delivered after the transaction, in the background + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnProjectItemWebhook, + isAsync: true, + txMatch: { + _class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc] }, + objectClass: tracker.class.Issue + } + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTracker.trigger.OnProjectWebhookRemove, + txMatch: { + _class: core.class.TxRemoveDoc, + objectClass: tracker.class.ProjectWebhook + } + }) + builder.mixin( tracker.ids.AssigneeNotification, notification.class.NotificationType, diff --git a/models/tracker/src/actions.ts b/models/tracker/src/actions.ts index 659c90358d..cf56c10806 100644 --- a/models/tracker/src/actions.ts +++ b/models/tracker/src/actions.ts @@ -172,6 +172,68 @@ export function createActions (builder: Builder, issuesId: string, componentsId: tracker.action.DeleteIssue ) + // Archive (GitHub "Archive item"): the issue keeps its values and is hidden from the views until it is restored + createAction( + builder, + { + action: tracker.actionImpl.ArchiveIssue, + label: tracker.string.ArchiveItem, + icon: view.icon.Archive, + input: 'any', + category: tracker.category.Tracker, + target: tracker.class.Issue, + query: { + archivedAt: null + }, + context: { + mode: ['context', 'browser'], + group: 'remove' + } + }, + tracker.action.ArchiveIssue + ) + + createAction( + builder, + { + action: tracker.actionImpl.RestoreIssue, + label: tracker.string.RestoreItem, + icon: view.icon.Undo, + input: 'any', + category: tracker.category.Tracker, + target: tracker.class.Issue, + query: { + archivedAt: { $ne: null } + }, + context: { + mode: ['context', 'browser'], + group: 'remove' + } + }, + tracker.action.RestoreIssue + ) + + // Draft items (GitHub "Convert to issue"): the draft gets the next number of its project and stops being a draft + createAction( + builder, + { + action: tracker.actionImpl.ConvertDraftToIssue, + label: tracker.string.ConvertToIssue, + icon: tracker.icon.Issue, + input: 'any', + category: tracker.category.Tracker, + target: tracker.class.Issue, + query: { + isDraft: true + }, + context: { + mode: ['context', 'browser'], + group: 'edit' + } + }, + tracker.action.ConvertDraftToIssue + ) + builder.createDoc( view.class.ActionCategory, core.space.Model, diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index e785d30409..b19da2b8e1 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -47,6 +47,13 @@ import { TIssueTypeData, TMilestone, TProject, + TProjectField, + TIteration, + TInsightChart, + TWorkflow, + TProjectWebhook, + TProjectWebhookSecret, + TProjectStatusUpdate, TProjectTargetPreference, TRelatedIssueTarget, TTimeSpendReport, @@ -479,6 +486,13 @@ export function createModel (builder: Builder): void { TIssueStatus, TTypeIssuePriority, TMilestone, + TProjectField, + TIteration, + TInsightChart, + TWorkflow, + TProjectWebhook, + TProjectWebhookSecret, + TProjectStatusUpdate, TTypeMilestoneStatus, TTimeSpendReport, TTypeReportedTime, diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index 5d79b4af5c..265bbd8a97 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -52,7 +52,13 @@ import view, { type ViewOptionModel } from '@hcengineering/view' import { classicIssueTaskStatuses } from '.' import tracker from './plugin' import { DOMAIN_TRACKER } from './types' -import { ganttViewOptions, issuesOptions } from './viewlets' +import { + BOARD_GROUP_DEPTH, + ganttViewOptions, + issuesOptions, + ROADMAP_GROUP_DEPTH, + TABLE_GROUP_DEPTH +} from './viewlets' async function createDefaultProject (tx: TxOperations): Promise { const current = await tx.findOne(tracker.class.Project, { @@ -254,6 +260,28 @@ async function addSearchViewOptions (client: MigrationUpgradeClient): Promise { + const txOp = new TxOperations(client, core.account.System) + + const targets = [ + { id: tracker.viewlet.IssueList, depth: TABLE_GROUP_DEPTH }, + { id: tracker.viewlet.IssueKanban, depth: BOARD_GROUP_DEPTH }, + { id: tracker.viewlet.IssueRoadmap, depth: ROADMAP_GROUP_DEPTH } + ] + + for (const t of targets) { + const viewlets = await client.findAll(view.class.Viewlet, { _id: t.id }) + for (const v of viewlets) { + const current = v.viewOptions + if (current === undefined || current.groupDepth === t.depth) continue + await txOp.update(v, { viewOptions: { ...current, groupDepth: t.depth } }) + } + } +} + async function migrateDefaultStatuses (client: MigrationClient, logger: ModelLogger): Promise { const defaultTypeId = tracker.ids.ClassingProjectType const typeDescriptor = tracker.descriptors.ProjectType @@ -588,6 +616,10 @@ export const trackerOperation: MigrateOperation = { { state: 'add-search-view-options', func: addSearchViewOptions + }, + { + state: 'set-nested-group-depth', + func: setNestedGroupDepth } ]) } diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index 89d904ac03..0e8b9ebcdb 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -72,6 +72,9 @@ export default mergeIds(trackerId, tracker, { IssueTemplateList: '' as Ref, IssueKanban: '' as Ref, IssueGantt: '' as Ref, + IssueRoadmap: '' as Ref, + IssueCalendar: '' as Ref, + IssueWorkload: '' as Ref, MilestoneList: '' as Ref, ComponentList: '' as Ref, ProjectList: '' as Ref, @@ -104,6 +107,9 @@ export default mergeIds(trackerId, tracker, { EditProject: '' as ViewAction, DeleteProject: '' as ViewAction, DeleteIssue: '' as ViewAction, + ArchiveIssue: '' as ViewAction, + RestoreIssue: '' as ViewAction, + ConvertDraftToIssue: '' as ViewAction, DeleteMilestone: '' as ViewAction }, action: { @@ -111,7 +117,10 @@ export default mergeIds(trackerId, tracker, { DeleteMilestone: '' as Ref>>, DeleteProject: '' as Ref>>, DeleteProjectClean: '' as Ref>>, - DeleteIssue: '' as Ref>> + DeleteIssue: '' as Ref>>, + ArchiveIssue: '' as Ref>>, + RestoreIssue: '' as Ref>>, + ConvertDraftToIssue: '' as Ref>> }, // For migration only diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index 853e19450c..bb54dcc0b6 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -40,6 +40,7 @@ import { Model, Prop, ReadOnly, + TypeBoolean, TypeCollaborativeDoc, TypeDate, TypeMarkup, @@ -71,6 +72,22 @@ import { type IssueTemplate, type IssueTemplateChild, type Milestone, + type InsightChart, + type InsightDateBucket, + type InsightLayout, + type InsightYAxis, + type Iteration, + type ProjectField, + type ProjectFieldOption, + ProjectFieldType, + type ProjectStatus, + type ProjectStatusUpdate, + type ProjectWebhook, + type ProjectWebhookSecret, + type WebhookEvent, + type Workflow, + type WorkflowConfig, + type WorkflowKind, type MilestoneStatus, type Project, type RelatedClassRule, @@ -145,6 +162,18 @@ export class TProject extends TTaskProject implements Project { @Prop(TypeRecord(), tracker.string.WorkingDaysConfig) workingDaysConfig?: WorkingDaysConfig + + @Prop(TypeString(), tracker.string.ProjectShortDescription) + @Hidden() + shortDescription?: string + + @Prop(TypeMarkup(), tracker.string.ProjectReadme) + @Hidden() + readme?: Markup + + @Prop(TypeBoolean(), tracker.string.ProjectTemplate) + @Hidden() + isTemplate?: boolean } /** * @public @@ -243,6 +272,10 @@ export class TIssue extends TTask implements Issue { @ReadOnly() declare space: Ref + @Prop(TypeRecord(), tracker.string.CustomFields) + @Hidden() + customFields?: Record + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.IssueStartDate) @Index(IndexKind.Indexed) declare startDate: Timestamp | null @@ -250,6 +283,17 @@ export class TIssue extends TTask implements Issue { @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.DueDate) declare dueDate: Timestamp | null + // Set while the issue is archived (GitHub "Archive item"), null once restored + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.ArchivedAt) + @Hidden() + archivedAt?: Timestamp | null + + // Set while the issue is a draft item (GitHub "draft issue"): an item of the project that is not an issue yet, + // with number 0. Converting it takes the next number of the project and sets false + @Prop(TypeBoolean(), tracker.string.Draft) + @Hidden() + isDraft?: boolean + // Soft deadline, independent of dueDate. Optional. // When set, the Gantt renders a flag marker at this date and flags the // issue as overdue when dueDate > deadline. Undefined for existing issues @@ -462,6 +506,198 @@ export class TMilestone extends TDoc implements Milestone { declare space: Ref } +/** + * @public + */ +@Model(tracker.class.ProjectField, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.ProjectField, tracker.icon.Issues, '', 'label', undefined, tracker.string.ProjectFields) +export class TProjectField extends TDoc implements ProjectField { + @Prop(TypeString(), tracker.string.Title) + label!: string + + @Prop(TypeString(), tracker.string.FieldKey) + @ReadOnly() + key!: string + + @Prop(TypeString(), tracker.string.FieldType) + @ReadOnly() + type!: ProjectFieldType + + @Prop(TypeNumber(), tracker.string.Number) + @Hidden() + position!: number + + @Prop(TypeString(), tracker.string.Description) + description?: string + + @Prop(TypeRecord(), tracker.string.FieldDefaultValue) + defaultValue?: string | number | null + + @Prop(ArrOf(TypeRecord()), tracker.string.FieldOptions) + options?: ProjectFieldOption[] + + declare space: Ref +} + +/** + * A time box of an Iteration field. Issues reference it by id from `customFields[field.key]`. + * @public + */ +@Model(tracker.class.Iteration, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.Iteration, tracker.icon.Issues, '', 'label', undefined, tracker.string.Iterations) +export class TIteration extends TDoc implements Iteration { + @Prop(TypeRef(tracker.class.ProjectField), tracker.string.ProjectField) + @ReadOnly() + field!: Ref + + @Prop(TypeString(), tracker.string.Title) + label!: string + + @Prop(TypeNumber(), tracker.string.Number) + @Hidden() + number!: number + + @Prop(TypeDate(), tracker.string.StartDate) + startDate!: Timestamp + + @Prop(TypeNumber(), tracker.string.IterationDuration) + duration!: number + + @Prop(TypeBoolean(), tracker.string.IterationBreak) + isBreak?: boolean + + declare space: Ref +} + +/** + * A saved chart of the project Insights. `space` is the project. + * @public + */ +@Model(tracker.class.InsightChart, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.InsightChart, tracker.icon.Issues, '', 'name', undefined, tracker.string.InsightCharts) +export class TInsightChart extends TDoc implements InsightChart { + @Prop(TypeString(), tracker.string.Title) + name!: string + + @Prop(TypeString(), tracker.string.InsightLayout) + layout!: InsightLayout + + @Prop(TypeString(), tracker.string.InsightXAxis) + xField!: string + + @Prop(TypeString(), tracker.string.InsightDateBucket) + xBucket?: InsightDateBucket | null + + @Prop(TypeString(), tracker.string.InsightGroupBy) + groupField?: string | null + + @Prop(TypeRecord(), tracker.string.InsightYAxis) + yAggregate!: InsightYAxis + + @Prop(TypeString(), tracker.string.InsightFilter) + filter!: string + + @Prop(TypeNumber(), tracker.string.Number) + @Hidden() + position!: number + + declare space: Ref +} + +/** + * A built-in workflow of the project. `space` is the project. + * @public + */ +@Model(tracker.class.Workflow, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.Workflow, tracker.icon.Issues, '', 'name', undefined, tracker.string.Workflows) +export class TWorkflow extends TDoc implements Workflow { + @Prop(TypeString(), tracker.string.Title) + name!: string + + @Prop(TypeBoolean(), tracker.string.WorkflowEnabled) + enabled!: boolean + + @Prop(TypeString(), tracker.string.Workflow) + kind!: WorkflowKind + + @Prop(TypeString(), tracker.string.InsightFilter) + filter?: string + + @Prop(TypeRecord(), tracker.string.Workflow) + @Hidden() + config?: WorkflowConfig + + @Prop(TypeNumber(), tracker.string.Number) + @Hidden() + runRequestedAt?: Timestamp + + declare space: Ref +} + +/** + * A webhook of the project that is called when an item changes. `space` is the project. + * @public + */ +@Model(tracker.class.ProjectWebhook, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.ProjectWebhook, tracker.icon.Issues, '', 'url', undefined, tracker.string.ProjectWebhooks) +export class TProjectWebhook extends TDoc implements ProjectWebhook { + @Prop(TypeString(), tracker.string.WebhookUrl) + url!: string + + @Prop(TypeBoolean(), tracker.string.WorkflowEnabled) + enabled!: boolean + + @Prop(ArrOf(TypeString()), tracker.string.WebhookEvents) + events!: WebhookEvent[] + + @Prop(TypeBoolean(), tracker.string.WebhookSecret) + hasSecret!: boolean + + @Prop(TypeString(), tracker.string.Description) + description?: string + + declare space: Ref +} + +/** + * A status update of the project (GitHub "Project status updates"). `space` is the project; the author is the + * creator of the document, the latest update is the status of the project. + * @public + */ +@Model(tracker.class.ProjectStatusUpdate, core.class.Doc, DOMAIN_TRACKER) +@UX(tracker.string.ProjectStatusUpdate, tracker.icon.Issues, '', undefined, undefined, tracker.string.ProjectStatusUpdates) +export class TProjectStatusUpdate extends TDoc implements ProjectStatusUpdate { + @Prop(TypeString(), tracker.string.Status) + status!: ProjectStatus + + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.StartDate) + startDate?: Timestamp | null + + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.TargetDate) + targetDate?: Timestamp | null + + @Prop(TypeMarkup(), tracker.string.Description) + body!: Markup + + declare space: Ref +} + +/** + * The secret of a webhook. It lives in the personal space of the person who set it (never in the project), so the + * other members of the project can not read it. + * @public + */ +@Model(tracker.class.ProjectWebhookSecret, core.class.Doc, DOMAIN_TRACKER) +export class TProjectWebhookSecret extends TDoc implements ProjectWebhookSecret { + @Prop(TypeRef(tracker.class.ProjectWebhook), tracker.string.ProjectWebhook) + @Hidden() + webhook!: Ref + + @Prop(TypeString(), tracker.string.WebhookSecret) + @Hidden() + secret!: string +} + @UX(core.string.Number) @Model(tracker.class.TypeReportedTime, core.class.Type) export class TTypeReportedTime extends TType {} diff --git a/models/tracker/src/viewlets.ts b/models/tracker/src/viewlets.ts index 30282c7ae0..332d86c545 100644 --- a/models/tracker/src/viewlets.ts +++ b/models/tracker/src/viewlets.ts @@ -55,7 +55,27 @@ const SEARCH_VIEW_OPTIONS: ViewOptionModel[] = [ } ] +// Row height of the table layout (GitHub Projects: "Row height"). Saved with the view like any view option. +const ROW_HEIGHT_VIEW_OPTION: ViewOptionModel = { + key: 'rowHeight', + type: 'dropdown', + defaultValue: 'default', + values: [ + { id: 'compact', label: tracker.string.RowHeightCompact }, + { id: 'default', label: tracker.string.RowHeightDefault }, + { id: 'comfortable', label: tracker.string.RowHeightComfortable } + ], + label: tracker.string.RowHeight +} + +// Levels of nested grouping ("Group by", then "Then by") a layout offers. The Table nests three levels, the Board +// shows the swimlanes in two (a lane and a sub-lane) and the Roadmap groups its rows in two. +export const TABLE_GROUP_DEPTH = 3 +export const BOARD_GROUP_DEPTH = 2 +export const ROADMAP_GROUP_DEPTH = 2 + export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ + groupDepth: kanban ? BOARD_GROUP_DEPTH : TABLE_GROUP_DEPTH, groupBy: [ 'status', 'kind', @@ -107,7 +127,7 @@ export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({ action: view.function.HideArchived, label: view.string.HideArchived }, - ...(!kanban ? [showColorsViewOption] : []), + ...(!kanban ? [showColorsViewOption, ROW_HEIGHT_VIEW_OPTION] : []), ...SEARCH_VIEW_OPTIONS ] }) @@ -559,6 +579,69 @@ export function ganttConfig (): BuildModelKey[] { ] } +// Roadmap (GitHub Projects layout of the same name). Start/target date fields, zoom, markers and the fields shown +// on an item are not listed here: they are stored in the view options under the `roadmap` key and edited in the +// toolbar of the layout itself. Group-by and order-by are the generic ones, plus the custom fields that the +// client view extension adds. +export function roadmapViewOptions (): ViewOptionsModel { + const base = issuesOptions(true) + return { + groupBy: ['status', 'kind', 'assignee', 'priority', 'component', 'milestone'], + // A roadmap starts ungrouped ('#no_category' is the "No grouping" choice of the Customize View popup) + defaultGroupBy: '#no_category', + orderBy: [ + ['startDate', SortingOrder.Ascending], + ['dueDate', SortingOrder.Ascending], + ['rank', SortingOrder.Ascending], + ['modifiedOn', SortingOrder.Descending], + ['createdOn', SortingOrder.Descending], + ['status', SortingOrder.Ascending], + ['priority', SortingOrder.Ascending] + ], + groupDepth: ROADMAP_GROUP_DEPTH, + other: base.other + } +} + +// Calendar (month grid, week and agenda). Which dates place an item, and the mode, are stored in the view options under +// the `calendar` key and edited in the toolbar of the layout itself. The fields shown on an item are the columns of the +// view (Configure columns), like the cards of the board. There is no grouping: a day is the group. Order-by is the +// order of the items inside a day. +export function calendarViewOptions (): ViewOptionsModel { + const base = issuesOptions(true) + return { + // A single entry: the Customize View popup offers no "Group by" for a layout that has one entry + groupBy: ['status'], + defaultGroupBy: '#no_category', + orderBy: [ + ['rank', SortingOrder.Ascending], + ['priority', SortingOrder.Ascending], + ['startDate', SortingOrder.Ascending], + ['dueDate', SortingOrder.Ascending], + ['modifiedOn', SortingOrder.Descending], + ['createdOn', SortingOrder.Descending], + ['status', SortingOrder.Ascending] + ], + groupDepth: 1, + other: base.other + } +} + +// Workload (people by time buckets). The dates that place an item, the zoom, the load measure and the capacity per day +// are stored in the view options under the `workload` key and edited in the toolbar of the layout itself. The rows are +// the assignees, so there is no grouping and no sorting; the fields of an item are not configurable. +export function workloadViewOptions (): ViewOptionsModel { + const base = issuesOptions(true) + return { + // A single entry: the Customize View popup offers no "Group by" for a layout that has one entry + groupBy: ['status'], + defaultGroupBy: '#no_category', + orderBy: [['rank', SortingOrder.Ascending]], + groupDepth: 1, + other: base.other + } +} + export function defineViewlets (builder: Builder): void { builder.createDoc( view.class.ViewletDescriptor, @@ -571,6 +654,39 @@ export function defineViewlets (builder: Builder): void { tracker.viewlet.Kanban ) + builder.createDoc( + view.class.ViewletDescriptor, + core.space.Model, + { + label: tracker.string.Roadmap, + icon: tracker.icon.Roadmap, + component: tracker.component.RoadmapView + }, + tracker.viewlet.Roadmap + ) + + builder.createDoc( + view.class.ViewletDescriptor, + core.space.Model, + { + label: tracker.string.Calendar, + icon: tracker.icon.Calendar, + component: tracker.component.CalendarView + }, + tracker.viewlet.Calendar + ) + + builder.createDoc( + view.class.ViewletDescriptor, + core.space.Model, + { + label: tracker.string.Workload, + icon: tracker.icon.Workload, + component: tracker.component.WorkloadView + }, + tracker.viewlet.Workload + ) + builder.createDoc( view.class.ViewletDescriptor, core.space.Model, @@ -839,12 +955,18 @@ export function defineViewlets (builder: Builder): void { descriptor: tracker.viewlet.Kanban, viewOptions: { ...issuesOptions(true), - groupDepth: 1 + // The board starts without swimlanes: its "Group by" is the swimlanes, the columns come from the column + // field of the board settings (the status unless chosen otherwise). '#no_category' is the "No grouping" + // choice of the Customize View popup. + defaultGroupBy: '#no_category', + groupDepth: BOARD_GROUP_DEPTH }, configOptions: { strict: true }, + // The fields of a card; the title, the identifier and the status marker are always shown config: [ + 'assignee', 'subIssues', 'priority', 'component', @@ -859,6 +981,49 @@ export function defineViewlets (builder: Builder): void { tracker.viewlet.IssueKanban ) + // The roadmap follows Kanban so that List stays the default viewlet + builder.createDoc( + view.class.Viewlet, + core.space.Model, + { + attachTo: tracker.class.Issue, + descriptor: tracker.viewlet.Roadmap, + viewOptions: roadmapViewOptions(), + configOptions: { strict: true, hiddenKeys: ['title'] }, + config: [] + }, + tracker.viewlet.IssueRoadmap + ) + + // The calendar follows the roadmap, after the layouts of the saved view bar + builder.createDoc( + view.class.Viewlet, + core.space.Model, + { + attachTo: tracker.class.Issue, + descriptor: tracker.viewlet.Calendar, + viewOptions: calendarViewOptions(), + configOptions: { strict: true, hiddenKeys: ['title'] }, + // The fields an item of the calendar shows; the title, the identifier and the status marker are always shown + config: ['assignee', 'priority', 'dueDate', 'labels'] + }, + tracker.viewlet.IssueCalendar + ) + + // The workload follows the calendar, the last layout of the saved view bar + builder.createDoc( + view.class.Viewlet, + core.space.Model, + { + attachTo: tracker.class.Issue, + descriptor: tracker.viewlet.Workload, + viewOptions: workloadViewOptions(), + configOptions: { strict: true, hiddenKeys: ['title'] }, + config: [] + }, + tracker.viewlet.IssueWorkload + ) + // Gantt is registered AFTER List + Kanban so List remains the default // viewlet (ViewletSelector falls back to viewlets[0] when no preference // is saved). Putting Gantt last avoids surprising users with an empty @@ -964,6 +1129,12 @@ export function defineViewlets (builder: Builder): void { } }, 'members', + { + key: '', + label: tracker.string.ProjectStatus, + presenter: tracker.component.ProjectStatusPresenter, + displayProps: { key: 'projectStatus' } + }, { key: 'defaultAssignee', props: { kind: 'list' } @@ -1002,6 +1173,12 @@ export function defineViewlets (builder: Builder): void { } }, 'members', + { + key: '', + label: tracker.string.ProjectStatus, + presenter: tracker.component.ProjectStatusPresenter, + displayProps: { key: 'projectStatus' } + }, { key: 'defaultAssignee', props: { kind: 'list' } diff --git a/models/view/src/index.ts b/models/view/src/index.ts index b5b501624c..c5e6ef160d 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -133,6 +133,11 @@ export class TFilteredView extends TDoc implements FilteredView { users!: AccountUuid[] attachedTo!: string sharable?: boolean + project?: Ref + config?: (BuildModelKey | string)[] + order?: number + extra?: string + filterQuery?: string } @Model(view.class.FilterMode, core.class.Doc, DOMAIN_MODEL) diff --git a/packages/kanban/src/components/Kanban.svelte b/packages/kanban/src/components/Kanban.svelte index a787d8327c..bb80e84442 100644 --- a/packages/kanban/src/components/Kanban.svelte +++ b/packages/kanban/src/components/Kanban.svelte @@ -55,6 +55,9 @@ export let getUpdateProps: (doc: Doc, state: CategoryType) => DocumentUpdate | undefined export let getAvailableCategories: ((doc: Doc) => Promise) | undefined = undefined + // See KanbanRow: query of the full documents of a column, for columns that are not a plain attribute + export let getGroupQuery: ((state: CategoryType, stateObjects: Item[]) => DocumentQuery) | undefined = + undefined const dispatch = createEventDispatcher() @@ -429,6 +432,7 @@ {query} {options} {groupByKey} + {getGroupQuery} > diff --git a/packages/kanban/src/components/KanbanRow.svelte b/packages/kanban/src/components/KanbanRow.svelte index c9a78b8cbb..8f3e133108 100644 --- a/packages/kanban/src/components/KanbanRow.svelte +++ b/packages/kanban/src/components/KanbanRow.svelte @@ -45,6 +45,10 @@ export let options: FindOptions | undefined = undefined export let groupByKey: any export let limiter: RateLimiter + // Query of the full documents of the column. By default they are found by the group-by key and the column; + // a host whose columns are not a plain attribute of the document passes the query by the ids it shows. + export let getGroupQuery: ((state: CategoryType, stateObjects: Item[]) => DocumentQuery) | undefined = + undefined export let cardDragOver: (evt: CardDragEvent, object: Item) => void export let cardDrop: (evt: CardDragEvent, object: Item) => void @@ -74,7 +78,7 @@ let limitedObjects: IdMap = new Map() const docQuery = createQuery() - $: groupQuery = { + $: attributeQuery = { ...query, [groupByKey]: typeof state === 'object' @@ -84,6 +88,26 @@ : state } + // The query of the host is rebuilt only when it changes, so that moving cards around does not restart it + let hostQuery: DocumentQuery = {} + let hostQueryKey = '' + function updateHostQuery ( + factory: (state: CategoryType, stateObjects: Item[]) => DocumentQuery, + state: CategoryType, + stateObjects: Item[], + limit: number + ): void { + const next = factory(state, stateObjects.slice(0, limit)) + const key = JSON.stringify(next) + if (key !== hostQueryKey) { + hostQueryKey = key + hostQuery = next + } + } + $: if (getGroupQuery !== undefined) updateHostQuery(getGroupQuery, state, stateObjects, limit) + + $: groupQuery = getGroupQuery !== undefined ? hostQuery : attributeQuery + $: docQuery.query( _class, groupQuery, diff --git a/packages/kanban/src/index.ts b/packages/kanban/src/index.ts index ba1c590266..8e412e2fdf 100644 --- a/packages/kanban/src/index.ts +++ b/packages/kanban/src/index.ts @@ -17,3 +17,4 @@ import '@hcengineering/platform-rig/profiles/ui/svelte' export * from './types' export { default as Kanban } from './components/Kanban.svelte' +export { default as KanbanRow } from './components/KanbanRow.svelte' diff --git a/plugins/tracker-assets/assets/icons.svg b/plugins/tracker-assets/assets/icons.svg index eda12e190e..a343a6f0a3 100644 --- a/plugins/tracker-assets/assets/icons.svg +++ b/plugins/tracker-assets/assets/icons.svg @@ -129,6 +129,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index 4d785162d8..f328db3971 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Přehled", "Projects": "Vaše projekty", "More": "Více", @@ -313,7 +372,309 @@ "SearchEmptyTitle": "Pro „{query}\" nebyly nalezeny žádné úkoly", "SearchEmptyActiveFilters": "Aktivní filtry: {filters}", "SearchEmptyClearFilters": "Hledat bez filtrů", - "SearchEmptyAllProjects": "Hledat ve všech projektech" + "SearchEmptyAllProjects": "Hledat ve všech projektech", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 4886ccce28..c3b5779782 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "Deine Projekte", "More": "Mehr", @@ -561,7 +620,309 @@ "SearchEmptyTitle": "Keine Treffer für \"{query}\"", "SearchEmptyActiveFilters": "Aktive Filter: {filters}", "SearchEmptyClearFilters": "Suche ohne Filter", - "SearchEmptyAllProjects": "In allen Projekten suchen" + "SearchEmptyAllProjects": "In allen Projekten suchen", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index d9dc6a07ca..4aec602136 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "Your projects", "More": "More", @@ -561,7 +620,309 @@ "SearchEmptyTitle": "No issues found for \"{query}\"", "SearchEmptyActiveFilters": "Active filters: {filters}", "SearchEmptyClearFilters": "Search without filters", - "SearchEmptyAllProjects": "Search in all projects" + "SearchEmptyAllProjects": "Search in all projects", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 1ff5a8a77b..9aa17a755a 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Seguimiento", "Projects": "Tus proyectos", "More": "Más", @@ -306,7 +365,309 @@ "SearchEmptyTitle": "No se encontraron tareas para \"{query}\"", "SearchEmptyActiveFilters": "Filtros activos: {filters}", "SearchEmptyClearFilters": "Buscar sin filtros", - "SearchEmptyAllProjects": "Buscar en todos los proyectos" + "SearchEmptyAllProjects": "Buscar en todos los proyectos", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index df9e6094c2..037ee7f494 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Suivi", "Projects": "Vos projets", "More": "Plus", @@ -306,7 +365,309 @@ "SearchEmptyTitle": "Aucun problème trouvé pour \"{query}\"", "SearchEmptyActiveFilters": "Filtres actifs : {filters}", "SearchEmptyClearFilters": "Rechercher sans filtres", - "SearchEmptyAllProjects": "Rechercher dans tous les projets" + "SearchEmptyAllProjects": "Rechercher dans tous les projets", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 318f9326f7..cbd9ff53d3 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "I tuoi progetti", "More": "Altro", @@ -306,7 +365,309 @@ "SearchEmptyTitle": "Nessuna issue trovata per \"{query}\"", "SearchEmptyActiveFilters": "Filtri attivi: {filters}", "SearchEmptyClearFilters": "Cerca senza filtri", - "SearchEmptyAllProjects": "Cerca in tutti i progetti" + "SearchEmptyAllProjects": "Cerca in tutti i progetti", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index d060f6f83e..90af182d06 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "トラッカー", "Projects": "あなたのプロジェクト", "More": "その他", @@ -306,7 +365,309 @@ "SearchEmptyTitle": "\"{query}\" に一致するイシューが見つかりません", "SearchEmptyActiveFilters": "アクティブなフィルター:{filters}", "SearchEmptyClearFilters": "フィルターなしで検索", - "SearchEmptyAllProjects": "すべてのプロジェクトを検索" + "SearchEmptyAllProjects": "すべてのプロジェクトを検索", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 0d3bba943e..a612793e80 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "트래커", "Projects": "내 프로젝트", "More": "더 보기", @@ -306,7 +365,309 @@ "SearchEmptyTitle": "\"{query}\"에 대한 이슈를 찾을 수 없습니다", "SearchEmptyActiveFilters": "활성 필터: {filters}", "SearchEmptyClearFilters": "필터 없이 검색", - "SearchEmptyAllProjects": "모든 프로젝트에서 검색" + "SearchEmptyAllProjects": "모든 프로젝트에서 검색", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index fa2e855527..ce34798d3d 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Zarządzanie zadaniami", "Projects": "Twoje projekty", "More": "Więcej", @@ -290,7 +349,309 @@ "UnsetParentIssue": "Wyczyść zagadnienie nadrzędne", "ForbidCreateProjectPermission": "Zakaż tworzenia projektów", "ForbidCreateProjectPermissionDescription": "Zakaż użytkownikom tworzenia nowych projektów.", - "AllowCreatingIssues": "Zezwól na tworzenie zadań" + "AllowCreatingIssues": "Zezwól na tworzenie zadań", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index fb3ea90226..79c688706d 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Seguimento", "Projects": "Seus projetos", "More": "Mais", @@ -306,7 +365,309 @@ "SearchEmptyTitle": "Nenhum problema encontrado para \"{query}\"", "SearchEmptyActiveFilters": "Filtros ativos: {filters}", "SearchEmptyClearFilters": "Procurar sem filtros", - "SearchEmptyAllProjects": "Procurar em todos os projetos" + "SearchEmptyAllProjects": "Procurar em todos os projetos", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 4e5ffcb65a..8b86f3ffe9 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Seguimento", "Projects": "Os teus projetos", "More": "Mais", @@ -306,7 +365,309 @@ "SearchEmptyTitle": "Nenhum problema encontrado para \"{query}\"", "SearchEmptyActiveFilters": "Filtros ativos: {filters}", "SearchEmptyClearFilters": "Procurar sem filtros", - "SearchEmptyAllProjects": "Procurar em todos os projetos" + "SearchEmptyAllProjects": "Procurar em todos os projetos", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index a3e3bacfa6..25ed6affd7 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Высота строки", + "RowHeightCompact": "Компактная", + "RowHeightDefault": "Обычная", + "RowHeightComfortable": "Просторная", + "CustomFieldFilter": "Фильтр по полям", + "AddFieldFilter": "Добавить фильтр по полю", + "NoFieldFilters": "Нет фильтров по пользовательским полям", + "ClearFieldFilters": "Очистить все", + "RemoveFieldFilter": "Удалить фильтр", + "FieldFilterUnknownField": "Неизвестное поле", + "FieldFilterValue": "Значение", + "FieldFilterFrom": "От", + "FieldFilterTo": "До", + "FieldFilterOpContains": "содержит", + "FieldFilterOpEq": "равно", + "FieldFilterOpGt": "больше", + "FieldFilterOpGte": "больше или равно", + "FieldFilterOpLt": "меньше", + "FieldFilterOpLte": "меньше или равно", + "FieldFilterOpBetween": "между", + "FieldFilterOpBefore": "до", + "FieldFilterOpAfter": "после", + "FieldFilterOpAnyOf": "любое из", + "FieldFilterOpIsEmpty": "пусто", + "FieldFilterOpIsNotEmpty": "не пусто", + "CustomFieldScanLimitExceeded": "Фильтрация, сортировка и группировка по пользовательским полям отключены: в этом представлении больше {limit} задач. Сузьте выборку другими фильтрами.", + "NoFieldValue": "Нет значения: {field}", + "ProjectField": "Поле", + "ProjectFields": "Поля", + "CustomFields": "Пользовательские поля", + "FieldKey": "Ключ", + "FieldType": "Тип", + "FieldDefaultValue": "Значение по умолчанию", + "FieldOptions": "Варианты", + "FieldEmptyValue": "Пусто", + "NewProjectField": "Новое поле", + "EditProjectField": "Редактировать поле", + "DeleteProjectField": "Удалить поле {name}?", + "DeleteProjectFieldConfirm": "Поле и его значения во всех задачах будут удалены.", + "NoProjectFields": "Полей пока нет", + "ProjectFieldNamePlaceholder": "Название поля", + "ProjectFieldDescriptionPlaceholder": "Описание", + "ProjectFieldTypeText": "Текст", + "ProjectFieldTypeNumber": "Число", + "ProjectFieldTypeDate": "Дата", + "ProjectFieldTypeSingleSelect": "Одиночный выбор", + "ProjectFieldTypeMultiSelect": "Множественный выбор", + "AddProjectFieldOption": "Добавить вариант", + "ProjectFieldOptionLabel": "Вариант", + "ProjectFieldOptionDescription": "Описание", + "ProjectFieldErrorEmptyLabel": "Укажите название поля", + "ProjectFieldErrorDuplicateLabel": "Поле с таким названием уже существует", + "ProjectFieldErrorTooManyFields": "В проекте может быть не более 50 полей", + "ProjectFieldErrorTooManyOptions": "В поле может быть не более 50 вариантов", + "ProjectFieldErrorDuplicateOption": "Названия вариантов должны быть уникальными", + "ProjectFieldErrorEmptyOption": "Название варианта не может быть пустым", + "ProjectFieldErrorOptionsRequired": "Добавьте хотя бы один вариант", + "ProjectFieldErrorDefaultNotAllowed": "Этот тип поля не поддерживает значение по умолчанию", + "ProjectFieldErrorInvalidDefault": "Недопустимое значение по умолчанию", "TrackerApplication": "Трекер", "Projects": "Проекты", "More": "Больше", @@ -561,7 +620,309 @@ "SearchEmptyTitle": "Не найдено задач по запросу «{query}»", "SearchEmptyActiveFilters": "Активные фильтры: {filters}", "SearchEmptyClearFilters": "Искать без фильтров", - "SearchEmptyAllProjects": "Искать во всех проектах" + "SearchEmptyAllProjects": "Искать во всех проектах", + "Iteration": "Итерация", + "Iterations": "Итерации", + "IterationDuration": "Длительность", + "IterationBreak": "Перерыв", + "IterationTitlePlaceholder": "Название итерации", + "IterationStartsOn": "Начало", + "IterationDurationDays": "дн.", + "IterationDurationWeeks": "нед.", + "IterationCurrentMarker": "текущая", + "IterationCurrent": "Текущая итерация", + "IterationNext": "Следующая итерация", + "IterationPrevious": "Предыдущая итерация", + "NoIteration": "Без итерации", + "NoIterations": "Итераций пока нет", + "AddIteration": "Добавить итерацию", + "InsertIterationBreak": "Вставить перерыв после", + "DeleteIteration": "Удалить итерацию", + "DeleteIterationConfirm": "У задач из «{name}» значение итерации будет сброшено.", + "UpcomingIterations": "Текущая и будущие итерации", + "CompletedIterations": "Завершённые итерации", + "IterationSavedImmediately": "Изменения итераций сохраняются сразу. Изменение длительности или даты начала сдвигает последующие итерации.", + "IterationErrorOverlap": "Итерация не может начинаться раньше окончания предыдущей.", + "IterationErrorInvalidDuration": "Длительность должна быть целым числом дней, не менее 1.", + "IterationErrorEmptyLabel": "Название не может быть пустым.", + "MoveItemsTo": "Переместить задачи в…", + "IterationRollupSummary": "{done} из {count} выполнено · {estimation} ч", + "ProjectFieldTypeIteration": "Итерация", + "Roadmap": "Дорожная карта", + "RoadmapDates": "Даты", + "RoadmapStartField": "Поле даты начала", + "RoadmapTargetField": "Поле целевой даты", + "RoadmapMilestoneStart": "Начало вехи", + "RoadmapMilestoneTarget": "Целевая дата вехи", + "RoadmapReadOnlySource": "Только чтение", + "RoadmapDatesHint": "Задачи только с одной датой показываются маркером. Задачи без дат перечислены в разделе «Без дат».", + "RoadmapMarkers": "Маркеры", + "RoadmapMarkerMilestones": "Вехи", + "RoadmapMarkerIterations": "Итерации", + "RoadmapMarkerDates": "Даты задач", + "RoadmapFields": "Поля", + "RoadmapZoomMonth": "Месяц", + "RoadmapZoomQuarter": "Квартал", + "RoadmapZoomYear": "Год", + "RoadmapToday": "Сегодня", + "RoadmapUnscheduled": "Без дат", + "RoadmapSetDates": "Задать даты", + "RoadmapNoValue": "Нет значения", + "RoadmapEmpty": "Нет элементов для отображения", + "RoadmapTruncated": "Показаны только первые {limit} элементов. Сузьте фильтр, чтобы увидеть остальные.", + "RoadmapUndo": "Отменить", + "RoadmapRescheduled": "Даты обновлены", + "RoadmapRescheduleFailed": "Не удалось обновить даты", + "RoadmapReadOnlyDates": "Эти даты нельзя изменить на дорожной карте", + "BoardColumnField": "Поле колонок", + "BoardHideColumn": "Скрыть колонку", + "BoardSetColumnLimit": "Задать лимит колонки", + "BoardEditColumnLimit": "Изменить лимит колонки", + "BoardRemoveColumnLimit": "Убрать лимит колонки", + "BoardColumnLimitPlaceholder": "Лимит задач", + "BoardColumnLimitExceeded": "Лимит колонки превышен: {count} из {limit}", + "BoardHiddenColumns": "Скрытые колонки", + "BoardShowColumn": "Показать", + "BoardShowHiddenColumns": "Показать скрытые колонки ({count})", + "BoardClearColumnLimits": "Убрать лимиты колонок ({count})", + "BoardCollapseLane": "Свернуть дорожку", + "BoardExpandLane": "Развернуть дорожку", + "SliceBy": "Срез по", + "SliceAll": "Все элементы", + "SliceClose": "Закрыть панель среза", + "SliceScanLimitExceeded": "Срез отключён: в этом представлении больше {limit} задач. Сузьте выборку другими фильтрами.", + "HierarchyView": "Иерархия", + "HierarchyProgress": "Выполнено подзадач: {done} из {total}", + "HierarchyScanLimitExceeded": "Иерархия подзадач отключена: в этом представлении больше {limit} задач. Сузьте выборку другими фильтрами.", + "FieldSum": "Сумма по полям", + "FieldSumTotal": "Итого", + "FieldSumScanLimitExceeded": "Итоги не показаны: в этом представлении больше {limit} задач. Сузьте выборку другими фильтрами.", + "Insights": "Аналитика", + "InsightsClose": "Назад к проекту", + "InsightChart": "График", + "InsightCharts": "Графики", + "InsightNewChart": "Новый график", + "InsightDefaultChartName": "Задачи по статусам", + "InsightChartNamePrefix": "График", + "InsightRename": "Переименовать график", + "InsightDuplicate": "Дублировать график", + "InsightDelete": "Удалить график", + "InsightDeleteConfirm": "Удалить график «{name}»? Это действие нельзя отменить.", + "InsightUnsaved": "Несохранённые изменения", + "InsightSave": "Сохранить изменения", + "InsightSaveAsNew": "Сохранить как новый график", + "InsightDiscard": "Отменить изменения", + "InsightConfigure": "Настройка", + "InsightLayout": "Тип графика", + "InsightLayoutBar": "Полосы", + "InsightLayoutColumn": "Столбцы", + "InsightLayoutStackedBar": "Полосы с накоплением", + "InsightLayoutStackedColumn": "Столбцы с накоплением", + "InsightLayoutStackedArea": "Области с накоплением", + "InsightLayoutLine": "Линия", + "InsightXAxis": "Ось X", + "InsightGroupBy": "Группировать по", + "InsightGroupByNone": "Нет", + "InsightYAxis": "Ось Y", + "InsightYCount": "Количество задач", + "InsightYSum": "Сумма", + "InsightYAverage": "Среднее", + "InsightYMinimum": "Минимум", + "InsightYMaximum": "Максимум", + "InsightYField": "Числовое поле", + "InsightYTitleSum": "Сумма: {field}", + "InsightYTitleAverage": "Среднее: {field}", + "InsightYTitleMinimum": "Минимум: {field}", + "InsightYTitleMaximum": "Максимум: {field}", + "InsightDateBucket": "Группировать даты по", + "InsightBucketDay": "День", + "InsightBucketWeek": "Неделя", + "InsightBucketMonth": "Месяц", + "InsightFilter": "Фильтр", + "InsightScanLimitExceeded": "График не построен: в нём больше {limit} задач. Сузьте выборку фильтром.", + "InsightNoData": "Нет задач, подходящих для этого графика", + "InsightItems": "Задач: {count}", + "InsightOpenIssues": "Нажмите, чтобы показать эти задачи", + "InsightFieldDeadline": "Крайний срок", + "InsightFieldRemoved": "Поля этого графика больше не существует, поэтому он построен с настройками по умолчанию.", + "InsightFilterInvalid": "Фильтр этого графика недействителен, поэтому график не построен.", + "ArchiveItem": "Архивировать", + "RestoreItem": "Восстановить", + "ArchivedItems": "Архивные элементы", + "ArchivedItemsEmpty": "Нет архивных элементов", + "ArchivedItemsHint": "Архивные элементы сохраняют значения полей. Они скрыты в представлениях, срезах и аналитике; чтобы увидеть их в представлении, используйте фильтр is:archived.", + "ArchivedItemsLimit": "Показаны первые {limit} из {total} архивных элементов", + "ArchivedAt": "Архивировано", + "ArchivedRestoreAll": "Восстановить все", + "ArchivedDeleteAll": "Удалить все", + "ArchivedDelete": "Удалить", + "ArchivedDeleteConfirm": "Безвозвратно удалить архивные элементы ({count})? Это действие нельзя отменить.", + "Workflow": "Автоматизация", + "Workflows": "Автоматизации", + "WorkflowsHint": "Встроенные автоматизации проекта. Их изменения выполняются от имени системного аккаунта и отображаются в активности как System.", + "WorkflowEnabled": "Включено", + "WorkflowSetStatusDoneOnClose": "Элемент закрыт", + "WorkflowSetStatusDoneOnCloseDescr": "Когда задача закрыта (выполнена или отменена), установить выбранное поле, по умолчанию Status, в значение Done.", + "WorkflowItemReopened": "Элемент открыт снова", + "WorkflowItemReopenedDescr": "Когда закрытая задача открыта снова, вернуть выбранное поле в значение по умолчанию для открытых задач, по умолчанию Todo.", + "WorkflowItemAdded": "Элемент добавлен в проект", + "WorkflowItemAddedDescr": "Когда создана задача, установить выбранное поле в значение по умолчанию, по умолчанию Todo, если в нём ещё нет значения.", + "WorkflowAutoArchive": "Автоархивация элементов", + "WorkflowAutoArchiveDescr": "Архивировать элементы, подходящие под фильтр, например закрытые элементы без изменений две недели. При включении архивируются и уже подходящие элементы.", + "WorkflowAutoAddFromQuery": "Автовозврат элементов", + "WorkflowAutoAddFromQueryDescr": "Задачи принадлежат одному проекту, поэтому из других проектов ничего не переносится. Автоматизация возвращает из архива элементы, подходящие под фильтр; если автоархивация снова заархивировала бы элемент, он остаётся в архиве.", + "WorkflowTargetField": "Поле", + "WorkflowTargetValue": "Значение", + "WorkflowTargetAutomatic": "Автоматически (поле Status)", + "WorkflowTargetResolved": "Устанавливает {field} в значение {value}", + "WorkflowTargetNone": "Нечего устанавливать: в проекте нужно поле с единственным выбором Status с таким значением, либо выберите поле.", + "WorkflowFilterRequired": "Введите фильтр. Без него эта автоматизация ничего не делает.", + "WorkflowFilterInvalid": "Фильтр некорректен, поэтому эта автоматизация пропускается.", + "WorkflowPreviewActive": "Сейчас подходит активных элементов: {count}", + "WorkflowPreviewArchived": "Сейчас подходит архивных элементов: {count}", + "WorkflowPreviewOver": "Подходит больше {count} элементов", + "WorkflowRunNote": "Автоматизации с фильтром запускаются при изменении автоматизации, при изменениях в проекте (не чаще раза в минуту) и при открытии проекта; таймера нет. За один запуск изменяется не более {limit} элементов.", + "ProjectWebhook": "Вебхук", + "ProjectWebhooks": "Вебхуки", + "WebhooksHint": "Получайте подписанное JSON-сообщение при изменении элемента. Подпись передаётся в заголовке X-Huly-Signature-256.", + "WebhookUrl": "URL для доставки", + "WebhookEvents": "События", + "WebhookSecret": "Секрет", + "WebhookSecretSet": "Секрет задан", + "WebhookSecretNew": "Новый секрет", + "WebhookSecretKeepPlaceholder": "Оставьте пустым, чтобы сохранить текущий секрет", + "WebhookSecretGenerate": "Сгенерировать", + "WebhookSecretNote": "Секрет только для записи: он хранится там, где его могут прочитать только вы и сервер, и больше не показывается. Скопируйте его сейчас.", + "WebhookEventCreated": "Элемент создан", + "WebhookEventEdited": "Элемент изменён (изменено значение поля)", + "WebhookEventArchived": "Элемент архивирован", + "WebhookEventRestored": "Элемент восстановлен", + "WebhookEventDeleted": "Элемент удалён", + "WebhookAdd": "Добавить вебхук", + "WebhookDeleteConfirm": "Удалить этот вебхук?", + "WebhookUrlRequired": "Введите URL", + "WebhookUrlInvalid": "URL некорректен", + "WebhookUrlNotHttps": "Допускаются только URL с https", + "WebhookUrlNotPublic": "URL должен указывать на публичный адрес, а не на локальную или частную сеть", + "WebhooksEmpty": "Нет вебхуков", + "WebhookLimit": "В проекте может быть не более {max} вебхуков", + "WebhookNoEvents": "Выберите хотя бы одно событие", + "WebhookSecretTooLong": "Секрет слишком длинный (не более {limit} символов)", + "ProjectShortDescription": "Краткое описание", + "ProjectShortDescriptionPlaceholder": "Добавьте краткое описание (до 256 символов)", + "ProjectShortDescriptionTooLong": "Краткое описание слишком длинное (не более {limit} символов)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Опишите проект, поддерживается Markdown", + "ProjectReadmeWrite": "Редактор", + "ProjectReadmePreview": "Предпросмотр", + "ProjectReadmeEmpty": "Нечего показывать", + "ProjectReadmeTooLong": "README слишком длинный", + "ProjectTemplate": "Шаблон", + "ProjectAbout": "О проекте", + "ProjectDetails": "Сведения о проекте", + "ProjectNoDescription": "Нет описания", + "ProjectStatus": "Статус проекта", + "ProjectStatusUpdate": "Обновление статуса", + "ProjectStatusUpdates": "Обновления статуса", + "ProjectStatusInactive": "Неактивен", + "ProjectStatusOnTrack": "По плану", + "ProjectStatusAtRisk": "Под угрозой", + "ProjectStatusOffTrack": "Выбился из плана", + "ProjectStatusComplete": "Завершён", + "ProjectStatusNone": "Нет статуса", + "NewProjectStatusUpdate": "Добавить обновление статуса", + "EditProjectStatusUpdate": "Изменить обновление статуса", + "ProjectStatusBodyPlaceholder": "Напишите обновление, поддерживается Markdown", + "ProjectStatusDelete": "Удалить обновление статуса", + "ProjectStatusDeleteConfirm": "Удалить это обновление статуса?", + "ProjectStatusNoUpdates": "Обновлений статуса пока нет", + "ProjectStatusDatesInvalid": "Целевая дата раньше даты начала", + "ProjectStatusBodyTooLong": "Обновление слишком длинное", + "ProjectSettings": "Настройки проекта", + "ProjectSettingsMenu": "Настройки", + "ProjectSettingsGeneral": "Основные", + "ProjectSettingsDangerZone": "Опасная зона", + "ProjectFieldSelectHint": "Выберите поле, чтобы изменить его настройки", + "ProjectClose": "Закрыть проект", + "ProjectCloseDescr": "Закрытый проект доступен только для чтения: он скрыт из списков проектов, в него нельзя добавлять задачи. Его можно открыть снова в любой момент.", + "ProjectCloseConfirm": "Закрыть этот проект?", + "ProjectReopen": "Открыть проект снова", + "ProjectReopenDescr": "Откройте проект снова, чтобы он появился в списках и его можно было изменять.", + "ProjectVisibility": "Видимость", + "ProjectVisibilityDescr": "Приватный проект виден только его участникам, публичный проект виден всем в рабочем пространстве.", + "ProjectVisibilityPrivate": "Приватный", + "ProjectVisibilityPublic": "Публичный", + "ProjectVisibilityNeedsMembers": "Приватному проекту нужны участники, и среди них владелец", + "ProjectMakeTemplate": "Сделать шаблоном", + "ProjectUnmakeTemplate": "Убрать из шаблонов", + "ProjectTemplateDescr": "Шаблон можно использовать для создания новых проектов с его полями, представлениями, воркфлоу и диаграммами.", + "ProjectDeleteForever": "Удалить этот проект", + "ProjectDeleteDescr": "Удаление проекта удаляет все его задачи, поля, представления и всё остальное. Это действие нельзя отменить.", + "ProjectDeleteTypeName": "Введите название проекта {name} для подтверждения", + "ProjectNoPermission": "У вас нет прав на это изменение", + "CopyProject": "Копировать проект", + "ProjectCopyName": "Копия {name}", + "ProjectFromTemplate": "Создать из шаблона", + "ProjectNoTemplate": "Пустой проект", + "ProjectIncludeIssues": "Включить задачи", + "ProjectIncludeIssuesHint": "Задачи не копируются. Копия получает поля, представления, воркфлоу и диаграммы проекта.", + "ProjectItemLimitReached": "В проекте максимально допустимое число элементов ({limit}). Удалите часть, чтобы добавить новые.", + "ProjectSettingsClosedNote": "Этот проект закрыт", + "Draft": "Черновик", + "DraftItemTooltip": "Черновик ещё не является задачей. Преобразуйте его в задачу, чтобы он получил номер.", + "ConvertToIssue": "Преобразовать в задачу", + "AddItemPlaceholder": "Добавить элемент: введите название и нажмите Enter или введите #, чтобы найти задачу", + "AddItemTooLong": "Название может содержать не более {limit} символов", + "AddItemFailed": "Не удалось добавить элемент", + "AddItemNoIssues": "Задачи не найдены", + "AddItemInProject": "Уже в этом проекте", + "AddItemArchived": "В архиве: нажмите, чтобы восстановить", + "AddItemMoveHere": "Переместить сюда", + "AddItemMoveHint": "Задача принадлежит одному проекту. Выбранная задача другого проекта того же типа будет перемещена сюда.", + "AddItemOtherType": "Нельзя переместить сюда", + "ExportViewData": "Экспортировать данные представления", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "Не удалось экспортировать данные представления", + "CopyViewLink": "Скопировать ссылку на представление", + "Calendar": "Календарь", + "CalendarMonth": "Месяц", + "CalendarWeek": "Неделя", + "CalendarAgenda": "Повестка", + "CalendarPrevious": "Назад", + "CalendarNext": "Вперёд", + "CalendarNoDateSource": "Нет", + "CalendarDatesHint": "Элементы с двумя датами показаны полосами по дням, элементы с одной датой чипами, а элементы без дат перечислены в разделе «Без дат».", + "CalendarMore": "Ещё {count}", + "CalendarAddItemOn": "Добавить элемент на {date}", + "CalendarDayItems": "Элементы на {date}", + "CalendarAgendaEmpty": "В этом периоде нет элементов", + "CalendarUnscheduledCount": "Без дат ({count})", + "CalendarUnscheduledHint": "Перетащите элемент на день, чтобы запланировать его.", + "CalendarUnscheduledMore": "Ещё {count} элементов не показано. Сузьте фильтр, чтобы увидеть их.", + "CalendarAddNeedsDates": "Нельзя добавить элемент на день, пока представление берёт даты из вехи", + "Workload": "Загрузка", + "WorkloadMeasure": "Мера загрузки", + "WorkloadMeasureCount": "Количество элементов", + "WorkloadZoomDay": "День", + "WorkloadCapacity": "Ёмкость в день", + "WorkloadCapacityHint": "Сколько один человек может взять в рабочий день, в единицах меры загрузки (часы для оценки и оставшегося времени). Рабочие дни берутся из настроек проекта.", + "WorkloadDatesHint": "Загрузка элемента равномерно распределяется по рабочим дням между его датами. Элемент с одной датой целиком попадает на этот день. Элементы без дат перечислены в разделе «Без дат».", + "WorkloadUtilization": "Загрузка {percent}", + "WorkloadOverBuckets": "Перегрузка: {count}", + "WorkloadNoCapacity": "нет ёмкости", + "WorkloadStateUnder": "Загрузка ниже ёмкости", + "WorkloadStateNear": "Загрузка близка к ёмкости", + "WorkloadStateOver": "Перегрузка", + "WorkloadCellLabel": "{person}, {period}: загрузка {load} из {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, без дат: загрузка {load}, {count, plural, =1 {1 элемент} one {# элемент} few {# элемента} many {# элементов} other {# элемента}}", + "WorkloadNoLoadItems": "{count, plural, =1 {У 1 элемента нет} one {У # элемента нет} few {У # элементов нет} many {У # элементов нет} other {У # элементов нет}} значения для меры загрузки", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Перетащите элемент на другого человека, чтобы переназначить его.", + "WorkloadPanelClose": "Закрыть список элементов", + "WorkloadReassign": "Переназначить", + "WorkloadReassigned": "Исполнитель изменён", + "WorkloadReassignFailed": "Не удалось изменить исполнителя", + "WorkloadOutOfRange": "Запланированных элементов вне показанного периода: {count}, они не учитываются. Выберите более крупный масштаб, чтобы увидеть их." }, "status": {} } diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index 34aa6ad5d5..c0c3094d2d 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "Tracker", "Projects": "Projeleriniz", "More": "Daha fazla", @@ -544,7 +603,309 @@ "SearchEmptyTitle": "\"{query}\" için sorun bulunamadı", "SearchEmptyActiveFilters": "Aktif filtreler: {filters}", "SearchEmptyClearFilters": "Filtresiz ara", - "SearchEmptyAllProjects": "Tüm projelerde ara" + "SearchEmptyAllProjects": "Tüm projelerde ara", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 09b80894b2..6f36862a63 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -1,5 +1,64 @@ { "string": { + "RowHeight": "Row height", + "RowHeightCompact": "Compact", + "RowHeightDefault": "Default", + "RowHeightComfortable": "Comfortable", + "CustomFieldFilter": "Field filter", + "AddFieldFilter": "Add field filter", + "NoFieldFilters": "No custom field filters", + "ClearFieldFilters": "Clear all", + "RemoveFieldFilter": "Remove filter", + "FieldFilterUnknownField": "Unknown field", + "FieldFilterValue": "Value", + "FieldFilterFrom": "From", + "FieldFilterTo": "To", + "FieldFilterOpContains": "contains", + "FieldFilterOpEq": "equals", + "FieldFilterOpGt": "greater than", + "FieldFilterOpGte": "greater than or equal", + "FieldFilterOpLt": "less than", + "FieldFilterOpLte": "less than or equal", + "FieldFilterOpBetween": "between", + "FieldFilterOpBefore": "before", + "FieldFilterOpAfter": "after", + "FieldFilterOpAnyOf": "is any of", + "FieldFilterOpIsEmpty": "is empty", + "FieldFilterOpIsNotEmpty": "is not empty", + "CustomFieldScanLimitExceeded": "Filtering, sorting and grouping by custom fields are turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "NoFieldValue": "No {field}", + "ProjectField": "Field", + "ProjectFields": "Fields", + "CustomFields": "Custom fields", + "FieldKey": "Key", + "FieldType": "Type", + "FieldDefaultValue": "Default value", + "FieldOptions": "Options", + "FieldEmptyValue": "Empty", + "NewProjectField": "New field", + "EditProjectField": "Edit field", + "DeleteProjectField": "Delete field {name}?", + "DeleteProjectFieldConfirm": "The field and its values on all issues will be deleted.", + "NoProjectFields": "No fields yet", + "ProjectFieldNamePlaceholder": "Field name", + "ProjectFieldDescriptionPlaceholder": "Description", + "ProjectFieldTypeText": "Text", + "ProjectFieldTypeNumber": "Number", + "ProjectFieldTypeDate": "Date", + "ProjectFieldTypeSingleSelect": "Single select", + "ProjectFieldTypeMultiSelect": "Multi select", + "AddProjectFieldOption": "Add option", + "ProjectFieldOptionLabel": "Option", + "ProjectFieldOptionDescription": "Description", + "ProjectFieldErrorEmptyLabel": "Field name is required", + "ProjectFieldErrorDuplicateLabel": "A field with this name already exists", + "ProjectFieldErrorTooManyFields": "A project can have at most 50 fields", + "ProjectFieldErrorTooManyOptions": "A field can have at most 50 options", + "ProjectFieldErrorDuplicateOption": "Option names must be unique", + "ProjectFieldErrorEmptyOption": "Option names cannot be empty", + "ProjectFieldErrorOptionsRequired": "Add at least one option", + "ProjectFieldErrorDefaultNotAllowed": "This field type does not support a default value", + "ProjectFieldErrorInvalidDefault": "Invalid default value", "TrackerApplication": "追踪器", "Projects": "您的项目", "More": "更多", @@ -323,7 +382,309 @@ "SearchEmptyTitle": "未找到与 \"{query}\" 匹配的问题", "SearchEmptyActiveFilters": "活动筛选器:{filters}", "SearchEmptyClearFilters": "不使用筛选器搜索", - "SearchEmptyAllProjects": "在所有项目中搜索" + "SearchEmptyAllProjects": "在所有项目中搜索", + "Iteration": "Iteration", + "Iterations": "Iterations", + "IterationDuration": "Duration", + "IterationBreak": "Break", + "IterationTitlePlaceholder": "Iteration title", + "IterationStartsOn": "Starts on", + "IterationDurationDays": "days", + "IterationDurationWeeks": "weeks", + "IterationCurrentMarker": "current", + "IterationCurrent": "Current iteration", + "IterationNext": "Next iteration", + "IterationPrevious": "Previous iteration", + "NoIteration": "No iteration", + "NoIterations": "No iterations yet", + "AddIteration": "Add iteration", + "InsertIterationBreak": "Insert break after", + "DeleteIteration": "Delete iteration", + "DeleteIterationConfirm": "Issues in \"{name}\" will have no iteration.", + "UpcomingIterations": "Current and upcoming iterations", + "CompletedIterations": "Completed iterations", + "IterationSavedImmediately": "Changes to iterations are saved immediately. Changing a duration or a start date moves the later iterations.", + "IterationErrorOverlap": "An iteration cannot start before the previous one ends.", + "IterationErrorInvalidDuration": "The duration must be a whole number of days, at least 1.", + "IterationErrorEmptyLabel": "The title cannot be empty.", + "MoveItemsTo": "Move items to…", + "IterationRollupSummary": "{done} of {count} done · {estimation} h", + "ProjectFieldTypeIteration": "Iteration", + "Roadmap": "Roadmap", + "RoadmapDates": "Dates", + "RoadmapStartField": "Start date field", + "RoadmapTargetField": "Target date field", + "RoadmapMilestoneStart": "Milestone start", + "RoadmapMilestoneTarget": "Milestone target date", + "RoadmapReadOnlySource": "Read only", + "RoadmapDatesHint": "Items with only one date are shown as a marker. Items without dates are listed under Unscheduled.", + "RoadmapMarkers": "Markers", + "RoadmapMarkerMilestones": "Milestones", + "RoadmapMarkerIterations": "Iterations", + "RoadmapMarkerDates": "Dates of items", + "RoadmapFields": "Fields", + "RoadmapZoomMonth": "Month", + "RoadmapZoomQuarter": "Quarter", + "RoadmapZoomYear": "Year", + "RoadmapToday": "Today", + "RoadmapUnscheduled": "Unscheduled", + "RoadmapSetDates": "Set dates", + "RoadmapNoValue": "No value", + "RoadmapEmpty": "There are no items to show", + "RoadmapTruncated": "Only the first {limit} items are shown. Narrow the filter to see the rest.", + "RoadmapUndo": "Undo", + "RoadmapRescheduled": "Dates updated", + "RoadmapRescheduleFailed": "Could not update the dates", + "RoadmapReadOnlyDates": "These dates cannot be changed in the roadmap", + "BoardColumnField": "Column field", + "BoardHideColumn": "Hide column", + "BoardSetColumnLimit": "Set column limit", + "BoardEditColumnLimit": "Edit column limit", + "BoardRemoveColumnLimit": "Remove column limit", + "BoardColumnLimitPlaceholder": "Item limit", + "BoardColumnLimitExceeded": "The column limit is exceeded: {count} of {limit}", + "BoardHiddenColumns": "Hidden columns", + "BoardShowColumn": "Show", + "BoardShowHiddenColumns": "Show hidden columns ({count})", + "BoardClearColumnLimits": "Clear column limits ({count})", + "BoardCollapseLane": "Collapse swimlane", + "BoardExpandLane": "Expand swimlane", + "SliceBy": "Slice by", + "SliceAll": "All items", + "SliceClose": "Close slice panel", + "SliceScanLimitExceeded": "Slicing is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "HierarchyView": "Hierarchy", + "HierarchyProgress": "{done} of {total} sub-issues done", + "HierarchyScanLimitExceeded": "Sub-issue hierarchy is turned off: this view has more than {limit} issues. Narrow it down with other filters.", + "FieldSum": "Field sum", + "FieldSumTotal": "Total", + "FieldSumScanLimitExceeded": "Totals are not shown: this view has more than {limit} issues. Narrow it down with other filters.", + "Insights": "Insights", + "InsightsClose": "Back to project", + "InsightChart": "Chart", + "InsightCharts": "Charts", + "InsightNewChart": "New chart", + "InsightDefaultChartName": "Issues by status", + "InsightChartNamePrefix": "Chart", + "InsightRename": "Rename chart", + "InsightDuplicate": "Duplicate chart", + "InsightDelete": "Delete chart", + "InsightDeleteConfirm": "Delete the chart \"{name}\"? This cannot be undone.", + "InsightUnsaved": "Unsaved changes", + "InsightSave": "Save changes", + "InsightSaveAsNew": "Save to new chart", + "InsightDiscard": "Discard changes", + "InsightConfigure": "Configure", + "InsightLayout": "Layout", + "InsightLayoutBar": "Bar", + "InsightLayoutColumn": "Column", + "InsightLayoutStackedBar": "Stacked bar", + "InsightLayoutStackedColumn": "Stacked column", + "InsightLayoutStackedArea": "Stacked area", + "InsightLayoutLine": "Line", + "InsightXAxis": "X-axis", + "InsightGroupBy": "Group by", + "InsightGroupByNone": "None", + "InsightYAxis": "Y-axis", + "InsightYCount": "Count of items", + "InsightYSum": "Sum", + "InsightYAverage": "Average", + "InsightYMinimum": "Minimum", + "InsightYMaximum": "Maximum", + "InsightYField": "Number field", + "InsightYTitleSum": "Sum of {field}", + "InsightYTitleAverage": "Average of {field}", + "InsightYTitleMinimum": "Minimum of {field}", + "InsightYTitleMaximum": "Maximum of {field}", + "InsightDateBucket": "Group dates by", + "InsightBucketDay": "Day", + "InsightBucketWeek": "Week", + "InsightBucketMonth": "Month", + "InsightFilter": "Filter", + "InsightScanLimitExceeded": "The chart is not drawn: it has more than {limit} issues. Narrow it down with a filter.", + "InsightNoData": "No issues match this chart", + "InsightItems": "{count} issues", + "InsightOpenIssues": "Click to show these issues", + "InsightFieldDeadline": "Deadline", + "InsightFieldRemoved": "A field of this chart no longer exists, it is drawn with the default settings instead.", + "InsightFilterInvalid": "The filter of this chart is not valid, so the chart is not drawn.", + "ArchiveItem": "Archive", + "RestoreItem": "Restore", + "ArchivedItems": "Archived items", + "ArchivedItemsEmpty": "No archived items", + "ArchivedItemsHint": "Archived items keep their field values. They are hidden from views, slices and insights; use the filter is:archived to see them in a view.", + "ArchivedItemsLimit": "Showing the first {limit} of {total} archived items", + "ArchivedAt": "Archived", + "ArchivedRestoreAll": "Restore all", + "ArchivedDeleteAll": "Delete all", + "ArchivedDelete": "Delete", + "ArchivedDeleteConfirm": "Permanently delete {count} archived items? This cannot be undone.", + "Workflow": "Workflow", + "Workflows": "Workflows", + "WorkflowsHint": "Built-in automations of the project. Their changes are made by the system account, so they show as System in the activity.", + "WorkflowEnabled": "Enabled", + "WorkflowSetStatusDoneOnClose": "Item closed", + "WorkflowSetStatusDoneOnCloseDescr": "When an issue is closed (done or canceled), set the chosen field, by default Status, to Done.", + "WorkflowItemReopened": "Item reopened", + "WorkflowItemReopenedDescr": "When a closed issue is reopened, set the chosen field back to the default open value, by default Todo.", + "WorkflowItemAdded": "Item added to project", + "WorkflowItemAddedDescr": "When an issue is created, set the chosen field to the default value, by default Todo, unless it already has a value.", + "WorkflowAutoArchive": "Auto-archive items", + "WorkflowAutoArchiveDescr": "Archive the items that match the filter, for example closed items not updated for two weeks. Items that already match are archived when this is turned on.", + "WorkflowAutoAddFromQuery": "Auto-add items", + "WorkflowAutoAddFromQueryDescr": "Issues belong to one project, so nothing is pulled in from other projects. This restores archived items that match the filter; when Auto-archive would archive an item again, it stays archived.", + "WorkflowTargetField": "Field", + "WorkflowTargetValue": "Value", + "WorkflowTargetAutomatic": "Automatic (field Status)", + "WorkflowTargetResolved": "Sets {field} to {value}", + "WorkflowTargetNone": "Nothing to set: the project needs a single select field called Status with this value, or choose a field.", + "WorkflowFilterRequired": "Enter a filter. Without one this workflow does nothing.", + "WorkflowFilterInvalid": "The filter is not valid, so this workflow is skipped.", + "WorkflowPreviewActive": "{count} active items match now", + "WorkflowPreviewArchived": "{count} archived items match now", + "WorkflowPreviewOver": "More than {count} items match", + "WorkflowRunNote": "Filter workflows run when a workflow changes, when the project changes (at most once a minute) and when the project is opened; there is no timer. At most {limit} items are changed per run.", + "ProjectWebhook": "Webhook", + "ProjectWebhooks": "Webhooks", + "WebhooksHint": "Receive a signed JSON message when an item changes. The signature is sent in the X-Huly-Signature-256 header.", + "WebhookUrl": "Payload URL", + "WebhookEvents": "Events", + "WebhookSecret": "Secret", + "WebhookSecretSet": "A secret is set", + "WebhookSecretNew": "New secret", + "WebhookSecretKeepPlaceholder": "Leave empty to keep the current secret", + "WebhookSecretGenerate": "Generate", + "WebhookSecretNote": "The secret is write-only: it is kept where only you and the server can read it and is never shown again. Copy it now.", + "WebhookEventCreated": "Item created", + "WebhookEventEdited": "Item edited (field value changed)", + "WebhookEventArchived": "Item archived", + "WebhookEventRestored": "Item restored", + "WebhookEventDeleted": "Item deleted", + "WebhookAdd": "Add webhook", + "WebhookDeleteConfirm": "Delete this webhook?", + "WebhookUrlRequired": "Enter the URL", + "WebhookUrlInvalid": "The URL is not valid", + "WebhookUrlNotHttps": "Only https URLs are allowed", + "WebhookUrlNotPublic": "The URL must point to a public address, not to a local or private network", + "WebhooksEmpty": "No webhooks", + "WebhookLimit": "A project can have {max} webhooks", + "WebhookNoEvents": "Choose at least one event", + "WebhookSecretTooLong": "The secret is too long (at most {limit} characters)", + "ProjectShortDescription": "Short description", + "ProjectShortDescriptionPlaceholder": "Add a short description (up to 256 characters)", + "ProjectShortDescriptionTooLong": "The short description is too long (at most {limit} characters)", + "ProjectReadme": "README", + "ProjectReadmePlaceholder": "Describe the project, Markdown is supported", + "ProjectReadmeWrite": "Write", + "ProjectReadmePreview": "Preview", + "ProjectReadmeEmpty": "Nothing to preview", + "ProjectReadmeTooLong": "The README is too long", + "ProjectTemplate": "Template", + "ProjectAbout": "About", + "ProjectDetails": "Project details", + "ProjectNoDescription": "No description", + "ProjectStatus": "Project status", + "ProjectStatusUpdate": "Status update", + "ProjectStatusUpdates": "Status updates", + "ProjectStatusInactive": "Inactive", + "ProjectStatusOnTrack": "On track", + "ProjectStatusAtRisk": "At risk", + "ProjectStatusOffTrack": "Off track", + "ProjectStatusComplete": "Complete", + "ProjectStatusNone": "No status", + "NewProjectStatusUpdate": "Add status update", + "EditProjectStatusUpdate": "Edit status update", + "ProjectStatusBodyPlaceholder": "Write an update, Markdown is supported", + "ProjectStatusDelete": "Delete status update", + "ProjectStatusDeleteConfirm": "Delete this status update?", + "ProjectStatusNoUpdates": "No status updates yet", + "ProjectStatusDatesInvalid": "The target date is before the start date", + "ProjectStatusBodyTooLong": "The update is too long", + "ProjectSettings": "Project settings", + "ProjectSettingsMenu": "Settings", + "ProjectSettingsGeneral": "General", + "ProjectSettingsDangerZone": "Danger zone", + "ProjectFieldSelectHint": "Select a field to edit its settings", + "ProjectClose": "Close project", + "ProjectCloseDescr": "A closed project is read-only: it is hidden from the project lists and no issues can be added to it. You can reopen it at any time.", + "ProjectCloseConfirm": "Close this project?", + "ProjectReopen": "Reopen project", + "ProjectReopenDescr": "Reopen the project so that it is listed again and can be edited.", + "ProjectVisibility": "Visibility", + "ProjectVisibilityDescr": "A private project is visible only to its members, a public project is visible to everyone in the workspace.", + "ProjectVisibilityPrivate": "Private", + "ProjectVisibilityPublic": "Public", + "ProjectVisibilityNeedsMembers": "A private project needs members, and an owner among them", + "ProjectMakeTemplate": "Make template", + "ProjectUnmakeTemplate": "Unmake template", + "ProjectTemplateDescr": "A template can be used to create new projects with its fields, views, workflows and charts.", + "ProjectDeleteForever": "Delete this project", + "ProjectDeleteDescr": "Deleting a project removes all of its issues, fields, views and everything else in it. This cannot be undone.", + "ProjectDeleteTypeName": "Type the project name {name} to confirm", + "ProjectNoPermission": "You do not have permission to change this", + "CopyProject": "Copy project", + "ProjectCopyName": "Copy of {name}", + "ProjectFromTemplate": "Create from template", + "ProjectNoTemplate": "Blank project", + "ProjectIncludeIssues": "Include issues", + "ProjectIncludeIssuesHint": "Issues are not copied. The copy gets the fields, views, workflows and charts of the project.", + "ProjectItemLimitReached": "This project holds the most items a project can ({limit}). Delete some to add more.", + "ProjectSettingsClosedNote": "This project is closed", + "Draft": "Draft", + "DraftItemTooltip": "A draft item is not an issue yet. Convert it to an issue to give it a number.", + "ConvertToIssue": "Convert to issue", + "AddItemPlaceholder": "Add item: type a title and press Enter, or type # to find an issue", + "AddItemTooLong": "A title can have at most {limit} characters", + "AddItemFailed": "The item could not be added", + "AddItemNoIssues": "No issues found", + "AddItemInProject": "Already in this project", + "AddItemArchived": "Archived: click to restore", + "AddItemMoveHere": "Move here", + "AddItemMoveHint": "An issue belongs to one project. Picking an issue of another project of the same type moves it here.", + "AddItemOtherType": "Cannot be moved here", + "ExportViewData": "Export view data", + "ExportColumnUrl": "URL", + "ExportViewDataFailed": "The view data could not be exported", + "CopyViewLink": "Copy link to view", + "Calendar": "Calendar", + "CalendarMonth": "Month", + "CalendarWeek": "Week", + "CalendarAgenda": "Agenda", + "CalendarPrevious": "Previous", + "CalendarNext": "Next", + "CalendarNoDateSource": "None", + "CalendarDatesHint": "Items with both dates are bars across the days. Items with one date are chips. Items without dates are listed under Unscheduled.", + "CalendarMore": "+{count} more", + "CalendarAddItemOn": "Add item on {date}", + "CalendarDayItems": "Items on {date}", + "CalendarAgendaEmpty": "There are no items in this period", + "CalendarUnscheduledCount": "Unscheduled ({count})", + "CalendarUnscheduledHint": "Drag an item onto a day to schedule it.", + "CalendarUnscheduledMore": "{count} more items are not listed. Narrow the filter to see them.", + "CalendarAddNeedsDates": "Items cannot be added to a day while the view takes its dates from the milestone", + "Workload": "Workload", + "WorkloadMeasure": "Load measure", + "WorkloadMeasureCount": "Item count", + "WorkloadZoomDay": "Day", + "WorkloadCapacity": "Capacity per day", + "WorkloadCapacityHint": "What one person can take on a working day, in the unit of the load measure (hours for the estimate and the remaining time). The working days follow the settings of the project.", + "WorkloadDatesHint": "The load of an item is spread evenly over the working days between its dates. An item with one date puts all of its load on that day. Items without dates are listed under Unscheduled.", + "WorkloadUtilization": "{percent} utilization", + "WorkloadOverBuckets": "{count} over capacity", + "WorkloadNoCapacity": "no capacity", + "WorkloadStateUnder": "Under capacity", + "WorkloadStateNear": "Near capacity", + "WorkloadStateOver": "Over capacity", + "WorkloadCellLabel": "{person}, {period}: load {load} of {capacity} ({percent}), {state}", + "WorkloadUnscheduledCellLabel": "{person}, unscheduled: load {load}, {count, plural, =1 {1 item} other {# items}}", + "WorkloadNoLoadItems": "{count, plural, =1 {1 item has} other {# items have}} no value for the load measure", + "WorkloadPanelTitle": "{person}: {period}", + "WorkloadPanelHint": "Drag an item onto another person to reassign it.", + "WorkloadPanelClose": "Close the list of items", + "WorkloadReassign": "Reassign", + "WorkloadReassigned": "Assignee changed", + "WorkloadReassignFailed": "Could not change the assignee", + "WorkloadOutOfRange": "{count, plural, =1 {1 scheduled item is} other {# scheduled items are}} outside the period that is shown and not counted. Choose a longer zoom to see {count, plural, =1 {it} other {them}}." }, "status": {} } diff --git a/plugins/tracker-assets/src/index.ts b/plugins/tracker-assets/src/index.ts index 4c64fa48f9..a0e4771005 100644 --- a/plugins/tracker-assets/src/index.ts +++ b/plugins/tracker-assets/src/index.ts @@ -65,5 +65,8 @@ loadMetadata(tracker.icon, { Duplicate: `${icons}#duplicate`, TimeReport: `${icons}#timeReport`, Estimation: `${icons}#estimation`, - Gantt: `${icons}#timeline` + Gantt: `${icons}#timeline`, + Roadmap: `${icons}#roadmap`, + Calendar: `${icons}#calendar-layout`, + Workload: `${icons}#workload-layout` }) diff --git a/plugins/tracker-resources/jest.config.js b/plugins/tracker-resources/jest.config.js index 4b4c455c23..ea16d50280 100644 --- a/plugins/tracker-resources/jest.config.js +++ b/plugins/tracker-resources/jest.config.js @@ -7,6 +7,8 @@ module.exports = { // tests that only need constants (PaletteColorIndexes, etc.) don't pull // in the svelte/store transitive import from the main index. // Tests that need richer UI stubs override this via their own jest.mock(). - '^@hcengineering/ui$': '/node_modules/@hcengineering/ui/src/colors.ts' + '^@hcengineering/ui$': '/node_modules/@hcengineering/ui/src/colors.ts', + // The filter grammar is a pure module of view-resources; the package index pulls in svelte components + '^@hcengineering/view-resources$': '/src/__mocks__/view-resources.js' } } diff --git a/plugins/tracker-resources/src/__mocks__/view-resources.js b/plugins/tracker-resources/src/__mocks__/view-resources.js new file mode 100644 index 0000000000..76f100827f --- /dev/null +++ b/plugins/tracker-resources/src/__mocks__/view-resources.js @@ -0,0 +1,14 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +const path = require('path') + +// Jest stand-in for @hcengineering/view-resources: only the pure modules (filter grammar, table editing), +// without the svelte components. +// The path is built at run time so that the type checkers do not pull view-resources sources into this package. +module.exports = { + filterGrammar: require(path.resolve(__dirname, '../../../view-resources/src/filter/grammar')), + tableEdit: require(path.resolve(__dirname, '../../../view-resources/src/tableEdit')) +} diff --git a/plugins/tracker-resources/src/__tests__/issueFilter.test.ts b/plugins/tracker-resources/src/__tests__/issueFilter.test.ts new file mode 100644 index 0000000000..dc6cfeaaed --- /dev/null +++ b/plugins/tracker-resources/src/__tests__/issueFilter.test.ts @@ -0,0 +1,256 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, toIterationRanges, type ProjectField } from '@hcengineering/tracker' +import { filterGrammar } from '@hcengineering/view-resources' +import { buildIssueFilterSchema, customFilterToQuery, fieldFilterName } from '../issueFilter' +import { buildFiltersPredicate, endOfDay, startOfDay, type CustomFieldFilter } from '../projectFields/query' +import { buildRegistry } from '../projectFields/registry' + +function field (key: string, label: string, type: ProjectFieldType, options?: Array<[string, string]>): ProjectField { + return { + _id: key, + key, + label, + type, + position: 0, + options: options?.map(([value, l]) => ({ value, label: l })) + } as unknown as ProjectField +} + +const fields = [ + field('storyPoints', 'Story points', ProjectFieldType.Number), + field('notes', 'Notes', ProjectFieldType.Text), + field('target', 'Target date', ProjectFieldType.Date), + field('size', 'Size', ProjectFieldType.SingleSelect, [ + ['s', 'Small'], + ['l', 'Extra large'] + ]), + field('area', 'Area', ProjectFieldType.MultiSelect, [ + ['fe', 'Frontend'], + ['be', 'Backend'] + ]), + field('status', 'Status', ProjectFieldType.Text) +] + +const { compileFilter, evaluate, parseFilter } = filterGrammar + +const schema = buildIssueFilterSchema({ + statuses: [{ id: 's1', name: 'Todo' }], + priorities: [{ id: 1, name: 'Urgent' }], + assignees: [{ id: 'p1', name: 'Alice' }], + components: [], + milestones: [], + labels: [{ id: 'l1', name: 'bug' }], + labelRefs: [{ issue: 'i1', label: 'l1' }], + customFields: fields, + noParentId: 'no-parent' +}) + +describe('fieldFilterName', () => { + it('lowercases and hyphenates', () => { + expect(fieldFilterName('Story Points', 'storyPoints')).toBe('story-points') + }) + + it('supports non-latin labels', () => { + expect(fieldFilterName('Оценка работ', 'ocenkaRabot')).toBe('оценка-работ') + }) + + it('falls back to the key for labels that are not valid names', () => { + expect(fieldFilterName('2 weeks', 'f2Weeks')).toBe('f2weeks') + expect(fieldFilterName('???', 'fieldX')).toBe('fieldx') + }) +}) + +describe('buildIssueFilterSchema', () => { + it('contains the built-in fields and the custom fields', () => { + const names = schema.map((f) => f.name) + expect(names).toEqual(expect.arrayContaining(['title', 'status', 'priority', 'assignee', 'label', 'parent-issue'])) + expect(names).toEqual(expect.arrayContaining(['story-points', 'notes', 'target-date', 'size', 'area'])) + }) + + it('keeps built-in fields when a custom field has the same name', () => { + expect(schema.filter((f) => f.name === 'status')).toHaveLength(1) + expect(schema.find((f) => f.name === 'status')?.source).toBe('attribute') + }) + + it('maps custom field types and options', () => { + const size = schema.find((f) => f.name === 'size') + expect(size).toMatchObject({ type: 'select', source: 'custom', key: 'size' }) + expect(size?.options).toEqual([ + { id: 's', name: 'Small' }, + { id: 'l', name: 'Extra large' } + ]) + expect(schema.find((f) => f.name === 'area')?.type).toBe('multi') + expect(schema.find((f) => f.name === 'target-date')?.type).toBe('date') + }) + + it('resolves labels through the tag references', () => { + const label = schema.find((f) => f.name === 'label') + expect(label?.resolveDocIds?.(['l1'])).toEqual(['i1']) + expect(label?.read?.({ _id: 'i1' })).toEqual(['l1']) + expect(label?.read?.({ _id: 'other' })).toEqual([]) + }) + + it('parses filter strings against the schema', () => { + expect(parseFilter('story-points:>3 target-date:@today size:"extra large" label:bug', schema).ok).toBe(true) + }) +}) + +describe('customFilterToQuery', () => { + const registry = buildRegistry(fields) + const rule = (fieldKey: string, operator: any, value?: any): CustomFieldFilter => ({ + id: fieldKey + operator, + fieldKey, + operator, + value + }) + + it('writes each operator in grammar syntax', () => { + const q = (r: CustomFieldFilter): string => customFilterToQuery([r], registry.byKey) + expect(q(rule('storyPoints', 'eq', 5))).toBe('story-points:5') + expect(q(rule('storyPoints', 'gt', 5))).toBe('story-points:>5') + expect(q(rule('storyPoints', 'lte', 5))).toBe('story-points:<=5') + expect(q(rule('storyPoints', 'between', { from: 1, to: 3 }))).toBe('story-points:1..3') + expect(q(rule('storyPoints', 'between', { from: 1 }))).toBe('story-points:1..*') + expect(q(rule('notes', 'contains', ' hello world '))).toBe('notes:"hello world"') + expect(q(rule('size', 'anyOf', ['s', 'l']))).toBe('size:Small,"Extra large"') + expect(q(rule('notes', 'isEmpty'))).toBe('no:notes') + expect(q(rule('notes', 'isNotEmpty'))).toBe('has:notes') + }) + + it('writes dates as calendar days', () => { + const d = new Date(2026, 0, 5, 15).getTime() + expect(customFilterToQuery([rule('target', 'before', d)], registry.byKey)).toBe('target-date:<2026-01-05') + expect(customFilterToQuery([rule('target', 'after', d)], registry.byKey)).toBe('target-date:>2026-01-05') + expect(customFilterToQuery([rule('target', 'between', { from: d, to: d })], registry.byKey)).toBe( + 'target-date:2026-01-05..2026-01-05' + ) + }) + + it('skips incomplete rules and unknown fields and joins with AND', () => { + const q = customFilterToQuery( + [rule('storyPoints', 'gt'), rule('gone', 'eq', 1), rule('storyPoints', 'eq', 2), rule('notes', 'isEmpty')], + registry.byKey + ) + expect(q).toBe('story-points:2 no:notes') + }) + + it('selects the same issues as the rules did', () => { + const day = new Date(2026, 5, 10, 9).getTime() + const issues: any[] = [ + { _id: 'a', customFields: { storyPoints: 3, notes: 'Hello World', target: day, size: 's', area: ['fe'] } }, + { _id: 'b', customFields: { storyPoints: 8, notes: 'x', target: endOfDay(day) + 1, size: 'l', area: [] } }, + { _id: 'c', customFields: { storyPoints: 5, target: startOfDay(day) - 1 } }, + { _id: 'd' } + ] + const ruleSets: CustomFieldFilter[][] = [ + [rule('storyPoints', 'gt', 3)], + [rule('storyPoints', 'between', { from: 3, to: 5 })], + [rule('notes', 'contains', 'hello')], + [rule('size', 'anyOf', ['s', 'l'])], + [rule('area', 'anyOf', ['fe'])], + [rule('area', 'isEmpty')], + [rule('target', 'before', day)], + [rule('target', 'after', day)], + [rule('target', 'between', { from: day, to: day })], + [rule('storyPoints', 'gte', 5), rule('notes', 'isNotEmpty')] + ] + const ctx = { now: day } + for (const rules of ruleSets) { + const legacy = buildFiltersPredicate(registry.byKey, rules) + const text = customFilterToQuery(rules, registry.byKey) + const parsed = compileFilter(text, schema, ctx) + if (!parsed.ok) throw new Error(`${text}: ${parsed.error.message}`) + for (const issue of issues) { + expect([text, issue._id, evaluate(parsed.value.ast, issue, ctx)]).toEqual([text, issue._id, legacy(issue)]) + } + } + }) +}) + +describe('iteration fields in the filter', () => { + const day = (m: number, d: number): number => new Date(2026, m - 1, d).getTime() + const sprintField = field('sprint', 'Sprint', ProjectFieldType.Iteration) + const iterations: any[] = [ + { _id: 'it1', field: 'sprint', label: 'Sprint 1', number: 1, startDate: day(10, 5), duration: 7 }, + { _id: 'it2', field: 'sprint', label: 'Sprint 2', number: 2, startDate: day(10, 12), duration: 7 }, + { _id: 'brk', field: 'sprint', label: 'Break', number: 0, startDate: day(10, 19), duration: 7, isBreak: true }, + { _id: 'it3', field: 'sprint', label: 'Sprint 3', number: 3, startDate: day(10, 26), duration: 7 }, + { _id: 'other', field: 'elsewhere', label: 'Other', number: 1, startDate: day(10, 5), duration: 7 } + ] + const sprintSchema = buildIssueFilterSchema({ + statuses: [], + priorities: [], + assignees: [], + components: [], + milestones: [], + labels: [], + labelRefs: [], + customFields: [sprintField], + iterations, + noParentId: 'no-parent' + }) + // Wednesday of the second iteration + const now = new Date(2026, 9, 14, 12).getTime() + const ctx = { + now, + iterations: (key: string) => (key === 'sprint' ? toIterationRanges(iterations.filter((it) => it.field === 'sprint')) : []) + } + const issues: any[] = [ + { _id: 'a', customFields: { sprint: 'it1' } }, + { _id: 'b', customFields: { sprint: 'it2' } }, + { _id: 'c', customFields: { sprint: 'it3' } }, + { _id: 'd' } + ] + const match = (text: string): string[] => { + const parsed = parseFilter(text, sprintSchema) + if (!parsed.ok) throw new Error(`${text}: ${parsed.error.message}`) + const predicate = filterGrammar.createPredicate(parsed.value, ctx) + return issues.filter((i) => predicate(i)).map((i) => i._id) + } + + it('offers the iterations of the field, without breaks, as options', () => { + const spec = sprintSchema.find((f) => f.name === 'sprint') + expect(spec?.type).toBe('iteration') + expect(spec?.options).toEqual([ + { id: 'it1', name: 'Sprint 1' }, + { id: 'it2', name: 'Sprint 2' }, + { id: 'it3', name: 'Sprint 3' } + ]) + }) + + it('resolves @current, @next and @previous against the iterations of the project', () => { + expect(match('sprint:@current')).toEqual(['b']) + expect(match('sprint:@next')).toEqual(['c']) + expect(match('sprint:@previous')).toEqual(['a']) + expect(match('sprint:@current,@next')).toEqual(['b', 'c']) + expect(match('sprint:@current+1')).toEqual(['c']) + expect(match('sprint:>@previous')).toEqual(['b', 'c']) + expect(match('-sprint:@current')).toEqual(['a', 'c', 'd']) + expect(match('no:sprint')).toEqual(['d']) + }) + + it('matches by title', () => { + expect(match('sprint:"Sprint 1"')).toEqual(['a']) + }) + + it('finds no current iteration during a break', () => { + const during = { ...ctx, now: new Date(2026, 9, 21, 12).getTime() } + const parsed = parseFilter('sprint:@current', sprintSchema) + if (!parsed.ok) throw new Error('parse') + const predicate = filterGrammar.createPredicate(parsed.value, during) + expect(issues.filter((i) => predicate(i))).toEqual([]) + }) + + it('names the picked iterations when folding the rules into a filter string', () => { + const rules: CustomFieldFilter[] = [ + { id: '1', fieldKey: 'sprint', operator: 'anyOf', value: ['@current', 'it1'] } + ] + const text = customFilterToQuery(rules, new Map([['sprint', sprintField]]), () => iterations.filter((it) => it.field === 'sprint')) + expect(text).toBe('sprint:@current,"Sprint 1"') + expect(match(text)).toEqual(['a', 'b']) + }) +}) diff --git a/plugins/tracker-resources/src/archive/__tests__/apply.test.ts b/plugins/tracker-resources/src/archive/__tests__/apply.test.ts new file mode 100644 index 0000000000..f91d846a6c --- /dev/null +++ b/plugins/tracker-resources/src/archive/__tests__/apply.test.ts @@ -0,0 +1,57 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { MAX_ARCHIVE_BATCH, selectArchiveTargets, setArchived } from '../apply' + +const doc = (id: string, archivedAt?: number | null): any => ({ _id: id, archivedAt }) + +describe('selectArchiveTargets', () => { + it('takes the issues that are not archived when archiving, the archived ones when restoring', () => { + const issues = [doc('a'), doc('b', 5), doc('c', null), doc('d', 7)] + expect(selectArchiveTargets(issues, true).map((i) => i._id)).toEqual(['a', 'c']) + expect(selectArchiveTargets(issues, false).map((i) => i._id)).toEqual(['b', 'd']) + }) + + it('takes an issue once and is capped', () => { + expect(selectArchiveTargets([doc('a'), doc('a')], true)).toHaveLength(1) + const many = Array.from({ length: MAX_ARCHIVE_BATCH + 50 }, (_, i) => doc(`i${i}`)) + expect(selectArchiveTargets(many, true)).toHaveLength(MAX_ARCHIVE_BATCH) + }) +}) + +describe('setArchived', () => { + function client (): { client: any, updates: any[], committed: () => boolean } { + const updates: any[] = [] + let committed = false + const ops = { + update: async (d: any, u: any) => { + updates.push([d._id, u]) + }, + commit: async () => { + committed = true + } + } + return { client: { apply: () => ops }, updates, committed: () => committed } + } + + it('archives with one timestamp in one batch, and skips what is archived already', async () => { + const c = client() + await setArchived(c.client, [doc('a'), doc('b'), doc('c', 5)], true) + expect(c.updates.map((u) => u[0])).toEqual(['a', 'b']) + expect(c.updates[0][1].archivedAt).toBeGreaterThan(0) + expect(c.updates[0][1].archivedAt).toBe(c.updates[1][1].archivedAt) + expect(c.committed()).toBe(true) + }) + + it('restores with null and writes nothing when there is nothing to restore', async () => { + const c = client() + await setArchived(c.client, [doc('a', 5)], false) + expect(c.updates).toEqual([['a', { archivedAt: null }]]) + const none = client() + await setArchived(none.client, [doc('a')], false) + expect(none.updates).toEqual([]) + expect(none.committed()).toBe(false) + }) +}) diff --git a/plugins/tracker-resources/src/archive/actions.ts b/plugins/tracker-resources/src/archive/actions.ts new file mode 100644 index 0000000000..018a9c45a4 --- /dev/null +++ b/plugins/tracker-resources/src/archive/actions.ts @@ -0,0 +1,32 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { Analytics } from '@hcengineering/analytics' +import { getClient } from '@hcengineering/presentation' +import type { Issue } from '@hcengineering/tracker' + +import { setArchived } from './apply' + +/** + * Archives issues (GitHub "Archive item"). They keep every value and can be restored. + */ +export async function archiveIssue (issue: Issue | Issue[]): Promise { + try { + await setArchived(getClient(), Array.isArray(issue) ? issue : [issue], true) + } catch (err: any) { + Analytics.handleError(err) + } +} + +/** + * Restores archived issues to the views. + */ +export async function restoreIssue (issue: Issue | Issue[]): Promise { + try { + await setArchived(getClient(), Array.isArray(issue) ? issue : [issue], false) + } catch (err: any) { + Analytics.handleError(err) + } +} diff --git a/plugins/tracker-resources/src/archive/apply.ts b/plugins/tracker-resources/src/archive/apply.ts new file mode 100644 index 0000000000..83448b3558 --- /dev/null +++ b/plugins/tracker-resources/src/archive/apply.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref, TxOperations } from '@hcengineering/core' +import { archiveUpdate, isIssueArchived, restoreUpdate, type Issue } from '@hcengineering/tracker' + +/** + * Items an archive or restore of one batch handles at most; a bigger selection is cut, so one click never turns into + * an unbounded write. + */ +export const MAX_ARCHIVE_BATCH = 500 + +/** + * The issues a batch handles: the ones that are not in the wanted state yet (so archiving twice does nothing), each + * once, up to the cap. + */ +export function selectArchiveTargets> ( + issues: readonly T[], + archive: boolean +): T[] { + const seen = new Set>() + const result: T[] = [] + for (const issue of issues) { + if (seen.has(issue._id) || isIssueArchived(issue) === archive) continue + seen.add(issue._id) + result.push(issue) + if (result.length >= MAX_ARCHIVE_BATCH) break + } + return result +} + +/** + * Archives (`archive`) or restores issues in one batch. The ones that are in the wanted state are left alone. + */ +export async function setArchived (client: TxOperations, issues: Issue[], archive: boolean): Promise { + const targets = selectArchiveTargets(issues, archive) + if (targets.length === 0) return + const now = Date.now() + const ops = client.apply() + for (const issue of targets) { + await ops.update(issue, archive ? archiveUpdate(now) : restoreUpdate()) + } + await ops.commit() +} diff --git a/plugins/tracker-resources/src/board/__tests__/cardFields.test.ts b/plugins/tracker-resources/src/board/__tests__/cardFields.test.ts new file mode 100644 index 0000000000..395a1b6cdb --- /dev/null +++ b/plugins/tracker-resources/src/board/__tests__/cardFields.test.ts @@ -0,0 +1,132 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { customFieldKeyOf, hasCardFooter, planCardFields, type CardFooterFacts } from '../cardFields' + +// The field list the board starts with: the look the cards had before they followed the list +const defaultList = [ + 'assignee', + 'subIssues', + 'priority', + 'component', + 'milestone', + 'dueDate', + 'labels', + 'estimation', + 'attachments', + 'comments' +] + +const fieldColumn = (fieldKey: string): { key: string, props: { fieldKey: string }, displayProps: { key: string } } => ({ + key: '', + props: { fieldKey }, + displayProps: { key: `cf_${fieldKey}` } +}) + +describe('planCardFields', () => { + it('shows everything of the default list', () => { + expect(planCardFields(defaultList)).toEqual({ + assignee: true, + chips: [ + { kind: 'builtin', id: 'subIssues' }, + { kind: 'builtin', id: 'priority' }, + { kind: 'builtin', id: 'component' }, + { kind: 'builtin', id: 'milestone' }, + { kind: 'builtin', id: 'dueDate' } + ], + labels: true, + estimation: true, + attachments: true, + comments: true + }) + }) + + it('shows nothing but the title for an empty list', () => { + expect(planCardFields([])).toEqual({ + assignee: false, + chips: [], + labels: false, + estimation: false, + attachments: false, + comments: false + }) + }) + + it('follows the order of the list for the chips', () => { + const plan = planCardFields(['milestone', 'priority', fieldColumn('stage'), 'dueDate']) + expect(plan.chips).toEqual([ + { kind: 'builtin', id: 'milestone' }, + { kind: 'builtin', id: 'priority' }, + { kind: 'custom', fieldKey: 'stage' }, + { kind: 'builtin', id: 'dueDate' } + ]) + }) + + it('switches single fields on and off', () => { + const plan = planCardFields(['labels', { key: 'comments' }]) + expect(plan.labels).toBe(true) + expect(plan.comments).toBe(true) + expect(plan.assignee).toBe(false) + expect(plan.estimation).toBe(false) + expect(plan.chips).toEqual([]) + }) + + it('shows a custom field and an iteration field as chips', () => { + const plan = planCardFields([fieldColumn('stage'), fieldColumn('sprint')]) + expect(plan.chips).toEqual([ + { kind: 'custom', fieldKey: 'stage' }, + { kind: 'custom', fieldKey: 'sprint' } + ]) + }) + + it('ignores repeats and fields it does not know', () => { + const plan = planCardFields(['priority', 'priority', fieldColumn('a'), fieldColumn('a'), 'title', { key: 'kind' }, '']) + expect(plan.chips).toEqual([ + { kind: 'builtin', id: 'priority' }, + { kind: 'custom', fieldKey: 'a' } + ]) + }) +}) + +describe('customFieldKeyOf', () => { + it('reads the field key of a custom field column only', () => { + expect(customFieldKeyOf(fieldColumn('stage'))).toBe('stage') + expect(customFieldKeyOf('priority')).toBeUndefined() + expect(customFieldKeyOf({ key: 'labels' })).toBeUndefined() + expect(customFieldKeyOf({ key: '', props: { fieldKey: '' } })).toBeUndefined() + expect(customFieldKeyOf({ key: '', props: { fieldKey: 4 } })).toBeUndefined() + }) +}) + +describe('hasCardFooter', () => { + const facts = (over: Partial = {}): CardFooterFacts => ({ + reportedTime: 0, + childEstimation: 0, + comments: 0, + parentComments: 0, + attachments: 0, + ...over + }) + const all = planCardFields(defaultList) + + it('has nothing to show for an issue without activity', () => { + expect(hasCardFooter(all, facts())).toBe(false) + }) + + it('shows what the list switched on', () => { + expect(hasCardFooter(all, facts({ reportedTime: 2 }))).toBe(true) + expect(hasCardFooter(all, facts({ childEstimation: 1 }))).toBe(true) + expect(hasCardFooter(all, facts({ comments: 1 }))).toBe(true) + expect(hasCardFooter(all, facts({ parentComments: 3 }))).toBe(true) + expect(hasCardFooter(all, facts({ attachments: 1 }))).toBe(true) + }) + + it('has no footer for the fields that are switched off', () => { + const none = planCardFields([]) + expect(hasCardFooter(none, facts({ reportedTime: 2, comments: 1, attachments: 1 }))).toBe(false) + expect(hasCardFooter(planCardFields(['comments']), facts({ attachments: 4, reportedTime: 1 }))).toBe(false) + expect(hasCardFooter(planCardFields(['comments']), facts({ comments: 4 }))).toBe(true) + }) +}) diff --git a/plugins/tracker-resources/src/board/__tests__/columns.test.ts b/plugins/tracker-resources/src/board/__tests__/columns.test.ts new file mode 100644 index 0000000000..be8ccd65f1 --- /dev/null +++ b/plugins/tracker-resources/src/board/__tests__/columns.test.ts @@ -0,0 +1,156 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import { ProjectFieldType, type Iteration } from '@hcengineering/tracker' +import { + buildColumnCategories, + buildLaneCategories, + categoryKey, + categoryOfItem, + fieldOfKey, + groupByCustomField, + isCustomDimensionKey, + isVisibleCategory, + partitionColumns +} from '../columns' + +const stage = { + key: 'stage', + type: ProjectFieldType.SingleSelect, + options: [ + { value: 'todo', label: 'To do' }, + { value: 'doing', label: 'Doing' }, + { value: 'done', label: 'Done' } + ] +} + +const day = 24 * 60 * 60 * 1000 +const sprint = { key: 'sprint', type: ProjectFieldType.Iteration } +const iterations: Array> = [ + { _id: 'i2' as Ref, startDate: 14 * day, number: 2 }, + { _id: 'ib' as Ref, startDate: 7 * day, number: 0, isBreak: true }, + { _id: 'i1' as Ref, startDate: 0, number: 1 }, + { _id: 'i3' as Ref, startDate: 21 * day, number: 3 } +] + +const issue = (id: string, customFields?: Record): { id: string, customFields?: Record } => ({ + id, + customFields +}) + +describe('categoryKey', () => { + it('is the name of a category object and the string of anything else', () => { + expect(categoryKey({ name: 'Todo', values: [] })).toBe('Todo') + expect(categoryKey('abc')).toBe('abc') + expect(categoryKey(3)).toBe('3') + expect(categoryKey(undefined)).toBe('undefined') + }) +}) + +describe('keys', () => { + it('recognizes the keys of custom fields', () => { + expect(isCustomDimensionKey('customFields.stage')).toBe(true) + expect(isCustomDimensionKey('status')).toBe(false) + expect(isCustomDimensionKey('customFields.')).toBe(false) + }) + + it('finds the field of a key', () => { + const byKey = new Map([['stage', stage]]) + expect(fieldOfKey('customFields.stage', byKey)).toBe(stage) + expect(fieldOfKey('customFields.nope', byKey)).toBeUndefined() + expect(fieldOfKey('status', byKey)).toBeUndefined() + }) +}) + +describe('categoryOfItem and groupByCustomField', () => { + it('reads an id, and nothing for empty values', () => { + expect(categoryOfItem(issue('a', { stage: 'todo' }), 'stage')).toBe('todo') + expect(categoryOfItem(issue('a', { stage: '' }), 'stage')).toBeUndefined() + expect(categoryOfItem(issue('a', { stage: null }), 'stage')).toBeUndefined() + expect(categoryOfItem(issue('a', { stage: 5 }), 'stage')).toBeUndefined() + expect(categoryOfItem(issue('a'), 'stage')).toBeUndefined() + }) + + it('groups by the value and keeps the order of the items', () => { + const items = [ + issue('1', { stage: 'doing' }), + issue('2'), + issue('3', { stage: 'todo' }), + issue('4', { stage: 'doing' }), + issue('5', { other: 'x' }) + ] + const groups = groupByCustomField(items, 'stage') + expect(groups.doing.map((i) => i.id)).toEqual(['1', '4']) + expect(groups.todo.map((i) => i.id)).toEqual(['3']) + expect(groups[categoryKey(undefined)].map((i) => i.id)).toEqual(['2', '5']) + }) +}) + +describe('buildColumnCategories', () => { + it('lists No value first, then every option, also empty ones', () => { + expect(buildColumnCategories(stage, [], [])).toEqual([undefined, 'todo', 'doing', 'done']) + expect(buildColumnCategories(stage, [], [issue('1', { stage: 'done' })])).toEqual([ + undefined, + 'todo', + 'doing', + 'done' + ]) + }) + + it('appends a value that is no option any more', () => { + expect(buildColumnCategories(stage, [], [issue('1', { stage: 'removed' })])).toEqual([ + undefined, + 'todo', + 'doing', + 'done', + 'removed' + ]) + }) + + it('orders iterations by date, leaves out breaks, and puts No value first', () => { + expect(buildColumnCategories(sprint, iterations, [])).toEqual([undefined, 'i1', 'i2', 'i3']) + }) + + it('has just the No value column for a field without options', () => { + expect(buildColumnCategories({ key: 'x', type: ProjectFieldType.SingleSelect }, [], [])).toEqual([undefined]) + }) +}) + +describe('buildLaneCategories', () => { + const items = [issue('1', { stage: 'doing' }), issue('2'), issue('3', { stage: 'todo' })] + + it('lists the options that have items, in order, and No value last', () => { + expect(buildLaneCategories(stage, [], items, false)).toEqual(['todo', 'doing', undefined]) + }) + + it('lists every option with includeEmpty', () => { + expect(buildLaneCategories(stage, [], items, true)).toEqual(['todo', 'doing', 'done', undefined]) + }) + + it('has no No value lane when every item has a value', () => { + expect(buildLaneCategories(stage, [], [issue('1', { stage: 'done' })], false)).toEqual(['done']) + }) + + it('orders iteration lanes by date', () => { + const all = [issue('1', { sprint: 'i3' }), issue('2', { sprint: 'i1' }), issue('3')] + expect(buildLaneCategories(sprint, iterations, all, false)).toEqual(['i1', 'i3', undefined]) + }) +}) + +describe('hidden columns', () => { + it('tells visible from hidden columns by key', () => { + expect(isVisibleCategory('todo', ['done'])).toBe(true) + expect(isVisibleCategory('done', ['done'])).toBe(false) + expect(isVisibleCategory({ name: 'Done', values: [] }, ['Done'])).toBe(false) + expect(isVisibleCategory(undefined, [categoryKey(undefined)])).toBe(false) + }) + + it('splits the columns and keeps their order', () => { + const columns = [undefined, 'todo', 'doing', 'done'] + expect(partitionColumns(columns, ['todo', 'undefined'])).toEqual({ visible: ['doing', 'done'], hidden: [undefined, 'todo'] }) + expect(partitionColumns(columns, [])).toEqual({ visible: columns, hidden: [] }) + }) +}) diff --git a/plugins/tracker-resources/src/board/__tests__/config.test.ts b/plugins/tracker-resources/src/board/__tests__/config.test.ts new file mode 100644 index 0000000000..c4a4a46ed5 --- /dev/null +++ b/plugins/tracker-resources/src/board/__tests__/config.test.ts @@ -0,0 +1,288 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType } from '@hcengineering/tracker' +import { + BOARD_OPTION_KEY, + columnFieldKeys, + DEFAULT_BOARD_CONFIG, + hiddenOf, + isAvailableColumnField, + isDefaultBoardConfig, + limitsOf, + normalizeBoardConfig, + normalizeLimit, + readBoardConfig, + resolveBoardDimensions, + withAllColumnsShown, + withBoardConfig, + withColumnField, + withColumnHidden, + withColumnLimit, + withoutColumnLimits +} from '../config' +import path from 'path' + +// The saved views logic is a pure module of view-resources. The path is built at run time so that the type +// checkers do not pull view-resources sources into this package. +const { isViewDirty } = require(path.resolve(__dirname, '../../../../view-resources/src/savedViews')) as { + isViewDirty: (baseline: Record, current: Record) => boolean +} + +const fields = [ + { key: 'stage', type: ProjectFieldType.SingleSelect }, + { key: 'sprint', type: ProjectFieldType.Iteration }, + { key: 'effort', type: ProjectFieldType.Number }, + { key: 'tags', type: ProjectFieldType.MultiSelect } +] + +describe('normalizeBoardConfig', () => { + it('gives the defaults for nothing or garbage', () => { + expect(normalizeBoardConfig(undefined)).toEqual(DEFAULT_BOARD_CONFIG) + expect(normalizeBoardConfig(null)).toEqual(DEFAULT_BOARD_CONFIG) + expect(normalizeBoardConfig('x')).toEqual(DEFAULT_BOARD_CONFIG) + expect(normalizeBoardConfig([])).toEqual(DEFAULT_BOARD_CONFIG) + expect(normalizeBoardConfig({ columnField: 5, columnLimits: 'a', hiddenColumns: 7 })).toEqual(DEFAULT_BOARD_CONFIG) + }) + + it('keeps valid values and drops damaged parts', () => { + const config = normalizeBoardConfig({ + columnField: 'customFields.stage', + columnLimits: { 'customFields.stage': { todo: 3, bad: 0, worse: -1, frac: 1.5, str: '2' }, status: 'x' }, + hiddenColumns: { status: ['Done', 'Done', 4], other: 'x', empty: [] } + }) + expect(config).toEqual({ + columnField: 'customFields.stage', + columnLimits: { 'customFields.stage': { todo: 3 } }, + hiddenColumns: { status: ['Done'] } + }) + }) + + it('does not share structure with the input', () => { + const raw = { columnField: 'status', columnLimits: { status: { a: 1 } }, hiddenColumns: { status: ['a'] } } + const config = normalizeBoardConfig(raw) + config.columnLimits.status.a = 9 + config.hiddenColumns.status.push('b') + expect(raw.columnLimits.status.a).toBe(1) + expect(raw.hiddenColumns.status).toEqual(['a']) + }) +}) + +describe('normalizeLimit', () => { + it('accepts only positive whole numbers', () => { + expect(normalizeLimit(3)).toBe(3) + expect(normalizeLimit(0)).toBeUndefined() + expect(normalizeLimit(-2)).toBeUndefined() + expect(normalizeLimit(2.5)).toBeUndefined() + expect(normalizeLimit(NaN)).toBeUndefined() + expect(normalizeLimit('3')).toBeUndefined() + expect(normalizeLimit(undefined)).toBeUndefined() + }) +}) + +describe('column limits and hidden columns', () => { + it('sets, changes and removes a limit', () => { + const one = withColumnLimit(DEFAULT_BOARD_CONFIG, 'status', 'Todo', 3) + expect(limitsOf(one, 'status')).toEqual({ Todo: 3 }) + const two = withColumnLimit(one, 'status', 'Todo', 5) + expect(limitsOf(two, 'status')).toEqual({ Todo: 5 }) + const none = withColumnLimit(two, 'status', 'Todo', undefined) + expect(none).toEqual(DEFAULT_BOARD_CONFIG) + expect(limitsOf(none, 'status')).toEqual({}) + }) + + it('does not change the config it was given', () => { + const base = withColumnLimit(DEFAULT_BOARD_CONFIG, 'status', 'Todo', 3) + withColumnLimit(base, 'status', 'Todo', 9) + withColumnHidden(base, 'status', 'Todo', true) + expect(limitsOf(base, 'status')).toEqual({ Todo: 3 }) + expect(hiddenOf(base, 'status')).toEqual([]) + expect(DEFAULT_BOARD_CONFIG).toEqual({ columnField: 'status', columnLimits: {}, hiddenColumns: {} }) + }) + + it('keeps the limits per column field', () => { + let config = withColumnLimit(DEFAULT_BOARD_CONFIG, 'status', 'Todo', 3) + config = withColumnLimit(config, 'customFields.stage', 'a', 2) + config = withoutColumnLimits(config, 'status') + expect(limitsOf(config, 'status')).toEqual({}) + expect(limitsOf(config, 'customFields.stage')).toEqual({ a: 2 }) + }) + + it('ignores an invalid limit like a removal', () => { + const config = withColumnLimit(withColumnLimit(DEFAULT_BOARD_CONFIG, 'status', 'a', 4), 'status', 'a', 0) + expect(config).toEqual(DEFAULT_BOARD_CONFIG) + }) + + it('hides and shows columns', () => { + let config = withColumnHidden(DEFAULT_BOARD_CONFIG, 'status', 'Done', true) + config = withColumnHidden(config, 'status', 'Backlog', true) + expect(hiddenOf(config, 'status')).toEqual(['Done', 'Backlog']) + // Hiding twice does not repeat the column + expect(hiddenOf(withColumnHidden(config, 'status', 'Done', true), 'status')).toEqual(['Backlog', 'Done']) + config = withColumnHidden(config, 'status', 'Done', false) + expect(hiddenOf(config, 'status')).toEqual(['Backlog']) + expect(withAllColumnsShown(config, 'status')).toEqual(DEFAULT_BOARD_CONFIG) + }) + + it('keeps the limits and hidden columns when the column field changes', () => { + let config = withColumnLimit(DEFAULT_BOARD_CONFIG, 'status', 'Todo', 3) + config = withColumnHidden(config, 'status', 'Done', true) + const other = withColumnField(config, 'customFields.stage') + expect(other.columnField).toBe('customFields.stage') + expect(limitsOf(other, 'status')).toEqual({ Todo: 3 }) + expect(hiddenOf(other, 'status')).toEqual(['Done']) + expect(withColumnField(other, 'status')).toEqual(config) + }) +}) + +describe('column field', () => { + it('offers the built-in attributes and the single-select and iteration fields', () => { + expect(columnFieldKeys({ fields })).toEqual([ + 'status', + 'assignee', + 'priority', + 'component', + 'milestone', + 'customFields.stage', + 'customFields.sprint' + ]) + }) + + it('knows which keys can be the column field', () => { + expect(isAvailableColumnField('status', { fields: [] })).toBe(true) + expect(isAvailableColumnField('customFields.stage', { fields })).toBe(true) + expect(isAvailableColumnField('customFields.sprint', { fields })).toBe(true) + expect(isAvailableColumnField('customFields.effort', { fields })).toBe(false) + expect(isAvailableColumnField('customFields.tags', { fields })).toBe(false) + expect(isAvailableColumnField('customFields.gone', { fields })).toBe(false) + expect(isAvailableColumnField('createdBy', { fields })).toBe(false) + }) +}) + +describe('resolveBoardDimensions', () => { + const available = { fields } + + it('uses the status columns and no swimlanes by default', () => { + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['#no_category'], available)).toEqual({ + columnKey: 'status', + laneKey: undefined + }) + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, undefined, available)).toEqual({ + columnKey: 'status', + laneKey: undefined + }) + }) + + it('takes the group-by of the view as swimlanes', () => { + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['assignee'], available)).toEqual({ + columnKey: 'status', + laneKey: 'assignee' + }) + expect( + resolveBoardDimensions({ ...DEFAULT_BOARD_CONFIG, columnField: 'customFields.sprint' }, ['customFields.stage'], available) + ).toEqual({ columnKey: 'customFields.sprint', laneKey: 'customFields.stage' }) + }) + + it('ignores swimlanes by the field of the columns', () => { + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['status'], available).laneKey).toBeUndefined() + expect( + resolveBoardDimensions({ ...DEFAULT_BOARD_CONFIG, columnField: 'customFields.stage' }, ['customFields.stage'], available) + .laneKey + ).toBeUndefined() + }) + + it('ignores swimlanes by a custom field that does not exist or cannot be grouped by', () => { + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['customFields.gone'], available).laneKey).toBeUndefined() + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['customFields.effort'], available).laneKey).toBeUndefined() + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['customFields.sprint'], available).laneKey).toBe( + 'customFields.sprint' + ) + }) + + it('takes the second group-by as sub-lanes', () => { + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['assignee', 'priority'], available)).toEqual({ + columnKey: 'status', + laneKey: 'assignee', + subLaneKey: 'priority' + }) + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['assignee', 'customFields.sprint'], available).subLaneKey).toBe( + 'customFields.sprint' + ) + // a single group-by has no sub-lanes, as before + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['assignee'], available).subLaneKey).toBeUndefined() + // only two levels are drawn + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['assignee', 'priority', 'component'], available)).toEqual({ + columnKey: 'status', + laneKey: 'assignee', + subLaneKey: 'priority' + }) + }) + + it('skips the field of the columns and fields that are gone when choosing the levels', () => { + // the column field is not a swimlane, so the next level moves up + expect(resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['status', 'assignee', 'priority'], available)).toEqual({ + columnKey: 'status', + laneKey: 'assignee', + subLaneKey: 'priority' + }) + expect( + resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['assignee', 'customFields.gone'], available).subLaneKey + ).toBeUndefined() + expect( + resolveBoardDimensions(DEFAULT_BOARD_CONFIG, ['assignee', 'assignee'], available).subLaneKey + ).toBeUndefined() + }) + + it('falls back to the status when the column field does not exist', () => { + expect( + resolveBoardDimensions({ ...DEFAULT_BOARD_CONFIG, columnField: 'customFields.gone' }, [], available).columnKey + ).toBe('status') + expect( + resolveBoardDimensions({ ...DEFAULT_BOARD_CONFIG, columnField: 'customFields.effort' }, [], available).columnKey + ).toBe('status') + }) +}) + +describe('storing the board config in the view options', () => { + const custom = { ...DEFAULT_BOARD_CONFIG, columnField: 'customFields.stage' } + + it('does not store the default config', () => { + expect(withBoardConfig({ groupBy: ['x'], [BOARD_OPTION_KEY]: custom }, DEFAULT_BOARD_CONFIG)).toEqual({ + groupBy: ['x'] + }) + expect(isDefaultBoardConfig(DEFAULT_BOARD_CONFIG)).toBe(true) + expect(isDefaultBoardConfig(custom)).toBe(false) + }) + + it('stores and reads a custom config', () => { + const options = withBoardConfig({ groupBy: ['x'] }, custom) + expect((options as Record)[BOARD_OPTION_KEY]).toEqual(custom) + expect(readBoardConfig(options)).toEqual(custom) + expect(readBoardConfig(undefined)).toEqual(DEFAULT_BOARD_CONFIG) + }) + + it('does not change the options it was given', () => { + const options = { groupBy: ['x'] } + withBoardConfig(options, custom) + expect(options).toEqual({ groupBy: ['x'] }) + }) + + it('takes part in the unsaved changes of a saved view', () => { + const saved = { viewOptions: { groupBy: ['#no_category'], orderBy: ['modifiedOn', -1] } } + expect(isViewDirty(saved, { viewOptions: withBoardConfig(saved.viewOptions, DEFAULT_BOARD_CONFIG) })).toBe(false) + + const changed = withBoardConfig(saved.viewOptions, withColumnLimit(DEFAULT_BOARD_CONFIG, 'status', 'Todo', 3)) + expect(isViewDirty(saved, { viewOptions: changed })).toBe(true) + + // Changing it back makes the view clean again + const back = withBoardConfig(changed, withColumnLimit(readBoardConfig(changed), 'status', 'Todo', undefined)) + expect(isViewDirty(saved, { viewOptions: back })).toBe(false) + + const hidden = withBoardConfig(saved.viewOptions, withColumnHidden(DEFAULT_BOARD_CONFIG, 'status', 'Done', true)) + expect(isViewDirty(saved, { viewOptions: hidden })).toBe(true) + const column = withBoardConfig(saved.viewOptions, custom) + expect(isViewDirty(saved, { viewOptions: column })).toBe(true) + }) +}) diff --git a/plugins/tracker-resources/src/board/__tests__/limits.test.ts b/plugins/tracker-resources/src/board/__tests__/limits.test.ts new file mode 100644 index 0000000000..25f1963c3a --- /dev/null +++ b/plugins/tracker-resources/src/board/__tests__/limits.test.ts @@ -0,0 +1,55 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { formatColumnCount, limitState, parseLimitInput } from '../limits' + +describe('limitState', () => { + it('is none without a limit', () => { + expect(limitState(10, undefined)).toBe('none') + expect(limitState(10, 0)).toBe('none') + expect(limitState(10, -1)).toBe('none') + }) + + it('compares the count with the limit', () => { + expect(limitState(0, 3)).toBe('within') + expect(limitState(2, 3)).toBe('within') + expect(limitState(3, 3)).toBe('reached') + expect(limitState(4, 3)).toBe('exceeded') + }) +}) + +describe('formatColumnCount', () => { + it('shows the count alone or against the limit', () => { + expect(formatColumnCount(3, undefined)).toBe('3') + expect(formatColumnCount(3, 5)).toBe('3/5') + expect(formatColumnCount(7, 5)).toBe('7/5') + expect(formatColumnCount(0, 5)).toBe('0/5') + expect(formatColumnCount(2, 0)).toBe('2') + }) +}) + +describe('parseLimitInput', () => { + it('reads a number', () => { + expect(parseLimitInput(4)).toEqual({ ok: true, limit: 4 }) + expect(parseLimitInput(' 12 ')).toEqual({ ok: true, limit: 12 }) + }) + + it('takes empty and zero as no limit', () => { + expect(parseLimitInput(undefined)).toEqual({ ok: true, limit: undefined }) + expect(parseLimitInput(null)).toEqual({ ok: true, limit: undefined }) + expect(parseLimitInput('')).toEqual({ ok: true, limit: undefined }) + expect(parseLimitInput(' ')).toEqual({ ok: true, limit: undefined }) + expect(parseLimitInput(0)).toEqual({ ok: true, limit: undefined }) + expect(parseLimitInput('0')).toEqual({ ok: true, limit: undefined }) + }) + + it('rejects what is not a positive whole number', () => { + expect(parseLimitInput(-3)).toEqual({ ok: false }) + expect(parseLimitInput(2.5)).toEqual({ ok: false }) + expect(parseLimitInput('abc')).toEqual({ ok: false }) + expect(parseLimitInput(NaN)).toEqual({ ok: false }) + expect(parseLimitInput({})).toEqual({ ok: false }) + }) +}) diff --git a/plugins/tracker-resources/src/board/__tests__/move.test.ts b/plugins/tracker-resources/src/board/__tests__/move.test.ts new file mode 100644 index 0000000000..c52ea21872 --- /dev/null +++ b/plugins/tracker-resources/src/board/__tests__/move.test.ts @@ -0,0 +1,153 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + attributeTarget, + customTarget, + isAvailableCategory, + isNoopUpdate, + resolveDropUpdate, + type DropTarget +} from '../move' + +const attr = (key: string, value: unknown): DropTarget => ({ kind: 'attribute', key, value }) +const custom = (fieldKey: string, value: string | undefined): DropTarget => ({ kind: 'custom', fieldKey, value }) + +describe('resolveDropUpdate', () => { + it('writes the attribute of a column', () => { + expect(resolveDropUpdate({}, [attr('status', 's2')])).toEqual({ status: 's2' }) + }) + + it('writes the value of a custom field column', () => { + expect(resolveDropUpdate({ customFields: { stage: 'todo', other: 7 } }, [custom('stage', 'done')])).toEqual({ + customFields: { stage: 'done', other: 7 } + }) + }) + + it('clears a custom field when dropped on No value', () => { + expect(resolveDropUpdate({ customFields: { stage: 'todo', other: 7 } }, [custom('stage', undefined)])).toEqual({ + customFields: { other: 7 } + }) + }) + + it('works for an issue without any custom field', () => { + expect(resolveDropUpdate({}, [custom('stage', 'todo')])).toEqual({ customFields: { stage: 'todo' } }) + expect(resolveDropUpdate({}, [custom('stage', undefined)])).toEqual({}) + }) + + it('writes the column and the swimlane together', () => { + expect(resolveDropUpdate({}, [attr('status', 's2'), attr('assignee', 'p1')])).toEqual({ + status: 's2', + assignee: 'p1' + }) + expect(resolveDropUpdate({ customFields: { x: 1 } }, [attr('status', 's2'), custom('stage', 'doing')])).toEqual({ + status: 's2', + customFields: { x: 1, stage: 'doing' } + }) + }) + + it('merges two custom fields into one record', () => { + expect( + resolveDropUpdate({ customFields: { stage: 'todo', sprint: 'i1', keep: 'k' } }, [ + custom('sprint', 'i2'), + custom('stage', undefined) + ]) + ).toEqual({ customFields: { sprint: 'i2', keep: 'k' } }) + }) + + it('writes an iteration id like any custom value', () => { + expect(resolveDropUpdate({ customFields: { sprint: 'i1' } }, [custom('sprint', 'i3')])).toEqual({ + customFields: { sprint: 'i3' } + }) + }) + + it('skips the missing swimlane target', () => { + expect(resolveDropUpdate({}, [attr('status', 's2'), undefined])).toEqual({ status: 's2' }) + expect(resolveDropUpdate({}, [undefined, undefined])).toEqual({}) + }) + + it('writes nothing for a custom field that already has the value', () => { + expect(resolveDropUpdate({ customFields: { stage: 'todo' } }, [custom('stage', 'todo')])).toEqual({}) + }) + + it('refuses contradicting targets', () => { + expect(resolveDropUpdate({}, [attr('status', 's1'), attr('status', 's2')])).toBeUndefined() + expect(resolveDropUpdate({}, [custom('stage', 'a'), custom('stage', 'b')])).toBeUndefined() + expect(resolveDropUpdate({}, [custom('stage', 'a'), custom('stage', 'a')])).toEqual({ customFields: { stage: 'a' } }) + }) + + it('does not change the document it was given', () => { + const doc = { customFields: { stage: 'todo' } } + resolveDropUpdate(doc, [custom('stage', 'done')]) + expect(doc).toEqual({ customFields: { stage: 'todo' } }) + }) +}) + +describe('isNoopUpdate', () => { + it('is true when every value is already there', () => { + expect(isNoopUpdate({ status: 's1', customFields: { a: 1 } }, { status: 's1', customFields: { a: 1 } })).toBe(true) + expect(isNoopUpdate({ status: 's1' }, {})).toBe(true) + }) + + it('is false when a value differs', () => { + expect(isNoopUpdate({ status: 's1' }, { status: 's2' })).toBe(false) + expect(isNoopUpdate({ customFields: { a: 1 } }, { customFields: { a: 2 } })).toBe(false) + }) +}) + +describe('isAvailableCategory', () => { + it('allows everything when nothing is restricted', () => { + expect(isAvailableCategory(undefined, 'a')).toBe(true) + expect(isAvailableCategory(undefined, undefined)).toBe(true) + }) + + it('allows the listed categories only', () => { + expect(isAvailableCategory(['a', 'b'], 'a')).toBe(true) + expect(isAvailableCategory(['a', 'b'], 'c')).toBe(false) + expect(isAvailableCategory([], 'a')).toBe(false) + expect(isAvailableCategory([undefined], undefined)).toBe(true) + }) + + it('allows a category with several values when one of them is listed', () => { + const status = { name: 'Todo', values: [{ _id: 's1', space: 'p1' }, { _id: 's2', space: 'p2' }] } + expect(isAvailableCategory(['s2'], status)).toBe(true) + expect(isAvailableCategory(['s3'], status)).toBe(false) + expect(isAvailableCategory([status], status)).toBe(true) + expect(isAvailableCategory(['s1'], { name: 'x' })).toBe(false) + }) +}) + +describe('attributeTarget', () => { + it('writes a plain value', () => { + expect(attributeTarget('priority', 2, 'p')).toEqual({ kind: 'attribute', key: 'priority', value: 2 }) + expect(attributeTarget('priority', 0, 'p')).toEqual({ kind: 'attribute', key: 'priority', value: 0 }) + expect(attributeTarget('assignee', 'a1', 'p')).toEqual({ kind: 'attribute', key: 'assignee', value: 'a1' }) + }) + + it('picks the value of the project of the card from a category with several values', () => { + const status = { name: 'Todo', values: [{ _id: 's1', space: 'p1' }, { _id: 's2', space: 'p2' }] } + expect(attributeTarget('status', status, 'p2')).toEqual({ kind: 'attribute', key: 'status', value: 's2' }) + expect(attributeTarget('status', status, 'p3')).toBeUndefined() + }) + + it('clears an attribute that can be empty when dropped on No value', () => { + expect(attributeTarget('assignee', undefined, 'p')).toEqual({ kind: 'attribute', key: 'assignee', value: null }) + expect(attributeTarget('milestone', undefined, 'p')).toEqual({ kind: 'attribute', key: 'milestone', value: null }) + expect(attributeTarget('component', null, 'p')).toEqual({ kind: 'attribute', key: 'component', value: null }) + }) + + it('refuses No value for an attribute that cannot be empty', () => { + expect(attributeTarget('status', undefined, 'p')).toBeUndefined() + expect(attributeTarget('priority', undefined, 'p')).toBeUndefined() + }) +}) + +describe('customTarget', () => { + it('writes an id, or clears the field', () => { + expect(customTarget('stage', 'todo')).toEqual({ kind: 'custom', fieldKey: 'stage', value: 'todo' }) + expect(customTarget('stage', undefined)).toEqual({ kind: 'custom', fieldKey: 'stage', value: undefined }) + expect(customTarget('stage', '')).toEqual({ kind: 'custom', fieldKey: 'stage', value: undefined }) + }) +}) diff --git a/plugins/tracker-resources/src/board/__tests__/swimlanes.test.ts b/plugins/tracker-resources/src/board/__tests__/swimlanes.test.ts new file mode 100644 index 0000000000..02578a0efe --- /dev/null +++ b/plugins/tracker-resources/src/board/__tests__/swimlanes.test.ts @@ -0,0 +1,223 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { categoryKey, groupByCustomField } from '../columns' +import { buildBoardGrid, cellLanes, gridOrder, toggleLane } from '../swimlanes' + +interface Item { + id: string + customFields?: Record +} +const item = (id: string, customFields?: Record): Item => ({ id, customFields }) + +const items = [ + item('1', { stage: 'todo', team: 'a' }), + item('2', { stage: 'doing', team: 'a' }), + item('3', { stage: 'todo', team: 'b' }), + item('4', { team: 'b' }), + item('5', { stage: 'done' }) +] + +const input = { + items, + lanes: ['a', 'b', undefined], + columns: [undefined, 'todo', 'doing'], + bucketLanes: (list: readonly Item[]) => groupByCustomField(list, 'team'), + bucketColumns: (list: readonly Item[]) => groupByCustomField(list, 'stage'), + showEmptyLanes: false +} + +const ids = (list: Item[]): string[] => list.map((i) => i.id) + +describe('buildBoardGrid', () => { + it('lays the items out as swimlanes of columns', () => { + const grid = buildBoardGrid(input) + expect(grid.lanes.map((l) => l.key)).toEqual(['a', 'b', 'undefined']) + const a = grid.lanes[0] + expect(a.cells.map((c) => c.columnKey)).toEqual(['undefined', 'todo', 'doing']) + expect(a.cells.map((c) => ids(c.items))).toEqual([[], ['1'], ['2']]) + const b = grid.lanes[1] + expect(b.cells.map((c) => ids(c.items))).toEqual([['4'], ['3'], []]) + const none = grid.lanes[2] + expect(none.cells.map((c) => ids(c.items))).toEqual([[], [], []]) + expect(ids(none.items)).toEqual(['5']) + }) + + it('counts the items of a swimlane in the visible columns only', () => { + const grid = buildBoardGrid(input) + expect(grid.lanes.map((l) => l.count)).toEqual([2, 2, 0]) + // Item 5 is in a hidden column ("done") of the No team swimlane + expect(grid.lanes[2].items).toHaveLength(1) + }) + + it('counts the items of a column over all swimlanes', () => { + const grid = buildBoardGrid(input) + expect(grid.columnTotals.get('todo')).toBe(2) + expect(grid.columnTotals.get('doing')).toBe(1) + expect(grid.columnTotals.get('undefined')).toBe(1) + expect(grid.columnTotals.get('done')).toBe(1) + expect(grid.columnTotals.get('nope')).toBeUndefined() + }) + + it('puts every item in exactly one cell of the visible columns', () => { + const all = { ...input, columns: [undefined, 'todo', 'doing', 'done'] } + const grid = buildBoardGrid(all) + const seen = grid.lanes.flatMap((l) => l.cells.flatMap((c) => ids(c.items))) + expect(seen.sort()).toEqual(['1', '2', '3', '4', '5']) + }) + + it('drops empty swimlanes unless asked to show them', () => { + const lanes = ['a', 'empty', 'b'] + expect(buildBoardGrid({ ...input, lanes }).lanes.map((l) => l.key)).toEqual(['a', 'b']) + const shown = buildBoardGrid({ ...input, lanes, showEmptyLanes: true }) + expect(shown.lanes.map((l) => l.key)).toEqual(['a', 'empty', 'b']) + expect(shown.lanes[1].cells.map((c) => c.items.length)).toEqual([0, 0, 0]) + }) + + it('works with category objects of built-in fields', () => { + const status = { name: 'Todo', values: [{ _id: 's1', space: 'p' }] } + const grid = buildBoardGrid({ + items: ['x', 'y'], + lanes: [{ name: 'L', values: [] }], + columns: [status], + bucketLanes: (list) => ({ L: [...list] }), + bucketColumns: (list) => ({ [categoryKey(status)]: list.filter((i) => i === 'y') }), + showEmptyLanes: false + }) + expect(grid.lanes[0].cells[0].items).toEqual(['y']) + expect(grid.lanes[0].key).toBe('L') + }) + + it('has no swimlanes for no items', () => { + const grid = buildBoardGrid({ ...input, items: [] }) + expect(grid.lanes).toEqual([]) + expect(grid.columnTotals.size).toBe(0) + }) +}) + +describe('toggleLane', () => { + it('collapses and expands a swimlane without changing the set it was given', () => { + const none = new Set() + const one = toggleLane(none, 'a') + expect([...one]).toEqual(['a']) + expect(none.size).toBe(0) + expect([...toggleLane(one, 'b')].sort()).toEqual(['a', 'b']) + expect(toggleLane(one, 'a').size).toBe(0) + }) +}) + +describe('gridOrder', () => { + it('goes swimlane by swimlane, column by column', () => { + const grid = buildBoardGrid({ ...input, columns: [undefined, 'todo', 'doing', 'done'] }) + expect(ids(gridOrder(grid, new Set()))).toEqual(['1', '2', '4', '3', '5']) + }) + + it('skips collapsed swimlanes', () => { + const grid = buildBoardGrid(input) + expect(ids(gridOrder(grid, new Set(['a'])))).toEqual(['4', '3']) + }) +}) + +describe('sub-lanes', () => { + const nested = { + ...input, + columns: [undefined, 'todo', 'doing', 'done'], + // the sub-lane field is "kind", kept in customFields like the others + subLanes: ['bug', 'task', undefined], + bucketSubLanes: (list: readonly Item[]) => groupByCustomField(list, 'kind') + } + const withKinds = [ + item('1', { stage: 'todo', team: 'a', kind: 'bug' }), + item('2', { stage: 'doing', team: 'a', kind: 'task' }), + item('3', { stage: 'todo', team: 'b', kind: 'task' }), + item('4', { team: 'b' }), + item('5', { stage: 'done', team: 'a', kind: 'bug' }) + ] + const grid = buildBoardGrid({ ...nested, items: withKinds }) + + it('nests a lane per sub-lane value inside every swimlane, without empty sub-lanes', () => { + expect(grid.lanes.map((l) => l.key)).toEqual(['a', 'b']) + const a = grid.lanes[0] + expect(a.subLanes.map((l) => l.category)).toEqual(['bug', 'task']) + expect(a.subLanes.map((l) => l.depth)).toEqual([1, 1]) + expect(a.subLanes.map((l) => l.path)).toEqual([['a', 'bug'], ['a', 'task']]) + const b = grid.lanes[1] + // "No kind" is last and only listed where there are items without a kind + expect(b.subLanes.map((l) => l.category)).toEqual(['task', undefined]) + }) + + it('gives every sub-lane a key that is unique over the board', () => { + const keys = [...grid.lanes.map((l) => l.key), ...grid.lanes.flatMap((l) => l.subLanes.map((s) => s.key))] + expect(new Set(keys).size).toBe(keys.length) + }) + + it('holds the cells in the sub-lanes and every item in exactly one cell', () => { + const a = grid.lanes[0] + expect(a.cells).toEqual([]) + expect(cellLanes(grid).every((l) => l.cells.length === 4)).toBe(true) + const seen = cellLanes(grid).flatMap((l) => l.cells.flatMap((c) => ids(c.items))) + expect(seen.sort()).toEqual(['1', '2', '3', '4', '5']) + expect(ids(a.items)).toEqual(['1', '2', '5']) + expect(a.subLanes[0].cells.map((c) => ids(c.items))).toEqual([[], ['1'], [], ['5']]) + }) + + it('counts the items of a swimlane over its sub-lanes in the visible columns', () => { + const visible = buildBoardGrid({ ...nested, items: withKinds, columns: [undefined, 'todo', 'doing'] }) + expect(visible.lanes.map((l) => l.count)).toEqual([2, 2]) + expect(visible.lanes[0].subLanes.map((l) => l.count)).toEqual([1, 1]) + expect(visible.lanes[0].items).toHaveLength(3) + }) + + it('keeps the column totals over all swimlanes and sub-lanes', () => { + expect(grid.columnTotals.get('todo')).toBe(2) + expect(grid.columnTotals.get('done')).toBe(1) + }) + + it('shows empty swimlanes with "show empty groups" but never empty sub-lanes', () => { + const shown = buildBoardGrid({ ...nested, items: withKinds, lanes: ['a', 'empty', 'b'], showEmptyLanes: true }) + expect(shown.lanes.map((l) => l.key)).toEqual(['a', 'empty', 'b']) + expect(shown.lanes[1].subLanes).toEqual([]) + // an empty swimlane draws its own (empty) cells, like a board without sub-lanes + expect(shown.lanes[1].cells).toHaveLength(4) + }) + + it('is the plain grid without sub-lanes', () => { + const plain = buildBoardGrid({ ...input, items: withKinds }) + expect(plain.lanes.every((l) => l.subLanes.length === 0 && l.depth === 0)).toBe(true) + expect(plain.lanes[0].path).toEqual(['a']) + expect(cellLanes(plain)).toEqual(plain.lanes) + }) + + it('moves the keyboard lane by lane, sub-lane by sub-lane, and skips collapsed ones', () => { + expect(ids(gridOrder(grid, new Set()))).toEqual(['1', '5', '2', '3', '4']) + // collapse the swimlane "a" + expect(ids(gridOrder(grid, new Set(['a'])))).toEqual(['3', '4']) + // collapse one sub-lane of "a" + expect(ids(gridOrder(grid, new Set([grid.lanes[0].subLanes[0].key])))).toEqual(['2', '3', '4']) + }) + + it('groups 50k items on two levels quickly', () => { + const teams = ['a', 'b', 'c', 'd'] + const kinds = ['bug', 'task', 'epic'] + const stages = ['todo', 'doing', 'done'] + const big: Item[] = [] + for (let n = 0; n < 50000; n++) { + big.push(item(String(n), { team: teams[n % 4], kind: kinds[n % 3], stage: stages[n % 3 === 0 ? 0 : n % 5 % 3] })) + } + const started = Date.now() + const result = buildBoardGrid({ + items: big, + lanes: teams, + columns: stages, + bucketLanes: (list) => groupByCustomField(list, 'team'), + bucketColumns: (list) => groupByCustomField(list, 'stage'), + showEmptyLanes: false, + subLanes: kinds, + bucketSubLanes: (list) => groupByCustomField(list, 'kind') + }) + expect(Date.now() - started).toBeLessThan(3000) + expect(cellLanes(result).reduce((n, l) => n + l.count, 0)).toBe(50000) + }) +}) diff --git a/plugins/tracker-resources/src/board/cardFields.ts b/plugins/tracker-resources/src/board/cardFields.ts new file mode 100644 index 0000000000..50e98408ef --- /dev/null +++ b/plugins/tracker-resources/src/board/cardFields.ts @@ -0,0 +1,108 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** + * What a card of the board shows, derived from the field list of the view (the columns of the saved view). + * The title, the identifier and the status marker are always shown, the rest follows the list. The same list + * drives the columns of the table, so a field is switched on and off in one place for both layouts. + */ + +/** Fields of an issue the cards know how to show as a chip, besides the custom fields. */ +export const BUILTIN_CARD_CHIPS = ['subIssues', 'priority', 'component', 'milestone', 'dueDate'] as const + +export type BuiltinCardChip = (typeof BUILTIN_CARD_CHIPS)[number] + +export type CardChip = { kind: 'builtin', id: BuiltinCardChip } | { kind: 'custom', fieldKey: string } + +export interface CardFieldPlan { + // The assignee avatar + assignee: boolean + // Chips in the order of the field list + chips: CardChip[] + labels: boolean + estimation: boolean + attachments: boolean + comments: boolean +} + +/** What of the field list of a view the plan reads: a key, or a column with the key and the props of its presenter. */ +export type CardFieldKey = string | { key: string, props?: Record } + +function builtinChip (key: string): BuiltinCardChip | undefined { + return (BUILTIN_CARD_CHIPS as readonly string[]).includes(key) ? (key as BuiltinCardChip) : undefined +} + +/** Key of the custom field a column of the field list stands for. */ +export function customFieldKeyOf (item: CardFieldKey): string | undefined { + if (typeof item === 'string') return undefined + const fieldKey = item.props?.fieldKey + return typeof fieldKey === 'string' && fieldKey !== '' ? fieldKey : undefined +} + +export function planCardFields (config: readonly CardFieldKey[]): CardFieldPlan { + const plan: CardFieldPlan = { + assignee: false, + chips: [], + labels: false, + estimation: false, + attachments: false, + comments: false + } + const seen = new Set() + for (const item of config) { + const fieldKey = customFieldKeyOf(item) + if (fieldKey !== undefined) { + if (!seen.has(`c:${fieldKey}`)) { + seen.add(`c:${fieldKey}`) + plan.chips.push({ kind: 'custom', fieldKey }) + } + continue + } + const key = typeof item === 'string' ? item : item.key + switch (key) { + case 'assignee': + plan.assignee = true + break + case 'labels': + plan.labels = true + break + case 'estimation': + plan.estimation = true + break + case 'attachments': + plan.attachments = true + break + case 'comments': + plan.comments = true + break + default: { + const id = builtinChip(key) + if (id !== undefined && !seen.has(`b:${id}`)) { + seen.add(`b:${id}`) + plan.chips.push({ kind: 'builtin', id }) + } + } + } + } + return plan +} + +export interface CardFooterFacts { + // Time reported on the issue and its sub-issues + reportedTime: number + // Estimation of the sub-issues + childEstimation: number + comments: number + // Comments of the parent issue, shown on a sub-issue + parentComments: number + attachments: number +} + +/** Whether the footer of a card (estimation, attachments, comments) has anything to show. */ +export function hasCardFooter (plan: CardFieldPlan, facts: CardFooterFacts): boolean { + if (plan.estimation && (facts.reportedTime > 0 || facts.childEstimation > 0)) return true + if (plan.comments && (facts.comments > 0 || facts.parentComments > 0)) return true + return plan.attachments && facts.attachments > 0 +} diff --git a/plugins/tracker-resources/src/board/columns.ts b/plugins/tracker-resources/src/board/columns.ts new file mode 100644 index 0000000000..e6049f7623 --- /dev/null +++ b/plugins/tracker-resources/src/board/columns.ts @@ -0,0 +1,122 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Iteration, ProjectField } from '@hcengineering/tracker' +import { buildIterationGroupCategories, ProjectFieldType } from '@hcengineering/tracker' +import { buildGroupCategories, parseCustomFieldViewKey } from '../projectFields/query' + +/** + * Pure logic of the board columns and swimlanes that come from a custom field (a single-select or an Iteration + * field). The values live in the untyped `Issue.customFields` record, so the grouping runs on the client over the + * issues of the board. Columns and swimlanes that come from a built-in attribute use the generic view machinery. + * + * A "category" is what a column or a swimlane stands for: an option id or an iteration id of the field, or + * `undefined` for the issues without a value ("No "). + */ + +type WithCustomFields = { customFields?: Record } + +// Documents are typed by what the caller has; the custom fields are read from them when they are there +const customFieldsOf = (item: object): Record | undefined => (item as WithCustomFields).customFields + +/** Key under which a category is stored in the buckets of the items; the one `groupBy` of the view plugin uses. */ +export function categoryKey (category: unknown): string { + if (category !== null && typeof category === 'object') { + const name = (category as { name?: unknown }).name + return String(name) + } + return String(category) +} + +/** Whether the view key addresses a custom field. */ +export function isCustomDimensionKey (key: string): boolean { + return parseCustomFieldViewKey(key) !== undefined +} + +/** The custom field a view key addresses, if it exists in the project. */ +export function fieldOfKey> ( + key: string, + byKey: ReadonlyMap +): F | undefined { + const fieldKey = parseCustomFieldViewKey(key) + return fieldKey === undefined ? undefined : byKey.get(fieldKey) +} + +/** Value of the field of an item as a category: an id, or `undefined` when the item has no value. */ +export function categoryOfItem (item: object, fieldKey: string): string | undefined { + const value = customFieldsOf(item)?.[fieldKey] + return typeof value === 'string' && value !== '' ? value : undefined +} + +/** + * Groups the items by the value of a custom field. The keys of the result are `categoryKey` of the categories, + * the order of the items is kept. + */ +export function groupByCustomField ( + items: readonly T[], + fieldKey: string +): Record { + const res: Record = {} + for (const item of items) { + const key = categoryKey(categoryOfItem(item, fieldKey)) + ;(res[key] ??= []).push(item) + } + return res +} + +/** + * Columns of a board whose column field is a single-select or an Iteration field, left to right: "No ", + * then every option (or iteration, in calendar order) even when it has no items, then values found on items that + * are no option any more. + */ +export function buildColumnCategories ( + field: Pick, + iterations: ReadonlyArray>, + items: readonly object[] +): Array { + // With `includeEmpty` the builders list every option, and "No " as the last one + const docs = items as readonly WithCustomFields[] + const all = + field.type === ProjectFieldType.Iteration + ? buildIterationGroupCategories(iterations, docs, field.key, true) + : buildGroupCategories(field, docs, true) + return [undefined, ...all.filter((c) => c !== undefined)] +} + +/** + * Swimlanes of a board whose "Group by" is a single-select or an Iteration field: the options (or iterations) that + * have items, in their order, then values that are no option any more, and "No " last. All of them are + * listed with `includeEmpty`. + */ +export function buildLaneCategories ( + field: Pick, + iterations: ReadonlyArray>, + items: readonly object[], + includeEmpty: boolean +): Array { + const docs = items as readonly WithCustomFields[] + return field.type === ProjectFieldType.Iteration + ? buildIterationGroupCategories(iterations, docs, field.key, includeEmpty) + : buildGroupCategories(field, docs, includeEmpty) +} + +/** A category that is not hidden. */ +export function isVisibleCategory (category: unknown, hidden: readonly string[]): boolean { + return !hidden.includes(categoryKey(category)) +} + +/** Splits the columns into the visible ones and the hidden ones, keeping their order. */ +export function partitionColumns ( + columns: readonly C[], + hidden: readonly string[] +): { visible: C[], hidden: C[] } { + const visible: C[] = [] + const hiddenColumns: C[] = [] + for (const column of columns) { + if (isVisibleCategory(column, hidden)) visible.push(column) + else hiddenColumns.push(column) + } + return { visible, hidden: hiddenColumns } +} diff --git a/plugins/tracker-resources/src/board/config.ts b/plugins/tracker-resources/src/board/config.ts new file mode 100644 index 0000000000..4d34e222ac --- /dev/null +++ b/plugins/tracker-resources/src/board/config.ts @@ -0,0 +1,246 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { deepEqual } from 'fast-equals' +import { MAX_BOARD_GROUP_LEVELS, resolveGroupLevels } from '../grouping/levels' +import { isGroupableType, parseCustomFieldViewKey, toCustomFieldViewKey } from '../projectFields/query' +import type { ProjectField } from '@hcengineering/tracker' + +/** + * Settings of a board that are not part of the generic view options. They are stored in the view options under + * `BOARD_OPTION_KEY`, so that they are saved with the saved view, restored when it is opened and take part in + * its unsaved-changes tracking (the same way as the roadmap settings). + * + * Swimlanes are not stored here: like on GitHub they are the "Group by" of the view (`viewOptions.groupBy`). + */ +export interface BoardConfig { + // View key of the field the columns come from: a built-in attribute (`status`) or `customFields.` + columnField: string + // Advisory item limits of the columns, by column field and then by column key (see `categoryKey`) + columnLimits: Record> + // Keys of the columns that are hidden, by column field + hiddenColumns: Record +} + +export const BOARD_OPTION_KEY = 'board' + +/** Column field of a board that was never configured. */ +export const DEFAULT_COLUMN_FIELD = 'status' + +export { NO_GROUPING } from '../grouping/levels' + +/** Built-in attributes of an issue that can be the column field, in the order they are offered. */ +export const BUILTIN_COLUMN_FIELDS: readonly string[] = ['status', 'assignee', 'priority', 'component', 'milestone'] + +export const DEFAULT_BOARD_CONFIG: Readonly = { + columnField: DEFAULT_COLUMN_FIELD, + columnLimits: {}, + hiddenColumns: {} +} + +function isRecord (raw: unknown): raw is Record { + return raw !== null && typeof raw === 'object' && !Array.isArray(raw) +} + +/** A limit is a positive whole number; anything else means "no limit". */ +export function normalizeLimit (raw: unknown): number | undefined { + return typeof raw === 'number' && Number.isInteger(raw) && raw > 0 ? raw : undefined +} + +function normalizeLimits (raw: unknown): Record> { + const res: Record> = {} + if (!isRecord(raw)) return res + for (const [field, limits] of Object.entries(raw)) { + if (!isRecord(limits)) continue + const own: Record = {} + for (const [column, limit] of Object.entries(limits)) { + const value = normalizeLimit(limit) + if (value !== undefined) own[column] = value + } + if (Object.keys(own).length > 0) res[field] = own + } + return res +} + +function normalizeHidden (raw: unknown): Record { + const res: Record = {} + if (!isRecord(raw)) return res + for (const [field, columns] of Object.entries(raw)) { + if (!Array.isArray(columns)) continue + const own: string[] = [] + for (const column of columns) { + if (typeof column === 'string' && !own.includes(column)) own.push(column) + } + if (own.length > 0) res[field] = own + } + return res +} + +/** Turns whatever is stored in the view options into a valid config; missing or damaged parts get the defaults. */ +export function normalizeBoardConfig (raw: unknown): BoardConfig { + if (!isRecord(raw)) return cloneBoardConfig(DEFAULT_BOARD_CONFIG) + return { + columnField: typeof raw.columnField === 'string' && raw.columnField !== '' ? raw.columnField : DEFAULT_COLUMN_FIELD, + columnLimits: normalizeLimits(raw.columnLimits), + hiddenColumns: normalizeHidden(raw.hiddenColumns) + } +} + +export function cloneBoardConfig (config: Readonly): BoardConfig { + const columnLimits: Record> = {} + for (const [field, limits] of Object.entries(config.columnLimits)) columnLimits[field] = { ...limits } + const hiddenColumns: Record = {} + for (const [field, columns] of Object.entries(config.hiddenColumns)) hiddenColumns[field] = [...columns] + return { columnField: config.columnField, columnLimits, hiddenColumns } +} + +export function readBoardConfig (viewOptions: Record | undefined): BoardConfig { + return normalizeBoardConfig(viewOptions?.[BOARD_OPTION_KEY]) +} + +export function isDefaultBoardConfig (config: BoardConfig): boolean { + return deepEqual(config, DEFAULT_BOARD_CONFIG) +} + +/** + * The view options with the board config stored in them. The default config is not stored at all, so that + * a view that was never customized stays equal to its saved state (no false "unsaved changes" dot). + */ +export function withBoardConfig> (options: O, config: BoardConfig): O { + const next: Record = { ...options } + if (isDefaultBoardConfig(config)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[BOARD_OPTION_KEY] + } else { + next[BOARD_OPTION_KEY] = cloneBoardConfig(config) + } + return next as O +} + +/** Limits of the columns of one column field, by column key. */ +export function limitsOf (config: BoardConfig, columnField: string): Readonly> { + return config.columnLimits[columnField] ?? {} +} + +/** Keys of the hidden columns of one column field. */ +export function hiddenOf (config: BoardConfig, columnField: string): readonly string[] { + return config.hiddenColumns[columnField] ?? [] +} + +/** The config with the limit of one column set; `undefined` removes the limit. */ +export function withColumnLimit ( + config: BoardConfig, + columnField: string, + columnKey: string, + limit: number | undefined +): BoardConfig { + const next = cloneBoardConfig(config) + const own = { ...(next.columnLimits[columnField] ?? {}) } + const value = normalizeLimit(limit) + if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete own[columnKey] + } else { + own[columnKey] = value + } + if (Object.keys(own).length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next.columnLimits[columnField] + } else { + next.columnLimits[columnField] = own + } + return next +} + +/** The config with all limits of one column field removed. */ +export function withoutColumnLimits (config: BoardConfig, columnField: string): BoardConfig { + const next = cloneBoardConfig(config) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next.columnLimits[columnField] + return next +} + +/** The config with one column hidden or shown. */ +export function withColumnHidden ( + config: BoardConfig, + columnField: string, + columnKey: string, + hidden: boolean +): BoardConfig { + const next = cloneBoardConfig(config) + const rest = (next.hiddenColumns[columnField] ?? []).filter((it) => it !== columnKey) + const own = hidden ? [...rest, columnKey] : rest + if (own.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next.hiddenColumns[columnField] + } else { + next.hiddenColumns[columnField] = own + } + return next +} + +/** The config with every column of one column field shown. */ +export function withAllColumnsShown (config: BoardConfig, columnField: string): BoardConfig { + const next = cloneBoardConfig(config) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next.hiddenColumns[columnField] + return next +} + +/** The config with another column field. Limits and hidden columns are kept per field, so they stay with it. */ +export function withColumnField (config: BoardConfig, columnField: string): BoardConfig { + return { ...cloneBoardConfig(config), columnField } +} + +/** What the board needs to know about the project to check the view keys. */ +export interface BoardFieldAvailability { + // Custom fields of the project that exist + fields: ReadonlyArray> +} + +/** A view key that can be the column field, with the project's fields at hand. */ +export function isAvailableColumnField (key: string, available: BoardFieldAvailability): boolean { + if (BUILTIN_COLUMN_FIELDS.includes(key)) return true + const fieldKey = parseCustomFieldViewKey(key) + if (fieldKey === undefined) return false + return available.fields.some((f) => f.key === fieldKey && isGroupableType(f.type)) +} + +/** View keys that can be the column field, in the order they are offered: built-in first, then the custom fields. */ +export function columnFieldKeys (available: BoardFieldAvailability): string[] { + return [ + ...BUILTIN_COLUMN_FIELDS, + ...available.fields.filter((f) => isGroupableType(f.type)).map((f) => toCustomFieldViewKey(f.key)) + ] +} + +export interface BoardDimensions { + // Field the columns come from + columnKey: string + // Field the swimlanes come from; undefined when the board has no swimlanes + laneKey?: string + // Field the sub-lanes of a swimlane come from ("Then by"); only with swimlanes + subLaneKey?: string +} + +/** + * The fields the board is laid out by. A column field that does not exist (a deleted custom field) falls back to + * the status. The swimlanes are the first "Group by" of the view and the sub-lanes the second ("Then by"). The same + * field as the columns would only give one swimlane per column, so it is skipped, and so is a custom field that does + * not exist or cannot be grouped by. + */ +export function resolveBoardDimensions ( + config: BoardConfig, + groupBy: readonly string[] | undefined, + available: BoardFieldAvailability +): BoardDimensions { + const columnKey = isAvailableColumnField(config.columnField, available) ? config.columnField : DEFAULT_COLUMN_FIELD + const [laneKey, subLaneKey] = resolveGroupLevels(groupBy, { + max: MAX_BOARD_GROUP_LEVELS, + exclude: [columnKey], + isAvailable: (key) => parseCustomFieldViewKey(key) === undefined || isAvailableColumnField(key, available) + }) + return { columnKey, laneKey, subLaneKey } +} diff --git a/plugins/tracker-resources/src/board/limits.ts b/plugins/tracker-resources/src/board/limits.ts new file mode 100644 index 0000000000..d4ce523e8f --- /dev/null +++ b/plugins/tracker-resources/src/board/limits.ts @@ -0,0 +1,45 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { normalizeLimit } from './config' + +/** + * Column limits of the board are advisory (GitHub "Set column limit"): they never stop an item from being + * dropped on a column, the header only shows the count against the limit and turns red when it is exceeded. + */ +export type LimitState = 'none' | 'within' | 'reached' | 'exceeded' + +/** How the count of a column relates to its limit. */ +export function limitState (count: number, limit: number | undefined): LimitState { + const value = normalizeLimit(limit) + if (value === undefined) return 'none' + if (count > value) return 'exceeded' + return count === value ? 'reached' : 'within' +} + +/** The count as shown in the column header: `3` without a limit, `3/5` with one. */ +export function formatColumnCount (count: number, limit: number | undefined): string { + const value = normalizeLimit(limit) + return value === undefined ? String(count) : `${count}/${value}` +} + +export type LimitInput = + // A new limit + | { ok: true, limit: number } + // An empty input removes the limit + | { ok: true, limit: undefined } + | { ok: false } + +/** + * Reads what the user typed (or what the number input of the popup returned) as a limit. Empty or zero means + * "no limit"; anything else that is not a positive whole number is rejected. + */ +export function parseLimitInput (raw: unknown): LimitInput { + if (raw === undefined || raw === null) return { ok: true, limit: undefined } + const n = typeof raw === 'string' ? (raw.trim() === '' ? undefined : Number(raw.trim())) : raw + if (n === undefined || n === 0) return { ok: true, limit: undefined } + const limit = normalizeLimit(n) + return limit === undefined ? { ok: false } : { ok: true, limit } +} diff --git a/plugins/tracker-resources/src/board/move.ts b/plugins/tracker-resources/src/board/move.ts new file mode 100644 index 0000000000..93bc64c9ed --- /dev/null +++ b/plugins/tracker-resources/src/board/move.ts @@ -0,0 +1,99 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { deepEqual } from 'fast-equals' +import { mergeCustomFieldValue } from '../projectFields/registry' + +/** + * What dropping a card on a column (and a swimlane) writes. Every axis of the board is a field: a built-in + * attribute of the issue, or a custom field stored in `Issue.customFields`. + */ +export type DropTarget = + | { kind: 'attribute', key: string, value: unknown } + | { kind: 'custom', fieldKey: string, value: string | undefined } + +export type DropUpdate = Record + +/** + * The update that puts an issue where it was dropped: the targets of the column and of the swimlane, in any order. + * Targets that are `undefined` (no swimlanes) are skipped. + * + * - Attribute targets write `{ [key]: value }`. + * - Custom field targets are merged into one `customFields` record, so that the other custom fields of the issue + * stay as they are. A target without a value ("No ") clears the field. + * - Returns `{}` when the issue already is where it was dropped, and `undefined` when the targets contradict each + * other (the same field with two different values), so the drop is not possible. + */ +export function resolveDropUpdate (doc: object, targets: ReadonlyArray): DropUpdate | undefined { + const current = (doc as { customFields?: Record }).customFields + const update: DropUpdate = {} + const wanted = new Map() + let customFields: Record | undefined + for (const target of targets) { + if (target === undefined) continue + const id = target.kind === 'attribute' ? `a:${target.key}` : `c:${target.fieldKey}` + const value = target.value + if (wanted.has(id) && !deepEqual(wanted.get(id), value)) return undefined + wanted.set(id, value) + if (target.kind === 'attribute') { + update[target.key] = value + } else { + customFields = mergeCustomFieldValue(customFields ?? current, target.fieldKey, target.value ?? null) + } + } + if (customFields !== undefined && !deepEqual(customFields, current ?? {})) { + update.customFields = customFields + } + return update +} + +/** + * Whether a drop changes anything for a document, given what it has now. + */ +export function isNoopUpdate (doc: object, update: object): boolean { + return Object.entries(update).every(([key, value]) => deepEqual((doc as Record)[key], value)) +} + +/** + * Whether a card can be dropped on a column or a swimlane, given the categories the host says are available for it + * (for example only the statuses of the project of the card). `undefined` means that every category is available. + * A category that stands for several values (the same status in several projects) is available when one of them is. + */ +export function isAvailableCategory (available: readonly unknown[] | undefined, category: unknown): boolean { + if (available === undefined) return true + if (available.includes(category)) return true + if (category !== null && typeof category === 'object') { + const values = (category as { values?: unknown }).values + if (Array.isArray(values)) { + return values.some((v) => available.includes((v as { _id?: unknown })?._id)) + } + } + return false +} + +/** Attributes of an issue that can be empty, so that a card can be dropped on "No ". */ +export const NULLABLE_ATTRIBUTES: readonly string[] = ['assignee', 'component', 'milestone'] + +/** + * What dropping on the column or swimlane of a built-in attribute writes. A category that stands for several values + * (the same status in several projects) gives the value of the project of the card. `undefined` when the card cannot + * go there: the category has no value for the project of the card, or the attribute cannot be empty. + */ +export function attributeTarget (key: string, category: unknown, space: string): DropTarget | undefined { + if (category === undefined || category === null) { + return NULLABLE_ATTRIBUTES.includes(key) ? { kind: 'attribute', key, value: null } : undefined + } + if (typeof category === 'object') { + const values = (category as { values?: Array<{ _id: unknown, space: unknown }> }).values + const value = values?.find((it) => it.space === space)?._id + return value === undefined ? undefined : { kind: 'attribute', key, value } + } + return { kind: 'attribute', key, value: category } +} + +/** What dropping on the column or swimlane of a custom field writes: the id, or nothing for "No ". */ +export function customTarget (fieldKey: string, category: unknown): DropTarget { + return { kind: 'custom', fieldKey, value: typeof category === 'string' && category !== '' ? category : undefined } +} diff --git a/plugins/tracker-resources/src/board/swimlanes.ts b/plugins/tracker-resources/src/board/swimlanes.ts new file mode 100644 index 0000000000..382d068ed1 --- /dev/null +++ b/plugins/tracker-resources/src/board/swimlanes.ts @@ -0,0 +1,143 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { categoryKey } from './columns' + +/** Items of one swimlane that fall into one column. */ +export interface BoardCell { + columnKey: string + column: C + items: T[] +} + +export interface BoardLane { + // Unique key of the lane: the key of its category, for a sub-lane the key of its swimlane and its own (a path) + key: string + // What the swimlane stands for (an id, a category object of a built-in field, or `undefined` for "No ") + category: C + // The categories from the swimlane down to this lane: one for a swimlane, two for a sub-lane + path: C[] + // 0 for a swimlane, 1 for a sub-lane + depth: number + // All items of the lane, also those in hidden columns + items: T[] + // One cell per visible column, in the order of the columns; empty for a swimlane that has sub-lanes (they hold + // the cells) + cells: Array> + // Items of the lane in visible columns + count: number + // Sub-lanes ("Then by"), top to bottom; empty when the board has one level of swimlanes + subLanes: Array> +} + +export interface BoardGrid { + lanes: Array> + // Items per column key over all swimlanes + columnTotals: Map +} + +export interface BoardGridInput { + items: readonly T[] + // Swimlanes top to bottom + lanes: readonly C[] + // Visible columns left to right + columns: readonly C[] + // Groups items into buckets named by `categoryKey`; the grouping of the field of the swimlanes + bucketLanes: (items: readonly T[]) => Record + // The same for the field of the columns + bucketColumns: (items: readonly T[]) => Record + // Swimlanes without items are dropped unless this is set + showEmptyLanes: boolean + // Sub-lanes of every swimlane ("Then by") and the grouping of their field. A sub-lane without items in its + // swimlane is dropped, so "show empty groups" applies to the first level only. + subLanes?: readonly C[] + bucketSubLanes?: (items: readonly T[]) => Record +} + +/** + * Lays the items out as swimlanes of columns, optionally with a second level of sub-lanes inside every swimlane. An + * item is in exactly one swimlane (and sub-lane) and one column. An item whose swimlane, sub-lane or column is not + * among the given ones (for example a hidden column) is in none of the cells. + */ +export function buildBoardGrid (input: BoardGridInput): BoardGrid { + const { items, lanes, columns } = input + + const makeCells = (laneItems: readonly T[]): Array> => { + const byColumn = input.bucketColumns(laneItems) + return columns.map((column) => { + const columnKey = categoryKey(column) + return { columnKey, column, items: byColumn[columnKey] ?? [] } + }) + } + + const byLane = input.bucketLanes(items) + const lanesOut: Array> = [] + for (const category of lanes) { + const key = categoryKey(category) + const laneItems = byLane[key] ?? [] + if (laneItems.length === 0 && !input.showEmptyLanes) continue + + const subLanes: Array> = [] + if (input.subLanes !== undefined && input.bucketSubLanes !== undefined) { + const bySub = input.bucketSubLanes(laneItems) + for (const sub of input.subLanes) { + const subItems = bySub[categoryKey(sub)] ?? [] + if (subItems.length === 0) continue + const cells = makeCells(subItems) + subLanes.push({ + key: `${key}\u0000${categoryKey(sub)}`, + category: sub, + path: [category, sub], + depth: 1, + items: subItems, + cells, + count: cells.reduce((sum, c) => sum + c.items.length, 0), + subLanes: [] + }) + } + } + + const cells = subLanes.length > 0 ? [] : makeCells(laneItems) + lanesOut.push({ + key, + category, + path: [category], + depth: 0, + items: laneItems, + cells, + count: subLanes.length > 0 ? subLanes.reduce((sum, l) => sum + l.count, 0) : cells.reduce((sum, c) => sum + c.items.length, 0), + subLanes + }) + } + const totals = new Map() + for (const [key, bucket] of Object.entries(input.bucketColumns(items))) totals.set(key, bucket.length) + return { lanes: lanesOut, columnTotals: totals } +} + +/** The lanes that hold cells, top to bottom: a swimlane without sub-lanes, or the sub-lanes of a swimlane. */ +export function cellLanes (grid: BoardGrid): Array> { + return grid.lanes.flatMap((lane) => (lane.subLanes.length > 0 ? lane.subLanes : [lane])) +} + +/** The collapsed swimlanes after one of them was collapsed or expanded. */ +export function toggleLane (collapsed: ReadonlySet, laneKey: string): Set { + const next = new Set(collapsed) + if (next.has(laneKey)) next.delete(laneKey) + else next.add(laneKey) + return next +} + +/** Items of the grid in the order a keyboard moves through them: lane by lane, column by column. */ +export function gridOrder (grid: BoardGrid, collapsed: ReadonlySet): T[] { + const res: T[] = [] + for (const lane of grid.lanes) { + if (collapsed.has(lane.key)) continue + for (const own of lane.subLanes.length > 0 ? lane.subLanes : [lane]) { + if (collapsed.has(own.key)) continue + for (const cell of own.cells) res.push(...cell.items) + } + } + return res +} diff --git a/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts b/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts new file mode 100644 index 0000000000..826446ecfb --- /dev/null +++ b/plugins/tracker-resources/src/bulkEdit/__tests__/issueCells.test.ts @@ -0,0 +1,310 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Issue, ProjectField } from '@hcengineering/tracker' +import { IssuePriority, ProjectFieldType } from '@hcengineering/tracker' +import { tableEdit } from '@hcengineering/view-resources' + +import { createIssueCellColumns, runIssueOps, type IssueCellLookups, type IssueLabelRef } from '../issueCells' + +const issue = (over: Record & { _id: string }): Issue => + ({ + _class: 'tracker:class:Issue', + space: 'proj', + attachedTo: 'no-parent', + attachedToClass: 'tracker:class:Issue', + collection: 'subIssues', + title: 'Title', + status: 'st-todo', + priority: IssuePriority.NoPriority, + assignee: null, + component: null, + milestone: null, + dueDate: null, + estimation: 0, + ...over + }) as unknown as Issue + +const field = (over: Partial & { key: string, type: ProjectFieldType }): ProjectField => + ({ _id: over.key, label: over.key, position: 0, ...over }) as unknown as ProjectField + +const fields = [ + field({ key: 'note', type: ProjectFieldType.Text }), + field({ key: 'points', type: ProjectFieldType.Number }), + field({ key: 'due', type: ProjectFieldType.Date }), + field({ + key: 'size', + type: ProjectFieldType.SingleSelect, + options: [ + { value: 's', label: 'Small' }, + { value: 'l', label: 'Large' } + ] + }), + field({ + key: 'areas', + type: ProjectFieldType.MultiSelect, + options: [ + { value: 'ui', label: 'UI' }, + { value: 'api', label: 'API' } + ] + }), + field({ key: 'sprint', type: ProjectFieldType.Iteration }) +] + +const day = (m: number, d: number): number => new Date(2026, m - 1, d).getTime() +const iterations: any[] = [ + { _id: 'it1', label: 'Sprint 1', number: 1, startDate: day(1, 5), duration: 7 }, + { _id: 'it2', label: 'Sprint 2', number: 2, startDate: day(1, 12), duration: 7 }, + { _id: 'brk', label: 'Holiday', number: 0, startDate: day(1, 19), duration: 7, isBreak: true } +] + +let refs: Record = {} +let allowed = true + +const lookups: IssueCellLookups = { + statuses: () => [ + { id: 'st-todo', label: 'Todo' }, + { id: 'st-done', label: 'Done' } + ], + priorities: [ + { id: IssuePriority.NoPriority, label: 'No priority' }, + { id: IssuePriority.High, label: 'High' } + ], + assignees: [{ id: 'p1', label: 'Ann' }], + components: [{ id: 'c1', label: 'Core' }], + milestones: [{ id: 'm1', label: 'v1' }], + labels: [ + { id: 'l-bug', title: 'bug', color: 3 }, + { id: 'l-ux', title: 'ux', color: 5 } + ], + labelRefs: (id) => refs[id] ?? [], + fields: new Map(fields.map((f) => [f.key, f])), + iterations: () => iterations, + canEdit: () => allowed, + now: () => new Date(2026, 0, 10, 12).getTime() +} + +const column = createIssueCellColumns(() => lookups) + +const edit = (key: string, doc: Issue, text: string): tableEdit.ParseResult => { + const col = column(key) + if (col?.edit === undefined) throw new Error(`no editable column ${key}`) + return col.edit(doc, text) +} + +beforeEach(() => { + refs = {} + allowed = true +}) + +describe('iteration cells', () => { + // The lookups pin "now" to 2026-01-10, which is in Sprint 1 + it('shows the title and parses titles case-insensitively', () => { + const d = issue({ _id: 'i1', customFields: { sprint: 'it2' } }) + expect(column('cf_sprint')?.format(d)).toBe('Sprint 2') + expect(edit('cf_sprint', d, 'sprint 1')).toMatchObject({ + ok: true, + value: [{ kind: 'update', after: { customFields: { sprint: 'it1' } } }] + }) + }) + + it('understands the filter keywords', () => { + const d = issue({ _id: 'i1' }) + const after = (text: string): unknown => { + const res = edit('cf_sprint', d, text) + return res.ok ? (res.value[0] as any).after.customFields : res + } + expect(after('@current')).toEqual({ sprint: 'it1' }) + expect(after('@next')).toEqual({ sprint: 'it2' }) + expect(after('@current+1')).toEqual({ sprint: 'it2' }) + expect(edit('cf_sprint', d, '@previous')).toEqual({ ok: false, reason: 'unknown' }) + }) + + it('refuses breaks and unknown titles, and clears on empty text', () => { + const d = issue({ _id: 'i1', customFields: { sprint: 'it1', other: 1 } }) + expect(edit('cf_sprint', d, 'Holiday')).toEqual({ ok: false, reason: 'unknown' }) + expect(edit('cf_sprint', d, 'nope')).toEqual({ ok: false, reason: 'unknown' }) + expect(edit('cf_sprint', d, '')).toMatchObject({ ok: true, value: [{ after: { customFields: { other: 1 } } }] }) + }) +}) + +describe('issue cell columns', () => { + it('has no column for read-only cells', () => { + expect(column('issue')).toBeUndefined() + expect(column('modified')).toBeUndefined() + expect(column('cf_missing')).toBeUndefined() + expect(column('cf_sprint')).toBeDefined() + }) + + it('sets the status by name and refuses to clear it', () => { + const d = issue({ _id: 'i1' }) + expect(column('status')?.format(d)).toBe('Todo') + expect(edit('status', d, 'done')).toMatchObject({ + ok: true, + value: [{ kind: 'update', before: { status: 'st-todo' }, after: { status: 'st-done' } }] + }) + expect(edit('status', d, '')).toEqual({ ok: false, reason: 'notClearable' }) + expect(edit('status', d, 'Blocked')).toEqual({ ok: false, reason: 'unknown' }) + }) + + it('sets and clears the priority', () => { + const d = issue({ _id: 'i1', priority: IssuePriority.High }) + expect(column('priority')?.format(d)).toBe('High') + expect(edit('priority', d, '')).toMatchObject({ ok: true, value: [{ after: { priority: IssuePriority.NoPriority } }] }) + expect(edit('priority', issue({ _id: 'i2' }), 'high')).toMatchObject({ + ok: true, + value: [{ after: { priority: IssuePriority.High } }] + }) + }) + + it('sets and clears the assignee, component and milestone', () => { + const d = issue({ _id: 'i1', assignee: 'p1' as any }) + expect(column('assignee')?.format(d)).toBe('Ann') + expect(edit('assignee', d, '')).toMatchObject({ ok: true, value: [{ before: { assignee: 'p1' }, after: { assignee: null } }] }) + expect(edit('component', issue({ _id: 'i2' }), 'core')).toMatchObject({ ok: true, value: [{ after: { component: 'c1' } }] }) + expect(edit('milestone', issue({ _id: 'i2' }), 'v2')).toEqual({ ok: false, reason: 'unknown' }) + }) + + it('parses due dates and estimation', () => { + const d = issue({ _id: 'i1' }) + expect(edit('dueDate', d, '2026-02-01')).toMatchObject({ + ok: true, + value: [{ after: { dueDate: new Date(2026, 1, 1).getTime() } }] + }) + expect(edit('dueDate', d, '@today+2d')).toMatchObject({ + ok: true, + value: [{ after: { dueDate: new Date(2026, 0, 12).getTime() } }] + }) + expect(edit('dueDate', d, 'soon')).toEqual({ ok: false, reason: 'invalid' }) + expect(edit('estimation', d, '3.5')).toMatchObject({ ok: true, value: [{ after: { estimation: 3.5 } }] }) + expect(edit('estimation', issue({ _id: 'i2', estimation: 4 }), '')).toMatchObject({ + ok: true, + value: [{ before: { estimation: 4 }, after: { estimation: 0 } }] + }) + expect(edit('estimation', d, '-1')).toEqual({ ok: false, reason: 'invalid' }) + expect(column('estimation')?.format(d)).toBe('') + }) + + it('does not clear the title', () => { + const d = issue({ _id: 'i1' }) + expect(edit('title', d, ' New title ')).toMatchObject({ ok: true, value: [{ after: { title: 'New title' } }] }) + expect(edit('title', d, ' ')).toEqual({ ok: false, reason: 'notClearable' }) + }) + + it('reads and writes custom fields of every type', () => { + const d = issue({ _id: 'i1', customFields: { note: 'x', size: 'l', areas: ['ui', 'api'], due: new Date(2026, 5, 1).getTime() } }) + expect(column('cf_note')?.format(d)).toBe('x') + expect(column('cf_size')?.format(d)).toBe('Large') + expect(column('cf_areas')?.format(d)).toBe('UI, API') + expect(column('cf_due')?.format(d)).toBe('2026-06-01') + expect(column('cf_points')?.format(d)).toBe('') + + expect(edit('cf_points', d, '8')).toMatchObject({ + ok: true, + value: [{ after: { customFields: { note: 'x', size: 'l', areas: ['ui', 'api'], due: expect.any(Number), points: 8 } } }] + }) + expect(edit('cf_size', d, 'small')).toMatchObject({ ok: true, value: [{ after: { customFields: expect.objectContaining({ size: 's' }) } }] }) + expect(edit('cf_size', d, 'huge')).toEqual({ ok: false, reason: 'unknown' }) + expect(edit('cf_areas', d, 'api')).toMatchObject({ ok: true, value: [{ after: { customFields: expect.objectContaining({ areas: ['api'] }) } }] }) + expect(edit('cf_points', d, 'many')).toEqual({ ok: false, reason: 'invalid' }) + }) + + it('removes the key when a custom field is cleared and restores an empty record on undo', () => { + const d = issue({ _id: 'i1', customFields: { note: 'x' } }) + const res = edit('cf_note', d, '') + expect(res).toMatchObject({ ok: true, value: [{ before: { customFields: { note: 'x' } }, after: { customFields: {} } }] }) + const bare = issue({ _id: 'i2' }) + const set = edit('cf_note', bare, 'y') + expect(set).toMatchObject({ ok: true, value: [{ before: { customFields: {} }, after: { customFields: { note: 'y' } } }] }) + }) + + it('sets labels by adding and removing references', () => { + refs.i1 = [ + { _id: 'r1', tag: 'l-bug', title: 'bug', color: 3 }, + { _id: 'r2', tag: 'l-ux', title: 'ux', color: 5 } + ] + const d = issue({ _id: 'i1' }) + expect(column('labels')?.format(d)).toBe('bug, ux') + + const res = edit('labels', d, 'ux') + expect(res.ok).toBe(true) + if (res.ok) { + expect(res.value).toHaveLength(1) + expect(res.value[0]).toMatchObject({ + kind: 'remove', + target: { _id: 'r1', attachedTo: 'i1', collection: 'labels' }, + attributes: { tag: 'l-bug', title: 'bug', color: 3 } + }) + } + + const fresh = edit('labels', issue({ _id: 'i2' }), 'bug, ux') + expect(fresh.ok && fresh.value.map((o) => o.kind)).toEqual(['add', 'add']) + expect(fresh.ok && fresh.value[0]).toMatchObject({ attributes: { tag: 'l-bug', title: 'bug', color: 3 }, target: { attachedTo: 'i2' } }) + // Clearing removes every label + expect(edit('labels', d, '')).toMatchObject({ ok: true, value: [{ kind: 'remove' }, { kind: 'remove' }] }) + // An unknown label rejects the cell + expect(edit('labels', d, 'bug, nope')).toEqual({ ok: false, reason: 'unknown' }) + // Same labels: nothing to do + expect(edit('labels', d, 'bug, ux')).toEqual({ ok: true, value: [] }) + }) + + it('is read-only where the user may not change the attribute', () => { + allowed = false + expect(edit('status', issue({ _id: 'i1' }), 'done')).toEqual({ ok: false, reason: 'readonly' }) + expect(edit('cf_note', issue({ _id: 'i1' }), 'x')).toEqual({ ok: false, reason: 'readonly' }) + }) + + it('plans a whole paste: valid cells apply, the others are counted', () => { + const docs = [issue({ _id: 'i1' }), issue({ _id: 'i2' })] + const adapter = { column } + const plan = tableEdit.planEdits( + [ + { doc: docs[0], key: 'status', text: 'Done' }, + { doc: docs[0], key: 'cf_points', text: '5' }, + { doc: docs[1], key: 'status', text: 'Nope' }, + { doc: docs[1], key: 'priority', text: 'High' } + ], + adapter + ) + expect(plan.applied).toBe(3) + expect(plan.skipped).toBe(1) + expect(plan.items).toBe(2) + // The two cells of the first issue are one update + expect(plan.ops.filter((o) => o.target._id === 'i1')).toHaveLength(1) + }) +}) + +describe('runIssueOps', () => { + function fakeClient (result = true): { client: any, calls: any[][] } { + const calls: any[][] = [] + const batch: any = { + updateCollection: async (...args: any[]) => calls.push(['update', ...args]), + addCollection: async (...args: any[]) => calls.push(['add', ...args]), + removeCollection: async (...args: any[]) => calls.push(['remove', ...args]), + commit: async () => ({ result }) + } + return { client: { apply: () => batch }, calls } + } + + const target = { _id: 'i1', _class: 'cls', space: 'sp', attachedTo: 'p', attachedToClass: 'pc', collection: 'subIssues' } + + it('sends every operation in one batch', async () => { + const { client, calls } = fakeClient() + await runIssueOps(client, [ + { kind: 'update', target, before: { a: 1 }, after: { a: 2 } }, + { kind: 'add', target: { ...target, _id: 't1' }, attributes: { tag: 'x' } }, + { kind: 'remove', target: { ...target, _id: 't2' }, attributes: {} } + ]) + expect(calls.map((c) => c[0])).toEqual(['update', 'add', 'remove']) + expect(calls[0].slice(1)).toEqual(['cls', 'sp', 'i1', 'p', 'pc', 'subIssues', { a: 2 }]) + // The id of an added document is kept, so that undo can remove it + expect(calls[1].slice(-1)).toEqual(['t1']) + }) + + it('fails when the batch is rejected', async () => { + const { client } = fakeClient(false) + await expect(runIssueOps(client, [{ kind: 'update', target, before: {}, after: {} }])).rejects.toThrow('rejected') + }) +}) diff --git a/plugins/tracker-resources/src/bulkEdit/issueCells.ts b/plugins/tracker-resources/src/bulkEdit/issueCells.ts new file mode 100644 index 0000000000..5ca218cca6 --- /dev/null +++ b/plugins/tracker-resources/src/bulkEdit/issueCells.ts @@ -0,0 +1,367 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { generateId, type DocumentUpdate, type TxOperations } from '@hcengineering/core' +import tags from '@hcengineering/tags' +import type { Issue, Iteration, ProjectField } from '@hcengineering/tracker' +import { + IssuePriority, + ProjectFieldType, + getAssignableIterations, + getFieldValue, + resolveRelativeIteration +} from '@hcengineering/tracker' +import { tableEdit } from '@hcengineering/view-resources' + +import { mergeCustomFieldValue } from '../projectFields/registry' + +type CellColumn = tableEdit.CellColumn +type EditOp = tableEdit.EditOp +type ValueOption = tableEdit.ValueOption + +/** Label of an issue (a tag reference), as far as bulk editing needs it. */ +export interface IssueLabelRef { + // Id of the reference + _id: string + // Id of the label (tag element) + tag: string + title: string + color: number +} + +/** What the cells of the issue table can be set to. The lists follow the project that is shown. */ +export interface IssueCellLookups { + // Statuses an issue can have (depends on its project and kind) + statuses: (issue: Issue) => Array> + priorities: Array> + assignees: Array> + components: Array> + milestones: Array> + labels: Array<{ id: string, title: string, color: number }> + labelRefs: (issueId: string) => IssueLabelRef[] + // Custom fields by field key + fields: ReadonlyMap + // Iterations of an iteration field + iterations?: (field: ProjectField) => readonly Iteration[] + // Whether the current user may change the attribute of the issue + canEdit?: (issue: Issue, attribute: string) => boolean + now?: () => number +} + +const CELL_FIELD_PREFIX = 'cf_' + +/** Key of a custom field column, as marked on the rendered cells. */ +export function customFieldColumnKey (fieldKey: string): string { + return `${CELL_FIELD_PREFIX}${fieldKey}` +} + +export function issueTarget (issue: Issue): tableEdit.DocTarget { + return { + _id: issue._id, + _class: issue._class, + space: issue.space, + attachedTo: issue.attachedTo, + attachedToClass: issue.attachedToClass, + collection: issue.collection + } +} + +const ok = (ops: EditOp[]): tableEdit.ParseResult => ({ ok: true, value: ops }) +const fail = (reason: tableEdit.ParseFailure): tableEdit.ParseResult => ({ ok: false, reason }) + +function setAttribute (issue: Issue, key: string, value: unknown): tableEdit.ParseResult { + return ok([tableEdit.updateOp(issueTarget(issue), issue as unknown as Record, { [key]: value })]) +} + +type Resolve = (lookups: IssueCellLookups) => (issue: Issue) => Array> + +// A column whose value is one of a list of options and may be cleared to null (assignee, component, milestone) +function optionColumn ( + key: string, + attribute: keyof Issue & string, + options: Resolve, + lookups: () => IssueCellLookups +): CellColumn { + return { + key, + clearable: true, + format: (issue) => tableEdit.formatOptionValue(issue[attribute] as string | null, options(lookups())(issue)), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, attribute) === false) return fail('readonly') + const parsed = tableEdit.parseOptionValue(text, options(l)(issue)) + return parsed.ok ? setAttribute(issue, attribute, parsed.value) : parsed + } + } +} + +function numberColumn ( + key: string, + attribute: 'estimation', + lookups: () => IssueCellLookups +): CellColumn { + return { + key, + clearable: true, + // Zero hours is how an issue without an estimate is stored + format: (issue) => (issue[attribute] > 0 ? tableEdit.formatNumberValue(issue[attribute]) : ''), + edit: (issue, text) => { + if (lookups().canEdit?.(issue, attribute) === false) return fail('readonly') + const parsed = tableEdit.parseNumberValue(text, { min: 0 }) + return parsed.ok ? setAttribute(issue, attribute, parsed.value ?? 0) : parsed + } + } +} + +function labelsColumn (lookups: () => IssueCellLookups): CellColumn { + const options = (l: IssueCellLookups): Array> => l.labels.map((it) => ({ id: it.id, label: it.title })) + return { + key: 'labels', + clearable: true, + format: (issue) => + lookups() + .labelRefs(issue._id) + .map((r) => r.title) + .join(', '), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'labels') === false) return fail('readonly') + const parsed = tableEdit.parseMultiOptionValue(text, options(l)) + if (parsed.ok === false) return parsed + const wanted = new Set(parsed.value) + const current = l.labelRefs(issue._id) + const ops: EditOp[] = [] + // The labels are a collection of references, so the value is set by adding and removing references + for (const ref of current) { + if (!wanted.has(ref.tag)) { + ops.push({ + kind: 'remove', + target: { + _id: ref._id, + _class: tags.class.TagReference, + space: issue.space, + attachedTo: issue._id, + attachedToClass: issue._class, + collection: 'labels' + }, + attributes: { tag: ref.tag, title: ref.title, color: ref.color } + }) + } + } + const have = new Set(current.map((r) => r.tag)) + for (const id of parsed.value) { + if (have.has(id)) continue + const label = l.labels.find((it) => it.id === id) + if (label === undefined) continue + ops.push({ + kind: 'add', + target: { + _id: generateId(), + _class: tags.class.TagReference, + space: issue.space, + attachedTo: issue._id, + attachedToClass: issue._class, + collection: 'labels' + }, + attributes: { tag: label.id, title: label.title, color: label.color } + }) + } + return ok(ops) + } + } +} + +function fieldOptions (field: ProjectField): Array> { + return (field.options ?? []).map((o) => ({ id: o.value, label: o.label })) +} + +// `@current`, `@next` and `@previous`, optionally with `+N` / `-N`, as in the filter string +const ITERATION_KEYWORD = /^@(current|next|previous)(?:([+-])(\d+))?$/i + +function iterationOptions (field: ProjectField, lookups: IssueCellLookups): Array> { + return getAssignableIterations(lookups.iterations?.(field) ?? []).map((it) => ({ id: it._id, label: it.label })) +} + +function parseIteration ( + field: ProjectField, + text: string, + lookups: IssueCellLookups, + now: number +): tableEdit.ParseResult { + const m = ITERATION_KEYWORD.exec(text.trim()) + if (m === null) return tableEdit.parseOptionValue(text, iterationOptions(field, lookups)) + const offset = m[2] === undefined ? 0 : Number(m[3]) * (m[2] === '-' ? -1 : 1) + const target = resolveRelativeIteration( + lookups.iterations?.(field) ?? [], + m[1].toLowerCase() as 'current' | 'next' | 'previous', + offset, + now + ) + return target === undefined ? { ok: false, reason: 'unknown' } : { ok: true, value: target._id } +} + +function customFieldColumn (field: ProjectField, lookups: () => IssueCellLookups): CellColumn | undefined { + const key = customFieldColumnKey(field.key) + const read = (issue: Issue): ReturnType => getFieldValue(issue.customFields, field) + + let format: (issue: Issue) => string + let parse: (text: string, now: number) => tableEdit.ParseResult + switch (field.type) { + case ProjectFieldType.Text: + format = (issue) => (read(issue) as string | null) ?? '' + parse = (text) => tableEdit.parseTextValue(text) + break + case ProjectFieldType.Number: + format = (issue) => tableEdit.formatNumberValue(read(issue) as number | null) + parse = (text) => tableEdit.parseNumberValue(text) + break + case ProjectFieldType.Date: + format = (issue) => tableEdit.formatDateValue(read(issue) as number | null) + parse = (text, now) => tableEdit.parseDateValue(text, now) + break + case ProjectFieldType.SingleSelect: + format = (issue) => tableEdit.formatOptionValue(read(issue) as string | null, fieldOptions(field)) + parse = (text) => tableEdit.parseOptionValue(text, fieldOptions(field)) + break + case ProjectFieldType.MultiSelect: + format = (issue) => tableEdit.formatMultiOptionValue(read(issue) as string[] | null, fieldOptions(field)) + parse = (text) => tableEdit.parseMultiOptionValue(text, fieldOptions(field)) + break + case ProjectFieldType.Iteration: + format = (issue) => tableEdit.formatOptionValue(read(issue) as string | null, iterationOptions(field, lookups())) + parse = (text, now) => parseIteration(field, text, lookups(), now) + break + default: + return undefined + } + return { + key, + clearable: true, + format, + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'customFields') === false) return fail('readonly') + const parsed = parse(text, l.now?.() ?? Date.now()) + if (parsed.ok === false) return parsed + const customFields = mergeCustomFieldValue(issue.customFields, field.key, parsed.value) + const op = tableEdit.updateOp(issueTarget(issue), issue as unknown as Record, { customFields }) + // An issue without custom fields goes back to an empty record, not to null + if (op.kind === 'update') op.before.customFields = issue.customFields ?? {} + return ok([op]) + } + } +} + +/** + * Cells of the issue table: how each column is shown on the clipboard and how pasted text becomes an update. + * The lookups are read on every call, so the adapter follows the data the view currently has. + */ +export function createIssueCellColumns (lookups: () => IssueCellLookups): (key: string) => CellColumn | undefined { + const columns = new Map() + columns.set('title', { + key: 'title', + format: (issue) => issue.title, + edit: (issue, text) => { + if (lookups().canEdit?.(issue, 'title') === false) return fail('readonly') + const title = text.trim() + // An issue cannot lose its title + return title === '' ? fail('notClearable') : setAttribute(issue, 'title', title) + } + }) + columns.set('status', { + key: 'status', + format: (issue) => tableEdit.formatOptionValue(issue.status, lookups().statuses(issue)), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'status') === false) return fail('readonly') + const parsed = tableEdit.parseOptionValue(text, l.statuses(issue)) + if (parsed.ok === false) return parsed + // Every issue has a status + return parsed.value === null ? fail('notClearable') : setAttribute(issue, 'status', parsed.value) + } + }) + columns.set('priority', { + key: 'priority', + clearable: true, + format: (issue) => tableEdit.formatOptionValue(issue.priority, lookups().priorities), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'priority') === false) return fail('readonly') + const parsed = tableEdit.parseOptionValue(text, l.priorities) + // Clearing the priority sets "No priority" + return parsed.ok ? setAttribute(issue, 'priority', parsed.value ?? IssuePriority.NoPriority) : parsed + } + }) + columns.set('assignee', optionColumn('assignee', 'assignee', (l) => () => l.assignees, lookups)) + columns.set('component', optionColumn('component', 'component', (l) => () => l.components, lookups)) + columns.set('milestone', optionColumn('milestone', 'milestone', (l) => () => l.milestones, lookups)) + columns.set('dueDate', { + key: 'dueDate', + clearable: true, + format: (issue) => tableEdit.formatDateValue(issue.dueDate), + edit: (issue, text) => { + const l = lookups() + if (l.canEdit?.(issue, 'dueDate') === false) return fail('readonly') + const parsed = tableEdit.parseDateValue(text, l.now?.() ?? Date.now()) + return parsed.ok ? setAttribute(issue, 'dueDate', parsed.value) : parsed + } + }) + columns.set('estimation', numberColumn('estimation', 'estimation', lookups)) + columns.set('labels', labelsColumn(lookups)) + + return (key) => { + const known = columns.get(key) + if (known !== undefined) return known + if (!key.startsWith(CELL_FIELD_PREFIX)) return undefined + const field = lookups().fields.get(key.slice(CELL_FIELD_PREFIX.length)) + return field === undefined ? undefined : customFieldColumn(field, lookups) + } +} + +/** + * Applies edit operations to the issues in one atomic batch. + */ +export async function runIssueOps (client: TxOperations, ops: readonly EditOp[]): Promise { + const batch = client.apply() + for (const op of ops) { + const t = op.target + switch (op.kind) { + case 'update': + await batch.updateCollection( + t._class as Issue['_class'], + t.space as Issue['space'], + t._id as Issue['_id'], + t.attachedTo as Issue['attachedTo'], + t.attachedToClass as Issue['attachedToClass'], + t.collection as string, + op.after as DocumentUpdate + ) + break + case 'add': + await batch.addCollection( + t._class as any, + t.space as any, + t.attachedTo as any, + t.attachedToClass as any, + t.collection as string, + op.attributes as any, + t._id as any + ) + break + case 'remove': + await batch.removeCollection( + t._class as any, + t.space as any, + t._id as any, + t.attachedTo as any, + t.attachedToClass as any, + t.collection as string + ) + break + } + } + const res = await batch.commit() + if (!res.result) throw new Error('The changes were rejected: the data was changed meanwhile') +} diff --git a/plugins/tracker-resources/src/calendar/__tests__/config.test.ts b/plugins/tracker-resources/src/calendar/__tests__/config.test.ts new file mode 100644 index 0000000000..3f19e9cc85 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/__tests__/config.test.ts @@ -0,0 +1,127 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import path from 'path' +import { + CALENDAR_OPTION_KEY, + calendarSelection, + DEFAULT_CALENDAR_CONFIG, + isDefaultCalendarConfig, + NO_DATE_SOURCE, + normalizeCalendarConfig, + readCalendarConfig, + sanitizeCalendarConfig, + withCalendarConfig +} from '../config' + +// The saved views logic is a pure module of view-resources. The path is built at run time so that the type +// checkers do not pull view-resources sources into this package. +const { isViewDirty } = require(path.resolve(__dirname, '../../../../view-resources/src/savedViews')) as { + isViewDirty: (baseline: Record, current: Record) => boolean +} + +describe('normalizeCalendarConfig', () => { + it('gives the defaults for nothing or garbage', () => { + expect(normalizeCalendarConfig(undefined)).toEqual(DEFAULT_CALENDAR_CONFIG) + expect(normalizeCalendarConfig(null)).toEqual(DEFAULT_CALENDAR_CONFIG) + expect(normalizeCalendarConfig('x')).toEqual(DEFAULT_CALENDAR_CONFIG) + expect(normalizeCalendarConfig({ mode: 'year', start: 5, target: 'nope' })).toEqual(DEFAULT_CALENDAR_CONFIG) + }) + + it('keeps valid values, a switched off field included', () => { + const config = { mode: 'agenda', start: NO_DATE_SOURCE, target: 'field:release' } + expect(normalizeCalendarConfig(config)).toEqual(config) + expect(normalizeCalendarConfig({ mode: 'week', start: 'iteration:sprint', target: 'iteration:sprint' })).toEqual({ + mode: 'week', + start: 'iteration:sprint', + target: 'iteration:sprint' + }) + }) + + it('needs one date field: two switched off give the default pair and keep the mode', () => { + expect(normalizeCalendarConfig({ mode: 'week', start: 'none', target: 'none' })).toEqual({ + ...DEFAULT_CALENDAR_CONFIG, + mode: 'week' + }) + }) + + it('does not share state with the defaults', () => { + const res = normalizeCalendarConfig(undefined) + res.start = 'x' + expect(DEFAULT_CALENDAR_CONFIG.start).toBe('issue:startDate') + }) +}) + +describe('storing the config in the view options', () => { + const options = { groupBy: ['#no_category'], orderBy: ['rank', 1] } + + it('stores a customized config and reads it back next to the other options', () => { + const config = { ...DEFAULT_CALENDAR_CONFIG, mode: 'week' as const, target: 'field:release' } + const stored = withCalendarConfig(options, config) + expect(stored.groupBy).toEqual(options.groupBy) + expect(readCalendarConfig(stored)).toEqual(config) + }) + + it('does not store the default config and removes a stored one that returns to it', () => { + const custom = withCalendarConfig(options, { ...DEFAULT_CALENDAR_CONFIG, mode: 'agenda' }) + expect(CALENDAR_OPTION_KEY in custom).toBe(true) + const back = withCalendarConfig(custom, { ...DEFAULT_CALENDAR_CONFIG }) + expect(CALENDAR_OPTION_KEY in back).toBe(false) + expect(back).toEqual(options) + }) + + it('does not modify the options it is given', () => { + const frozen = Object.freeze({ ...options }) + expect(() => withCalendarConfig(frozen, { ...DEFAULT_CALENDAR_CONFIG, mode: 'week' })).not.toThrow() + }) + + it('does not collide with the roadmap or board settings', () => { + const stored = withCalendarConfig({ roadmap: { zoom: 'year' }, board: { columnField: 'x' } }, { + ...DEFAULT_CALENDAR_CONFIG, + mode: 'week' + }) + expect(stored.roadmap).toEqual({ zoom: 'year' }) + expect(stored.board).toEqual({ columnField: 'x' }) + }) + + it('tells the default from a changed config', () => { + expect(isDefaultCalendarConfig(normalizeCalendarConfig(undefined))).toBe(true) + expect(isDefaultCalendarConfig({ ...DEFAULT_CALENDAR_CONFIG, start: NO_DATE_SOURCE })).toBe(false) + }) + + it('makes a saved view dirty only when the calendar settings changed', () => { + const saved = { viewOptions: withCalendarConfig(options, DEFAULT_CALENDAR_CONFIG) as any } + expect(isViewDirty(saved, { viewOptions: withCalendarConfig(options, DEFAULT_CALENDAR_CONFIG) as any })).toBe(false) + const week = withCalendarConfig(options, { ...DEFAULT_CALENDAR_CONFIG, mode: 'week' }) + expect(isViewDirty(saved, { viewOptions: week as any })).toBe(true) + // Changing the setting and changing it back is not a change + const again = withCalendarConfig(week, { ...DEFAULT_CALENDAR_CONFIG }) + expect(isViewDirty(saved, { viewOptions: again as any })).toBe(false) + // A view saved with a calendar config is clean while it is unchanged + const customSaved = { viewOptions: week as any } + expect(isViewDirty(customSaved, { viewOptions: withCalendarConfig(options, readCalendarConfig(week)) as any })).toBe(false) + }) +}) + +describe('sanitizeCalendarConfig', () => { + const sources = new Set(['issue:startDate', 'issue:dueDate', 'field:release']) + + it('keeps a config that is fully available, a switched off field included', () => { + const config = { mode: 'week' as const, start: NO_DATE_SOURCE, target: 'field:release' } + expect(sanitizeCalendarConfig(config, sources)).toEqual(config) + }) + + it('falls back to the default for a field that is gone', () => { + const res = sanitizeCalendarConfig({ mode: 'month', start: 'field:deleted', target: 'field:release' }, sources) + expect(calendarSelection(res)).toEqual({ start: DEFAULT_CALENDAR_CONFIG.start, target: 'field:release' }) + }) + + it('does not end up with no date field at all', () => { + const res = sanitizeCalendarConfig({ mode: 'agenda', start: NO_DATE_SOURCE, target: 'field:deleted' }, new Set(['issue:startDate'])) + // The target falls back to the due date, which is not available either, but the start is switched off: use the defaults + expect(res.mode).toBe('agenda') + expect(res.start === NO_DATE_SOURCE && res.target === NO_DATE_SOURCE).toBe(false) + }) +}) diff --git a/plugins/tracker-resources/src/calendar/__tests__/events.test.ts b/plugins/tracker-resources/src/calendar/__tests__/events.test.ts new file mode 100644 index 0000000000..e689380c13 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/__tests__/events.test.ts @@ -0,0 +1,208 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { groupAgenda } from '../agenda' +import { + buildCalendarEvents, + compareEvents, + eventsInRange, + eventsOnDay, + isMultiDay, + layoutWeek, + type CalendarEvent +} from '../events' +import type { ItemSchedule } from '../../roadmap/dates' + +interface Item { + id: string + schedule: ItemSchedule +} + +const range = (id: string, start: number, target: number, inverted = false): Item => ({ + id, + schedule: { kind: 'range', start, target, inverted } +}) +const marker = (id: string, day: number): Item => ({ id, schedule: { kind: 'marker', day, role: 'target' } }) +const none = (id: string): Item => ({ id, schedule: { kind: 'unscheduled' } }) + +function build (items: Item[]): { events: Array>, unscheduled: Item[] } { + return buildCalendarEvents( + items, + (i) => i.id, + (i) => i.schedule + ) +} + +// A week that starts on day 100 +const W = 100 + +describe('buildCalendarEvents', () => { + it('makes bars, chips and sets aside the items without a day, keeping the order', () => { + const { events, unscheduled } = build([range('a', 101, 103), none('b'), marker('c', 105), range('d', 102, 102)]) + expect(events.map((e) => e.id)).toEqual(['a', 'c', 'd']) + expect(events.map((e) => e.order)).toEqual([0, 2, 3]) + expect(unscheduled.map((i) => i.id)).toEqual(['b']) + expect(events.map(isMultiDay)).toEqual([true, false, false]) + }) + + it('carries the inverted flag of a range', () => { + const { events } = build([range('a', 101, 103, true)]) + expect(events[0]).toMatchObject({ start: 101, end: 103, inverted: true }) + }) +}) + +describe('event queries', () => { + const { events } = build([range('a', 98, 102), marker('b', 105), range('c', 110, 112), marker('d', 101)]) + + it('finds the events that touch a range', () => { + expect(eventsInRange(events, { start: 100, end: 107 }).map((e) => e.id)).toEqual(['a', 'b', 'd']) + expect(eventsInRange(events, { start: 103, end: 105 }).map((e) => e.id)).toEqual([]) + expect(eventsInRange(events, { start: 112, end: 113 }).map((e) => e.id)).toEqual(['c']) + }) + + it('lists the events of a day with bars first', () => { + expect(eventsOnDay(events, 101).map((e) => e.id)).toEqual(['a', 'd']) + expect(eventsOnDay(events, 111).map((e) => e.id)).toEqual(['c']) + expect(eventsOnDay(events, 120)).toEqual([]) + }) + + it('orders bars by start then length and chips by the sort order', () => { + const list = build([marker('chip1', 101), range('long', 101, 105), range('short', 101, 102), range('early', 99, 101), marker('chip0', 101)]) + .events + const ids = [...list].sort(compareEvents).map((e) => e.id) + expect(ids).toEqual(['early', 'long', 'short', 'chip1', 'chip0']) + }) +}) + +describe('layoutWeek', () => { + it('splits a bar at the edges of the week and flags the continuation', () => { + const { events } = build([range('a', 97, 102), range('b', 104, 110)]) + const layout = layoutWeek(events, W) + const a = layout.segments.find((s) => s.event.id === 'a') + const b = layout.segments.find((s) => s.event.id === 'b') + expect(a).toMatchObject({ col: 0, span: 3, continuesBefore: true, continuesAfter: false }) + expect(b).toMatchObject({ col: 4, span: 3, continuesBefore: false, continuesAfter: true }) + // The next week has the rest of both + const next = layoutWeek(events, W + 7) + expect(next.segments.map((s) => s.event.id)).toEqual(['b']) + expect(next.segments[0]).toMatchObject({ col: 0, span: 4, continuesBefore: true, continuesAfter: false }) + }) + + it('packs overlapping bars into separate lanes and reuses a lane after a bar ended', () => { + const { events } = build([range('a', 100, 102), range('b', 101, 103), range('c', 103, 105), range('d', 104, 106)]) + const lanes = Object.fromEntries(layoutWeek(events, W).segments.map((s) => [s.event.id, s.lane])) + expect(lanes).toEqual({ a: 0, b: 1, c: 0, d: 1 }) + expect(layoutWeek(events, W).laneCount).toBe(2) + }) + + it('never puts two segments over the same column in one lane', () => { + const items: Item[] = [] + for (let i = 0; i < 40; i++) { + const start = W - 2 + ((i * 3) % 9) + items.push(i % 3 === 0 ? marker(`m${i}`, start) : range(`r${i}`, start, start + (i % 5))) + } + const layout = layoutWeek(build(items).events, W) + const used = new Set() + for (const s of layout.segments) { + for (let c = s.col; c < s.col + s.span; c++) { + const key = `${s.lane}:${c}` + expect(used.has(key)).toBe(false) + used.add(key) + } + } + }) + + it('gives the chips of a day lanes in the sort order, below the bars', () => { + const { events } = build([marker('c0', 101), marker('c1', 101), range('bar', 100, 103), marker('c2', 101)]) + const layout = layoutWeek(events, W) + const laneOf = (id: string): number => layout.segments.find((s) => s.event.id === id)?.lane ?? -1 + expect(laneOf('bar')).toBe(0) + expect(laneOf('c0')).toBe(1) + expect(laneOf('c1')).toBe(2) + expect(laneOf('c2')).toBe(3) + }) + + it('shows everything when there is no limit or enough room', () => { + const { events } = build([marker('a', 100), marker('b', 100), marker('c', 100)]) + const unlimited = layoutWeek(events, W) + expect(unlimited.visibleLanes).toBe(3) + expect(unlimited.segments.every((s) => !s.hidden)).toBe(true) + expect(layoutWeek(events, W, 3).hiddenByColumn).toEqual([0, 0, 0, 0, 0, 0, 0]) + }) + + it('hides what does not fit, keeps the last row for "+N more" and counts the hidden per day', () => { + const { events } = build([ + marker('a', 100), + marker('b', 100), + marker('c', 100), + marker('d', 100), + range('wide', 100, 102), + marker('e', 102) + ]) + const layout = layoutWeek(events, W, 3) + // The bar takes lane 0, then a, b, c, d on lanes 1-4, and e on lane 1 of column 2 + expect(layout.laneCount).toBe(5) + expect(layout.visibleLanes).toBe(2) + const hidden = layout.segments.filter((s) => s.hidden).map((s) => s.event.id) + expect(hidden).toEqual(['b', 'c', 'd']) + expect(layout.hiddenByColumn).toEqual([3, 0, 0, 0, 0, 0, 0]) + expect(layout.segments.filter((s) => !s.hidden).map((s) => s.event.id)).toEqual(['wide', 'a', 'e']) + }) + + it('counts a hidden bar on each day it covers', () => { + const { events } = build([marker('a', 100), marker('b', 101), range('x', 100, 101), range('y', 100, 101), range('z', 100, 101)]) + const layout = layoutWeek(events, W, 2) + expect(layout.visibleLanes).toBe(1) + expect(layout.hiddenByColumn[0]).toBe(layout.segments.filter((s) => s.hidden && s.col <= 0 && s.col + s.span > 0).length) + expect(layout.hiddenByColumn[0]).toBeGreaterThan(0) + expect(layout.hiddenByColumn[1]).toBeGreaterThan(0) + }) + + it('shows only the "+N more" row when a single row fits', () => { + const { events } = build([marker('a', 100), marker('b', 100)]) + const layout = layoutWeek(events, W, 1) + expect(layout.visibleLanes).toBe(0) + expect(layout.hiddenByColumn[0]).toBe(2) + }) + + it('handles a week without events', () => { + const layout = layoutWeek([], W, 3) + expect(layout).toMatchObject({ laneCount: 0, visibleLanes: 0, segments: [] }) + }) +}) + +describe('groupAgenda', () => { + it('groups by day in date order with bars before chips, listing a bar on each of its days', () => { + const { events } = build([marker('chip', 102), range('bar', 101, 103), marker('early', 101), marker('outside', 200)]) + const days = groupAgenda(events, { start: 100, end: 110 }) + expect(days.map((d) => d.day)).toEqual([101, 102, 103]) + expect(days[0].entries.map((e) => e.event.id)).toEqual(['bar', 'early']) + expect(days[1].entries.map((e) => e.event.id)).toEqual(['bar', 'chip']) + expect(days[2].entries.map((e) => e.event.id)).toEqual(['bar']) + }) + + it('marks the first and the last day of an event', () => { + const { events } = build([range('bar', 101, 103)]) + const days = groupAgenda(events, { start: 100, end: 110 }) + expect(days.map((d) => [d.entries[0].first, d.entries[0].last])).toEqual([ + [true, false], + [false, false], + [false, true] + ]) + }) + + it('clips an event to the range', () => { + const { events } = build([range('bar', 95, 105)]) + const days = groupAgenda(events, { start: 100, end: 103 }) + expect(days.map((d) => d.day)).toEqual([100, 101, 102]) + expect(days[0].entries[0].first).toBe(false) + expect(days[2].entries[0].last).toBe(false) + }) + + it('is empty without events in the range', () => { + expect(groupAgenda([], { start: 0, end: 10 })).toEqual([]) + expect(groupAgenda(build([marker('a', 50)]).events, { start: 0, end: 10 })).toEqual([]) + }) +}) diff --git a/plugins/tracker-resources/src/calendar/__tests__/grid.test.ts b/plugins/tracker-resources/src/calendar/__tests__/grid.test.ts new file mode 100644 index 0000000000..f21efdc051 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/__tests__/grid.test.ts @@ -0,0 +1,256 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { dayParts, dayToTime, toDay } from '../../roadmap/timeScale' +import { + addMonths, + anchorForDay, + buildMonthGrid, + buildWeek, + dayOf, + daysInMonth, + formatCalendarTitle, + isFocusKey, + isWeekendDay, + monthStartOf, + moveFocusDay, + normalizeFirstDay, + rangeHas, + shiftAnchor, + visibleRange, + weekdayLabels, + weekStartOf +} from '../grid' + +const local = (y: number, m: number, d: number, h = 0): number => new Date(y, m - 1, d, h).getTime() +const day = (y: number, m: number, d: number): number => toDay(local(y, m, d)) + +const MONDAY = 1 +const SUNDAY = 0 +const SATURDAY = 6 + +describe('normalizeFirstDay', () => { + it('keeps a valid weekday and maps Intl Sunday (7) to 0', () => { + expect(normalizeFirstDay(0)).toBe(0) + expect(normalizeFirstDay(1)).toBe(1) + expect(normalizeFirstDay(6)).toBe(6) + expect(normalizeFirstDay(7)).toBe(0) + }) + + it('falls back to Monday for anything else', () => { + for (const v of [undefined, null, 'x', -1, 8, 1.5, NaN]) expect(normalizeFirstDay(v)).toBe(MONDAY) + }) +}) + +describe('months and weeks', () => { + it('knows the length of a month, leap years included', () => { + expect(daysInMonth(2026, 1)).toBe(28) + expect(daysInMonth(2028, 1)).toBe(29) + expect(daysInMonth(2026, 9)).toBe(31) + expect(daysInMonth(2026, 10)).toBe(30) + }) + + it('finds the first day of the week for every week start', () => { + // Saturday the 3rd of October 2026 + const d = day(2026, 10, 3) + expect(dayParts(d).weekday).toBe(SATURDAY) + expect(weekStartOf(d, MONDAY)).toBe(day(2026, 9, 28)) + expect(weekStartOf(d, SUNDAY)).toBe(day(2026, 9, 27)) + expect(weekStartOf(d, SATURDAY)).toBe(d) + // A day that is the first day of its week stays + expect(weekStartOf(day(2026, 9, 28), MONDAY)).toBe(day(2026, 9, 28)) + }) + + it('adds months and clamps the date to the length of the target month', () => { + expect(addMonths(day(2026, 1, 31), 1)).toBe(day(2026, 2, 28)) + expect(addMonths(day(2028, 1, 31), 1)).toBe(day(2028, 2, 29)) + expect(addMonths(day(2026, 3, 31), -1)).toBe(day(2026, 2, 28)) + expect(addMonths(day(2026, 12, 15), 1)).toBe(day(2027, 1, 15)) + expect(addMonths(day(2026, 1, 15), -1)).toBe(day(2025, 12, 15)) + expect(addMonths(day(2026, 5, 10), 14)).toBe(day(2027, 7, 10)) + }) + + it('finds the weekend', () => { + expect(isWeekendDay(day(2026, 10, 3))).toBe(true) + expect(isWeekendDay(day(2026, 10, 4))).toBe(true) + expect(isWeekendDay(day(2026, 10, 5))).toBe(false) + expect(isWeekendDay(day(2026, 10, 2), [5, 6])).toBe(true) + }) +}) + +describe('buildMonthGrid', () => { + const today = day(2026, 10, 3) + + it('has whole weeks from the one with the 1st to the one with the last day (Monday start)', () => { + // October 2026 starts on a Thursday and ends on a Saturday + const weeks = buildMonthGrid(day(2026, 10, 20), MONDAY, today) + expect(weeks).toHaveLength(5) + expect(weeks[0].startDay).toBe(day(2026, 9, 28)) + expect(weeks[0].days[0].day).toBe(day(2026, 9, 28)) + expect(weeks[4].days[6].day).toBe(day(2026, 11, 1)) + for (const w of weeks) expect(w.days).toHaveLength(7) + }) + + it('starts the weeks on Sunday when asked to', () => { + const weeks = buildMonthGrid(day(2026, 10, 20), SUNDAY, today) + expect(weeks[0].startDay).toBe(day(2026, 9, 27)) + expect(dayParts(weeks[0].startDay).weekday).toBe(SUNDAY) + }) + + it('needs 6 weeks for a month that starts late in the week and 4 for a short one that starts on the first day', () => { + // August 2026 starts on a Saturday, 31 days + expect(buildMonthGrid(day(2026, 8, 10), MONDAY, today)).toHaveLength(6) + // February 2027 starts on a Monday, 28 days + expect(buildMonthGrid(day(2027, 2, 10), MONDAY, today)).toHaveLength(4) + }) + + it('marks the days of the month, today and the weekend', () => { + const weeks = buildMonthGrid(day(2026, 10, 20), MONDAY, today) + const all = weeks.flatMap((w) => w.days) + expect(all.filter((d) => d.inMonth)).toHaveLength(31) + expect(all.filter((d) => d.today).map((d) => d.day)).toEqual([today]) + expect(all.every((d) => d.weekend === (d.weekday === 0 || d.weekday === 6))).toBe(true) + expect(all[0]).toMatchObject({ inMonth: false, month: 8, date: 28 }) + }) + + it('has consecutive days with no gap and no repeat', () => { + for (const first of [SUNDAY, MONDAY, SATURDAY]) { + const days = buildMonthGrid(day(2026, 10, 1), first, today).flatMap((w) => w.days.map((d) => d.day)) + for (let i = 1; i < days.length; i++) expect(days[i]).toBe(days[i - 1] + 1) + } + }) + + it('does not lose or repeat a day around a daylight saving change', () => { + // Changes of the clock in Europe, the US and New Zealand fall in these months; whatever the time zone of the run is + for (const [y, m] of [ + [2026, 3], + [2026, 4], + [2026, 9], + [2026, 10], + [2026, 11] + ]) { + const weeks = buildMonthGrid(day(y, m, 15), MONDAY, today) + const days = weeks.flatMap((w) => w.days) + expect(days.length).toBe(weeks.length * 7) + expect(days.filter((d) => d.inMonth)).toHaveLength(daysInMonth(y, m - 1)) + for (const d of days) { + // Every cell is a distinct local calendar date that maps back to itself + expect(toDay(dayToTime(d.day))).toBe(d.day) + expect(new Date(dayToTime(d.day)).getDate()).toBe(d.date) + } + for (let i = 1; i < days.length; i++) expect(days[i].day).toBe(days[i - 1].day + 1) + } + }) +}) + +describe('buildWeek', () => { + it('is the week of the day with the right first day', () => { + const w = buildWeek(day(2026, 10, 3), MONDAY, day(2026, 10, 3)) + expect(w.days.map((d) => d.date)).toEqual([28, 29, 30, 1, 2, 3, 4]) + expect(w.days.every((d) => d.inMonth)).toBe(true) + expect(w.days[5].today).toBe(true) + const s = buildWeek(day(2026, 10, 3), SUNDAY, 0) + expect(s.days[0].day).toBe(day(2026, 9, 27)) + }) + + it('has 7 days across the clock change', () => { + // Europe moves the clock on the 25th of October 2026, the US on the 1st of November + for (const d of [day(2026, 10, 25), day(2026, 11, 1), day(2026, 3, 29), day(2026, 3, 8), day(2026, 4, 5), day(2026, 9, 27)]) { + const w = buildWeek(d, MONDAY, 0) + expect(w.days).toHaveLength(7) + expect(w.days.map((x) => x.day)).toEqual([0, 1, 2, 3, 4, 5, 6].map((i) => w.startDay + i)) + expect(new Set(w.days.map((x) => x.date)).size).toBe(7) + } + }) +}) + +describe('visibleRange and navigation', () => { + it('covers the whole weeks of the grid in the month mode', () => { + const r = visibleRange('month', day(2026, 10, 20), MONDAY) + expect(r).toEqual({ start: day(2026, 9, 28), end: day(2026, 11, 2) }) + }) + + it('is the week in the week mode and exactly the month for the agenda', () => { + expect(visibleRange('week', day(2026, 10, 3), MONDAY)).toEqual({ start: day(2026, 9, 28), end: day(2026, 10, 5) }) + expect(visibleRange('agenda', day(2026, 10, 20), MONDAY)).toEqual({ start: day(2026, 10, 1), end: day(2026, 11, 1) }) + }) + + it('moves by a month, or a week in the week mode', () => { + expect(shiftAnchor('month', day(2026, 10, 31), 1)).toBe(day(2026, 11, 30)) + expect(shiftAnchor('agenda', day(2026, 1, 31), -1)).toBe(day(2025, 12, 31)) + expect(shiftAnchor('week', day(2026, 10, 3), 1)).toBe(day(2026, 10, 10)) + expect(shiftAnchor('week', day(2026, 10, 3), -1)).toBe(day(2026, 9, 26)) + }) + + it('walks forward and back to where it started within a month length', () => { + const start = day(2026, 6, 15) + let a = start + for (let i = 0; i < 12; i++) a = shiftAnchor('month', a, 1) + expect(a).toBe(day(2027, 6, 15)) + for (let i = 0; i < 12; i++) a = shiftAnchor('month', a, -1) + expect(a).toBe(start) + }) + + it('keeps the anchor when the day is visible and jumps to the day otherwise', () => { + const anchor = day(2026, 10, 20) + expect(anchorForDay('month', anchor, day(2026, 10, 31), MONDAY)).toBe(anchor) + // The grid shows the first days of November + expect(anchorForDay('month', anchor, day(2026, 11, 1), MONDAY)).toBe(anchor) + expect(anchorForDay('month', anchor, day(2026, 11, 20), MONDAY)).toBe(day(2026, 11, 20)) + expect(rangeHas({ start: 5, end: 8 }, 7)).toBe(true) + expect(rangeHas({ start: 5, end: 8 }, 8)).toBe(false) + }) + + it('has month starts', () => { + expect(monthStartOf(day(2026, 10, 20))).toBe(day(2026, 10, 1)) + expect(dayOf(2026, 11, 32)).toBe(day(2027, 1, 1)) + }) +}) + +describe('keyboard', () => { + const d = day(2026, 10, 7) + + it('moves by a day, a week, to the ends of the week and by a month', () => { + expect(moveFocusDay(d, 'ArrowLeft', MONDAY)).toBe(d - 1) + expect(moveFocusDay(d, 'ArrowRight', MONDAY)).toBe(d + 1) + expect(moveFocusDay(d, 'ArrowUp', MONDAY)).toBe(d - 7) + expect(moveFocusDay(d, 'ArrowDown', MONDAY)).toBe(d + 7) + expect(moveFocusDay(d, 'Home', MONDAY)).toBe(day(2026, 10, 5)) + expect(moveFocusDay(d, 'End', MONDAY)).toBe(day(2026, 10, 11)) + expect(moveFocusDay(d, 'Home', SUNDAY)).toBe(day(2026, 10, 4)) + expect(moveFocusDay(d, 'End', SUNDAY)).toBe(day(2026, 10, 10)) + expect(moveFocusDay(d, 'PageDown', MONDAY)).toBe(day(2026, 11, 7)) + expect(moveFocusDay(d, 'PageUp', MONDAY)).toBe(day(2026, 9, 7)) + }) + + it('recognises the keys it handles', () => { + expect(isFocusKey('ArrowLeft')).toBe(true) + expect(isFocusKey('Enter')).toBe(false) + }) +}) + +describe('labels', () => { + it('lists the weekday names from the first day of the week', () => { + expect(weekdayLabels(MONDAY, 'en')).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) + expect(weekdayLabels(SUNDAY, 'en')[0]).toBe('Sun') + expect(weekdayLabels(SATURDAY, 'en')).toEqual(['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']) + expect(weekdayLabels(MONDAY, 'en', 'long')[0]).toBe('Monday') + }) + + it('titles a month and a week', () => { + expect(formatCalendarTitle('month', day(2026, 10, 20), MONDAY, 'en')).toBe('October 2026') + expect(formatCalendarTitle('agenda', day(2026, 10, 20), MONDAY, 'en')).toBe('October 2026') + const week = formatCalendarTitle('week', day(2026, 10, 7), MONDAY, 'en') + expect(week).toContain('5') + expect(week).toContain('11') + expect(week).toContain('2026') + }) + + it('titles a week that spans two years', () => { + const title = formatCalendarTitle('week', day(2026, 12, 31), MONDAY, 'en') + expect(title).toContain('2026') + expect(title).toContain('2027') + }) +}) diff --git a/plugins/tracker-resources/src/calendar/__tests__/reschedule.test.ts b/plugins/tracker-resources/src/calendar/__tests__/reschedule.test.ts new file mode 100644 index 0000000000..901f7b358a --- /dev/null +++ b/plugins/tracker-resources/src/calendar/__tests__/reschedule.test.ts @@ -0,0 +1,188 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType } from '@hcengineering/tracker' +import { draftValuesForDay } from '../addItem' +import { dragDelta } from '../drag' +import { buildDateSources, resolveSchedule, type DateIssue, type DateLookups, type DateSelection } from '../../roadmap/dates' +import { buildIssuePatch, planReschedule, planSchedule, previewSchedule, type PlanContext } from '../../roadmap/reschedule' +import { toDay } from '../../roadmap/timeScale' + +const local = (y: number, m: number, d: number, h = 0): number => new Date(y, m - 1, d, h).getTime() +const day = (y: number, m: number, d: number): number => toDay(local(y, m, d)) + +const iterations = [ + { _id: 'i1', startDate: local(2026, 10, 5), duration: 7 }, + { _id: 'i2', startDate: local(2026, 10, 12), duration: 7 } +] +const lookups: DateLookups = { + milestone: (id) => (id === 'm' ? { startDate: local(2026, 11, 1), targetDate: local(2026, 11, 30) } : undefined), + iterations: (key) => (key === 'sprint' ? iterations : []) +} +const sources = new Map( + buildDateSources( + [ + { key: 'release', label: 'Release', type: ProjectFieldType.Date }, + { key: 'sprint', label: 'Sprint', type: ProjectFieldType.Iteration } + ], + { startDate: 'S', dueDate: 'D', deadline: 'DL', milestoneStart: 'MS', milestoneTarget: 'MT' } + ).map((s) => [s.id, s]) +) + +function ctx (issue: DateIssue, selection: DateSelection): PlanContext { + return { issue, selection, sources, lookups, schedule: resolveSchedule(issue, selection, lookups) } +} + +const dates: DateSelection = { start: 'issue:startDate', target: 'issue:dueDate' } + +describe('dragDelta', () => { + const range = { kind: 'range' as const, start: 10, target: 14, inverted: false } + + it('moves by the distance between the grabbed day and the day under the pointer', () => { + // Grabbed the bar by its third day (12), dropped on day 20: both dates move by 8 and keep the duration + expect(dragDelta('move', range, 12, 20)).toBe(8) + expect(dragDelta('move', range, 12, 5)).toBe(-7) + expect(dragDelta('move', { kind: 'marker', day: 3, role: 'target' }, 3, 3)).toBe(0) + }) + + it('moves the dragged edge to the day under the pointer', () => { + expect(dragDelta('resize-start', range, 10, 8)).toBe(-2) + expect(dragDelta('resize-end', range, 14, 20)).toBe(6) + }) + + it('cannot resize what is not a range', () => { + const marker = { kind: 'marker' as const, day: 3, role: 'target' as const } + expect(dragDelta('resize-start', marker, 3, 9)).toBe(0) + expect(dragDelta('resize-end', { kind: 'unscheduled' }, 3, 9)).toBe(0) + }) +}) + +describe('rescheduling an event', () => { + it('keeps the duration when a bar is moved to another day', () => { + const issue: DateIssue = { startDate: local(2026, 10, 12), dueDate: local(2026, 10, 14) } + const schedule = resolveSchedule(issue, dates, lookups) + const delta = dragDelta('move', schedule, day(2026, 10, 13), day(2026, 10, 27)) + const plan = planReschedule(ctx(issue, dates), 'move', delta) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + const patch = buildIssuePatch(issue, plan.writes) + expect(patch).toEqual({ startDate: local(2026, 10, 26), dueDate: local(2026, 10, 28) }) + // The preview shows the same days + expect(previewSchedule(schedule, 'move', delta)).toMatchObject({ start: day(2026, 10, 26), target: day(2026, 10, 28) }) + }) + + it('keeps the time of day and moves a bar across the end of daylight saving time', () => { + const issue: DateIssue = { startDate: local(2026, 10, 22, 9), dueDate: local(2026, 10, 24, 17) } + const plan = planReschedule(ctx(issue, dates), 'move', 5) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + const patch = buildIssuePatch(issue, plan.writes) + expect(patch).toEqual({ startDate: local(2026, 10, 27, 9), dueDate: local(2026, 10, 29, 17) }) + }) + + it('writes only the edge that is dragged', () => { + const issue: DateIssue = { startDate: local(2026, 10, 12), dueDate: local(2026, 10, 14) } + const end = planReschedule(ctx(issue, dates), 'resize-end', 3) + expect(end.ok && buildIssuePatch(issue, end.writes)).toEqual({ dueDate: local(2026, 10, 17) }) + const start = planReschedule(ctx(issue, dates), 'resize-start', -2) + expect(start.ok && buildIssuePatch(issue, start.writes)).toEqual({ startDate: local(2026, 10, 10) }) + }) + + it('never lets the start pass the end when an edge is dragged over the other', () => { + const issue: DateIssue = { startDate: local(2026, 10, 12), dueDate: local(2026, 10, 14) } + const schedule = resolveSchedule(issue, dates, lookups) + const delta = dragDelta('resize-end', schedule, day(2026, 10, 14), day(2026, 10, 1)) + const plan = planReschedule(ctx(issue, dates), 'resize-end', delta) + expect(plan.ok && buildIssuePatch(issue, plan.writes)).toEqual({ dueDate: local(2026, 10, 12) }) + }) + + it('moves a chip with one date', () => { + const issue: DateIssue = { dueDate: local(2026, 10, 14) } + const plan = planReschedule(ctx(issue, dates), 'move', -4) + expect(plan.ok && buildIssuePatch(issue, plan.writes)).toEqual({ dueDate: local(2026, 10, 10) }) + }) + + it('does not write a milestone date', () => { + const issue: DateIssue = { milestone: 'm', dueDate: local(2026, 11, 3) } + const selection = { start: 'milestone:startDate', target: 'issue:dueDate' } + const move = planReschedule(ctx(issue, selection), 'move', 2) + expect(move).toEqual({ ok: false, reason: 'readonly' }) + // The other edge can still be dragged + const resize = planReschedule(ctx(issue, selection), 'resize-end', 2) + expect(resize.ok && buildIssuePatch(issue, resize.writes)).toEqual({ dueDate: local(2026, 11, 5) }) + }) + + it('moves an item to the iteration the new day belongs to', () => { + const issue: DateIssue = { customFields: { sprint: 'i1' } } + const selection = { start: 'iteration:sprint', target: 'iteration:sprint' } + const plan = planReschedule(ctx(issue, selection), 'move', 7) + expect(plan.ok && buildIssuePatch(issue, plan.writes)).toEqual({ customFields: { sprint: 'i2' } }) + }) + + it('writes custom date fields without losing the other custom fields', () => { + const issue: DateIssue = { customFields: { release: local(2026, 10, 20), other: 'x' } } + const selection = { start: 'none', target: 'field:release' } + const plan = planReschedule(ctx(issue, selection), 'move', 2) + expect(plan.ok && buildIssuePatch(issue, plan.writes)).toEqual({ + customFields: { release: local(2026, 10, 22), other: 'x' } + }) + }) + + it('reports no change for a drop on the same day', () => { + const issue: DateIssue = { dueDate: local(2026, 10, 14) } + expect(planReschedule(ctx(issue, dates), 'move', 0)).toEqual({ ok: false, reason: 'unchanged' }) + }) + + it('puts an item without a day on the day it is dropped on', () => { + const plan = planSchedule({ issue: {}, selection: dates, sources, lookups }, day(2026, 10, 9)) + expect(plan.ok && buildIssuePatch({}, plan.writes)).toEqual({ + startDate: local(2026, 10, 9), + dueDate: local(2026, 10, 9) + }) + }) + + it('only sets the date that is switched on when the other one is none', () => { + const plan = planSchedule({ issue: {}, selection: { start: 'none', target: 'issue:dueDate' }, sources, lookups }, day(2026, 10, 9)) + expect(plan.ok && buildIssuePatch({}, plan.writes)).toEqual({ dueDate: local(2026, 10, 9) }) + }) +}) + +describe('draftValuesForDay', () => { + const base = { selection: dates, sources, lookups } + + it('gives the day in both date fields', () => { + const res = draftValuesForDay(base, day(2026, 10, 9)) + expect(res).toEqual({ ok: true, values: { startDate: local(2026, 10, 9), dueDate: local(2026, 10, 9) } }) + }) + + it('gives the day in a custom date field and the deadline', () => { + const res = draftValuesForDay({ ...base, selection: { start: 'field:release', target: 'issue:deadline' } }, day(2026, 10, 9)) + expect(res).toEqual({ + ok: true, + values: { deadline: local(2026, 10, 9), customFields: { release: local(2026, 10, 9) } } + }) + }) + + it('gives the iteration of the day for an iteration field', () => { + const res = draftValuesForDay({ ...base, selection: { start: 'iteration:sprint', target: 'iteration:sprint' } }, day(2026, 10, 14)) + expect(res).toEqual({ ok: true, values: { customFields: { sprint: 'i2' } } }) + }) + + it('fails when the dates cannot be written', () => { + const res = draftValuesForDay( + { ...base, selection: { start: 'milestone:startDate', target: 'milestone:targetDate' } }, + day(2026, 10, 9) + ) + expect(res).toEqual({ ok: false, reason: 'readonly' }) + }) + + it('writes only the writable one of a milestone and an issue date', () => { + const res = draftValuesForDay( + { ...base, selection: { start: 'milestone:startDate', target: 'issue:dueDate' } }, + day(2026, 10, 9) + ) + expect(res).toEqual({ ok: true, values: { dueDate: local(2026, 10, 9) } }) + }) +}) diff --git a/plugins/tracker-resources/src/calendar/addItem.ts b/plugins/tracker-resources/src/calendar/addItem.ts new file mode 100644 index 0000000000..e57f5820f4 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/addItem.ts @@ -0,0 +1,28 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { DraftValues } from '../draft/create' +import { planSchedule, buildIssuePatch, type PlanFailure, type ScheduleContext } from '../roadmap/reschedule' + +export type DayDraftValues = { ok: true, values: DraftValues } | { ok: false, reason: PlanFailure } + +/** + * What a draft item that is added to a day cell starts with: the day in the date fields of the view (the same plan as + * putting an item without dates on a day), nothing else. Fails when neither date field can be written, e.g. when the + * view takes its dates from the milestone. + */ +export function draftValuesForDay (ctx: Omit, day: number): DayDraftValues { + const plan = planSchedule({ ...ctx, issue: {} }, day) + if (plan.ok === false) return { ok: false, reason: plan.reason } + const patch = buildIssuePatch({}, plan.writes) + const values: DraftValues = {} + for (const key of ['startDate', 'dueDate', 'deadline'] as const) { + const value = patch[key] + if (typeof value === 'number') values[key] = value + } + const custom = patch.customFields + if (custom !== null && typeof custom === 'object') values.customFields = { ...(custom as Record) } + return { ok: true, values } +} diff --git a/plugins/tracker-resources/src/calendar/agenda.ts b/plugins/tracker-resources/src/calendar/agenda.ts new file mode 100644 index 0000000000..2e750e23b2 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/agenda.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { compareEvents, type CalendarEvent } from './events' +import type { DayRange } from './grid' + +/** One entry of the agenda: an event on one of its days. */ +export interface AgendaEntry { + event: CalendarEvent + // The first day of the event / the last one (a bar that spans days is listed on each of them) + first: boolean + last: boolean +} + +export interface AgendaDay { + day: number + entries: Array> +} + +/** + * The days of the range that have events, in date order, each with its events (bars first, then the chips in the sort + * order). An event that spans several days is listed on each of them, so the day a viewer looks at is complete. + */ +export function groupAgenda (events: ReadonlyArray>, range: DayRange): Array> { + const byDay = new Map>>() + for (const event of events) { + const from = Math.max(event.start, range.start) + const to = Math.min(event.end, range.end - 1) + for (let day = from; day <= to; day++) { + let list = byDay.get(day) + if (list === undefined) { + list = [] + byDay.set(day, list) + } + list.push({ event, first: day === event.start, last: day === event.end }) + } + } + return [...byDay.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([day, entries]) => ({ day, entries: entries.sort((a, b) => compareEvents(a.event, b.event)) })) +} diff --git a/plugins/tracker-resources/src/calendar/config.ts b/plugins/tracker-resources/src/calendar/config.ts new file mode 100644 index 0000000000..72e56ef2b3 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/config.ts @@ -0,0 +1,97 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { deepEqual } from 'fast-equals' +import { ISSUE_DUE_SOURCE, ISSUE_START_SOURCE, parseSourceId, type DateSelection } from '../roadmap/dates' +import { isCalendarMode, type CalendarMode } from './grid' + +/** + * Settings of a calendar view. They are stored in the view options under `CALENDAR_OPTION_KEY`, like the settings of + * the roadmap, so that they are saved with the saved view, restored when it is opened and take part in its + * unsaved-changes tracking. Which month or week is shown is navigation, not a setting, and is not stored. + */ +export interface CalendarConfig { + mode: CalendarMode + // Id of the date source of the start of an item (see roadmap/dates.ts), or NO_DATE_SOURCE + start: string + // Id of the date source of the end of an item, or NO_DATE_SOURCE + target: string +} + +export const CALENDAR_OPTION_KEY = 'calendar' + +/** A date field that is switched off: an item then takes its day from the other field only. */ +export const NO_DATE_SOURCE = 'none' + +export const DEFAULT_CALENDAR_MODE: CalendarMode = 'month' + +export const DEFAULT_CALENDAR_CONFIG: Readonly = { + mode: DEFAULT_CALENDAR_MODE, + start: ISSUE_START_SOURCE, + target: ISSUE_DUE_SOURCE +} + +function validSource (raw: unknown, fallback: string): string { + if (typeof raw !== 'string') return fallback + return raw === NO_DATE_SOURCE || parseSourceId(raw) !== undefined ? raw : fallback +} + +/** + * Turns whatever is stored in the view options into a valid config; missing or damaged parts get the defaults. + * A calendar needs at least one date field, so two switched off fields give the default pair. + */ +export function normalizeCalendarConfig (raw: unknown): CalendarConfig { + if (raw === null || typeof raw !== 'object') return { ...DEFAULT_CALENDAR_CONFIG } + const r = raw as Record + const start = validSource(r.start, DEFAULT_CALENDAR_CONFIG.start) + const target = validSource(r.target, DEFAULT_CALENDAR_CONFIG.target) + const mode = isCalendarMode(r.mode) ? r.mode : DEFAULT_CALENDAR_CONFIG.mode + if (start === NO_DATE_SOURCE && target === NO_DATE_SOURCE) { + return { mode, start: DEFAULT_CALENDAR_CONFIG.start, target: DEFAULT_CALENDAR_CONFIG.target } + } + return { mode, start, target } +} + +export function readCalendarConfig (viewOptions: Record | undefined): CalendarConfig { + return normalizeCalendarConfig(viewOptions?.[CALENDAR_OPTION_KEY]) +} + +export function isDefaultCalendarConfig (config: CalendarConfig): boolean { + return deepEqual(config, DEFAULT_CALENDAR_CONFIG) +} + +/** + * The view options with the calendar config stored in them. The default config is not stored at all, so that + * a view that was never customized stays equal to its saved state (no false "unsaved changes" dot). + */ +export function withCalendarConfig> (options: O, config: CalendarConfig): O { + const next: Record = { ...options } + if (isDefaultCalendarConfig(config)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[CALENDAR_OPTION_KEY] + } else { + next[CALENDAR_OPTION_KEY] = { ...config } + } + return next as O +} + +/** The start and target sources of a view; a switched off field has no source and gives no day. */ +export function calendarSelection (config: CalendarConfig): DateSelection { + return { start: config.start, target: config.target } +} + +/** + * Drops what no longer exists (a deleted field) from the config. A source that is gone falls back to the default; + * when that leaves no field at all the default pair is used. + */ +export function sanitizeCalendarConfig (config: CalendarConfig, sourceIds: ReadonlySet): CalendarConfig { + const keep = (id: string, fallback: string): string => (id === NO_DATE_SOURCE || sourceIds.has(id) ? id : fallback) + const start = keep(config.start, DEFAULT_CALENDAR_CONFIG.start) + const target = keep(config.target, DEFAULT_CALENDAR_CONFIG.target) + if (start === NO_DATE_SOURCE && target === NO_DATE_SOURCE) { + return { ...config, start: DEFAULT_CALENDAR_CONFIG.start, target: DEFAULT_CALENDAR_CONFIG.target } + } + return { ...config, start, target } +} diff --git a/plugins/tracker-resources/src/calendar/drag.ts b/plugins/tracker-resources/src/calendar/drag.ts new file mode 100644 index 0000000000..4a5173b737 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/drag.ts @@ -0,0 +1,23 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ItemSchedule } from '../roadmap/dates' +import type { RescheduleMode } from '../roadmap/reschedule' + +/** + * Whole days an event moved when it is dragged from the day cell the pointer grabbed it in to the day cell the pointer + * is over now. Moving shifts both dates by the distance between the two cells (so grabbing a bar by its third day and + * dropping that on another day keeps the duration), resizing moves the edge that is dragged to the cell. + */ +export function dragDelta (mode: RescheduleMode, schedule: ItemSchedule, grabDay: number, overDay: number): number { + switch (mode) { + case 'move': + return overDay - grabDay + case 'resize-start': + return schedule.kind === 'range' ? overDay - schedule.start : 0 + case 'resize-end': + return schedule.kind === 'range' ? overDay - schedule.target : 0 + } +} diff --git a/plugins/tracker-resources/src/calendar/events.ts b/plugins/tracker-resources/src/calendar/events.ts new file mode 100644 index 0000000000..d59a8c319c --- /dev/null +++ b/plugins/tracker-resources/src/calendar/events.ts @@ -0,0 +1,171 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ItemSchedule } from '../roadmap/dates' +import { DAYS_IN_WEEK, type DayRange } from './grid' + +// What the month and week grids draw: an item with two dates is a bar over the days from its start to its end, an item +// with one date (or whose two dates are the same day) is a chip in one day cell. + +export interface CalendarEvent { + id: string + item: T + // First and last day, both inclusive + start: number + end: number + // The start lies after the target, so the days are the other way round (drawn hatched) + inverted: boolean + // Position of the item in the list the view sorted; chips of a day keep this order + order: number +} + +/** Whether the event covers more than one day. */ +export function isMultiDay (event: Pick, 'start' | 'end'>): boolean { + return event.end > event.start +} + +/** + * Turns the schedules of the items into events (in the order of the items) and sets aside the items without a day. + */ +export function buildCalendarEvents ( + items: readonly T[], + idOf: (item: T) => string, + scheduleOf: (item: T) => ItemSchedule +): { events: Array>, unscheduled: T[] } { + const events: Array> = [] + const unscheduled: T[] = [] + items.forEach((item, order) => { + const schedule = scheduleOf(item) + switch (schedule.kind) { + case 'unscheduled': + unscheduled.push(item) + break + case 'marker': + events.push({ id: idOf(item), item, start: schedule.day, end: schedule.day, inverted: false, order }) + break + case 'range': + events.push({ + id: idOf(item), + item, + start: schedule.start, + end: schedule.target, + inverted: schedule.inverted, + order + }) + break + } + }) + return { events, unscheduled } +} + +/** The events that touch the days of the range. */ +export function eventsInRange (events: ReadonlyArray>, range: DayRange): Array> { + return events.filter((e) => e.end >= range.start && e.start < range.end) +} + +/** The events that cover a day: bars first (earliest start, then longest), then the chips in the sort order. */ +export function eventsOnDay (events: ReadonlyArray>, day: number): Array> { + return events.filter((e) => e.start <= day && e.end >= day).sort(compareEvents) +} + +/** Bars before chips; bars by start and length, chips (and equal bars) by the order of the items. */ +export function compareEvents (a: CalendarEvent, b: CalendarEvent): number { + const am = isMultiDay(a) + const bm = isMultiDay(b) + if (am !== bm) return am ? -1 : 1 + if (am) { + if (a.start !== b.start) return a.start - b.start + const lengthDiff = b.end - b.start - (a.end - a.start) + if (lengthDiff !== 0) return lengthDiff + } + return a.order - b.order +} + +// ---- weeks ---- + +/** The part of an event that falls in one week row. */ +export interface WeekSegment { + event: CalendarEvent + // Column of the first day of the segment in the week (0 is the first day of the week) + col: number + // Number of columns, 1-7 + span: number + // The event started in an earlier week / goes on in a later one (drawn without that rounded edge) + continuesBefore: boolean + continuesAfter: boolean + // Row of the segment below the day numbers, packed so that segments of a column never overlap + lane: number + // Left out because the cell has no room (counted in the "+N more" of its days) + hidden: boolean +} + +export interface WeekLayout { + startDay: number + segments: Array> + // Rows the segments need + laneCount: number + // Rows that are drawn; the one after them holds the "+N more" links when something is hidden + visibleLanes: number + // Segments left out per column + hiddenByColumn: number[] +} + +/** + * Splits the events into the segments of one week and packs them into lanes: bars first, then the chips of each day in + * the sort order, each in the first lane that is free in all of its columns. With `maxLanes` (the rows that fit in + * the cell) lanes beyond the limit are hidden, leaving the last row for the "+N more" links. + */ +export function layoutWeek ( + events: ReadonlyArray>, + startDay: number, + maxLanes: number = Number.POSITIVE_INFINITY +): WeekLayout { + const endDay = startDay + DAYS_IN_WEEK - 1 + const inWeek = events.filter((e) => e.end >= startDay && e.start <= endDay).sort(compareEvents) + + // Free columns of every lane + const lanes: boolean[][] = [] + const segments: Array> = [] + for (const event of inWeek) { + const col = Math.max(event.start, startDay) - startDay + const last = Math.min(event.end, endDay) - startDay + const span = last - col + 1 + let lane = 0 + for (; lane < lanes.length; lane++) { + let free = true + for (let c = col; c <= last; c++) { + if (lanes[lane][c]) { + free = false + break + } + } + if (free) break + } + if (lane === lanes.length) lanes.push(new Array(DAYS_IN_WEEK).fill(false)) + for (let c = col; c <= last; c++) lanes[lane][c] = true + segments.push({ + event, + col, + span, + continuesBefore: event.start < startDay, + continuesAfter: event.end > endDay, + lane, + hidden: false + }) + } + + const laneCount = lanes.length + const overflow = laneCount > maxLanes + const visibleLanes = overflow ? Math.max(0, Math.floor(maxLanes) - 1) : laneCount + const hiddenByColumn = new Array(DAYS_IN_WEEK).fill(0) + if (overflow) { + for (const s of segments) { + if (s.lane < visibleLanes) continue + s.hidden = true + for (let c = s.col; c < s.col + s.span; c++) hiddenByColumn[c]++ + } + } + return { startDay, segments, laneCount, visibleLanes, hiddenByColumn } +} diff --git a/plugins/tracker-resources/src/calendar/grid.ts b/plugins/tracker-resources/src/calendar/grid.ts new file mode 100644 index 0000000000..f5b1996b42 --- /dev/null +++ b/plugins/tracker-resources/src/calendar/grid.ts @@ -0,0 +1,253 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { dayParts } from '../roadmap/timeScale' + +// Grids of the Calendar layout. Everything is computed on whole calendar days (the index of the day since 1970-01-01 of +// the local calendar date, see roadmap/timeScale.ts), so a daylight saving change never gives a week of 6 or 8 days. + +export type CalendarMode = 'month' | 'week' | 'agenda' + +export const CALENDAR_MODES: readonly CalendarMode[] = ['month', 'week', 'agenda'] + +export const DAYS_IN_WEEK = 7 + +const DAY_MS = 86400000 + +/** Saturday and Sunday (0 is Sunday). */ +export const DEFAULT_WEEKEND: readonly number[] = [0, 6] + +export function isCalendarMode (value: unknown): value is CalendarMode { + return value === 'month' || value === 'week' || value === 'agenda' +} + +/** + * The first day of the week as a weekday number (0 is Sunday, 1 Monday): the setting of the platform when it is a + * valid weekday (Intl reports Sunday as 7), Monday otherwise. + */ +export function normalizeFirstDay (value: unknown): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 7) return 1 + return value % 7 +} + +/** Index of the day with the given calendar date (month 0-11), also for a month or a date out of range. */ +export function dayOf (year: number, month: number, date: number = 1): number { + return Math.round(Date.UTC(year, month, date) / DAY_MS) +} + +export function daysInMonth (year: number, month: number): number { + return new Date(Date.UTC(year, month + 1, 0)).getUTCDate() +} + +/** First day of the week that contains the day. */ +export function weekStartOf (day: number, firstDay: number): number { + const weekday = dayParts(day).weekday + return day - ((weekday - firstDay + DAYS_IN_WEEK) % DAYS_IN_WEEK) +} + +/** First day of the month that contains the day. */ +export function monthStartOf (day: number): number { + const p = dayParts(day) + return dayOf(p.year, p.month, 1) +} + +/** The same date `count` months later (or earlier); a date that the month does not have (31st) becomes its last day. */ +export function addMonths (day: number, count: number): number { + const p = dayParts(day) + const target = new Date(Date.UTC(p.year, p.month + count, 1)) + const year = target.getUTCFullYear() + const month = target.getUTCMonth() + return dayOf(year, month, Math.min(p.date, daysInMonth(year, month))) +} + +export function isWeekendDay (day: number, weekend: readonly number[] = DEFAULT_WEEKEND): boolean { + return weekend.includes(dayParts(day).weekday) +} + +export interface CalendarDay { + day: number + year: number + // 0-11 + month: number + // 1-31 + date: number + weekday: number + // Whether the day belongs to the month that is shown (always true in a week) + inMonth: boolean + weekend: boolean + today: boolean +} + +export interface CalendarWeek { + startDay: number + days: CalendarDay[] +} + +function makeDay (day: number, inMonthOf: number | undefined, today: number, weekend: readonly number[]): CalendarDay { + const p = dayParts(day) + const month = inMonthOf !== undefined ? dayParts(inMonthOf) : undefined + return { + day, + year: p.year, + month: p.month, + date: p.date, + weekday: p.weekday, + inMonth: month === undefined || (month.year === p.year && month.month === p.month), + weekend: weekend.includes(p.weekday), + today: day === today + } +} + +function makeWeek (startDay: number, inMonthOf: number | undefined, today: number, weekend: readonly number[]): CalendarWeek { + const days: CalendarDay[] = [] + for (let i = 0; i < DAYS_IN_WEEK; i++) days.push(makeDay(startDay + i, inMonthOf, today, weekend)) + return { startDay, days } +} + +/** The weeks of the month that contains `anchor`: whole weeks, from the one with the 1st to the one with the last day. */ +export function buildMonthGrid ( + anchor: number, + firstDay: number, + today: number, + weekend: readonly number[] = DEFAULT_WEEKEND +): CalendarWeek[] { + const first = monthStartOf(anchor) + const last = addMonths(first, 1) - 1 + const weeks: CalendarWeek[] = [] + for (let start = weekStartOf(first, firstDay); start <= last; start += DAYS_IN_WEEK) { + weeks.push(makeWeek(start, anchor, today, weekend)) + } + return weeks +} + +/** The week that contains `anchor`. */ +export function buildWeek ( + anchor: number, + firstDay: number, + today: number, + weekend: readonly number[] = DEFAULT_WEEKEND +): CalendarWeek { + return makeWeek(weekStartOf(anchor, firstDay), undefined, today, weekend) +} + +export interface DayRange { + // First day + start: number + // First day after the range + end: number +} + +/** + * The days a mode shows: the whole weeks of the month grid, the week, or the month for the agenda. + */ +export function visibleRange (mode: CalendarMode, anchor: number, firstDay: number): DayRange { + switch (mode) { + case 'month': { + const weeks = buildMonthGrid(anchor, firstDay, 0) + return { start: weeks[0].startDay, end: weeks[weeks.length - 1].startDay + DAYS_IN_WEEK } + } + case 'week': { + const start = weekStartOf(anchor, firstDay) + return { start, end: start + DAYS_IN_WEEK } + } + case 'agenda': { + const start = monthStartOf(anchor) + return { start, end: addMonths(start, 1) } + } + } +} + +/** The anchor after the Previous (-1) or Next (1) button: a week in the week mode, a month in the others. */ +export function shiftAnchor (mode: CalendarMode, anchor: number, direction: -1 | 1): number { + return mode === 'week' ? anchor + direction * DAYS_IN_WEEK : addMonths(anchor, direction) +} + +/** Whether the day is inside the range. */ +export function rangeHas (range: DayRange, day: number): boolean { + return day >= range.start && day < range.end +} + +/** + * The anchor that shows a day: the same anchor when the day is visible already, the day itself otherwise. + */ +export function anchorForDay (mode: CalendarMode, anchor: number, day: number, firstDay: number): number { + return rangeHas(visibleRange(mode, anchor, firstDay), day) ? anchor : day +} + +// ---- keyboard ---- + +export type FocusKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'Home' | 'End' | 'PageUp' | 'PageDown' + +const FOCUS_KEYS: readonly string[] = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'] + +export function isFocusKey (key: string): key is FocusKey { + return FOCUS_KEYS.includes(key) +} + +/** + * The day the keyboard focus moves to: arrows move by a day or a week, Home and End to the first and last day of the + * week, Page Up and Page Down to the same date of the previous and next month. + */ +export function moveFocusDay (day: number, key: FocusKey, firstDay: number): number { + switch (key) { + case 'ArrowLeft': + return day - 1 + case 'ArrowRight': + return day + 1 + case 'ArrowUp': + return day - DAYS_IN_WEEK + case 'ArrowDown': + return day + DAYS_IN_WEEK + case 'Home': + return weekStartOf(day, firstDay) + case 'End': + return weekStartOf(day, firstDay) + DAYS_IN_WEEK - 1 + case 'PageUp': + return addMonths(day, -1) + case 'PageDown': + return addMonths(day, 1) + } +} + +// ---- labels ---- + +function utcDate (day: number): Date { + return new Date(day * DAY_MS) +} + +/** Names of the weekdays in the order of the week, starting with `firstDay`. */ +export function weekdayLabels (firstDay: number, locale?: string, style: 'short' | 'long' = 'short'): string[] { + const format = new Intl.DateTimeFormat(locale, { weekday: style, timeZone: 'UTC' }) + // The 4th of January 1970 is a Sunday + const sunday = 3 + const res: string[] = [] + for (let i = 0; i < DAYS_IN_WEEK; i++) res.push(format.format(utcDate(sunday + ((firstDay + i) % DAYS_IN_WEEK)))) + return res +} + +/** Title of the toolbar: "October 2026" for a month (and the agenda), "Oct 5 – 11, 2026" for a week. */ +export function formatCalendarTitle (mode: CalendarMode, anchor: number, firstDay: number, locale?: string): string { + if (mode !== 'week') { + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(utcDate(anchor)) + } + const start = weekStartOf(anchor, firstDay) + const format = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) + const formatRange = (format as unknown as { formatRange?: (a: Date, b: Date) => string }).formatRange + return typeof formatRange === 'function' + ? formatRange.call(format, utcDate(start), utcDate(start + DAYS_IN_WEEK - 1)) + : `${format.format(utcDate(start))} – ${format.format(utcDate(start + DAYS_IN_WEEK - 1))}` +} + +/** Long date of a day, e.g. for the title of the popup of a day. */ +export function formatDayLong (day: number, locale?: string): string { + return new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeZone: 'UTC' }).format(utcDate(day)) +} + +/** Short heading of a day of the agenda, e.g. "Mon, Oct 5". */ +export function formatDayShort (day: number, locale?: string): string { + return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format( + utcDate(day) + ) +} diff --git a/plugins/tracker-resources/src/components/CreateIssue.svelte b/plugins/tracker-resources/src/components/CreateIssue.svelte index e724a2a38c..78a290dadd 100644 --- a/plugins/tracker-resources/src/components/CreateIssue.svelte +++ b/plugins/tracker-resources/src/components/CreateIssue.svelte @@ -61,6 +61,8 @@ IssuePriority, IssueStatus, IssueTemplate, + isProjectItemLimitReached, + MAX_PROJECT_ITEMS, Milestone, Project, ProjectTargetPreference, @@ -103,6 +105,8 @@ export let assignee: Ref | null = null export let component: Ref | null = null export let milestone: Ref | null = null + // Values of the custom fields the issue starts with, e.g. the column of a board it is added from + export let customFields: Record | undefined = undefined export let relatedTo: Doc | undefined export let shouldSaveDraft: boolean = true export let parentIssue: Issue | undefined @@ -234,6 +238,25 @@ let currentProject: Project | undefined + // A project holds at most MAX_PROJECT_ITEMS items (GitHub parity): a full project refuses new issues with an explicit + // error. The server removes the issue again if a client skips this check (OnProjectItemLimit). + const itemCountQuery = createQuery() + let projectItems = 0 + $: if (_space !== undefined) { + itemCountQuery.query( + tracker.class.Issue, + { space: _space as Ref }, + (res) => { + projectItems = res.total + }, + { limit: 1, total: true, projection: { _id: 1 } } + ) + } else { + itemCountQuery.unsubscribe() + projectItems = 0 + } + $: itemLimitReached = isProjectItemLimitReached(projectItems) + let descriptionBox: AttachmentStyledBox | undefined $: updateIssueStatusId(object, currentProject) @@ -243,7 +266,8 @@ getTitle(object.title ?? '').length > 0 && object.status !== undefined && kind !== undefined && - currentProject !== undefined + currentProject !== undefined && + !itemLimitReached $: empty = { assignee: assignee ?? currentProject?.defaultAssignee, @@ -516,7 +540,8 @@ relations: relatedTo !== undefined ? [{ _id: relatedTo._id, _class: relatedTo._class }] : [], childInfo: [], kind, - identifier + identifier, + ...(customFields !== undefined && Object.keys(customFields).length > 0 ? { customFields } : {}) } if (!isEmptyMarkup(object.description)) { @@ -1049,6 +1074,13 @@ {/each} {/if} + + {#if itemLimitReached} + + + {/if} + + + + + {lane.items.length} + + {#if !isCollapsed && !group} +
+ {#each lane.cells as cell (cell.columnKey)} + {@const id = cellId(lane, cell.columnKey)} + +
{ + dragOver(ev, lane, cell.columnKey, cell.column) + }} + on:dragleave={(ev) => { + dragLeave(ev, id) + }} + on:drop|preventDefault={() => { + void drop(lane, cell.column) + }} + > + {}} + cardDrop={() => {}} + onDragStart={(object) => { + void onDragStart(object) + }} + {showMenu} + {_class} + {options} + groupByKey={'_id'} + {getGroupQuery} + > + + + + + +
+ {/each} +
+ {/if} + + {/each} + + + + + + diff --git a/plugins/tracker-resources/src/components/board/BoardOptionsSection.svelte b/plugins/tracker-resources/src/components/board/BoardOptionsSection.svelte new file mode 100644 index 0000000000..be376bcaf4 --- /dev/null +++ b/plugins/tracker-resources/src/components/board/BoardOptionsSection.svelte @@ -0,0 +1,119 @@ + + + +
+
+ + { + if (e.detail !== dimensions.columnKey) change(withColumnField(config, e.detail)) + }} + /> +
+{#if hiddenCount > 0} + + +
{ + change(withAllColumnsShown(config, dimensions.columnKey)) + }} + > + +
+{/if} +{#if limitCount > 0} + + +
{ + change(withoutColumnLimits(config, dimensions.columnKey)) + }} + > + +
+{/if} diff --git a/plugins/tracker-resources/src/components/board/CardFieldRenderer.svelte b/plugins/tracker-resources/src/components/board/CardFieldRenderer.svelte new file mode 100644 index 0000000000..779812efe8 --- /dev/null +++ b/plugins/tracker-resources/src/components/board/CardFieldRenderer.svelte @@ -0,0 +1,229 @@ + + + + + +
{ + void openDoc(client.getHierarchy(), issue) + }} +> +
+
+
+ +
+
+ +
+ +
+
+ + {#if plan.assignee} + + {/if} +
+
+
+ {issue.title} +
+
+ {#each plan.chips as chip (chip.kind === 'custom' ? `c:${chip.fieldKey}` : `b:${chip.id}`)} + {#if chip.kind === 'custom'} + {@const custom = customChip(chip)} + {#if custom !== undefined} + + + + {/if} + {:else if chip.id === 'subIssues'} + {#if issue.subIssues > 0} + + {/if} + {:else if chip.id === 'priority'} + + {:else if chip.id === 'component'} + + {:else if chip.id === 'milestone'} + + {:else if chip.id === 'dueDate'} + + {/if} + {/each} +
+ {#if plan.labels} +
+ { + if (res.detail.full) labelsFull = true + }} + /> +
+ {/if} + {#if withFooter} + + {:else} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/board/DimensionTitle.svelte b/plugins/tracker-resources/src/components/board/DimensionTitle.svelte new file mode 100644 index 0000000000..5c5138eefa --- /dev/null +++ b/plugins/tracker-resources/src/components/board/DimensionTitle.svelte @@ -0,0 +1,36 @@ + + + +{#if info.custom} + {#if category === undefined} + {info.emptyLabel ?? ''} + {:else if info.customHeader !== undefined} + + {/if} +{:else if info.presenter !== undefined} + +{/if} diff --git a/plugins/tracker-resources/src/components/board/HiddenColumnsPanel.svelte b/plugins/tracker-resources/src/components/board/HiddenColumnsPanel.svelte new file mode 100644 index 0000000000..ac89f26565 --- /dev/null +++ b/plugins/tracker-resources/src/components/board/HiddenColumnsPanel.svelte @@ -0,0 +1,82 @@ + + + +{#if columns.length > 0} +
+
+
+ {#each columns as column (column.key)} +
+ + + + {column.count} +
+ {/each} +
+{/if} + + diff --git a/plugins/tracker-resources/src/components/board/types.ts b/plugins/tracker-resources/src/components/board/types.ts new file mode 100644 index 0000000000..7da845bfcb --- /dev/null +++ b/plugins/tracker-resources/src/components/board/types.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { AttributeModel } from '@hcengineering/view' + +/** + * How the categories of one axis of the board (the columns or the swimlanes) are shown and written. + */ +export interface DimensionInfo { + // View key of the field: a built-in attribute, or `customFields.` + key: string + // Whether the field is a custom field + custom: boolean + // Presenter of the title of a category of a built-in field + presenter?: AttributeModel + // Presenter of the title of a category of a custom field (an option or an iteration) + customHeader?: any + // Title of the category without a value of a custom field ("No ") + emptyLabel?: string +} diff --git a/plugins/tracker-resources/src/components/calendar/CalendarAddItemPopup.svelte b/plugins/tracker-resources/src/components/calendar/CalendarAddItemPopup.svelte new file mode 100644 index 0000000000..a2b4e41024 --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarAddItemPopup.svelte @@ -0,0 +1,40 @@ + + + +
+
+
+ +
+
+ + diff --git a/plugins/tracker-resources/src/components/calendar/CalendarAgenda.svelte b/plugins/tracker-resources/src/components/calendar/CalendarAgenda.svelte new file mode 100644 index 0000000000..e62e02829c --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarAgenda.svelte @@ -0,0 +1,116 @@ + + + +
+ {#each days as { day, entries } (day)} +
+

+ {formatDayShort(day, locale)} + {entries.length} +

+
+ {#each entries as entry (entry.event.id)} + {@const issue = entry.event.item} + +
{ + showMenu(ev, { object: issue }) + }} + > + +
+ {/each} +
+
+ {:else} +
+ {/each} +
+ + diff --git a/plugins/tracker-resources/src/components/calendar/CalendarDayPopup.svelte b/plugins/tracker-resources/src/components/calendar/CalendarDayPopup.svelte new file mode 100644 index 0000000000..49a20af84c --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarDayPopup.svelte @@ -0,0 +1,79 @@ + + + +
+
+
+
+
+ {#each issues as issue (issue._id)} + +
{ + showMenu(ev, { object: issue }) + }} + on:click={() => { + // The card opens the issue, the popup is not needed any more + dispatch('close') + }} + > + +
+ {/each} +
+
+
+ + diff --git a/plugins/tracker-resources/src/components/calendar/CalendarEventChip.svelte b/plugins/tracker-resources/src/components/calendar/CalendarEventChip.svelte new file mode 100644 index 0000000000..aac19f0c75 --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarEventChip.svelte @@ -0,0 +1,202 @@ + + + + +
{ + dispatch('press', { mode: 'move', event: ev }) + }} + on:keydown={(ev) => { + if (ev.key === 'Enter') dispatch('open') + }} + on:contextmenu={(ev) => { + dispatch('menu', ev) + }} +> + {#if resizeStart && !continuesBefore} + +
{ + dispatch('press', { mode: 'resize-start', event: ev }) + }} + /> + {/if} + {#if continuesBefore}‹{/if} + {#if showAssignee && issue.assignee != null} + + + + {/if} + {#if isIssueDraft(issue)} + + {:else} + {issue.identifier} + {/if} + {issue.title} + {#if showPriority && issue.priority !== 0} + + + + {/if} + {#if continuesAfter}›{/if} + {#if resizeEnd && !continuesAfter} + +
{ + dispatch('press', { mode: 'resize-end', event: ev }) + }} + /> + {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/calendar/CalendarGrid.svelte b/plugins/tracker-resources/src/components/calendar/CalendarGrid.svelte new file mode 100644 index 0000000000..94e423f43a --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarGrid.svelte @@ -0,0 +1,297 @@ + + + +
+
+ {#each weekdays as name} +
{name}
+ {/each} +
+
+ {#each layouts as { week, layout } (week.startDay)} +
+
+ {#each week.days as cell (cell.day)} + +
{ + cellClick(ev, cell) + }} + on:keydown={(ev) => { + cellKey(ev, cell) + }} + > + + {#if cell.date === 1 && mode === 'month'}{monthFormat(cell)} {/if}{cell.date} + +
+ {/each} +
+
+ {#each layout.segments as seg (seg.event.id)} + {#if !seg.hidden} + {@const issue = seg.event.item} + {@const schedule = scheduleOf(issue)} + {@const resizable = schedule.kind === 'range' && !schedule.inverted} +
+ c.kind === 'builtin' && c.id === 'priority')} + multiDay={isMultiDay(seg.event)} + continuesBefore={seg.continuesBefore} + continuesAfter={seg.continuesAfter} + inverted={seg.event.inverted} + draggable={canDrag(issue, 'move')} + resizeStart={resizable && canDrag(issue, 'resize-start')} + resizeEnd={resizable && canDrag(issue, 'resize-end')} + dragging={draggingId === issue._id} + on:press={(e) => { + dispatch('press', { issue, mode: e.detail.mode, event: e.detail.event }) + }} + on:open={() => { + dispatch('open', { issue }) + }} + on:menu={(e) => { + dispatch('menu', { issue, event: e.detail }) + }} + /> +
+ {/if} + {/each} + {#each layout.hiddenByColumn as count, col} + {#if count > 0} + + {/if} + {/each} +
+
+ {/each} +
+
+ + diff --git a/plugins/tracker-resources/src/components/calendar/CalendarToolbar.svelte b/plugins/tracker-resources/src/components/calendar/CalendarToolbar.svelte new file mode 100644 index 0000000000..408f2f6797 --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarToolbar.svelte @@ -0,0 +1,174 @@ + + + +
+
+
+
+
+
+ { + const id = ev.detail?.id + if (isCalendarMode(id) && id !== config.mode) dispatch('change', { ...config, mode: id }) + }} + /> +
+
+ + diff --git a/plugins/tracker-resources/src/components/calendar/CalendarUnscheduled.svelte b/plugins/tracker-resources/src/components/calendar/CalendarUnscheduled.svelte new file mode 100644 index 0000000000..aac691b0e3 --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarUnscheduled.svelte @@ -0,0 +1,184 @@ + + + + + + diff --git a/plugins/tracker-resources/src/components/calendar/CalendarView.svelte b/plugins/tracker-resources/src/components/calendar/CalendarView.svelte new file mode 100644 index 0000000000..5c3e59c4ad --- /dev/null +++ b/plugins/tracker-resources/src/components/calendar/CalendarView.svelte @@ -0,0 +1,744 @@ + + + +
+ { + changeSettings(e.detail) + }} + on:previous={() => { + navigate(-1) + }} + on:next={() => { + navigate(1) + }} + on:today={goToday} + on:unscheduled={() => { + unscheduledOpen = !unscheduledOpen + }} + /> + {#if truncated} +
+ +
+ {/if} +
+
+ {#if settings.mode === 'agenda'} + + {:else} + { + startDrag(e.detail.event, e.detail.issue, e.detail.mode) + }} + on:open={(e) => { + openIssue(e.detail.issue) + }} + on:menu={(e) => { + showMenu(e.detail.event, { object: e.detail.issue }) + }} + on:dayclick={(e) => { + addItemOnDay(e.detail.day, e.detail.element) + }} + on:more={(e) => { + showDayItems(e.detail.day, e.detail.element) + }} + on:key={(e) => { + onGridKey(e.detail) + }} + /> + {/if} +
+ {#if unscheduledOpen && unscheduled.length > 0} + { + startDrag(e.detail.event, e.detail.issue, 'schedule') + }} + on:open={(e) => { + openIssue(e.detail.issue) + }} + on:dates={(e) => { + pickDates(e.detail.event, e.detail.issue) + }} + /> + {/if} +
+ + {#if drag !== undefined && drag.moved && drag.mode === 'schedule' && drag.allowed && dragIssue !== undefined} +
+ {dragIssue.identifier} + {dragIssue.title} +
+ {/if} + + {#if toast !== undefined} +
+ {toast.text} + {#if toast.undo !== undefined} + + {/if} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/draft/AddItemRow.svelte b/plugins/tracker-resources/src/components/draft/AddItemRow.svelte new file mode 100644 index 0000000000..4c4e7e00d4 --- /dev/null +++ b/plugins/tracker-resources/src/components/draft/AddItemRow.svelte @@ -0,0 +1,270 @@ + + + +{#if availability !== 'readonly'} +
+ {#if placement === 'above' && input.kind === 'search' && currentProject !== undefined && enabled} + { + text = '' + }} + /> + {/if} +
+ + { + failed = false + }} + /> +
+ {#if placement === 'below' && input.kind === 'search' && currentProject !== undefined && enabled} + { + text = '' + }} + /> + {/if} + {#if input.kind === 'too-long'} + + {:else if failed} + + {:else if availability === 'limit'} + + {/if} +
+{/if} + + diff --git a/plugins/tracker-resources/src/components/draft/AddItemSearch.svelte b/plugins/tracker-resources/src/components/draft/AddItemSearch.svelte new file mode 100644 index 0000000000..8fdafc58c7 --- /dev/null +++ b/plugins/tracker-resources/src/components/draft/AddItemSearch.svelte @@ -0,0 +1,252 @@ + + + + + + diff --git a/plugins/tracker-resources/src/components/fieldSum/FieldSumFooter.svelte b/plugins/tracker-resources/src/components/fieldSum/FieldSumFooter.svelte new file mode 100644 index 0000000000..287eb8b8f0 --- /dev/null +++ b/plugins/tracker-resources/src/components/fieldSum/FieldSumFooter.svelte @@ -0,0 +1,106 @@ + + + +{#if fields.length > 0 && ready} + +{/if} + + diff --git a/plugins/tracker-resources/src/components/fieldSum/FieldSumGroupSummary.svelte b/plugins/tracker-resources/src/components/fieldSum/FieldSumGroupSummary.svelte new file mode 100644 index 0000000000..79ca107ae9 --- /dev/null +++ b/plugins/tracker-resources/src/components/fieldSum/FieldSumGroupSummary.svelte @@ -0,0 +1,52 @@ + + + +{#if text !== undefined} + {text} +{/if} + + diff --git a/plugins/tracker-resources/src/components/insights/ChartConfigPanel.svelte b/plugins/tracker-resources/src/components/insights/ChartConfigPanel.svelte new file mode 100644 index 0000000000..07346d1908 --- /dev/null +++ b/plugins/tracker-resources/src/components/insights/ChartConfigPanel.svelte @@ -0,0 +1,213 @@ + + + +
+
+ + { + if (e.detail !== config.layout) change(withLayout(config, e.detail, fields)) + }} + /> +
+ +
+ + { + if (e.detail !== undefined && e.detail !== config.xField) change(withXField(config, e.detail, fields)) + }} + /> +
+ + {#if xIsDate} +
+ + { + change({ ...config, xBucket: e.detail }) + }} + /> +
+ {/if} + +
+ + { + if (e.detail === undefined) return + change(withGroupField(config, e.detail === NONE ? undefined : e.detail)) + }} + /> +
+ +
+ + { + if (e.detail !== config.yAggregate.type) setAggregate(e.detail) + }} + /> + {#if config.yAggregate.type !== 'count'} + { + if (e.detail !== undefined) change(withYAxis(config, config.yAggregate.type, e.detail)) + }} + /> + {/if} +
+
+ + diff --git a/plugins/tracker-resources/src/components/insights/IconInsights.svelte b/plugins/tracker-resources/src/components/insights/IconInsights.svelte new file mode 100644 index 0000000000..04e6b0d74d --- /dev/null +++ b/plugins/tracker-resources/src/components/insights/IconInsights.svelte @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/plugins/tracker-resources/src/components/insights/InsightsPanel.svelte b/plugins/tracker-resources/src/components/insights/InsightsPanel.svelte new file mode 100644 index 0000000000..601bab00dd --- /dev/null +++ b/plugins/tracker-resources/src/components/insights/InsightsPanel.svelte @@ -0,0 +1,781 @@ + + + +
+
+ { + dispatch('close') + }} + /> + +
+ +
+ + +
+ {#if active !== undefined} +
+ {active.name} + {#if dirty && !$restrictionStore.readonly} +
+
+ {/if} +
+ + {#if fieldsFallback} +
+ {/if} + {#if filterInvalid} + + {/if} + {#if overLimit} + + {/if} +
+ {#if data !== undefined} +
+ {yTitle} + {xLabel} + {formatItems(data.items)} +
+ {#if data.categories.length === 0} +
+ {:else} + + +
+ {/if} + {:else if !overLimit && !filterInvalid} +
…
+ {/if} +
+ {/if} +
+ + +
+
+ + diff --git a/plugins/tracker-resources/src/components/insights/charts/ChartCanvas.svelte b/plugins/tracker-resources/src/components/insights/charts/ChartCanvas.svelte new file mode 100644 index 0000000000..10749c81bf --- /dev/null +++ b/plugins/tracker-resources/src/components/insights/charts/ChartCanvas.svelte @@ -0,0 +1,358 @@ + + + +
+ + + {#each geometry.valueTicks as tick} + {#if horizontal} + + {tick.label} + {:else} + + {tick.label} + {/if} + {/each} + + {#if horizontal} + + {:else} + + {/if} + + {#each geometry.categoryTicks as tick} + {#if tick.visible} + {#if horizontal} + + {data.categories[tick.index].label}{categoryText(tick.index)} + + {:else} + + {data.categories[tick.index].label}{categoryText(tick.index)} + + {/if} + {/if} + {/each} + + + {#each geometry.areas as area} + {#each area.paths as path} + + {/each} + {/each} + {#if continuous} + {#each geometry.categoryTicks as tick} + { + show(ev, tick.index, undefined) + }} + on:mouseleave={hide} + on:click={() => { + select(tick.index, undefined) + }} + /> + {/each} + {/if} + + {#each geometry.lines as line} + {#each line.segments as segment} + + {/each} + {/each} + {#each geometry.lines as line} + {#each line.points as point} + { + show(ev, point.category, undefined) + }} + on:mouseleave={hide} + on:focus={(ev) => { + show(ev, point.category, point.series) + }} + on:blur={hide} + on:click|stopPropagation={() => { + select(point.category, point.series) + }} + on:keydown={(ev) => { + onKey(ev, point.category, point.series) + }} + /> + {/each} + {/each} + + + {#each geometry.bars as bar} + { + show(ev, bar.category, bar.series) + }} + on:mousemove={(ev) => { + show(ev, bar.category, bar.series) + }} + on:mouseleave={hide} + on:focus={(ev) => { + show(ev, bar.category, bar.series) + }} + on:blur={hide} + on:click={() => { + select(bar.category, bar.series) + }} + on:keydown={(ev) => { + onKey(ev, bar.category, bar.series) + }} + /> + {/each} + + + {#if hover !== undefined} +
+
{data.categories[hover.category].label}
+ {#each hoverRows as row} +
+ {#if grouped} + + {row.label} + {/if} + {valueText(row.value)} +
+
{valueLabel} · {formatItems(row.items)}
+ {/each} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/insights/charts/ChartLegend.svelte b/plugins/tracker-resources/src/components/insights/charts/ChartLegend.svelte new file mode 100644 index 0000000000..ed78cef77f --- /dev/null +++ b/plugins/tracker-resources/src/components/insights/charts/ChartLegend.svelte @@ -0,0 +1,50 @@ + + + +{#if items.length > 0} +
    + {#each items as item} +
  • + + {item.label} +
  • + {/each} +
+{/if} + + diff --git a/plugins/tracker-resources/src/components/issues/DraftBadge.svelte b/plugins/tracker-resources/src/components/issues/DraftBadge.svelte new file mode 100644 index 0000000000..1eb9f3ac00 --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/DraftBadge.svelte @@ -0,0 +1,43 @@ + + + + + + + diff --git a/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte b/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte index 95e45a718a..0ca4e86699 100644 --- a/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte @@ -28,6 +28,7 @@ import { ObjectPresenterType } from '@hcengineering/view' import tracker from '../../plugin' + import DraftBadge from './DraftBadge.svelte' export let value: WithLookup | undefined export let disabled: boolean = false @@ -70,11 +71,16 @@
{/if} - + {#if value.isDraft === true} + + + {:else} + + {/if} @@ -82,7 +88,11 @@
{:else if value && type === 'text'} - {value.identifier} + {#if value.isDraft === true} + + {:else} + {value.identifier} + {/if} {/if} {/if} diff --git a/plugins/tracker-resources/src/components/issues/IssuesView.svelte b/plugins/tracker-resources/src/components/issues/IssuesView.svelte index 71bd34e4b0..54d61a2c51 100644 --- a/plugins/tracker-resources/src/components/issues/IssuesView.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuesView.svelte @@ -1,39 +1,156 @@
+ {#if isNew && (isCopy || templates.length > 0)} +
+
+
+ {#if copyFrom !== undefined} + {copyFrom.name} + {:else} + + {/if} +
+ {#if source !== undefined} +
+
+
+ +
+ {/if} + {/if}
{/each} + + {#if project != null} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
diff --git a/plugins/tracker-resources/src/components/projects/ProjectDangerZone.svelte b/plugins/tracker-resources/src/components/projects/ProjectDangerZone.svelte new file mode 100644 index 0000000000..8b8072135e --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectDangerZone.svelte @@ -0,0 +1,204 @@ + + + + + {#if project.archived} +
+ {/if} + {#if !canEdit && !canArchive && !canDelete} +
+ {/if} + +
+
+ + + +
+
+ +
+
+ + +
+
+
+
+ +
+
+ + + +
+
+ +
+
+ + + + +
+
+ + {#if error !== undefined} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/projects/ProjectDetailsPanel.svelte b/plugins/tracker-resources/src/components/projects/ProjectDetailsPanel.svelte new file mode 100644 index 0000000000..b1a8a81134 --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectDetailsPanel.svelte @@ -0,0 +1,217 @@ + + + + + + diff --git a/plugins/tracker-resources/src/components/projects/ProjectGeneralSettings.svelte b/plugins/tracker-resources/src/components/projects/ProjectGeneralSettings.svelte new file mode 100644 index 0000000000..08d6f5e26f --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectGeneralSettings.svelte @@ -0,0 +1,120 @@ + + + + +
+ + +
+
+ + + {shortDescription.length}/{MAX_PROJECT_SHORT_DESCRIPTION} +
+
+ + +
+ {#if showErrors && error !== undefined} +
+
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/projects/ProjectSettings.svelte b/plugins/tracker-resources/src/components/projects/ProjectSettings.svelte new file mode 100644 index 0000000000..2f66283d90 --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectSettings.svelte @@ -0,0 +1,145 @@ + + + + dispatch('close')} + on:close +> +
+ +
+ {#if project === undefined} +
+ {:else if section === 'general'} + + {:else if section === 'fields'} + + {:else if section === 'workflows'} + + {:else if section === 'webhooks'} + + {:else if section === 'archived'} + + {:else} + + {/if} +
+
+
+ + diff --git a/plugins/tracker-resources/src/components/projects/ProjectSettingsCard.svelte b/plugins/tracker-resources/src/components/projects/ProjectSettingsCard.svelte new file mode 100644 index 0000000000..e6c1fbe453 --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectSettingsCard.svelte @@ -0,0 +1,100 @@ + + + +{#if embedded} +
+
+ +
+
+ +
+ {#if !hideFooter} + + {/if} +
+{:else} + + + +{/if} + + diff --git a/plugins/tracker-resources/src/components/projects/ProjectStatusPill.svelte b/plugins/tracker-resources/src/components/projects/ProjectStatusPill.svelte new file mode 100644 index 0000000000..b16b1bf5e9 --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectStatusPill.svelte @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/plugins/tracker-resources/src/components/projects/ProjectStatusPresenter.svelte b/plugins/tracker-resources/src/components/projects/ProjectStatusPresenter.svelte new file mode 100644 index 0000000000..d49cbbeab8 --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectStatusPresenter.svelte @@ -0,0 +1,31 @@ + + + +{#if latest !== undefined} + +{/if} diff --git a/plugins/tracker-resources/src/components/projects/ProjectStatusUpdatePopup.svelte b/plugins/tracker-resources/src/components/projects/ProjectStatusUpdatePopup.svelte new file mode 100644 index 0000000000..7d3d3f0409 --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ProjectStatusUpdatePopup.svelte @@ -0,0 +1,181 @@ + + + + dispatch('close')} + on:close + on:changeContent +> +
+ {#each PROJECT_STATUSES as item (item)} + + {/each} +
+
+ + +
+ + {#if showErrors && error !== undefined} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/projects/ReadmeEditor.svelte b/plugins/tracker-resources/src/components/projects/ReadmeEditor.svelte new file mode 100644 index 0000000000..5c4ace6ded --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ReadmeEditor.svelte @@ -0,0 +1,72 @@ + + + +
+ + {#if mode === 'write'} + + {:else} +
+ {#if value !== undefined && !isEmptyMarkup(value)} + + {:else} + + {/if} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/roadmap/RoadmapOptionsPopup.svelte b/plugins/tracker-resources/src/components/roadmap/RoadmapOptionsPopup.svelte new file mode 100644 index 0000000000..1358f25faf --- /dev/null +++ b/plugins/tracker-resources/src/components/roadmap/RoadmapOptionsPopup.svelte @@ -0,0 +1,127 @@ + + + +
+
+
+ {#each state as section (section.id)} + {#if section.title !== undefined} +
{section.title}
+ {/if} + {#each section.items as item (item.id)} + + {/each} + {/each} + {#if hint !== undefined} +
{hint}
+ {/if} +
+
+
+ + diff --git a/plugins/tracker-resources/src/components/roadmap/RoadmapToolbar.svelte b/plugins/tracker-resources/src/components/roadmap/RoadmapToolbar.svelte new file mode 100644 index 0000000000..6ba1bc1db2 --- /dev/null +++ b/plugins/tracker-resources/src/components/roadmap/RoadmapToolbar.svelte @@ -0,0 +1,217 @@ + + + +
+
+
+
+
+
+ + diff --git a/plugins/tracker-resources/src/components/roadmap/RoadmapView.svelte b/plugins/tracker-resources/src/components/roadmap/RoadmapView.svelte new file mode 100644 index 0000000000..e3d92e55b6 --- /dev/null +++ b/plugins/tracker-resources/src/components/roadmap/RoadmapView.svelte @@ -0,0 +1,1445 @@ + + + +
+ { + scrollToDay(today, 0.3) + }} + /> + {#if truncated} +
+
+ {/if} +
+
+ +
+
+
+
+ {#each header.top as tick (tick.startDay)} +
+ {formatTick(tick, $themeStore.language, 'top')} +
+ {/each} +
+
+ {#each header.bottom as tick (tick.startDay)} +
+ {formatTick(tick, $themeStore.language, 'bottom')} +
+ {/each} +
+
+ {#each visibleMarkers as marker (marker.id)} + + {marker.label} + + {/each} +
+
+
+ +
+ +
+ {#each header.bottom as tick (tick.startDay)} +
+ {/each} + {#each visibleMarkers as marker (marker.id)} + {#if marker.endDay !== undefined} +
+ {/if} +
+ {/each} +
+
+ + {#each visibleRows as row (rowKey(row))} + {#if row.type === 'item'} + {@const issue = row.item} + {@const base = scheduleOf(issue)} + {@const schedule = shownSchedule(issue, base, drag)} + {@const shape = itemShape(schedule, scale)} + {@const label = buildItemLabel(issue, config.fields, resolvers)} + {@const placement = shape !== undefined ? placeLabel(shape, estimateTextWidth(label), scale.width) : 'right'} + {@const dragging = drag?.id === issue._id && drag.moved} +
{ + showMenu(ev, { object: issue }) + }} + > +
+ + {#if row.unscheduled && !readonly} + + {/if} +
+ + +
{ + if (row.unscheduled) timelineClick(ev, issue) + }} + > + {#if shape !== undefined} + +
{ + startDrag(ev, issue, 'move') + }} + on:keydown={(ev) => { + if (ev.key === 'Enter') openIssue(issue) + }} + > + {#if shape.kind === 'bar' && placement === 'inside'} + {label} + {/if} + {#if shape.kind === 'bar' && base.kind === 'range' && !base.inverted} + {#if canDrag(issue, 'resize-start')} + +
{ + startDrag(ev, issue, 'resize-start') + }} + /> + {/if} + {#if canDrag(issue, 'resize-end')} + +
{ + startDrag(ev, issue, 'resize-end') + }} + /> + {/if} + {/if} +
+ {#if label !== '' && placement !== 'inside'} + + {label} + + {/if} + {:else if row.unscheduled && !readonly} + + {/if} +
+
+ {:else} + +
+ +
+ {/if} + {/each} +
+
+ {#if ready && layout.rows.length === 0} +
+ {/if} +
+ + {#if toast !== undefined} +
+ {toast.text} + {#if toast.undo !== undefined} + + {/if} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/roadmap/types.ts b/plugins/tracker-resources/src/components/roadmap/types.ts new file mode 100644 index 0000000000..2e584398f0 --- /dev/null +++ b/plugins/tracker-resources/src/components/roadmap/types.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +export interface OptionItem { + id: string + // Already translated + label: string + checked: boolean + // Shown dimmed after the label, e.g. "Read only" + note?: string +} + +export interface OptionSection { + id: string + // Already translated + title?: string + // A single section behaves like radio buttons, a multi section like check boxes + mode: 'single' | 'multi' + items: OptionItem[] +} diff --git a/plugins/tracker-resources/src/components/slice/IconSlice.svelte b/plugins/tracker-resources/src/components/slice/IconSlice.svelte new file mode 100644 index 0000000000..c757245ff7 --- /dev/null +++ b/plugins/tracker-resources/src/components/slice/IconSlice.svelte @@ -0,0 +1,17 @@ + + + + + + + diff --git a/plugins/tracker-resources/src/components/slice/SlicePanel.svelte b/plugins/tracker-resources/src/components/slice/SlicePanel.svelte new file mode 100644 index 0000000000..6544f7be89 --- /dev/null +++ b/plugins/tracker-resources/src/components/slice/SlicePanel.svelte @@ -0,0 +1,221 @@ + + + +
+
+ + { + dispatch('close') + }} + /> +
+
+ { + if (e.detail !== config.field) dispatch('field', e.detail) + }} + /> +
+
+ {#if overLimit} + + {:else if data === undefined} +
…
+ {:else} + + {#each data.values as value (value.id)} + + {/each} + {#if data.none > 0 || chosen.has(SLICE_NONE)} + + {/if} + {/if} +
+
+ + diff --git a/plugins/tracker-resources/src/components/view/ProjectViewOptionsSection.svelte b/plugins/tracker-resources/src/components/view/ProjectViewOptionsSection.svelte new file mode 100644 index 0000000000..7860745d76 --- /dev/null +++ b/plugins/tracker-resources/src/components/view/ProjectViewOptionsSection.svelte @@ -0,0 +1,110 @@ + + + +{#if layout === 'board'} + { + dispatch('update', e.detail) + }} + /> +{/if} +{#if layout === 'table'} +
+ + +
+ + + + + + +
+{/if} +
+
+ +
+{#each available as field (field.key)} + + +
{ + toggleSum(field.key) + }} + > + {field.label} + + + + { + toggleSum(field.key) + }} + /> + +
+{/each} + + diff --git a/plugins/tracker-resources/src/components/webhooks/WebhooksPopup.svelte b/plugins/tracker-resources/src/components/webhooks/WebhooksPopup.svelte new file mode 100644 index 0000000000..527b15a969 --- /dev/null +++ b/plugins/tracker-resources/src/components/webhooks/WebhooksPopup.svelte @@ -0,0 +1,247 @@ + + + + dispatch('close')} + on:close + on:changeContent +> + {#if draft === undefined} +
+ {#if webhooks.length === 0} +
+
+ {/if} + {#each webhooks as webhook (webhook._id)} +
+ { + void setEnabled(webhook, e.detail) + }} + /> +
{webhook.url}
+ {#if webhook.hasSecret} + + {/if} +
+ {/each} +
+
+ {:else} + +
+ +
+
+ {#each WEBHOOK_EVENTS as event} +
+ { + setEvent(event, e.detail) + }} + /> +
+ {/each} +
+
+
+ +
+
+ {#if showErrors && error !== undefined} +
+
+ {/if} + {/if} +
diff --git a/plugins/tracker-resources/src/components/workflows/WorkflowFilterEditor.svelte b/plugins/tracker-resources/src/components/workflows/WorkflowFilterEditor.svelte new file mode 100644 index 0000000000..064e578492 --- /dev/null +++ b/plugins/tracker-resources/src/components/workflows/WorkflowFilterEditor.svelte @@ -0,0 +1,82 @@ + + + + { + dispatch('apply', e.detail) + }} +/> +
+ {#if compiled.ok === false} + {#if compiled.reason === 'empty'} +
diff --git a/plugins/tracker-resources/src/components/workflows/WorkflowsPopup.svelte b/plugins/tracker-resources/src/components/workflows/WorkflowsPopup.svelte new file mode 100644 index 0000000000..d356d05ab3 --- /dev/null +++ b/plugins/tracker-resources/src/components/workflows/WorkflowsPopup.svelte @@ -0,0 +1,283 @@ + + + + dispatch('close')} + on:close + on:changeContent +> +
+ {#each $workflows as workflow (workflow.kind)} +
+
+
+ + { + toggle(workflow, e.detail) + }} + /> +
+
+ {#if message?.kind === workflow.kind} +
+ {/if} + + {#if isFieldWorkflowKind(workflow.kind)} + {@const target = resolved(workflow)} +
+
+
+ {#if target !== undefined} +
+ {:else if isFilterWorkflowKind(workflow.kind)} + { + applyFilter(workflow, e.detail) + }} + /> + {/if} +
+ {/each} +
+
+
+ + diff --git a/plugins/tracker-resources/src/components/workload/WorkloadGrid.svelte b/plugins/tracker-resources/src/components/workload/WorkloadGrid.svelte new file mode 100644 index 0000000000..7234f05294 --- /dev/null +++ b/plugins/tracker-resources/src/components/workload/WorkloadGrid.svelte @@ -0,0 +1,592 @@ + + + +
+
+
+
+
+
+
+
+
+ {#each visibleGroups as group (group.from)} +
+ {formatHeaderGroup(group, locale)} +
+ {/each} +
+
+ {#each visibleBuckets as bucket (bucket.index)} +
+ {formatBucketLabel(axis.zoom, bucket, locale)} +
+ {/each} +
+
+
+ + +
+ + diff --git a/plugins/tracker-resources/src/components/workload/WorkloadPanel.svelte b/plugins/tracker-resources/src/components/workload/WorkloadPanel.svelte new file mode 100644 index 0000000000..cf47163014 --- /dev/null +++ b/plugins/tracker-resources/src/components/workload/WorkloadPanel.svelte @@ -0,0 +1,219 @@ + + + + + + diff --git a/plugins/tracker-resources/src/components/workload/WorkloadToolbar.svelte b/plugins/tracker-resources/src/components/workload/WorkloadToolbar.svelte new file mode 100644 index 0000000000..dbb6b11e5a --- /dev/null +++ b/plugins/tracker-resources/src/components/workload/WorkloadToolbar.svelte @@ -0,0 +1,297 @@ + + + +
+
+ + +
+
+
    +
  • +
  • +
  • !
  • +
+
+
+ + diff --git a/plugins/tracker-resources/src/components/workload/WorkloadView.svelte b/plugins/tracker-resources/src/components/workload/WorkloadView.svelte new file mode 100644 index 0000000000..176262ef24 --- /dev/null +++ b/plugins/tracker-resources/src/components/workload/WorkloadView.svelte @@ -0,0 +1,712 @@ + + + +
+ { + changeSettings(e.detail) + }} + on:today={() => { + void grid?.scrollToToday() + }} + /> + {#if truncated} +
+ +
+ {/if} + {#if result.outOfRange > 0} +
+
+ {/if} +
+
+ {#if gridRows.length === 0} +
+ {:else} + { + select(e.detail) + }} + /> + {/if} +
+ {#if selected !== undefined} + { + onPress(e.detail.issue, e.detail.event) + }} + on:open={(e) => { + openIssue(e.detail.issue) + }} + on:reassign={(e) => { + pickAssignee(e.detail.event, e.detail.issue) + }} + on:close={() => { + selected = undefined + }} + /> + {/if} +
+ + {#if drag !== undefined && drag.moved && dragIssue !== undefined} +
+ {dragIssue.identifier} + {dragIssue.title} +
+ {/if} + + {#if toast !== undefined} +
+ {toast.text} + {#if toast.undo !== undefined} + + {/if} +
+ {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/workload/types.ts b/plugins/tracker-resources/src/components/workload/types.ts new file mode 100644 index 0000000000..d7acd078c5 --- /dev/null +++ b/plugins/tracker-resources/src/components/workload/types.ts @@ -0,0 +1,27 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Issue } from '@hcengineering/tracker' +import type { RowLoad, RowSummary } from '../../workload/compute' +import type { RowInfo } from '../../workload/rows' + +/** A row of the grid: a person (or the unassigned items) with the load and the header figures. */ +export interface GridRow { + info: RowInfo + load: RowLoad + summary: RowSummary +} + +/** A cell of the grid: a bucket of a row, or the cell of the items without a day. */ +export interface CellRef { + row: string + bucket: number | 'unscheduled' +} + +/** An item in the list of a cell and the part of its load that falls into the cell. */ +export interface PanelItem { + issue: Issue + share: number +} diff --git a/plugins/tracker-resources/src/draft/__tests__/addItem.test.ts b/plugins/tracker-resources/src/draft/__tests__/addItem.test.ts new file mode 100644 index 0000000000..aa8ccb65ab --- /dev/null +++ b/plugins/tracker-resources/src/draft/__tests__/addItem.test.ts @@ -0,0 +1,128 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { AccountRole } from '@hcengineering/core' +import { IssuePriority, MAX_PROJECT_ITEMS } from '@hcengineering/tracker' + +import { addItemAvailability, classifySearchResult, draftValuesFromUpdate } from '../addItem' +import { resolveDraftTarget } from '../target' + +describe('addItemAvailability', () => { + const base = { readonly: false, role: AccountRole.User, canCreate: true, itemCount: 10 } + + it('takes input for somebody who can create issues in a project with room', () => { + expect(addItemAvailability(base)).toBe('ok') + }) + + it('is read-only for read-only viewers and for those who may not create issues', () => { + expect(addItemAvailability({ ...base, readonly: true })).toBe('readonly') + expect(addItemAvailability({ ...base, role: AccountRole.ReadOnlyGuest })).toBe('readonly') + expect(addItemAvailability({ ...base, canCreate: false })).toBe('readonly') + }) + + it('stops at the item limit', () => { + expect(addItemAvailability({ ...base, itemCount: MAX_PROJECT_ITEMS - 1 })).toBe('ok') + expect(addItemAvailability({ ...base, itemCount: MAX_PROJECT_ITEMS })).toBe('limit') + }) + + it('reports read-only before the limit', () => { + expect(addItemAvailability({ ...base, readonly: true, itemCount: MAX_PROJECT_ITEMS })).toBe('readonly') + }) +}) + +describe('draftValuesFromUpdate', () => { + it('has no values for no update', () => { + expect(draftValuesFromUpdate(undefined)).toEqual({}) + expect(draftValuesFromUpdate({})).toEqual({}) + }) + + it('takes what a draft has from the column and the swimlane', () => { + expect( + draftValuesFromUpdate({ + status: 'st1', + priority: IssuePriority.High, + assignee: null, + component: 'c1', + milestone: null, + customFields: { sprint: 'it1' } + }) + ).toEqual({ + status: 'st1', + priority: IssuePriority.High, + assignee: null, + component: 'c1', + milestone: null, + customFields: { sprint: 'it1' } + }) + }) + + it('ignores everything else', () => { + expect(draftValuesFromUpdate({ title: 'x', number: 5, isDraft: false, status: 7, priority: 99, customFields: [1] })).toEqual({}) + }) + + it('copies the custom fields', () => { + const custom = { a: 'b' } + const values = draftValuesFromUpdate({ customFields: custom }) + expect(values.customFields).toEqual(custom) + expect(values.customFields).not.toBe(custom) + }) +}) + +describe('classifySearchResult', () => { + const ctx = (props: Record = {}): any => ({ + project: 'p1', + projectType: 'type1', + typeOfProject: (p: string) => ({ p1: 'type1', p2: 'type1', p3: 'type2' })[p], + canEdit: () => true, + ...props + }) + const issue = (props: Record = {}): any => ({ _id: 'i1', space: 'p1', ...props }) + + it('leaves an item of the project alone', () => { + expect(classifySearchResult(issue(), ctx())).toBe('in-project') + }) + + it('restores an archived item of the project', () => { + expect(classifySearchResult(issue({ archivedAt: 5 }), ctx())).toBe('restore') + expect(classifySearchResult(issue({ archivedAt: null }), ctx())).toBe('in-project') + expect(classifySearchResult(issue({ archivedAt: 5 }), ctx({ canEdit: () => false }))).toBe('not-movable') + }) + + it('moves an issue of another project of the same type', () => { + expect(classifySearchResult(issue({ space: 'p2' }), ctx())).toBe('move') + }) + + it('does not move across project types, from a closed project, an archived issue or without permission', () => { + expect(classifySearchResult(issue({ space: 'p3' }), ctx())).toBe('not-movable') + expect(classifySearchResult(issue({ space: 'gone' }), ctx())).toBe('not-movable') + expect(classifySearchResult(issue({ space: 'p2', archivedAt: 5 }), ctx())).toBe('not-movable') + expect(classifySearchResult(issue({ space: 'p2' }), ctx({ canEdit: () => false }))).toBe('not-movable') + }) +}) + +describe('resolveDraftTarget', () => { + const project: any = { _id: 'p1', identifier: 'P', type: 'type1' } + const types: any[] = [ + { _id: 'other', parent: 'type2', statuses: ['x'] }, + { _id: 'epic', parent: 'type1', statuses: ['s1', 's2'] }, + { _id: 'task', parent: 'type1', statuses: ['s3'] } + ] + + it('takes the first creatable task type and its first status', () => { + expect(resolveDraftTarget(project, types, (t) => t._id !== 'epic')).toEqual({ project, kind: 'task', status: 's3' }) + expect(resolveDraftTarget(project, types, () => true)).toEqual({ project, kind: 'epic', status: 's1' }) + }) + + it('prefers the default status of the project', () => { + const withDefault = { ...project, defaultIssueStatus: 'sd' } + expect(resolveDraftTarget(withDefault, types, () => true)?.status).toBe('sd') + }) + + it('finds nothing without a creatable task type or a status', () => { + expect(resolveDraftTarget(project, types, () => false)).toBeUndefined() + expect(resolveDraftTarget({ ...project, type: 'type3' }, types, () => true)).toBeUndefined() + expect(resolveDraftTarget(project, [{ _id: 't', parent: 'type1', statuses: [] } as any], () => true)).toBeUndefined() + }) +}) diff --git a/plugins/tracker-resources/src/draft/__tests__/convert.test.ts b/plugins/tracker-resources/src/draft/__tests__/convert.test.ts new file mode 100644 index 0000000000..c5647a96c3 --- /dev/null +++ b/plugins/tracker-resources/src/draft/__tests__/convert.test.ts @@ -0,0 +1,105 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { convertDraftsToIssues, DraftConvertError, MAX_CONVERT_BATCH, selectDraftsToConvert } from '../convert' + +const draft = (id: string, space = 'p1', isDraft: boolean = true): any => ({ _id: id, space, isDraft }) + +interface Fake { + client: any + sequence: Record + matches: any[] + updates: any[] + commits: number +} + +function fake (opts: { commitResult?: boolean, projects?: Record } = {}): Fake { + const state: Fake = { client: undefined, sequence: { p1: 10, p2: 100 }, matches: [], updates: [], commits: 0 } + const projects = opts.projects ?? { p1: { _id: 'p1', identifier: 'ONE' }, p2: { _id: 'p2', identifier: 'TWO' } } + state.client = { + findOne: async (_class: any, query: any) => projects[query._id], + updateDoc: async (_class: any, _space: any, id: string, update: any, retrieve: boolean) => { + expect(update).toEqual({ $inc: { sequence: 1 } }) + expect(retrieve).toBe(true) + state.sequence[id] += 1 + return { object: { sequence: state.sequence[id] } } + }, + apply: () => { + const batch: any = { + match: (_class: any, query: any) => { + state.matches.push(query) + return batch + }, + update: async (doc: any, update: any) => { + state.updates.push([doc._id, update]) + }, + commit: async () => { + state.commits++ + return { result: opts.commitResult ?? true } + } + } + return batch + } + } + return state +} + +describe('selectDraftsToConvert', () => { + it('keeps the drafts, each once', () => { + const list = [draft('a'), draft('a'), draft('b', 'p1', false), { _id: 'c', space: 'p1' }, draft('d')] + expect(selectDraftsToConvert(list).map((d) => d._id)).toEqual(['a', 'd']) + }) + + it('stops at the cap', () => { + const list = Array.from({ length: MAX_CONVERT_BATCH + 10 }, (_, i) => draft(`d${i}`)) + expect(selectDraftsToConvert(list)).toHaveLength(MAX_CONVERT_BATCH) + }) +}) + +describe('convertDraftsToIssues', () => { + it('numbers the drafts from the sequence of their project and clears the flag in one batch', async () => { + const f = fake() + const result = await convertDraftsToIssues(f.client, [draft('a'), draft('b'), draft('c', 'p2')]) + expect(f.updates).toEqual([ + ['a', { number: 11, identifier: 'ONE-11', isDraft: false }], + ['b', { number: 12, identifier: 'ONE-12', isDraft: false }], + ['c', { number: 101, identifier: 'TWO-101', isDraft: false }] + ]) + expect(f.commits).toBe(1) + expect([...result.entries()]).toEqual([ + ['a', 'ONE-11'], + ['b', 'ONE-12'], + ['c', 'TWO-101'] + ]) + }) + + it('goes through only while every draft is still a draft', async () => { + const f = fake() + await convertDraftsToIssues(f.client, [draft('a'), draft('b')]) + expect(f.matches).toEqual([ + { _id: 'a', isDraft: true }, + { _id: 'b', isDraft: true } + ]) + }) + + it('does nothing for items that are not drafts', async () => { + const f = fake() + const result = await convertDraftsToIssues(f.client, [draft('a', 'p1', false)]) + expect(result.size).toBe(0) + expect(f.commits).toBe(0) + expect(f.sequence.p1).toBe(10) + }) + + it('fails when the batch is refused', async () => { + const f = fake({ commitResult: false }) + await expect(convertDraftsToIssues(f.client, [draft('a')])).rejects.toBeInstanceOf(DraftConvertError) + }) + + it('fails without touching the sequence when a project is gone', async () => { + const f = fake({ projects: {} }) + await expect(convertDraftsToIssues(f.client, [draft('a')])).rejects.toBeInstanceOf(DraftConvertError) + expect(f.sequence.p1).toBe(10) + }) +}) diff --git a/plugins/tracker-resources/src/draft/__tests__/create.test.ts b/plugins/tracker-resources/src/draft/__tests__/create.test.ts new file mode 100644 index 0000000000..88ba7586f8 --- /dev/null +++ b/plugins/tracker-resources/src/draft/__tests__/create.test.ts @@ -0,0 +1,108 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { IssuePriority } from '@hcengineering/tracker' + +import { buildDraftIssue, createDraftItem, pickDefaultKind, type DraftTarget } from '../create' + +const target = (props: Record = {}): DraftTarget => + ({ + project: { _id: 'p1', identifier: 'PROJ', type: 'type1', defaultIssueStatus: undefined }, + kind: 'kind1', + status: 'todo', + ...props + }) as any + +describe('buildDraftIssue', () => { + it('makes a draft with number 0, a placeholder identifier and the defaults of the project', () => { + const doc = buildDraftIssue(target(), 'Think about it') + expect(doc.isDraft).toBe(true) + expect(doc.number).toBe(0) + expect(doc.identifier).toBe('PROJ-Draft') + expect(doc.title).toBe('Think about it') + expect(doc.status).toBe('todo') + expect(doc.kind).toBe('kind1') + expect(doc.priority).toBe(IssuePriority.NoPriority) + expect(doc.assignee).toBeNull() + expect(doc.parents).toEqual([]) + expect(doc.customFields).toBeUndefined() + }) + + it('has no assignee unless one is given, not even the default assignee of the project', () => { + const withDefault = target({ project: { _id: 'p1', identifier: 'P', type: 't', defaultAssignee: 'emp1' } }) + expect(buildDraftIssue(withDefault, 'a').assignee).toBeNull() + expect(buildDraftIssue(withDefault, 'a', { assignee: null }).assignee).toBeNull() + expect(buildDraftIssue(withDefault, 'a', { assignee: 'emp2' as any }).assignee).toBe('emp2') + }) + + it('starts with the values of the column and the lane it is added from', () => { + const doc = buildDraftIssue(target(), 'a', { + status: 'doing' as any, + priority: IssuePriority.High, + component: 'c1' as any, + milestone: 'm1' as any, + customFields: { sprint: 'it1' } + }) + expect(doc.status).toBe('doing') + expect(doc.priority).toBe(IssuePriority.High) + expect(doc.component).toBe('c1') + expect(doc.milestone).toBe('m1') + expect(doc.customFields).toEqual({ sprint: 'it1' }) + }) + + it('starts with the dates it is given, e.g. the day of a calendar cell', () => { + const doc = buildDraftIssue(target(), 'a', { startDate: 100, dueDate: 200, deadline: 300 }) + expect(doc.startDate).toBe(100) + expect(doc.dueDate).toBe(200) + expect(doc.deadline).toBe(300) + const plain = buildDraftIssue(target(), 'a') + expect(plain.startDate).toBeNull() + expect(plain.dueDate).toBeNull() + expect('deadline' in plain).toBe(false) + }) + + it('leaves out an empty custom field record', () => { + expect('customFields' in buildDraftIssue(target(), 'a', { customFields: {} })).toBe(false) + }) +}) + +describe('createDraftItem', () => { + it('adds the draft as a top level issue of the project without touching the sequence', async () => { + const calls: any[][] = [] + const client: any = { + addCollection: async (...args: any[]) => { + calls.push(args) + return args[args.length - 1] + } + } + const id = await createDraftItem(client, target(), 'Idea') + expect(calls).toHaveLength(1) + const [, space, attachedTo, , collection, data, createdId] = calls[0] + expect(space).toBe('p1') + expect(attachedTo).toBeDefined() + expect(collection).toBe('subIssues') + expect(data.isDraft).toBe(true) + expect(createdId).toBe(id) + expect(typeof id).toBe('string') + }) +}) + +describe('pickDefaultKind', () => { + const types = [ + { _id: 'other', parent: 'type2' }, + { _id: 'epic', parent: 'type1' }, + { _id: 'task', parent: 'type1' } + ] as any[] + + it('takes the first task type of the project type that can be created', () => { + expect(pickDefaultKind(types, 'type1' as any, (t) => t._id !== 'epic')).toBe('task') + expect(pickDefaultKind(types, 'type1' as any, () => true)).toBe('epic') + }) + + it('finds nothing for a project type without a creatable task type', () => { + expect(pickDefaultKind(types, 'type3' as any, () => true)).toBeUndefined() + expect(pickDefaultKind(types, 'type1' as any, () => false)).toBeUndefined() + }) +}) diff --git a/plugins/tracker-resources/src/draft/actions.ts b/plugins/tracker-resources/src/draft/actions.ts new file mode 100644 index 0000000000..efd6ca7523 --- /dev/null +++ b/plugins/tracker-resources/src/draft/actions.ts @@ -0,0 +1,21 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { Analytics } from '@hcengineering/analytics' +import { getClient } from '@hcengineering/presentation' +import type { Issue } from '@hcengineering/tracker' + +import { convertDraftsToIssues } from './convert' + +/** + * Converts draft items to issues (GitHub "Convert to issue"): they get the next numbers of their project. + */ +export async function convertDraftToIssue (draft: Issue | Issue[]): Promise { + try { + await convertDraftsToIssues(getClient(), Array.isArray(draft) ? draft : [draft]) + } catch (err: any) { + Analytics.handleError(err) + } +} diff --git a/plugins/tracker-resources/src/draft/addItem.ts b/plugins/tracker-resources/src/draft/addItem.ts new file mode 100644 index 0000000000..f9cf9a249d --- /dev/null +++ b/plugins/tracker-resources/src/draft/addItem.ts @@ -0,0 +1,86 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { AccountRole, type Ref } from '@hcengineering/core' +import type { ProjectType } from '@hcengineering/task' +import { isProjectItemLimitReached, IssuePriority, type Project } from '@hcengineering/tracker' + +import type { DraftValues } from './create' + +/** + * Whether the "Add item" row of a project takes input: `ok`, `readonly` (a read-only viewer, or somebody who may not + * create issues in the project), or `limit` (the project holds the most items it can). + */ +export type AddItemAvailability = 'ok' | 'readonly' | 'limit' + +export interface AddItemAvailabilityInput { + // The view is read-only (the restrictions of the workspace or of the view) + readonly: boolean + role: AccountRole | undefined + // Whether the permissions of the project allow the user to create issues + canCreate: boolean + // Items the project holds now, drafts and archived items included + itemCount: number +} + +export function addItemAvailability (input: AddItemAvailabilityInput): AddItemAvailability { + if (input.readonly || input.role === AccountRole.ReadOnlyGuest || !input.canCreate) return 'readonly' + return isProjectItemLimitReached(input.itemCount) ? 'limit' : 'ok' +} + +const PRIORITIES = new Set(Object.values(IssuePriority).filter((it): it is number => typeof it === 'number')) + +/** + * The values a draft starts with, taken from what dropping a card on a board cell writes (`resolveDropUpdate`): the + * column and the swimlane. Only what a draft has is taken over, anything else is ignored. + */ +export function draftValuesFromUpdate (update: Record | undefined): DraftValues { + const values: DraftValues = {} + if (update === undefined) return values + if (typeof update.status === 'string') values.status = update.status as DraftValues['status'] + if (typeof update.priority === 'number' && PRIORITIES.has(update.priority)) values.priority = update.priority + for (const key of ['assignee', 'component', 'milestone'] as const) { + const value = update[key] + if (typeof value === 'string' || value === null) (values as Record)[key] = value + } + const custom = update.customFields + if (custom !== null && typeof custom === 'object' && !Array.isArray(custom)) { + values.customFields = { ...(custom as Record) } + } + return values +} + +/** + * What picking an issue from the search of the "Add item" row does: + * - `in-project`: it is an item of this project already, nothing to do; + * - `restore`: it is an archived item of this project, it is restored; + * - `move`: it is an issue of another project of the same type, which can be moved here (an issue belongs to exactly + * one project, so it leaves the other one); + * - `not-movable`: it cannot be moved here (another project type, a project that is closed, an archived issue, or an + * issue the user may not change). + */ +export type SearchResultAction = 'in-project' | 'restore' | 'move' | 'not-movable' + +export interface SearchResultContext { + project: Ref + projectType: Ref + // Type of the project with the given id, `undefined` for a project that is closed or not known + typeOfProject: (project: Ref) => Ref | undefined + // Whether the user may change the issue + canEdit: (issueId: string) => boolean +} + +export function classifySearchResult ( + issue: { _id: string, space: Ref, archivedAt?: number | null }, + ctx: SearchResultContext +): SearchResultAction { + const archived = issue.archivedAt !== undefined && issue.archivedAt !== null + if (issue.space === ctx.project) { + if (!archived) return 'in-project' + return ctx.canEdit(issue._id) ? 'restore' : 'not-movable' + } + if (archived || ctx.typeOfProject(issue.space) !== ctx.projectType || !ctx.canEdit(issue._id)) return 'not-movable' + return 'move' +} diff --git a/plugins/tracker-resources/src/draft/convert.ts b/plugins/tracker-resources/src/draft/convert.ts new file mode 100644 index 0000000000..76a55bcf0f --- /dev/null +++ b/plugins/tracker-resources/src/draft/convert.ts @@ -0,0 +1,77 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { type Ref, type TxOperations } from '@hcengineering/core' +import tracker, { convertDraftUpdate, isIssueDraft, type Issue, type Project } from '@hcengineering/tracker' + +/** Drafts one conversion handles at most, so that one click never turns into an unbounded write. */ +export const MAX_CONVERT_BATCH = 500 + +/** A draft that could not be converted (somebody changed it meanwhile, or its project is gone). */ +export class DraftConvertError extends Error { + constructor (message: string) { + super(message) + this.name = 'DraftConvertError' + } +} + +/** The drafts a conversion handles: the ones that are still drafts, each once, up to the cap. */ +export function selectDraftsToConvert> (drafts: readonly T[]): T[] { + const seen = new Set>() + const result: T[] = [] + for (const draft of drafts) { + if (seen.has(draft._id) || !isIssueDraft(draft)) continue + seen.add(draft._id) + result.push(draft) + if (result.length >= MAX_CONVERT_BATCH) break + } + return result +} + +/** + * Converts draft items to issues (GitHub "Convert to issue"). The numbers come from the sequence of the project, the + * way the create issue form takes them: the sequence is incremented first (the new value is needed to build the + * identifier), then the drafts of all projects are updated in one `apply` batch that only goes through when every one + * of them is still a draft, so that a draft converted by somebody else in the meantime is never renumbered. When the + * batch is refused, the numbers that were taken stay unused, as after a failed create. + * + * Returns the identifiers the drafts got, by draft id. + */ +export async function convertDraftsToIssues ( + client: TxOperations, + drafts: readonly Issue[] +): Promise, string>> { + const targets = selectDraftsToConvert(drafts) + const result = new Map, string>() + if (targets.length === 0) return result + + const projects = new Map, Project>() + for (const draft of targets) { + if (projects.has(draft.space)) continue + const project = await client.findOne(tracker.class.Project, { _id: draft.space }) + if (project === undefined) throw new DraftConvertError(`Project ${draft.space} was not found`) + projects.set(draft.space, project) + } + + const batch = client.apply(undefined, 'tracker.convertDraft') + for (const draft of targets) { + const project = projects.get(draft.space) as Project + const incResult = await client.updateDoc( + tracker.class.Project, + core.space.Space, + project._id, + { $inc: { sequence: 1 } }, + true + ) + const number = (incResult as any).object.sequence as number + const update = convertDraftUpdate(number, project.identifier) + batch.match(tracker.class.Issue, { _id: draft._id, isDraft: true }) + await batch.update(draft, update) + result.set(draft._id, update.identifier) + } + const committed = await batch.commit() + if (!committed.result) throw new DraftConvertError('The draft was changed meanwhile and is not converted') + return result +} diff --git a/plugins/tracker-resources/src/draft/create.ts b/plugins/tracker-resources/src/draft/create.ts new file mode 100644 index 0000000000..071f782f9b --- /dev/null +++ b/plugins/tracker-resources/src/draft/create.ts @@ -0,0 +1,121 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { generateId, type DocData, type Ref, type TxOperations } from '@hcengineering/core' +import type { TaskType } from '@hcengineering/task' +import tracker, { + draftIdentifier, + DRAFT_NUMBER, + IssuePriority, + type Component, + type Issue, + type IssueStatus, + type Milestone, + type Project +} from '@hcengineering/tracker' +import type { Employee } from '@hcengineering/contact' + +/** What a draft item needs to know about its project. */ +export type DraftProject = Pick + +/** + * Values a draft starts with, e.g. the column and the swimlane of the board it is added from. What is not given is + * the default of the project (status, assignee) or empty. + */ +export interface DraftValues { + status?: Ref + priority?: IssuePriority + assignee?: Ref | null + component?: Ref | null + milestone?: Ref | null + // Dates, e.g. the day of the calendar cell a draft is added from + startDate?: number | null + dueDate?: number | null + deadline?: number | null + customFields?: Record +} + +/** What a draft needs that comes from the task types of the project. */ +export interface DraftTarget { + project: DraftProject + kind: Ref + // The status of a draft that is added without one (the default status of the project, or the first status of its type) + status: Ref +} + +/** + * The kind (task type) a new item gets: the first task type of the project type that issues can be created of, like the + * type selector of the create issue form. + */ +export function pickDefaultKind ( + taskTypes: Iterable>, + projectType: Project['type'], + canCreate: (taskType: Pick) => boolean +): Ref | undefined { + for (const taskType of taskTypes) { + if (taskType.parent === projectType && canCreate(taskType)) return taskType._id + } + return undefined +} + +/** + * The document of a draft item. It has the number 0 and a placeholder identifier, because it takes no number from the + * sequence of the project until it is converted to an issue. + */ +export function buildDraftIssue (target: DraftTarget, title: string, values: DraftValues = {}): DocData { + const { project } = target + return { + title, + description: null, + // The default assignee of the project is not applied: it would send an assignment notification for a mere idea + assignee: values.assignee ?? null, + component: values.component ?? null, + milestone: values.milestone ?? null, + number: DRAFT_NUMBER, + status: values.status ?? target.status, + priority: values.priority ?? IssuePriority.NoPriority, + rank: '', + comments: 0, + subIssues: 0, + startDate: values.startDate ?? null, + dueDate: values.dueDate ?? null, + parents: [], + reportedTime: 0, + remainingTime: 0, + estimation: 0, + reports: 0, + relations: [], + childInfo: [], + kind: target.kind, + identifier: draftIdentifier(project.identifier), + isDraft: true, + ...(values.deadline !== undefined && values.deadline !== null ? { deadline: values.deadline } : {}), + ...(values.customFields !== undefined && Object.keys(values.customFields).length > 0 + ? { customFields: values.customFields } + : {}) + } +} + +/** + * Creates a draft item in the project and returns its id. The sequence of the project is not touched. + */ +export async function createDraftItem ( + client: Pick, + target: DraftTarget, + title: string, + values: DraftValues = {} +): Promise> { + const id: Ref = generateId() + await client.addCollection( + tracker.class.Issue, + target.project._id, + tracker.ids.NoParent, + tracker.class.Issue, + 'subIssues', + buildDraftIssue(target, title, values), + id + ) + return id +} diff --git a/plugins/tracker-resources/src/draft/itemCount.ts b/plugins/tracker-resources/src/draft/itemCount.ts new file mode 100644 index 0000000000..0cfee357fd --- /dev/null +++ b/plugins/tracker-resources/src/draft/itemCount.ts @@ -0,0 +1,40 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import { createQuery } from '@hcengineering/presentation' +import type { Project } from '@hcengineering/tracker' +import { readable, type Readable } from 'svelte/store' + +import tracker from '../plugin' + +const stores = new Map, Readable>() + +/** + * Number of items (issues, drafts and archived items) a project holds, live. One store, and so one query, is shared by + * everything that asks for the same project (every column of a board has an "Add item" row). The query is started on + * the first subscription and stopped when the last subscriber leaves. + */ +export function sharedItemCountStore (project: Ref): Readable { + let store = stores.get(project) + if (store === undefined) { + store = readable(0, (set) => { + const query = createQuery(true) + query.query( + tracker.class.Issue, + { space: project }, + (res) => { + set(res.total) + }, + { limit: 1, total: true, projection: { _id: 1 } } + ) + return () => { + query.unsubscribe() + } + }) + stores.set(project, store) + } + return store +} diff --git a/plugins/tracker-resources/src/draft/target.ts b/plugins/tracker-resources/src/draft/target.ts new file mode 100644 index 0000000000..1e70ec3c33 --- /dev/null +++ b/plugins/tracker-resources/src/draft/target.ts @@ -0,0 +1,36 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import type { ProjectType, TaskType } from '@hcengineering/task' +import type { IssueStatus } from '@hcengineering/tracker' + +import type { DraftProject, DraftTarget } from './create' + +/** What a draft needs to know about a task type. */ +export interface DraftTaskType { + _id: Ref + parent: Ref + statuses: ReadonlyArray | string> +} + +/** + * The kind and the status a draft of the project starts with. The kind is the first task type of the project type that + * issues can be created of (like the type selector of the create issue form); the status is the default status of the + * project, or else the first status of that kind. `undefined` when the project type has no such task type or status. + */ +export function resolveDraftTarget ( + project: DraftProject, + taskTypes: Iterable, + canCreate: (taskType: T) => boolean +): DraftTarget | undefined { + for (const taskType of taskTypes) { + if (taskType.parent !== (project.type as unknown) || !canCreate(taskType)) continue + const status = project.defaultIssueStatus ?? (taskType.statuses[0] as Ref | undefined) + if (status === undefined) continue + return { project, kind: taskType._id, status } + } + return undefined +} diff --git a/plugins/tracker-resources/src/fieldSum/__tests__/fieldSum.test.ts b/plugins/tracker-resources/src/fieldSum/__tests__/fieldSum.test.ts new file mode 100644 index 0000000000..f46a6e0460 --- /dev/null +++ b/plugins/tracker-resources/src/fieldSum/__tests__/fieldSum.test.ts @@ -0,0 +1,202 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, type ProjectField } from '@hcengineering/tracker' +import { + buildSummableFields, + ESTIMATION_SUM_KEY, + FIELD_SUMS_OPTION_KEY, + normalizeFieldSums, + readFieldSums, + resolveFieldSums, + sumProjection, + toggleFieldSum, + withFieldSums +} from '../config' +import { + computeFieldSums, + computeFieldSumsByGroup, + formatFieldSums, + formatSumValue, + sumNumbers, + toSummable +} from '../sum' + +const fields = [ + { key: 'points', label: 'Story points', type: ProjectFieldType.Number }, + { key: 'notes', label: 'Notes', type: ProjectFieldType.Text }, + { key: 'hours', label: 'Hours', type: ProjectFieldType.Number } +] as unknown as ProjectField[] + +const summable = buildSummableFields(fields, 'Estimation') + +describe('toSummable', () => { + it('accepts finite numbers and numeric text', () => { + expect(toSummable(5)).toBe(5) + expect(toSummable(-2.5)).toBe(-2.5) + expect(toSummable('7')).toBe(7) + expect(toSummable(' 1.5 ')).toBe(1.5) + expect(toSummable(0)).toBe(0) + }) + + it('ignores everything else', () => { + for (const v of [NaN, Infinity, -Infinity, '', ' ', 'abc', '5kg', null, undefined, {}, [], true, ['1']]) { + expect(toSummable(v)).toBeUndefined() + } + }) +}) + +describe('sumNumbers', () => { + it('sums an empty list to zero', () => { + expect(sumNumbers([])).toEqual({ sum: 0, count: 0 }) + }) + + it('ignores values that are not numbers and counts the ones that are', () => { + expect(sumNumbers([1, NaN, undefined, null, 'x', 2, '3'])).toEqual({ sum: 6, count: 3 }) + }) + + it('is not poisoned by NaN or Infinity', () => { + const { sum } = sumNumbers([1, NaN, Infinity, -Infinity, 2]) + expect(sum).toBe(3) + }) + + it('does not accumulate float error', () => { + expect(sumNumbers([0.1, 0.2]).sum).toBeCloseTo(0.3, 12) + expect(sumNumbers(new Array(10).fill(0.1)).sum).toBe(1) + }) + + it('is exact for a large list', () => { + const values = new Array(200000).fill(0.1) + const { sum, count } = sumNumbers(values) + expect(count).toBe(200000) + expect(Math.abs(sum - 20000)).toBeLessThan(1e-6) + const ints = Array.from({ length: 100000 }, (_, i) => i + 1) + expect(sumNumbers(ints).sum).toBe(5000050000) + }) + + it('keeps a sum that overflows infinite instead of NaN', () => { + const res = sumNumbers([1e308, 1e308, 5, -1e308]) + expect(res.sum).toBe(Infinity) + expect(res.count).toBe(4) + }) + + it('handles negative values and cancellation', () => { + expect(sumNumbers([5, -5]).sum).toBe(0) + expect(sumNumbers([1e16, 1, -1e16]).sum).toBe(1) + }) +}) + +describe('computeFieldSums', () => { + const docs = [ + { estimation: 2, customFields: { points: 3, hours: 1.5 } }, + { estimation: 0, customFields: { points: 5 } }, + { estimation: 4 }, + { customFields: { points: 'n/a', hours: NaN } } + ] + + it('sums every field over the documents', () => { + const res = computeFieldSums(docs, summable.filter((f) => f.key !== 'customFields.hours')) + expect(res).toEqual([ + { key: 'estimation', label: 'Estimation', sum: 6, count: 3 }, + { key: 'customFields.points', label: 'Story points', sum: 8, count: 2 } + ]) + }) + + it('gives zero for no documents', () => { + expect(computeFieldSums([], summable).map((r) => r.sum)).toEqual([0, 0, 0]) + }) + + it('gives no results for no fields', () => { + expect(computeFieldSums(docs, [])).toEqual([]) + }) + + it('sums the documents of a view that stay after the filter, nothing else', () => { + // A document that is filtered out of the list is not passed in, so it does not count + const shown = docs.filter((d) => (d.estimation ?? 0) > 0) + expect(computeFieldSums(shown, [summable[0]])[0].sum).toBe(6) + expect(computeFieldSums(docs.slice(1, 2), [summable[0]])[0].sum).toBe(0) + }) + + it('sums per group, a document in one group only', () => { + const items = [ + { g: 'a', estimation: 1 }, + { g: 'b', estimation: 10 }, + { g: 'a', estimation: 2 }, + { g: 'c' } + ] + const res = computeFieldSumsByGroup(items, [summable[0]], (d) => d.g) + expect(res.get('a')?.[0].sum).toBe(3) + expect(res.get('b')?.[0].sum).toBe(10) + expect(res.get('c')?.[0]).toMatchObject({ sum: 0, count: 0 }) + // The groups add up to the total + const total = [...res.values()].reduce((acc, r) => acc + r[0].sum, 0) + expect(total).toBe(computeFieldSums(items, [summable[0]])[0].sum) + expect(res.has('d')).toBe(false) + }) +}) + +describe('formatting', () => { + it('prints at most two decimals without trailing zeros', () => { + expect(formatSumValue(12)).toBe('12') + expect(formatSumValue(12.5)).toBe('12.5') + expect(formatSumValue(1.005)).toBe('1') + expect(formatSumValue(0.1 + 0.2)).toBe('0.3') + expect(formatSumValue(-0.001)).toBe('0') + expect(formatSumValue(1e21)).toBe('1000000000000000000000') + expect(formatSumValue(Infinity)).toBe('∞') + }) + + it('formats a line of sums', () => { + const res = computeFieldSums([{ estimation: 2, customFields: { points: 3 } }], summable.slice(0, 2)) + expect(formatFieldSums(res)).toBe('Estimation: 2 · Story points: 3') + expect(formatFieldSums(res, false)).toBe('2 · 3') + expect(formatFieldSums([])).toBeUndefined() + }) +}) + +describe('config', () => { + it('offers the estimation and the Number fields only', () => { + expect(summable.map((f) => f.key)).toEqual(['estimation', 'customFields.points', 'customFields.hours']) + expect(summable.map((f) => f.label)).toEqual(['Estimation', 'Story points', 'Hours']) + }) + + it('reads whatever is stored defensively', () => { + expect(normalizeFieldSums(undefined)).toEqual([]) + expect(normalizeFieldSums('estimation')).toEqual([]) + expect(normalizeFieldSums(['a', '', 'a', 5, 'b'])).toEqual(['a', 'b']) + expect(readFieldSums({ [FIELD_SUMS_OPTION_KEY]: ['estimation'] })).toEqual(['estimation']) + expect(readFieldSums(undefined)).toEqual([]) + }) + + it('stores nothing for an empty list, so that the view stays clean', () => { + const opts = { groupBy: ['status'], [FIELD_SUMS_OPTION_KEY]: ['estimation'] } + expect(FIELD_SUMS_OPTION_KEY in withFieldSums(opts, [])).toBe(false) + expect(withFieldSums({ groupBy: ['status'] }, [])).toEqual({ groupBy: ['status'] }) + expect(withFieldSums(opts, ['estimation', 'customFields.points'])[FIELD_SUMS_OPTION_KEY]).toEqual([ + 'estimation', + 'customFields.points' + ]) + // The options that were passed are not changed + expect(opts[FIELD_SUMS_OPTION_KEY]).toEqual(['estimation']) + }) + + it('toggles a field', () => { + expect(toggleFieldSum([], 'estimation')).toEqual(['estimation']) + expect(toggleFieldSum(['estimation'], 'customFields.points')).toEqual(['estimation', 'customFields.points']) + expect(toggleFieldSum(['estimation', 'customFields.points'], 'estimation')).toEqual(['customFields.points']) + }) + + it('leaves out a field that no longer exists', () => { + const res = resolveFieldSums(['customFields.hours', 'customFields.gone', ESTIMATION_SUM_KEY], summable) + expect(res.map((f) => f.key)).toEqual(['customFields.hours', 'estimation']) + }) + + it('knows what has to be loaded', () => { + expect(sumProjection([])).toEqual([]) + expect(sumProjection(['estimation'])).toEqual(['estimation']) + expect(sumProjection(['customFields.a', 'customFields.b'])).toEqual(['customFields']) + expect(sumProjection(['estimation', 'customFields.a', 'unknown']).sort()).toEqual(['customFields', 'estimation']) + }) +}) diff --git a/plugins/tracker-resources/src/fieldSum/config.ts b/plugins/tracker-resources/src/fieldSum/config.ts new file mode 100644 index 0000000000..ecebcf008d --- /dev/null +++ b/plugins/tracker-resources/src/fieldSum/config.ts @@ -0,0 +1,104 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ProjectField } from '@hcengineering/tracker' +import { ProjectFieldType } from '@hcengineering/tracker' +import { parseCustomFieldViewKey, toCustomFieldViewKey } from '../projectFields/query' + +/** + * Number fields whose sum a view shows ("Field sum" of GitHub Projects). The keys are stored in the view options + * under `FIELD_SUMS_OPTION_KEY`, so that they are saved with the saved view, restored when it is opened and take + * part in its unsaved-changes tracking (the same way as the roadmap and board settings). + * + * A key is the view key of the field: `estimation` for the estimation of an issue and `customFields.` for a + * Number custom field. + */ +export const FIELD_SUMS_OPTION_KEY = 'fieldSums' + +/** View key of the built-in number field, the estimation of an issue. */ +export const ESTIMATION_SUM_KEY = 'estimation' + +/** A number field of an issue that can be summed. */ +export interface SummableField { + // View key, see above + key: string + // Already translated + label: string + // Raw value of the field in an issue; anything that is not a finite number is ignored by the sums + read: (doc: any) => unknown +} + +/** Keys that are stored: unique, in the order they were chosen, empty ones dropped. */ +export function normalizeFieldSums (raw: unknown): string[] { + if (!Array.isArray(raw)) return [] + const res: string[] = [] + for (const key of raw) { + if (typeof key === 'string' && key !== '' && !res.includes(key)) res.push(key) + } + return res +} + +export function readFieldSums (viewOptions: Record | undefined): string[] { + return normalizeFieldSums(viewOptions?.[FIELD_SUMS_OPTION_KEY]) +} + +/** The view options with the keys stored. Nothing is stored for an empty list, so a view without sums stays clean. */ +export function withFieldSums> (options: O, keys: readonly string[]): O { + const next: Record = { ...options } + const own = normalizeFieldSums(keys) + if (own.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[FIELD_SUMS_OPTION_KEY] + } else { + next[FIELD_SUMS_OPTION_KEY] = own + } + return next as O +} + +/** The keys with one switched on or off; a field that is switched on goes last. */ +export function toggleFieldSum (keys: readonly string[], key: string): string[] { + return keys.includes(key) ? keys.filter((it) => it !== key) : [...keys, key] +} + +/** + * Number fields a view can sum: the estimation of the issue first, then the Number custom fields of the project in + * the order of the project. + */ +export function buildSummableFields ( + fields: ReadonlyArray>, + estimationLabel: string +): SummableField[] { + return [ + { key: ESTIMATION_SUM_KEY, label: estimationLabel, read: (doc) => doc?.estimation }, + ...fields + .filter((f) => f.type === ProjectFieldType.Number) + .map((f) => ({ + key: toCustomFieldViewKey(f.key), + label: f.label, + read: (doc: any) => doc?.customFields?.[f.key] + })) + ] +} + +/** The chosen fields that exist, in the order they were chosen. A deleted field is left out (it stays in the view). */ +export function resolveFieldSums (keys: readonly string[], available: readonly SummableField[]): SummableField[] { + const byKey = new Map(available.map((f) => [f.key, f])) + const res: SummableField[] = [] + for (const key of keys) { + const field = byKey.get(key) + if (field !== undefined) res.push(field) + } + return res +} + +/** Document properties that the sums of the keys read. */ +export function sumProjection (keys: readonly string[]): string[] { + const res = new Set() + for (const key of keys) { + if (key === ESTIMATION_SUM_KEY) res.add('estimation') + else if (parseCustomFieldViewKey(key) !== undefined) res.add('customFields') + } + return [...res] +} diff --git a/plugins/tracker-resources/src/fieldSum/load.ts b/plugins/tracker-resources/src/fieldSum/load.ts new file mode 100644 index 0000000000..d7fbad4091 --- /dev/null +++ b/plugins/tracker-resources/src/fieldSum/load.ts @@ -0,0 +1,18 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { translate } from '@hcengineering/platform' +import type { ProjectField } from '@hcengineering/tracker' +import tracker from '../plugin' +import { buildSummableFields, type SummableField } from './config' + +/** The number fields a view can sum, with the translated label of the estimation. */ +export async function loadSummableFields ( + fields: ReadonlyArray>, + lang: string +): Promise { + const estimation = await translate(tracker.string.Estimation, {}, lang) + return buildSummableFields(fields, estimation) +} diff --git a/plugins/tracker-resources/src/fieldSum/sum.ts b/plugins/tracker-resources/src/fieldSum/sum.ts new file mode 100644 index 0000000000..03026d7038 --- /dev/null +++ b/plugins/tracker-resources/src/fieldSum/sum.ts @@ -0,0 +1,98 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { SummableField } from './config' + +/** Sum of one field over a list of documents. */ +export interface FieldSumResult { + key: string + label: string + sum: number + // Documents that have a number in the field + count: number +} + +/** The number a raw value stands for; anything that is not a finite number (also `NaN`, text, empty) is none. */ +export function toSummable (raw: unknown): number | undefined { + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined + if (typeof raw === 'string' && raw.trim() !== '') { + const n = Number(raw) + return Number.isFinite(n) ? n : undefined + } + return undefined +} + +/** + * Sum of the numbers among the values. The values that are not numbers are ignored, they neither poison the sum + * nor count. The sum is compensated (Neumaier), so that `0.1 + 0.2` is `0.3` and long lists do not drift. + */ +export function sumNumbers (values: Iterable): { sum: number, count: number } { + let sum = 0 + let compensation = 0 + let count = 0 + for (const raw of values) { + const n = toSummable(raw) + if (n === undefined) continue + count++ + // A sum that overflowed stays infinite (it never turns into NaN), the rest is only counted + if (!Number.isFinite(sum)) continue + const t = sum + n + if (!Number.isFinite(t)) { + sum = t + continue + } + compensation += Math.abs(sum) >= Math.abs(n) ? sum - t + n : n - t + sum + sum = t + } + return { sum: Number.isFinite(sum) ? sum + compensation : sum, count } +} + +/** Sums of the fields over the documents, one result per field, in the order of the fields. */ +export function computeFieldSums (docs: readonly unknown[], fields: readonly SummableField[]): FieldSumResult[] { + return fields.map((field) => { + const { sum, count } = sumNumbers(docs.map((doc) => field.read(doc))) + return { key: field.key, label: field.label, sum, count } + }) +} + +/** + * Sums per group. `groupOf` gives the group of a document; the documents of a group are summed over their own, + * a document is in exactly one group. + */ +export function computeFieldSumsByGroup ( + docs: readonly T[], + fields: readonly SummableField[], + groupOf: (doc: T) => string +): Map { + const groups = new Map() + for (const doc of docs) { + const group = groupOf(doc) + const list = groups.get(group) + if (list === undefined) groups.set(group, [doc]) + else list.push(doc) + } + const res = new Map() + for (const [group, list] of groups) res.set(group, computeFieldSums(list, fields)) + return res +} + +/** A sum as text: at most two decimals, no trailing zeros, no `-0`. */ +export function formatSumValue (value: number): string { + if (!Number.isFinite(value)) return value > 0 ? '∞' : value < 0 ? '-∞' : '0' + // Beyond 2^53 / 100 there are no decimals left to round + const rounded = Math.abs(value) >= 1e13 ? value : Math.round(value * 100) / 100 + const text = String(Object.is(rounded, -0) ? 0 : rounded) + // Very large or small numbers print in exponent form; show them in full + return /e/i.test(text) ? rounded.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 2 }) : text +} + +/** + * The sums as one line, e.g. `Estimation: 12 · Story points: 5`. Nothing for no fields, so that a view without + * sums shows nothing. + */ +export function formatFieldSums (results: readonly FieldSumResult[], withLabels: boolean = true): string | undefined { + if (results.length === 0) return undefined + return results.map((r) => (withLabels ? `${r.label}: ${formatSumValue(r.sum)}` : formatSumValue(r.sum))).join(' · ') +} diff --git a/plugins/tracker-resources/src/grouping/__tests__/levels.test.ts b/plugins/tracker-resources/src/grouping/__tests__/levels.test.ts new file mode 100644 index 0000000000..9f4ceabf1d --- /dev/null +++ b/plugins/tracker-resources/src/grouping/__tests__/levels.test.ts @@ -0,0 +1,58 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + collapsedGroupsStorageKey, + groupStateScope, + MAX_BOARD_GROUP_LEVELS, + MAX_TABLE_GROUP_LEVELS, + resolveGroupLevels +} from '../levels' + +describe('resolveGroupLevels', () => { + const max = MAX_TABLE_GROUP_LEVELS + + it('gives the single level of a stored single group-by as it always was', () => { + expect(resolveGroupLevels(['status'], { max })).toEqual(['status']) + expect(resolveGroupLevels(['#no_category'], { max })).toEqual([]) + expect(resolveGroupLevels(undefined, { max })).toEqual([]) + expect(resolveGroupLevels([], { max })).toEqual([]) + }) + + it('keeps the levels in order and caps them', () => { + expect(resolveGroupLevels(['status', 'assignee'], { max })).toEqual(['status', 'assignee']) + expect(resolveGroupLevels(['a', 'b', 'c', 'd'], { max })).toEqual(['a', 'b', 'c']) + expect(resolveGroupLevels(['a', 'b', 'c'], { max: MAX_BOARD_GROUP_LEVELS })).toEqual(['a', 'b']) + }) + + it('ends at "No grouping"', () => { + expect(resolveGroupLevels(['status', '#no_category', 'assignee'], { max })).toEqual(['status']) + expect(resolveGroupLevels(['#no_category', 'status'], { max })).toEqual([]) + }) + + it('skips a repeated key, an excluded key and an unavailable key', () => { + expect(resolveGroupLevels(['status', 'status', 'priority'], { max })).toEqual(['status', 'priority']) + expect(resolveGroupLevels(['status', 'assignee', 'priority'], { max, exclude: ['status'] })).toEqual([ + 'assignee', + 'priority' + ]) + const isAvailable = (key: string): boolean => key !== 'customFields.gone' + expect(resolveGroupLevels(['customFields.gone', 'priority'], { max, isAvailable })).toEqual(['priority']) + }) + + it('ignores damaged entries', () => { + expect(resolveGroupLevels(['status', 5 as unknown as string, 'priority'], { max })).toEqual(['status']) + }) +}) + +describe('group state keys', () => { + it('identify the view of a project', () => { + expect(groupStateScope('p1', 'v1')).toBe('p1.v1') + expect(groupStateScope(undefined, 'v1')).toBe('all.v1') + expect(groupStateScope('p1', undefined)).toBeUndefined() + expect(collapsedGroupsStorageKey('roadmap', 'p1.v1')).not.toBe(collapsedGroupsStorageKey('board', 'p1.v1')) + expect(collapsedGroupsStorageKey('board', 'p1.v1')).not.toBe(collapsedGroupsStorageKey('board', 'p1.v2')) + }) +}) diff --git a/plugins/tracker-resources/src/grouping/__tests__/nested.test.ts b/plugins/tracker-resources/src/grouping/__tests__/nested.test.ts new file mode 100644 index 0000000000..ef1dac0d53 --- /dev/null +++ b/plugins/tracker-resources/src/grouping/__tests__/nested.test.ts @@ -0,0 +1,284 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + allGroups, + buildNestedGroups, + flattenNestedGroups, + groupPathId, + summarizeGroups, + toggleGroupCollapsed, + visibleNestedItems, + type GroupLevel +} from '../nested' + +interface Item { + id: string + team?: string + stage?: string + size?: number +} +const item = (id: string, team?: string, stage?: string, size?: number): Item => ({ id, team, stage, size }) + +const items: Item[] = [ + item('1', 'b', 'todo', 3), + item('2', 'a', 'doing', 5), + item('3', 'a', 'todo', 1), + item('4', undefined, 'todo', 2), + item('5', 'b', undefined, 4), + item('6', 'a', 'todo', 8) +] + +const byTeam: GroupLevel = { valueOf: (i) => i.team } +const byStage: GroupLevel = { valueOf: (i) => i.stage } +const ids = (list: Item[]): string[] => list.map((i) => i.id) + +describe('buildNestedGroups', () => { + it('has no groups without levels', () => { + expect(buildNestedGroups(items, [])).toEqual([]) + }) + + it('nests the groups level by level and keeps the order of the items', () => { + const groups = buildNestedGroups(items, [byTeam, byStage]) + // first seen order, "No team" last + expect(groups.map((g) => g.value)).toEqual(['b', 'a', undefined]) + const a = groups[1] + expect(ids(a.items)).toEqual(['2', '3', '6']) + expect(a.children.map((g) => g.value)).toEqual(['doing', 'todo']) + expect(ids(a.children[1].items)).toEqual(['3', '6']) + expect(a.children[1].level).toBe(1) + expect(a.children[1].parentId).toBe(a.id) + expect(a.children[1].children).toEqual([]) + }) + + it('puts the group without a value last at every level', () => { + const groups = buildNestedGroups(items, [byTeam, byStage]) + expect(groups[groups.length - 1].value).toBeUndefined() + const b = groups[0] + expect(b.children.map((g) => g.value)).toEqual(['todo', undefined]) + }) + + it('treats null and empty values as no value', () => { + const groups = buildNestedGroups( + [item('1', ''), item('2'), item('3', 'a')], + [{ valueOf: (i) => (i.id === '2' ? null : i.team) }] + ) + expect(groups.map((g) => [g.value, ids(g.items)])).toEqual([ + ['a', ['3']], + [undefined, ['1', '2']] + ]) + }) + + it('orders by the given order, then by the comparator, then by first seen', () => { + const level: GroupLevel = { + valueOf: (i) => i.team, + order: ['b', 'a'], + compare: (x, y) => x.localeCompare(y) + } + const more = [...items, item('7', 'z'), item('8', 'c')] + expect(buildNestedGroups(more, [level]).map((g) => g.value)).toEqual(['b', 'a', 'c', 'z', undefined]) + // without a comparator the unlisted values follow in the order they are met + const noCompare = { ...level, compare: undefined } + expect(buildNestedGroups(more, [noCompare]).map((g) => g.value)).toEqual(['b', 'a', 'z', 'c', undefined]) + }) + + it('orders alphabetically by label through the comparator', () => { + const labels: Record = { a: 'Zed', b: 'Alpha' } + const level: GroupLevel = { + valueOf: (i) => i.team, + compare: (x, y) => labels[x].localeCompare(labels[y]) + } + expect(buildNestedGroups(items, [level]).map((g) => g.value)).toEqual(['b', 'a', undefined]) + }) + + it('orders by iteration date when the order lists iterations by their start', () => { + const iterationOf: Record = { 1: 'i3', 2: 'i1', 3: 'i2', 4: 'i1', 5: undefined, 6: 'i3' } + const level: GroupLevel = { valueOf: (i) => iterationOf[i.id], order: ['i1', 'i2', 'i3'] } + expect(buildNestedGroups(items, [level]).map((g) => [g.value, g.items.length])).toEqual([ + ['i1', 2], + ['i2', 1], + ['i3', 2], + [undefined, 1] + ]) + }) + + it('lists the empty values of the order only when asked to', () => { + const level: GroupLevel = { valueOf: (i) => i.team, order: ['a', 'x', 'b', undefined] } + expect(buildNestedGroups(items, [level]).map((g) => g.value)).toEqual(['a', 'b', undefined]) + const all = buildNestedGroups(items, [{ ...level, includeEmpty: true }]) + expect(all.map((g) => [g.value, g.items.length])).toEqual([ + ['a', 3], + ['x', 0], + ['b', 2], + [undefined, 1] + ]) + }) + + it('lists "No value" for an empty level only when the order mentions it', () => { + const level: GroupLevel = { valueOf: (i) => i.team, order: ['a'], includeEmpty: true } + expect(buildNestedGroups([item('1', 'a')], [level]).map((g) => g.value)).toEqual(['a']) + const withNone: GroupLevel = { ...level, order: ['a', undefined] } + expect(buildNestedGroups([item('1', 'a')], [withNone]).map((g) => g.value)).toEqual(['a', undefined]) + }) + + it('shows empty groups of the first level only when the deeper levels do not ask for it', () => { + const first: GroupLevel = { valueOf: (i) => i.team, order: ['a', 'x', 'b'], includeEmpty: true } + const second: GroupLevel = { valueOf: (i) => i.stage, order: ['todo', 'doing', 'done'] } + const groups = buildNestedGroups(items, [first, second]) + expect(groups.map((g) => g.value)).toEqual(['a', 'x', 'b', undefined]) + expect(groups[1].items).toEqual([]) + expect(groups[1].children).toEqual([]) + // no empty "done" under a + expect(groups[0].children.map((g) => g.value)).toEqual(['todo', 'doing']) + }) + + it('lists the empty values of a deeper level under every parent when that level asks for it', () => { + const second: GroupLevel = { valueOf: (i) => i.stage, order: ['todo', 'done'], includeEmpty: true } + const groups = buildNestedGroups(items, [byTeam, second]) + // the first group is "b": one item in todo, none in done, one without a stage + expect(groups[0].value).toBe('b') + expect(groups[0].children.map((g) => [g.value, g.items.length])).toEqual([ + ['todo', 1], + ['done', 0], + [undefined, 1] + ]) + }) + + it('ignores a repeated value in the order', () => { + const level: GroupLevel = { valueOf: (i) => i.team, order: ['a', 'a', 'b'] } + expect(buildNestedGroups(items, [level]).map((g) => g.value)).toEqual(['a', 'b', undefined]) + }) + + it('counts every level', () => { + const groups = buildNestedGroups(items, [byTeam, byStage]) + const counts = allGroups(groups).map((g) => [g.id, g.items.length]) + expect(counts).toContainEqual([groupPathId(undefined, 'a'), 3]) + expect(counts).toContainEqual([groupPathId(groupPathId(undefined, 'a'), 'todo'), 2]) + // the counts of the children add up to the parent + for (const g of allGroups(groups)) { + if (g.children.length > 0) expect(g.children.reduce((n, c) => n + c.items.length, 0)).toBe(g.items.length) + } + }) + + it('supports three levels', () => { + const size: GroupLevel = { valueOf: (i) => (i.size === undefined ? undefined : i.size > 3 ? 'big' : 'small') } + const groups = buildNestedGroups(items, [byTeam, byStage, size]) + const leaf = groups[1].children[1].children + expect(leaf.map((g) => [g.value, ids(g.items), g.level])).toEqual([ + ['small', ['3'], 2], + ['big', ['6'], 2] + ]) + expect(leaf[0].children).toEqual([]) + }) +}) + +describe('group ids', () => { + it('are unique paths that survive other groups coming and going', () => { + const full = buildNestedGroups(items, [byTeam, byStage]) + const without = buildNestedGroups(items.filter((i) => i.team !== 'b'), [byTeam, byStage]) + const idsOf = (g: ReturnType>): string[] => allGroups(g).map((x) => x.id) + expect(new Set(idsOf(full)).size).toBe(idsOf(full).length) + for (const id of idsOf(without)) expect(idsOf(full)).toContain(id) + }) + + it('tell "No value" from a value, and the same value under two parents', () => { + expect(groupPathId(undefined, undefined)).not.toBe(groupPathId(undefined, 'undefined')) + const groups = buildNestedGroups(items, [byTeam, byStage]) + const todoIds = allGroups(groups).filter((g) => g.value === 'todo').map((g) => g.id) + expect(new Set(todoIds).size).toBe(todoIds.length) + expect(todoIds.length).toBeGreaterThan(1) + }) + + it('escape the separator of values', () => { + expect(groupPathId('/a', 'x/y')).toBe('/a/x%2Fy') + }) +}) + +describe('flattenNestedGroups', () => { + const groups = buildNestedGroups(items, [byTeam, byStage]) + + it('draws headers and, at the last level, the items', () => { + const rows = flattenNestedGroups(groups, new Set()) + const first = rows.slice(0, 4).map((r) => (r.type === 'group' ? `g${r.depth}:${r.group.value}` : `i${r.depth}:${r.item.id}`)) + expect(first).toEqual(['g0:b', 'g1:todo', 'i2:1', 'g1:undefined']) + expect(rows.filter((r) => r.type === 'item')).toHaveLength(items.length) + }) + + it('hides what is below a collapsed group but not the group', () => { + const a = groups[1] + const rows = flattenNestedGroups(groups, new Set([a.id])) + expect(rows.some((r) => r.type === 'group' && r.group.id === a.id && r.collapsed)).toBe(true) + expect(rows.some((r) => r.type === 'item' && ['2', '3', '6'].includes(r.item.id))).toBe(false) + // a collapsed sub group keeps its siblings + const todo = a.children[1] + const some = flattenNestedGroups(groups, new Set([todo.id])) + expect(some.some((r) => r.type === 'item' && r.item.id === '2')).toBe(true) + expect(some.some((r) => r.type === 'item' && r.item.id === '3')).toBe(false) + }) + + it('lists the visible items in the order they are drawn', () => { + expect(ids(visibleNestedItems(groups, new Set()))).toEqual( + flattenNestedGroups(groups, new Set()).flatMap((r) => (r.type === 'item' ? [r.item.id] : [])) + ) + expect(ids(visibleNestedItems(groups, new Set([groups[0].id])))).not.toContain('1') + }) +}) + +describe('summarizeGroups', () => { + it('summarizes every level and skips groups without items', () => { + const level: GroupLevel = { valueOf: (i) => i.team, order: ['a', 'x'], includeEmpty: true } + const groups = buildNestedGroups(items, [level, byStage]) + const sums = summarizeGroups(groups, (list) => list.reduce((n, i) => n + (i.size ?? 0), 0)) + expect(sums.get(groupPathId(undefined, 'a'))).toBe(14) + expect(sums.get(groupPathId(groupPathId(undefined, 'a'), 'todo'))).toBe(9) + expect(sums.has(groupPathId(undefined, 'x'))).toBe(false) + }) + + it('leaves a group out when there is nothing to show', () => { + const groups = buildNestedGroups(items, [byTeam]) + expect(summarizeGroups(groups, () => undefined).size).toBe(0) + }) +}) + +describe('toggleGroupCollapsed', () => { + it('collapses and expands without changing the given set', () => { + const start: ReadonlySet = new Set(['x']) + const next = toggleGroupCollapsed(start, 'y') + expect([...next]).toEqual(['x', 'y']) + expect([...start]).toEqual(['x']) + expect([...toggleGroupCollapsed(next, 'x')]).toEqual(['y']) + }) +}) + +describe('large lists', () => { + it('groups 50k items on three levels quickly', () => { + const teams = ['t0', 't1', 't2', 't3', 't4', 't5', 't6', 't7'] + const stages = ['s0', 's1', 's2', 's3', 's4'] + const kinds = ['k0', 'k1', 'k2'] + const big: Item[] = [] + for (let n = 0; n < 50000; n++) { + big.push({ + id: String(n), + team: n % 11 === 0 ? undefined : teams[n % teams.length], + stage: stages[n % stages.length], + size: n % 7 + }) + } + const levels: Array> = [ + { valueOf: (i) => i.team, order: teams }, + { valueOf: (i) => i.stage, order: stages }, + { valueOf: (i) => kinds[Number(i.id) % kinds.length] } + ] + const started = Date.now() + const groups = buildNestedGroups(big, levels) + const rows = flattenNestedGroups(groups, new Set()) + const sums = summarizeGroups(groups, (list) => list.length) + const took = Date.now() - started + expect(groups.reduce((n, g) => n + g.items.length, 0)).toBe(50000) + expect(rows.filter((r) => r.type === 'item')).toHaveLength(50000) + expect(sums.size).toBe(allGroups(groups).length) + expect(took).toBeLessThan(3000) + }) +}) diff --git a/plugins/tracker-resources/src/grouping/levels.ts b/plugins/tracker-resources/src/grouping/levels.ts new file mode 100644 index 0000000000..a32323527f --- /dev/null +++ b/plugins/tracker-resources/src/grouping/levels.ts @@ -0,0 +1,55 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** The "No grouping" value of the group-by view option (it is `noCategory` of the view plugin). */ +export const NO_GROUPING = '#no_category' + +/** Levels of grouping a layout offers: the Table nests up to three, the Board and the Roadmap two. */ +export const MAX_TABLE_GROUP_LEVELS = 3 +export const MAX_BOARD_GROUP_LEVELS = 2 +export const MAX_ROADMAP_GROUP_LEVELS = 2 + +export interface GroupLevelsOptions { + // The most levels of the layout + max: number + // Keys that are not a level here (the board does not group by the field of its columns) + exclude?: readonly string[] + // Keys that can be grouped by in this project; a key that is not available (a deleted custom field) is skipped + isAvailable?: (key: string) => boolean +} + +/** + * The levels of grouping a layout draws, from the `groupBy` of the view options. "No grouping" ends the list (the + * view options keep it only as the single entry), a key that repeats an earlier level, an excluded or an + * unavailable key is skipped, and at most `max` levels are used. A stored single `groupBy` gives the same single + * level as it always did. + */ +export function resolveGroupLevels (groupBy: readonly string[] | undefined, options: GroupLevelsOptions): string[] { + const res: string[] = [] + for (const key of groupBy ?? []) { + if (typeof key !== 'string' || key === '' || key === NO_GROUPING) break + if (res.includes(key) || options.exclude?.includes(key) === true) continue + if (options.isAvailable !== undefined && !options.isAvailable(key)) continue + res.push(key) + if (res.length >= options.max) break + } + return res +} + +/** Layouts that keep state of their groups per view. */ +export type GroupLayout = 'table' | 'roadmap' | 'board' + +/** + * Identity of a view for the state its groups keep (collapsed groups): the project and the saved view. The table + * adds the group path to it, the roadmap and the board store a set of collapsed group ids under it. + */ +export function groupStateScope (project: string | undefined, viewId: string | undefined): string | undefined { + return viewId === undefined || viewId === '' ? undefined : `${project ?? 'all'}.${viewId}` +} + +/** Local storage key of the collapsed groups of one view in one layout. */ +export function collapsedGroupsStorageKey (layout: GroupLayout, scope: string): string { + return `tracker.groups.collapsed.${layout}.${scope}` +} diff --git a/plugins/tracker-resources/src/grouping/nested.ts b/plugins/tracker-resources/src/grouping/nested.ts new file mode 100644 index 0000000000..10dd2552ff --- /dev/null +++ b/plugins/tracker-resources/src/grouping/nested.ts @@ -0,0 +1,207 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** + * Pure logic of nested (multi-level) grouping: the items are split by the value of a first field, every group by a + * second field and so on. It is used by the layouts that draw their own groups (the roadmap rows and the board + * swimlanes); the table uses the generic nested list of the view plugin. + * + * A "value" is what an item has in a field: an id, an option value, a number turned into a string... or `undefined` + * for an item without a value ("No "). The group without a value is always the last one of its level. + */ + +/** How the items are split at one level. */ +export interface GroupLevel { + // Value of an item at this level; `undefined`, `null` and an empty string mean "no value" + valueOf: (item: T) => string | undefined | null + // Known values in their display order: the options of a field, the iterations in calendar order. Values that are + // not listed follow, in the order of `compare` (or in the order they are met without it). + order?: ReadonlyArray + // Orders the values that `order` does not list, e.g. alphabetically by their label + compare?: (a: string, b: string) => number + // Also list the values of `order` that have no items + includeEmpty?: boolean +} + +export interface NestedGroup { + // Unique and stable path of the group (see `groupPathId`): it does not change when other groups come or go, + // so it can key the collapsed state of the group + id: string + // 0 for the first level + level: number + // Value of the group at its own level; `undefined` for "No " + value: string | undefined + parentId: string | undefined + // Every item below the group, in the order of the input + items: T[] + // Groups of the next level; empty at the last level + children: Array> +} + +/** Row of the flat, drawn form of the groups. */ +export type NestedRow = + | { type: 'group', group: NestedGroup, depth: number, collapsed: boolean } + | { type: 'item', item: T, depth: number, group: NestedGroup } + +const NO_VALUE = '' + +/** Path id of a group: the id of its parent and its own value (empty for "No "). */ +export function groupPathId (parentId: string | undefined, value: string | undefined): string { + return `${parentId ?? ''}/${value === undefined ? NO_VALUE : encodeURIComponent(value)}` +} + +function normalize (raw: string | undefined | null): string | undefined { + return raw === undefined || raw === null || raw === '' ? undefined : String(raw) +} + +interface Bucket { + value: string | undefined + items: T[] +} + +// The buckets of one level in display order +function bucketLevel (items: readonly T[], level: GroupLevel): Array> { + const buckets = new Map() + for (const item of items) { + const value = normalize(level.valueOf(item)) + const bucket = buckets.get(value) + if (bucket === undefined) buckets.set(value, [item]) + else bucket.push(item) + } + + const res: Array> = [] + const emit = (value: string | undefined, includeIfEmpty: boolean): void => { + const bucket = buckets.get(value) + if (bucket === undefined && !includeIfEmpty) return + res.push({ value, items: bucket ?? [] }) + buckets.delete(value) + } + + const listed = new Set() + for (const value of level.order ?? []) { + if (value === undefined || listed.has(value)) continue + listed.add(value) + emit(value, level.includeEmpty === true) + } + const rest = [...buckets.keys()].filter((v): v is string => v !== undefined) + if (level.compare !== undefined) rest.sort(level.compare) + for (const value of rest) emit(value, false) + // "No " is last; with `includeEmpty` it is listed when the order mentions it + const noValueListed = (level.order ?? []).includes(undefined) + emit(undefined, level.includeEmpty === true && noValueListed) + return res +} + +function build ( + items: readonly T[], + levels: ReadonlyArray>, + depth: number, + parentId: string | undefined +): Array> { + const level = levels[depth] + return bucketLevel(items, level).map(({ value, items: own }) => { + const id = groupPathId(parentId, value) + return { + id, + level: depth, + value, + parentId, + items: own, + children: depth + 1 < levels.length ? build(own, levels, depth + 1, id) : [] + } + }) +} + +/** + * Splits the items into nested groups, one level per entry of `levels`. Items keep their relative order inside a + * group, so a sort applied before grouping is preserved. Without levels there are no groups. + * + * `includeEmpty` of a level lists the values of its `order` without items under every group of the level above, so + * a caller that wants "show empty groups" for the first level only sets it there only. + */ +export function buildNestedGroups ( + items: readonly T[], + levels: ReadonlyArray> +): Array> { + if (levels.length === 0) return [] + return build(items, levels, 0, undefined) +} + +/** Every group of the tree, parents before their children. */ +export function allGroups (groups: ReadonlyArray>): Array> { + const res: Array> = [] + const visit = (list: ReadonlyArray>): void => { + for (const group of list) { + res.push(group) + visit(group.children) + } + } + visit(groups) + return res +} + +/** + * The groups drawn top to bottom: a group header, then (unless the group is collapsed) its sub groups, or at the + * last level its items. A collapsed group hides everything below it, not itself. + */ +export function flattenNestedGroups ( + groups: ReadonlyArray>, + collapsed: ReadonlySet +): Array> { + const rows: Array> = [] + const visit = (list: ReadonlyArray>, depth: number): void => { + for (const group of list) { + const isCollapsed = collapsed.has(group.id) + rows.push({ type: 'group', group, depth, collapsed: isCollapsed }) + if (isCollapsed) continue + if (group.children.length > 0) { + visit(group.children, depth + 1) + } else { + for (const item of group.items) rows.push({ type: 'item', item, depth: depth + 1, group }) + } + } + } + visit(groups, 0) + return rows +} + +/** Items in the order they are drawn, without those of collapsed groups; the order a keyboard moves through them. */ +export function visibleNestedItems (groups: ReadonlyArray>, collapsed: ReadonlySet): T[] { + const res: T[] = [] + const visit = (list: ReadonlyArray>): void => { + for (const group of list) { + if (collapsed.has(group.id)) continue + if (group.children.length > 0) visit(group.children) + else res.push(...group.items) + } + } + visit(groups) + return res +} + +/** + * A value computed from the items of every group, at every level (e.g. the sum of a field). The map is keyed by + * the group id; a group without items is not in it. + */ +export function summarizeGroups ( + groups: ReadonlyArray>, + summarize: (items: readonly T[]) => R | undefined +): Map { + const res = new Map() + for (const group of allGroups(groups)) { + if (group.items.length === 0) continue + const summary = summarize(group.items) + if (summary !== undefined) res.set(group.id, summary) + } + return res +} + +/** The collapsed groups after one group was collapsed or expanded; the given set is not changed. */ +export function toggleGroupCollapsed (collapsed: ReadonlySet, id: string): Set { + const next = new Set(collapsed) + if (next.has(id)) next.delete(id) + else next.add(id) + return next +} diff --git a/plugins/tracker-resources/src/hierarchy/__tests__/hierarchy.test.ts b/plugins/tracker-resources/src/hierarchy/__tests__/hierarchy.test.ts new file mode 100644 index 0000000000..d94e6f8cff --- /dev/null +++ b/plugins/tracker-resources/src/hierarchy/__tests__/hierarchy.test.ts @@ -0,0 +1,117 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + HIERARCHY_OPTION_KEY, + isHierarchyEnabled, + isHierarchySwitchedOn, + newViewHierarchyOptions, + withHierarchy +} from '../config' +import { + expansionStorageKey, + MAX_EXPANDED_IDS, + parseExpanded, + serializeExpanded, + toggleExpanded +} from '../expansion' +import { buildProgressIndex, formatProgress, progressOf, progressRatio } from '../progress' + +describe('hierarchy switch', () => { + it('is off for views that never stored it (legacy and unsaved views)', () => { + expect(isHierarchyEnabled(undefined)).toBe(false) + expect(isHierarchyEnabled({ groupBy: ['status'] })).toBe(false) + expect(isHierarchyEnabled({ [HIERARCHY_OPTION_KEY]: false })).toBe(false) + expect(isHierarchyEnabled({ [HIERARCHY_OPTION_KEY]: 'yes' })).toBe(false) + }) + + it('is on when stored, unless the list hides its sub-issues', () => { + expect(isHierarchyEnabled({ [HIERARCHY_OPTION_KEY]: true })).toBe(true) + expect(isHierarchyEnabled({ [HIERARCHY_OPTION_KEY]: true, shouldShowSubIssues: true })).toBe(true) + expect(isHierarchyEnabled({ [HIERARCHY_OPTION_KEY]: true, shouldShowSubIssues: false })).toBe(false) + expect(isHierarchySwitchedOn({ [HIERARCHY_OPTION_KEY]: true, shouldShowSubIssues: false })).toBe(true) + }) + + it('does not store the off state, so that on and off again leaves the view clean', () => { + const base = { groupBy: ['status'], orderBy: ['rank', 1] } + const on = withHierarchy>(base, true) + expect(on[HIERARCHY_OPTION_KEY]).toBe(true) + expect(withHierarchy(on, false)).toEqual(base) + expect(base).toEqual({ groupBy: ['status'], orderBy: ['rank', 1] }) + }) + + it('starts new table views with the hierarchy and nothing else', () => { + expect(newViewHierarchyOptions(true)).toEqual({ [HIERARCHY_OPTION_KEY]: true }) + expect(newViewHierarchyOptions(false)).toBeUndefined() + }) +}) + +describe('expansion state', () => { + it('is kept per project and view', () => { + expect(expansionStorageKey('p1', 'v1')).not.toBe(expansionStorageKey('p1', 'v2')) + expect(expansionStorageKey('p1', 'v1')).not.toBe(expansionStorageKey('p2', 'v1')) + }) + + it('round-trips', () => { + const ids = new Set(['a', 'b']) + expect(parseExpanded(serializeExpanded(ids))).toEqual(ids) + }) + + it('survives damaged storage', () => { + for (const raw of [null, undefined, '', '{', 'null', '{"a":1}', '"x"', '5']) { + expect(parseExpanded(raw)).toEqual(new Set()) + } + expect(parseExpanded('["a",1,null,"","b"]')).toEqual(new Set(['a', 'b'])) + }) + + it('toggles without changing the given set', () => { + const start = new Set(['a']) + const added = toggleExpanded(start, 'b') + expect([...added]).toEqual(['a', 'b']) + expect([...start]).toEqual(['a']) + expect([...toggleExpanded(added, 'a')]).toEqual(['b']) + }) + + it('forgets the oldest ids above the cap', () => { + const ids = new Set(['a', 'b', 'c']) + expect([...toggleExpanded(ids, 'd', 3)]).toEqual(['b', 'c', 'd']) + const many = new Set(Array.from({ length: MAX_EXPANDED_IDS + 50 }, (_, i) => `i${i}`)) + expect(parseExpanded(serializeExpanded(many)).size).toBe(MAX_EXPANDED_IDS) + expect(parseExpanded(serializeExpanded(many)).has('i49')).toBe(false) + expect(parseExpanded(serializeExpanded(many)).has(`i${MAX_EXPANDED_IDS + 49}`)).toBe(true) + }) +}) + +describe('sub-issue progress', () => { + const closed = new Set(['done', 'canceled']) + const NO_PARENT = 'tracker:ids:NoParent' + const subs = [ + { attachedTo: 'p1', status: 'done' }, + { attachedTo: 'p1', status: 'todo' }, + { attachedTo: 'p1', status: 'canceled' }, + { attachedTo: 'p2', status: 'todo' }, + { attachedTo: NO_PARENT, status: 'done' } + ] + + it('counts the done and all sub-issues of every parent', () => { + const index = buildProgressIndex(subs, closed, NO_PARENT) + expect(progressOf(index, 'p1')).toEqual({ done: 2, total: 3 }) + expect(progressOf(index, 'p2')).toEqual({ done: 0, total: 1 }) + expect(index.size).toBe(2) + }) + + it('has no progress for an issue without sub-issues or for no data', () => { + expect(progressOf(buildProgressIndex(subs, closed, NO_PARENT), 'p3')).toBeUndefined() + expect(buildProgressIndex([], closed, NO_PARENT).size).toBe(0) + }) + + it('formats and measures the progress', () => { + expect(formatProgress({ done: 2, total: 5 })).toBe('2/5') + expect(progressRatio({ done: 2, total: 5 })).toBeCloseTo(0.4) + expect(progressRatio({ done: 0, total: 0 })).toBe(0) + expect(progressRatio(undefined)).toBe(0) + expect(progressRatio({ done: 7, total: 5 })).toBe(1) + }) +}) diff --git a/plugins/tracker-resources/src/hierarchy/__tests__/viewState.test.ts b/plugins/tracker-resources/src/hierarchy/__tests__/viewState.test.ts new file mode 100644 index 0000000000..85568558de --- /dev/null +++ b/plugins/tracker-resources/src/hierarchy/__tests__/viewState.test.ts @@ -0,0 +1,62 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import * as path from 'path' +import { withFieldSums } from '../../fieldSum/config' +import { withSliceConfig } from '../../slice/config' +import { withHierarchy } from '../config' + +// The saved views logic is a pure module of view-resources. The path is built at run time so that the type +// checkers do not pull view-resources sources into this package. +const { isViewDirty } = require(path.resolve(__dirname, '../../../../view-resources/src/savedViews')) as { + isViewDirty: (baseline: Record, current: Record) => boolean +} + +type Options = Record + +// Hierarchy, slice and field sums are stored in the view options, so the saved view saves them and the unsaved +// changes dot follows them without any change in the saved views code. +describe('unsaved changes of the Phase 8 settings', () => { + const legacy: Options = { groupBy: ['status'], orderBy: ['modifiedOn', -1], shouldShowSubIssues: true } + + it('a view saved before the settings existed is clean and has the hierarchy off', () => { + expect(isViewDirty({ viewOptions: legacy }, { viewOptions: { ...legacy } })).toBe(false) + }) + + it('turning the hierarchy on makes the view dirty, turning it off again makes it clean', () => { + const on = withHierarchy(legacy, true) + expect(isViewDirty({ viewOptions: legacy }, { viewOptions: on })).toBe(true) + expect(isViewDirty({ viewOptions: legacy }, { viewOptions: withHierarchy(on, false) })).toBe(false) + }) + + it('a new table view starts with the hierarchy and is clean until changed', () => { + const fresh = withHierarchy(legacy, true) + expect(isViewDirty({ viewOptions: fresh }, { viewOptions: { ...fresh } })).toBe(false) + expect(isViewDirty({ viewOptions: fresh }, { viewOptions: withHierarchy(fresh, false) })).toBe(true) + }) + + it('opening the slice panel, choosing a value and closing it', () => { + const open = withSliceConfig(legacy, { field: 'status', value: [] }) + expect(isViewDirty({ viewOptions: legacy }, { viewOptions: open })).toBe(true) + const chosen = withSliceConfig(open, { field: 'status', value: ['todo'] }) + expect(isViewDirty({ viewOptions: open }, { viewOptions: chosen })).toBe(true) + expect(isViewDirty({ viewOptions: legacy }, { viewOptions: withSliceConfig(chosen, undefined) })).toBe(false) + }) + + it('choosing and clearing the fields to sum', () => { + const one = withFieldSums(legacy, ['estimation']) + expect(isViewDirty({ viewOptions: legacy }, { viewOptions: one })).toBe(true) + expect(isViewDirty({ viewOptions: one }, { viewOptions: withFieldSums(one, ['estimation', 'customFields.points']) })).toBe(true) + expect(isViewDirty({ viewOptions: legacy }, { viewOptions: withFieldSums(one, []) })).toBe(false) + }) + + it('the settings are independent of one another', () => { + const all = withFieldSums(withSliceConfig(withHierarchy(legacy, true), { field: 'status', value: [] }), ['estimation']) + const noSlice = withSliceConfig(all, undefined) + expect(isViewDirty({ viewOptions: all }, { viewOptions: noSlice })).toBe(true) + expect(noSlice.hierarchy).toBe(true) + expect(noSlice.fieldSums).toEqual(['estimation']) + }) +}) diff --git a/plugins/tracker-resources/src/hierarchy/config.ts b/plugins/tracker-resources/src/hierarchy/config.ts new file mode 100644 index 0000000000..ea20a3514e --- /dev/null +++ b/plugins/tracker-resources/src/hierarchy/config.ts @@ -0,0 +1,45 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** + * Sub-issue hierarchy of the table ("Show hierarchy" of GitHub Projects). The switch is stored in the view options + * under `HIERARCHY_OPTION_KEY`, so that it is saved with the saved view, restored when it is opened and takes part + * in its unsaved-changes tracking. It is absent (off) for a view that was saved before the feature existed and for + * the unsaved default view; a new table view starts with it on. + */ +export const HIERARCHY_OPTION_KEY = 'hierarchy' + +/** Key of the view option "Sub-issues"; a list that hides the sub-issues has nothing to nest. */ +export const SUB_ISSUES_OPTION_KEY = 'shouldShowSubIssues' + +/** Whether the tree is shown for the view options. */ +export function isHierarchyEnabled (viewOptions: Record | undefined): boolean { + return viewOptions?.[HIERARCHY_OPTION_KEY] === true && viewOptions[SUB_ISSUES_OPTION_KEY] !== false +} + +/** Whether the view option itself is on, whatever the "Sub-issues" option says. */ +export function isHierarchySwitchedOn (viewOptions: Record | undefined): boolean { + return viewOptions?.[HIERARCHY_OPTION_KEY] === true +} + +/** + * The view options with the switch set. Off is not stored at all, so that switching it on and off again does not + * make the view look changed. + */ +export function withHierarchy> (options: O, enabled: boolean): O { + const next: Record = { ...options } + if (enabled) { + next[HIERARCHY_OPTION_KEY] = true + } else { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[HIERARCHY_OPTION_KEY] + } + return next as O +} + +/** What a new view of a layout starts with: tables show the hierarchy, the other layouts do not use it. */ +export function newViewHierarchyOptions (isTable: boolean): Record | undefined { + return isTable ? { [HIERARCHY_OPTION_KEY]: true } : undefined +} diff --git a/plugins/tracker-resources/src/hierarchy/expansion.ts b/plugins/tracker-resources/src/hierarchy/expansion.ts new file mode 100644 index 0000000000..1d8cbe5039 --- /dev/null +++ b/plugins/tracker-resources/src/hierarchy/expansion.ts @@ -0,0 +1,51 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Which parent rows of the hierarchy a viewer has expanded. The state is kept per viewer and per view (in local +// storage), so that the tree stays the way it was left when the view is opened again. Pure helpers; the store +// that uses them is in expansionStore.ts. + +/** The most ids that are remembered per view; the oldest are forgotten first. */ +export const MAX_EXPANDED_IDS = 2000 + +/** Local storage key of the expanded rows of one view. */ +export function expansionStorageKey (project: string, viewId: string): string { + return `tracker.hierarchy.expanded.${project}.${viewId}` +} + +/** The ids stored in a raw value; anything damaged yields an empty set instead of breaking the view. */ +export function parseExpanded (raw: string | null | undefined): Set { + if (raw === null || raw === undefined || raw === '') return new Set() + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return new Set() + const ids = parsed.filter((it): it is string => typeof it === 'string' && it !== '') + return new Set(ids.slice(-MAX_EXPANDED_IDS)) + } catch { + return new Set() + } +} + +export function serializeExpanded (ids: ReadonlySet): string { + return JSON.stringify([...ids]) +} + +/** + * The set with the row expanded or collapsed. A new set is returned, the given one is not changed. An expanded + * row is the newest of the set; above `MAX_EXPANDED_IDS` the oldest are dropped. + */ +export function toggleExpanded (ids: ReadonlySet, id: string, cap: number = MAX_EXPANDED_IDS): Set { + const next = new Set(ids) + if (next.has(id)) { + next.delete(id) + return next + } + next.add(id) + while (next.size > cap) { + const oldest = next.values().next().value as string + next.delete(oldest) + } + return next +} diff --git a/plugins/tracker-resources/src/hierarchy/expansionStore.ts b/plugins/tracker-resources/src/hierarchy/expansionStore.ts new file mode 100644 index 0000000000..3368ce6f5d --- /dev/null +++ b/plugins/tracker-resources/src/hierarchy/expansionStore.ts @@ -0,0 +1,54 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { writable, type Readable } from 'svelte/store' +import { parseExpanded, serializeExpanded, toggleExpanded } from './expansion' + +export interface ExpansionStore extends Readable> { + toggle: (id: string) => void +} + +const stores = new Map() + +function load (key: string): Set { + try { + return parseExpanded(localStorage.getItem(key)) + } catch { + // Storage can be unavailable (private mode); the tree then only keeps its state for the session + return new Set() + } +} + +function save (key: string, ids: ReadonlySet): void { + try { + if (ids.size === 0) localStorage.removeItem(key) + else localStorage.setItem(key, serializeExpanded(ids)) + } catch { + // The in-memory state still works + } +} + +/** + * The expanded rows of one view of the viewer. The same store is returned for the same key, so that every list + * that shows the view agrees, and it is mirrored to local storage. + */ +export function expansionStore (storageKey: string): ExpansionStore { + let store = stores.get(storageKey) + if (store === undefined) { + const state = writable>(load(storageKey)) + store = { + subscribe: state.subscribe, + toggle: (id) => { + state.update((ids) => { + const next = toggleExpanded(ids, id) + save(storageKey, next) + return next + }) + } + } + stores.set(storageKey, store) + } + return store +} diff --git a/plugins/tracker-resources/src/hierarchy/progress.ts b/plugins/tracker-resources/src/hierarchy/progress.ts new file mode 100644 index 0000000000..b071dccfa0 --- /dev/null +++ b/plugins/tracker-resources/src/hierarchy/progress.ts @@ -0,0 +1,57 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** How many sub-issues of an issue are done (GitHub's "Sub-issues progress"). */ +export interface SubIssueProgress { + done: number + total: number +} + +/** What the progress needs to know about a sub-issue. */ +export interface SubIssueRef { + attachedTo: string + status: string +} + +/** + * Progress of every parent, over its direct sub-issues. A sub-issue is done when its status is one of + * `closedStatuses` (done or cancelled, the same rule as the sub-issues button of a row). The progress counts all + * sub-issues of the parent, whether the view shows them or not. + * `noParentId` is the value of `attachedTo` of an issue without a parent. + */ +export function buildProgressIndex ( + subIssues: readonly SubIssueRef[], + closedStatuses: ReadonlySet, + noParentId: string +): Map { + const index = new Map() + for (const sub of subIssues) { + if (sub.attachedTo === noParentId || sub.attachedTo === '' || sub.attachedTo === undefined) continue + let progress = index.get(sub.attachedTo) + if (progress === undefined) { + progress = { done: 0, total: 0 } + index.set(sub.attachedTo, progress) + } + progress.total++ + if (closedStatuses.has(sub.status)) progress.done++ + } + return index +} + +/** The progress of one parent; undefined when it has no sub-issues. */ +export function progressOf (index: ReadonlyMap, parentId: string): SubIssueProgress | undefined { + return index.get(parentId) +} + +/** Share of the sub-issues that are done, 0..1; 0 when there are none. */ +export function progressRatio (progress: SubIssueProgress | undefined): number { + if (progress === undefined || progress.total <= 0) return 0 + return Math.min(1, Math.max(0, progress.done / progress.total)) +} + +/** `done/total`, e.g. `2/5`. */ +export function formatProgress (progress: SubIssueProgress): string { + return `${progress.done}/${progress.total}` +} diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 0dc6d9d682..eff2023fba 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -53,6 +53,9 @@ import CreateIssue from './components/CreateIssue.svelte' import EditRelatedTargets from './components/EditRelatedTargets.svelte' import EditRelatedTargetsPopup from './components/EditRelatedTargetsPopup.svelte' import GanttView from './components/gantt/GanttView.svelte' +import RoadmapView from './components/roadmap/RoadmapView.svelte' +import CalendarView from './components/calendar/CalendarView.svelte' +import WorkloadView from './components/workload/WorkloadView.svelte' import AssigneeEditor from './components/issues/AssigneeEditor.svelte' import DueDatePresenter from './components/issues/DueDatePresenter.svelte' import EditIssue from './components/issues/edit/EditIssue.svelte' @@ -125,6 +128,8 @@ import IssueRelationPresenter from './components/IssueRelationPresenter.svelte' import RelationActivityPresenter from './components/activity/RelationActivityPresenter.svelte' import DependencyShiftedPresenter from './components/notifications/DependencyShiftedPresenter.svelte' import PredecessorsColumnPresenter from './components/issues/PredecessorsColumnPresenter.svelte' +import CustomFieldColumn from './projectFields/CustomFieldColumn.svelte' +import ProjectStatusPresenter from './components/projects/ProjectStatusPresenter.svelte' import ComponentSelector from './components/components/ComponentSelector.svelte' @@ -138,6 +143,8 @@ import { deleteObjects, setFilters } from '@hcengineering/view-resources' +import { archiveIssue, restoreIssue } from './archive/actions' +import { convertDraftToIssue } from './draft/actions' import MoveAndDeleteMilestonePopup from './components/milestones/MoveAndDeleteMilestonePopup.svelte' import EditIssueTemplate from './components/templates/EditIssueTemplate.svelte' import TemplateEstimationEditor from './components/templates/EstimationEditor.svelte' @@ -188,6 +195,19 @@ export { default as SubIssueList } from './components/issues/edit/SubIssueList.s export { default as IssueStatusIcon } from './components/issues/IssueStatusIcon.svelte' export { default as StatusPresenter } from './components/issues/StatusPresenter.svelte' +export { default as CustomFieldPresenter } from './projectFields/CustomFieldPresenter.svelte' +export { default as FieldValueEditor } from './projectFields/FieldValueEditor.svelte' +export { default as ProjectFieldsPopup } from './projectFields/ProjectFieldsPopup.svelte' +export { default as ArchivedItemsPopup } from './components/archive/ArchivedItemsPopup.svelte' +export { default as WorkflowsPopup } from './components/workflows/WorkflowsPopup.svelte' +export { default as WebhooksPopup } from './components/webhooks/WebhooksPopup.svelte' +export { default as ProjectSettings } from './components/projects/ProjectSettings.svelte' +export { default as ProjectStatusUpdatePopup } from './components/projects/ProjectStatusUpdatePopup.svelte' +export * from './projectDetails/copy' +export * from './projectDetails/lifecycle' +export * from './projectFields/registry' +export { projectFieldsStore, sharedProjectFieldsStore } from './projectFields/projectFieldsStore' + export { activeProjects, CreateProject, IssuePresenter, PriorityEditor, StatusEditor, TitlePresenter } export async function queryIssue ( @@ -442,6 +462,9 @@ export default async (): Promise => ({ IssuesView, KanbanView, GanttView, + RoadmapView, + CalendarView, + WorkloadView, ProjectComponents, IssuePreview, RelationsPopup, @@ -465,6 +488,8 @@ export default async (): Promise => ({ RelationActivityPresenter, DependencyShiftedPresenter, PredecessorsColumn: PredecessorsColumnPresenter, + CustomFieldColumn, + ProjectStatusPresenter, ComponentSelector, IssueTemplates, IssueTemplatePresenter, @@ -542,7 +567,10 @@ export default async (): Promise => ({ EditProject: editProject, DeleteMilestone: deleteMilestone, DeleteProject: deleteProject, - DeleteIssue: deleteIssue + DeleteIssue: deleteIssue, + ArchiveIssue: archiveIssue, + RestoreIssue: restoreIssue, + ConvertDraftToIssue: convertDraftToIssue }, resolver: { Location: resolveLocation diff --git a/plugins/tracker-resources/src/insights/__tests__/aggregate.test.ts b/plugins/tracker-resources/src/insights/__tests__/aggregate.test.ts new file mode 100644 index 0000000000..a40da902b0 --- /dev/null +++ b/plugins/tracker-resources/src/insights/__tests__/aggregate.test.ts @@ -0,0 +1,261 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + aggregateCell, + bucketDayRange, + bucketStart, + CHART_ALL, + CHART_NONE, + computeChart, + formatBucketLabel, + MAX_FILLED_DATE_BUCKETS, + nextBucketStart, + type CategoryDimension, + type DateDimension +} from '../aggregate' + +interface Doc { + status?: string + tags?: string[] + team?: string + points?: unknown + due?: number +} + +function category ( + options: Array<[string, string]>, + read: (doc: Doc) => string[], + includeEmpty: boolean = false +): CategoryDimension { + return { + kind: 'category', + options: options.map(([id, label]) => ({ id, label })), + includeEmpty, + ids: read, + noneLabel: 'No field' + } +} + +const status = category( + [ + ['todo', 'Todo'], + ['doing', 'Doing'], + ['done', 'Done'] + ], + (d) => (d.status !== undefined ? [d.status] : []) +) +const team = category( + [ + ['a', 'Team A'], + ['b', 'Team B'] + ], + (d) => (d.team !== undefined ? [d.team] : []) +) +const tags = category( + [ + ['bug', 'bug'], + ['ui', 'ui'] + ], + (d) => d.tags ?? [] +) + +const docs: Doc[] = [ + { status: 'todo', team: 'a', points: 3 }, + { status: 'todo', team: 'b', points: 5 }, + { status: 'done', team: 'a', points: 2 }, + { status: 'done', team: 'a' }, + { team: 'b', points: 'x' } +] + +describe('computeChart: buckets and series', () => { + it('counts by the options of the X field in their order, "No " last', () => { + const res = computeChart({ docs, x: status, measure: { type: 'count' } }) + expect(res.categories.map((c) => c.label)).toEqual(['Todo', 'Done', 'No field']) + expect(res.categories.map((c) => c.none)).toEqual([false, false, true]) + expect(res.series).toEqual([{ id: CHART_ALL, label: '', none: false }]) + expect(res.values).toEqual([[2, 2, 1]]) + expect(res.counts).toEqual([[2, 2, 1]]) + expect(res.items).toBe(5) + }) + + it('lists the options no item has when the field asks for it, and no empty "No "', () => { + const all = { ...status, includeEmpty: true } + const res = computeChart({ docs: docs.filter((d) => d.status !== undefined), x: all, measure: { type: 'count' } }) + expect(res.categories.map((c) => c.label)).toEqual(['Todo', 'Doing', 'Done']) + expect(res.values).toEqual([[2, 0, 2]]) + }) + + it('makes a series for every used option of the group field and a "No " series last', () => { + const res = computeChart({ docs, x: status, group: team, measure: { type: 'count' } }) + expect(res.series.map((s) => s.label)).toEqual(['Team A', 'Team B']) + expect(res.values).toEqual([ + [1, 2, 0], // Team A: todo, done, none + [1, 0, 1] + ]) + const withNone = computeChart({ + docs: [...docs, { status: 'todo' }], + x: status, + group: team, + measure: { type: 'count' } + }) + expect(withNone.series.map((s) => [s.label, s.none])).toEqual([ + ['Team A', false], + ['Team B', false], + ['No field', true] + ]) + expect(withNone.values[2]).toEqual([1, 0, 0]) + }) + + it('counts an item with several values in each of them', () => { + const res = computeChart({ + docs: [{ tags: ['bug', 'ui'] }, { tags: ['bug'] }, { tags: [] }], + x: tags, + measure: { type: 'count' } + }) + expect(res.categories.map((c) => c.label)).toEqual(['bug', 'ui', 'No field']) + expect(res.values).toEqual([[2, 1, 1]]) + expect(res.items).toBe(3) + }) + + it('crosses two multi valued fields', () => { + const res = computeChart({ + docs: [{ tags: ['bug', 'ui'], team: 'a' }], + x: tags, + group: team, + measure: { type: 'count' } + }) + expect(res.values).toEqual([[1, 1]]) + }) + + it('has nothing to draw without items', () => { + const res = computeChart({ docs: [], x: status, group: team, measure: { type: 'count' } }) + expect(res).toEqual({ categories: [], series: [], values: [], counts: [], items: 0 }) + }) +}) + +describe('computeChart: Y aggregates', () => { + const measure = (type: 'sum' | 'avg' | 'min' | 'max'): any => ({ type, read: (d: Doc) => d.points }) + + it('sums the numbers of a bucket and ignores values that are not numbers', () => { + const res = computeChart({ docs, x: status, measure: measure('sum') }) + // Todo 3 + 5, Done 2 (the item without points adds nothing), No status: "x" is not a number + expect(res.values).toEqual([[8, 2, 0]]) + // The item counts are still the items + expect(res.counts).toEqual([[2, 2, 1]]) + }) + + it('averages over the items that have a number, with no value for a bucket that has none', () => { + const res = computeChart({ docs, x: status, measure: measure('avg') }) + expect(res.values).toEqual([[4, 2, null]]) + }) + + it('takes the minimum and the maximum', () => { + expect(computeChart({ docs, x: status, measure: measure('min') }).values).toEqual([[3, 2, null]]) + expect(computeChart({ docs, x: status, measure: measure('max') }).values).toEqual([[5, 2, null]]) + }) + + it('handles negative numbers and decimals without float noise', () => { + const items: Doc[] = [{ points: -2 }, { points: 0.1 }, { points: 0.2 }] + const res = computeChart({ docs: items, x: status, measure: measure('sum') }) + expect(res.values).toEqual([[-1.7]]) + expect(computeChart({ docs: items, x: status, measure: measure('min') }).values).toEqual([[-2]]) + }) + + it('treats a cell without items as 0 for count and sum and as no value for the rest', () => { + expect(aggregateCell('count', undefined)).toBe(0) + expect(aggregateCell('sum', undefined)).toBe(0) + expect(aggregateCell('avg', undefined)).toBeNull() + expect(aggregateCell('min', undefined)).toBeNull() + expect(aggregateCell('max', { count: 2, numbers: [] })).toBeNull() + }) + + it('fills the cells of a grouped chart', () => { + const res = computeChart({ docs, x: status, group: team, measure: measure('sum') }) + expect(res.values).toEqual([ + [3, 2, 0], + [5, 0, 0] + ]) + }) +}) + +describe('date buckets', () => { + // 2026-10-05 is a Monday + const day = (y: number, m: number, d: number, h: number = 12): number => new Date(y, m - 1, d, h).getTime() + + it('buckets a timestamp by day, week (Monday) and month in local time', () => { + const ts = day(2026, 10, 8, 15) // Thursday + expect(bucketStart(ts, 'day')).toBe(day(2026, 10, 8, 0)) + expect(bucketStart(ts, 'week')).toBe(day(2026, 10, 5, 0)) + expect(bucketStart(day(2026, 10, 11, 23), 'week')).toBe(day(2026, 10, 5, 0)) // Sunday + expect(bucketStart(ts, 'month')).toBe(day(2026, 10, 1, 0)) + }) + + it('steps to the next bucket across month and year ends', () => { + expect(nextBucketStart(day(2026, 12, 31, 0), 'day')).toBe(day(2027, 1, 1, 0)) + expect(nextBucketStart(day(2026, 12, 28, 0), 'week')).toBe(day(2027, 1, 4, 0)) + expect(nextBucketStart(day(2026, 12, 1, 0), 'month')).toBe(day(2027, 1, 1, 0)) + }) + + it('labels and ranges buckets', () => { + expect(formatBucketLabel(day(2026, 10, 5, 0), 'week')).toBe('2026-10-05') + expect(formatBucketLabel(day(2026, 10, 1, 0), 'month')).toBe('2026-10') + expect(bucketDayRange(day(2026, 10, 5, 0), 'week')).toEqual({ from: '2026-10-05', to: '2026-10-11' }) + expect(bucketDayRange(day(2026, 2, 1, 0), 'month')).toEqual({ from: '2026-02-01', to: '2026-02-28' }) + }) + + const due = (bucket: DateDimension['bucket']): DateDimension => ({ + kind: 'date', + bucket, + read: (d: Doc) => d.due, + noneLabel: 'No due' + }) + + it('fills in the buckets between the first and the last item', () => { + const items: Doc[] = [{ due: day(2026, 10, 1) }, { due: day(2026, 10, 1, 18) }, { due: day(2026, 10, 4) }] + const res = computeChart({ docs: items, x: due('day'), measure: { type: 'count' } }) + expect(res.categories.map((c) => c.label)).toEqual(['2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04']) + expect(res.values).toEqual([[2, 0, 0, 1]]) + expect(res.categories[0].start).toBe(day(2026, 10, 1, 0)) + expect(res.categories[0].end).toBe(day(2026, 10, 2, 0) - 1) + }) + + it('keeps the items without a date in a last "No " bucket', () => { + const items: Doc[] = [{ due: day(2026, 10, 1) }, {}, { due: Number.NaN }] + const res = computeChart({ docs: items, x: due('month'), measure: { type: 'count' } }) + expect(res.categories.map((c) => [c.label, c.none])).toEqual([ + ['2026-10', false], + ['No due', true] + ]) + expect(res.values).toEqual([[1, 2]]) + }) + + it('does not fill in a range of more than the cap, it draws the buckets that have items', () => { + const items: Doc[] = [{ due: day(2020, 1, 1) }, { due: day(2026, 1, 1) }] + const res = computeChart({ docs: items, x: due('day'), measure: { type: 'count' } }) + expect(res.categories).toHaveLength(2) + expect(MAX_FILLED_DATE_BUCKETS).toBeGreaterThan(0) + }) + + it('groups a date axis by series', () => { + const items: Doc[] = [ + { due: day(2026, 10, 1), team: 'a' }, + { due: day(2026, 10, 2), team: 'b' } + ] + const res = computeChart({ docs: items, x: due('day'), group: team, measure: { type: 'count' } }) + expect(res.values).toEqual([ + [1, 0], + [0, 1] + ]) + }) +}) + +describe('computeChart: options', () => { + it('drops an item whose value is not an option of a field (a removed status counts as no value)', () => { + const strict = category([['a', 'A']], (d) => (d.status === 'a' ? ['a'] : [])) + const res = computeChart({ docs: [{ status: 'a' }, { status: 'gone' }], x: strict, measure: { type: 'count' } }) + expect(res.categories.map((c) => c.id)).toEqual(['a', CHART_NONE]) + }) +}) diff --git a/plugins/tracker-resources/src/insights/__tests__/build.test.ts b/plugins/tracker-resources/src/insights/__tests__/build.test.ts new file mode 100644 index 0000000000..06c8855f2f --- /dev/null +++ b/plugins/tracker-resources/src/insights/__tests__/build.test.ts @@ -0,0 +1,209 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { filterGrammar } from '@hcengineering/view-resources' +import { computeChart } from '../aggregate' +import { buildChartRequest, chartMeasure } from '../build' +import { defaultChartConfig, resolveChartConfig, type ChartConfig } from '../config' +import { cellFilter } from '../drill' +import { buildChartFields, chartFieldId, chartProjection } from '../fields' +import { customFields, makeFields, noneLabel } from './fixtures' +import { buildIssueFilterSchema } from '../../issueFilter' + +const fields = makeFields() + +function run (docs: any[], config: ChartConfig): ReturnType { + const resolved = resolveChartConfig(config, fields) + const request = buildChartRequest(docs, resolved, fields, noneLabel) + if (request === undefined) throw new Error('no request') + return computeChart(request) +} + +describe('buildChartFields', () => { + it('offers the built-in category fields, then the custom ones, in the slice order', () => { + expect(fields.groups.map((f) => f.id)).toEqual([ + 'status', + 'priority', + 'assignee', + 'label', + 'component', + 'milestone', + 'customFields.size', + 'customFields.areas', + 'customFields.sprint' + ]) + }) + + it('offers dates for the X-axis only, after the category fields', () => { + expect(fields.axis.map((f) => f.id).slice(-4)).toEqual(['due', 'start', 'deadline', 'customFields.shipped']) + expect(fields.groups.every((f) => f.kind === 'category')).toBe(true) + expect(fields.axis.some((f) => f.id === 'title' || f.id === 'customFields.notes')).toBe(false) + }) + + it('offers the estimation and the Number fields for the Y-axis', () => { + expect(fields.numbers.map((f) => f.id)).toEqual(['estimate', 'customFields.points']) + }) + + it('orders statuses by the workflow, people and labels alphabetically, the rest as given', () => { + expect(fields.byId.get('status')?.options.map((o) => o.label)).toEqual(['Todo', 'Doing', 'Done']) + expect(fields.byId.get('assignee')?.options.map((o) => o.label)).toEqual(['Amy', 'Zed']) + expect(fields.byId.get('customFields.size')?.options.map((o) => o.label)).toEqual(['Small', 'Medium', 'Large']) + expect(fields.byId.get('customFields.sprint')?.options.map((o) => o.label)).toEqual(['Iteration 1', 'Iteration 2']) + }) + + it('uses the translated labels of the built-in fields', () => { + const labels = new Map([['status', 'Статус']]) + const translated = buildChartFields({ schema: fields.byId.get('status') !== undefined ? [fields.byId.get('status')!.spec] : [], labels }) + expect(translated.byId.get('status')?.label).toBe('Статус') + }) + + it('lists the options of custom fields even when no item has them, the others only when used', () => { + expect(fields.byId.get('customFields.size')?.includeEmpty).toBe(true) + expect(fields.byId.get('status')?.includeEmpty).toBe(false) + }) + + it('addresses a field the way the slice panel does', () => { + expect(chartFieldId({ name: 'size', source: 'custom', key: 'size' })).toBe('customFields.size') + expect(chartFieldId({ name: 'status', source: 'attribute', key: 'status' })).toBe('status') + }) + + it('skips a custom field that has a name of its own already', () => { + const schema = buildIssueFilterSchema({ + statuses: [], + priorities: [], + assignees: [], + components: [], + milestones: [], + labels: [], + labelRefs: [], + customFields: [...customFields], + noParentId: 'x' + }) + expect(buildChartFields({ schema }).byId.size).toBeGreaterThan(5) + }) + + it('projects the properties a chart reads', () => { + const status = fields.byId.get('status') + const size = fields.byId.get('customFields.size') + expect(chartProjection([status, size, undefined]).sort()).toEqual(['_id', 'customFields', 'status']) + }) +}) + +describe('building and computing a chart', () => { + const issues = [ + { _id: 'i1', status: 'todo', priority: 1, estimation: 3, customFields: { size: 's', points: 5, shipped: new Date(2026, 9, 5, 10).getTime() } }, + { _id: 'i2', status: 'todo', priority: 2, estimation: 5, customFields: { size: 's', points: 1 } }, + { _id: 'i3', status: 'done', priority: 1, estimation: 8, customFields: { size: 'l', shipped: new Date(2026, 9, 20, 10).getTime() } }, + { _id: 'i4', status: 'gone', priority: 0, estimation: 'n/a' } + ] + + it('draws the default chart: the issues by status', () => { + const res = run(issues, defaultChartConfig()) + expect(res.categories.map((c) => c.label)).toEqual(['Todo', 'Done', 'No Status']) + expect(res.values).toEqual([[2, 1, 1]]) + }) + + it('counts by a custom field with all its options and a "No " bucket', () => { + const res = run(issues, { ...defaultChartConfig(), xField: 'customFields.size' }) + expect(res.categories.map((c) => c.label)).toEqual(['Small', 'Medium', 'Large', 'No Size']) + expect(res.values).toEqual([[2, 0, 1, 1]]) + }) + + it('stacks by another field and sums the estimation', () => { + const res = run(issues, { + ...defaultChartConfig(), + layout: 'stackedColumn', + groupField: 'priority', + yAggregate: { type: 'sum', field: 'estimate' } + }) + expect(res.series.map((s) => s.label)).toEqual(['No priority', 'Urgent', 'High']) + // Todo: urgent 3, high 5; Done: urgent 8; No Status: no priority (the text estimate is ignored) + expect(res.values).toEqual([ + [0, 0, 0], + [3, 8, 0], + [5, 0, 0] + ]) + }) + + it('averages a Number custom field and leaves the empty cells without value', () => { + const res = run(issues, { ...defaultChartConfig(), yAggregate: { type: 'avg', field: 'customFields.points' } }) + expect(res.values).toEqual([[3, null, null]]) + }) + + it('buckets a date field for a line', () => { + const res = run(issues, { ...defaultChartConfig(), layout: 'line', xField: 'customFields.shipped', xBucket: 'week' }) + expect(res.categories.map((c) => c.label)).toEqual(['2026-10-05', '2026-10-12', '2026-10-19', 'No Shipped']) + expect(res.values).toEqual([[1, 0, 1, 2]]) + }) + + it('reads the labels of the issues from the label references', () => { + const res = run( + [{ _id: 'i1' }, { _id: 'i2' }, { _id: 'i3' }], + { ...defaultChartConfig(), xField: 'label' } + ) + expect(res.categories.map((c) => c.label)).toEqual(['bug', 'ui', 'No Label']) + expect(res.values).toEqual([[2, 1, 1]]) + }) + + it('has no request for a number field on the X-axis or an unknown field', () => { + expect(buildChartRequest([], { ...defaultChartConfig(), xField: 'estimate' }, fields, noneLabel)).toBeUndefined() + expect(buildChartRequest([], { ...defaultChartConfig(), xField: 'nope' }, fields, noneLabel)).toBeUndefined() + }) + + it('counts when the measure has no number field', () => { + expect(chartMeasure({ ...defaultChartConfig(), yAggregate: { type: 'sum', field: 'nope' } }, fields)).toEqual({ type: 'count' }) + }) +}) + +describe('cellFilter', () => { + const config: ChartConfig = { ...defaultChartConfig(), filter: 'is:open', groupField: 'priority' } + const resolved = resolveChartConfig(config, fields) + const schema = fields.axis.map((f) => f.spec) + + function filterOf (cfg: ChartConfig, docs: any[], category: number, series: number): string { + const res = resolveChartConfig(cfg, fields) + const data = computeChart(buildChartRequest(docs, res, fields, noneLabel) as any) + return cellFilter(res, fields, data.categories[category], data.series[series]) + } + + it('adds the bucket and the series to the filter of the chart', () => { + const docs = [{ _id: 'i1', status: 'todo', priority: 1 }] + expect(filterOf(config, docs, 0, 0)).toBe('is:open status:Todo priority:Urgent') + }) + + it('addresses the items without a value with no:', () => { + const docs = [{ _id: 'i1', priority: 1 }] + expect(filterOf(config, docs, 0, 0)).toBe('is:open no:status priority:Urgent') + const other = [{ _id: 'i1', status: 'todo' }] + expect(filterOf(config, other, 0, 0)).toBe('is:open status:Todo no:priority') + }) + + it('quotes values that need it', () => { + const docs = [{ _id: 'i1', customFields: { sprint: 'it2' } }] + const res = filterOf({ ...defaultChartConfig(), xField: 'customFields.sprint' }, docs, 1, 0) + expect(res).toBe('sprint:"Iteration 2"') + expect(filterGrammar.parseFilter(res, schema).ok).toBe(true) + }) + + it('uses the day range of a date bucket, and the filter parses and selects the items of the bucket', () => { + const inside = new Date(2026, 9, 7, 10).getTime() + const outside = new Date(2026, 9, 14, 10).getTime() + const docs = [{ _id: 'a', customFields: { shipped: inside } }, { _id: 'b', customFields: { shipped: outside } }] + const cfg = { ...defaultChartConfig(), layout: 'line' as const, xField: 'customFields.shipped', xBucket: 'week' as const } + const text = filterOf(cfg, docs, 0, 0) + expect(text).toBe('shipped:2026-10-05..2026-10-11') + const parsed = filterGrammar.parseFilter(text, schema) + if (parsed.ok === false) throw new Error('filter does not parse') + const match = filterGrammar.createPredicate(parsed.value, { now: Date.now() }) + expect(docs.filter(match).map((d) => d._id)).toEqual(['a']) + }) + + it('produces filters that the grammar parses for the none bucket as well', () => { + const text = filterOf({ ...defaultChartConfig() }, [{ _id: 'i1' }], 0, 0) + expect(text).toBe('no:status') + expect(filterGrammar.parseFilter(text, schema).ok).toBe(true) + expect(resolved.groupField).toBe('priority') + }) +}) diff --git a/plugins/tracker-resources/src/insights/__tests__/config.test.ts b/plugins/tracker-resources/src/insights/__tests__/config.test.ts new file mode 100644 index 0000000000..1ec711eda1 --- /dev/null +++ b/plugins/tracker-resources/src/insights/__tests__/config.test.ts @@ -0,0 +1,194 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + allowsDateAxis, + chartDocData, + defaultChartConfig, + INSIGHT_LAYOUTS, + isChartDirty, + isHorizontalLayout, + isStackedLayout, + nextChartPosition, + normalizeChartConfig, + resolveChartConfig, + sortCharts, + withGroupField, + withLayout, + withXField, + withYAxis +} from '../config' +import { makeFields } from './fixtures' + +const fields = makeFields() + +describe('layouts', () => { + it('offers exactly the six GitHub layouts', () => { + expect(INSIGHT_LAYOUTS).toEqual(['bar', 'column', 'stackedBar', 'stackedColumn', 'stackedArea', 'line']) + }) + + it('knows stacked, horizontal and continuous layouts', () => { + expect(INSIGHT_LAYOUTS.filter(isStackedLayout)).toEqual(['stackedBar', 'stackedColumn', 'stackedArea']) + expect(INSIGHT_LAYOUTS.filter(isHorizontalLayout)).toEqual(['bar', 'stackedBar']) + expect(INSIGHT_LAYOUTS.filter(allowsDateAxis)).toEqual(['stackedArea', 'line']) + }) +}) + +describe('normalizeChartConfig', () => { + it('gives the default for anything that is not a config', () => { + expect(normalizeChartConfig(undefined)).toEqual(defaultChartConfig()) + expect(normalizeChartConfig('x')).toEqual(defaultChartConfig()) + expect(normalizeChartConfig([])).toEqual(defaultChartConfig()) + }) + + it('repairs the parts that are not valid', () => { + const res = normalizeChartConfig({ + layout: 'pie', + xField: '', + groupField: '', + yAggregate: { type: 'median', field: 5 }, + filter: 7, + xBucket: 'year' + }) + expect(res).toEqual(defaultChartConfig()) + }) + + it('keeps a valid config and drops the number field of the count', () => { + expect( + normalizeChartConfig({ + layout: 'line', + xField: 'due', + xBucket: 'month', + groupField: 'status', + yAggregate: { type: 'sum', field: 'estimate' }, + filter: 'is:open' + }) + ).toEqual({ + layout: 'line', + xField: 'due', + xBucket: 'month', + groupField: 'status', + yAggregate: { type: 'sum', field: 'estimate' }, + filter: 'is:open' + }) + expect(normalizeChartConfig({ yAggregate: { type: 'count', field: 'estimate' } }).yAggregate).toEqual({ type: 'count' }) + // A sum without a field cannot be computed + expect(normalizeChartConfig({ yAggregate: { type: 'sum' } }).yAggregate).toEqual({ type: 'count' }) + }) +}) + +describe('isChartDirty', () => { + const base = defaultChartConfig() + + it('is clean for the same config, whitespace around the filter and absent values', () => { + expect(isChartDirty(base, { ...base })).toBe(false) + expect(isChartDirty(base, { ...base, filter: ' ' })).toBe(false) + expect(isChartDirty({ ...base, filter: 'a' }, { ...base, filter: ' a ' })).toBe(false) + }) + + it('is dirty for every setting that changes', () => { + expect(isChartDirty(base, { ...base, layout: 'bar' })).toBe(true) + expect(isChartDirty(base, { ...base, xField: 'priority' })).toBe(true) + expect(isChartDirty(base, { ...base, groupField: 'priority' })).toBe(true) + expect(isChartDirty(base, { ...base, yAggregate: { type: 'sum', field: 'estimate' } })).toBe(true) + expect(isChartDirty(base, { ...base, filter: 'is:open' })).toBe(true) + }) + + it('is clean again when a change is changed back', () => { + const changed = withGroupField(base, 'priority') + expect(isChartDirty(base, changed)).toBe(true) + expect(isChartDirty(base, withGroupField(changed, undefined))).toBe(false) + }) + + it('ignores a bucket that the X-axis does not use', () => { + expect(isChartDirty(base, { ...base, xBucket: 'month' }, fields)).toBe(false) + const date = { ...base, layout: 'line' as const, xField: 'due' } + expect(isChartDirty(date, { ...date, xBucket: 'month' }, fields)).toBe(true) + // The default bucket is the same as no bucket + expect(isChartDirty(date, { ...date, xBucket: 'week' }, fields)).toBe(false) + }) +}) + +describe('resolveChartConfig', () => { + it('keeps a config the fields allow', () => { + const config = { ...defaultChartConfig(), groupField: 'priority', yAggregate: { type: 'avg' as const, field: 'estimate' } } + expect(resolveChartConfig(config, fields)).toEqual(config) + }) + + it('falls back to the first category field for a missing X field', () => { + expect(resolveChartConfig({ ...defaultChartConfig(), xField: 'customFields.gone' }, fields).xField).toBe('status') + }) + + it('does not draw a date axis for a bar layout, and adds the bucket for a line', () => { + const date = { ...defaultChartConfig(), xField: 'due' } + expect(resolveChartConfig(date, fields).xField).toBe('status') + const line = resolveChartConfig({ ...date, layout: 'line' }, fields) + expect(line.xField).toBe('due') + expect(line.xBucket).toBe('week') + }) + + it('drops a series field that is gone, is not a category or equals the X field', () => { + expect(resolveChartConfig({ ...defaultChartConfig(), groupField: 'customFields.gone' }, fields).groupField).toBeUndefined() + expect(resolveChartConfig({ ...defaultChartConfig(), groupField: 'due' }, fields).groupField).toBeUndefined() + expect(resolveChartConfig({ ...defaultChartConfig(), groupField: 'status' }, fields).groupField).toBeUndefined() + }) + + it('counts when the number field of the Y-axis is gone or is not a number', () => { + expect( + resolveChartConfig({ ...defaultChartConfig(), yAggregate: { type: 'sum', field: 'customFields.gone' } }, fields).yAggregate + ).toEqual({ type: 'count' }) + expect(resolveChartConfig({ ...defaultChartConfig(), yAggregate: { type: 'sum', field: 'status' } }, fields).yAggregate).toEqual({ + type: 'count' + }) + }) +}) + +describe('config edits', () => { + it('moves a date X-axis back to a category field when the layout cannot draw it', () => { + const line = withXField({ ...defaultChartConfig(), layout: 'line' }, 'due', fields) + expect(line.xBucket).toBe('week') + const column = withLayout(line, 'column', fields) + expect(column.xField).toBe('status') + expect(column.xBucket).toBeUndefined() + // Another layout that draws dates keeps it + expect(withLayout(line, 'stackedArea', fields).xField).toBe('due') + }) + + it('removes the series field that becomes the X field', () => { + const config = withGroupField(defaultChartConfig(), 'priority') + expect(withXField(config, 'priority', fields).groupField).toBeUndefined() + expect(withGroupField(defaultChartConfig(), 'status').groupField).toBeUndefined() + }) + + it('sets the Y-axis', () => { + expect(withYAxis(defaultChartConfig(), 'sum', 'estimate').yAggregate).toEqual({ type: 'sum', field: 'estimate' }) + expect(withYAxis(defaultChartConfig(), 'count', 'estimate').yAggregate).toEqual({ type: 'count' }) + expect(withYAxis(defaultChartConfig(), 'sum', undefined).yAggregate).toEqual({ type: 'count' }) + }) +}) + +describe('chart documents', () => { + it('builds the data of a new chart', () => { + expect(chartDocData('Chart 1', { ...defaultChartConfig(), filter: ' is:open ' }, 3)).toEqual({ + name: 'Chart 1', + layout: 'column', + xField: 'status', + yAggregate: { type: 'count' }, + filter: 'is:open', + position: 3 + }) + }) + + it('appends after the last position and sorts by position then creation', () => { + expect(nextChartPosition([])).toBe(0) + expect(nextChartPosition([{ position: 0 }, { position: 4 }])).toBe(5) + const sorted = sortCharts([ + { id: 'c', position: 1, createdOn: 5 }, + { id: 'b', position: 0, createdOn: 9 }, + { id: 'a', position: 0, createdOn: 1 } + ]) + expect(sorted.map((c) => c.id)).toEqual(['a', 'b', 'c']) + }) +}) diff --git a/plugins/tracker-resources/src/insights/__tests__/fixtures.ts b/plugins/tracker-resources/src/insights/__tests__/fixtures.ts new file mode 100644 index 0000000000..6766b16732 --- /dev/null +++ b/plugins/tracker-resources/src/insights/__tests__/fixtures.ts @@ -0,0 +1,76 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, type Iteration, type ProjectField } from '@hcengineering/tracker' +import { buildIssueFilterSchema } from '../../issueFilter' +import { buildChartFields, type ChartFields } from '../fields' + +function field (key: string, label: string, type: ProjectFieldType, options?: Array<[string, string]>): ProjectField { + return { + _id: `f-${key}`, + key, + label, + type, + position: 0, + options: options?.map(([value, l]) => ({ value, label: l })) + } as unknown as ProjectField +} + +export const customFields = [ + field('size', 'Size', ProjectFieldType.SingleSelect, [ + ['s', 'Small'], + ['m', 'Medium'], + ['l', 'Large'] + ]), + field('areas', 'Areas', ProjectFieldType.MultiSelect, [ + ['ui', 'UI'], + ['api', 'API'] + ]), + field('points', 'Points', ProjectFieldType.Number), + field('notes', 'Notes', ProjectFieldType.Text), + field('shipped', 'Shipped', ProjectFieldType.Date), + field('sprint', 'Sprint', ProjectFieldType.Iteration) +] + +export const iterations = [ + { _id: 'it1', label: 'Iteration 1', field: 'f-sprint', isBreak: false, startDate: 0, duration: 7 }, + { _id: 'it2', label: 'Iteration 2', field: 'f-sprint', isBreak: false, startDate: 0, duration: 7 } +] as unknown as Iteration[] + +export function makeFields (): ChartFields { + const schema = buildIssueFilterSchema({ + statuses: [ + { id: 'done', name: 'Done' }, + { id: 'todo', name: 'Todo' }, + { id: 'doing', name: 'Doing' } + ], + priorities: [ + { id: 0, name: 'No priority' }, + { id: 1, name: 'Urgent' }, + { id: 2, name: 'High' } + ], + assignees: [ + { id: 'u-zed', name: 'Zed' }, + { id: 'u-amy', name: 'Amy' } + ], + components: [{ id: 'c1', name: 'Backend' }], + milestones: [{ id: 'm1', name: 'v1' }], + labels: [ + { id: 'l-bug', name: 'bug' }, + { id: 'l-ui', name: 'ui' } + ], + labelRefs: [ + { issue: 'i1', label: 'l-bug' }, + { issue: 'i1', label: 'l-ui' }, + { issue: 'i2', label: 'l-bug' } + ], + customFields, + iterations, + noParentId: 'no-parent' + }) + return buildChartFields({ schema, statusOrder: ['todo', 'doing', 'done'] }) +} + +export const noneLabel = (label: string): string => `No ${label}` diff --git a/plugins/tracker-resources/src/insights/__tests__/layout.test.ts b/plugins/tracker-resources/src/insights/__tests__/layout.test.ts new file mode 100644 index 0000000000..eee0fe9f17 --- /dev/null +++ b/plugins/tracker-resources/src/insights/__tests__/layout.test.ts @@ -0,0 +1,281 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ChartData } from '../aggregate' +import { categoryLabelWidth, computeGeometry, formatTick, niceScale, truncateLabel, valueRange } from '../layout' + +function data (values: Array>, none: number[] = []): ChartData { + const n = values[0]?.length ?? 0 + return { + categories: Array.from({ length: n }, (_, i) => ({ id: `c${i}`, label: `C${i}`, none: none.includes(i) })), + series: values.map((_, i) => ({ id: `s${i}`, label: `S${i}`, none: false })), + values, + counts: values.map((row) => row.map((v) => v ?? 0)), + items: 0 + } +} + +const size = { width: 400, height: 300 } +const margins = { top: 10, right: 10, bottom: 40, left: 50 } // plot 340 x 250 + +describe('niceScale', () => { + it('rounds the bounds and the step', () => { + expect(niceScale(0, 7)).toMatchObject({ min: 0, max: 8, step: 2, ticks: [0, 2, 4, 6, 8] }) + expect(niceScale(0, 100)).toMatchObject({ max: 100, step: 20 }) + const small = niceScale(0, 0.3) + expect(small.step).toBeCloseTo(0.1) + expect(small.ticks[0]).toBe(0) + expect(small.ticks[1]).toBe(0.1) + expect(small.max).toBeGreaterThanOrEqual(0.3) + }) + + it('always contains 0 and covers negative values', () => { + expect(niceScale(5, 9).min).toBe(0) + const res = niceScale(-30, 10) + expect(res.min).toBeLessThanOrEqual(-30) + expect(res.ticks).toContain(0) + }) + + it('turns an empty range into 0..1', () => { + expect(niceScale(0, 0)).toMatchObject({ min: 0, max: 1 }) + expect(niceScale(Number.NaN, Number.NaN).max).toBe(1) + }) +}) + +describe('labels', () => { + it('truncates with an ellipsis and counts characters, not code units', () => { + expect(truncateLabel('Backend', 10)).toBe('Backend') + expect(truncateLabel('Backend team', 6)).toBe('Backe…') + expect(truncateLabel('ab', 1)).toBe('…') + expect(truncateLabel('ab', 0)).toBe('') + expect(truncateLabel('😀😀😀', 2)).toBe('😀…') + }) + + it('sizes the margin for the longest label within bounds', () => { + expect(categoryLabelWidth(['a'])).toBe(40) + expect(categoryLabelWidth(['x'.repeat(100)])).toBe(180) + expect(categoryLabelWidth(['x'.repeat(10)])).toBe(Math.ceil(10 * 6.5) + 12) + expect(formatTick(1.5)).toBe('1.5') + }) +}) + +describe('valueRange', () => { + it('uses the largest value for clustered layouts and the largest total for stacked ones', () => { + const d = data([ + [2, 3], + [4, 1] + ]) + expect(valueRange(d, 'column')).toEqual({ min: 0, max: 4 }) + expect(valueRange(d, 'stackedColumn')).toEqual({ min: 0, max: 6 }) + expect(valueRange(d, 'stackedArea')).toEqual({ min: 0, max: 6 }) + }) + + it('stacks negative values downwards and ignores gaps', () => { + const d = data([ + [2, null], + [-3, -1] + ]) + expect(valueRange(d, 'stackedColumn')).toEqual({ min: -3, max: 2 }) + expect(valueRange(d, 'line')).toEqual({ min: -3, max: 2 }) + }) +}) + +describe('computeGeometry: columns', () => { + it('draws columns whose height is proportional to the value, from the baseline up', () => { + const geometry = computeGeometry(data([[2, 4]]), 'column', size, margins) + expect(geometry.horizontal).toBe(false) + expect(geometry.plot).toEqual({ x: 50, y: 10, width: 340, height: 250 }) + // The axis is 0..4 (step 1), the baseline is the bottom of the plot + expect(geometry.baseline).toBeCloseTo(260) + expect(geometry.bars).toHaveLength(2) + const [a, b] = geometry.bars + expect(b.height).toBeCloseTo(250) + expect(a.height).toBeCloseTo(125) + expect(a.y + a.height).toBeCloseTo(260) + // Two bands of 170, a bar is 70% of its band and centred in it + expect(a.width).toBeCloseTo(119) + expect(a.x + a.width / 2).toBeCloseTo(50 + 85) + expect(b.x + b.width / 2).toBeCloseTo(50 + 255) + }) + + it('puts the series side by side when they are not stacked', () => { + const geometry = computeGeometry( + data([ + [1, 1], + [2, 2] + ]), + 'column', + size, + margins + ) + const first = geometry.bars.filter((b) => b.category === 0) + expect(first).toHaveLength(2) + expect(first[0].width).toBeCloseTo(first[1].width) + expect(first[1].x).toBeCloseTo(first[0].x + first[0].width) + expect(first[0].width).toBeCloseTo(119 / 2) + }) + + it('stacks the series on top of each other', () => { + const geometry = computeGeometry( + data([ + [1, 1], + [3, 1] + ]), + 'stackedColumn', + size, + margins + ) + const first = geometry.bars.filter((b) => b.category === 0) + // 0..4 axis: 1 and 3 stacked reach the top + expect(first[0].y).toBeCloseTo(260 - 62.5) + expect(first[1].y).toBeCloseTo(10) + expect(first[1].y + first[1].height).toBeCloseTo(first[0].y) + expect(first[0].x).toBeCloseTo(first[1].x) + }) + + it('stacks negative values below the baseline', () => { + const geometry = computeGeometry( + data([ + [2, 0], + [-2, 0] + ]), + 'stackedColumn', + size, + margins + ) + const [up, down] = geometry.bars + expect(up.y + up.height).toBeCloseTo(geometry.baseline) + expect(down.y).toBeCloseTo(geometry.baseline) + }) + + it('draws no bar for a gap or a zero', () => { + const geometry = computeGeometry(data([[0, null, 3]]), 'column', size, margins) + expect(geometry.bars.map((b) => b.category)).toEqual([2]) + }) + + it('shows every n-th category label when they would not fit', () => { + const many = data([Array.from({ length: 40 }, (_, i) => i)]) + const ticks = computeGeometry(many, 'column', size, margins).categoryTicks + const visible = ticks.filter((t) => t.visible) + expect(visible.length).toBeLessThan(ticks.length) + expect(visible[0].index).toBe(0) + const few = computeGeometry(data([[1, 2, 3]]), 'column', size, margins).categoryTicks + expect(few.every((t) => t.visible)).toBe(true) + }) +}) + +describe('computeGeometry: bars', () => { + it('draws horizontal bars with the categories running down', () => { + const geometry = computeGeometry(data([[2, 4]]), 'bar', size, margins) + expect(geometry.horizontal).toBe(true) + expect(geometry.baseline).toBeCloseTo(50) + const [a, b] = geometry.bars + expect(b.width).toBeCloseTo(340) + expect(a.width).toBeCloseTo(170) + expect(a.x).toBeCloseTo(50) + // The first category is at the top + expect(a.y).toBeLessThan(b.y) + expect(geometry.categoryTicks[0].center).toBeCloseTo(10 + 62.5) + }) + + it('stacks horizontal bars from left to right', () => { + const geometry = computeGeometry( + data([ + [1, 0], + [1, 0] + ]), + 'stackedBar', + size, + margins + ) + const [a, b] = geometry.bars + expect(b.x).toBeCloseTo(a.x + a.width) + }) +}) + +describe('computeGeometry: lines and areas', () => { + it('joins the points of a series in one path and breaks it at a gap', () => { + const geometry = computeGeometry(data([[1, 2, null, 3, 4]]), 'line', size, margins) + const [line] = geometry.lines + expect(line.points).toHaveLength(4) + expect(line.segments).toHaveLength(2) + expect(line.segments[0].startsWith('M')).toBe(true) + // The points are at the centres of the bands + expect(line.points[0].x).toBeCloseTo(50 + 34) + }) + + it('does not join the "No " point to the line', () => { + const geometry = computeGeometry(data([[1, 2, 3]], [2]), 'line', size, margins) + const [line] = geometry.lines + expect(line.points).toHaveLength(3) + expect(line.segments).toHaveLength(1) + }) + + it('has a marker but no path for a single point', () => { + const geometry = computeGeometry(data([[3]]), 'line', size, margins) + expect(geometry.lines[0].points).toHaveLength(1) + expect(geometry.lines[0].segments).toEqual([]) + }) + + it('stacks the areas and closes every path', () => { + const geometry = computeGeometry( + data([ + [1, 2], + [1, 1] + ]), + 'stackedArea', + size, + margins + ) + expect(geometry.areas).toHaveLength(2) + for (const area of geometry.areas) { + expect(area.paths).toHaveLength(1) + expect(area.paths[0].endsWith('Z')).toBe(true) + } + // The axis covers the stacked total 3 + expect(geometry.valueTicks[geometry.valueTicks.length - 1].value).toBeGreaterThanOrEqual(3) + }) + + it('treats a gap as 0 when stacking and draws a lone date as a narrow column', () => { + const one = computeGeometry(data([[2]]), 'stackedArea', size, margins) + expect(one.areas[0].paths).toHaveLength(1) + const withGap = computeGeometry( + data([ + [1, null], + [1, 1] + ]), + 'stackedArea', + size, + margins + ) + expect(withGap.areas[1].paths).toHaveLength(1) + }) + + it('stands the items without a date as a stacked bar next to the area', () => { + const geometry = computeGeometry(data([[1, 2, 5]], [2]), 'stackedArea', size, margins) + expect(geometry.bars).toHaveLength(1) + expect(geometry.bars[0].category).toBe(2) + expect(geometry.areas[0].paths).toHaveLength(1) + }) +}) + +describe('computeGeometry: no data', () => { + it('has an axis and no marks', () => { + const empty: ChartData = { categories: [], series: [], values: [], counts: [], items: 0 } + for (const layout of ['bar', 'column', 'stackedArea', 'line'] as const) { + const geometry = computeGeometry(empty, layout, size, margins) + expect(geometry.bars).toEqual([]) + expect(geometry.lines).toEqual([]) + expect(geometry.areas).toEqual([]) + expect(geometry.valueTicks.length).toBeGreaterThan(1) + } + }) + + it('survives a box smaller than its margins', () => { + const geometry = computeGeometry(data([[1]]), 'column', { width: 20, height: 20 }, margins) + expect(geometry.plot.width).toBe(1) + expect(geometry.plot.height).toBe(1) + }) +}) diff --git a/plugins/tracker-resources/src/insights/aggregate.ts b/plugins/tracker-resources/src/insights/aggregate.ts new file mode 100644 index 0000000000..ddcb1e13f3 --- /dev/null +++ b/plugins/tracker-resources/src/insights/aggregate.ts @@ -0,0 +1,257 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { InsightAggregate, InsightDateBucket } from '@hcengineering/tracker' +import { sumNumbers, toSummable } from '../fieldSum/sum' + +// The aggregation of an Insights chart: the issues are put into buckets by the X field (and into series by the group +// field) and every cell gets the count of its issues or an aggregate of a number field. Pure, runs over the issues +// the host scanned; it knows nothing of fields, filters or the platform. + +/** The id of the bucket and of the series of the items without a value ("No "). */ +export const CHART_NONE = '__none__' + +/** The id of the only series of a chart that is not grouped. */ +export const CHART_ALL = '__all__' + +/** Date buckets are filled in over gaps up to this many; beyond it only the buckets that have items are drawn. */ +export const MAX_FILLED_DATE_BUCKETS = 1000 + +export interface CategoryDimension { + kind: 'category' + // Options in the order of the axis + options: ReadonlyArray<{ id: string, label: string }> + // List the options that no item has (with 0) + includeEmpty: boolean + // Ids of the options of an item; none for an item without a value + ids: (doc: any) => string[] + // Already translated "No " + noneLabel: string +} + +export interface DateDimension { + kind: 'date' + bucket: InsightDateBucket + // The date of an item as a timestamp; undefined for an item without one + read: (doc: any) => number | undefined + noneLabel: string +} + +export type Dimension = CategoryDimension | DateDimension + +export interface Measure { + type: InsightAggregate + // The raw value of the number field; anything that is not a finite number is ignored. Not needed for the count + read?: (doc: any) => unknown +} + +export interface ChartRequest { + docs: readonly unknown[] + x: Dimension + group?: CategoryDimension + measure: Measure +} + +export interface ChartCategory { + id: string + label: string + // The bucket of the items without a value + none: boolean + // First and last millisecond of a date bucket + start?: number + end?: number +} + +export interface ChartSeries { + id: string + // Empty for the only series of a chart that is not grouped + label: string + none: boolean +} + +export interface ChartData { + categories: ChartCategory[] + series: ChartSeries[] + // Y value by series, then by category; null where the cell has nothing to aggregate (average, minimum, maximum of no numbers) + values: Array> + // Number of items by series, then by category + counts: number[][] + // Items the chart is made of + items: number +} + +// ---- dates ---- + +/** First moment of the day, the Monday of the week or the first day of the month (local time) that contains the timestamp. */ +export function bucketStart (ts: number, bucket: InsightDateBucket): number { + const d = new Date(ts) + switch (bucket) { + case 'day': + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() + case 'week': + return new Date(d.getFullYear(), d.getMonth(), d.getDate() - ((d.getDay() + 6) % 7)).getTime() + case 'month': + return new Date(d.getFullYear(), d.getMonth(), 1).getTime() + } +} + +/** Start of the bucket after the one that starts at `start`. */ +export function nextBucketStart (start: number, bucket: InsightDateBucket): number { + const d = new Date(start) + switch (bucket) { + case 'day': + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() + case 'week': + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7).getTime() + case 'month': + return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime() + } +} + +function pad (n: number): string { + return String(n).padStart(2, '0') +} + +/** `YYYY-MM-DD` of the local day of a timestamp. */ +export function formatDay (ts: number): string { + const d = new Date(ts) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +/** Label of a date bucket: the day, the first day of the week (Monday) or the month (`YYYY-MM`). */ +export function formatBucketLabel (start: number, bucket: InsightDateBucket): string { + if (bucket === 'month') return formatDay(start).slice(0, 7) + return formatDay(start) +} + +/** First and last day (`YYYY-MM-DD`) of a date bucket, as a filter range uses them. */ +export function bucketDayRange (start: number, bucket: InsightDateBucket): { from: string, to: string } { + return { from: formatDay(start), to: formatDay(nextBucketStart(start, bucket) - 1) } +} + +function bucketStarts (used: ReadonlySet, bucket: InsightDateBucket): number[] { + if (used.size === 0) return [] + const sorted = [...used].sort((a, b) => a - b) + const first = sorted[0] + const last = sorted[sorted.length - 1] + const filled: number[] = [] + for (let at = first; at <= last; at = nextBucketStart(at, bucket)) { + filled.push(at) + if (filled.length > MAX_FILLED_DATE_BUCKETS) return sorted + } + return filled +} + +// ---- aggregation ---- + +interface Cell { + count: number + numbers: number[] +} + +/** Value of a cell for the aggregate; null when there is nothing to aggregate. */ +export function aggregateCell (type: InsightAggregate, cell: { count: number, numbers: readonly number[] } | undefined): number | null { + if (type === 'count') return cell?.count ?? 0 + const numbers = cell?.numbers ?? [] + if (type === 'sum') return sumNumbers(numbers).sum + if (numbers.length === 0) return null + if (type === 'avg') return sumNumbers(numbers).sum / numbers.length + let res = numbers[0] + for (const n of numbers) res = type === 'min' ? Math.min(res, n) : Math.max(res, n) + return res +} + +function categoryKeys (dim: CategoryDimension, doc: unknown): string[] { + const ids = dim.ids(doc) + return ids.length === 0 ? [CHART_NONE] : [...new Set(ids)] +} + +function xKeys (dim: Dimension, doc: unknown): string[] { + if (dim.kind === 'category') return categoryKeys(dim, doc) + const ts = dim.read(doc) + return typeof ts === 'number' && Number.isFinite(ts) ? [String(bucketStart(ts, dim.bucket))] : [CHART_NONE] +} + +/** + * Computes the chart: the buckets of the X-axis, the series, and the value and the item count of every cell. + * + * - An item with several values in a field (labels, multi select) counts in each of them. + * - Items without a value are in the "No " bucket, the last one; it is there only when it has items. + * - Category buckets are the options of the field in their order (all of them for the fields the project owns, the + * used ones for the others), a date axis is every bucket from the first item to the last, with empty ones between. + * - The count counts every item; the other aggregates use the items that have a number in the field, and a cell with + * none has no value (null) for the average, minimum and maximum, 0 for the sum. + */ +export function computeChart (request: ChartRequest): ChartData { + const { docs, x, group, measure } = request + const needsNumbers = measure.type !== 'count' && measure.read !== undefined + const cells = new Map>() + const usedX = new Set() + const usedSeries = new Set() + + for (const doc of docs) { + const xs = xKeys(x, doc) + const gs = group !== undefined ? categoryKeys(group, doc) : [CHART_ALL] + const n = needsNumbers ? toSummable(measure.read?.(doc)) : undefined + for (const g of gs) { + usedSeries.add(g) + let row = cells.get(g) + if (row === undefined) { + row = new Map() + cells.set(g, row) + } + for (const xk of xs) { + usedX.add(xk) + let cell = row.get(xk) + if (cell === undefined) { + cell = { count: 0, numbers: [] } + row.set(xk, cell) + } + cell.count++ + if (n !== undefined) cell.numbers.push(n) + } + } + } + + const categories: ChartCategory[] = [] + if (x.kind === 'category') { + for (const o of x.options) { + if (usedX.has(o.id) || x.includeEmpty) categories.push({ id: o.id, label: o.label, none: false }) + } + } else { + const used = new Set() + for (const k of usedX) if (k !== CHART_NONE) used.add(Number(k)) + for (const start of bucketStarts(used, x.bucket)) { + categories.push({ + id: String(start), + label: formatBucketLabel(start, x.bucket), + none: false, + start, + end: nextBucketStart(start, x.bucket) - 1 + }) + } + } + if (usedX.has(CHART_NONE)) categories.push({ id: CHART_NONE, label: x.noneLabel, none: true }) + + const series: ChartSeries[] = [] + if (group === undefined) { + series.push({ id: CHART_ALL, label: '', none: false }) + } else { + for (const o of group.options) if (usedSeries.has(o.id)) series.push({ id: o.id, label: o.label, none: false }) + if (usedSeries.has(CHART_NONE)) series.push({ id: CHART_NONE, label: group.noneLabel, none: true }) + } + + // Nothing to draw: no buckets also means no series, so that a consumer never sees series of empty rows + if (categories.length === 0) return { categories, series: [], values: [], counts: [], items: docs.length } + + const values: Array> = [] + const counts: number[][] = [] + for (const s of series) { + const row = cells.get(s.id) + values.push(categories.map((c) => aggregateCell(measure.type, row?.get(c.id)))) + counts.push(categories.map((c) => row?.get(c.id)?.count ?? 0)) + } + return { categories, series, values, counts, items: docs.length } +} diff --git a/plugins/tracker-resources/src/insights/build.ts b/plugins/tracker-resources/src/insights/build.ts new file mode 100644 index 0000000000..a8bb7c15b0 --- /dev/null +++ b/plugins/tracker-resources/src/insights/build.ts @@ -0,0 +1,72 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { filterGrammar } from '@hcengineering/view-resources' +import { optionIdSet, sliceValueIds } from '../slice/values' +import type { CategoryDimension, ChartRequest, DateDimension, Measure } from './aggregate' +import type { ChartConfig } from './config' +import { DEFAULT_DATE_BUCKET } from './config' +import type { ChartField, ChartFields } from './fields' + +/** The dimension of a category field: its options in axis order and the options an item has. */ +export function categoryDimension (field: ChartField, noneLabel: string): CategoryDimension { + const known = optionIdSet(field.spec) + return { + kind: 'category', + options: field.options, + includeEmpty: field.includeEmpty, + ids: (doc) => sliceValueIds(field.spec, doc, known), + noneLabel + } +} + +/** The dimension of a date field: the moment of an item, bucketed. A value that is not a finite number is no date. */ +export function dateDimension (field: ChartField, bucket: DateDimension['bucket'], noneLabel: string): DateDimension { + return { + kind: 'date', + bucket, + read: (doc) => { + const raw = filterGrammar.readFieldValue(field.spec, doc) + return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined + }, + noneLabel + } +} + +/** + * What the Y-axis computes. `config` must be resolved (`resolveChartConfig`), so that the number field exists. + */ +export function chartMeasure (config: ChartConfig, fields: ChartFields): Measure { + const type = config.yAggregate.type + if (type === 'count') return { type } + const field = config.yAggregate.field !== undefined ? fields.byId.get(config.yAggregate.field) : undefined + if (field === undefined) return { type: 'count' } + return { type, read: (doc) => filterGrammar.readFieldValue(field.spec, doc) } +} + +/** + * The request for `computeChart` for a resolved config over the scanned issues; undefined when the X-axis field + * does not exist. + * @param noneLabel the translated "No " for a field label + */ +export function buildChartRequest ( + docs: readonly unknown[], + config: ChartConfig, + fields: ChartFields, + noneLabel: (fieldLabel: string) => string +): ChartRequest | undefined { + const x = fields.byId.get(config.xField) + if (x === undefined || x.kind === 'number') return undefined + const group = config.groupField !== undefined ? fields.byId.get(config.groupField) : undefined + return { + docs, + x: + x.kind === 'date' + ? dateDimension(x, config.xBucket ?? DEFAULT_DATE_BUCKET, noneLabel(x.label)) + : categoryDimension(x, noneLabel(x.label)), + group: group?.kind === 'category' ? categoryDimension(group, noneLabel(group.label)) : undefined, + measure: chartMeasure(config, fields) + } +} diff --git a/plugins/tracker-resources/src/insights/colors.ts b/plugins/tracker-resources/src/insights/colors.ts new file mode 100644 index 0000000000..9764543061 --- /dev/null +++ b/plugins/tracker-resources/src/insights/colors.ts @@ -0,0 +1,18 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { getPlatformColor } from '@hcengineering/ui' +import type { ChartSeries } from './aggregate' + +/** Color of the series without a value ("No "): neutral, so that it never looks like a real value. */ +export const NONE_SERIES_COLOR = 'var(--theme-dark-color)' + +/** + * Color of a series: a color of the platform palette by the position of the series (stable while the options of the + * field keep their order), a neutral one for "No ". + */ +export function seriesColor (series: Pick, index: number, dark: boolean): string { + return series.none ? NONE_SERIES_COLOR : getPlatformColor(index, dark) +} diff --git a/plugins/tracker-resources/src/insights/config.ts b/plugins/tracker-resources/src/insights/config.ts new file mode 100644 index 0000000000..244e8885cd --- /dev/null +++ b/plugins/tracker-resources/src/insights/config.ts @@ -0,0 +1,225 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Data } from '@hcengineering/core' +import type { InsightAggregate, InsightChart, InsightDateBucket, InsightLayout, InsightYAxis } from '@hcengineering/tracker' +import type { ChartFields } from './fields' + +/** + * The configuration of an Insights chart (what a saved `InsightChart` stores besides its name and position). + * It is also what the configuration panel edits: the draft is compared with the saved chart to tell whether + * there are unsaved changes. + */ +export interface ChartConfig { + layout: InsightLayout + // Id of the field of the X-axis, see `chartFieldId` + xField: string + // Buckets of a date X-axis + xBucket?: InsightDateBucket + // Id of the field that makes the series + groupField?: string + yAggregate: InsightYAxis + // GitHub-style filter string + filter: string +} + +/** The six layouts of GitHub, in the order the layout menu lists them. */ +export const INSIGHT_LAYOUTS: readonly InsightLayout[] = ['bar', 'column', 'stackedBar', 'stackedColumn', 'stackedArea', 'line'] + +export const INSIGHT_AGGREGATES: readonly InsightAggregate[] = ['count', 'sum', 'avg', 'min', 'max'] + +export const INSIGHT_DATE_BUCKETS: readonly InsightDateBucket[] = ['day', 'week', 'month'] + +/** Id of the chart that is shown before any chart is saved (it exists only until the first one is saved). */ +export const DEFAULT_CHART_ID = 'default-chart' + +/** Buckets of a date X-axis until the user picks another size. */ +export const DEFAULT_DATE_BUCKET: InsightDateBucket = 'week' + +/** The id of the Status field, the X-axis of the default chart. */ +export const STATUS_FIELD_ID = 'status' + +/** The default chart: "Issues by status", a column chart with the count of items. */ +export function defaultChartConfig (): ChartConfig { + return { layout: 'column', xField: STATUS_FIELD_ID, yAggregate: { type: 'count' }, filter: '' } +} + +function isRecord (raw: unknown): raw is Record { + return raw !== null && typeof raw === 'object' && !Array.isArray(raw) +} + +function oneOf (list: readonly T[], raw: unknown): T | undefined { + return typeof raw === 'string' && (list as readonly string[]).includes(raw) ? (raw as T) : undefined +} + +/** Whether the layout is drawn over a continuous axis (a date can be the X-axis). */ +export function allowsDateAxis (layout: InsightLayout): boolean { + return layout === 'line' || layout === 'stackedArea' +} + +/** Whether the layout puts the series on top of each other. */ +export function isStackedLayout (layout: InsightLayout): boolean { + return layout === 'stackedBar' || layout === 'stackedColumn' || layout === 'stackedArea' +} + +/** Whether the categories run down the vertical axis. */ +export function isHorizontalLayout (layout: InsightLayout): boolean { + return layout === 'bar' || layout === 'stackedBar' +} + +/** Whether the layout is a bar layout (as opposed to a line or an area). */ +export function isBarLayout (layout: InsightLayout): boolean { + return !allowsDateAxis(layout) +} + +/** + * Turns whatever is stored (an older or damaged doc) into a config; a part that is not valid gets its default. + */ +export function normalizeChartConfig (raw: unknown): ChartConfig { + const base = defaultChartConfig() + if (!isRecord(raw)) return base + const y = isRecord(raw.yAggregate) ? raw.yAggregate : {} + const type = oneOf(INSIGHT_AGGREGATES, y.type) ?? 'count' + const yField = typeof y.field === 'string' && y.field !== '' ? y.field : undefined + const bucket = oneOf(INSIGHT_DATE_BUCKETS, raw.xBucket) + return { + layout: oneOf(INSIGHT_LAYOUTS, raw.layout) ?? base.layout, + xField: typeof raw.xField === 'string' && raw.xField !== '' ? raw.xField : base.xField, + ...(bucket !== undefined ? { xBucket: bucket } : {}), + ...(typeof raw.groupField === 'string' && raw.groupField !== '' ? { groupField: raw.groupField } : {}), + yAggregate: type === 'count' || yField === undefined ? { type: 'count' } : { type, field: yField }, + filter: typeof raw.filter === 'string' ? raw.filter : '' + } +} + +/** The configuration of a saved chart. */ +export function configOfChart (chart: Pick): ChartConfig { + return normalizeChartConfig(chart) +} + +/** + * The part of the config in the form it is stored and compared in: absent values are left out, the filter is + * trimmed (whitespace around it is not a change), and a bucket is kept only while the X-axis can use it. + */ +function canonical (config: ChartConfig, fields?: ChartFields): Record { + const c = normalizeChartConfig(config) + const usesBucket = fields === undefined ? c.xBucket !== undefined : fields.byId.get(c.xField)?.kind === 'date' + return { + layout: c.layout, + xField: c.xField, + xBucket: usesBucket ? (c.xBucket ?? DEFAULT_DATE_BUCKET) : undefined, + groupField: c.groupField, + yAggregate: c.yAggregate.type === 'count' ? { type: 'count' } : c.yAggregate, + filter: c.filter.trim() + } +} + +/** + * Whether the draft differs from the baseline (the saved chart, or the default one): the unsaved changes dot. + * A setting that is changed and changed back is clean again. + */ +export function isChartDirty (baseline: ChartConfig, current: ChartConfig, fields?: ChartFields): boolean { + return JSON.stringify(canonical(baseline, fields)) !== JSON.stringify(canonical(current, fields)) +} + +/** + * The config that is drawn: what the fields of the project allow. A field that does not exist any more (or that the + * layout cannot use) falls back, without anything being rewritten in the saved chart. + */ +export function resolveChartConfig (config: ChartConfig, fields: ChartFields): ChartConfig { + const c = normalizeChartConfig(config) + const x = fields.byId.get(c.xField) + const xOk = x !== undefined && (x.kind === 'category' || (x.kind === 'date' && allowsDateAxis(c.layout))) + const xField = xOk ? c.xField : (fields.axis.find((f) => f.kind === 'category')?.id ?? c.xField) + const xKind = fields.byId.get(xField)?.kind + const group = c.groupField !== undefined ? fields.byId.get(c.groupField) : undefined + const groupField = group?.kind === 'category' && c.groupField !== xField ? c.groupField : undefined + const yField = c.yAggregate.field !== undefined ? fields.byId.get(c.yAggregate.field) : undefined + const yAggregate: InsightYAxis = c.yAggregate.type !== 'count' && yField?.kind === 'number' ? c.yAggregate : { type: 'count' } + return { + layout: c.layout, + xField, + ...(xKind === 'date' ? { xBucket: c.xBucket ?? DEFAULT_DATE_BUCKET } : {}), + ...(groupField !== undefined ? { groupField } : {}), + yAggregate, + filter: c.filter + } +} + +/** The config with another layout; a date X-axis that the layout cannot draw goes back to the first category field. */ +export function withLayout (config: ChartConfig, layout: InsightLayout, fields: ChartFields): ChartConfig { + const next = { ...config, layout } + const x = fields.byId.get(config.xField) + if (x?.kind === 'date' && !allowsDateAxis(layout)) { + const first = fields.axis.find((f) => f.kind === 'category') + if (first !== undefined) return withXField({ ...next }, first.id, fields) + } + return next +} + +/** The config with another X-axis field; the series field cannot be the same field, the bucket goes with a date. */ +export function withXField (config: ChartConfig, xField: string, fields: ChartFields): ChartConfig { + const next: ChartConfig = { ...config, xField } + if (fields.byId.get(xField)?.kind === 'date') next.xBucket = config.xBucket ?? DEFAULT_DATE_BUCKET + else delete next.xBucket + if (next.groupField === xField) delete next.groupField + return next +} + +/** The config with another series field; `undefined` is "no grouping". */ +export function withGroupField (config: ChartConfig, groupField: string | undefined): ChartConfig { + const next: ChartConfig = { ...config } + if (groupField === undefined || groupField === config.xField) delete next.groupField + else next.groupField = groupField + return next +} + +/** The config with another Y-axis; the number field is dropped for the count. */ +export function withYAxis (config: ChartConfig, type: InsightAggregate, field: string | undefined): ChartConfig { + return { ...config, yAggregate: type === 'count' || field === undefined ? { type: 'count' } : { type, field } } +} + +/** The values a new chart document is created with. */ +export function chartDocData (name: string, config: ChartConfig, position: number): Data { + const c = normalizeChartConfig(config) + return { + name, + layout: c.layout, + xField: c.xField, + ...(c.xBucket !== undefined ? { xBucket: c.xBucket } : {}), + ...(c.groupField !== undefined ? { groupField: c.groupField } : {}), + yAggregate: c.yAggregate, + filter: c.filter.trim(), + position + } +} + +/** + * The values an update of a saved chart writes. A cleared setting is written as `null`, because an update that leaves + * a property out (or sets it to undefined) does not remove it. + */ +export function chartDocUpdate (config: ChartConfig): Pick { + const c = normalizeChartConfig(config) + return { + layout: c.layout, + xField: c.xField, + xBucket: c.xBucket ?? null, + groupField: c.groupField ?? null, + yAggregate: c.yAggregate, + filter: c.filter.trim() + } +} + +/** Order value for a chart appended after the others. */ +export function nextChartPosition (charts: ReadonlyArray>): number { + let max = -1 + for (const chart of charts) if (chart.position > max) max = chart.position + return max + 1 +} + +/** The charts in the order of the sidebar: by position, ties by creation time. */ +export function sortCharts & { createdOn?: number }> (charts: readonly T[]): T[] { + return [...charts].sort((a, b) => a.position - b.position || (a.createdOn ?? 0) - (b.createdOn ?? 0)) +} diff --git a/plugins/tracker-resources/src/insights/drill.ts b/plugins/tracker-resources/src/insights/drill.ts new file mode 100644 index 0000000000..de5e6fd1b2 --- /dev/null +++ b/plugins/tracker-resources/src/insights/drill.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { filterGrammar } from '@hcengineering/view-resources' +import { bucketDayRange, CHART_NONE, type ChartCategory, type ChartSeries } from './aggregate' +import { DEFAULT_DATE_BUCKET, type ChartConfig } from './config' +import type { ChartField, ChartFields } from './fields' + +function termFor (query: string, field: ChartField, value: string | undefined, none: boolean): string { + if (none || value === undefined) return filterGrammar.appendTerm(query, 'no', field.spec.name) + return filterGrammar.appendTerm(query, field.spec.name, value) +} + +/** + * The filter string that selects the issues of a bar, a point or a segment: the filter of the chart, the bucket of the + * X-axis and the series. Applying it to the project view lists the issues behind the number. + */ +export function cellFilter ( + config: ChartConfig, + fields: ChartFields, + category: ChartCategory, + series: ChartSeries | undefined +): string { + let query = config.filter.trim() + const x = fields.byId.get(config.xField) + if (x !== undefined) { + if (x.kind === 'date' && !category.none && category.start !== undefined) { + const { from, to } = bucketDayRange(category.start, config.xBucket ?? DEFAULT_DATE_BUCKET) + query = filterGrammar.appendTerm(query, x.spec.name, `${from}..${to}`) + } else { + query = termFor(query, x, category.label, category.none || category.id === CHART_NONE) + } + } + const group = config.groupField !== undefined ? fields.byId.get(config.groupField) : undefined + if (group !== undefined && series !== undefined) { + query = termFor(query, group, series.label, series.none) + } + return query +} diff --git a/plugins/tracker-resources/src/insights/fields.ts b/plugins/tracker-resources/src/insights/fields.ts new file mode 100644 index 0000000000..6ff7bc71af --- /dev/null +++ b/plugins/tracker-resources/src/insights/fields.ts @@ -0,0 +1,136 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { filterGrammar } from '@hcengineering/view-resources' +import { BUILTIN_SLICE_FIELDS, listsEmptyValues, sliceFieldId } from '../slice/fields' + +type FieldSpec = filterGrammar.FieldSpec + +/** + * What a field can be in a chart: the buckets of an axis or the series (`category`: one of its options), the dates + * of a continuous X-axis (`date`), or the numbers the Y-axis adds up (`number`). Text fields have no use in a chart. + */ +export type ChartFieldKind = 'category' | 'date' | 'number' + +export interface ChartOption { + id: string + label: string +} + +/** A field of the project a chart can use. */ +export interface ChartField { + // Id the chart stores the field by, see `chartFieldId` + id: string + // Already translated + label: string + kind: ChartFieldKind + // The filter field it comes from: how its value is read and what the filter string calls it + spec: FieldSpec + // Options of a category field in the order of the axis + options: ChartOption[] + // List the options that no item has (with 0): the fields that belong to the project do, the others list what is used + includeEmpty: boolean +} + +export interface ChartFields { + // The fields that can be the X-axis: category fields first, then date fields + axis: ChartField[] + // The fields that can make the series + groups: ChartField[] + // The fields the Y-axis can add up + numbers: ChartField[] + byId: ReadonlyMap +} + +export interface ChartFieldsInput { + // The filter schema of the project, see `buildIssueFilterSchema` + schema: readonly FieldSpec[] + // Translated labels of the built-in fields by filter name; the label of the schema is used for the others + labels?: ReadonlyMap + // Status ids in the order of the workflow; the other statuses follow + statusOrder?: readonly string[] +} + +/** The id a chart stores a field by: the filter name of a built-in field, `customFields.` for a custom one. */ +export const chartFieldId = sliceFieldId + +/** Built-in fields whose options are sorted by their label, because their order has no meaning. */ +const SORTED_BY_LABEL = new Set(['assignee', 'label', 'component', 'milestone']) + +function kindOf (spec: FieldSpec): ChartFieldKind | undefined { + // The time of the last change is a filter (`updated:`), not something a chart is drawn over + if (spec.source === 'attribute' && spec.name === 'updated') return undefined + if (spec.type === 'date') return 'date' + if (spec.type === 'number') return 'number' + if (spec.source === 'custom') { + return spec.type === 'select' || spec.type === 'multi' || spec.type === 'iteration' ? 'category' : undefined + } + return BUILTIN_SLICE_FIELDS.includes(spec.name) ? 'category' : undefined +} + +function orderedOptions (spec: FieldSpec, statusOrder: readonly string[] | undefined): ChartOption[] { + const seen = new Set() + let options: ChartOption[] = [] + for (const o of spec.options ?? []) { + const id = String(o.id) + if (seen.has(id)) continue + seen.add(id) + options.push({ id, label: o.name }) + } + if (spec.source === 'attribute' && SORTED_BY_LABEL.has(spec.name)) { + options = options.sort((a, b) => a.label.localeCompare(b.label)) + } else if (spec.source === 'attribute' && spec.name === 'status' && statusOrder !== undefined) { + const position = new Map(statusOrder.map((id, i) => [String(id), i])) + const rank = (o: ChartOption): number => position.get(o.id) ?? Number.MAX_SAFE_INTEGER + options = options.map((o, i) => ({ o, i })).sort((a, b) => rank(a.o) - rank(b.o) || a.i - b.i).map((it) => it.o) + } + return options +} + +/** + * The fields a chart can use, from the filter schema of the project: the same built-in fields as the slice panel and + * the single select, multi select and iteration fields as buckets and series, the date fields (due date, start date, + * deadline and the Date fields) for a date X-axis, the estimation and the Number fields for the Y-axis. + */ +export function buildChartFields (input: ChartFieldsInput): ChartFields { + const categories: ChartField[] = [] + const dates: ChartField[] = [] + const numbers: ChartField[] = [] + for (const spec of input.schema) { + const kind = kindOf(spec) + if (kind === undefined) continue + const field: ChartField = { + id: chartFieldId(spec), + label: spec.source === 'custom' ? spec.label : (input.labels?.get(spec.name) ?? spec.label), + kind, + spec, + options: kind === 'category' ? orderedOptions(spec, input.statusOrder) : [], + includeEmpty: listsEmptyValues(spec) + } + if (kind === 'category') categories.push(field) + else if (kind === 'date') dates.push(field) + else numbers.push(field) + } + // Built-in fields keep the order of the slice panel, the custom ones follow in the order of the project + const rank = (f: ChartField): number => { + const i = f.spec.source === 'attribute' ? BUILTIN_SLICE_FIELDS.indexOf(f.spec.name) : -1 + return i === -1 ? BUILTIN_SLICE_FIELDS.length : i + } + const sortedCategories = categories.map((f, i) => ({ f, i })).sort((a, b) => rank(a.f) - rank(b.f) || a.i - b.i).map((it) => it.f) + const byId = new Map() + for (const f of [...sortedCategories, ...dates, ...numbers]) if (!byId.has(f.id)) byId.set(f.id, f) + return { axis: [...sortedCategories, ...dates], groups: sortedCategories, numbers, byId } +} + +/** Document properties a chart reads, to project the scan with. */ +export function chartProjection (fields: ReadonlyArray): string[] { + const res = new Set(['_id']) + for (const field of fields) { + if (field === undefined) continue + res.add(field.spec.source === 'custom' ? 'customFields' : field.spec.key) + for (const dependency of field.spec.dependsOn ?? []) res.add(dependency) + } + return [...res] +} diff --git a/plugins/tracker-resources/src/insights/layout.ts b/plugins/tracker-resources/src/insights/layout.ts new file mode 100644 index 0000000000..7523903d0f --- /dev/null +++ b/plugins/tracker-resources/src/insights/layout.ts @@ -0,0 +1,417 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { InsightLayout } from '@hcengineering/tracker' +import { formatSumValue } from '../fieldSum/sum' +import type { ChartData } from './aggregate' +import { isHorizontalLayout, isStackedLayout } from './config' + +// The geometry of a chart in pixels: scales, ticks, bars, lines and areas. Pure, so that the drawing component only +// has to put the shapes on the page and the layouts can be tested without a browser. + +export interface Size { + width: number + height: number +} + +export interface Rect { + x: number + y: number + width: number + height: number +} + +export interface Tick { + value: number + // Position along the value axis (x for a horizontal layout, y otherwise) + position: number + label: string +} + +export interface CategoryTick { + index: number + // Centre of the band of the category along the category axis + center: number + // Size of the band of the category + band: number + // The label is shown; with many categories only every n-th one is + visible: boolean +} + +/** A bar, or the segment of a stacked bar. */ +export interface BarMark extends Rect { + series: number + category: number + value: number +} + +export interface PointMark { + x: number + y: number + series: number + category: number + value: number +} + +/** A line of a series: the points drawn as one path, a gap (no value, "No ") starts a new one. */ +export interface LineMark { + series: number + segments: string[] + points: PointMark[] +} + +/** A band of a stacked area: closed paths, one per run of categories. */ +export interface AreaMark { + series: number + paths: string[] +} + +export interface ChartGeometry { + layout: InsightLayout + horizontal: boolean + plot: Rect + valueTicks: Tick[] + categoryTicks: CategoryTick[] + // Position of the value 0 along the value axis + baseline: number + bars: BarMark[] + lines: LineMark[] + areas: AreaMark[] +} + +export interface Margins { + top: number + right: number + bottom: number + left: number +} + +// ---- scales ---- + +function niceNumber (raw: number): number { + const exp = Math.floor(Math.log10(raw)) + const f = raw / Math.pow(10, exp) + const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10 + return nice * Math.pow(10, exp) +} + +export interface NiceScale { + min: number + max: number + step: number + ticks: number[] +} + +/** + * A value axis that covers [min, max] and always contains 0: round bounds, a round step, about `target` ticks. + * An empty range (all values 0) becomes 0..1. + */ +export function niceScale (min: number, max: number, target: number = 5): NiceScale { + let lo = Math.min(0, min) + let hi = Math.max(0, max) + if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo === hi) { + lo = 0 + hi = 1 + } + const step = niceNumber((hi - lo) / Math.max(1, target)) + const niceMin = Math.floor(lo / step) * step + const niceMax = Math.ceil(hi / step) * step + const ticks: number[] = [] + const count = Math.round((niceMax - niceMin) / step) + for (let i = 0; i <= count; i++) { + // Rounding keeps 0.1 + 0.2 style noise out of the labels + ticks.push(Number((niceMin + i * step).toPrecision(12))) + } + return { min: niceMin, max: niceMax, step, ticks } +} + +/** A number as an axis label. */ +export function formatTick (value: number): string { + return formatSumValue(value) +} + +/** The shortest `max` characters of a label, with an ellipsis when it was cut. */ +export function truncateLabel (label: string, max: number): string { + if (max < 1) return '' + const chars = [...label] + if (chars.length <= max) return label + return max === 1 ? '…' : `${chars.slice(0, max - 1).join('')}…` +} + +/** Pixels of a character of the axis labels; the estimate sizes the margins for the longest label. */ +export const CHAR_WIDTH = 6.5 + +/** Width the labels of the category axis need (horizontal layouts), within sane bounds. */ +export function categoryLabelWidth (labels: readonly string[], min: number = 40, max: number = 180): number { + const longest = labels.reduce((m, l) => Math.max(m, [...l].length), 0) + return Math.min(max, Math.max(min, Math.ceil(longest * CHAR_WIDTH) + 12)) +} + +// ---- data ---- + +interface Stack { + // Sum of the positive values and of the negative ones by category + positive: number[] + negative: number[] +} + +function stackTotals (data: ChartData): Stack { + const n = data.categories.length + const positive = new Array(n).fill(0) + const negative = new Array(n).fill(0) + for (const row of data.values) { + row.forEach((v, i) => { + if (v === null) return + if (v >= 0) positive[i] += v + else negative[i] += v + }) + } + return { positive, negative } +} + +/** The range of the values the layout draws, before it is rounded to an axis. */ +export function valueRange (data: ChartData, layout: InsightLayout): { min: number, max: number } { + if (isStackedLayout(layout)) { + const { positive, negative } = stackTotals(data) + return { min: Math.min(0, ...negative), max: Math.max(0, ...positive) } + } + let min = 0 + let max = 0 + for (const row of data.values) { + for (const v of row) { + if (v === null) continue + min = Math.min(min, v) + max = Math.max(max, v) + } + } + return { min, max } +} + +function labelStep (count: number, room: number, each: number): number { + const fits = Math.max(1, Math.floor(room / each)) + return Math.max(1, Math.ceil(count / fits)) +} + +// ---- geometry ---- + +function pointText (x: number, y: number): string { + return `${Number(x.toFixed(2))},${Number(y.toFixed(2))}` +} + +/** + * Places everything of a chart in a box. The plot is the box less the margins (room for the labels); the caller + * decides the margins, see `categoryLabelWidth` and `formatTick`. + */ +export function computeGeometry (data: ChartData, layout: InsightLayout, size: Size, margins: Margins): ChartGeometry { + const horizontal = isHorizontalLayout(layout) + const stacked = isStackedLayout(layout) + const plot: Rect = { + x: margins.left, + y: margins.top, + width: Math.max(1, size.width - margins.left - margins.right), + height: Math.max(1, size.height - margins.top - margins.bottom) + } + const range = valueRange(data, layout) + const scale = niceScale(range.min, range.max) + const span = scale.max - scale.min + // Position of a value along the value axis + const at = (v: number): number => + horizontal ? plot.x + ((v - scale.min) / span) * plot.width : plot.y + plot.height - ((v - scale.min) / span) * plot.height + + const valueTicks = scale.ticks.map((value) => ({ value, position: at(value), label: formatTick(value) })) + const count = data.categories.length + const length = horizontal ? plot.height : plot.width + const band = count > 0 ? length / count : length + const start = horizontal ? plot.y : plot.x + const step = labelStep(count, length, horizontal ? 16 : 56) + const categoryTicks: CategoryTick[] = data.categories.map((_c, index) => ({ + index, + center: start + band * (index + 0.5), + band, + visible: index % step === 0 + })) + + const geometry: ChartGeometry = { + layout, + horizontal, + plot, + valueTicks, + categoryTicks, + baseline: at(0), + bars: [], + lines: [], + areas: [] + } + if (count === 0) return geometry + + const isLine = layout === 'line' + const isArea = layout === 'stackedArea' + if (!isLine && !isArea) { + geometry.bars = barMarks(data, stacked, horizontal, plot, band, at) + return geometry + } + + const centers = categoryTicks.map((t) => t.center) + if (isLine) { + geometry.lines = data.values.map((row, series) => lineMark(row, series, centers, data, at)) + return geometry + } + + // Stacked area: the items without a value cannot be joined to the dates, they stand as a stacked bar + const stack = areaStack(data) + geometry.areas = data.values.map((_row, series) => ({ + series, + paths: areaPaths(series, stack, data, centers, band, at) + })) + const noneIndex = data.categories.findIndex((c) => c.none) + if (noneIndex !== -1) geometry.bars = noneBars(data, noneIndex, band, plot, at) + return geometry +} + +function barMarks ( + data: ChartData, + stacked: boolean, + horizontal: boolean, + plot: Rect, + band: number, + at: (v: number) => number +): BarMark[] { + const bars: BarMark[] = [] + const seriesCount = data.series.length + const groupSize = band * 0.7 + const positive = new Array(data.categories.length).fill(0) + const negative = new Array(data.categories.length).fill(0) + data.values.forEach((row, series) => { + row.forEach((value, category) => { + if (value === null || value === 0) return + const origin = (horizontal ? plot.y : plot.x) + band * category + (band - groupSize) / 2 + const size = stacked ? groupSize : groupSize / seriesCount + const offset = stacked ? 0 : size * series + let from: number + let to: number + if (stacked) { + const base = value >= 0 ? positive[category] : negative[category] + from = base + to = base + value + if (value >= 0) positive[category] = to + else negative[category] = to + } else { + from = 0 + to = value + } + const a = at(from) + const b = at(to) + const low = Math.min(a, b) + const length = Math.abs(a - b) + bars.push( + horizontal + ? { x: low, y: origin + offset, width: length, height: size, series, category, value } + : { x: origin + offset, y: low, width: size, height: length, series, category, value } + ) + }) + }) + return bars +} + +function lineMark ( + row: ReadonlyArray, + series: number, + centers: readonly number[], + data: ChartData, + at: (v: number) => number +): LineMark { + const points: PointMark[] = [] + const segments: string[] = [] + let current: string[] = [] + const flush = (): void => { + // A run of one point is only a marker, it has no path + if (current.length > 1) segments.push(`M${current.join('L')}`) + current = [] + } + row.forEach((value, category) => { + if (value === null) { + flush() + return + } + const x = centers[category] + const y = at(value) + points.push({ x, y, series, category, value }) + if (data.categories[category].none) { + flush() + return + } + current.push(pointText(x, y)) + }) + flush() + return { series, segments, points } +} + +interface AreaStack { + // Top of each series by category: the sum of the values of the series up to it + tops: number[][] +} + +function areaStack (data: ChartData): AreaStack { + const n = data.categories.length + const running = new Array(n).fill(0) + const tops: number[][] = [] + for (const row of data.values) { + const top = row.map((v, i) => { + running[i] += v ?? 0 + return running[i] + }) + tops.push(top) + } + return { tops } +} + +function areaPaths ( + series: number, + stack: AreaStack, + data: ChartData, + centers: readonly number[], + band: number, + at: (v: number) => number +): string[] { + const paths: string[] = [] + const tops = stack.tops[series] + const bottoms = series === 0 ? tops.map(() => 0) : stack.tops[series - 1] + let run: number[] = [] + const flush = (): void => { + if (run.length === 1) { + // A single date has no neighbour to join: it is drawn as a narrow column + const i = run[0] + const left = centers[i] - band * 0.15 + const right = centers[i] + band * 0.15 + paths.push(`M${pointText(left, at(tops[i]))}L${pointText(right, at(tops[i]))}L${pointText(right, at(bottoms[i]))}L${pointText(left, at(bottoms[i]))}Z`) + } else if (run.length > 1) { + const upper = run.map((i) => pointText(centers[i], at(tops[i]))) + const lower = [...run].reverse().map((i) => pointText(centers[i], at(bottoms[i]))) + paths.push(`M${upper.join('L')}L${lower.join('L')}Z`) + } + run = [] + } + data.categories.forEach((c, i) => { + if (c.none) flush() + else run.push(i) + }) + flush() + return paths +} + +function noneBars (data: ChartData, category: number, band: number, plot: Rect, at: (v: number) => number): BarMark[] { + const bars: BarMark[] = [] + const width = band * 0.5 + let top = 0 + data.values.forEach((row, series) => { + const value = row[category] + if (value === null || value <= 0) return + const a = at(top) + const b = at(top + value) + top += value + bars.push({ x: plot.x + band * category + (band - width) / 2, y: Math.min(a, b), width, height: Math.abs(a - b), series, category, value }) + }) + return bars +} diff --git a/plugins/tracker-resources/src/issueFilter.ts b/plugins/tracker-resources/src/issueFilter.ts new file mode 100644 index 0000000000..1b5d221afb --- /dev/null +++ b/plugins/tracker-resources/src/issueFilter.ts @@ -0,0 +1,113 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Iteration, ProjectField } from '@hcengineering/tracker' +import { + buildIssueFilterSchema as buildSharedIssueFilterSchema, + fieldFilterName, + ProjectFieldType, + type IssueFilterSchemaInput, + type NamedOption +} from '@hcengineering/tracker' +import type { filterGrammar } from '@hcengineering/view-resources' + +import { isFilterComplete, type CustomFieldFilter } from './projectFields/query' + +// Glue between the filter grammar (view-resources) and the tracker's issues: the field schema +// the grammar parses against, and the conversion of the legacy custom-field rules into filter text. +// The schema itself is built in `@hcengineering/tracker`, because the server workflows parse filters with it too. + +type FieldSpec = filterGrammar.FieldSpec + +export { fieldFilterName } +export type { IssueFilterSchemaInput, NamedOption } + +/** + * Fields a project's issues can be filtered by: the built-in attributes first, then the project's + * custom fields. A custom field whose name collides with an earlier one is not filterable by string. + */ +export function buildIssueFilterSchema (input: IssueFilterSchemaInput): FieldSpec[] { + return buildSharedIssueFilterSchema(input) +} + +function pad (n: number): string { + return String(n).padStart(2, '0') +} + +function formatDay (ts: number): string { + const d = new Date(ts) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +function quote (text: string): string { + return /[\s,:()"]/.test(text) || text === '' ? `"${text.replaceAll('"', '\\"')}"` : text +} + +/** + * Filter text for the legacy custom-field rules (AND of all rules), so that they can be folded into + * the filter string. Rules that are incomplete or refer to a field that no longer exists are skipped. + */ +export function customFilterToQuery ( + rules: readonly CustomFieldFilter[], + fields: ReadonlyMap>, + // Iterations of an iteration field by its key, to name the picked ones + iterationsOf: (fieldKey: string) => ReadonlyArray> = () => [] +): string { + const terms: string[] = [] + for (const rule of rules) { + const field = fields.get(rule.fieldKey) + if (field === undefined || !isFilterComplete(rule)) continue + const name = fieldFilterName(field.label, field.key) + const isDate = field.type === ProjectFieldType.Date + const num = (v: number): string => (isDate ? formatDay(v) : String(v)) + const value = rule.value + switch (rule.operator) { + case 'isEmpty': + terms.push(`no:${name}`) + break + case 'isNotEmpty': + terms.push(`has:${name}`) + break + case 'contains': + terms.push(`${name}:${quote((value as string).trim())}`) + break + case 'eq': + terms.push(`${name}:${num(value as number)}`) + break + case 'gt': + case 'after': + terms.push(`${name}:>${num(value as number)}`) + break + case 'gte': + terms.push(`${name}:>=${num(value as number)}`) + break + case 'lt': + case 'before': + terms.push(`${name}:<${num(value as number)}`) + break + case 'lte': + terms.push(`${name}:<=${num(value as number)}`) + break + case 'between': { + const range = value as { from?: number, to?: number } + const from = range.from !== undefined ? num(range.from) : '*' + const to = range.to !== undefined ? num(range.to) : '*' + terms.push(`${name}:${from}..${to}`) + break + } + case 'anyOf': { + const labels = (value as string[]).map( + (id) => + (field.type === ProjectFieldType.Iteration + ? iterationsOf(field.key).find((it) => it._id === id)?.label + : field.options?.find((o) => o.value === id)?.label) ?? id + ) + terms.push(`${name}:${labels.map(quote).join(',')}`) + break + } + } + } + return terms.join(' ') +} diff --git a/plugins/tracker-resources/src/issues.ts b/plugins/tracker-resources/src/issues.ts index 053ef156a6..c81679f700 100644 --- a/plugins/tracker-resources/src/issues.ts +++ b/plugins/tracker-resources/src/issues.ts @@ -1,7 +1,14 @@ import { type Doc, type DocumentUpdate, type Ref, type RelatedDocument, type TxOperations } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' import presentation, { getClient } from '@hcengineering/presentation' -import { trackerId, type Component, type Issue, type Milestone } from '@hcengineering/tracker' +import { + issueLinkSegment, + parseDraftLinkSegment, + trackerId, + type Component, + type Issue, + type Milestone +} from '@hcengineering/tracker' import { getCurrentResolvedLocation, getPanelURI, type Location, type ResolvedLocation } from '@hcengineering/ui' import { accessDeniedStore } from '@hcengineering/view-resources' import { workbenchId } from '@hcengineering/workbench' @@ -35,13 +42,17 @@ export async function issueTitleProvider (client: TxOperations, ref: Ref, d return await getIssueTitle(object) } +/** + * The part of a link that opens the issue: its identifier, or for a draft item (no identifier of an issue yet) its id. + */ export async function getTitle (doc: Doc): Promise { - const issue = doc as Issue - return issue.identifier + return issueLinkSegment(doc as Issue) } export function generateIssuePanelUri (issue: Issue): string { - return getPanelURI(tracker.component.EditIssue, issue.identifier, issue._class, 'content') + // A draft is opened by its id, there is no identifier of an issue to resolve + const id = issue.isDraft === true ? issue._id : issue.identifier + return getPanelURI(tracker.component.EditIssue, id, issue._class, 'content') } export async function issueLinkFragmentProvider (doc: Doc): Promise { @@ -92,6 +103,33 @@ export async function generateIssueLocation (loc: Location, issueId: string): Pr } } +// A draft item has no identifier of an issue, its link carries the id of the document +export async function generateDraftLocation (loc: Location, draftId: string): Promise { + const client = getClient() + const issue = await client.findOne( + tracker.class.Issue, + { _id: draftId as Ref, isDraft: true }, + { showArchived: true } + ) + if (issue === undefined) { + accessDeniedStore.set(true) + console.error(`Could not find draft ${draftId}.`) + return undefined + } + const appComponent = loc.path[0] ?? '' + const workspace = loc.path[1] ?? '' + return { + loc: { + path: [appComponent, workspace], + fragment: generateIssuePanelUri(issue) + }, + defaultLocation: { + path: [appComponent, workspace, trackerId, issue.space, 'issues'], + fragment: generateIssuePanelUri(issue) + } + } +} + export async function resolveLocation (loc: Location): Promise { const app = loc.path[2] if (app !== trackerId) { @@ -105,6 +143,12 @@ export async function resolveLocation (loc: Location): Promise + + + diff --git a/plugins/tracker-resources/src/iterations/IterationGroupExtras.svelte b/plugins/tracker-resources/src/iterations/IterationGroupExtras.svelte new file mode 100644 index 0000000000..4fe3c41575 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationGroupExtras.svelte @@ -0,0 +1,86 @@ + + + + + +
+ + + {#if value !== undefined && rollup.count > 0} + + {/if} +
diff --git a/plugins/tracker-resources/src/iterations/IterationGroupHeader.svelte b/plugins/tracker-resources/src/iterations/IterationGroupHeader.svelte new file mode 100644 index 0000000000..14c6e51897 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationGroupHeader.svelte @@ -0,0 +1,27 @@ + + + +{iteration?.label ?? value ?? ''} +{#if iteration !== undefined} + {range} + {#if current} + + {/if} +{/if} diff --git a/plugins/tracker-resources/src/iterations/IterationPresenter.svelte b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte new file mode 100644 index 0000000000..de8f7a65f6 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte @@ -0,0 +1,28 @@ + + + + + {iteration.label} + {#if current} + + {/if} + diff --git a/plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x new file mode 100644 index 0000000000..de8f7a65f6 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x @@ -0,0 +1,28 @@ + + + + + {iteration.label} + {#if current} + + {/if} + diff --git a/plugins/tracker-resources/src/iterations/IterationRow.svelte b/plugins/tracker-resources/src/iterations/IterationRow.svelte new file mode 100644 index 0000000000..86b529c4b1 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationRow.svelte @@ -0,0 +1,158 @@ + + + +
+
+ +
+ commitStart(e.currentTarget.value)} + /> +
+ +
+ + dispatch('insertBreak')} + /> + dispatch('remove')} + /> +
+ + diff --git a/plugins/tracker-resources/src/iterations/IterationsEditor.svelte b/plugins/tracker-resources/src/iterations/IterationsEditor.svelte new file mode 100644 index 0000000000..93550d8830 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/IterationsEditor.svelte @@ -0,0 +1,130 @@ + + + +
+
+ {#if upcoming.length === 0} +
+ {/if} + {#each upcoming as iteration (iteration._id)} + change(iteration, e.detail)} + on:insertBreak={() => add(iteration._id, true)} + on:remove={() => remove(iteration)} + /> + {/each} +
+
+ + {#if completed.length > 0} + + {#if showCompleted} + {#each completed as iteration (iteration._id)} + change(iteration, e.detail)} + on:insertBreak={() => add(iteration._id, true)} + on:remove={() => remove(iteration)} + /> + {/each} + {/if} + {/if} + + {#if error !== undefined} +
+ {/if} +
+
+ + diff --git a/plugins/tracker-resources/src/iterations/actions.ts b/plugins/tracker-resources/src/iterations/actions.ts new file mode 100644 index 0000000000..d1dc1342cf --- /dev/null +++ b/plugins/tracker-resources/src/iterations/actions.ts @@ -0,0 +1,93 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref, TxOperations } from '@hcengineering/core' +import type { Issue, Iteration, ProjectField } from '@hcengineering/tracker' +import { planAddIteration, planIterationChange, planMoveItems, type AddIterationOptions, type IterationChange, type IterationPlanError } from '@hcengineering/tracker' + +import tracker from '../plugin' + +/** + * Move the given issues from one iteration of a field to another one (or to none). Issues that are + * not in `from` are left alone. The change is atomic. Returns the number of issues that were moved. + */ +export async function moveIterationItems ( + client: TxOperations, + issues: ReadonlyArray>, + field: Pick, + from: string, + to: string | null +): Promise { + if (issues.length === 0) return 0 + // The documents of a list group are projections: the full issues are needed to update them + const full = await client.findAll(tracker.class.Issue, { _id: { $in: issues.map((i) => i._id as Ref) } }) + const plan = planMoveItems(full, field.key, from, to) + if (plan.length === 0) return 0 + const batch = client.apply() + for (const { issue, customFields } of plan) { + await batch.updateCollection( + issue._class, + issue.space, + issue._id, + issue.attachedTo, + issue.attachedToClass, + issue.collection, + { customFields } + ) + } + const res = await batch.commit() + if (!res.result) throw new Error('The items could not be moved: the data was changed meanwhile') + return plan.length +} + +/** + * Apply a change of one iteration (title, start, duration) and the shift of the later ones in one batch. + * Returns the reason when the change is not allowed. + */ +export async function changeIteration ( + client: TxOperations, + iterations: readonly Iteration[], + id: Ref, + change: IterationChange +): Promise { + const plan = planIterationChange(iterations, id, change) + if (plan.ok === false) return plan.error + if (plan.updates.length === 0) return undefined + const space = iterations.find((it) => it._id === id)?.space + if (space === undefined) return 'unknown' + const batch = client.apply() + for (const u of plan.updates) { + await batch.updateDoc(tracker.class.Iteration, space, u.id, u.update) + } + await batch.commit() + return undefined +} + +/** + * Add an iteration or a break to a field (see `planAddIteration`) and shift what follows it, in one batch. + */ +export async function addIteration ( + client: TxOperations, + field: Pick, + iterations: readonly Iteration[], + options: AddIterationOptions +): Promise { + const plan = planAddIteration(iterations, options) + if (plan.ok === false) return plan.error + const batch = client.apply() + for (const u of plan.updates) { + await batch.updateDoc(tracker.class.Iteration, field.space, u.id, u.update) + } + await batch.createDoc(tracker.class.Iteration, field.space, { ...plan.draft, field: field._id }) + await batch.commit() + return undefined +} + +/** + * Delete an iteration. The server removes it from the issues that were in it. + */ +export async function removeIteration (client: TxOperations, iteration: Iteration): Promise { + await client.removeDoc(tracker.class.Iteration, iteration.space, iteration._id) +} diff --git a/plugins/tracker-resources/src/iterations/iterationsStore.ts b/plugins/tracker-resources/src/iterations/iterationsStore.ts new file mode 100644 index 0000000000..5f1d9350d5 --- /dev/null +++ b/plugins/tracker-resources/src/iterations/iterationsStore.ts @@ -0,0 +1,61 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createQuery } from '@hcengineering/presentation' +import type { Ref } from '@hcengineering/core' +import type { Iteration, Project, ProjectField } from '@hcengineering/tracker' +import { readable, type Readable } from 'svelte/store' + +import tracker from '../plugin' + +/** + * Live list of the iterations of all Iteration fields of a project. The query is started on first + * subscription and stopped when the last subscriber leaves. + */ +export function iterationsStore (project: Ref): Readable { + return readable([], (set) => { + const query = createQuery(true) + query.query(tracker.class.Iteration, { space: project }, (result) => { + set(result) + }) + return () => { + query.unsubscribe() + } + }) +} + +const sharedStores = new Map, Readable>() + +/** + * Same as `iterationsStore`, but one store (and so one live query) per project is shared by all callers. + * Use it where many components need the iterations at once, e.g. one cell per list row. + */ +export function sharedIterationsStore (project: Ref): Readable { + let store = sharedStores.get(project) + if (store === undefined) { + store = iterationsStore(project) + sharedStores.set(project, store) + } + return store +} + +/** + * The iterations of one field. + */ +export function iterationsOfField (iterations: readonly Iteration[], field: Pick): Iteration[] { + return iterations.filter((it) => it.field === field._id) +} + +/** + * The iterations of every field, by field key. + */ +export function iterationsByFieldKey ( + iterations: readonly Iteration[], + fields: ReadonlyArray> +): Map { + const res = new Map() + for (const field of fields) res.set(field.key, iterationsOfField(iterations, field)) + return res +} diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index 665a87f98e..bbd9131c83 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -37,6 +37,9 @@ export default mergeIds(trackerId, tracker, { List: '' as Ref, Kanban: '' as Ref, Gantt: '' as Ref, + Roadmap: '' as Ref, + Calendar: '' as Ref, + Workload: '' as Ref, MilestoneIssuesList: '' as Ref, ComponentIssuesList: '' as Ref }, @@ -45,6 +48,372 @@ export default mergeIds(trackerId, tracker, { IssueCategory: '' as Ref }, string: { + ProjectField: '' as IntlString, + ProjectFields: '' as IntlString, + CustomFields: '' as IntlString, + FieldKey: '' as IntlString, + FieldType: '' as IntlString, + FieldDefaultValue: '' as IntlString, + FieldOptions: '' as IntlString, + FieldEmptyValue: '' as IntlString, + NewProjectField: '' as IntlString, + EditProjectField: '' as IntlString, + DeleteProjectField: '' as IntlString, + DeleteProjectFieldConfirm: '' as IntlString, + NoProjectFields: '' as IntlString, + ProjectFieldNamePlaceholder: '' as IntlString, + ProjectFieldDescriptionPlaceholder: '' as IntlString, + ProjectFieldTypeText: '' as IntlString, + ProjectFieldTypeNumber: '' as IntlString, + ProjectFieldTypeDate: '' as IntlString, + ProjectFieldTypeSingleSelect: '' as IntlString, + ProjectFieldTypeMultiSelect: '' as IntlString, + Iteration: '' as IntlString, + Iterations: '' as IntlString, + IterationDuration: '' as IntlString, + IterationBreak: '' as IntlString, + IterationTitlePlaceholder: '' as IntlString, + IterationStartsOn: '' as IntlString, + IterationDurationDays: '' as IntlString, + IterationDurationWeeks: '' as IntlString, + IterationCurrentMarker: '' as IntlString, + IterationCurrent: '' as IntlString, + IterationNext: '' as IntlString, + IterationPrevious: '' as IntlString, + NoIteration: '' as IntlString, + NoIterations: '' as IntlString, + AddIteration: '' as IntlString, + InsertIterationBreak: '' as IntlString, + DeleteIteration: '' as IntlString, + DeleteIterationConfirm: '' as IntlString, + UpcomingIterations: '' as IntlString, + CompletedIterations: '' as IntlString, + IterationSavedImmediately: '' as IntlString, + IterationErrorOverlap: '' as IntlString, + IterationErrorInvalidDuration: '' as IntlString, + IterationErrorEmptyLabel: '' as IntlString, + MoveItemsTo: '' as IntlString, + IterationRollupSummary: '' as IntlString, + ProjectFieldTypeIteration: '' as IntlString, + // Roadmap layout + Roadmap: '' as IntlString, + RoadmapDates: '' as IntlString, + RoadmapStartField: '' as IntlString, + RoadmapTargetField: '' as IntlString, + RoadmapMilestoneStart: '' as IntlString, + RoadmapMilestoneTarget: '' as IntlString, + RoadmapReadOnlySource: '' as IntlString, + RoadmapDatesHint: '' as IntlString, + RoadmapMarkers: '' as IntlString, + RoadmapMarkerMilestones: '' as IntlString, + RoadmapMarkerIterations: '' as IntlString, + RoadmapMarkerDates: '' as IntlString, + RoadmapFields: '' as IntlString, + RoadmapZoomMonth: '' as IntlString, + RoadmapZoomQuarter: '' as IntlString, + RoadmapZoomYear: '' as IntlString, + RoadmapToday: '' as IntlString, + RoadmapUnscheduled: '' as IntlString, + RoadmapSetDates: '' as IntlString, + RoadmapNoValue: '' as IntlString, + RoadmapEmpty: '' as IntlString, + RoadmapTruncated: '' as IntlString, + RoadmapUndo: '' as IntlString, + RoadmapRescheduled: '' as IntlString, + RoadmapRescheduleFailed: '' as IntlString, + RoadmapReadOnlyDates: '' as IntlString, + // Calendar layout + Calendar: '' as IntlString, + CalendarMonth: '' as IntlString, + CalendarWeek: '' as IntlString, + CalendarAgenda: '' as IntlString, + CalendarPrevious: '' as IntlString, + CalendarNext: '' as IntlString, + CalendarNoDateSource: '' as IntlString, + CalendarDatesHint: '' as IntlString, + CalendarMore: '' as IntlString, + CalendarAddItemOn: '' as IntlString, + CalendarDayItems: '' as IntlString, + CalendarAgendaEmpty: '' as IntlString, + CalendarUnscheduledCount: '' as IntlString, + CalendarUnscheduledHint: '' as IntlString, + CalendarUnscheduledMore: '' as IntlString, + CalendarAddNeedsDates: '' as IntlString, + // Workload layout + Workload: '' as IntlString, + WorkloadMeasure: '' as IntlString, + WorkloadMeasureCount: '' as IntlString, + WorkloadZoomDay: '' as IntlString, + WorkloadCapacity: '' as IntlString, + WorkloadCapacityHint: '' as IntlString, + WorkloadDatesHint: '' as IntlString, + WorkloadUtilization: '' as IntlString, + WorkloadOverBuckets: '' as IntlString, + WorkloadNoCapacity: '' as IntlString, + WorkloadStateUnder: '' as IntlString, + WorkloadStateNear: '' as IntlString, + WorkloadStateOver: '' as IntlString, + WorkloadCellLabel: '' as IntlString, + WorkloadUnscheduledCellLabel: '' as IntlString, + WorkloadNoLoadItems: '' as IntlString, + WorkloadPanelTitle: '' as IntlString, + WorkloadPanelHint: '' as IntlString, + WorkloadPanelClose: '' as IntlString, + WorkloadReassign: '' as IntlString, + WorkloadReassigned: '' as IntlString, + WorkloadReassignFailed: '' as IntlString, + WorkloadOutOfRange: '' as IntlString, + // Board layout + BoardColumnField: '' as IntlString, + BoardHideColumn: '' as IntlString, + BoardSetColumnLimit: '' as IntlString, + BoardEditColumnLimit: '' as IntlString, + BoardRemoveColumnLimit: '' as IntlString, + BoardColumnLimitPlaceholder: '' as IntlString, + BoardColumnLimitExceeded: '' as IntlString, + BoardHiddenColumns: '' as IntlString, + BoardShowColumn: '' as IntlString, + BoardShowHiddenColumns: '' as IntlString, + BoardClearColumnLimits: '' as IntlString, + BoardCollapseLane: '' as IntlString, + BoardExpandLane: '' as IntlString, + // Insights + Insights: '' as IntlString, + InsightsClose: '' as IntlString, + InsightChart: '' as IntlString, + InsightCharts: '' as IntlString, + InsightNewChart: '' as IntlString, + InsightDefaultChartName: '' as IntlString, + InsightChartNamePrefix: '' as IntlString, + InsightRename: '' as IntlString, + InsightDuplicate: '' as IntlString, + InsightDelete: '' as IntlString, + InsightDeleteConfirm: '' as IntlString, + InsightUnsaved: '' as IntlString, + InsightSave: '' as IntlString, + InsightSaveAsNew: '' as IntlString, + InsightDiscard: '' as IntlString, + InsightConfigure: '' as IntlString, + InsightLayout: '' as IntlString, + InsightLayoutBar: '' as IntlString, + InsightLayoutColumn: '' as IntlString, + InsightLayoutStackedBar: '' as IntlString, + InsightLayoutStackedColumn: '' as IntlString, + InsightLayoutStackedArea: '' as IntlString, + InsightLayoutLine: '' as IntlString, + InsightXAxis: '' as IntlString, + InsightGroupBy: '' as IntlString, + InsightGroupByNone: '' as IntlString, + InsightYAxis: '' as IntlString, + InsightYCount: '' as IntlString, + InsightYSum: '' as IntlString, + InsightYAverage: '' as IntlString, + InsightYMinimum: '' as IntlString, + InsightYMaximum: '' as IntlString, + InsightYField: '' as IntlString, + InsightYTitleSum: '' as IntlString, + InsightYTitleAverage: '' as IntlString, + InsightYTitleMinimum: '' as IntlString, + InsightYTitleMaximum: '' as IntlString, + InsightDateBucket: '' as IntlString, + InsightBucketDay: '' as IntlString, + InsightBucketWeek: '' as IntlString, + InsightBucketMonth: '' as IntlString, + InsightFilter: '' as IntlString, + InsightScanLimitExceeded: '' as IntlString, + InsightNoData: '' as IntlString, + InsightItems: '' as IntlString, + InsightOpenIssues: '' as IntlString, + InsightFieldDeadline: '' as IntlString, + InsightFieldRemoved: '' as IntlString, + InsightFilterInvalid: '' as IntlString, + // Archive, workflows and webhooks + ArchiveItem: '' as IntlString, + RestoreItem: '' as IntlString, + ArchivedItems: '' as IntlString, + ArchivedItemsEmpty: '' as IntlString, + ArchivedItemsHint: '' as IntlString, + ArchivedItemsLimit: '' as IntlString, + ArchivedAt: '' as IntlString, + ArchivedRestoreAll: '' as IntlString, + ArchivedDeleteAll: '' as IntlString, + ArchivedDelete: '' as IntlString, + ArchivedDeleteConfirm: '' as IntlString, + Workflow: '' as IntlString, + Workflows: '' as IntlString, + WorkflowsHint: '' as IntlString, + WorkflowEnabled: '' as IntlString, + WorkflowSetStatusDoneOnClose: '' as IntlString, + WorkflowSetStatusDoneOnCloseDescr: '' as IntlString, + WorkflowItemReopened: '' as IntlString, + WorkflowItemReopenedDescr: '' as IntlString, + WorkflowItemAdded: '' as IntlString, + WorkflowItemAddedDescr: '' as IntlString, + WorkflowAutoArchive: '' as IntlString, + WorkflowAutoArchiveDescr: '' as IntlString, + WorkflowAutoAddFromQuery: '' as IntlString, + WorkflowAutoAddFromQueryDescr: '' as IntlString, + WorkflowTargetField: '' as IntlString, + WorkflowTargetValue: '' as IntlString, + WorkflowTargetAutomatic: '' as IntlString, + WorkflowTargetResolved: '' as IntlString, + WorkflowTargetNone: '' as IntlString, + WorkflowFilterRequired: '' as IntlString, + WorkflowFilterInvalid: '' as IntlString, + WorkflowPreviewActive: '' as IntlString, + WorkflowPreviewArchived: '' as IntlString, + WorkflowPreviewOver: '' as IntlString, + WorkflowRunNote: '' as IntlString, + ProjectWebhook: '' as IntlString, + ProjectWebhooks: '' as IntlString, + WebhooksHint: '' as IntlString, + WebhookUrl: '' as IntlString, + WebhookEvents: '' as IntlString, + WebhookSecret: '' as IntlString, + WebhookSecretSet: '' as IntlString, + WebhookSecretNew: '' as IntlString, + WebhookSecretKeepPlaceholder: '' as IntlString, + WebhookSecretGenerate: '' as IntlString, + WebhookSecretNote: '' as IntlString, + WebhookEventCreated: '' as IntlString, + WebhookEventEdited: '' as IntlString, + WebhookEventArchived: '' as IntlString, + WebhookEventRestored: '' as IntlString, + WebhookEventDeleted: '' as IntlString, + WebhookAdd: '' as IntlString, + WebhookDeleteConfirm: '' as IntlString, + WebhookUrlRequired: '' as IntlString, + WebhookUrlInvalid: '' as IntlString, + WebhookUrlNotHttps: '' as IntlString, + WebhookUrlNotPublic: '' as IntlString, + WebhooksEmpty: '' as IntlString, + WebhookLimit: '' as IntlString, + WebhookNoEvents: '' as IntlString, + WebhookSecretTooLong: '' as IntlString, + // Project settings, details, status updates and templates + ProjectShortDescription: '' as IntlString, + ProjectShortDescriptionPlaceholder: '' as IntlString, + ProjectShortDescriptionTooLong: '' as IntlString, + ProjectReadme: '' as IntlString, + ProjectReadmePlaceholder: '' as IntlString, + ProjectReadmeWrite: '' as IntlString, + ProjectReadmePreview: '' as IntlString, + ProjectReadmeEmpty: '' as IntlString, + ProjectReadmeTooLong: '' as IntlString, + ProjectTemplate: '' as IntlString, + ProjectAbout: '' as IntlString, + ProjectDetails: '' as IntlString, + ProjectNoDescription: '' as IntlString, + ProjectStatus: '' as IntlString, + ProjectStatusUpdate: '' as IntlString, + ProjectStatusUpdates: '' as IntlString, + ProjectStatusInactive: '' as IntlString, + ProjectStatusOnTrack: '' as IntlString, + ProjectStatusAtRisk: '' as IntlString, + ProjectStatusOffTrack: '' as IntlString, + ProjectStatusComplete: '' as IntlString, + ProjectStatusNone: '' as IntlString, + NewProjectStatusUpdate: '' as IntlString, + EditProjectStatusUpdate: '' as IntlString, + ProjectStatusBodyPlaceholder: '' as IntlString, + ProjectStatusDelete: '' as IntlString, + ProjectStatusDeleteConfirm: '' as IntlString, + ProjectStatusNoUpdates: '' as IntlString, + ProjectStatusDatesInvalid: '' as IntlString, + ProjectStatusBodyTooLong: '' as IntlString, + ProjectSettings: '' as IntlString, + ProjectSettingsMenu: '' as IntlString, + ProjectSettingsGeneral: '' as IntlString, + ProjectSettingsDangerZone: '' as IntlString, + ProjectFieldSelectHint: '' as IntlString, + ProjectClose: '' as IntlString, + ProjectCloseDescr: '' as IntlString, + ProjectCloseConfirm: '' as IntlString, + ProjectReopen: '' as IntlString, + ProjectReopenDescr: '' as IntlString, + ProjectVisibility: '' as IntlString, + ProjectVisibilityDescr: '' as IntlString, + ProjectVisibilityPrivate: '' as IntlString, + ProjectVisibilityPublic: '' as IntlString, + ProjectVisibilityNeedsMembers: '' as IntlString, + ProjectMakeTemplate: '' as IntlString, + ProjectUnmakeTemplate: '' as IntlString, + ProjectTemplateDescr: '' as IntlString, + ProjectDeleteForever: '' as IntlString, + ProjectDeleteDescr: '' as IntlString, + ProjectDeleteTypeName: '' as IntlString, + ProjectNoPermission: '' as IntlString, + CopyProject: '' as IntlString, + ProjectCopyName: '' as IntlString, + ProjectFromTemplate: '' as IntlString, + ProjectNoTemplate: '' as IntlString, + ProjectIncludeIssues: '' as IntlString, + ProjectIncludeIssuesHint: '' as IntlString, + ProjectItemLimitReached: '' as IntlString, + ProjectSettingsClosedNote: '' as IntlString, + // Draft items, the "Add item" row and "Export view data" + Draft: '' as IntlString, + DraftItemTooltip: '' as IntlString, + ConvertToIssue: '' as IntlString, + AddItemPlaceholder: '' as IntlString, + AddItemTooLong: '' as IntlString, + AddItemFailed: '' as IntlString, + AddItemNoIssues: '' as IntlString, + AddItemInProject: '' as IntlString, + AddItemArchived: '' as IntlString, + AddItemMoveHere: '' as IntlString, + AddItemMoveHint: '' as IntlString, + AddItemOtherType: '' as IntlString, + ExportViewData: '' as IntlString, + ExportColumnUrl: '' as IntlString, + CopyViewLink: '' as IntlString, + ExportViewDataFailed: '' as IntlString, + // Sub-issue hierarchy, slice by and field sum + SliceBy: '' as IntlString, + SliceAll: '' as IntlString, + SliceClose: '' as IntlString, + SliceScanLimitExceeded: '' as IntlString, + HierarchyView: '' as IntlString, + HierarchyProgress: '' as IntlString, + HierarchyScanLimitExceeded: '' as IntlString, + FieldSum: '' as IntlString, + FieldSumTotal: '' as IntlString, + FieldSumScanLimitExceeded: '' as IntlString, + AddProjectFieldOption: '' as IntlString, + ProjectFieldOptionLabel: '' as IntlString, + ProjectFieldOptionDescription: '' as IntlString, + ProjectFieldErrorEmptyLabel: '' as IntlString, + ProjectFieldErrorDuplicateLabel: '' as IntlString, + ProjectFieldErrorTooManyFields: '' as IntlString, + ProjectFieldErrorTooManyOptions: '' as IntlString, + ProjectFieldErrorDuplicateOption: '' as IntlString, + ProjectFieldErrorEmptyOption: '' as IntlString, + ProjectFieldErrorOptionsRequired: '' as IntlString, + ProjectFieldErrorDefaultNotAllowed: '' as IntlString, + ProjectFieldErrorInvalidDefault: '' as IntlString, + CustomFieldFilter: '' as IntlString, + AddFieldFilter: '' as IntlString, + NoFieldFilters: '' as IntlString, + ClearFieldFilters: '' as IntlString, + RemoveFieldFilter: '' as IntlString, + FieldFilterUnknownField: '' as IntlString, + FieldFilterValue: '' as IntlString, + FieldFilterFrom: '' as IntlString, + FieldFilterTo: '' as IntlString, + FieldFilterOpContains: '' as IntlString, + FieldFilterOpEq: '' as IntlString, + FieldFilterOpGt: '' as IntlString, + FieldFilterOpGte: '' as IntlString, + FieldFilterOpLt: '' as IntlString, + FieldFilterOpLte: '' as IntlString, + FieldFilterOpBetween: '' as IntlString, + FieldFilterOpBefore: '' as IntlString, + FieldFilterOpAfter: '' as IntlString, + FieldFilterOpAnyOf: '' as IntlString, + FieldFilterOpIsEmpty: '' as IntlString, + FieldFilterOpIsNotEmpty: '' as IntlString, + CustomFieldScanLimitExceeded: '' as IntlString, + NoFieldValue: '' as IntlString, More: '' as IntlString, Delete: '' as IntlString, Open: '' as IntlString, @@ -349,6 +718,10 @@ export default mergeIds(trackerId, tracker, { SearchScopeTitleDescription: '' as IntlString, SearchScopeAll: '' as IntlString, SearchHighlight: '' as IntlString, + RowHeight: '' as IntlString, + RowHeightCompact: '' as IntlString, + RowHeightDefault: '' as IntlString, + RowHeightComfortable: '' as IntlString, SearchEmptyTitle: '' as IntlString, SearchEmptyActiveFilters: '' as IntlString, SearchEmptyClearFilters: '' as IntlString, @@ -569,6 +942,9 @@ export default mergeIds(trackerId, tracker, { IssuesView: '' as AnyComponent, KanbanView: '' as AnyComponent, GanttView: '' as AnyComponent, + RoadmapView: '' as AnyComponent, + CalendarView: '' as AnyComponent, + WorkloadView: '' as AnyComponent, ProjectComponents: '' as AnyComponent, IssuePreview: '' as AnyComponent, RelationsPopup: '' as AnyComponent, @@ -601,10 +977,16 @@ export default mergeIds(trackerId, tracker, { // Activity-Log RelationActivityPresenter: '' as AnyComponent, // Predecessor column in the Tracker list view. - PredecessorsColumn: '' as AnyComponent + PredecessorsColumn: '' as AnyComponent, + // List column showing and editing one custom field of a project. + CustomFieldColumn: '' as AnyComponent, + // Latest status of a project, a column of the project list. + ProjectStatusPresenter: '' as AnyComponent }, metadata: { - CreateIssueDraft: '' as Metadata + CreateIssueDraft: '' as Metadata, + // Max issues custom-field filter/sort/group works on (TRACKER_CUSTOM_FIELD_SCAN_LIMIT, default 5000). + CustomFieldScanLimit: '' as Metadata }, function: { IssueTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise>, diff --git a/plugins/tracker-resources/src/projectDetails/__tests__/copy.test.ts b/plugins/tracker-resources/src/projectDetails/__tests__/copy.test.ts new file mode 100644 index 0000000000..521df5f944 --- /dev/null +++ b/plugins/tracker-resources/src/projectDetails/__tests__/copy.test.ts @@ -0,0 +1,56 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import tracker from '@hcengineering/tracker' +import view from '@hcengineering/view' + +import { applyProjectCopyPlan, loadProjectCopySource } from '../copy' + +describe('loadProjectCopySource', () => { + it('reads the five kinds of documents of the project', async () => { + const calls: Array<[string, any]> = [] + const client: any = { + findAll: async (cls: string, query: any) => { + calls.push([cls, query]) + return [{ _id: `${cls}-1` }] + } + } + const project: any = { _id: 'p1', shortDescription: 's' } + const source = await loadProjectCopySource(client, project) + expect(calls).toEqual([ + [tracker.class.ProjectField, { space: 'p1' }], + [tracker.class.Iteration, { space: 'p1' }], + [view.class.FilteredView, { project: 'p1' }], + [tracker.class.Workflow, { space: 'p1' }], + [tracker.class.InsightChart, { space: 'p1' }] + ]) + expect(source.project).toBe(project) + expect(source.fields).toHaveLength(1) + expect(source.views).toHaveLength(1) + expect(source.charts).toHaveLength(1) + }) +}) + +describe('applyProjectCopyPlan', () => { + it('creates the documents of the plan in order with their ids', async () => { + const created: any[] = [] + const batch = { + createDoc: async (cls: string, space: string, data: any, id: string) => { + created.push([cls, space, data, id]) + } + } + const plan: any = { + ops: [ + { _class: 'a', space: 's', id: '1', data: { x: 1 } }, + { _class: 'b', space: 's', id: '2', data: { y: 2 } } + ] + } + await applyProjectCopyPlan(batch as any, plan) + expect(created).toEqual([ + ['a', 's', { x: 1 }, '1'], + ['b', 's', { y: 2 }, '2'] + ]) + }) +}) diff --git a/plugins/tracker-resources/src/projectDetails/__tests__/identifier.test.ts b/plugins/tracker-resources/src/projectDetails/__tests__/identifier.test.ts new file mode 100644 index 0000000000..9be593c203 --- /dev/null +++ b/plugins/tracker-resources/src/projectDetails/__tests__/identifier.test.ts @@ -0,0 +1,26 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { freeIdentifier } from '../identifier' + +describe('freeIdentifier', () => { + it('numbers the copy of a project', () => { + expect(freeIdentifier('TSK', new Set(['TSK']))).toBe('TSK2') + }) + + it('skips the identifiers that are taken', () => { + expect(freeIdentifier('TSK', new Set(['TSK', 'TSK2', 'TSK3']))).toBe('TSK4') + }) + + it('shortens a long base to stay within five characters', () => { + expect(freeIdentifier('ABCDE', new Set(['ABCDE']))).toBe('ABCD2') + const taken = new Set(['ABCDE', ...Array.from({ length: 8 }, (_, i) => `ABCD${i + 2}`)]) + expect(freeIdentifier('ABCDE', taken)).toBe('ABC10') + }) + + it('cleans the base like the identifier field does', () => { + expect(freeIdentifier('my-p', new Set())).toBe('MY_P2') + }) +}) diff --git a/plugins/tracker-resources/src/projectDetails/__tests__/lifecycle.test.ts b/plugins/tracker-resources/src/projectDetails/__tests__/lifecycle.test.ts new file mode 100644 index 0000000000..90b5970f89 --- /dev/null +++ b/plugins/tracker-resources/src/projectDetails/__tests__/lifecycle.test.ts @@ -0,0 +1,114 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + closeProject, + deleteProjectConfirmed, + ProjectSettingsError, + reopenProject, + setProjectTemplate, + setProjectVisibility +} from '../lifecycle' + +function writer (): { client: any, updates: any[], removed: any[] } { + const updates: any[] = [] + const removed: any[] = [] + return { + updates, + removed, + client: { + update: async (doc: any, update: any) => { + updates.push([doc._id, update]) + }, + remove: async (doc: any) => { + removed.push(doc._id) + } + } + } +} + +const project = (props: Record = {}): any => ({ + _id: 'p1', + name: 'Roadmap', + archived: false, + private: false, + members: ['a'], + owners: ['a'], + ...props +}) + +describe('close and reopen', () => { + it('closes an open project and reopens a closed one', async () => { + const w = writer() + await closeProject(w.client, project()) + await reopenProject(w.client, project({ archived: true })) + expect(w.updates).toEqual([ + ['p1', { archived: true }], + ['p1', { archived: false }] + ]) + }) + + it('does nothing when the project is in the wanted state', async () => { + const w = writer() + await closeProject(w.client, project({ archived: true })) + await reopenProject(w.client, project()) + expect(w.updates).toEqual([]) + }) +}) + +describe('visibility', () => { + it('maps private and public on Space.private', async () => { + const w = writer() + await setProjectVisibility(w.client, project(), true) + await setProjectVisibility(w.client, project({ private: true }), false) + expect(w.updates).toEqual([ + ['p1', { private: true }], + ['p1', { private: false }] + ]) + }) + + it('does nothing when nothing changes', async () => { + const w = writer() + await setProjectVisibility(w.client, project(), false) + expect(w.updates).toEqual([]) + }) + + it('refuses a private project nobody could open', async () => { + const w = writer() + await expect(setProjectVisibility(w.client, project({ members: [] }), true)).rejects.toBeInstanceOf( + ProjectSettingsError + ) + expect(w.updates).toEqual([]) + // making it public is always possible + await setProjectVisibility(w.client, project({ members: [], private: true }), false) + expect(w.updates).toHaveLength(1) + }) +}) + +describe('template', () => { + it('marks and unmarks', async () => { + const w = writer() + await setProjectTemplate(w.client, project(), true) + await setProjectTemplate(w.client, project({ isTemplate: true }), false) + await setProjectTemplate(w.client, project(), false) + expect(w.updates).toEqual([ + ['p1', { isTemplate: true }], + ['p1', { isTemplate: false }] + ]) + }) +}) + +describe('delete', () => { + it('deletes only after the name was typed', async () => { + const w = writer() + await expect(deleteProjectConfirmed(w.client, project(), 'roadmap')).rejects.toMatchObject({ + code: 'nameMismatch' + }) + await expect(deleteProjectConfirmed(w.client, project(), '')).rejects.toBeInstanceOf(ProjectSettingsError) + expect(w.removed).toEqual([]) + await deleteProjectConfirmed(w.client, project(), 'Roadmap') + expect(w.removed).toEqual(['p1']) + }) +}) diff --git a/plugins/tracker-resources/src/projectDetails/copy.ts b/plugins/tracker-resources/src/projectDetails/copy.ts new file mode 100644 index 0000000000..ed4cb23c9a --- /dev/null +++ b/plugins/tracker-resources/src/projectDetails/copy.ts @@ -0,0 +1,66 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Class, Doc, Ref, Space, TxOperations } from '@hcengineering/core' +import tracker, { + type InsightChart, + type Iteration, + type Project, + type ProjectCopyClasses, + type ProjectCopyPlan, + type ProjectCopySource, + type ProjectField, + type Workflow +} from '@hcengineering/tracker' +import view, { type FilteredView } from '@hcengineering/view' + +/** The classes a copy of a project creates, for `buildProjectCopyPlan`. */ +export const projectCopyClasses: ProjectCopyClasses = { + field: tracker.class.ProjectField, + iteration: tracker.class.Iteration, + view: view.class.FilteredView, + workflow: tracker.class.Workflow, + chart: tracker.class.InsightChart +} + +/** + * Reads what a copy of the project is made from: its fields, iterations, saved views, workflows and Insights charts. + */ +export async function loadProjectCopySource ( + client: Pick, + project: Pick +): Promise { + const space = project._id + const [fields, iterations, views, workflows, charts] = await Promise.all([ + client.findAll(tracker.class.ProjectField, { space }), + client.findAll(tracker.class.Iteration, { space }), + client.findAll(view.class.FilteredView, { project: space }), + client.findAll(tracker.class.Workflow, { space }), + client.findAll(tracker.class.InsightChart, { space }) + ]) + return { + project, + fields: fields as ProjectField[], + iterations: iterations as Iteration[], + views: views as FilteredView[], + workflows: workflows as Workflow[], + charts: charts as InsightChart[] + } +} + +/** The part of a batch the plan needs. */ +export interface CreateDocBatch { + createDoc: (_class: Ref>, space: Ref, data: any, id?: Ref) => Promise +} + +/** + * Creates the documents of a copy plan, in the order of the plan. Meant for the batch that also creates the project, + * so that a copy is made completely or not at all. + */ +export async function applyProjectCopyPlan (batch: CreateDocBatch, plan: ProjectCopyPlan): Promise { + for (const op of plan.ops) { + await batch.createDoc(op._class, op.space, op.data, op.id) + } +} diff --git a/plugins/tracker-resources/src/projectDetails/identifier.ts b/plugins/tracker-resources/src/projectDetails/identifier.ts new file mode 100644 index 0000000000..5719e2cbfc --- /dev/null +++ b/plugins/tracker-resources/src/projectDetails/identifier.ts @@ -0,0 +1,21 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** Longest identifier of a project. */ +export const MAX_IDENTIFIER_LENGTH = 5 + +/** + * The first identifier derived from `base` that no other project uses: `TSK2`, `TSK3`, ..., kept within the longest + * identifier by shortening the base. Used to name the copy of a project. + */ +export function freeIdentifier (base: string, taken: ReadonlySet): string { + const clean = base.toUpperCase().replace(/[-\s]/g, '_') + for (let n = 2; n < 100000; n++) { + const suffix = String(n) + const candidate = `${clean.slice(0, Math.max(1, MAX_IDENTIFIER_LENGTH - suffix.length))}${suffix}` + if (!taken.has(candidate)) return candidate + } + return clean.slice(0, MAX_IDENTIFIER_LENGTH) +} diff --git a/plugins/tracker-resources/src/projectDetails/lifecycle.ts b/plugins/tracker-resources/src/projectDetails/lifecycle.ts new file mode 100644 index 0000000000..1a688631a2 --- /dev/null +++ b/plugins/tracker-resources/src/projectDetails/lifecycle.ts @@ -0,0 +1,58 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { TxOperations } from '@hcengineering/core' +import { canMakeProjectPrivate, isDeleteConfirmed, type Project } from '@hcengineering/tracker' + +// The settings of a project that GitHub keeps in its danger zone, mapped on what Huly has: +// - "Close project" is the archive of the space (hidden from the lists, not offered when creating issues), "Reopen +// project" restores it; +// - visibility private / public is `Space.private`; +// - "Make template" is the `isTemplate` flag; +// - deleting a project asks for its name. + +export class ProjectSettingsError extends Error { + constructor (readonly code: 'nameMismatch' | 'noMembers', message: string) { + super(message) + this.name = 'ProjectSettingsError' + } +} + +type Writer = Pick + +/** Closes the project (archives the space). */ +export async function closeProject (client: Writer, project: Project): Promise { + if (project.archived) return + await client.update(project, { archived: true }) +} + +/** Reopens a closed project. */ +export async function reopenProject (client: Writer, project: Project): Promise { + if (!project.archived) return + await client.update(project, { archived: false }) +} + +/** Makes the project private or public. A private project must keep an owner among its members. */ +export async function setProjectVisibility (client: Writer, project: Project, isPrivate: boolean): Promise { + if (project.private === isPrivate) return + if (isPrivate && !canMakeProjectPrivate(project)) { + throw new ProjectSettingsError('noMembers', 'A private project needs an owner among its members') + } + await client.update(project, { private: isPrivate }) +} + +/** Marks the project as a template or takes the mark away. */ +export async function setProjectTemplate (client: Writer, project: Project, isTemplate: boolean): Promise { + if ((project.isTemplate ?? false) === isTemplate) return + await client.update(project, { isTemplate }) +} + +/** Deletes the project for good, after the name was typed. The server removes everything that belongs to it. */ +export async function deleteProjectConfirmed (client: Writer, project: Project, typedName: string): Promise { + if (!isDeleteConfirmed(project.name, typedName)) { + throw new ProjectSettingsError('nameMismatch', 'The typed name is not the name of the project') + } + await client.remove(project) +} diff --git a/plugins/tracker-resources/src/projectDetails/status.ts b/plugins/tracker-resources/src/projectDetails/status.ts new file mode 100644 index 0000000000..c90c862fce --- /dev/null +++ b/plugins/tracker-resources/src/projectDetails/status.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { IntlString } from '@hcengineering/platform' +import { ProjectStatus } from '@hcengineering/tracker' + +import tracker from '../plugin' + +/** Label of a project status. */ +export function projectStatusLabel (status: ProjectStatus): IntlString { + switch (status) { + case ProjectStatus.OnTrack: + return tracker.string.ProjectStatusOnTrack + case ProjectStatus.AtRisk: + return tracker.string.ProjectStatusAtRisk + case ProjectStatus.OffTrack: + return tracker.string.ProjectStatusOffTrack + case ProjectStatus.Complete: + return tracker.string.ProjectStatusComplete + default: + return tracker.string.ProjectStatusInactive + } +} + +/** Colour of the dot of a status, the colours GitHub uses (gray, green, yellow, red, purple). */ +export function projectStatusColor (status: ProjectStatus): string { + switch (status) { + case ProjectStatus.OnTrack: + return '#2da44e' + case ProjectStatus.AtRisk: + return '#d4a72c' + case ProjectStatus.OffTrack: + return '#cf222e' + case ProjectStatus.Complete: + return '#8250df' + default: + return '#8c959f' + } +} diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte new file mode 100644 index 0000000000..d8788be666 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/CustomFieldColumn.svelte @@ -0,0 +1,107 @@ + + + +{#if field !== undefined} + + +
+ {#if isText} + {#if editing && !readonly} + + {:else} + + +
{ + if (!readonly) editing = true + }} + > + +
+ {/if} + {:else} + save(e.detail)} /> + {/if} +
+{/if} + + diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldFilterButton.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldFilterButton.svelte new file mode 100644 index 0000000000..ecae0ea0f5 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/CustomFieldFilterButton.svelte @@ -0,0 +1,32 @@ + + + +
+
+
+ + diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldGroupHeader.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldGroupHeader.svelte new file mode 100644 index 0000000000..64f7d7784c --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/CustomFieldGroupHeader.svelte @@ -0,0 +1,15 @@ + + + +{label} diff --git a/plugins/tracker-resources/src/projectFields/CustomFieldPresenter.svelte b/plugins/tracker-resources/src/projectFields/CustomFieldPresenter.svelte new file mode 100644 index 0000000000..42ac42a842 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/CustomFieldPresenter.svelte @@ -0,0 +1,37 @@ + + + +{#if value === null} + — +{:else if field.type === ProjectFieldType.Date && typeof value === 'number'} + +{:else if field.type === ProjectFieldType.Iteration} + {#if iteration !== undefined} + + {:else} + — + {/if} +{:else if field.type === ProjectFieldType.SingleSelect || field.type === ProjectFieldType.MultiSelect} + {labels.join(', ')} +{:else} + {value} +{/if} diff --git a/plugins/tracker-resources/src/projectFields/DateFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/DateFieldEditor.svelte new file mode 100644 index 0000000000..2bae039a46 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/DateFieldEditor.svelte @@ -0,0 +1,29 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/FieldValueEditor.svelte b/plugins/tracker-resources/src/projectFields/FieldValueEditor.svelte new file mode 100644 index 0000000000..25ad91e6db --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/FieldValueEditor.svelte @@ -0,0 +1,52 @@ + + + +{#if field.type === ProjectFieldType.Text} + +{:else if field.type === ProjectFieldType.Number} + +{:else if field.type === ProjectFieldType.Date} + +{:else if field.type === ProjectFieldType.SingleSelect} + +{:else if field.type === ProjectFieldType.MultiSelect} + +{:else if field.type === ProjectFieldType.Iteration} + +{/if} diff --git a/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte b/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte new file mode 100644 index 0000000000..eddb02a177 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/IssueCustomFields.svelte @@ -0,0 +1,39 @@ + + + +{#if fields.length > 0} +
+ {#each fields as field (field._id)} + + {field.label} + + setValue(field.key, e.detail)} + /> + {/each} +{/if} diff --git a/plugins/tracker-resources/src/projectFields/MultiSelectFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/MultiSelectFieldEditor.svelte new file mode 100644 index 0000000000..5129c9f02a --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/MultiSelectFieldEditor.svelte @@ -0,0 +1,37 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/NumberFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/NumberFieldEditor.svelte new file mode 100644 index 0000000000..494489f28d --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/NumberFieldEditor.svelte @@ -0,0 +1,31 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/ProjectFieldsPopup.svelte b/plugins/tracker-resources/src/projectFields/ProjectFieldsPopup.svelte new file mode 100644 index 0000000000..f798fb9ab9 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/ProjectFieldsPopup.svelte @@ -0,0 +1,577 @@ + + + + dispatch('close')} + on:close + on:changeContent +> +
+ {#if embedded || draft === undefined} +
+ {#if fields.length === 0} +
+
+ {/if} + {#each fields as field, i (field._id)} + +
{ + // The buttons of the row (move, edit, delete) do their own thing + if (embedded && !(e.target instanceof Element && e.target.closest('button') !== null)) startEdit(field) + }} + > + +
{field.label}
+ + move(field, -1)} /> + move(field, 1)} + /> + {#if !embedded} +
+ {/each} +
+
+
+ {/if} + {#if draft !== undefined} +
+ + +
+
+ + {#if isSelect(draft.type)} +
+ {#each draft.options as option (option.value)} +
+
+ {/each} +
+
+ {/if} + + {#if draft.type === ProjectFieldType.Iteration} + {#if draft.id === undefined} +
+
+
+
+ {:else if currentField !== undefined} + + {/if} + {/if} + + {#if supportsDefault(draft.type)} +
+
+ {/if} + + {#if showErrors && error !== undefined} +
+ {/if} +
+ {:else if embedded} +
+ {/if} +
+
+ + diff --git a/plugins/tracker-resources/src/projectFields/SingleSelectFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/SingleSelectFieldEditor.svelte new file mode 100644 index 0000000000..7534de5e67 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/SingleSelectFieldEditor.svelte @@ -0,0 +1,39 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/TextFieldEditor.svelte b/plugins/tracker-resources/src/projectFields/TextFieldEditor.svelte new file mode 100644 index 0000000000..54616d51ac --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/TextFieldEditor.svelte @@ -0,0 +1,28 @@ + + + + diff --git a/plugins/tracker-resources/src/projectFields/__tests__/query.test.ts b/plugins/tracker-resources/src/projectFields/__tests__/query.test.ts new file mode 100644 index 0000000000..3c8d32c8c2 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/__tests__/query.test.ts @@ -0,0 +1,326 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, type ProjectField } from '@hcengineering/tracker' +import { + activeFilterCount, + buildFieldComparator, + buildFieldPredicate, + buildFiltersPredicate, + buildGroupCategories, + endOfDay, + startOfDay, + isGroupableType, + isSortableType, + isFilterableType, + resolveIterationValues, + type IterationContext, + DEFAULT_CUSTOM_FIELD_SCAN_LIMIT, + exceedsScanLimit, + isFilterComplete, + operatorsFor, + parseCustomFieldViewKey, + resolveScanLimit, + toCustomFieldViewKey, + type CustomFieldFilter +} from '../query' + +function field (key: string, type: ProjectFieldType, options?: Array<[string, string]>): ProjectField { + return { + key, + type, + options: options?.map(([value, label]) => ({ value, label })) + } as unknown as ProjectField +} + +const text = field('note', ProjectFieldType.Text) +const num = field('points', ProjectFieldType.Number) +const date = field('due', ProjectFieldType.Date) +const single = field('size', ProjectFieldType.SingleSelect, [ + ['s', 'Small'], + ['m', 'Medium'], + ['l', 'Large'] +]) +const multi = field('tags', ProjectFieldType.MultiSelect, [ + ['a', 'A'], + ['b', 'B'], + ['c', 'C'] +]) + +const day = 24 * 3600 * 1000 + +describe('custom field filter predicates', () => { + it('text contains is case insensitive and ignores non-strings', () => { + const p = buildFieldPredicate(text, { operator: 'contains', value: 'Foo' }) + expect(p({ note: 'a fOo b' })).toBe(true) + expect(p({ note: 'bar' })).toBe(false) + expect(p({})).toBe(false) + expect(p(undefined)).toBe(false) + }) + + it('number comparisons', () => { + const ctx = (op: any, v: number) => buildFieldPredicate(num, { operator: op, value: v }) + expect(ctx('eq', 3)({ points: 3 })).toBe(true) + expect(ctx('eq', 3)({ points: 4 })).toBe(false) + expect(ctx('gt', 3)({ points: 3 })).toBe(false) + expect(ctx('gte', 3)({ points: 3 })).toBe(true) + expect(ctx('lt', 3)({ points: 2 })).toBe(true) + expect(ctx('lte', 3)({ points: 4 })).toBe(false) + // zero is a real value, not empty + expect(ctx('eq', 0)({ points: 0 })).toBe(true) + expect(ctx('lt', 5)({})).toBe(false) + }) + + it('number and date ranges support open ends and are inclusive', () => { + const range = buildFieldPredicate(num, { operator: 'between', value: { from: 2, to: 5 } }) + expect(range({ points: 2 })).toBe(true) + expect(range({ points: 5 })).toBe(true) + expect(range({ points: 6 })).toBe(false) + const open = buildFieldPredicate(date, { operator: 'between', value: { from: 10 * day } }) + expect(open({ due: 12 * day })).toBe(true) + expect(open({ due: 8 * day })).toBe(false) + expect(open({})).toBe(false) + }) + + it('date bounds are whole calendar days', () => { + const d = new Date(2026, 5, 10, 15, 30).getTime() + const before = buildFieldPredicate(date, { operator: 'before', value: d }) + const after = buildFieldPredicate(date, { operator: 'after', value: d }) + // Any time on the bound day is neither before nor after it + expect(before({ due: d })).toBe(false) + expect(after({ due: d })).toBe(false) + expect(before({ due: startOfDay(d) })).toBe(false) + expect(after({ due: endOfDay(d) })).toBe(false) + expect(before({ due: startOfDay(d) - 1 })).toBe(true) + expect(after({ due: endOfDay(d) + 1 })).toBe(true) + const between = buildFieldPredicate(date, { operator: 'between', value: { from: d, to: d } }) + expect(between({ due: startOfDay(d) })).toBe(true) + expect(between({ due: endOfDay(d) })).toBe(true) + expect(between({ due: endOfDay(d) + 1 })).toBe(false) + }) + + it('select any-of works for single and multi select and ignores stale options', () => { + const s = buildFieldPredicate(single, { operator: 'anyOf', value: ['s', 'l'] }) + expect(s({ size: 'l' })).toBe(true) + expect(s({ size: 'm' })).toBe(false) + expect(s({ size: 'removed' })).toBe(false) + const m = buildFieldPredicate(multi, { operator: 'anyOf', value: ['b'] }) + expect(m({ tags: ['a', 'b'] })).toBe(true) + expect(m({ tags: ['a', 'c'] })).toBe(false) + expect(m({})).toBe(false) + }) + + it('is-empty and is-not-empty treat missing, null, stale and empty array as empty', () => { + const empty = buildFieldPredicate(single, { operator: 'isEmpty' }) + expect(empty({})).toBe(true) + expect(empty({ size: null })).toBe(true) + expect(empty({ size: 'removed' })).toBe(true) + expect(empty({ size: 'm' })).toBe(false) + const notEmpty = buildFieldPredicate(multi, { operator: 'isNotEmpty' }) + expect(notEmpty({ tags: [] })).toBe(false) + expect(notEmpty({ tags: ['a'] })).toBe(true) + expect(buildFieldPredicate(num, { operator: 'isEmpty' })({ points: 0 })).toBe(false) + }) + + it('incomplete rules do not restrict the result', () => { + expect(isFilterComplete({ operator: 'contains', value: ' ' })).toBe(false) + expect(isFilterComplete({ operator: 'anyOf', value: [] })).toBe(false) + expect(isFilterComplete({ operator: 'between', value: {} })).toBe(false) + expect(isFilterComplete({ operator: 'isEmpty' })).toBe(true) + expect(buildFieldPredicate(text, { operator: 'contains', value: '' })({ note: 'x' })).toBe(true) + }) + + it('combines rules with AND and a rule on a missing field matches nothing', () => { + const byKey = new Map([ + ['points', num], + ['size', single] + ]) + const filters: CustomFieldFilter[] = [ + { id: '1', fieldKey: 'points', operator: 'gte', value: 3 }, + { id: '2', fieldKey: 'size', operator: 'anyOf', value: ['m'] } + ] + const pred = buildFiltersPredicate(byKey, filters) + expect(pred({ customFields: { points: 5, size: 'm' } })).toBe(true) + expect(pred({ customFields: { points: 1, size: 'm' } })).toBe(false) + expect(pred({ customFields: { points: 5, size: 's' } })).toBe(false) + expect(pred({})).toBe(false) + expect(buildFiltersPredicate(byKey, [])({})).toBe(true) + const stale = buildFiltersPredicate(byKey, [{ id: '3', fieldKey: 'gone', operator: 'isEmpty' }]) + expect(stale({ customFields: {} })).toBe(false) + expect(activeFilterCount([...filters, { id: '4', fieldKey: 'size', operator: 'anyOf', value: [] }])).toBe(2) + }) + + it('lists operators per type', () => { + expect(operatorsFor(ProjectFieldType.Text)).toContain('contains') + expect(operatorsFor(ProjectFieldType.Number)).toContain('between') + expect(operatorsFor(ProjectFieldType.Date)).toContain('before') + expect(operatorsFor(ProjectFieldType.MultiSelect)).toContain('anyOf') + expect(operatorsFor(ProjectFieldType.Iteration)).toEqual(['anyOf', 'isEmpty', 'isNotEmpty']) + }) +}) + +describe('custom field sorting', () => { + const sortIds = (f: ProjectField, dir: 1 | -1, items: Array<{ id: string, customFields?: Record }>) => + [...items].sort(buildFieldComparator(f, dir)).map((i) => i.id) + + it('sorts text with locale compare and puts empty last in both directions', () => { + const items = [ + { id: 'b', customFields: { note: 'beta' } }, + { id: 'none' }, + { id: 'a', customFields: { note: 'Alpha' } }, + { id: 'c', customFields: { note: 'gamma' } } + ] + expect(sortIds(text, 1, items)).toEqual(['a', 'b', 'c', 'none']) + expect(sortIds(text, -1, items)).toEqual(['c', 'b', 'a', 'none']) + }) + + it('sorts numbers numerically (not as strings) and keeps zero', () => { + const items = [ + { id: '10', customFields: { points: 10 } }, + { id: '2', customFields: { points: 2 } }, + { id: '0', customFields: { points: 0 } }, + { id: 'none', customFields: {} } + ] + expect(sortIds(num, 1, items)).toEqual(['0', '2', '10', 'none']) + expect(sortIds(num, -1, items)).toEqual(['10', '2', '0', 'none']) + }) + + it('sorts dates chronologically', () => { + const items = [ + { id: 'late', customFields: { due: 9 * day } }, + { id: 'early', customFields: { due: 1 * day } }, + { id: 'none' } + ] + expect(sortIds(date, 1, items)).toEqual(['early', 'late', 'none']) + expect(sortIds(date, -1, items)).toEqual(['late', 'early', 'none']) + }) + + it('sorts single select by option order', () => { + const items = [ + { id: 'l', customFields: { size: 'l' } }, + { id: 's', customFields: { size: 's' } }, + { id: 'm', customFields: { size: 'm' } }, + { id: 'stale', customFields: { size: 'removed' } } + ] + expect(sortIds(single, 1, items)).toEqual(['s', 'm', 'l', 'stale']) + expect(sortIds(single, -1, items)).toEqual(['l', 'm', 's', 'stale']) + }) +}) + +describe('custom field grouping', () => { + const docs = [ + { customFields: { size: 'l' } }, + { customFields: { size: 's' } }, + {}, + { customFields: { size: 'weird' } } + ] + + it('orders groups by option order with stray values and the empty group last', () => { + expect(buildGroupCategories(single, docs, false)).toEqual(['s', 'l', 'weird', undefined]) + }) + + it('omits the empty group when every issue has a value', () => { + expect(buildGroupCategories(single, [{ customFields: { size: 'm' } }], false)).toEqual(['m']) + }) + + it('lists empty options and the empty group when asked to', () => { + expect(buildGroupCategories(single, [{ customFields: { size: 'm' } }], true)).toEqual(['s', 'm', 'l', undefined]) + }) +}) + +describe('scan limit and view keys', () => { + it('resolves the configured limit', () => { + expect(resolveScanLimit(undefined)).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + expect(resolveScanLimit('123')).toBe(123) + expect(resolveScanLimit(-5)).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + expect(resolveScanLimit('abc')).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + expect(resolveScanLimit(1.5)).toBe(DEFAULT_CUSTOM_FIELD_SCAN_LIMIT) + }) + + it('flags only scans that exceed the limit', () => { + expect(exceedsScanLimit(5000, 5000)).toBe(false) + expect(exceedsScanLimit(5001, 5000)).toBe(true) + }) + + it('round-trips view keys', () => { + expect(toCustomFieldViewKey('size')).toBe('customFields.size') + expect(parseCustomFieldViewKey('customFields.size')).toBe('size') + expect(parseCustomFieldViewKey('customFields.')).toBeUndefined() + expect(parseCustomFieldViewKey('status')).toBeUndefined() + }) +}) + +describe('iteration fields', () => { + const sprint = field('sprint', ProjectFieldType.Iteration) + // Oct 5 - 11, Oct 12 - 18, Oct 19 - 25; the break Oct 26 - Nov 1 is not assignable + const day = (m: number, d: number): number => new Date(2026, m - 1, d).getTime() + const mk = (id: string, number: number, start: number, isBreak = false): any => ({ + _id: id, + number, + startDate: start, + duration: 7, + ...(isBreak ? { isBreak } : {}) + }) + const list = [mk('c', 3, day(10, 19)), mk('a', 1, day(10, 5)), mk('b', 2, day(10, 12)), mk('brk', 0, day(10, 26), true)] + const ctx = (now: number): IterationContext => ({ iterations: () => list, now }) + const now = new Date(2026, 9, 14, 12).getTime() + const cf = (value?: string): Record => (value === undefined ? {} : { sprint: value }) + + it('can be filtered, sorted and grouped', () => { + expect(isFilterableType(ProjectFieldType.Iteration)).toBe(true) + expect(isSortableType(ProjectFieldType.Iteration)).toBe(true) + expect(isGroupableType(ProjectFieldType.Iteration)).toBe(true) + expect(isGroupableType(ProjectFieldType.MultiSelect)).toBe(false) + }) + + it('resolves keywords to iteration ids', () => { + expect(resolveIterationValues('sprint', ['@current'], ctx(now))).toEqual(['b']) + expect(resolveIterationValues('sprint', ['@next', '@previous'], ctx(now))).toEqual(['c', 'a']) + expect(resolveIterationValues('sprint', ['@current+1', '@current-1', 'x'], ctx(now))).toEqual(['c', 'a', 'x']) + // No iteration at that offset, and keywords need a context + expect(resolveIterationValues('sprint', ['@current+5'], ctx(now))).toEqual([]) + expect(resolveIterationValues('sprint', ['@current', 'a'], undefined)).toEqual(['a']) + }) + + it('has no current iteration during a break', () => { + const duringBreak = new Date(2026, 9, 28, 12).getTime() + expect(resolveIterationValues('sprint', ['@current'], ctx(duringBreak))).toEqual([]) + expect(resolveIterationValues('sprint', ['@previous'], ctx(duringBreak))).toEqual(['c']) + }) + + it('filters by picked iterations and by keywords', () => { + const picked = buildFieldPredicate(sprint, { operator: 'anyOf', value: ['a', 'c'] }, ctx(now)) + expect([picked(cf('a')), picked(cf('b')), picked(cf('c')), picked(cf())]).toEqual([true, false, true, false]) + const current = buildFieldPredicate(sprint, { operator: 'anyOf', value: ['@current'] }, ctx(now)) + expect([current(cf('a')), current(cf('b')), current(cf())]).toEqual([false, true, false]) + // A keyword with nothing behind it matches nothing + const none = buildFieldPredicate(sprint, { operator: 'anyOf', value: ['@current'] }, ctx(new Date(2027, 0, 1).getTime())) + expect(none(cf('b'))).toBe(false) + const empty = buildFieldPredicate(sprint, { operator: 'isEmpty' }, ctx(now)) + expect([empty(cf('a')), empty(cf())]).toEqual([false, true]) + }) + + it('combines with other rules through the registry predicate', () => { + const rules: CustomFieldFilter[] = [{ id: '1', fieldKey: 'sprint', operator: 'anyOf', value: ['@next'] }] + const predicate = buildFiltersPredicate(new Map([['sprint', sprint]]), rules, ctx(now)) + expect(predicate({ customFields: cf('c') })).toBe(true) + expect(predicate({ customFields: cf('b') })).toBe(false) + }) + + it('sorts by the start of the iteration with empty values last', () => { + const items = [ + { id: 'x1', customFields: cf('c') }, + { id: 'x2', customFields: cf() }, + { id: 'x3', customFields: cf('a') }, + { id: 'x4', customFields: cf('gone') }, + { id: 'x5', customFields: cf('b') } + ] + const asc = [...items].sort(buildFieldComparator(sprint, 1, ctx(now))).map((i) => i.id) + expect(asc).toEqual(['x3', 'x5', 'x1', 'x4', 'x2']) + const desc = [...items].sort(buildFieldComparator(sprint, -1, ctx(now))).map((i) => i.id) + expect(desc.slice(-1)).toEqual(['x2']) + expect(desc.slice(0, 4)).toEqual(['x4', 'x1', 'x5', 'x3']) + }) +}) diff --git a/plugins/tracker-resources/src/projectFields/__tests__/registry.test.ts b/plugins/tracker-resources/src/projectFields/__tests__/registry.test.ts new file mode 100644 index 0000000000..d22f4dc478 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/__tests__/registry.test.ts @@ -0,0 +1,50 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import { ProjectFieldType, type ProjectField } from '@hcengineering/tracker' +import { buildRegistry, computeMove, mergeCustomFieldValue, nextFieldPosition, readIssueValue, resolveField } from '../registry' + +function f (id: string, key: string, position: number, type = ProjectFieldType.Text): ProjectField { + return { _id: id, key, position, type, label: key } as unknown as ProjectField +} + +describe('project field registry', () => { + const fields = [f('b', 'beta', 1), f('a', 'alpha', 0), f('c', 'points', 2, ProjectFieldType.Number)] + + it('sorts and resolves by key', () => { + const reg = buildRegistry(fields) + expect(reg.fields.map((x) => x.key)).toEqual(['alpha', 'beta', 'points']) + expect(resolveField(reg, 'beta')?._id).toBe('b') + expect(resolveField(reg, 'zzz')).toBeUndefined() + }) + + it('computes next position', () => { + expect(nextFieldPosition([])).toBe(0) + expect(nextFieldPosition(fields)).toBe(3) + }) + + it('moves fields and ignores out-of-range moves', () => { + expect(computeMove(fields, 'a' as Ref, -1)).toEqual([]) + expect(computeMove(fields, 'c' as Ref, 1)).toEqual([]) + expect(computeMove(fields, 'a' as Ref, 1)).toEqual([ + { id: 'b', position: 0 }, + { id: 'a', position: 1 } + ]) + }) + + it('merges values and removes empty ones', () => { + expect(mergeCustomFieldValue({ x: 1 }, 'y', 'v')).toEqual({ x: 1, y: 'v' }) + expect(mergeCustomFieldValue({ x: 1, y: 'v' }, 'y', null)).toEqual({ x: 1 }) + expect(mergeCustomFieldValue(undefined, 'y', [])).toEqual({}) + }) + + it('reads normalized issue values', () => { + const reg = buildRegistry(fields) + expect(readIssueValue(reg, { points: 5 }, 'points')).toBe(5) + expect(readIssueValue(reg, { points: 'x' }, 'points')).toBeNull() + expect(readIssueValue(reg, {}, 'missing')).toBeNull() + }) +}) diff --git a/plugins/tracker-resources/src/projectFields/actions.ts b/plugins/tracker-resources/src/projectFields/actions.ts new file mode 100644 index 0000000000..ee6ea56a65 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/actions.ts @@ -0,0 +1,30 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { TxOperations } from '@hcengineering/core' +import type { Issue, ProjectFieldValue } from '@hcengineering/tracker' + +import { mergeCustomFieldValue } from './registry' + +/** + * Store one custom field value on an issue. An empty value removes the key. + */ +export async function setIssueCustomFieldValue ( + client: TxOperations, + issue: Pick, + key: string, + value: ProjectFieldValue +): Promise { + const customFields = mergeCustomFieldValue(issue.customFields, key, value) + await client.updateCollection( + issue._class, + issue.space, + issue._id, + issue.attachedTo, + issue.attachedToClass, + issue.collection, + { customFields } + ) +} diff --git a/plugins/tracker-resources/src/projectFields/customFieldView.ts b/plugins/tracker-resources/src/projectFields/customFieldView.ts new file mode 100644 index 0000000000..4422028925 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/customFieldView.ts @@ -0,0 +1,202 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { SortingOrder, type CategoryType, type Doc, type Ref } from '@hcengineering/core' +import type { Iteration, Project, ProjectField } from '@hcengineering/tracker' +import { buildIterationGroupCategories, ProjectFieldType } from '@hcengineering/tracker' +import type { ClientGroupSummary, ClientHierarchy, ClientViewExtension } from '@hcengineering/view-resources' +import { writable, type Writable } from 'svelte/store' + +import IterationGroupExtras from '../iterations/IterationGroupExtras.svelte' +import IterationGroupHeader from '../iterations/IterationGroupHeader.svelte' +import { iterationsByFieldKey } from '../iterations/iterationsStore' +import tracker from '../plugin' +import CustomFieldGroupHeader from './CustomFieldGroupHeader.svelte' +import { + buildFieldComparator, + buildGroupCategories, + CUSTOM_FIELD_KEY_PREFIX, + isGroupableType, + isSortableType, + parseCustomFieldViewKey, + toCustomFieldViewKey, + type CustomFieldFilter, + type IterationContext +} from './query' +import type { ProjectFieldRegistry } from './registry' + +export interface CustomFieldViewParams { + registry: ProjectFieldRegistry + scanLimit: number + // Custom-field sort/group are switched off, e.g. because the view holds more issues than the scan limit + disabled: boolean + // "No " group labels by field key + emptyLabels: ReadonlyMap + // Iterations of the Iteration fields; empty when the project has none + iterations?: readonly Iteration[] + // Timestamp that `current` refers to; defaults to the time of the call + now?: number + // Nests sub-issues under their parents in lists whose view options turn it on + hierarchy?: ClientHierarchy + // Totals in the group headers of lists whose view options turn it on + groupSummary?: ClientGroupSummary + // Identity of the displayed saved view; lists that group on several levels keep their collapsed groups under it + groupStateScope?: string +} + +type IssueLike = Doc & { customFields?: Record } + +function headerPresenterFor (field: ProjectField, iterations: readonly Iteration[]): any { + if (field.type === ProjectFieldType.Iteration) { + const own = iterations.filter((it) => it.field === field._id) + return class extends IterationGroupHeader { + constructor (opts: any) { + super({ ...opts, props: { ...opts.props, iterations: own } }) + } + } + } + const options = field.options ?? [] + // The list renders the header as `new Presenter({ props: { value, ... } })`, so the options are bound here + return class extends CustomFieldGroupHeader { + constructor (opts: any) { + super({ ...opts, props: { ...opts.props, options } }) + } + } +} + +/** + * View extension that exposes the custom fields of a project to the issue list as optional columns + * and as group-by / order-by keys. Grouping and ordering are evaluated on the client (plan D1). + */ +export function createCustomFieldViewExtension (params: CustomFieldViewParams): ClientViewExtension { + const { registry, scanLimit, disabled, emptyLabels } = params + const iterations = params.iterations ?? [] + const byKey = iterationsByFieldKey(iterations, registry.fields) + const iterationContext: IterationContext = { + iterations: (fieldKey) => byKey.get(fieldKey) ?? [], + now: params.now ?? Date.now() + } + const extras = new Map() + const fieldOf = (key: string): ProjectField | undefined => { + const fieldKey = parseCustomFieldViewKey(key) + return fieldKey === undefined ? undefined : registry.byKey.get(fieldKey) + } + const headers = new Map() + + return { + // Any key of this form is ours, even if its field does not exist (any more), so a saved + // group/order choice can never reach the server as an unknown attribute + handlesKey: (key) => key.startsWith(CUSTOM_FIELD_KEY_PREFIX), + groupByKeys: () => + registry.fields + .filter((f) => isGroupableType(f.type)) + .map((f) => ({ id: toCustomFieldViewKey(f.key), label: f.label })), + orderByKeys: () => + registry.fields + .filter((f) => isSortableType(f.type)) + .map((f) => ({ id: toCustomFieldViewKey(f.key), label: f.label })), + columns: () => + registry.fields + .map((f) => ({ + key: { + key: '', + presenter: tracker.component.CustomFieldColumn, + props: { fieldKey: f.key }, + displayProps: { key: `cf_${f.key}`, optional: true } + }, + label: f.label + })), + projectionKey: () => 'customFields', + // Totals in the header of an iteration group need the status and the estimation + extraProjection: (key) => (fieldOf(key)?.type === ProjectFieldType.Iteration ? ['status', 'estimation'] : []), + getCategories: (key, docs, viewOptions): CategoryType[] => { + const field = fieldOf(key) + if (field === undefined || disabled || !isGroupableType(field.type)) return [undefined] + if (field.type === ProjectFieldType.Iteration) { + return buildIterationGroupCategories( + byKey.get(field.key) ?? [], + docs as IssueLike[], + field.key, + viewOptions.shouldShowAll === true + ) + } + return buildGroupCategories(field, docs as IssueLike[], viewOptions.shouldShowAll === true) + }, + getGroupHeader: (key) => { + const field = fieldOf(key) + if (field === undefined) return undefined + let presenter = headers.get(key) + if (presenter === undefined) { + presenter = headerPresenterFor(field, iterations) + headers.set(key, presenter) + } + return presenter + }, + getGroupExtras: (key) => { + const field = fieldOf(key) + if (field === undefined || field.type !== ProjectFieldType.Iteration) return undefined + let component = extras.get(key) + if (component === undefined) { + const own = byKey.get(field.key) ?? [] + // The list renders the extras as `new Component({ props: { value, docs, space } })` + component = class extends IterationGroupExtras { + constructor (opts: any) { + super({ ...opts, props: { ...opts.props, field, iterations: own } }) + } + } + extras.set(key, component) + } + return component + }, + emptyGroupLabel: (key) => { + const fieldKey = parseCustomFieldViewKey(key) + return fieldKey === undefined ? undefined : emptyLabels.get(fieldKey) + }, + compare: (key, order: SortingOrder) => { + const field = fieldOf(key) + if (field === undefined || disabled || !isSortableType(field.type)) return undefined + const cmp = buildFieldComparator(field, order === SortingOrder.Ascending ? 1 : -1, iterationContext) + return (a, b) => cmp(a as IssueLike, b as IssueLike) + }, + scanLimit, + hierarchy: params.hierarchy, + groupSummary: params.groupSummary, + groupStateScope: params.groupStateScope + } +} + +const filterStores = new Map, Writable>() +const storageKey = (project: Ref): string => `tracker.customFieldFilters.${project}` + +function loadFilters (project: Ref): CustomFieldFilter[] { + try { + const raw = localStorage.getItem(storageKey(project)) + const parsed = raw !== null ? JSON.parse(raw) : [] + return Array.isArray(parsed) ? parsed.filter((f) => typeof f?.fieldKey === 'string') : [] + } catch { + return [] + } +} + +/** + * Custom-field filter rules of a project view. Kept for the session and mirrored to local storage + * so that a reload does not silently drop the filter. + */ +export function customFieldFilterStore (project: Ref): Writable { + let store = filterStores.get(project) + if (store === undefined) { + store = writable(loadFilters(project)) + store.subscribe((value) => { + try { + if (value.length === 0) localStorage.removeItem(storageKey(project)) + else localStorage.setItem(storageKey(project), JSON.stringify(value)) + } catch { + // Storage can be unavailable (private mode, quota); the in-memory state still works + } + }) + filterStores.set(project, store) + } + return store +} diff --git a/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts b/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts new file mode 100644 index 0000000000..a784122c05 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/projectFieldsStore.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createQuery } from '@hcengineering/presentation' +import type { Project } from '@hcengineering/tracker' +import type { Ref } from '@hcengineering/core' +import { readable, type Readable } from 'svelte/store' + +import tracker from '../plugin' +import { buildRegistry, type ProjectFieldRegistry } from './registry' + +/** + * Live registry of the fields defined in a project. The query is started on first + * subscription and stopped when the last subscriber leaves. + */ +export function projectFieldsStore (project: Ref): Readable { + return readable(buildRegistry([]), (set) => { + const query = createQuery(true) + query.query(tracker.class.ProjectField, { space: project }, (result) => { + set(buildRegistry(result)) + }) + return () => { + query.unsubscribe() + } + }) +} + +const sharedStores = new Map, Readable>() + +/** + * Same as `projectFieldsStore`, but one store (and so one live query) per project is shared by all + * callers. Use it where many components need the registry at once, e.g. one cell per list row. + */ +export function sharedProjectFieldsStore (project: Ref): Readable { + let store = sharedStores.get(project) + if (store === undefined) { + store = projectFieldsStore(project) + sharedStores.set(project, store) + } + return store +} diff --git a/plugins/tracker-resources/src/projectFields/query.ts b/plugins/tracker-resources/src/projectFields/query.ts new file mode 100644 index 0000000000..f4fb09f719 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/query.ts @@ -0,0 +1,388 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Iteration, ProjectField } from '@hcengineering/tracker' +import { getFieldValue, ProjectFieldType, resolveRelativeIteration } from '@hcengineering/tracker' + +/** + * Pure helpers for filtering, sorting and grouping issues by custom field values. + * Values live in an untyped `Issue.customFields` record (plan D1), so all of this runs on + * the client over a bounded set of issues, see `DEFAULT_CUSTOM_FIELD_SCAN_LIMIT`. + */ + +/** Prefix of the view-option keys (groupBy / orderBy) that address a custom field. */ +export const CUSTOM_FIELD_KEY_PREFIX = 'customFields.' + +/** Default value of the `TRACKER_CUSTOM_FIELD_SCAN_LIMIT` setting. */ +export const DEFAULT_CUSTOM_FIELD_SCAN_LIMIT = 5000 + +/** + * Normalize a configured scan limit. Anything that is not a positive integer falls back to the default. + */ +export function resolveScanLimit (raw: unknown): number { + const n = typeof raw === 'string' ? Number(raw) : raw + return typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : DEFAULT_CUSTOM_FIELD_SCAN_LIMIT +} + +/** + * Custom-field filter/sort/group works on a scanned window of `limit` issues. When the scan returned + * more than that, the features must be disabled instead of silently working on a truncated set. + * `scanned` is the size of a scan that was capped at `limit + 1`. + */ +export function exceedsScanLimit (scanned: number, limit: number): boolean { + return scanned > limit +} + +export function toCustomFieldViewKey (fieldKey: string): string { + return `${CUSTOM_FIELD_KEY_PREFIX}${fieldKey}` +} + +/** + * Field key addressed by a view-option key, or undefined for regular attributes. + */ +export function parseCustomFieldViewKey (key: string): string | undefined { + return key.startsWith(CUSTOM_FIELD_KEY_PREFIX) && key.length > CUSTOM_FIELD_KEY_PREFIX.length + ? key.slice(CUSTOM_FIELD_KEY_PREFIX.length) + : undefined +} + +/** @public */ +export type FieldFilterOperator = + | 'contains' + | 'eq' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'between' + | 'before' + | 'after' + | 'anyOf' + | 'isEmpty' + | 'isNotEmpty' + +export interface FieldRange { + from?: number + to?: number +} + +/** + * What an Iteration field needs to be filtered or sorted: its iterations (by field key) and what `now` is. + */ +export interface IterationContext { + iterations: (fieldKey: string) => ReadonlyArray> + now: number +} + +// An iteration is picked by id or relative to today: `@current`, `@next`, `@previous`, optionally `+N` / `-N` +const ITERATION_TOKEN = /^@(current|next|previous)(?:([+-])(\d+))?$/ + +/** + * Keywords offered next to the iterations of a field in a filter. + */ +export const ITERATION_KEYWORDS = ['@current', '@next', '@previous'] as const + +export type FieldFilterValue = string | number | string[] | FieldRange | undefined + +/** + * One rule of the custom-field filter. Rules are combined with AND. + */ +export interface CustomFieldFilter { + id: string + fieldKey: string + operator: FieldFilterOperator + value?: FieldFilterValue +} + +const EMPTY_OPERATORS: FieldFilterOperator[] = ['isEmpty', 'isNotEmpty'] + +/** + * Operators offered for a field type. The first one is the default. + */ +export function operatorsFor (type: ProjectFieldType): FieldFilterOperator[] { + switch (type) { + case ProjectFieldType.Text: + return ['contains', ...EMPTY_OPERATORS] + case ProjectFieldType.Number: + return ['eq', 'gt', 'gte', 'lt', 'lte', 'between', ...EMPTY_OPERATORS] + case ProjectFieldType.Date: + return ['before', 'after', 'between', ...EMPTY_OPERATORS] + case ProjectFieldType.SingleSelect: + case ProjectFieldType.MultiSelect: + case ProjectFieldType.Iteration: + return ['anyOf', ...EMPTY_OPERATORS] + default: + return [] + } +} + +/** Field types that can be filtered on. */ +export function isFilterableType (type: ProjectFieldType): boolean { + return operatorsFor(type).length > 0 +} + +/** Field types that can be sorted on. Multi-select has no meaningful order (GitHub parity). */ +export function isSortableType (type: ProjectFieldType): boolean { + return ( + type === ProjectFieldType.Text || + type === ProjectFieldType.Number || + type === ProjectFieldType.Date || + type === ProjectFieldType.SingleSelect || + type === ProjectFieldType.Iteration + ) +} + +/** Only single-select and iteration fields can be grouped by (GitHub parity). */ +export function isGroupableType (type: ProjectFieldType): boolean { + return type === ProjectFieldType.SingleSelect || type === ProjectFieldType.Iteration +} + +function isRange (value: FieldFilterValue): value is FieldRange { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * A rule without a usable value is a draft the user is still editing; it must not affect the result. + */ +export function isFilterComplete (filter: Pick): boolean { + if (EMPTY_OPERATORS.includes(filter.operator)) return true + const { value } = filter + switch (filter.operator) { + case 'contains': + return typeof value === 'string' && value.trim() !== '' + case 'eq': + case 'gt': + case 'gte': + case 'lt': + case 'lte': + case 'before': + case 'after': + return typeof value === 'number' && Number.isFinite(value) + case 'between': + return isRange(value) && (typeof value.from === 'number' || typeof value.to === 'number') + case 'anyOf': + return Array.isArray(value) && value.length > 0 + default: + return false + } +} + +/** Start of the local calendar day containing the timestamp. */ +export function startOfDay (ts: number): number { + return new Date(ts).setHours(0, 0, 0, 0) +} + +/** Last millisecond of the local calendar day containing the timestamp. */ +export function endOfDay (ts: number): number { + return new Date(ts).setHours(23, 59, 59, 999) +} + +function isEmptyValue (value: unknown): boolean { + return value === null || value === undefined || (Array.isArray(value) && value.length === 0) +} + +export type CustomFieldsRecord = Record | undefined + +/** + * Iteration ids a list of picked ids and relative keywords stands for. A keyword that has no iteration + * (no current iteration, say) stands for nothing. + */ +export function resolveIterationValues ( + fieldKey: string, + values: readonly string[], + context: IterationContext | undefined +): string[] { + const res: string[] = [] + for (const v of values) { + const m = ITERATION_TOKEN.exec(v) + if (m === null) { + res.push(v) + continue + } + if (context === undefined) continue + const offset = m[2] === undefined ? 0 : Number(m[3]) * (m[2] === '-' ? -1 : 1) + const target = resolveRelativeIteration( + context.iterations(fieldKey), + m[1] as 'current' | 'next' | 'previous', + offset, + context.now + ) + if (target !== undefined) res.push(target._id) + } + return res +} + +/** + * Build a predicate over `Issue.customFields` for one filter rule. + * Incomplete rules match everything. Date bounds are whole calendar days: "before D" excludes D, + * "after D" starts the day after D, and a range includes both end days. + */ +export function buildFieldPredicate ( + field: Pick, + filter: Pick, + iterationContext?: IterationContext +): (customFields: CustomFieldsRecord) => boolean { + if (!isFilterComplete(filter)) return () => true + const read = (cf: CustomFieldsRecord): ReturnType => getFieldValue(cf, field) + const { operator, value } = filter + + if (operator === 'isEmpty') return (cf) => isEmptyValue(read(cf)) + if (operator === 'isNotEmpty') return (cf) => !isEmptyValue(read(cf)) + + if (operator === 'contains') { + const needle = (value as string).trim().toLowerCase() + return (cf) => { + const v = read(cf) + return typeof v === 'string' && v.toLowerCase().includes(needle) + } + } + + if (operator === 'anyOf') { + const wanted = new Set( + field.type === ProjectFieldType.Iteration + ? resolveIterationValues(field.key, value as string[], iterationContext) + : (value as string[]) + ) + return (cf) => { + const v = read(cf) + if (Array.isArray(v)) return v.some((x) => wanted.has(x)) + return typeof v === 'string' && wanted.has(v) + } + } + + const isDate = field.type === ProjectFieldType.Date + + if (operator === 'between') { + const range = value as FieldRange + const from = isDate && range.from !== undefined ? startOfDay(range.from) : range.from + const to = isDate && range.to !== undefined ? endOfDay(range.to) : range.to + return (cf) => { + const v = read(cf) + if (typeof v !== 'number') return false + return (from === undefined || v >= from) && (to === undefined || v <= to) + } + } + + const bound = value as number + const dayStart = isDate ? startOfDay(bound) : bound + const dayEnd = isDate ? endOfDay(bound) : bound + const compare = (check: (v: number) => boolean) => (cf: CustomFieldsRecord) => { + const v = read(cf) + return typeof v === 'number' && check(v) + } + switch (operator) { + case 'eq': + return compare((v) => v === bound) + case 'gt': + return compare((v) => v > bound) + case 'after': + return compare((v) => v > dayEnd) + case 'gte': + return compare((v) => v >= bound) + case 'lt': + return compare((v) => v < bound) + case 'before': + return compare((v) => v < dayStart) + case 'lte': + return compare((v) => v <= bound) + default: + return () => true + } +} + +/** + * Combine all rules with AND. A rule that refers to a field that no longer exists matches nothing, + * so a stale rule is never silently ignored. + */ +export function buildFiltersPredicate ( + fieldsByKey: ReadonlyMap>, + filters: readonly CustomFieldFilter[], + iterationContext?: IterationContext +): (issue: { customFields?: Record }) => boolean { + const predicates = filters + .filter((f) => isFilterComplete(f)) + .map((f) => { + const field = fieldsByKey.get(f.fieldKey) + return field === undefined ? () => false : buildFieldPredicate(field, f, iterationContext) + }) + if (predicates.length === 0) return () => true + return (issue) => predicates.every((p) => p(issue.customFields)) +} + +/** Number of rules that actually restrict the result. */ +export function activeFilterCount (filters: readonly CustomFieldFilter[]): number { + return filters.filter((f) => isFilterComplete(f)).length +} + +/** Comparator for two non-empty values of the field. */ +function compareNonEmpty ( + field: Pick, + iterationContext?: IterationContext +): (a: any, b: any) => number { + switch (field.type) { + case ProjectFieldType.Iteration: { + // GitHub orders by the start of the iteration; an id no iteration answers to (deleted) goes last + const start = new Map((iterationContext?.iterations(field.key) ?? []).map((it) => [it._id as string, it.startDate])) + return (a: string, b: string) => (start.get(a) ?? Infinity) - (start.get(b) ?? Infinity) || 0 + } + case ProjectFieldType.Number: + case ProjectFieldType.Date: + return (a: number, b: number) => a - b + case ProjectFieldType.SingleSelect: { + // GitHub orders single-select values by the option order, not alphabetically + const order = new Map((field.options ?? []).map((o, i) => [o.value, i])) + return (a: string, b: string) => (order.get(a) ?? 0) - (order.get(b) ?? 0) + } + default: + return (a: string, b: string) => a.localeCompare(b) + } +} + +/** + * Comparator for issues by a custom field. `direction` is 1 (ascending) or -1 (descending). + * Empty values are always placed last, in both directions. + */ +export function buildFieldComparator ( + field: Pick, + direction: 1 | -1, + iterationContext?: IterationContext +): (a: { customFields?: Record }, b: { customFields?: Record }) => number { + const cmp = compareNonEmpty(field, iterationContext) + return (a, b) => { + const va = getFieldValue(a.customFields, field) + const vb = getFieldValue(b.customFields, field) + const ea = isEmptyValue(va) + const eb = isEmptyValue(vb) + if (ea || eb) return ea === eb ? 0 : ea ? 1 : -1 + return cmp(va, vb) * direction + } +} + +/** + * Group values (option ids) in display order for a single-select field: options in their defined + * order, followed by any stray value found in the data, and `undefined` (the "No " group) last. + * Options without issues are only listed when `includeEmpty` is set. + */ +export function buildGroupCategories ( + field: Pick, + docs: ReadonlyArray<{ customFields?: Record }>, + includeEmpty: boolean +): Array { + const used = new Set() + let hasEmpty = false + for (const doc of docs) { + const v = doc.customFields?.[field.key] + if (typeof v === 'string' && v !== '') used.add(v) + else hasEmpty = true + } + const result: Array = [] + for (const o of field.options ?? []) { + if (includeEmpty || used.has(o.value)) result.push(o.value) + used.delete(o.value) + } + result.push(...used) + if (hasEmpty || includeEmpty) result.push(undefined) + return result +} diff --git a/plugins/tracker-resources/src/projectFields/registry.ts b/plugins/tracker-resources/src/projectFields/registry.ts new file mode 100644 index 0000000000..11dc858e90 --- /dev/null +++ b/plugins/tracker-resources/src/projectFields/registry.ts @@ -0,0 +1,91 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ProjectField, ProjectFieldValue } from '@hcengineering/tracker' +import { getFieldValue } from '@hcengineering/tracker' + +/** + * Immutable lookup of a project's field definitions. + */ +export interface ProjectFieldRegistry { + // Fields ordered by position + fields: ProjectField[] + byKey: Map +} + +/** + * Order fields by position, falling back to creation time for equal positions. + */ +export function sortFields (fields: readonly ProjectField[]): ProjectField[] { + return [...fields].sort((a, b) => a.position - b.position || (a.createdOn ?? 0) - (b.createdOn ?? 0)) +} + +export function buildRegistry (fields: readonly ProjectField[]): ProjectFieldRegistry { + const sorted = sortFields(fields) + return { fields: sorted, byKey: new Map(sorted.map((f) => [f.key, f])) } +} + +/** + * Resolve a key stored in issue.customFields to its definition. + */ +export function resolveField (registry: ProjectFieldRegistry, key: string): ProjectField | undefined { + return registry.byKey.get(key) +} + +/** + * Position for a field appended after all existing ones. + */ +export function nextFieldPosition (fields: readonly ProjectField[]): number { + return fields.reduce((max, f) => Math.max(max, f.position), -1) + 1 +} + +/** + * Compute position updates that move a field one step up (-1) or down (1). + * Positions are renumbered densely so duplicates never persist. + * Returns an empty list when the move is not possible. + */ +export function computeMove ( + fields: readonly ProjectField[], + id: ProjectField['_id'], + direction: -1 | 1 +): Array<{ id: ProjectField['_id'], position: number }> { + const sorted = sortFields(fields) + const from = sorted.findIndex((f) => f._id === id) + const to = from + direction + if (from < 0 || to < 0 || to >= sorted.length) return [] + const order = [...sorted] + ;[order[from], order[to]] = [order[to], order[from]] + return order.map((f, position) => ({ id: f._id, position })).filter((u) => sorted.find((f) => f._id === u.id)?.position !== u.position) +} + +/** + * Merge a new value into a customFields record. Empty values remove the key. + */ +export function mergeCustomFieldValue ( + customFields: Record | undefined, + key: string, + value: ProjectFieldValue | undefined +): Record { + const next: Record = { ...(customFields ?? {}) } + if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[key] + } else { + next[key] = value + } + return next +} + +/** + * Read an issue's value for a field through the registry. + */ +export function readIssueValue ( + registry: ProjectFieldRegistry, + customFields: Record | undefined, + key: string +): ProjectFieldValue { + const field = resolveField(registry, key) + return field === undefined ? null : getFieldValue(customFields, field) +} diff --git a/plugins/tracker-resources/src/roadmap/__tests__/config.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/config.test.ts new file mode 100644 index 0000000000..3c47e8da1c --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/config.test.ts @@ -0,0 +1,128 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + DEFAULT_ROADMAP_CONFIG, + isDefaultConfig, + normalizeRoadmapConfig, + readRoadmapConfig, + ROADMAP_OPTION_KEY, + sanitizeConfig, + selectionOf, + withRoadmapConfig +} from '../config' +import path from 'path' + +// The saved views logic is a pure module of view-resources. The path is built at run time so that the type +// checkers do not pull view-resources sources into this package. +const { isViewDirty } = require(path.resolve(__dirname, '../../../../view-resources/src/savedViews')) as { + isViewDirty: (baseline: Record, current: Record) => boolean +} + +describe('normalizeRoadmapConfig', () => { + it('gives the defaults for nothing or garbage', () => { + expect(normalizeRoadmapConfig(undefined)).toEqual(DEFAULT_ROADMAP_CONFIG) + expect(normalizeRoadmapConfig(null)).toEqual(DEFAULT_ROADMAP_CONFIG) + expect(normalizeRoadmapConfig('x')).toEqual(DEFAULT_ROADMAP_CONFIG) + expect(normalizeRoadmapConfig({ start: 5, target: 'nope', zoom: 'week', markers: 7, fields: 'a' })).toEqual( + DEFAULT_ROADMAP_CONFIG + ) + }) + + it('keeps valid values', () => { + const config = { + start: 'field:begin', + target: 'iteration:sprint', + zoom: 'year', + markers: { milestones: true, iterations: ['sprint'], dates: ['issue:dueDate'] }, + fields: ['title', 'status'] + } + expect(normalizeRoadmapConfig(config)).toEqual(config) + }) + + it('drops damaged list entries and duplicates', () => { + const res = normalizeRoadmapConfig({ markers: { iterations: ['a', 1, '', 'a', 'b'] }, fields: ['x', 'x', 3] }) + expect(res.markers.iterations).toEqual(['a', 'b']) + expect(res.fields).toEqual(['x']) + }) + + it('does not share state with the defaults', () => { + const res = normalizeRoadmapConfig(undefined) + res.fields.push('status') + res.markers.iterations.push('x') + expect(DEFAULT_ROADMAP_CONFIG.fields).not.toContain('status') + expect(DEFAULT_ROADMAP_CONFIG.markers.iterations).toEqual([]) + }) +}) + +describe('storing the config in the view options', () => { + const options = { groupBy: ['#no_category'], orderBy: ['startDate', 1] } + + it('stores a customized config and reads it back', () => { + const config = { ...DEFAULT_ROADMAP_CONFIG, zoom: 'quarter' as const, start: 'field:begin' } + const stored = withRoadmapConfig(options, config) + expect(stored.groupBy).toEqual(options.groupBy) + expect(readRoadmapConfig(stored)).toEqual(config) + }) + + it('does not store the default config and removes a stored one that returns to it', () => { + const custom = withRoadmapConfig(options, { ...DEFAULT_ROADMAP_CONFIG, zoom: 'year' }) + expect(ROADMAP_OPTION_KEY in custom).toBe(true) + const back = withRoadmapConfig(custom, readRoadmapConfig(withRoadmapConfig({}, DEFAULT_ROADMAP_CONFIG))) + expect(ROADMAP_OPTION_KEY in back).toBe(false) + expect(back).toEqual(options) + }) + + it('does not modify the options it is given', () => { + const frozen = Object.freeze({ ...options }) + expect(() => withRoadmapConfig(frozen, { ...DEFAULT_ROADMAP_CONFIG, zoom: 'year' })).not.toThrow() + }) + + it('tells the default from a changed config', () => { + expect(isDefaultConfig(normalizeRoadmapConfig(undefined))).toBe(true) + expect(isDefaultConfig({ ...DEFAULT_ROADMAP_CONFIG, fields: ['title'] })).toBe(false) + }) + + it('makes a saved view dirty only when the roadmap settings changed', () => { + const saved = { viewOptions: withRoadmapConfig(options, DEFAULT_ROADMAP_CONFIG) as any } + expect(isViewDirty(saved, { viewOptions: withRoadmapConfig(options, DEFAULT_ROADMAP_CONFIG) as any })).toBe(false) + const zoomed = withRoadmapConfig(options, { ...DEFAULT_ROADMAP_CONFIG, zoom: 'quarter' }) + expect(isViewDirty(saved, { viewOptions: zoomed as any })).toBe(true) + // Changing the setting and changing it back is not a change + const again = withRoadmapConfig(zoomed, { ...DEFAULT_ROADMAP_CONFIG }) + expect(isViewDirty(saved, { viewOptions: again as any })).toBe(false) + }) +}) + +describe('sanitizeConfig', () => { + const available = { + sourceIds: new Set(['issue:startDate', 'issue:dueDate', 'field:begin', 'iteration:sprint']), + iterationFieldKeys: new Set(['sprint']), + labelFieldIds: new Set(['identifier', 'title', 'cf:size']) + } + + it('keeps a config that is fully available', () => { + const config = normalizeRoadmapConfig({ + start: 'field:begin', + target: 'iteration:sprint', + markers: { milestones: true, iterations: ['sprint'], dates: ['issue:dueDate'] }, + fields: ['title', 'cf:size'] + }) + expect(sanitizeConfig(config, available)).toEqual(config) + }) + + it('falls back to the default dates and drops what is gone', () => { + const config = normalizeRoadmapConfig({ + start: 'field:deleted', + target: 'iteration:deleted', + markers: { milestones: true, iterations: ['sprint', 'deleted'], dates: ['field:deleted', 'field:begin'] }, + fields: ['title', 'cf:deleted'] + }) + const res = sanitizeConfig(config, available) + expect(selectionOf(res)).toEqual({ start: DEFAULT_ROADMAP_CONFIG.start, target: DEFAULT_ROADMAP_CONFIG.target }) + expect(res.markers).toEqual({ milestones: true, iterations: ['sprint'], dates: ['field:begin'] }) + expect(res.fields).toEqual(['title']) + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/__tests__/customValue.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/customValue.test.ts new file mode 100644 index 0000000000..ffbfe6823e --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/customValue.test.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType } from '@hcengineering/tracker' +import { formatCustomValue } from '../customValue' + +const options = [ + { value: 'a', label: 'Small' }, + { value: 'b', label: 'Large' } +] + +describe('formatCustomValue', () => { + it('formats text and numbers', () => { + expect(formatCustomValue({ key: 't', type: ProjectFieldType.Text }, { t: 'hello' }, [])).toBe('hello') + expect(formatCustomValue({ key: 't', type: ProjectFieldType.Text }, { t: '' }, [])).toBeUndefined() + expect(formatCustomValue({ key: 'n', type: ProjectFieldType.Number }, { n: 5 }, [])).toBe('5') + expect(formatCustomValue({ key: 'n', type: ProjectFieldType.Number }, { n: 0 }, [])).toBe('0') + }) + + it('formats a date in the given locale', () => { + const ts = new Date(2026, 9, 3).getTime() + expect(formatCustomValue({ key: 'd', type: ProjectFieldType.Date }, { d: ts }, [], 'en')).toBe('Oct 3, 2026') + }) + + it('resolves option and iteration labels', () => { + expect(formatCustomValue({ key: 's', type: ProjectFieldType.SingleSelect, options }, { s: 'b' }, [])).toBe('Large') + expect(formatCustomValue({ key: 's', type: ProjectFieldType.SingleSelect, options }, { s: 'x' }, [])).toBeUndefined() + expect(formatCustomValue({ key: 'm', type: ProjectFieldType.MultiSelect, options }, { m: ['a', 'b'] }, [])).toBe( + 'Small, Large' + ) + expect( + formatCustomValue({ key: 'i', type: ProjectFieldType.Iteration }, { i: 'it1' }, [{ _id: 'it1', label: 'Sprint 1' }]) + ).toBe('Sprint 1') + expect(formatCustomValue({ key: 'i', type: ProjectFieldType.Iteration }, { i: 'gone' }, [])).toBeUndefined() + }) + + it('has no text without a value', () => { + expect(formatCustomValue({ key: 'n', type: ProjectFieldType.Number }, undefined, [])).toBeUndefined() + expect(formatCustomValue({ key: 'n', type: ProjectFieldType.Number }, {}, [])).toBeUndefined() + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/__tests__/dates.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/dates.test.ts new file mode 100644 index 0000000000..11bac84469 --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/dates.test.ts @@ -0,0 +1,226 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType } from '@hcengineering/tracker' +import { + buildDateSources, + iterationDays, + makeSourceId, + parseSourceId, + partitionBySchedule, + readSourceDay, + resolveSchedule, + scheduleDays, + type DateLookups +} from '../dates' +import { toDay } from '../timeScale' + +const local = (y: number, m: number, d: number, h = 0): number => new Date(y, m - 1, d, h).getTime() +const day = (y: number, m: number, d: number): number => toDay(local(y, m, d)) + +const lookups: DateLookups = { + milestone: (id) => + id === 'm1' + ? { startDate: local(2026, 11, 1), targetDate: local(2026, 11, 30) } + : id === 'm2' + ? { startDate: null, targetDate: local(2026, 12, 15) } + : undefined, + iterations: (key) => + key === 'sprint' + ? [ + { _id: 'i1', startDate: local(2026, 10, 5), duration: 14 }, + { _id: 'i2', startDate: local(2026, 10, 19), duration: 7 } + ] + : [] +} + +describe('source ids', () => { + it('round trips and rejects unknown ones', () => { + for (const ref of [ + { kind: 'issue', key: 'dueDate' }, + { kind: 'milestone', key: 'targetDate' }, + { kind: 'field', key: 'releaseDate' }, + { kind: 'iteration', key: 'sprint' } + ] as const) { + expect(parseSourceId(makeSourceId(ref))).toEqual(ref) + } + expect(parseSourceId('issue:title')).toBeUndefined() + expect(parseSourceId('milestone:label')).toBeUndefined() + expect(parseSourceId('whatever')).toBeUndefined() + expect(parseSourceId('field:')).toBeUndefined() + expect(parseSourceId('sprint:x')).toBeUndefined() + }) + + it('keeps colons inside a field key', () => { + expect(parseSourceId('field:a:b')).toEqual({ kind: 'field', key: 'a:b' }) + }) +}) + +describe('buildDateSources', () => { + const labels = { + startDate: 'Start date', + dueDate: 'Due date', + deadline: 'Deadline', + milestoneStart: 'Milestone start', + milestoneTarget: 'Milestone target' + } + + it('lists issue dates, milestone dates, then Date and Iteration fields only', () => { + const sources = buildDateSources( + [ + { key: 'release', label: 'Release', type: ProjectFieldType.Date }, + { key: 'notes', label: 'Notes', type: ProjectFieldType.Text }, + { key: 'sprint', label: 'Sprint', type: ProjectFieldType.Iteration }, + { key: 'size', label: 'Size', type: ProjectFieldType.SingleSelect } + ], + labels + ) + expect(sources.map((s) => s.id)).toEqual([ + 'issue:startDate', + 'issue:dueDate', + 'issue:deadline', + 'milestone:startDate', + 'milestone:targetDate', + 'field:release', + 'iteration:sprint' + ]) + }) + + it('marks the milestone dates as read only', () => { + const sources = buildDateSources([], labels) + expect(sources.filter((s) => !s.writable).map((s) => s.kind)).toEqual(['milestone', 'milestone']) + }) +}) + +describe('readSourceDay', () => { + it('reads built-in and custom dates', () => { + const issue = { startDate: local(2026, 10, 5, 14), dueDate: null, customFields: { release: local(2026, 12, 24) } } + expect(readSourceDay(issue, { kind: 'issue', key: 'startDate' }, 'start', lookups)).toBe(day(2026, 10, 5)) + expect(readSourceDay(issue, { kind: 'issue', key: 'dueDate' }, 'target', lookups)).toBeUndefined() + expect(readSourceDay(issue, { kind: 'field', key: 'release' }, 'target', lookups)).toBe(day(2026, 12, 24)) + expect(readSourceDay(issue, { kind: 'field', key: 'other' }, 'target', lookups)).toBeUndefined() + }) + + it('ignores values that are not timestamps', () => { + const issue = { customFields: { release: 'soon', nan: Number.NaN } } + expect(readSourceDay(issue, { kind: 'field', key: 'release' }, 'start', lookups)).toBeUndefined() + expect(readSourceDay(issue, { kind: 'field', key: 'nan' }, 'start', lookups)).toBeUndefined() + }) + + it('reads the dates of the milestone of the issue', () => { + expect(readSourceDay({ milestone: 'm1' }, { kind: 'milestone', key: 'startDate' }, 'start', lookups)).toBe(day(2026, 11, 1)) + expect(readSourceDay({ milestone: 'm1' }, { kind: 'milestone', key: 'targetDate' }, 'target', lookups)).toBe( + day(2026, 11, 30) + ) + // An open-ended milestone has no start + expect(readSourceDay({ milestone: 'm2' }, { kind: 'milestone', key: 'startDate' }, 'start', lookups)).toBeUndefined() + expect(readSourceDay({ milestone: 'gone' }, { kind: 'milestone', key: 'targetDate' }, 'target', lookups)).toBeUndefined() + expect(readSourceDay({ milestone: null }, { kind: 'milestone', key: 'targetDate' }, 'target', lookups)).toBeUndefined() + }) + + it('gives the first day of the iteration as start and the last day as target', () => { + const issue = { customFields: { sprint: 'i1' } } + const ref = { kind: 'iteration', key: 'sprint' } as const + expect(readSourceDay(issue, ref, 'start', lookups)).toBe(day(2026, 10, 5)) + // 14 days from Oct 5 end on Oct 18 + expect(readSourceDay(issue, ref, 'target', lookups)).toBe(day(2026, 10, 18)) + }) + + it('has no value for an unknown iteration', () => { + const ref = { kind: 'iteration', key: 'sprint' } as const + expect(readSourceDay({ customFields: { sprint: 'gone' } }, ref, 'start', lookups)).toBeUndefined() + expect(readSourceDay({}, ref, 'start', lookups)).toBeUndefined() + }) + + it('counts a one day iteration as one day', () => { + expect(iterationDays({ startDate: local(2026, 10, 5), duration: 1 })).toEqual({ + start: day(2026, 10, 5), + end: day(2026, 10, 5) + }) + }) +}) + +describe('resolveSchedule', () => { + const selection = { start: 'issue:startDate', target: 'issue:dueDate' } + + it('is a range when both dates are known', () => { + const s = resolveSchedule({ startDate: local(2026, 10, 5), dueDate: local(2026, 10, 9) }, selection, lookups) + expect(s).toEqual({ kind: 'range', start: day(2026, 10, 5), target: day(2026, 10, 9), inverted: false }) + }) + + it('is a one day range when both dates are the same day', () => { + const s = resolveSchedule({ startDate: local(2026, 10, 5, 9), dueDate: local(2026, 10, 5, 18) }, selection, lookups) + expect(s).toEqual({ kind: 'range', start: day(2026, 10, 5), target: day(2026, 10, 5), inverted: false }) + }) + + it('is a marker when only one date is known', () => { + expect(resolveSchedule({ startDate: local(2026, 10, 5) }, selection, lookups)).toEqual({ + kind: 'marker', + day: day(2026, 10, 5), + role: 'start' + }) + expect(resolveSchedule({ startDate: null, dueDate: local(2026, 10, 9) }, selection, lookups)).toEqual({ + kind: 'marker', + day: day(2026, 10, 9), + role: 'target' + }) + }) + + it('is unscheduled without dates', () => { + expect(resolveSchedule({ startDate: null, dueDate: null }, selection, lookups)).toEqual({ kind: 'unscheduled' }) + expect(resolveSchedule({}, { start: 'junk', target: 'junk2' }, lookups)).toEqual({ kind: 'unscheduled' }) + }) + + it('draws a start after the target from the earlier to the later day and flags it', () => { + const s = resolveSchedule({ startDate: local(2026, 10, 9), dueDate: local(2026, 10, 5) }, selection, lookups) + expect(s).toEqual({ kind: 'range', start: day(2026, 10, 5), target: day(2026, 10, 9), inverted: true }) + }) + + it('spans an iteration field used for both dates', () => { + const sel = { start: 'iteration:sprint', target: 'iteration:sprint' } + const s = resolveSchedule({ customFields: { sprint: 'i2' } }, sel, lookups) + expect(s).toEqual({ kind: 'range', start: day(2026, 10, 19), target: day(2026, 10, 25), inverted: false }) + }) + + it('mixes a date field with an iteration', () => { + const sel = { start: 'iteration:sprint', target: 'field:release' } + const s = resolveSchedule({ customFields: { sprint: 'i1', release: local(2026, 11, 20) } }, sel, lookups) + expect(s).toEqual({ kind: 'range', start: day(2026, 10, 5), target: day(2026, 11, 20), inverted: false }) + }) + + it('takes the dates of the milestone', () => { + const sel = { start: 'milestone:startDate', target: 'milestone:targetDate' } + expect(resolveSchedule({ milestone: 'm1' }, sel, lookups)).toMatchObject({ kind: 'range', start: day(2026, 11, 1) }) + // Open-ended milestone: only the target is known + expect(resolveSchedule({ milestone: 'm2' }, sel, lookups)).toEqual({ + kind: 'marker', + day: day(2026, 12, 15), + role: 'target' + }) + expect(resolveSchedule({}, sel, lookups)).toEqual({ kind: 'unscheduled' }) + }) +}) + +describe('schedule helpers', () => { + it('lists the days of a schedule', () => { + expect(scheduleDays({ kind: 'range', start: 1, target: 5, inverted: false })).toEqual([1, 5]) + expect(scheduleDays({ kind: 'marker', day: 3, role: 'start' })).toEqual([3]) + expect(scheduleDays({ kind: 'unscheduled' })).toEqual([]) + }) + + it('splits scheduled from unscheduled items and keeps their order', () => { + const items = [ + { id: 'a', dueDate: 1 }, + { id: 'b' }, + { id: 'c', dueDate: 2 }, + { id: 'd' } + ] + const split = partitionBySchedule(items, (it) => + it.dueDate !== undefined ? { kind: 'marker', day: it.dueDate, role: 'target' } : { kind: 'unscheduled' } + ) + expect(split.scheduled.map((i) => i.id)).toEqual(['a', 'c']) + expect(split.unscheduled.map((i) => i.id)).toEqual(['b', 'd']) + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/__tests__/labelGrouping.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/labelGrouping.test.ts new file mode 100644 index 0000000000..43a455966b --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/labelGrouping.test.ts @@ -0,0 +1,82 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + buildItemLabel, + customLabelFieldId, + LABEL_SEPARATOR, + parseCustomLabelFieldId, + toggleLabelField, + type LabelResolvers +} from '../label' + +interface Item { + identifier?: string + title: string + estimation?: number + status?: string + assignee?: string + tags?: string[] + custom?: Record +} + +const resolvers: LabelResolvers = { + status: (i) => i.status, + assignee: (i) => i.assignee, + priority: () => 'High', + component: () => undefined, + milestone: () => undefined, + labels: (i) => i.tags ?? [], + custom: (i, key) => i.custom?.[key] +} + +describe('buildItemLabel', () => { + const issue: Item = { + identifier: 'TSK-4', + title: 'Fix login', + estimation: 3, + status: 'In Progress', + assignee: 'Ann', + tags: ['bug', 'ui'], + custom: { size: 'L' } + } + + it('joins the chosen fields in the order given', () => { + expect(buildItemLabel(issue, ['identifier', 'title'], resolvers)).toBe(`TSK-4${LABEL_SEPARATOR}Fix login`) + expect(buildItemLabel(issue, ['title', 'identifier'], resolvers)).toBe(`Fix login${LABEL_SEPARATOR}TSK-4`) + }) + + it('renders every kind of field', () => { + const text = buildItemLabel( + issue, + ['status', 'assignee', 'priority', 'labels', 'estimation', customLabelFieldId('size')], + resolvers + ) + expect(text).toBe(['In Progress', 'Ann', 'High', 'bug, ui', '3 h', 'L'].join(LABEL_SEPARATOR)) + }) + + it('leaves out fields without a value and unknown fields', () => { + expect(buildItemLabel({ title: 'x' }, ['identifier', 'component', 'estimation', 'labels', 'bogus', 'cf:nope'], resolvers)).toBe('') + expect(buildItemLabel({ title: 'x', estimation: 0 }, ['title', 'estimation'], resolvers)).toBe('x') + }) + + it('is empty without fields', () => { + expect(buildItemLabel(issue, [], resolvers)).toBe('') + }) +}) + +describe('label field ids', () => { + it('round trips custom field ids', () => { + expect(parseCustomLabelFieldId(customLabelFieldId('size'))).toBe('size') + expect(parseCustomLabelFieldId('title')).toBeUndefined() + }) + + it('toggles a field and keeps the canonical order', () => { + const all = ['identifier', 'title', 'status', 'assignee'] + expect(toggleLabelField(['title'], 'identifier', all)).toEqual(['identifier', 'title']) + expect(toggleLabelField(['identifier', 'title'], 'identifier', all)).toEqual(['title']) + expect(toggleLabelField([], 'assignee', all)).toEqual(['assignee']) + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/__tests__/layout.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/layout.test.ts new file mode 100644 index 0000000000..70b31a5397 --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/layout.test.ts @@ -0,0 +1,234 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + buildRows, + estimateTextWidth, + GROUP_ROW_HEIGHT, + itemShape, + MARKER_SIZE, + MIN_BAR_WIDTH, + placeLabel, + ROW_HEIGHT, + UNSCHEDULED_ID, + visibleRowRange +} from '../layout' +import { createScale } from '../timeScale' + +const scale = createScale('month', 100, 500) // 8 px per day + +describe('itemShape', () => { + it('draws a range from the start of its first day to the end of its last day', () => { + const shape = itemShape({ kind: 'range', start: 110, target: 112, inverted: false }, scale) + expect(shape).toEqual({ kind: 'bar', x: 80, width: 24, center: 92 }) + }) + + it('draws a one day item one day wide', () => { + const shape = itemShape({ kind: 'range', start: 110, target: 110, inverted: false }, scale) + expect(shape?.width).toBe(8) + }) + + it('keeps a bar grabbable at the year zoom', () => { + const year = createScale('year', 0, 1000) + const shape = itemShape({ kind: 'range', start: 10, target: 10, inverted: false }, year) + expect(shape?.width).toBe(MIN_BAR_WIDTH) + }) + + it('draws a marker in the middle of its day', () => { + const shape = itemShape({ kind: 'marker', day: 110, role: 'target' }, scale) + expect(shape).toEqual({ kind: 'marker', x: 84 - MARKER_SIZE / 2, width: MARKER_SIZE, center: 84 }) + }) + + it('has no shape without dates', () => { + expect(itemShape({ kind: 'unscheduled' }, scale)).toBeUndefined() + }) +}) + +describe('placeLabel', () => { + const bar = { kind: 'bar', x: 100, width: 200, center: 200 } as const + + it('puts the text inside a bar that is wide enough', () => { + expect(placeLabel(bar, 150, 1000)).toBe('inside') + }) + + it('puts it to the right of a narrow bar, to the left when there is no room on the right', () => { + expect(placeLabel(bar, 300, 1000)).toBe('right') + expect(placeLabel({ kind: 'bar', x: 400, width: 200, center: 500 }, 300, 650)).toBe('left') + }) + + it('does not put text inside a marker', () => { + expect(placeLabel({ kind: 'marker', x: 10, width: 12, center: 16 }, 1, 1000)).toBe('right') + }) + + it('falls back to the right when there is room nowhere', () => { + expect(placeLabel({ kind: 'bar', x: 10, width: 10, center: 15 }, 500, 100)).toBe('right') + }) + + it('estimates the width of a text', () => { + expect(estimateTextWidth('abcd', 5)).toBe(20) + }) +}) + +describe('buildRows', () => { + const idOf = (it: { id: string }): string => it.id + const item = (id: string): { id: string } => ({ id }) + + it('lists the items one under the other without group headers', () => { + const layout = buildRows({ + groups: [{ id: 'g', items: [item('a'), item('b')] }], + unscheduled: [], + idOf, + collapsed: new Set(), + showGroupHeaders: false + }) + expect(layout.rows.map((r) => [r.type, r.y])).toEqual([ + ['item', 0], + ['item', ROW_HEIGHT] + ]) + expect(layout.height).toBe(2 * ROW_HEIGHT) + }) + + it('adds a header per group and hides the items of a collapsed group', () => { + const layout = buildRows({ + groups: [ + { id: 'g1', items: [item('a'), item('b')] }, + { id: 'g2', items: [item('c')] } + ], + unscheduled: [], + idOf, + collapsed: new Set(['g1']), + showGroupHeaders: true + }) + expect(layout.rows.map((r) => (r.type === 'group' ? `group:${r.id}:${r.count}:${r.collapsed}` : r.id))).toEqual([ + 'group:g1:2:true', + 'group:g2:1:false', + 'c' + ]) + expect(layout.height).toBe(2 * GROUP_ROW_HEIGHT + ROW_HEIGHT) + }) + + it('lists items without dates in their own section at the end', () => { + const layout = buildRows({ + groups: [{ id: 'g', items: [item('a')] }], + unscheduled: [item('x'), item('y')], + idOf, + collapsed: new Set(), + showGroupHeaders: false + }) + expect(layout.rows.map((r) => r.type)).toEqual(['item', 'unscheduled', 'item', 'item']) + const unscheduled = layout.rows.filter((r) => r.type === 'item' && r.unscheduled) + expect(unscheduled).toHaveLength(2) + expect(layout.rows[1]).toMatchObject({ id: UNSCHEDULED_ID, count: 2, collapsed: false }) + }) + + it('collapses the unscheduled section', () => { + const layout = buildRows({ + groups: [], + unscheduled: [item('x')], + idOf, + collapsed: new Set([UNSCHEDULED_ID]), + showGroupHeaders: false + }) + expect(layout.rows.map((r) => r.type)).toEqual(['unscheduled']) + expect(layout.height).toBe(GROUP_ROW_HEIGHT) + }) + + it('has no rows and no height when there is nothing', () => { + expect(buildRows({ groups: [], unscheduled: [], idOf, collapsed: new Set(), showGroupHeaders: true })).toEqual({ + rows: [], + height: 0 + }) + }) + + it('shows an empty group header only when asked for groups', () => { + const layout = buildRows({ + groups: [{ id: 'g', items: [] }], + unscheduled: [], + idOf, + collapsed: new Set(), + showGroupHeaders: true + }) + expect(layout.rows.map((r) => r.type)).toEqual(['group']) + }) +}) + +describe('buildRows with nested groups', () => { + const idOf = (it: { id: string }): string => it.id + const item = (id: string): { id: string } => ({ id }) + const a = item('a') + const b = item('b') + const c = item('c') + const groups = [ + { + id: 'g1', + items: [a, b, c], + children: [ + { id: 'g1/x', items: [a, b] }, + { id: 'g1/y', items: [c] } + ] + }, + { id: 'g2', items: [], children: [] } + ] + const layoutOf = (collapsed: string[]): ReturnType> => + buildRows({ groups, unscheduled: [], idOf, collapsed: new Set(collapsed), showGroupHeaders: true }) + const describeRows = (rows: ReturnType['rows']): string[] => + rows.map((r) => (r.type === 'group' ? `${r.id}@${r.depth}:${r.count}` : r.id)) + + it('draws the headers of the sub groups under their group and the items under the last level', () => { + expect(describeRows(layoutOf([]).rows)).toEqual(['g1@0:3', 'g1/x@1:2', 'a', 'b', 'g1/y@1:1', 'c', 'g2@0:0']) + }) + + it('hides everything below a collapsed group', () => { + expect(describeRows(layoutOf(['g1']).rows)).toEqual(['g1@0:3', 'g2@0:0']) + }) + + it('collapses a sub group without touching its siblings', () => { + expect(describeRows(layoutOf(['g1/x']).rows)).toEqual(['g1@0:3', 'g1/x@1:2', 'g1/y@1:1', 'c', 'g2@0:0']) + }) + + it('stacks the rows without gaps', () => { + const layout = layoutOf([]) + expect(layout.rows.map((r) => r.y)).toEqual([ + 0, + GROUP_ROW_HEIGHT, + 2 * GROUP_ROW_HEIGHT, + 2 * GROUP_ROW_HEIGHT + ROW_HEIGHT, + 2 * GROUP_ROW_HEIGHT + 2 * ROW_HEIGHT, + 3 * GROUP_ROW_HEIGHT + 2 * ROW_HEIGHT, + 3 * GROUP_ROW_HEIGHT + 3 * ROW_HEIGHT + ]) + expect(layout.height).toBe(4 * GROUP_ROW_HEIGHT + 3 * ROW_HEIGHT) + }) + + it('lists the items of the last level when there are no headers', () => { + const layout = buildRows({ groups, unscheduled: [], idOf, collapsed: new Set(['g1']), showGroupHeaders: false }) + expect(layout.rows.map((r) => r.id)).toEqual(['a', 'b', 'c']) + }) +}) + +describe('visibleRowRange', () => { + const rows = Array.from({ length: 100 }, (_, i) => ({ y: i * 10, height: 10 })) + + it('returns the rows that intersect the viewport plus an overscan', () => { + expect(visibleRowRange(rows, 0, 50, 0)).toEqual([0, 5]) + expect(visibleRowRange(rows, 200, 50, 0)).toEqual([20, 25]) + expect(visibleRowRange(rows, 200, 50, 2)).toEqual([18, 27]) + }) + + it('includes a row that is only partly visible', () => { + expect(visibleRowRange(rows, 205, 10, 0)).toEqual([20, 22]) + }) + + it('stays inside the list', () => { + expect(visibleRowRange(rows, 950, 500, 5)).toEqual([90, 100]) + expect(visibleRowRange(rows, -100, 50, 5)).toEqual([0, 5]) + expect(visibleRowRange([], 0, 100)).toEqual([0, 0]) + }) + + it('returns an empty range far below the content', () => { + const [from, to] = visibleRowRange(rows, 5000, 100, 0) + expect(from).toBe(to) + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/__tests__/markers.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/markers.test.ts new file mode 100644 index 0000000000..24b9691191 --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/markers.test.ts @@ -0,0 +1,160 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType } from '@hcengineering/tracker' +import { buildDateSources, type DateLookups } from '../dates' +import { + collectMarkers, + DEFAULT_MARKER_SETTINGS, + hasAnyMarker, + markersInRange, + toggleMarker, + type MarkerParams +} from '../markers' +import { toDay } from '../timeScale' + +const local = (y: number, m: number, d: number): number => new Date(y, m - 1, d).getTime() +const day = (y: number, m: number, d: number): number => toDay(local(y, m, d)) + +const iterations = [ + { _id: 'i1', label: 'Sprint 1', startDate: local(2026, 10, 5), duration: 7 }, + { _id: 'brk', label: 'Break', startDate: local(2026, 10, 12), duration: 3, isBreak: true }, + { _id: 'i2', label: 'Sprint 2', startDate: local(2026, 10, 15), duration: 7 } +] +const lookups: DateLookups = { milestone: () => undefined, iterations: () => iterations } +const sources = new Map( + buildDateSources([{ key: 'release', label: 'Release', type: ProjectFieldType.Date }], { + startDate: 'Start date', + dueDate: 'Due date', + deadline: 'Deadline', + milestoneStart: 'MS', + milestoneTarget: 'MT' + }).map((s) => [s.id, s]) +) + +function params (over: Partial): MarkerParams { + return { + settings: DEFAULT_MARKER_SETTINGS, + milestones: [ + { _id: 'm1', label: 'Beta', targetDate: local(2026, 11, 30), color: 3 }, + { _id: 'm2', label: 'GA', targetDate: local(2026, 10, 20) } + ], + iterationFields: [{ key: 'sprint', label: 'Sprint' }], + iterations: () => iterations, + sources, + issues: [], + lookups, + ...over + } +} + +describe('collectMarkers', () => { + it('shows nothing by default', () => { + expect(collectMarkers(params({}))).toEqual([]) + }) + + it('flags milestones at their target date, ordered by day', () => { + const res = collectMarkers(params({ settings: { ...DEFAULT_MARKER_SETTINGS, milestones: true } })) + expect(res.map((m) => [m.kind, m.label, m.day])).toEqual([ + ['milestone', 'GA', day(2026, 10, 20)], + ['milestone', 'Beta', day(2026, 11, 30)] + ]) + expect(res[1].color).toBe(3) + }) + + it('marks the iterations of the chosen fields, without breaks, with their span', () => { + const res = collectMarkers(params({ settings: { ...DEFAULT_MARKER_SETTINGS, iterations: ['sprint'] } })) + expect(res.map((m) => [m.label, m.day, m.endDay])).toEqual([ + ['Sprint 1', day(2026, 10, 5), day(2026, 10, 11)], + ['Sprint 2', day(2026, 10, 15), day(2026, 10, 21)] + ]) + }) + + it('ignores an iteration field that does not exist', () => { + expect(collectMarkers(params({ settings: { ...DEFAULT_MARKER_SETTINGS, iterations: ['gone'] } }))).toEqual([]) + }) + + it('marks the dates of the items with how many items fall on a day', () => { + const res = collectMarkers( + params({ + settings: { ...DEFAULT_MARKER_SETTINGS, dates: ['issue:dueDate'] }, + issues: [ + { dueDate: local(2026, 10, 9) }, + { dueDate: local(2026, 10, 9) }, + { dueDate: local(2026, 10, 12) }, + { dueDate: null } + ] + }) + ) + expect(res.map((m) => [m.kind, m.label, m.day, m.count])).toEqual([ + ['date', 'Due date', day(2026, 10, 9), 2], + ['date', 'Due date', day(2026, 10, 12), 1] + ]) + }) + + it('reads custom Date fields and skips unknown or iteration sources', () => { + const res = collectMarkers( + params({ + settings: { ...DEFAULT_MARKER_SETTINGS, dates: ['field:release', 'field:gone', 'iteration:sprint'] }, + issues: [{ customFields: { release: local(2026, 12, 1) } }] + }) + ) + expect(res.map((m) => m.label)).toEqual(['Release']) + }) + + it('combines all kinds in day order', () => { + const res = collectMarkers( + params({ + settings: { milestones: true, iterations: ['sprint'], dates: ['issue:dueDate'] }, + issues: [{ dueDate: local(2026, 10, 6) }] + }) + ) + expect(res.map((m) => m.kind)).toEqual(['iteration', 'date', 'iteration', 'milestone', 'milestone']) + for (let i = 1; i < res.length; i++) expect(res[i].day).toBeGreaterThanOrEqual(res[i - 1].day) + }) + + it('caps the number of date markers, keeping the busiest days', () => { + const issues = Array.from({ length: 150 }, (_, i) => ({ dueDate: local(2026, 1, 1) + i * 86400000 * 2 })) + // Day 0 gets three more items + issues.push({ dueDate: local(2026, 1, 1) }, { dueDate: local(2026, 1, 1) }, { dueDate: local(2026, 1, 1) }) + const res = collectMarkers(params({ settings: { ...DEFAULT_MARKER_SETTINGS, dates: ['issue:dueDate'] }, issues })) + expect(res).toHaveLength(100) + expect(res.some((m) => m.day === day(2026, 1, 1) && m.count === 4)).toBe(true) + }) +}) + +describe('markersInRange', () => { + const markers = [ + { id: 'a', kind: 'date' as const, day: 5, label: 'a' }, + { id: 'b', kind: 'iteration' as const, day: 8, endDay: 14, label: 'b' }, + { id: 'c', kind: 'date' as const, day: 20, label: 'c' } + ] + + it('keeps markers whose line or span touches the range', () => { + expect(markersInRange(markers, 10, 18).map((m) => m.id)).toEqual(['b']) + expect(markersInRange(markers, 0, 6).map((m) => m.id)).toEqual(['a']) + expect(markersInRange(markers, 20, 21).map((m) => m.id)).toEqual(['c']) + expect(markersInRange(markers, 21, 30)).toEqual([]) + }) +}) + +describe('toggleMarker', () => { + it('flips milestones', () => { + expect(toggleMarker(DEFAULT_MARKER_SETTINGS, { kind: 'milestones' }).milestones).toBe(true) + }) + + it('adds and removes list members without touching the original', () => { + const on = toggleMarker(DEFAULT_MARKER_SETTINGS, { kind: 'iterations', id: 'sprint' }) + expect(on.iterations).toEqual(['sprint']) + expect(DEFAULT_MARKER_SETTINGS.iterations).toEqual([]) + expect(toggleMarker(on, { kind: 'iterations', id: 'sprint' }).iterations).toEqual([]) + expect(toggleMarker(on, { kind: 'dates', id: 'issue:dueDate' }).dates).toEqual(['issue:dueDate']) + }) + + it('tells whether anything is marked', () => { + expect(hasAnyMarker(DEFAULT_MARKER_SETTINGS)).toBe(false) + expect(hasAnyMarker({ milestones: false, iterations: [], dates: ['x'] })).toBe(true) + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/__tests__/reschedule.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/reschedule.test.ts new file mode 100644 index 0000000000..2d4beb97ef --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/reschedule.test.ts @@ -0,0 +1,327 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { buildDateSources, resolveSchedule, type DateIssue, type DateLookups, type DateSelection } from '../dates' +import { + applyDelta, + buildIssuePatch, + iterationAt, + planReschedule, + planSchedule, + previewSchedule, + snapIteration +} from '../reschedule' +import { toDay } from '../timeScale' +import { ProjectFieldType } from '@hcengineering/tracker' + +const local = (y: number, m: number, d: number, h = 0): number => new Date(y, m - 1, d, h).getTime() +const day = (y: number, m: number, d: number): number => toDay(local(y, m, d)) + +const iterations = [ + { _id: 'i1', startDate: local(2026, 10, 5), duration: 7 }, + { _id: 'brk', startDate: local(2026, 10, 12), duration: 7, isBreak: true }, + { _id: 'i2', startDate: local(2026, 10, 19), duration: 7 }, + { _id: 'i3', startDate: local(2026, 10, 26), duration: 7 } +] + +const lookups: DateLookups = { + milestone: (id) => (id === 'm' ? { startDate: local(2026, 11, 1), targetDate: local(2026, 11, 30) } : undefined), + iterations: (key) => (key === 'sprint' ? iterations : []) +} + +const sources = new Map( + buildDateSources( + [ + { key: 'release', label: 'Release', type: ProjectFieldType.Date }, + { key: 'sprint', label: 'Sprint', type: ProjectFieldType.Iteration } + ], + { startDate: 'S', dueDate: 'D', deadline: 'DL', milestoneStart: 'MS', milestoneTarget: 'MT' } + ).map((s) => [s.id, s]) +) + +function ctx (issue: DateIssue, selection: DateSelection) { + return { issue, selection, sources, lookups, schedule: resolveSchedule(issue, selection, lookups) } +} + +const dates: DateSelection = { start: 'issue:startDate', target: 'issue:dueDate' } + +describe('applyDelta', () => { + const range = { start: 10, target: 14 } + + it('moves both dates and keeps the duration', () => { + expect(applyDelta(range, 'move', 3)).toEqual({ start: 13, target: 17 }) + expect(applyDelta(range, 'move', -20)).toEqual({ start: -10, target: -6 }) + }) + + it('resizes one edge', () => { + expect(applyDelta(range, 'resize-start', -2)).toEqual({ start: 8, target: 14 }) + expect(applyDelta(range, 'resize-end', 5)).toEqual({ start: 10, target: 19 }) + }) + + it('stops an edge at the other one so that the range keeps one day', () => { + expect(applyDelta(range, 'resize-start', 10)).toEqual({ start: 14, target: 14 }) + expect(applyDelta(range, 'resize-end', -10)).toEqual({ start: 10, target: 10 }) + }) +}) + +describe('previewSchedule', () => { + it('previews moves of ranges and markers', () => { + expect(previewSchedule({ kind: 'range', start: 1, target: 3, inverted: false }, 'move', 2)).toEqual({ + kind: 'range', + start: 3, + target: 5, + inverted: false + }) + expect(previewSchedule({ kind: 'marker', day: 4, role: 'target' }, 'move', -1)).toEqual({ + kind: 'marker', + day: 3, + role: 'target' + }) + }) + + it('does not resize markers, inverted ranges or unscheduled items', () => { + const marker = { kind: 'marker', day: 4, role: 'target' } as const + expect(previewSchedule(marker, 'resize-end', 3)).toBe(marker) + const inverted = { kind: 'range', start: 1, target: 3, inverted: true } as const + expect(previewSchedule(inverted, 'resize-start', 3)).toBe(inverted) + expect(previewSchedule({ kind: 'unscheduled' }, 'move', 3)).toEqual({ kind: 'unscheduled' }) + }) +}) + +describe('iterations', () => { + it('finds the iteration that contains a day, skipping breaks', () => { + expect(iterationAt(iterations, day(2026, 10, 6))?._id).toBe('i1') + expect(iterationAt(iterations, day(2026, 10, 27))?._id).toBe('i3') + // Inside a break: the closest real iteration + expect(iterationAt(iterations, day(2026, 10, 13))?._id).toBe('i1') + expect(iterationAt(iterations, day(2026, 10, 17))?._id).toBe('i2') + expect(iterationAt(iterations, day(2030, 1, 1))?._id).toBe('i3') + expect(iterationAt([], day(2026, 10, 6))).toBeUndefined() + }) + + it('snaps a start to the iteration that starts closest and a target to the one that ends closest', () => { + expect(snapIteration(iterations, day(2026, 10, 21), 'start')?._id).toBe('i2') + expect(snapIteration(iterations, day(2026, 10, 23), 'start')?._id).toBe('i3') + // Ends: i1 Oct 11, i2 Oct 25, i3 Nov 1 + expect(snapIteration(iterations, day(2026, 10, 30), 'target')?._id).toBe('i3') + expect(snapIteration(iterations, day(2026, 10, 24), 'target')?._id).toBe('i2') + }) +}) + +describe('planReschedule with date fields', () => { + const issue = { startDate: local(2026, 10, 5, 9), dueDate: local(2026, 10, 9, 17) } + + it('moves both dates by the same days and keeps their time of day', () => { + const plan = planReschedule(ctx(issue, dates), 'move', 7) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + const byKey = Object.fromEntries(plan.writes.map((w) => [w.key, w.value as number])) + expect(new Date(byKey.startDate).getDate()).toBe(12) + expect(new Date(byKey.startDate).getHours()).toBe(9) + expect(new Date(byKey.dueDate).getDate()).toBe(16) + expect(new Date(byKey.dueDate).getHours()).toBe(17) + }) + + it('keeps the duration across a month boundary', () => { + const plan = planReschedule(ctx(issue, dates), 'move', 30) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + const start = plan.writes.find((w) => w.key === 'startDate')?.value as number + const due = plan.writes.find((w) => w.key === 'dueDate')?.value as number + expect(toDay(due) - toDay(start)).toBe(4) + expect(toDay(start) - day(2026, 10, 5)).toBe(30) + }) + + it('writes only the start when its edge is dragged', () => { + const plan = planReschedule(ctx(issue, dates), 'resize-start', -3) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes.map((w) => w.key)).toEqual(['startDate']) + expect(toDay(plan.writes[0].value as number)).toBe(day(2026, 10, 2)) + }) + + it('writes only the target when its edge is dragged and clamps it at the start', () => { + const plan = planReschedule(ctx(issue, dates), 'resize-end', -20) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes.map((w) => w.key)).toEqual(['dueDate']) + expect(toDay(plan.writes[0].value as number)).toBe(day(2026, 10, 5)) + }) + + it('does nothing for a zero delta or a clamped resize', () => { + expect(planReschedule(ctx(issue, dates), 'move', 0)).toEqual({ ok: false, reason: 'unchanged' }) + const oneDay = { startDate: local(2026, 10, 5), dueDate: local(2026, 10, 5) } + expect(planReschedule(ctx(oneDay, dates), 'resize-end', -3)).toEqual({ ok: false, reason: 'unchanged' }) + }) + + it('moves a marker', () => { + const plan = planReschedule(ctx({ dueDate: local(2026, 10, 9) }, dates), 'move', -2) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes).toHaveLength(1) + expect(plan.writes[0]).toMatchObject({ sourceId: 'issue:dueDate', kind: 'issue', key: 'dueDate' }) + expect(toDay(plan.writes[0].value as number)).toBe(day(2026, 10, 7)) + }) + + it('cannot resize a marker, an inverted range or an item without dates', () => { + expect(planReschedule(ctx({ dueDate: local(2026, 10, 9) }, dates), 'resize-end', 2)).toEqual({ + ok: false, + reason: 'unsupported' + }) + const inverted = { startDate: local(2026, 10, 9), dueDate: local(2026, 10, 5) } + expect(planReschedule(ctx(inverted, dates), 'resize-start', 2)).toEqual({ ok: false, reason: 'unsupported' }) + expect(planReschedule(ctx({}, dates), 'move', 2)).toEqual({ ok: false, reason: 'noSchedule' }) + }) + + it('moves an inverted range with each date keeping its own side', () => { + const inverted = { startDate: local(2026, 10, 9), dueDate: local(2026, 10, 5) } + const plan = planReschedule(ctx(inverted, dates), 'move', 2) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + const start = plan.writes.find((w) => w.key === 'startDate')?.value as number + const due = plan.writes.find((w) => w.key === 'dueDate')?.value as number + expect(toDay(start)).toBe(day(2026, 10, 11)) + expect(toDay(due)).toBe(day(2026, 10, 7)) + }) + + it('writes custom Date fields', () => { + const sel = { start: 'issue:startDate', target: 'field:release' } + const i = { startDate: local(2026, 10, 5), customFields: { release: local(2026, 10, 20) } } + const plan = planReschedule(ctx(i, sel), 'resize-end', 5) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes[0]).toMatchObject({ kind: 'field', key: 'release' }) + expect(toDay(plan.writes[0].value as number)).toBe(day(2026, 10, 25)) + }) +}) + +describe('planReschedule with read only sources', () => { + const sel = { start: 'issue:startDate', target: 'milestone:targetDate' } + const issue = { startDate: local(2026, 11, 10), milestone: 'm' } + + it('refuses to move an item that takes a date from its milestone', () => { + expect(planReschedule(ctx(issue, sel), 'move', 3)).toEqual({ ok: false, reason: 'readonly' }) + expect(planReschedule(ctx(issue, sel), 'resize-end', 3)).toEqual({ ok: false, reason: 'readonly' }) + }) + + it('still resizes the edge that comes from a writable source', () => { + const plan = planReschedule(ctx(issue, sel), 'resize-start', -3) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes.map((w) => w.key)).toEqual(['startDate']) + }) +}) + +describe('planReschedule with iterations', () => { + const sel = { start: 'iteration:sprint', target: 'iteration:sprint' } + + it('moves the item to the iteration the bar is dragged to', () => { + const issue = { customFields: { sprint: 'i1' } } + // The bar covers Oct 5-11; one iteration further is Oct 19-25 (a break lies between) + const plan = planReschedule(ctx(issue, sel), 'move', 14) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes).toEqual([{ sourceId: 'iteration:sprint', kind: 'iteration', key: 'sprint', value: 'i2' }]) + }) + + it('keeps the iteration for a small movement', () => { + expect(planReschedule(ctx({ customFields: { sprint: 'i2' } }, sel), 'move', 2)).toEqual({ + ok: false, + reason: 'unchanged' + }) + }) + + it('moves back to an earlier iteration', () => { + const plan = planReschedule(ctx({ customFields: { sprint: 'i3' } }, sel), 'move', -7) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes[0].value).toBe('i2') + }) + + it('never lands in a break', () => { + // The start lands on Oct 15, inside the break that starts on Oct 12 + const plan = planReschedule(ctx({ customFields: { sprint: 'i1' } }, sel), 'move', 10) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes[0].value).toBe('i2') + }) + + it('resizes only the iteration of the edge that moved', () => { + const mixed = { start: 'iteration:sprint', target: 'issue:dueDate' } + const issue = { dueDate: local(2026, 11, 10), customFields: { sprint: 'i2' } } + const plan = planReschedule(ctx(issue, mixed), 'resize-start', 7) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes).toEqual([{ sourceId: 'iteration:sprint', kind: 'iteration', key: 'sprint', value: 'i3' }]) + }) +}) + +describe('planSchedule', () => { + it('sets both dates of an item without dates', () => { + const plan = planSchedule({ issue: {}, selection: dates, sources, lookups }, day(2026, 10, 14)) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes.map((w) => w.key).sort()).toEqual(['dueDate', 'startDate']) + for (const w of plan.writes) expect(w.value).toBe(local(2026, 10, 14)) + }) + + it('adds only the missing date to an item shown as a marker', () => { + const plan = planSchedule({ issue: { dueDate: local(2026, 10, 9) }, selection: dates, sources, lookups }, day(2026, 10, 1)) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + expect(plan.writes.map((w) => w.key)).toEqual(['startDate']) + }) + + it('writes a single source once when start and target share it', () => { + const sel = { start: 'field:release', target: 'field:release' } + const plan = planSchedule({ issue: {}, selection: sel, sources, lookups }, day(2026, 10, 14)) + expect(plan.ok && plan.writes).toHaveLength(1) + }) + + it('assigns the iteration that contains the day', () => { + const sel = { start: 'iteration:sprint', target: 'iteration:sprint' } + const plan = planSchedule({ issue: {}, selection: sel, sources, lookups }, day(2026, 10, 21)) + expect(plan).toEqual({ + ok: true, + writes: [{ sourceId: 'iteration:sprint', kind: 'iteration', key: 'sprint', value: 'i2' }] + }) + }) + + it('skips read only sources and fails when nothing can be written', () => { + const sel = { start: 'milestone:startDate', target: 'issue:dueDate' } + const plan = planSchedule({ issue: {}, selection: sel, sources, lookups }, day(2026, 10, 14)) + expect(plan.ok && plan.writes.map((w) => w.key)).toEqual(['dueDate']) + const none = planSchedule( + { issue: {}, selection: { start: 'milestone:startDate', target: 'milestone:targetDate' }, sources, lookups }, + day(2026, 10, 14) + ) + expect(none).toEqual({ ok: false, reason: 'readonly' }) + }) + + it('is unchanged when the item has all its dates', () => { + const i = { startDate: local(2026, 10, 5), dueDate: local(2026, 10, 9) } + expect(planSchedule({ issue: i, selection: dates, sources, lookups }, day(2026, 10, 14))).toEqual({ + ok: false, + reason: 'unchanged' + }) + }) +}) + +describe('buildIssuePatch', () => { + it('puts built-in dates at the top and merges custom values into one record', () => { + const patch = buildIssuePatch({ customFields: { keep: 1 } }, [ + { sourceId: 'issue:dueDate', kind: 'issue', key: 'dueDate', value: 5 }, + { sourceId: 'field:release', kind: 'field', key: 'release', value: 6 }, + { sourceId: 'iteration:sprint', kind: 'iteration', key: 'sprint', value: 'i2' } + ]) + expect(patch).toEqual({ dueDate: 5, customFields: { keep: 1, release: 6, sprint: 'i2' } }) + }) + + it('does not touch the custom fields for built-in dates', () => { + expect(buildIssuePatch({}, [{ sourceId: 'issue:startDate', kind: 'issue', key: 'startDate', value: 1 }])).toEqual({ + startDate: 1 + }) + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/__tests__/timeScale.test.ts b/plugins/tracker-resources/src/roadmap/__tests__/timeScale.test.ts new file mode 100644 index 0000000000..1827696fdd --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/__tests__/timeScale.test.ts @@ -0,0 +1,208 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + computeRange, + createScale, + dayParts, + dayToTime, + formatTick, + headerRows, + monthStart, + pixelsToDays, + PX_PER_DAY, + quarterStart, + scrollLeftForDay, + shiftTimestamp, + snapRange, + toDay, + weekStart, + yearStart +} from '../timeScale' + +const local = (y: number, m: number, d: number, h = 0): number => new Date(y, m - 1, d, h).getTime() + +describe('days', () => { + it('maps a timestamp to the local calendar day and back', () => { + const ts = local(2026, 10, 3, 17) + const day = toDay(ts) + expect(dayToTime(day)).toBe(local(2026, 10, 3)) + expect(dayParts(day)).toMatchObject({ year: 2026, month: 9, date: 3 }) + }) + + it('consecutive local days differ by one, also across a daylight saving change', () => { + for (const [y, m, d] of [ + [2026, 3, 28], + [2026, 3, 29], + [2026, 10, 24], + [2026, 10, 25], + [2026, 11, 1] + ]) { + expect(toDay(local(y, m, d + 1)) - toDay(local(y, m, d))).toBe(1) + } + }) + + it('shifts a timestamp by calendar days and keeps its time of day', () => { + const ts = local(2026, 3, 27, 15) + const shifted = shiftTimestamp(ts, 3) + expect(new Date(shifted).getHours()).toBe(15) + expect(toDay(shifted) - toDay(ts)).toBe(3) + expect(toDay(shiftTimestamp(ts, -30)) - toDay(ts)).toBe(-30) + }) + + it('finds the start of a month, quarter, year and week', () => { + const day = toDay(local(2026, 11, 18)) // a Wednesday + expect(dayParts(monthStart(day))).toMatchObject({ year: 2026, month: 10, date: 1 }) + expect(dayParts(quarterStart(day))).toMatchObject({ year: 2026, month: 9, date: 1 }) + expect(dayParts(yearStart(day))).toMatchObject({ year: 2026, month: 0, date: 1 }) + expect(dayParts(weekStart(day))).toMatchObject({ month: 10, date: 16, weekday: 1 }) + // A Sunday belongs to the week that started on the Monday before + expect(dayParts(weekStart(toDay(local(2026, 11, 22)))).date).toBe(16) + // A Monday is its own week start + expect(dayParts(weekStart(toDay(local(2026, 11, 16)))).date).toBe(16) + }) +}) + +describe('scale', () => { + it('has a different density for every zoom level', () => { + expect(PX_PER_DAY.month).toBeGreaterThan(PX_PER_DAY.quarter) + expect(PX_PER_DAY.quarter).toBeGreaterThan(PX_PER_DAY.year) + }) + + it('converts days to x and x to days', () => { + const scale = createScale('quarter', 1000, 1100) + expect(scale.width).toBe(100 * PX_PER_DAY.quarter) + expect(scale.dayToX(1000)).toBe(0) + expect(scale.dayToX(1010)).toBe(10 * PX_PER_DAY.quarter) + expect(scale.xToDay(scale.dayToX(1042))).toBe(1042) + // Anywhere inside a day gives that day + expect(scale.xToDay(scale.dayToX(1042) + PX_PER_DAY.quarter - 0.1)).toBe(1042) + }) + + it('never has an empty axis', () => { + expect(createScale('month', 10, 10).width).toBeGreaterThan(0) + }) + + it('snaps a range to whole units of the zoom level', () => { + const from = toDay(local(2026, 11, 18)) + const to = toDay(local(2027, 2, 3)) + const month = snapRange('month', from, to) + expect(dayParts(month.startDay)).toMatchObject({ year: 2026, month: 10, date: 1 }) + expect(dayParts(month.endDay)).toMatchObject({ year: 2027, month: 2, date: 1 }) + const quarter = snapRange('quarter', from, to) + expect(dayParts(quarter.startDay)).toMatchObject({ year: 2026, month: 9, date: 1 }) + expect(dayParts(quarter.endDay)).toMatchObject({ year: 2027, month: 3, date: 1 }) + const year = snapRange('year', from, to) + expect(dayParts(year.startDay)).toMatchObject({ year: 2026, month: 0, date: 1 }) + expect(dayParts(year.endDay)).toMatchObject({ year: 2028, month: 0, date: 1 }) + }) + + it('snaps a single day to one unit', () => { + const day = toDay(local(2026, 2, 14)) + const r = snapRange('month', day, day) + expect(dayParts(r.startDay).month).toBe(1) + expect(dayParts(r.endDay).month).toBe(2) + }) +}) + +describe('computeRange', () => { + const today = toDay(local(2026, 10, 3)) + + it('covers today and gives room around it when there are no dates', () => { + for (const zoom of ['month', 'quarter', 'year'] as const) { + const r = computeRange(zoom, [], today) + expect(r.startDay).toBeLessThanOrEqual(today) + expect(r.endDay).toBeGreaterThan(today) + expect(r.endDay - r.startDay).toBeGreaterThan(150) + } + }) + + it('has more room ahead of today than behind it', () => { + const r = computeRange('quarter', [], today) + expect(r.endDay - today).toBeGreaterThan(today - r.startDay) + }) + + it('stretches over every date', () => { + const early = toDay(local(2025, 1, 15)) + const late = toDay(local(2029, 6, 20)) + const r = computeRange('month', [early, late], today) + expect(r.startDay).toBeLessThanOrEqual(early) + expect(r.endDay).toBeGreaterThan(late) + }) + + it('ignores dates absurdly far from today', () => { + const r = computeRange('month', [toDay(local(1970, 1, 1)), toDay(local(2500, 1, 1))], today) + expect(r.endDay - r.startDay).toBeLessThan(400) + }) +}) + +describe('header', () => { + it('shows months over weeks at the month zoom', () => { + const range = snapRange('month', toDay(local(2026, 10, 1)), toDay(local(2026, 11, 30))) + const scale = createScale('month', range.startDay, range.endDay) + const { top, bottom } = headerRows(scale) + expect(top.map((t) => t.index)).toEqual([9, 10]) + expect(top.every((t) => t.kind === 'month')).toBe(true) + expect(bottom.every((t) => t.kind === 'week')).toBe(true) + // Weeks start on Mondays + expect(bottom.slice(1).every((t) => dayParts(t.startDay).weekday === 1)).toBe(true) + }) + + it('shows quarters over months at the quarter zoom and years over quarters at the year zoom', () => { + const range = snapRange('quarter', toDay(local(2026, 1, 1)), toDay(local(2026, 12, 31))) + const q = headerRows(createScale('quarter', range.startDay, range.endDay)) + expect(q.top.map((t) => t.index)).toEqual([1, 2, 3, 4]) + expect(q.bottom).toHaveLength(12) + + const yr = snapRange('year', toDay(local(2026, 1, 1)), toDay(local(2027, 12, 31))) + const y = headerRows(createScale('year', yr.startDay, yr.endDay)) + expect(y.top.map((t) => t.year)).toEqual([2026, 2027]) + expect(y.bottom).toHaveLength(8) + }) + + it('tiles the axis without gaps and clips ticks at the edges', () => { + const start = toDay(local(2026, 10, 14)) + const end = toDay(local(2026, 12, 20)) + const scale = createScale('month', start, end) + const rows = headerRows(scale) + for (const row of [rows.top, rows.bottom]) { + expect(row[0].startDay).toBe(start) + expect(row[row.length - 1].endDay).toBe(end) + for (let i = 1; i < row.length; i++) expect(row[i].startDay).toBe(row[i - 1].endDay) + const total = row.reduce((sum: number, t) => sum + t.width, 0) + expect(total).toBe(scale.width) + } + }) + + it('formats the labels', () => { + const scale = createScale('quarter', toDay(local(2026, 10, 1)), toDay(local(2027, 1, 1))) + const { top, bottom } = headerRows(scale) + expect(formatTick(top[0], 'en')).toBe('Q4 2026') + expect(formatTick(bottom[0], 'en', 'bottom')).toBe('Oct') + const yearly = headerRows(createScale('year', toDay(local(2026, 1, 1)), toDay(local(2027, 1, 1)))) + expect(formatTick(yearly.top[0])).toBe('2026') + expect(formatTick(yearly.bottom[1], undefined, 'bottom')).toBe('Q2') + const monthly = headerRows(createScale('month', toDay(local(2026, 10, 1)), toDay(local(2026, 11, 1)))) + expect(formatTick(monthly.top[0], 'en')).toBe('October 2026') + }) +}) + +describe('interaction', () => { + it('rounds a pixel movement to whole days', () => { + expect(pixelsToDays(PX_PER_DAY.month * 3 + 1, PX_PER_DAY.month)).toBe(3) + expect(pixelsToDays(PX_PER_DAY.month * 3 + PX_PER_DAY.month * 0.6, PX_PER_DAY.month)).toBe(4) + expect(pixelsToDays(-PX_PER_DAY.month * 2, PX_PER_DAY.month)).toBe(-2) + expect(Object.is(pixelsToDays(-0.1, PX_PER_DAY.month), 0)).toBe(true) + }) + + it('computes a scroll offset that stays inside the axis', () => { + const scale = createScale('month', 0, 400) + expect(scrollLeftForDay(scale, 0, 500)).toBe(0) + expect(scrollLeftForDay(scale, 399, 500)).toBe(scale.width - 500) + expect(scrollLeftForDay(scale, 200, 600, 0.5)).toBe(scale.dayToX(200) - 300) + // An axis narrower than the viewport does not scroll + expect(scrollLeftForDay(createScale('year', 0, 100), 50, 500)).toBe(0) + }) +}) diff --git a/plugins/tracker-resources/src/roadmap/config.ts b/plugins/tracker-resources/src/roadmap/config.ts new file mode 100644 index 0000000000..131b98896e --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/config.ts @@ -0,0 +1,133 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { deepEqual } from 'fast-equals' +import { ISSUE_DUE_SOURCE, ISSUE_START_SOURCE, parseSourceId, type DateSelection } from './dates' +import { DEFAULT_MARKER_SETTINGS, type MarkerSettings } from './markers' +import { DEFAULT_ROADMAP_ZOOM, isRoadmapZoom, type RoadmapZoom } from './timeScale' + +/** + * Settings of a roadmap view. They are stored in the view options under `ROADMAP_OPTION_KEY`, so that they are + * saved with the saved view, restored when it is opened and take part in its unsaved-changes tracking. + */ +export interface RoadmapConfig { + // Id of the date source of the start of an item (see dates.ts) + start: string + // Id of the date source of the target of an item + target: string + zoom: RoadmapZoom + markers: MarkerSettings + // Ids of the fields shown in the label of an item (see label.ts) + fields: string[] +} + +export const ROADMAP_OPTION_KEY = 'roadmap' + +export const DEFAULT_LABEL_FIELDS: readonly string[] = ['identifier', 'title'] + +export const DEFAULT_ROADMAP_CONFIG: Readonly = { + start: ISSUE_START_SOURCE, + target: ISSUE_DUE_SOURCE, + zoom: DEFAULT_ROADMAP_ZOOM, + markers: DEFAULT_MARKER_SETTINGS, + fields: [...DEFAULT_LABEL_FIELDS] +} + +function stringList (raw: unknown): string[] | undefined { + if (!Array.isArray(raw)) return undefined + const res: string[] = [] + for (const v of raw) { + if (typeof v === 'string' && v !== '' && !res.includes(v)) res.push(v) + } + return res +} + +function validSource (raw: unknown, fallback: string): string { + return typeof raw === 'string' && parseSourceId(raw) !== undefined ? raw : fallback +} + +/** Turns whatever is stored in the view options into a valid config; missing or damaged parts get the defaults. */ +export function normalizeRoadmapConfig (raw: unknown): RoadmapConfig { + if (raw === null || typeof raw !== 'object') return cloneConfig(DEFAULT_ROADMAP_CONFIG) + const r = raw as Record + const markers = (r.markers ?? {}) as Record + return { + start: validSource(r.start, DEFAULT_ROADMAP_CONFIG.start), + target: validSource(r.target, DEFAULT_ROADMAP_CONFIG.target), + zoom: isRoadmapZoom(r.zoom) ? r.zoom : DEFAULT_ROADMAP_CONFIG.zoom, + markers: { + milestones: markers.milestones === true, + iterations: stringList(markers.iterations) ?? [], + dates: stringList(markers.dates) ?? [] + }, + fields: stringList(r.fields) ?? [...DEFAULT_LABEL_FIELDS] + } +} + +export function cloneConfig (config: Readonly): RoadmapConfig { + return { + ...config, + markers: { + milestones: config.markers.milestones, + iterations: [...config.markers.iterations], + dates: [...config.markers.dates] + }, + fields: [...config.fields] + } +} + +export function readRoadmapConfig (viewOptions: Record | undefined): RoadmapConfig { + return normalizeRoadmapConfig(viewOptions?.[ROADMAP_OPTION_KEY]) +} + +export function isDefaultConfig (config: RoadmapConfig): boolean { + return deepEqual(config, DEFAULT_ROADMAP_CONFIG) +} + +/** + * The view options with the roadmap config stored in them. The default config is not stored at all, so that + * a view that was never customized stays equal to its saved state (no false "unsaved changes" dot). + */ +export function withRoadmapConfig> (options: O, config: RoadmapConfig): O { + const next: Record = { ...options } + if (isDefaultConfig(config)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[ROADMAP_OPTION_KEY] + } else { + next[ROADMAP_OPTION_KEY] = cloneConfig(config) + } + return next as O +} + +export function selectionOf (config: RoadmapConfig): DateSelection { + return { start: config.start, target: config.target } +} + +export interface ConfigAvailability { + // Ids of the date sources that exist + sourceIds: ReadonlySet + // Keys of the Iteration fields that exist + iterationFieldKeys: ReadonlySet + // Ids of the label fields that exist + labelFieldIds: ReadonlySet +} + +/** + * Drops what no longer exists (a deleted field) from the config. A start or target source that is gone falls + * back to the default. + */ +export function sanitizeConfig (config: RoadmapConfig, available: ConfigAvailability): RoadmapConfig { + return { + ...config, + start: available.sourceIds.has(config.start) ? config.start : DEFAULT_ROADMAP_CONFIG.start, + target: available.sourceIds.has(config.target) ? config.target : DEFAULT_ROADMAP_CONFIG.target, + markers: { + milestones: config.markers.milestones, + iterations: config.markers.iterations.filter((k) => available.iterationFieldKeys.has(k)), + dates: config.markers.dates.filter((id) => available.sourceIds.has(id)) + }, + fields: config.fields.filter((id) => available.labelFieldIds.has(id)) + } +} diff --git a/plugins/tracker-resources/src/roadmap/customValue.ts b/plugins/tracker-resources/src/roadmap/customValue.ts new file mode 100644 index 0000000000..c26530a3b1 --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/customValue.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { getFieldValue, ProjectFieldType, type ProjectField } from '@hcengineering/tracker' + +export interface IterationLabel { + _id: string + label: string +} + +/** + * Plain text of the value of a custom field, for labels and group headers; undefined when the item has no value. + */ +export function formatCustomValue ( + field: Pick, + customFields: Record | undefined, + iterations: readonly IterationLabel[], + locale?: string +): string | undefined { + const value = getFieldValue(customFields, field) + if (value === null) return undefined + switch (field.type) { + case ProjectFieldType.Text: + return value === '' ? undefined : String(value) + case ProjectFieldType.Number: + return String(value) + case ProjectFieldType.Date: + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(value as number)) + case ProjectFieldType.SingleSelect: + return (field.options ?? []).find((o) => o.value === value)?.label + case ProjectFieldType.MultiSelect: { + const options = field.options ?? [] + const labels = (value as string[]).map((v) => options.find((o) => o.value === v)?.label).filter((l) => l !== undefined) + return labels.length > 0 ? labels.join(', ') : undefined + } + case ProjectFieldType.Iteration: + return iterations.find((it) => it._id === value)?.label + } +} diff --git a/plugins/tracker-resources/src/roadmap/dates.ts b/plugins/tracker-resources/src/roadmap/dates.ts new file mode 100644 index 0000000000..6ed79e6840 --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/dates.ts @@ -0,0 +1,241 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType } from '@hcengineering/tracker' +import { dayToTime, toDay } from './timeScale' + +// Where the start and the target of a roadmap item come from. A source is addressed by an id that is stored +// in the saved view: +// issue:startDate | issue:dueDate | issue:deadline built-in dates of the issue +// milestone:startDate | milestone:targetDate dates of the milestone of the issue (read only) +// field: a custom Date field +// iteration: an Iteration field: its start gives the start of the +// item, its last day the target + +export type DateSourceKind = 'issue' | 'milestone' | 'field' | 'iteration' + +export type IssueDateKey = 'startDate' | 'dueDate' | 'deadline' + +export interface DateSourceRef { + kind: DateSourceKind + key: string +} + +export interface DateSource extends DateSourceRef { + id: string + label: string + // Whether dragging an item can write the source + writable: boolean +} + +export const ISSUE_START_SOURCE = 'issue:startDate' +export const ISSUE_DUE_SOURCE = 'issue:dueDate' +export const ISSUE_DEADLINE_SOURCE = 'issue:deadline' +export const MILESTONE_START_SOURCE = 'milestone:startDate' +export const MILESTONE_TARGET_SOURCE = 'milestone:targetDate' + +const ISSUE_KEYS: readonly string[] = ['startDate', 'dueDate', 'deadline'] +const MILESTONE_KEYS: readonly string[] = ['startDate', 'targetDate'] + +export function makeSourceId (ref: DateSourceRef): string { + return `${ref.kind}:${ref.key}` +} + +export function parseSourceId (id: string): DateSourceRef | undefined { + const at = id.indexOf(':') + if (at <= 0) return undefined + const kind = id.slice(0, at) + const key = id.slice(at + 1) + if (key === '') return undefined + switch (kind) { + case 'issue': + return ISSUE_KEYS.includes(key) ? { kind, key } : undefined + case 'milestone': + return MILESTONE_KEYS.includes(key) ? { kind, key } : undefined + case 'field': + case 'iteration': + return { kind, key } + default: + return undefined + } +} + +export interface DateSourceLabels { + startDate: string + dueDate: string + deadline: string + milestoneStart: string + milestoneTarget: string +} + +export interface FieldLike { + key: string + label: string + type: ProjectFieldType +} + +/** + * Everything a roadmap can take its dates from, in the order they are offered: + * the dates of the issue, the dates of its milestone, then the Date and Iteration fields of the project. + */ +export function buildDateSources (fields: readonly FieldLike[], labels: DateSourceLabels): DateSource[] { + const res: DateSource[] = [ + { id: ISSUE_START_SOURCE, kind: 'issue', key: 'startDate', label: labels.startDate, writable: true }, + { id: ISSUE_DUE_SOURCE, kind: 'issue', key: 'dueDate', label: labels.dueDate, writable: true }, + { id: ISSUE_DEADLINE_SOURCE, kind: 'issue', key: 'deadline', label: labels.deadline, writable: true }, + { id: MILESTONE_START_SOURCE, kind: 'milestone', key: 'startDate', label: labels.milestoneStart, writable: false }, + { + id: MILESTONE_TARGET_SOURCE, + kind: 'milestone', + key: 'targetDate', + label: labels.milestoneTarget, + writable: false + } + ] + for (const f of fields) { + if (f.type === ProjectFieldType.Date) { + res.push({ id: `field:${f.key}`, kind: 'field', key: f.key, label: f.label, writable: true }) + } else if (f.type === ProjectFieldType.Iteration) { + res.push({ id: `iteration:${f.key}`, kind: 'iteration', key: f.key, label: f.label, writable: true }) + } + } + return res +} + +// ---- reading ---- + +export interface IterationDates { + _id: string + startDate: number + duration: number + isBreak?: boolean +} + +export interface MilestoneDates { + startDate: number | null + targetDate: number +} + +export interface DateIssue { + startDate?: number | null + dueDate?: number | null + deadline?: number | null + milestone?: string | null + customFields?: Record +} + +export interface DateLookups { + milestone: (id: string) => MilestoneDates | undefined + // Iterations of an Iteration field, by the key of the field + iterations: (fieldKey: string) => readonly IterationDates[] +} + +export type DateRole = 'start' | 'target' + +/** First and last day (inclusive) of an iteration. */ +export function iterationDays (it: Pick): { start: number, end: number } { + const start = toDay(it.startDate) + return { start, end: start + Math.max(1, it.duration) - 1 } +} + +function timestampDay (value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? toDay(value) : undefined +} + +/** + * The day the source gives for an item, or undefined when the item has no value in it. + * `role` matters for iterations only: the start of the item is the first day of its iteration, the target the last. + */ +export function readSourceDay ( + issue: DateIssue, + source: DateSourceRef, + role: DateRole, + lookups: DateLookups +): number | undefined { + switch (source.kind) { + case 'issue': + return timestampDay(issue[source.key as IssueDateKey]) + case 'milestone': { + if (issue.milestone === undefined || issue.milestone === null) return undefined + const m = lookups.milestone(issue.milestone) + return m === undefined ? undefined : timestampDay(m[source.key as keyof MilestoneDates]) + } + case 'field': + return timestampDay(issue.customFields?.[source.key]) + case 'iteration': { + const id = issue.customFields?.[source.key] + if (typeof id !== 'string') return undefined + const it = lookups.iterations(source.key).find((i) => i._id === id) + if (it === undefined) return undefined + const days = iterationDays(it) + return role === 'start' ? days.start : days.end + } + } +} + +// ---- schedule of an item ---- + +/** The start and target sources of a view. */ +export interface DateSelection { + start: string + target: string +} + +export type ItemSchedule = + // Both dates are known: the item spans the days from `start` to `target`, both inclusive + | { kind: 'range', start: number, target: number, inverted: boolean } + // Only one date is known: it is drawn as a marker + | { kind: 'marker', day: number, role: DateRole } + | { kind: 'unscheduled' } + +/** + * Where an item is on the timeline. An item whose start lies after its target is drawn from the earlier to the + * later day and flagged as inverted. + */ +export function resolveSchedule (issue: DateIssue, selection: DateSelection, lookups: DateLookups): ItemSchedule { + const startRef = parseSourceId(selection.start) + const targetRef = parseSourceId(selection.target) + const start = startRef !== undefined ? readSourceDay(issue, startRef, 'start', lookups) : undefined + const target = targetRef !== undefined ? readSourceDay(issue, targetRef, 'target', lookups) : undefined + if (start !== undefined && target !== undefined) { + return start <= target + ? { kind: 'range', start, target, inverted: false } + : { kind: 'range', start: target, target: start, inverted: true } + } + if (start !== undefined) return { kind: 'marker', day: start, role: 'start' } + if (target !== undefined) return { kind: 'marker', day: target, role: 'target' } + return { kind: 'unscheduled' } +} + +/** Days an item occupies on the axis, used to size it. */ +export function scheduleDays (schedule: ItemSchedule): number[] { + switch (schedule.kind) { + case 'range': + return [schedule.start, schedule.target] + case 'marker': + return [schedule.day] + case 'unscheduled': + return [] + } +} + +/** Splits items into those with dates (bars and markers) and those without, keeping their order. */ +export function partitionBySchedule ( + items: readonly T[], + scheduleOf: (item: T) => ItemSchedule +): { scheduled: T[], unscheduled: T[] } { + const scheduled: T[] = [] + const unscheduled: T[] = [] + for (const item of items) { + if (scheduleOf(item).kind === 'unscheduled') unscheduled.push(item) + else scheduled.push(item) + } + return { scheduled, unscheduled } +} + +/** Timestamp to store for a day picked on the timeline: the start of the day. */ +export function dayToStoredDate (day: number): number { + return dayToTime(day) +} diff --git a/plugins/tracker-resources/src/roadmap/label.ts b/plugins/tracker-resources/src/roadmap/label.ts new file mode 100644 index 0000000000..f81709fa23 --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/label.ts @@ -0,0 +1,108 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// The text on a roadmap item: the fields chosen in the Fields menu, joined with a separator. + +export const LABEL_FIELD_IDENTIFIER = 'identifier' +export const LABEL_FIELD_TITLE = 'title' +export const LABEL_FIELD_STATUS = 'status' +export const LABEL_FIELD_ASSIGNEE = 'assignee' +export const LABEL_FIELD_PRIORITY = 'priority' +export const LABEL_FIELD_COMPONENT = 'component' +export const LABEL_FIELD_MILESTONE = 'milestone' +export const LABEL_FIELD_LABELS = 'labels' +export const LABEL_FIELD_ESTIMATION = 'estimation' +export const CUSTOM_LABEL_PREFIX = 'cf:' + +/** Built-in label fields in the order they are offered. */ +export const BUILTIN_LABEL_FIELDS: readonly string[] = [ + LABEL_FIELD_IDENTIFIER, + LABEL_FIELD_TITLE, + LABEL_FIELD_STATUS, + LABEL_FIELD_ASSIGNEE, + LABEL_FIELD_PRIORITY, + LABEL_FIELD_LABELS, + LABEL_FIELD_COMPONENT, + LABEL_FIELD_MILESTONE, + LABEL_FIELD_ESTIMATION +] + +export function customLabelFieldId (fieldKey: string): string { + return `${CUSTOM_LABEL_PREFIX}${fieldKey}` +} + +export function parseCustomLabelFieldId (id: string): string | undefined { + return id.startsWith(CUSTOM_LABEL_PREFIX) ? id.slice(CUSTOM_LABEL_PREFIX.length) : undefined +} + +export interface LabelIssue { + identifier?: string + title: string + estimation?: number +} + +/** Text of the other fields of an item; undefined or empty for a field the item has no value in. */ +export interface LabelResolvers { + status: (issue: I) => string | undefined + assignee: (issue: I) => string | undefined + priority: (issue: I) => string | undefined + component: (issue: I) => string | undefined + milestone: (issue: I) => string | undefined + labels: (issue: I) => string[] + custom: (issue: I, fieldKey: string) => string | undefined +} + +export const LABEL_SEPARATOR = ' · ' + +/** The label of an item for the chosen fields, in the order of `fields`; fields without a value are left out. */ +export function buildItemLabel (issue: I, fields: readonly string[], r: LabelResolvers): string { + const parts: string[] = [] + for (const id of fields) { + let text: string | undefined + switch (id) { + case LABEL_FIELD_IDENTIFIER: + text = issue.identifier + break + case LABEL_FIELD_TITLE: + text = issue.title + break + case LABEL_FIELD_STATUS: + text = r.status(issue) + break + case LABEL_FIELD_ASSIGNEE: + text = r.assignee(issue) + break + case LABEL_FIELD_PRIORITY: + text = r.priority(issue) + break + case LABEL_FIELD_COMPONENT: + text = r.component(issue) + break + case LABEL_FIELD_MILESTONE: + text = r.milestone(issue) + break + case LABEL_FIELD_LABELS: + text = r.labels(issue).join(', ') + break + case LABEL_FIELD_ESTIMATION: + text = issue.estimation !== undefined && issue.estimation > 0 ? `${issue.estimation} h` : undefined + break + default: { + const key = parseCustomLabelFieldId(id) + text = key !== undefined ? r.custom(issue, key) : undefined + } + } + if (text !== undefined && text.trim() !== '') parts.push(text.trim()) + } + return parts.join(LABEL_SEPARATOR) +} + +/** Adds or removes a field id, keeping the order of `all` so that the label does not depend on the click order. */ +export function toggleLabelField (selected: readonly string[], id: string, all: readonly string[]): string[] { + const next = new Set(selected) + if (next.has(id)) next.delete(id) + else next.add(id) + return all.filter((it) => next.has(it)) +} diff --git a/plugins/tracker-resources/src/roadmap/layout.ts b/plugins/tracker-resources/src/roadmap/layout.ts new file mode 100644 index 0000000000..4aee277b20 --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/layout.ts @@ -0,0 +1,177 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ItemSchedule } from './dates' +import type { RoadmapScale } from './timeScale' + +export const ROW_HEIGHT = 36 +export const GROUP_ROW_HEIGHT = 32 +// Narrowest a bar is drawn, so that a one day item stays visible and can be grabbed at the year zoom +export const MIN_BAR_WIDTH = 8 +export const MARKER_SIZE = 12 + +export interface ItemShape { + kind: 'bar' | 'marker' + // Left edge and width in pixels, relative to the start of the axis + x: number + width: number + // Horizontal middle + center: number +} + +/** + * Where an item is drawn: a range is a bar from the first to the end of the last day, a single date is a marker + * in the middle of its day. Items without dates have no shape. + */ +export function itemShape (schedule: ItemSchedule, scale: RoadmapScale): ItemShape | undefined { + switch (schedule.kind) { + case 'unscheduled': + return undefined + case 'marker': { + const center = scale.dayToX(schedule.day) + scale.pxPerDay / 2 + return { kind: 'marker', x: center - MARKER_SIZE / 2, width: MARKER_SIZE, center } + } + case 'range': { + const x = scale.dayToX(schedule.start) + const width = Math.max(scale.dayToX(schedule.target + 1) - x, MIN_BAR_WIDTH) + return { kind: 'bar', x, width, center: x + width / 2 } + } + } +} + +export type LabelPlacement = 'inside' | 'left' | 'right' + +/** + * Where the text of an item goes: inside the bar when it fits, otherwise next to it on the side that has room. + */ +export function placeLabel (shape: ItemShape, textWidth: number, axisWidth: number, gap: number = 8): LabelPlacement { + if (shape.kind === 'bar' && shape.width >= textWidth + gap * 2) return 'inside' + if (shape.x + shape.width + gap + textWidth <= axisWidth) return 'right' + if (shape.x - gap - textWidth >= 0) return 'left' + return 'right' +} + +export function estimateTextWidth (text: string, charWidth: number = 7): number { + return text.length * charWidth +} + +// ---- rows ---- + +export interface RowGroup { + id: string + // Items that have dates; for a group with sub groups all the items below it + items: T[] + // Sub groups (nested grouping). A group with sub groups draws them instead of its own items. + children?: ReadonlyArray> +} + +export type RoadmapRow = + // `depth` is the nesting level of the group, 0 for the first level + | { type: 'group', id: string, depth: number, y: number, height: number, count: number, collapsed: boolean } + | { type: 'item', id: string, item: T, y: number, height: number, unscheduled: boolean } + | { type: 'unscheduled', id: string, y: number, height: number, count: number, collapsed: boolean } + +export interface RowParams { + groups: ReadonlyArray> + // Items without dates; they are listed after all groups under one header + unscheduled: readonly T[] + idOf: (item: T) => string + // Ids of the collapsed groups; the id UNSCHEDULED_ID collapses the unscheduled section + collapsed: ReadonlySet + // Without grouping there are no group headers + showGroupHeaders: boolean + rowHeight?: number + groupHeight?: number +} + +export const UNSCHEDULED_ID = '#unscheduled' + +export interface RowsLayout { + rows: Array> + height: number +} + +/** + * Vertical layout of the roadmap: an optional header per group followed by its sub groups (or, at the last level, + * its items), and the section of the items without dates at the end. + */ +export function buildRows (params: RowParams): RowsLayout { + const rowHeight = params.rowHeight ?? ROW_HEIGHT + const groupHeight = params.groupHeight ?? GROUP_ROW_HEIGHT + const rows: Array> = [] + let y = 0 + + const addGroups = (groups: ReadonlyArray>, depth: number): void => { + for (const group of groups) { + const collapsed = params.collapsed.has(group.id) + if (params.showGroupHeaders) { + rows.push({ type: 'group', id: group.id, depth, y, height: groupHeight, count: group.items.length, collapsed }) + y += groupHeight + } + if (collapsed && params.showGroupHeaders) continue + if (group.children !== undefined && group.children.length > 0) { + addGroups(group.children, depth + 1) + continue + } + for (const item of group.items) { + rows.push({ type: 'item', id: params.idOf(item), item, y, height: rowHeight, unscheduled: false }) + y += rowHeight + } + } + } + addGroups(params.groups, 0) + + if (params.unscheduled.length > 0) { + const collapsed = params.collapsed.has(UNSCHEDULED_ID) + rows.push({ + type: 'unscheduled', + id: UNSCHEDULED_ID, + y, + height: groupHeight, + count: params.unscheduled.length, + collapsed + }) + y += groupHeight + if (!collapsed) { + for (const item of params.unscheduled) { + rows.push({ type: 'item', id: params.idOf(item), item, y, height: rowHeight, unscheduled: true }) + y += rowHeight + } + } + } + return { rows, height: y } +} + +/** + * Index range [first, last) of the rows that intersect the viewport, widened by `overscan` rows. + * Rows are ordered by `y`. + */ +export function visibleRowRange ( + rows: ReadonlyArray, 'y' | 'height'>>, + top: number, + viewportHeight: number, + overscan: number = 4 +): [number, number] { + if (rows.length === 0) return [0, 0] + const bottom = top + viewportHeight + // First row whose bottom edge is below the top of the viewport + let lo = 0 + let hi = rows.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (rows[mid].y + rows[mid].height <= top) lo = mid + 1 + else hi = mid + } + const first = lo + // First row that starts below the viewport + lo = first + hi = rows.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (rows[mid].y < bottom) lo = mid + 1 + else hi = mid + } + return [Math.max(0, first - overscan), Math.min(rows.length, lo + overscan)] +} diff --git a/plugins/tracker-resources/src/roadmap/markers.ts b/plugins/tracker-resources/src/roadmap/markers.ts new file mode 100644 index 0000000000..83aa71176c --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/markers.ts @@ -0,0 +1,132 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + iterationDays, + parseSourceId, + readSourceDay, + type DateIssue, + type DateLookups, + type DateSource, + type IterationDates +} from './dates' +import { toDay } from './timeScale' + +export type RoadmapMarkerKind = 'milestone' | 'iteration' | 'date' + +/** What is marked on the roadmap (the Markers menu); saved with the view. */ +export interface MarkerSettings { + // A flag at the target date of every milestone + milestones: boolean + // Keys of the Iteration fields whose iterations are marked + iterations: string[] + // Ids of the date sources (issue dates, Date fields) whose dates of the items are marked + dates: string[] +} + +export const DEFAULT_MARKER_SETTINGS: MarkerSettings = { milestones: false, iterations: [], dates: [] } + +export interface RoadmapMarker { + id: string + kind: RoadmapMarkerKind + // The day of the marker line (for an iteration its first day) + day: number + // Last day of an iteration, for shading its span + endDay?: number + label: string + // Palette color index of a milestone + color?: number + // Items on a date marker's day + count?: number +} + +export interface MilestoneLike { + _id: string + label: string + targetDate: number + color?: number +} + +export interface IterationFieldLike { + key: string + label: string +} + +export interface MarkerParams { + settings: MarkerSettings + milestones: readonly MilestoneLike[] + // Iteration fields and their iterations + iterationFields: readonly IterationFieldLike[] + iterations: (fieldKey: string) => readonly (IterationDates & { label: string })[] + // Date sources by id + sources: ReadonlyMap + issues: readonly DateIssue[] + lookups: DateLookups +} + +// A marker for every date of every item would drown the roadmap: the busiest days win +const MAX_DATE_MARKERS = 100 + +/** + * The vertical markers to draw, ordered by day: milestone flags, iteration boundaries and the dates of the items + * of the chosen sources. + */ +export function collectMarkers (params: MarkerParams): RoadmapMarker[] { + const { settings } = params + const res: RoadmapMarker[] = [] + + if (settings.milestones) { + for (const m of params.milestones) { + if (typeof m.targetDate !== 'number' || !Number.isFinite(m.targetDate)) continue + res.push({ id: `milestone:${m._id}`, kind: 'milestone', day: toDay(m.targetDate), label: m.label, color: m.color }) + } + } + + for (const key of settings.iterations) { + if (!params.iterationFields.some((f) => f.key === key)) continue + for (const it of params.iterations(key)) { + if (it.isBreak === true) continue + const { start, end } = iterationDays(it) + res.push({ id: `iteration:${it._id}`, kind: 'iteration', day: start, endDay: end, label: it.label }) + } + } + + for (const sourceId of settings.dates) { + const source = params.sources.get(sourceId) + const ref = parseSourceId(sourceId) + if (source === undefined || ref === undefined || ref.kind === 'iteration') continue + const counts = new Map() + for (const issue of params.issues) { + const day = readSourceDay(issue, ref, 'start', params.lookups) + if (day !== undefined) counts.set(day, (counts.get(day) ?? 0) + 1) + } + const busiest = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]).slice(0, MAX_DATE_MARKERS) + for (const [day, count] of busiest) { + res.push({ id: `date:${sourceId}:${day}`, kind: 'date', day, label: source.label, count }) + } + } + + return res.sort((a, b) => a.day - b.day || a.id.localeCompare(b.id)) +} + +/** Markers whose line or span intersects the axis [startDay, endDay). */ +export function markersInRange (markers: readonly RoadmapMarker[], startDay: number, endDay: number): RoadmapMarker[] { + return markers.filter((m) => m.day < endDay && (m.endDay ?? m.day) >= startDay) +} + +/** Flips one boolean or list member of the settings; returns a new object. */ +export function toggleMarker ( + settings: MarkerSettings, + target: { kind: 'milestones' } | { kind: 'iterations' | 'dates', id: string } +): MarkerSettings { + if (target.kind === 'milestones') return { ...settings, milestones: !settings.milestones } + const list = settings[target.kind] + const next = list.includes(target.id) ? list.filter((it) => it !== target.id) : [...list, target.id] + return { ...settings, [target.kind]: next } +} + +export function hasAnyMarker (settings: MarkerSettings): boolean { + return settings.milestones || settings.iterations.length > 0 || settings.dates.length > 0 +} diff --git a/plugins/tracker-resources/src/roadmap/reschedule.ts b/plugins/tracker-resources/src/roadmap/reschedule.ts new file mode 100644 index 0000000000..af8ac6926b --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/reschedule.ts @@ -0,0 +1,273 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + dayToStoredDate, + iterationDays, + parseSourceId, + readSourceDay, + type DateIssue, + type DateLookups, + type DateRole, + type DateSelection, + type DateSource, + type DateSourceKind, + type IterationDates, + type ItemSchedule +} from './dates' +import { shiftTimestamp } from './timeScale' + +/** Dragging the bar moves both dates, dragging an edge moves only that date. */ +export type RescheduleMode = 'move' | 'resize-start' | 'resize-end' + +/** A range keeps at least this many days (a single day). */ +export const MIN_DURATION_DAYS = 1 + +export interface DayRange { + start: number + target: number +} + +/** + * The range after the pointer moved by `delta` days. Moving keeps the duration, resizing never lets the + * start pass the target or the other way round (a range stays at least one day). + */ +export function applyDelta (range: DayRange, mode: RescheduleMode, delta: number): DayRange { + switch (mode) { + case 'move': + return { start: range.start + delta, target: range.target + delta } + case 'resize-start': + return { start: Math.min(range.start + delta, range.target), target: range.target } + case 'resize-end': + return { start: range.start, target: Math.max(range.target + delta, range.start) } + } +} + +/** The schedule of an item while it is dragged. Edges of markers and inverted ranges cannot be resized. */ +export function previewSchedule (schedule: ItemSchedule, mode: RescheduleMode, delta: number): ItemSchedule { + switch (schedule.kind) { + case 'unscheduled': + return schedule + case 'marker': + return mode === 'move' ? { ...schedule, day: schedule.day + delta } : schedule + case 'range': { + if (schedule.inverted && mode !== 'move') return schedule + const next = applyDelta(schedule, mode, delta) + return { ...schedule, start: next.start, target: next.target } + } + } +} + +// ---- iterations ---- + +/** + * The iteration (breaks excluded) that contains the day, or the closest one when none does. + */ +export function iterationAt (iterations: readonly IterationDates[], day: number): IterationDates | undefined { + let best: IterationDates | undefined + let bestDistance = Number.POSITIVE_INFINITY + for (const it of iterations) { + if (it.isBreak === true) continue + const { start, end } = iterationDays(it) + const distance = day < start ? start - day : day > end ? day - end : 0 + if (distance < bestDistance) { + best = it + bestDistance = distance + } + } + return best +} + +/** + * The iteration (breaks excluded) an item snaps to when its start (or target) is dragged to `day`: the one whose + * first day (or last day) is closest, so dragging by about an iteration moves the item to the neighbour. + */ +export function snapIteration ( + iterations: readonly IterationDates[], + day: number, + role: DateRole +): IterationDates | undefined { + let best: IterationDates | undefined + let bestDistance = Number.POSITIVE_INFINITY + for (const it of iterations) { + if (it.isBreak === true) continue + const days = iterationDays(it) + const distance = Math.abs((role === 'start' ? days.start : days.end) - day) + if (distance < bestDistance) { + best = it + bestDistance = distance + } + } + return best +} + +// ---- plans ---- + +/** A value to store in one date source of an item: a timestamp, or an iteration id for an Iteration field. */ +export interface DateWrite { + sourceId: string + kind: DateSourceKind + key: string + value: number | string +} + +export type PlanFailure = 'readonly' | 'unsupported' | 'unchanged' | 'noSchedule' + +export type DatePlan = { ok: true, writes: DateWrite[] } | { ok: false, reason: PlanFailure } + +export interface PlanContext { + issue: DateIssue + schedule: ItemSchedule + selection: DateSelection + sources: ReadonlyMap + lookups: DateLookups +} + +interface Endpoint { + role: DateRole + sourceId: string + oldDay: number + newDay: number +} + +function currentTimestamp (issue: DateIssue, kind: DateSourceKind, key: string): number | undefined { + const raw = kind === 'issue' ? (issue as Record)[key] : issue.customFields?.[key] + return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined +} + +function currentIterationId (issue: DateIssue, key: string): string | undefined { + const raw = issue.customFields?.[key] + return typeof raw === 'string' ? raw : undefined +} + +function endpointsOf (ctx: PlanContext, mode: RescheduleMode, delta: number): Endpoint[] | PlanFailure { + const { schedule, selection } = ctx + switch (schedule.kind) { + case 'unscheduled': + return 'noSchedule' + case 'marker': { + if (mode !== 'move') return 'unsupported' + return [ + { + role: schedule.role, + sourceId: schedule.role === 'start' ? selection.start : selection.target, + oldDay: schedule.day, + newDay: schedule.day + delta + } + ] + } + case 'range': { + if (schedule.inverted && mode !== 'move') return 'unsupported' + const next = applyDelta(schedule, mode, delta) + // An inverted range is drawn from the target source's day to the start source's day + const startOld = schedule.inverted ? schedule.target : schedule.start + const targetOld = schedule.inverted ? schedule.start : schedule.target + const startNew = schedule.inverted ? next.target : next.start + const targetNew = schedule.inverted ? next.start : next.target + const res: Endpoint[] = [] + if (mode !== 'resize-end') { + res.push({ role: 'start', sourceId: selection.start, oldDay: startOld, newDay: startNew }) + } + if (mode !== 'resize-start') { + res.push({ role: 'target', sourceId: selection.target, oldDay: targetOld, newDay: targetNew }) + } + return res + } + } +} + +/** + * Work out what to store when an item is dragged by `delta` days. A move keeps the duration; a resize writes + * only the edge that moved. Date sources receive the moved timestamp (keeping its time of day), Iteration sources + * the iteration that the new day snaps to (dragging an item over an iteration field moves it to another iteration). + * The milestone dates are read only: an item that takes one of its dates from there cannot be moved, only + * the edge that comes from a writable source can be resized. + */ +export function planReschedule (ctx: PlanContext, mode: RescheduleMode, delta: number): DatePlan { + const endpoints = endpointsOf(ctx, mode, delta) + if (typeof endpoints === 'string') return { ok: false, reason: endpoints } + if (delta === 0) return { ok: false, reason: 'unchanged' } + + const writes = new Map() + for (const e of endpoints) { + const source = ctx.sources.get(e.sourceId) + if (source === undefined || !source.writable) return { ok: false, reason: 'readonly' } + if (e.newDay === e.oldDay) continue + if (source.kind === 'iteration') { + const target = snapIteration(ctx.lookups.iterations(source.key), e.newDay, e.role) + if (target === undefined || target._id === currentIterationId(ctx.issue, source.key)) continue + writes.set(source.id, { sourceId: source.id, kind: source.kind, key: source.key, value: target._id }) + } else { + const ts = currentTimestamp(ctx.issue, source.kind, source.key) + if (ts === undefined) continue + writes.set(source.id, { + sourceId: source.id, + kind: source.kind, + key: source.key, + value: shiftTimestamp(ts, e.newDay - e.oldDay) + }) + } + } + return writes.size === 0 ? { ok: false, reason: 'unchanged' } : { ok: true, writes: [...writes.values()] } +} + +export interface ScheduleContext { + issue: DateIssue + selection: DateSelection + sources: ReadonlyMap + lookups: DateLookups +} + +/** + * Work out what to store to put an item on a day of the timeline: both dates are set to the day (an Iteration source + * to the iteration that contains it). Dates the item already has are kept, so only the missing one is added to an + * item that is shown as a marker. Read only sources are left alone. + */ +export function planSchedule (ctx: ScheduleContext, day: number): DatePlan { + const writes = new Map() + const roles: Array<[DateRole, string]> = [ + ['start', ctx.selection.start], + ['target', ctx.selection.target] + ] + let writable = false + for (const [role, id] of roles) { + const source = ctx.sources.get(id) + if (source === undefined || !source.writable) continue + writable = true + const ref = parseSourceId(id) + if (ref !== undefined && readSourceDay(ctx.issue, ref, role, ctx.lookups) !== undefined) continue + if (writes.has(source.id)) continue + if (source.kind === 'iteration') { + const target = iterationAt(ctx.lookups.iterations(source.key), day) + if (target === undefined) continue + writes.set(source.id, { sourceId: source.id, kind: source.kind, key: source.key, value: target._id }) + } else { + writes.set(source.id, { sourceId: source.id, kind: source.kind, key: source.key, value: dayToStoredDate(day) }) + } + } + if (!writable) return { ok: false, reason: 'readonly' } + return writes.size === 0 ? { ok: false, reason: 'unchanged' } : { ok: true, writes: [...writes.values()] } +} + +/** + * The attributes of the issue to update for the writes. Custom fields go into one merged `customFields` record. + */ +export function buildIssuePatch ( + issue: { customFields?: Record }, + writes: readonly DateWrite[] +): Record { + const patch: Record = {} + let custom: Record | undefined + for (const w of writes) { + if (w.kind === 'issue') { + patch[w.key] = w.value + } else if (w.kind === 'field' || w.kind === 'iteration') { + custom ??= { ...(issue.customFields ?? {}) } + custom[w.key] = w.value + } + } + if (custom !== undefined) patch.customFields = custom + return patch +} diff --git a/plugins/tracker-resources/src/roadmap/timeScale.ts b/plugins/tracker-resources/src/roadmap/timeScale.ts new file mode 100644 index 0000000000..4f195507df --- /dev/null +++ b/plugins/tracker-resources/src/roadmap/timeScale.ts @@ -0,0 +1,283 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Time axis of the Roadmap layout. Everything is computed on whole calendar days in the local time zone +// (like iterations and Date fields), so that daylight saving shifts never move an item by a day. +// A day is addressed by its index: the number of days since 1970-01-01 of the local calendar date. + +/** GitHub Projects zoom levels of a roadmap. */ +export type RoadmapZoom = 'month' | 'quarter' | 'year' + +export const ROADMAP_ZOOMS: readonly RoadmapZoom[] = ['month', 'quarter', 'year'] + +export const DEFAULT_ROADMAP_ZOOM: RoadmapZoom = 'month' + +const DAY_MS = 86400000 + +/** Pixels of one day at each zoom level. */ +export const PX_PER_DAY: Readonly> = { + month: 8, + quarter: 3, + year: 1 +} + +// Shortest time span the axis covers, so that a roadmap with few dates still has room to schedule in +const MIN_SPAN_DAYS: Readonly> = { + month: 186, + quarter: 366, + year: 1096 +} + +// Dates further from today than this do not stretch the axis (a typo like year 1970 must not create a huge canvas) +export const MAX_DISTANCE_DAYS = 3660 + +export function isRoadmapZoom (value: unknown): value is RoadmapZoom { + return value === 'month' || value === 'quarter' || value === 'year' +} + +// ---- days ---- + +/** Index of the local calendar day containing the timestamp. */ +export function toDay (ts: number): number { + const d = new Date(ts) + return Math.round(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY_MS) +} + +/** Timestamp of the start (local midnight) of the day. */ +export function dayToTime (day: number): number { + const u = new Date(day * DAY_MS) + return new Date(u.getUTCFullYear(), u.getUTCMonth(), u.getUTCDate()).getTime() +} + +/** + * Moves a timestamp by whole calendar days and keeps its time of day, so that a date that carries a time + * (some pickers store one) keeps it when an item is rescheduled. + */ +export function shiftTimestamp (ts: number, days: number): number { + const d = new Date(ts) + d.setDate(d.getDate() + days) + return d.getTime() +} + +export interface DayParts { + year: number + // 0-11 + month: number + // 1-31 + date: number + // 0 (Sunday) - 6 + weekday: number +} + +export function dayParts (day: number): DayParts { + const u = new Date(day * DAY_MS) + return { year: u.getUTCFullYear(), month: u.getUTCMonth(), date: u.getUTCDate(), weekday: u.getUTCDay() } +} + +function utcDay (year: number, month: number, date: number = 1): number { + return Math.round(Date.UTC(year, month, date) / DAY_MS) +} + +export function monthStart (day: number): number { + const p = dayParts(day) + return utcDay(p.year, p.month) +} + +export function quarterStart (day: number): number { + const p = dayParts(day) + return utcDay(p.year, p.month - (p.month % 3)) +} + +export function yearStart (day: number): number { + return utcDay(dayParts(day).year, 0) +} + +/** First day of the unit that follows the one that starts at `start`. */ +function nextUnitStart (unit: TickKind, start: number): number { + const p = dayParts(start) + switch (unit) { + case 'week': + return start + 7 + case 'month': + return utcDay(p.year, p.month + 1) + case 'quarter': + return utcDay(p.year, p.month + 3) + case 'year': + return utcDay(p.year + 1, 0) + } +} + +/** The Monday of the week containing the day. */ +export function weekStart (day: number): number { + const wd = dayParts(day).weekday + return day - ((wd + 6) % 7) +} + +// ---- scale ---- + +export interface RoadmapScale { + zoom: RoadmapZoom + pxPerDay: number + // First day of the axis + startDay: number + // First day after the axis + endDay: number + // Width of the axis in pixels + width: number + // X of the left edge of the day + dayToX: (day: number) => number + // The day under an x coordinate (relative to the axis start) + xToDay: (x: number) => number +} + +export function createScale (zoom: RoadmapZoom, startDay: number, endDay: number): RoadmapScale { + const pxPerDay = PX_PER_DAY[zoom] + const end = Math.max(endDay, startDay + 1) + return { + zoom, + pxPerDay, + startDay, + endDay: end, + width: (end - startDay) * pxPerDay, + dayToX: (day) => (day - startDay) * pxPerDay, + xToDay: (x) => startDay + Math.floor(x / pxPerDay) + } +} + +function unitStart (zoom: Exclude, day: number): number { + switch (zoom) { + case 'month': + return monthStart(day) + case 'quarter': + return quarterStart(day) + case 'year': + return yearStart(day) + } +} + +/** + * Widens a day range to whole units of the zoom level: months, quarters or years. + * `endDay` of the result is exclusive. + */ +export function snapRange (zoom: RoadmapZoom, fromDay: number, toDay: number): { startDay: number, endDay: number } { + const startDay = unitStart(zoom, fromDay) + const last = unitStart(zoom, Math.max(toDay, fromDay)) + return { startDay, endDay: nextUnitStart(zoom, last) } +} + +/** + * Range of the axis for the dates of the items: covers all of them and today, extends to the minimal span of + * the zoom level (with more room ahead of today than behind it) and is snapped to whole units. + * Dates too far from today are ignored. + */ +export function computeRange ( + zoom: RoadmapZoom, + days: Iterable, + today: number +): { startDay: number, endDay: number } { + const lowest = today - MAX_DISTANCE_DAYS + const highest = today + MAX_DISTANCE_DAYS + const span = MIN_SPAN_DAYS[zoom] + let from = today - Math.floor(span / 3) + let to = today + Math.ceil((span * 2) / 3) + for (const d of days) { + if (d < lowest || d > highest) continue + if (d < from) from = d + if (d > to) to = d + } + return snapRange(zoom, from, to) +} + +// ---- header ---- + +export type TickKind = 'year' | 'quarter' | 'month' | 'week' + +export interface RoadmapTick { + kind: TickKind + startDay: number + // First day after the tick + endDay: number + x: number + width: number + year: number + // Month 0-11, quarter 1-4 or the day of the month of a week's Monday + index: number +} + +export interface HeaderRows { + // Coarse row (months, quarters, years) + top: RoadmapTick[] + // Fine row (weeks, months, quarters); its boundaries are the grid lines + bottom: RoadmapTick[] +} + +function buildTicks (scale: RoadmapScale, kind: TickKind): RoadmapTick[] { + const res: RoadmapTick[] = [] + let cursor = kind === 'week' ? weekStart(scale.startDay) : unitStart(kind, scale.startDay) + while (cursor < scale.endDay) { + const next = nextUnitStart(kind, cursor) + const from = Math.max(cursor, scale.startDay) + const to = Math.min(next, scale.endDay) + const p = dayParts(cursor) + res.push({ + kind, + startDay: from, + endDay: to, + x: scale.dayToX(from), + width: (to - from) * scale.pxPerDay, + year: p.year, + index: kind === 'month' ? p.month : kind === 'quarter' ? Math.floor(p.month / 3) + 1 : kind === 'week' ? p.date : p.year + }) + cursor = next + } + return res +} + +/** Header rows of a zoom level: months over weeks, quarters over months, years over quarters. */ +export function headerRows (scale: RoadmapScale): HeaderRows { + switch (scale.zoom) { + case 'month': + return { top: buildTicks(scale, 'month'), bottom: buildTicks(scale, 'week') } + case 'quarter': + return { top: buildTicks(scale, 'quarter'), bottom: buildTicks(scale, 'month') } + case 'year': + return { top: buildTicks(scale, 'year'), bottom: buildTicks(scale, 'quarter') } + } +} + +/** Label of a tick, e.g. "October 2026", "Q4 2026", "Oct" or "5". */ +export function formatTick (tick: RoadmapTick, locale?: string, row: 'top' | 'bottom' = 'top'): string { + switch (tick.kind) { + case 'year': + return String(tick.year) + case 'quarter': + return row === 'top' ? `Q${tick.index} ${tick.year}` : `Q${tick.index}` + case 'month': { + const date = new Date(Date.UTC(tick.year, tick.index, 1)) + return new Intl.DateTimeFormat(locale, { + month: row === 'top' ? 'long' : 'short', + ...(row === 'top' ? { year: 'numeric' as const } : {}), + timeZone: 'UTC' + }).format(date) + } + case 'week': + return String(tick.index) + } +} + +// ---- interaction ---- + +/** Whole days a pointer movement of `dx` pixels stands for. */ +export function pixelsToDays (dx: number, pxPerDay: number): number { + const days = Math.round(dx / pxPerDay) + // Avoid -0 + return days === 0 ? 0 : days +} + +/** Scroll offset (of the axis) that puts the day at `anchor` (0..1) of a viewport of `viewportWidth` pixels. */ +export function scrollLeftForDay (scale: RoadmapScale, day: number, viewportWidth: number, anchor: number = 0.33): number { + const x = scale.dayToX(day) - viewportWidth * anchor + return Math.max(0, Math.min(x, Math.max(0, scale.width - viewportWidth))) +} diff --git a/plugins/tracker-resources/src/slice/__tests__/slice.test.ts b/plugins/tracker-resources/src/slice/__tests__/slice.test.ts new file mode 100644 index 0000000000..6fc3c9ff81 --- /dev/null +++ b/plugins/tracker-resources/src/slice/__tests__/slice.test.ts @@ -0,0 +1,394 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, type Iteration, type ProjectField } from '@hcengineering/tracker' +import { buildSummableFields } from '../../fieldSum/config' +import { buildIssueFilterSchema } from '../../issueFilter' +import { + normalizeSliceConfig, + readSliceConfig, + sanitizeSliceValues, + selectAllValues, + SLICE_NONE, + SLICE_OPTION_KEY, + toggleSliceValue, + withSliceConfig, + withSliceField, + type SliceConfig +} from '../config' +import { + buildSliceFields, + defaultSliceField, + listsEmptyValues, + resolveSliceField, + sliceFieldId, + sliceProjection +} from '../fields' +import { collectSliceSums, SLICE_ALL } from '../sums' +import { collectSliceValues, createSlicePredicate } from '../values' + +function field (key: string, label: string, type: ProjectFieldType, options?: Array<[string, string]>): ProjectField { + return { + _id: key, + key, + label, + type, + position: 0, + options: options?.map(([value, l]) => ({ value, label: l })) + } as unknown as ProjectField +} + +const customFields = [ + field('size', 'Size', ProjectFieldType.SingleSelect, [ + ['s', 'Small'], + ['m', 'Medium'], + ['l', 'Large'] + ]), + field('areas', 'Areas', ProjectFieldType.MultiSelect, [ + ['ui', 'UI'], + ['api', 'API'] + ]), + field('points', 'Points', ProjectFieldType.Number), + field('notes', 'Notes', ProjectFieldType.Text), + field('sprint', 'Sprint', ProjectFieldType.Iteration) +] + +const iterations = [ + { _id: 'it1', label: 'Iteration 1', field: 'sprint', isBreak: false, startDate: 0, duration: 7 }, + { _id: 'it2', label: 'Iteration 2', field: 'sprint', isBreak: false, startDate: 0, duration: 7 } +] as unknown as Iteration[] + +const schema = buildIssueFilterSchema({ + statuses: [ + { id: 'todo', name: 'Todo' }, + { id: 'doing', name: 'Doing' }, + { id: 'done', name: 'Done' } + ], + priorities: [ + { id: 0, name: 'No priority' }, + { id: 1, name: 'Urgent' }, + { id: 2, name: 'High' } + ], + assignees: [ + { id: 'u1', name: 'Alice' }, + { id: 'u2', name: 'Bob' } + ], + components: [{ id: 'c1', name: 'Core' }], + milestones: [{ id: 'm1', name: 'Beta' }], + labels: [ + { id: 'l1', name: 'bug' }, + { id: 'l2', name: 'ui' } + ], + labelRefs: [ + { issue: 'i1', label: 'l1' }, + { issue: 'i1', label: 'l2' }, + { issue: 'i2', label: 'l1' } + ], + customFields, + iterations, + noParentId: 'none' +}) + +const specOf = (id: string): any => { + const found = buildSliceFields(schema).find((f) => sliceFieldId(f) === id) + if (found === undefined) throw new Error(`no slice field ${id}`) + return found +} + +const issues = [ + { _id: 'i1', status: 'todo', priority: 1, assignee: 'u1', component: 'c1', customFields: { size: 's', areas: ['ui', 'api'], sprint: 'it1', points: 3 } }, + { _id: 'i2', status: 'todo', priority: 2, assignee: 'u1', component: null, customFields: { size: 's', areas: ['ui'], sprint: 'it2' } }, + { _id: 'i3', status: 'doing', priority: 0, assignee: null, customFields: { size: 'l', sprint: 'gone' } }, + { _id: 'i4', status: 'done', priority: 1, assignee: 'u2' } +] + +describe('slice fields', () => { + it('offers the built-in fields and the select, multi select and iteration fields', () => { + expect(buildSliceFields(schema).map(sliceFieldId)).toEqual([ + 'status', + 'priority', + 'assignee', + 'label', + 'component', + 'milestone', + 'customFields.size', + 'customFields.areas', + 'customFields.sprint' + ]) + }) + + it('does not offer title, dates, numbers, text or the parent', () => { + const ids = buildSliceFields(schema).map(sliceFieldId) + for (const bad of ['title', 'due', 'start', 'estimate', 'parent-issue', 'sub-issues', 'customFields.points', 'customFields.notes']) { + expect(ids).not.toContain(bad) + } + }) + + it('finds the field of the settings by its id, and survives a rename of a custom field', () => { + const fields = buildSliceFields(schema) + expect(resolveSliceField({ field: 'customFields.size', value: [] }, fields)?.key).toBe('size') + expect(resolveSliceField({ field: 'customFields.gone', value: [] }, fields)).toBeUndefined() + expect(resolveSliceField(undefined, fields)).toBeUndefined() + expect(defaultSliceField(fields)?.name).toBe('status') + expect(defaultSliceField([])).toBeUndefined() + }) + + it('knows what a field needs to be loaded', () => { + expect(sliceProjection(specOf('status'))).toEqual(['_id', 'status']) + expect(sliceProjection(specOf('customFields.size'))).toEqual(['_id', 'customFields']) + expect(listsEmptyValues(specOf('customFields.size'))).toBe(true) + expect(listsEmptyValues(specOf('status'))).toBe(false) + }) +}) + +describe('slice config', () => { + it('reads whatever is stored defensively', () => { + expect(normalizeSliceConfig(undefined)).toBeUndefined() + expect(normalizeSliceConfig('status')).toBeUndefined() + expect(normalizeSliceConfig({ field: '' })).toBeUndefined() + expect(normalizeSliceConfig({ field: 'status' })).toEqual({ field: 'status', value: [] }) + expect(normalizeSliceConfig({ field: 'status', value: ['a', 'a', '', 5, 'b'] })).toEqual({ + field: 'status', + value: ['a', 'b'] + }) + // A single value, as the key `value` suggests + expect(normalizeSliceConfig({ field: 'status', value: 'todo' })).toEqual({ field: 'status', value: ['todo'] }) + expect(readSliceConfig({ [SLICE_OPTION_KEY]: { field: 'priority', value: [] } })).toEqual({ field: 'priority', value: [] }) + expect(readSliceConfig(undefined)).toBeUndefined() + }) + + it('stores nothing when the panel is closed', () => { + const opts = { groupBy: ['status'], [SLICE_OPTION_KEY]: { field: 'status', value: ['todo'] } } + expect(SLICE_OPTION_KEY in withSliceConfig(opts, undefined)).toBe(false) + expect(withSliceConfig({ groupBy: ['status'] }, undefined)).toEqual({ groupBy: ['status'] }) + expect(withSliceConfig>({}, { field: 'status', value: ['x'] })[SLICE_OPTION_KEY]).toEqual({ field: 'status', value: ['x'] }) + expect(opts[SLICE_OPTION_KEY].value).toEqual(['todo']) + }) + + it('drops the chosen values when the field changes', () => { + const config: SliceConfig = { field: 'status', value: ['todo'] } + expect(withSliceField(config, 'status')).toBe(config) + expect(withSliceField(config, 'priority')).toEqual({ field: 'priority', value: [] }) + expect(withSliceField(undefined, 'status')).toEqual({ field: 'status', value: [] }) + expect(selectAllValues(config)).toEqual({ field: 'status', value: [] }) + }) + + it('chooses one value on a click and goes back to All on a second click', () => { + let config: SliceConfig = { field: 'status', value: [] } + config = toggleSliceValue(config, 'todo', false) + expect(config.value).toEqual(['todo']) + config = toggleSliceValue(config, 'doing', false) + expect(config.value).toEqual(['doing']) + config = toggleSliceValue(config, 'doing', false) + expect(config.value).toEqual([]) + }) + + it('adds and removes values with Cmd/Ctrl', () => { + let config: SliceConfig = { field: 'status', value: ['todo'] } + config = toggleSliceValue(config, 'doing', true) + expect(config.value).toEqual(['todo', 'doing']) + config = toggleSliceValue(config, SLICE_NONE, true) + expect(config.value).toEqual(['todo', 'doing', SLICE_NONE]) + config = toggleSliceValue(config, 'todo', true) + expect(config.value).toEqual(['doing', SLICE_NONE]) + // a plain click on one of several chosen values chooses it alone + expect(toggleSliceValue(config, 'doing', false).value).toEqual(['doing']) + }) + + it('drops chosen values that were deleted', () => { + expect(sanitizeSliceValues(['a', 'gone', SLICE_NONE], new Set(['a', 'b']))).toEqual(['a', SLICE_NONE]) + expect(sanitizeSliceValues([], new Set())).toEqual([]) + }) +}) + +describe('slice values', () => { + it('counts the items of every status, and hides the ones nobody has', () => { + const res = collectSliceValues(specOf('status'), issues) + expect(res.total).toBe(4) + expect(res.none).toBe(0) + expect(res.values.map((v) => [v.id, v.label, v.count])).toEqual([ + ['todo', 'Todo', 2], + ['doing', 'Doing', 1], + ['done', 'Done', 1] + ]) + expect(collectSliceValues(specOf('status'), issues.slice(0, 2)).values.map((v) => v.id)).toEqual(['todo']) + }) + + it('keeps a chosen value in the list even when nobody has it, so that the choice can be undone', () => { + const res = collectSliceValues(specOf('status'), issues.slice(0, 2), { keep: ['done'] }) + expect(res.values.map((v) => [v.id, v.count])).toEqual([ + ['todo', 2], + ['done', 0] + ]) + }) + + it('lists values nobody has when asked, and puts the listed order first', () => { + const res = collectSliceValues(specOf('status'), issues.slice(0, 2), { includeEmpty: true, order: ['done', 'doing', 'todo'] }) + expect(res.values.map((v) => [v.id, v.count])).toEqual([ + ['done', 0], + ['doing', 0], + ['todo', 2] + ]) + }) + + it('counts priorities by their number, and items without a priority as none', () => { + const res = collectSliceValues(specOf('priority'), issues) + expect(res.values.map((v) => [v.label, v.count])).toEqual([ + ['No priority', 1], + ['Urgent', 2], + ['High', 1] + ]) + }) + + it('counts items without a value as none', () => { + const res = collectSliceValues(specOf('assignee'), issues) + expect(res.none).toBe(1) + expect(res.values.map((v) => [v.label, v.count])).toEqual([ + ['Alice', 2], + ['Bob', 1] + ]) + expect(collectSliceValues(specOf('component'), issues).none).toBe(3) + }) + + it('counts an item once for each of its labels', () => { + const res = collectSliceValues(specOf('label'), issues) + expect(res.values.map((v) => [v.label, v.count])).toEqual([ + ['bug', 2], + ['ui', 1] + ]) + expect(res.none).toBe(2) + }) + + it('counts multi select values once per item', () => { + const res = collectSliceValues(specOf('customFields.areas'), issues, { includeEmpty: true }) + expect(res.values.map((v) => [v.label, v.count])).toEqual([ + ['UI', 2], + ['API', 1] + ]) + expect(res.none).toBe(2) + }) + + it('lists every option of a custom select with its count, in the order of the field', () => { + const res = collectSliceValues(specOf('customFields.size'), issues, { includeEmpty: true }) + expect(res.values.map((v) => [v.label, v.count])).toEqual([ + ['Small', 2], + ['Medium', 0], + ['Large', 1] + ]) + expect(res.none).toBe(1) + }) + + it('counts a deleted iteration as none', () => { + const res = collectSliceValues(specOf('customFields.sprint'), issues, { includeEmpty: true }) + expect(res.values.map((v) => [v.label, v.count])).toEqual([ + ['Iteration 1', 1], + ['Iteration 2', 1] + ]) + // i3 refers to a deleted iteration, i4 has no custom fields + expect(res.none).toBe(2) + }) + + it('sorts by label when asked', () => { + const res = collectSliceValues(specOf('assignee'), issues, { sortByLabel: true }) + expect(res.values.map((v) => v.label)).toEqual(['Alice', 'Bob']) + const reversed = { ...specOf('assignee'), options: [...specOf('assignee').options].reverse() } + expect(collectSliceValues(reversed, issues, { sortByLabel: true }).values.map((v) => v.label)).toEqual(['Alice', 'Bob']) + expect(collectSliceValues(reversed, issues).values.map((v) => v.label)).toEqual(['Bob', 'Alice']) + }) + + it('has no values for no items', () => { + expect(collectSliceValues(specOf('status'), [])).toEqual({ values: [], none: 0, total: 0 }) + }) + + it('handles a large list', () => { + const many = Array.from({ length: 50000 }, (_, i) => ({ _id: `x${i}`, status: i % 3 === 0 ? 'todo' : 'done' })) + const res = collectSliceValues(specOf('status'), many) + expect(res.values.find((v) => v.id === 'todo')?.count).toBe(16667) + expect(res.values.find((v) => v.id === 'done')?.count).toBe(33333) + }) +}) + +describe('slice predicate', () => { + const ids = (spec: any, config: SliceConfig | undefined): string[] => + issues.filter(createSlicePredicate(spec, config)).map((i) => i._id) + + it('lets every item through for All or no slice', () => { + expect(ids(specOf('status'), undefined)).toEqual(['i1', 'i2', 'i3', 'i4']) + expect(ids(specOf('status'), { field: 'status', value: [] })).toEqual(['i1', 'i2', 'i3', 'i4']) + }) + + it('keeps the items with the chosen value', () => { + expect(ids(specOf('status'), { field: 'status', value: ['todo'] })).toEqual(['i1', 'i2']) + expect(ids(specOf('priority'), { field: 'priority', value: ['1'] })).toEqual(['i1', 'i4']) + }) + + it('keeps the items with any of several chosen values', () => { + expect(ids(specOf('status'), { field: 'status', value: ['doing', 'done'] })).toEqual(['i3', 'i4']) + }) + + it('keeps the items without a value for the none entry, alone or with others', () => { + expect(ids(specOf('assignee'), { field: 'assignee', value: [SLICE_NONE] })).toEqual(['i3']) + expect(ids(specOf('assignee'), { field: 'assignee', value: [SLICE_NONE, 'u2'] })).toEqual(['i3', 'i4']) + }) + + it('matches an item with several values when one of them is chosen', () => { + expect(ids(specOf('label'), { field: 'label', value: ['l2'] })).toEqual(['i1']) + expect(ids(specOf('label'), { field: 'label', value: ['l1'] })).toEqual(['i1', 'i2']) + expect(ids(specOf('customFields.areas'), { field: 'customFields.areas', value: ['api'] })).toEqual(['i1']) + }) + + it('keeps items on a custom field, and treats a deleted option as none', () => { + expect(ids(specOf('customFields.size'), { field: 'customFields.size', value: ['s'] })).toEqual(['i1', 'i2']) + expect(ids(specOf('customFields.sprint'), { field: 'customFields.sprint', value: [SLICE_NONE] })).toEqual(['i3', 'i4']) + }) + + it('shows nothing for a value that nobody has', () => { + expect(ids(specOf('customFields.size'), { field: 'customFields.size', value: ['m'] })).toEqual([]) + }) + + it('composes with a filter by intersection', () => { + // The slice is one more condition on top of the filter: an item must pass both + const inFilter = (i: any): boolean => i.priority === 1 + const slice = createSlicePredicate(specOf('status'), { field: 'status', value: ['todo', 'done'] }) + expect(issues.filter((i) => inFilter(i) && slice(i)).map((i) => i._id)).toEqual(['i1', 'i4']) + }) +}) + +describe('slice sums', () => { + const summable = buildSummableFields(customFields, 'Estimation') + const withEstimates = [ + { _id: 'i1', status: 'todo', estimation: 2, customFields: { size: 's', areas: ['ui', 'api'], points: 3 } }, + { _id: 'i2', status: 'todo', estimation: 3, customFields: { size: 's', areas: ['ui'], points: 'x' } }, + { _id: 'i3', status: 'doing', estimation: 5, customFields: { size: 'l' } }, + { _id: 'i4', status: 'done' } + ] + + it('gives nothing without fields', () => { + expect(collectSliceSums(specOf('status'), withEstimates, []).size).toBe(0) + }) + + it('sums the chosen field for every value, for none and for all', () => { + const res = collectSliceSums(specOf('status'), withEstimates, [summable[0]]) + expect(res.get('todo')).toBe('5') + expect(res.get('doing')).toBe('5') + expect(res.get('done')).toBe('0') + expect(res.get(SLICE_ALL)).toBe('10') + expect(res.has(SLICE_NONE)).toBe(false) + }) + + it('puts the items without a value under none, and adds up several fields', () => { + const res = collectSliceSums(specOf('customFields.size'), withEstimates, [summable[0], summable[1]]) + expect(res.get('s')).toBe('5 · 3') + expect(res.get('l')).toBe('5 · 0') + expect(res.get(SLICE_NONE)).toBe('0 · 0') + }) + + it('counts an item with several values for each of them, like the counts do', () => { + const res = collectSliceSums(specOf('customFields.areas'), withEstimates, [summable[0]]) + expect(res.get('ui')).toBe('5') + expect(res.get('api')).toBe('2') + expect(res.get(SLICE_ALL)).toBe('10') + }) +}) diff --git a/plugins/tracker-resources/src/slice/config.ts b/plugins/tracker-resources/src/slice/config.ts new file mode 100644 index 0000000000..ce5acea06d --- /dev/null +++ b/plugins/tracker-resources/src/slice/config.ts @@ -0,0 +1,92 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** + * Slice by ("Slice by" panel of GitHub Projects): the panel lists the values of one field and the view shows only + * the items with the chosen values. The settings are stored in the view options under `SLICE_OPTION_KEY`, so that + * they are saved with the saved view, restored when it is opened and take part in its unsaved-changes tracking. + * The panel is open while the key exists. + */ +export interface SliceConfig { + // Id of the field, see `sliceFieldId` (fields.ts) + field: string + // Ids of the chosen values; `SLICE_NONE` stands for the items without a value. Empty means "All" + value: string[] +} + +export const SLICE_OPTION_KEY = 'slice' + +/** Value id of the items that have no value in the field ("No "). */ +export const SLICE_NONE = '__none__' + +function isRecord (raw: unknown): raw is Record { + return raw !== null && typeof raw === 'object' && !Array.isArray(raw) +} + +function uniqueStrings (raw: unknown): string[] { + const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [] + const res: string[] = [] + for (const v of list) { + if (typeof v === 'string' && v !== '' && !res.includes(v)) res.push(v) + } + return res +} + +/** Turns whatever is stored in the view options into a config; undefined when no panel is configured. */ +export function normalizeSliceConfig (raw: unknown): SliceConfig | undefined { + if (!isRecord(raw)) return undefined + if (typeof raw.field !== 'string' || raw.field === '') return undefined + return { field: raw.field, value: uniqueStrings(raw.value) } +} + +export function readSliceConfig (viewOptions: Record | undefined): SliceConfig | undefined { + return normalizeSliceConfig(viewOptions?.[SLICE_OPTION_KEY]) +} + +/** + * The view options with the slice stored; `undefined` closes the panel and removes the key, so that a view that + * never used the panel stays clean. + */ +export function withSliceConfig> (options: O, config: SliceConfig | undefined): O { + const next: Record = { ...options } + if (config === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[SLICE_OPTION_KEY] + } else { + next[SLICE_OPTION_KEY] = { field: config.field, value: [...config.value] } + } + return next as O +} + +/** The config for another field; the chosen values belong to the old field, so they are dropped. */ +export function withSliceField (config: SliceConfig | undefined, field: string): SliceConfig { + return config?.field === field ? config : { field, value: [] } +} + +/** The config with "All" chosen. */ +export function selectAllValues (config: SliceConfig): SliceConfig { + return { field: config.field, value: [] } +} + +/** + * The config after a click on a value. A plain click chooses that value alone, and a click on the only chosen value + * goes back to "All". With `multi` (Cmd/Ctrl) the value is added to or removed from the chosen ones. + */ +export function toggleSliceValue (config: SliceConfig, id: string, multi: boolean): SliceConfig { + if (multi) { + const value = config.value.includes(id) ? config.value.filter((it) => it !== id) : [...config.value, id] + return { field: config.field, value } + } + const only = config.value.length === 1 && config.value[0] === id + return { field: config.field, value: only ? [] : [id] } +} + +/** + * The chosen values that still exist; a value that was deleted (an option, an iteration) is dropped, so that a view + * never ends up showing nothing for a value nobody can see or clear. + */ +export function sanitizeSliceValues (value: readonly string[], existing: ReadonlySet): string[] { + return value.filter((id) => id === SLICE_NONE || existing.has(id)) +} diff --git a/plugins/tracker-resources/src/slice/fields.ts b/plugins/tracker-resources/src/slice/fields.ts new file mode 100644 index 0000000000..9e769c14cd --- /dev/null +++ b/plugins/tracker-resources/src/slice/fields.ts @@ -0,0 +1,67 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { filterGrammar } from '@hcengineering/view-resources' +import { toCustomFieldViewKey } from '../projectFields/query' +import type { SliceConfig } from './config' + +type FieldSpec = filterGrammar.FieldSpec + +/** Built-in fields that can be sliced, in the order they are offered; the custom fields follow. */ +export const BUILTIN_SLICE_FIELDS: readonly string[] = ['status', 'priority', 'assignee', 'label', 'component', 'milestone'] + +/** + * Id of a field in the slice settings: the filter name of a built-in field, `customFields.` for a custom one + * (its filter name follows its label, the key does not change when it is renamed). + */ +export function sliceFieldId (spec: Pick): string { + return spec.source === 'custom' ? toCustomFieldViewKey(spec.key) : spec.name +} + +function isSliceable (spec: FieldSpec): boolean { + if (spec.source === 'custom') { + return spec.type === 'select' || spec.type === 'multi' || spec.type === 'iteration' + } + return BUILTIN_SLICE_FIELDS.includes(spec.name) +} + +/** + * The fields of the filter schema a view can be sliced by: the built-in ones and the single select, multi select + * and iteration fields of the project. Title, dates, numbers and text cannot be sliced (GitHub parity). + */ +export function buildSliceFields (schema: readonly FieldSpec[]): FieldSpec[] { + const builtin = BUILTIN_SLICE_FIELDS.map((name) => schema.find((f) => f.source === 'attribute' && f.name === name)).filter( + (f): f is FieldSpec => f !== undefined + ) + return [...builtin, ...schema.filter((f) => f.source === 'custom' && isSliceable(f))] +} + +/** The field of the settings; undefined when it does not exist (any more). */ +export function resolveSliceField (config: SliceConfig | undefined, fields: readonly FieldSpec[]): FieldSpec | undefined { + if (config === undefined) return undefined + return fields.find((f) => sliceFieldId(f) === config.field) +} + +/** The field a panel starts with: the first one offered. */ +export function defaultSliceField (fields: readonly FieldSpec[]): FieldSpec | undefined { + return fields[0] +} + +/** Document properties a client evaluation of the field reads, for projecting a scan. */ +export function sliceProjection (spec: FieldSpec): string[] { + const res = new Set(['_id']) + res.add(spec.source === 'custom' ? 'customFields' : spec.key) + for (const dependency of spec.dependsOn ?? []) res.add(dependency) + return [...res] +} + +/** + * Whether the values of the field that no item has are listed (with a count of 0). The fields whose values belong to + * the project (its custom fields) list all of them; the others (a status of another project, every person of the + * workspace) list the values that are in use. + */ +export function listsEmptyValues (spec: Pick): boolean { + return spec.source === 'custom' +} diff --git a/plugins/tracker-resources/src/slice/sums.ts b/plugins/tracker-resources/src/slice/sums.ts new file mode 100644 index 0000000000..508eaf9754 --- /dev/null +++ b/plugins/tracker-resources/src/slice/sums.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { filterGrammar } from '@hcengineering/view-resources' +import type { SummableField } from '../fieldSum/config' +import { computeFieldSums, formatFieldSums } from '../fieldSum/sum' +import { SLICE_NONE } from './config' +import { optionIdSet, sliceValueIds } from './values' + +type FieldSpec = filterGrammar.FieldSpec + +/** Key of the sums of all the items ("All") in the result of `collectSliceSums`. */ +export const SLICE_ALL = '__all__' + +/** + * The sums of the chosen number fields for every value of the slice panel, as text by value id (`SLICE_NONE` for the + * items without a value, `SLICE_ALL` for all of them). An item with several values (labels) counts for each of them, + * as it does in the counts. Nothing is returned for no fields. + */ +export function collectSliceSums ( + spec: FieldSpec, + docs: readonly unknown[], + fields: readonly SummableField[] +): Map { + const res = new Map() + if (fields.length === 0) return res + const known = optionIdSet(spec) + const buckets = new Map() + for (const doc of docs) { + const ids = sliceValueIds(spec, doc, known) + for (const id of ids.length === 0 ? [SLICE_NONE] : ids) { + const list = buckets.get(id) + if (list === undefined) buckets.set(id, [doc]) + else list.push(doc) + } + } + for (const [id, list] of buckets) { + const text = formatFieldSums(computeFieldSums(list, fields), false) + if (text !== undefined) res.set(id, text) + } + const all = formatFieldSums(computeFieldSums(docs, fields), false) + if (all !== undefined) res.set(SLICE_ALL, all) + return res +} diff --git a/plugins/tracker-resources/src/slice/values.ts b/plugins/tracker-resources/src/slice/values.ts new file mode 100644 index 0000000000..3235e6b809 --- /dev/null +++ b/plugins/tracker-resources/src/slice/values.ts @@ -0,0 +1,115 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { filterGrammar } from '@hcengineering/view-resources' +import { SLICE_NONE, type SliceConfig } from './config' + +type FieldSpec = filterGrammar.FieldSpec + +/** One value of a slice panel. */ +export interface SliceValue { + // The id the option is stored by, as text + id: string + label: string + // Items with this value; an item with several values (labels) is counted for each + count: number +} + +export interface SliceValues { + values: SliceValue[] + // Items without a value in the field + none: number + // All the items counted ("All") + total: number +} + +export interface CollectOptions { + // List the values that no item has (with a count of 0) + includeEmpty?: boolean + // Ids in the order they are shown (e.g. statuses in the order of the workflow); values that are not listed follow + order?: ReadonlyArray + // Sort by label instead of by the order of the options (people, labels, ...) + sortByLabel?: boolean + // Ids that are listed even when no item has them, e.g. the chosen ones, so that a choice can always be seen and undone + keep?: readonly string[] +} + +function valuesOf (spec: FieldSpec, doc: unknown): string[] { + const raw = filterGrammar.readFieldValue(spec, doc) + if (Array.isArray(raw)) return raw.filter((v) => v !== null && v !== undefined && v !== '').map(String) + if (filterGrammar.isEmptyValue(raw)) return [] + return [String(raw)] +} + +/** + * The values of an item in a field: the ids of its options that exist, each once. An item without a value (or only + * with a value that is not an option any more) has none. + */ +export function sliceValueIds (spec: FieldSpec, doc: unknown, known: ReadonlySet = optionIdSet(spec)): string[] { + return [...new Set(valuesOf(spec, doc).filter((id) => known.has(id)))] +} + +/** The ids of the options of the field, as text. */ +export function optionIdSet (spec: Pick): Set { + return new Set((spec.options ?? []).map((o) => String(o.id))) +} + +/** + * The values of a field with the number of items that have each of them. A value that is not an option of the field + * (a deleted iteration, a status that was removed) counts as no value. The items are the ones the view shows without + * the slice itself, so the counts tell what choosing a value will leave. + */ +export function collectSliceValues (spec: FieldSpec, docs: readonly unknown[], options: CollectOptions = {}): SliceValues { + const known = optionIdSet(spec) + const counts = new Map() + let none = 0 + for (const doc of docs) { + const ids = sliceValueIds(spec, doc, known) + if (ids.length === 0) { + none++ + continue + } + for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1) + } + + const keep = new Set(options.keep ?? []) + let values: SliceValue[] = [] + for (const option of spec.options ?? []) { + const id = String(option.id) + const count = counts.get(id) ?? 0 + if (count === 0 && options.includeEmpty !== true && !keep.has(id)) continue + // The same id can only be listed once, even if the schema repeats it + if (values.some((v) => v.id === id)) continue + values.push({ id, label: option.name, count }) + } + + if (options.order !== undefined) { + const position = new Map(options.order.map((id, i) => [String(id), i])) + const rank = (v: SliceValue): number => position.get(v.id) ?? Number.MAX_SAFE_INTEGER + values = values + .map((v, i) => ({ v, i })) + .sort((a, b) => rank(a.v) - rank(b.v) || a.i - b.i) + .map((it) => it.v) + } else if (options.sortByLabel === true) { + values.sort((a, b) => a.label.localeCompare(b.label)) + } + return { values, none, total: docs.length } +} + +/** + * Whether an item is in the slice: its value is one of the chosen ones (`SLICE_NONE` chooses the items without a + * value). Nothing chosen ("All") lets every item through. + */ +export function createSlicePredicate (spec: FieldSpec, config: SliceConfig | undefined): (doc: unknown) => boolean { + if (config === undefined || config.value.length === 0) return () => true + const chosen = new Set(config.value.filter((id) => id !== SLICE_NONE)) + const wantsNone = config.value.includes(SLICE_NONE) + const known = optionIdSet(spec) + return (doc) => { + const own = sliceValueIds(spec, doc, known) + if (own.length === 0) return wantsNone + return own.some((id) => chosen.has(id)) + } +} diff --git a/plugins/tracker-resources/src/viewExport/__tests__/columns.test.ts b/plugins/tracker-resources/src/viewExport/__tests__/columns.test.ts new file mode 100644 index 0000000000..6d56be0e60 --- /dev/null +++ b/plugins/tracker-resources/src/viewExport/__tests__/columns.test.ts @@ -0,0 +1,137 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { buildViewTsv } from '@hcengineering/tracker' + +import { buildExportColumns, exportColumnId, type ExportEnv } from '../columns' + +const entry = (key: string, displayKey?: string): any => ({ + key, + ...(displayKey !== undefined ? { displayProps: { key: displayKey } } : {}) +}) + +const issue = (props: Record = {}): any => ({ + _id: 'i1', + title: 'Fix login', + identifier: 'PROJ-1', + status: 'st1', + kind: 'k1', + comments: 3, + estimation: 0, + parents: [], + modifiedOn: Date.UTC(2026, 1, 3, 4, 5, 6), + createdOn: Date.UTC(2026, 0, 2), + ...props +}) + +function env (): ExportEnv { + const formats: Record string> = { + priority: () => 'High', + status: (i) => (i.status === 'st1' ? 'Todo' : 'Done'), + assignee: () => 'Alice', + labels: () => 'bug, ui', + dueDate: () => '2026-03-01', + estimation: () => '2', + cf_sprint: () => 'Sprint 4' + } + return { + cell: (key) => (formats[key] !== undefined ? { format: formats[key] } : undefined), + label: (id) => `L:${id}`, + taskTypeName: (kind) => `Type ${kind}`, + issueUrl: (i) => `https://huly.test/tracker/${i.identifier}`, + draftLabel: 'Draft', + headers: { title: 'Title', identifier: 'Identifier', url: 'URL' } + } +} + +describe('exportColumnId', () => { + it('uses the key of the display properties, then the attribute', () => { + expect(exportColumnId('comments')).toBe('comments') + expect(exportColumnId(entry('', 'status'))).toBe('status') + expect(exportColumnId(entry('assignee'))).toBe('assignee') + expect(exportColumnId(entry('assignee', 'a2'))).toBe('a2') + }) + + it('has no id for spacers', () => { + expect(exportColumnId(entry(''))).toBeUndefined() + expect(exportColumnId('')).toBeUndefined() + }) +}) + +describe('buildExportColumns', () => { + it('starts with title, identifier and url', () => { + const columns = buildExportColumns([], env()) + expect(columns.map((c) => c.header)).toEqual(['Title', 'Identifier', 'URL']) + expect(columns.map((c) => c.value(issue()))).toEqual(['Fix login', 'PROJ-1', 'https://huly.test/tracker/PROJ-1']) + }) + + it('writes "Draft" as the identifier of a draft item', () => { + const [, identifier] = buildExportColumns([], env()) + expect(identifier.value(issue({ isDraft: true, identifier: 'PROJ-Draft' }))).toBe('Draft') + }) + + it('follows the order of the view and skips what is not a field', () => { + const config = [ + entry('', 'priority'), + entry('', 'issue'), + entry('', 'status'), + entry('', 'title'), + entry('', ''), + entry('', 'extension-area'), + entry('labels'), + entry('assignee'), + entry('', 'dueDate'), + entry('', 'cf_sprint'), + entry('', 'cf_gone') + ] + const columns = buildExportColumns(config, env()) + expect(columns.map((c) => c.id)).toEqual([ + 'title', + 'identifier', + 'url', + 'priority', + 'status', + 'labels', + 'assignee', + 'dueDate', + 'cf_sprint' + ]) + const row = columns.map((c) => c.value(issue())) + expect(row.slice(3)).toEqual(['High', 'Todo', 'bug, ui', 'Alice', '2026-03-01', 'Sprint 4']) + }) + + it('writes each column once', () => { + const columns = buildExportColumns([entry('', 'status'), entry('', 'status')], env()) + expect(columns.filter((c) => c.id === 'status')).toHaveLength(1) + }) + + it('writes the estimation as a number and nothing for no estimate', () => { + const column = buildExportColumns([entry('', 'estimation')], env()).find((c) => c.id === 'estimation') + expect(column?.value(issue({ estimation: 0 }))).toBe('') + expect(column?.value(issue({ estimation: 4.5 }))).toBe(4.5) + }) + + it('writes task type, counters, parent and times', () => { + const config = [entry('', 'kind'), entry('comments'), entry('attachedTo'), entry('modifiedOn'), entry('createdOn')] + const columns = buildExportColumns(config, env()) + const values = columns.slice(3).map((c) => c.value(issue({ parents: [{ identifier: 'PROJ-9' }] }))) + expect(values).toEqual(['Type k1', 3, 'PROJ-9', '2026-02-03T04:05:06.000Z', '2026-01-02T00:00:00.000Z']) + }) + + it('gives a whole file through the tsv builder', () => { + const columns = buildExportColumns([entry('', 'status'), entry('labels')], env()) + const rows = [issue(), issue({ _id: 'i2', title: '=evil\tx', identifier: 'PROJ-2', status: 'st2' })] + const tsv = buildViewTsv( + columns, + rows.map((r) => columns.map((c) => c.value(r))) + ) + expect(tsv.split('\n')).toEqual([ + 'Title\tIdentifier\tURL\tL:status\tL:labels', + 'Fix login\tPROJ-1\thttps://huly.test/tracker/PROJ-1\tTodo\tbug, ui', + "'=evil x\tPROJ-2\thttps://huly.test/tracker/PROJ-2\tDone\tbug, ui", + '' + ]) + }) +}) diff --git a/plugins/tracker-resources/src/viewExport/__tests__/groups.test.ts b/plugins/tracker-resources/src/viewExport/__tests__/groups.test.ts new file mode 100644 index 0000000000..9613b15252 --- /dev/null +++ b/plugins/tracker-resources/src/viewExport/__tests__/groups.test.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { NO_GROUPING } from '../../board/config' +import { exportGroupKeys } from '../groups' + +describe('exportGroupKeys', () => { + it('uses the group by of a table', () => { + expect(exportGroupKeys({ groupBy: ['status', 'assignee'] })).toEqual(['status', 'assignee']) + }) + + it('leaves out no grouping and repeated keys', () => { + expect(exportGroupKeys({ groupBy: [NO_GROUPING] })).toEqual([]) + expect(exportGroupKeys({ groupBy: [] })).toEqual([]) + expect(exportGroupKeys({ groupBy: undefined })).toEqual([]) + expect(exportGroupKeys({ groupBy: ['status', '', 'status'] })).toEqual(['status']) + }) + + it('lists a board lane after lane and column after column', () => { + expect(exportGroupKeys({ groupBy: ['assignee'], board: { columnKey: 'status', laneKey: 'assignee' } })).toEqual([ + 'assignee', + 'status' + ]) + expect(exportGroupKeys({ groupBy: [NO_GROUPING], board: { columnKey: 'status', laneKey: undefined } })).toEqual([ + 'status' + ]) + }) + + it('lists the sub-lanes of a board between its lanes and its columns', () => { + expect( + exportGroupKeys({ + groupBy: ['assignee', 'priority'], + board: { columnKey: 'status', laneKey: 'assignee', subLaneKey: 'priority' } + }) + ).toEqual(['assignee', 'priority', 'status']) + }) + + it('lists three levels of a table', () => { + expect(exportGroupKeys({ groupBy: ['status', 'assignee', 'priority'] })).toEqual(['status', 'assignee', 'priority']) + }) +}) diff --git a/plugins/tracker-resources/src/viewExport/__tests__/rows.test.ts b/plugins/tracker-resources/src/viewExport/__tests__/rows.test.ts new file mode 100644 index 0000000000..c72d1112a5 --- /dev/null +++ b/plugins/tracker-resources/src/viewExport/__tests__/rows.test.ts @@ -0,0 +1,98 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { SortingOrder } from '@hcengineering/core' +import { MAX_EXPORT_ROWS } from '@hcengineering/tracker' + +import { loadExportRows, type ExportRowsParams } from '../rows' + +const doc = (id: string, props: Record = {}): any => ({ _id: id, ...props }) + +function fakeClient (docs: any[]): { client: any, calls: any[] } { + const calls: any[] = [] + return { + calls, + client: { + findAll: async (_class: any, query: any, options: any) => { + calls.push({ query, options }) + return docs + } + } + } +} + +const params = (props: Partial = {}): ExportRowsParams => ({ + query: {}, + groupBy: [], + getCategories: async () => [undefined], + ...props +}) + +describe('loadExportRows', () => { + it('asks for what the view shows with the sort of the view, without lookups', async () => { + const f = fakeClient([doc('a')]) + await loadExportRows( + f.client, + 'cls' as any, + params({ + query: { status: 's1', '$lookup.attachedTo': 'x' } as any, + options: { lookup: { attachedTo: 'x' }, limit: 5, sort: { rank: 1 }, projection: { _id: 1 } } as any, + orderBy: ['dueDate', SortingOrder.Descending] + }) + ) + expect(f.calls[0].query).toEqual({ status: 's1' }) + expect(f.calls[0].options).toEqual({ sort: { dueDate: SortingOrder.Descending }, limit: MAX_EXPORT_ROWS + 1 }) + }) + + it('keeps the order of the server without grouping', async () => { + const f = fakeClient([doc('b'), doc('a')]) + const res = await loadExportRows(f.client, 'cls' as any, params()) + expect(res.rows.map((r) => r._id)).toEqual(['b', 'a']) + expect(res.truncated).toBe(false) + }) + + it('shows group after group in the order of the list', async () => { + const f = fakeClient([doc('1', { priority: 3 }), doc('2', { priority: 1 }), doc('3', { priority: 3 }), doc('4')]) + const res = await loadExportRows( + f.client, + 'cls' as any, + params({ groupBy: ['priority'], getCategories: async () => [1, 3, undefined] }) + ) + expect(res.rows.map((r) => r._id)).toEqual(['2', '1', '3', '4']) + }) + + it('groups and sorts by a client side key (a custom field)', async () => { + const f = fakeClient([ + doc('1', { customFields: { team: 'b', n: 2 } }), + doc('2', { customFields: { team: 'a', n: 9 } }), + doc('3', { customFields: { team: 'b', n: 1 } }) + ]) + const res = await loadExportRows( + f.client, + 'cls' as any, + params({ + groupBy: ['customFields.team'], + orderBy: ['customFields.n', SortingOrder.Ascending], + client: { + handlesKey: (key) => key.startsWith('customFields.'), + compare: (key, order) => (a: any, b: any) => + (a.customFields.n - b.customFields.n) * (order === SortingOrder.Ascending ? 1 : -1), + getCategories: () => ['a', 'b'] + } + }) + ) + expect(res.rows.map((r) => r._id)).toEqual(['2', '3', '1']) + // The server is not asked to sort by a key it does not know + expect(f.calls[0].options.sort).toBeUndefined() + }) + + it('cuts a view that is longer than an export can be', async () => { + const docs = Array.from({ length: MAX_EXPORT_ROWS + 1 }, (_, i) => doc(String(i))) + const f = fakeClient(docs) + const res = await loadExportRows(f.client, 'cls' as any, params()) + expect(res.rows).toHaveLength(MAX_EXPORT_ROWS) + expect(res.truncated).toBe(true) + }) +}) diff --git a/plugins/tracker-resources/src/viewExport/columns.ts b/plugins/tracker-resources/src/viewExport/columns.ts new file mode 100644 index 0000000000..fecb00c234 --- /dev/null +++ b/plugins/tracker-resources/src/viewExport/columns.ts @@ -0,0 +1,105 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Issue } from '@hcengineering/tracker' +import type { TsvValue } from '@hcengineering/tracker' +import type { BuildModelKey } from '@hcengineering/view' + +/** A column of the exported file: its header and how to read a cell from an issue. */ +export interface ExportColumn { + id: string + header: string + value: (issue: Issue) => TsvValue +} + +/** What the columns need from the view that is exported. */ +export interface ExportEnv { + // How a cell of the table is written as text (the same text as copying it), by column key; custom fields are `cf_` + cell: (key: string) => { format: (issue: Issue) => string } | undefined + // Header text of a column of the view + label: (id: string, entry: BuildModelKey | string) => string + taskTypeName: (kind: string) => string + issueUrl: (issue: Issue) => string + // What the identifier column of a draft item says + draftLabel: string + // Headers of the columns that every export starts with + headers: { title: string, identifier: string, url: string } +} + +/** + * The id of a column of the view config: the `key` of its display properties, or the attribute it shows. Entries that + * are no column (spacers, the extension area) have none. + */ +export function exportColumnId (entry: BuildModelKey | string): string | undefined { + const id = typeof entry === 'string' ? entry : (entry.displayProps?.key ?? entry.key) + return id === '' ? undefined : id +} + +const CELL_COLUMNS = new Set(['priority', 'status', 'assignee', 'component', 'milestone', 'dueDate', 'estimation', 'labels']) + +// Columns that the file always starts with, so that they are not written twice +const FIXED_IDS = new Set(['title', 'issue', 'number']) + +const iso = (time: number | null | undefined): string => + time === null || time === undefined || !Number.isFinite(time) ? '' : new Date(time).toISOString() + +function customFieldId (id: string): boolean { + return id.startsWith('cf_') +} + +function columnFor (id: string, header: string, env: ExportEnv): ExportColumn | undefined { + if (CELL_COLUMNS.has(id) || customFieldId(id)) { + const cell = env.cell(id) + if (cell === undefined) return undefined + // Numbers stay numbers in the file, so that a spreadsheet can sum them + if (id === 'estimation') return { id, header, value: (issue) => (issue.estimation > 0 ? issue.estimation : '') } + return { id, header, value: (issue) => cell.format(issue) } + } + switch (id) { + case 'kind': + return { id, header, value: (issue) => env.taskTypeName(issue.kind) } + case 'comments': + return { id, header, value: (issue) => issue.comments } + case 'attachments': + return { id, header, value: (issue) => (issue as Issue & { attachments?: number }).attachments ?? 0 } + case 'attachedTo': + case 'parent': + return { id, header, value: (issue) => issue.parents?.[0]?.identifier ?? '' } + case 'modified': + case 'modifiedOn': + return { id, header, value: (issue) => iso(issue.modifiedOn) } + case 'createdOn': + return { id, header, value: (issue) => iso(issue.createdOn) } + default: + return undefined + } +} + +/** + * The columns of an export (GitHub "Export view data"): Title, Identifier and URL first, then the visible fields of the + * view in the order of the view. Columns that have nothing to write (the sub-issue counter, the extension area, a custom + * field that no longer exists) are left out; a column that is listed twice is written once. + */ +export function buildExportColumns (config: ReadonlyArray, env: ExportEnv): ExportColumn[] { + const columns: ExportColumn[] = [ + { id: 'title', header: env.headers.title, value: (issue) => issue.title }, + { + id: 'identifier', + header: env.headers.identifier, + value: (issue) => (issue.isDraft === true ? env.draftLabel : issue.identifier) + }, + { id: 'url', header: env.headers.url, value: (issue) => env.issueUrl(issue) } + ] + const seen = new Set() + for (const entry of config) { + const id = exportColumnId(entry) + if (id === undefined || FIXED_IDS.has(id) || seen.has(id)) continue + const column = columnFor(id, env.label(id, entry), env) + if (column === undefined) continue + seen.add(id) + columns.push(column) + } + return columns +} diff --git a/plugins/tracker-resources/src/viewExport/download.ts b/plugins/tracker-resources/src/viewExport/download.ts new file mode 100644 index 0000000000..cb4cf25a84 --- /dev/null +++ b/plugins/tracker-resources/src/viewExport/download.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** + * Starts a browser download of the text as a tab separated file. + */ +export function downloadTsv (text: string, fileName: string): void { + const blob = new Blob([text], { type: 'text/tab-separated-values;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + // Revoked after the click has had time to start the download + setTimeout(() => { + URL.revokeObjectURL(url) + }, 1000) +} diff --git a/plugins/tracker-resources/src/viewExport/groups.ts b/plugins/tracker-resources/src/viewExport/groups.ts new file mode 100644 index 0000000000..b7c89f539e --- /dev/null +++ b/plugins/tracker-resources/src/viewExport/groups.ts @@ -0,0 +1,31 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { NO_GROUPING } from '../board/config' + +/** + * The keys an export is grouped by, outermost first, as the view shows its rows: a table or a roadmap lists group after + * group by the "Group by" and "Then by" levels of the view, a board lists lane after lane (and sub-lane after sub-lane), + * and inside a lane column after column. A key that means "no grouping" is left out, and so is a key that is listed twice. + */ +export function exportGroupKeys (params: { + groupBy: readonly string[] | undefined + // Set for a board: the keys of its columns, of its swimlanes and of their sub-lanes + board?: { columnKey: string, laneKey: string | undefined, subLaneKey?: string } +}): string[] { + const keys: string[] = [] + const add = (key: string | undefined): void => { + if (key === undefined || key === '' || key === NO_GROUPING || keys.includes(key)) return + keys.push(key) + } + if (params.board !== undefined) { + add(params.board.laneKey) + add(params.board.subLaneKey) + add(params.board.columnKey) + return keys + } + for (const key of params.groupBy ?? []) add(key) + return keys +} diff --git a/plugins/tracker-resources/src/viewExport/rows.ts b/plugins/tracker-resources/src/viewExport/rows.ts new file mode 100644 index 0000000000..6fc7275e84 --- /dev/null +++ b/plugins/tracker-resources/src/viewExport/rows.ts @@ -0,0 +1,93 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + getObjectValue, + SortingOrder, + type CategoryType, + type Class, + type Doc, + type DocumentQuery, + type FindOptions, + type Ref, + type TxOperations +} from '@hcengineering/core' +import { MAX_EXPORT_ROWS, orderRowsByGroups, type GroupCategory, type GroupLevel, type Issue } from '@hcengineering/tracker' + +/** What decides which rows an export has and in which order. */ +export interface ExportRowsParams { + // What the view shows: the query of the view (filter, search, slice) merged with the query its view options add + query: DocumentQuery + // Find options of the view options (without sort and limit) + options?: FindOptions + // Sort of the view, as the view options have it: the key and the direction + orderBy?: [string, SortingOrder] + // Group keys of the view, outermost first; keys that mean "no grouping" are left out by the caller + groupBy: readonly string[] + // Whether a key is evaluated on the client (a custom field), with its comparator and the groups it has + client?: { + handlesKey: (key: string) => boolean + compare: (key: string, order: SortingOrder) => ((a: Doc, b: Doc) => number) | undefined + getCategories: (key: string, docs: Doc[]) => CategoryType[] + } + // The groups of a built-in key, as the list makes them + getCategories: (docs: Doc[], key: string) => Promise +} + +export interface ExportRows { + rows: Issue[] + // The view holds more rows than an export can have, the rest is cut off + truncated: boolean +} + +function withoutLookupQuery (query: DocumentQuery): DocumentQuery { + const res: DocumentQuery = {} + for (const [key, value] of Object.entries(query)) { + if (!key.startsWith('$lookup.')) (res as Record)[key] = value + } + return res +} + +/** + * Loads the rows of a view for an export, in the order the list shows them: the sort of the view, then group after + * group. The rows are flat, a view that nests sub-issues under their parents exports every issue on its own row. + */ +export async function loadExportRows ( + client: Pick, + _class: Ref>, + params: ExportRowsParams +): Promise { + const { client: ext, orderBy } = params + const clientSorted = orderBy !== undefined && ext?.handlesKey(orderBy[0]) === true + // Lookups, projections and a limit of the view would only make the rows differ from what is exported + const options: FindOptions = { ...(params.options ?? {}) } + delete options.lookup + delete options.sort + delete options.limit + delete options.projection + const found = await client.findAll(_class, withoutLookupQuery(params.query), { + ...options, + ...(orderBy !== undefined && !clientSorted ? { sort: { [orderBy[0]]: orderBy[1] } } : {}), + limit: MAX_EXPORT_ROWS + 1 + }) + const truncated = found.length > MAX_EXPORT_ROWS + let rows: Issue[] = truncated ? found.slice(0, MAX_EXPORT_ROWS) : [...found] + + if (clientSorted && orderBy !== undefined) { + const compare = ext?.compare(orderBy[0], orderBy[1]) + if (compare !== undefined) rows = [...rows].sort((a, b) => compare(a, b)) + } + + const levels: Array> = [] + for (const key of params.groupBy) { + const categories = + ext?.handlesKey(key) === true ? ext.getCategories(key, rows) : await params.getCategories(rows, key) + levels.push({ + categories: categories as GroupCategory[], + valueOf: (row) => getObjectValue(key, row) + }) + } + return { rows: orderRowsByGroups(rows, levels), truncated } +} diff --git a/plugins/tracker-resources/src/webhooks/__tests__/draft.test.ts b/plugins/tracker-resources/src/webhooks/__tests__/draft.test.ts new file mode 100644 index 0000000000..cceaa97236 --- /dev/null +++ b/plugins/tracker-resources/src/webhooks/__tests__/draft.test.ts @@ -0,0 +1,86 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { MAX_PROJECT_WEBHOOKS } from '@hcengineering/tracker' + +import { + editWebhookDraft, + generateWebhookSecret, + newWebhookDraft, + toggleDraftEvent, + validateWebhookDraft, + webhookData +} from '../draft' + +const valid = { ...newWebhookDraft(), url: 'https://hooks.example.com/huly' } + +describe('validateWebhookDraft', () => { + it('accepts a valid draft', () => { + expect(validateWebhookDraft(valid, 0, true)).toBeUndefined() + }) + + it('reports the url problems', () => { + expect(validateWebhookDraft({ ...valid, url: '' }, 0, true)).toBe('empty') + expect(validateWebhookDraft({ ...valid, url: 'nope' }, 0, true)).toBe('invalid') + expect(validateWebhookDraft({ ...valid, url: 'http://hooks.example.com/' }, 0, true)).toBe('protocol') + expect(validateWebhookDraft({ ...valid, url: 'https://127.0.0.1/' }, 0, true)).toBe('address') + expect(validateWebhookDraft({ ...valid, url: 'https://intranet/' }, 0, true)).toBe('host') + }) + + it('needs an event, bounds the secret and the number of webhooks', () => { + expect(validateWebhookDraft({ ...valid, events: [] }, 0, true)).toBe('noEvents') + expect(validateWebhookDraft({ ...valid, secret: 'x'.repeat(300) }, 0, true)).toBe('secretTooLong') + expect(validateWebhookDraft(valid, MAX_PROJECT_WEBHOOKS, true)).toBe('tooMany') + // An existing webhook can still be edited at the limit + expect(validateWebhookDraft(valid, MAX_PROJECT_WEBHOOKS, false)).toBeUndefined() + }) +}) + +describe('webhookData', () => { + it('trims the url and keeps the known events in order', () => { + expect(webhookData({ ...valid, url: ' https://x.example.com/a ', events: ['deleted', 'created'] })).toEqual({ + url: 'https://x.example.com/a', + enabled: true, + events: ['created', 'deleted'], + hasSecret: false + }) + }) + + it('has a secret once one was typed and keeps it otherwise', () => { + expect(webhookData({ ...valid, secret: 's' }).hasSecret).toBe(true) + expect(webhookData(valid, { hasSecret: true }).hasSecret).toBe(true) + expect(webhookData(valid, { hasSecret: false }).hasSecret).toBe(false) + }) + + it('never carries the secret in the doc', () => { + expect(Object.values(webhookData({ ...valid, secret: 'top-secret' }))).not.toContain('top-secret') + expect(JSON.stringify(webhookData({ ...valid, secret: 'top-secret' }))).not.toContain('top-secret') + }) +}) + +describe('drafts', () => { + it('starts a new draft with every event and an edit draft without a secret', () => { + expect(newWebhookDraft().events).toEqual(['created', 'edited', 'archived', 'restored', 'deleted']) + expect(editWebhookDraft({ url: 'https://a/', enabled: false, events: ['deleted'] })).toEqual({ + url: 'https://a/', + enabled: false, + events: ['deleted'], + secret: '' + }) + }) + + it('toggles events', () => { + expect(toggleDraftEvent(['created'], 'edited', true)).toEqual(['created', 'edited']) + expect(toggleDraftEvent(['created', 'edited'], 'created', false)).toEqual(['edited']) + expect(toggleDraftEvent(['created'], 'created', true)).toEqual(['created']) + }) + + it('generates a 64 character hex secret from random bytes', () => { + const secret = generateWebhookSecret((bytes) => bytes.map((_, i) => i * 8)) + expect(secret).toHaveLength(64) + expect(secret).toMatch(/^[0-9a-f]{64}$/) + expect(secret.slice(0, 6)).toBe('000810') + }) +}) diff --git a/plugins/tracker-resources/src/webhooks/draft.ts b/plugins/tracker-resources/src/webhooks/draft.ts new file mode 100644 index 0000000000..401d9d5c3f --- /dev/null +++ b/plugins/tracker-resources/src/webhooks/draft.ts @@ -0,0 +1,81 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Data } from '@hcengineering/core' +import { + MAX_PROJECT_WEBHOOKS, + MAX_WEBHOOK_SECRET_LENGTH, + normalizeWebhookEvents, + validateWebhookUrl, + WEBHOOK_EVENTS, + type ProjectWebhook, + type WebhookEvent, + type WebhookUrlError +} from '@hcengineering/tracker' + +/** + * What the webhook form holds. + */ +export interface WebhookDraft { + url: string + enabled: boolean + events: WebhookEvent[] + // New secret; empty keeps the current one (the current one is never shown) + secret: string +} + +export type WebhookDraftError = WebhookUrlError | 'noEvents' | 'secretTooLong' | 'tooMany' + +/** + * The first problem of a draft, or undefined when it can be saved. The URL is checked with the same rules as the server + * (https only, no credentials, no internal host or address); the server resolves names again at delivery. + */ +export function validateWebhookDraft ( + draft: WebhookDraft, + existingCount: number, + isNew: boolean +): WebhookDraftError | undefined { + if (isNew && existingCount >= MAX_PROJECT_WEBHOOKS) return 'tooMany' + const url = validateWebhookUrl(draft.url) + if (url.ok === false) return url.error + if (normalizeWebhookEvents(draft.events).length === 0) return 'noEvents' + if (draft.secret.length > MAX_WEBHOOK_SECRET_LENGTH) return 'secretTooLong' + return undefined +} + +/** + * The fields of the webhook doc for a draft. `hasSecret` becomes true once a secret was typed, and stays as it was + * otherwise. + */ +export function webhookData (draft: WebhookDraft, current?: Pick): Data { + return { + url: draft.url.trim(), + enabled: draft.enabled, + events: normalizeWebhookEvents(draft.events), + hasSecret: draft.secret !== '' || current?.hasSecret === true + } +} + +/** A draft for a new webhook: all events, enabled. */ +export function newWebhookDraft (): WebhookDraft { + return { url: '', enabled: true, events: [...WEBHOOK_EVENTS], secret: '' } +} + +/** A draft that edits a webhook; the secret starts empty. */ +export function editWebhookDraft (webhook: Pick): WebhookDraft { + return { url: webhook.url, enabled: webhook.enabled, events: normalizeWebhookEvents(webhook.events), secret: '' } +} + +/** Toggles one event of a draft. */ +export function toggleDraftEvent (events: readonly WebhookEvent[], event: WebhookEvent, on: boolean): WebhookEvent[] { + return normalizeWebhookEvents(on ? [...events, event] : events.filter((e) => e !== event)) +} + +/** + * A random secret: 32 random bytes as hex. `random` fills a byte array (the platform's `crypto.getRandomValues`). + */ +export function generateWebhookSecret (random: (bytes: Uint8Array) => Uint8Array): string { + return Array.from(random(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join('') +} diff --git a/plugins/tracker-resources/src/workflows/__tests__/preview.test.ts b/plugins/tracker-resources/src/workflows/__tests__/preview.test.ts new file mode 100644 index 0000000000..3c46257aac --- /dev/null +++ b/plugins/tracker-resources/src/workflows/__tests__/preview.test.ts @@ -0,0 +1,59 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { buildWorkflowFilterSchema, WorkflowKind } from '@hcengineering/tracker' + +import { compileWorkflowPreview, countPreviewMatches } from '../preview' + +const NOW = new Date(2026, 5, 17, 12).getTime() +const DAY = 24 * 60 * 60 * 1000 + +const schema = buildWorkflowFilterSchema({ + statuses: [ + { id: 'st-todo', name: 'Todo' }, + { id: 'st-done', name: 'Done' } + ], + assignees: [], + components: [], + milestones: [], + customFields: [], + noParentId: 'no-parent' +}) +const ctx = { now: NOW, closedStatuses: new Set(['st-done']), noParentId: 'no-parent' } + +describe('compileWorkflowPreview', () => { + it('scans the active items for auto-archive and the archived ones for auto-add', () => { + const archive = compileWorkflowPreview(WorkflowKind.AutoArchive, 'is:closed', schema, ctx) + expect(archive.ok && archive.query).toMatchObject({ archivedAt: null }) + const add = compileWorkflowPreview(WorkflowKind.AutoAddFromQuery, 'is:closed', schema, ctx) + expect(add.ok && add.query).toMatchObject({ archivedAt: { $ne: null } }) + }) + + it('reports an empty or invalid filter', () => { + expect(compileWorkflowPreview(WorkflowKind.AutoArchive, ' ', schema, ctx)).toEqual({ ok: false, reason: 'empty' }) + expect(compileWorkflowPreview(WorkflowKind.AutoArchive, 'nope:1', schema, ctx)).toEqual({ ok: false, reason: 'invalid' }) + expect(compileWorkflowPreview(WorkflowKind.AutoArchive, '(is:closed', schema, ctx)).toEqual({ ok: false, reason: 'invalid' }) + // Labels are not available to workflows + expect(compileWorkflowPreview(WorkflowKind.AutoArchive, 'label:bug', schema, ctx)).toEqual({ ok: false, reason: 'invalid' }) + }) + + it('does not accept archived items in an auto-archive filter', () => { + expect(compileWorkflowPreview(WorkflowKind.AutoArchive, 'is:archived', schema, ctx)).toEqual({ ok: false, reason: 'invalid' }) + expect(compileWorkflowPreview(WorkflowKind.AutoAddFromQuery, 'is:archived', schema, ctx).ok).toBe(true) + }) + + it('counts what the filter matches, and tells when the scan was cut', () => { + const compiled = compileWorkflowPreview(WorkflowKind.AutoArchive, 'is:closed updated:<@today-2w', schema, ctx) + if (compiled.ok === false) throw new Error('compile') + const docs = [ + { _id: 'a', status: 'st-done', modifiedOn: NOW - 30 * DAY }, + { _id: 'b', status: 'st-done', modifiedOn: NOW - DAY }, + { _id: 'c', status: 'st-todo', modifiedOn: NOW - 30 * DAY } + ] + expect(countPreviewMatches(compiled, docs, 100)).toEqual({ count: 1, over: false }) + expect(countPreviewMatches(compiled, docs, 3)).toEqual({ count: 1, over: true }) + expect(compiled.projection).toEqual(expect.arrayContaining(['status', 'modifiedOn'])) + }) +}) diff --git a/plugins/tracker-resources/src/workflows/__tests__/save.test.ts b/plugins/tracker-resources/src/workflows/__tests__/save.test.ts new file mode 100644 index 0000000000..c086ed9cd2 --- /dev/null +++ b/plugins/tracker-resources/src/workflows/__tests__/save.test.ts @@ -0,0 +1,128 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import { resolveWorkflows, WorkflowKind, type Project, type Workflow } from '@hcengineering/tracker' + +import { planWorkflowSave, requestWorkflowRun, workflowsToKick } from '../save' + +const PROJECT = 'project-1' as Ref + +function stored (kind: WorkflowKind, props: Partial = {}): Workflow { + return { + _id: `wf-${kind}` as Ref, + _class: 'tracker:class:Workflow' as any, + space: PROJECT, + modifiedOn: 0, + modifiedBy: 'u' as any, + createdBy: 'u' as any, + createdOn: 0, + name: kind, + enabled: true, + kind, + ...props + } +} + +describe('planWorkflowSave', () => { + const defaults = resolveWorkflows([]) + const byKind = (list: ReturnType, kind: WorkflowKind): (typeof list)[number] => list.find((w) => w.kind === kind) as any + + it('creates the doc of a workflow that was never stored, with the patch applied to the defaults', () => { + const add = byKind(defaults, WorkflowKind.ItemAdded) + expect(planWorkflowSave(add, { enabled: false })).toEqual({ + type: 'create', + data: { name: 'Item added to project', kind: WorkflowKind.ItemAdded, enabled: false, config: {} } + }) + const archive = byKind(defaults, WorkflowKind.AutoArchive) + expect(planWorkflowSave(archive, { enabled: true })).toEqual({ + type: 'create', + data: { name: 'Auto-archive items', kind: WorkflowKind.AutoArchive, enabled: true, filter: 'is:closed updated:<@today-2w' } + }) + }) + + it('trims and bounds the filter', () => { + const archive = byKind(defaults, WorkflowKind.AutoArchive) + const op: any = planWorkflowSave(archive, { filter: ` is:closed ` }) + expect(op.data.filter).toBe('is:closed') + const long: any = planWorkflowSave(archive, { filter: 'x'.repeat(5000) }) + expect(long.data.filter).toHaveLength(1000) + }) + + it('does not keep a filter for a workflow that has none', () => { + const closed = byKind(defaults, WorkflowKind.SetStatusDoneOnClose) + const op: any = planWorkflowSave(closed, { filter: 'is:closed' }) + expect(op.data.filter).toBeUndefined() + }) + + it('updates only what changed of a stored workflow', () => { + const list = resolveWorkflows([stored(WorkflowKind.AutoArchive, { enabled: false, filter: 'is:closed' })]) + const archive = byKind(list, WorkflowKind.AutoArchive) + expect(planWorkflowSave(archive, { enabled: true })).toEqual({ type: 'update', id: 'wf-autoArchive', ops: { enabled: true } }) + expect(planWorkflowSave(archive, { filter: 'is:closed status:Done' })).toEqual({ + type: 'update', + id: 'wf-autoArchive', + ops: { filter: 'is:closed status:Done' } + }) + expect(planWorkflowSave(archive, { enabled: true, filter: 'is:closed status:Done' })).toEqual({ + type: 'update', + id: 'wf-autoArchive', + ops: { enabled: true, filter: 'is:closed status:Done' } + }) + }) + + it('writes nothing for a patch that changes nothing', () => { + const list = resolveWorkflows([stored(WorkflowKind.AutoArchive, { enabled: false, filter: 'is:closed' }), stored(WorkflowKind.ItemAdded)]) + expect(planWorkflowSave(byKind(list, WorkflowKind.AutoArchive), { enabled: false, filter: ' is:closed ' })).toBeUndefined() + expect(planWorkflowSave(byKind(list, WorkflowKind.ItemAdded), {})).toBeUndefined() + }) + + it('stores a chosen target and tells the default from a choice', () => { + const list = resolveWorkflows([stored(WorkflowKind.SetStatusDoneOnClose, { config: {} })]) + const closed = byKind(list, WorkflowKind.SetStatusDoneOnClose) + const target = { target: { field: 'stage', option: 'o-done' } } + expect(planWorkflowSave(closed, { config: target })).toEqual({ type: 'update', id: 'wf-setStatusDoneOnClose', ops: { config: target } }) + const chosen = byKind(resolveWorkflows([stored(WorkflowKind.SetStatusDoneOnClose, { config: target })]), WorkflowKind.SetStatusDoneOnClose) + expect(planWorkflowSave(chosen, { config: target })).toBeUndefined() + // Back to automatic + expect(planWorkflowSave(chosen, { config: {} })).toEqual({ type: 'update', id: 'wf-setStatusDoneOnClose', ops: { config: {} } }) + }) +}) + +describe('requestWorkflowRun', () => { + it('asks for the stored, enabled filter workflows only', () => { + const list = resolveWorkflows([ + stored(WorkflowKind.AutoArchive, { enabled: true, filter: 'is:closed' }), + stored(WorkflowKind.AutoAddFromQuery, { enabled: false, filter: 'is:closed' }), + stored(WorkflowKind.ItemAdded) + ]) + expect(workflowsToKick(list)).toEqual(['wf-autoArchive']) + expect(workflowsToKick(resolveWorkflows([]))).toEqual([]) + }) + + it('touches the workflow once per session and project, and tolerates a refusal', async () => { + const updates: any[] = [] + const client: any = { + updateDoc: async (...args: any[]) => { + updates.push(args) + } + } + const list = resolveWorkflows([stored(WorkflowKind.AutoArchive, { enabled: true, filter: 'is:closed' })]) + await requestWorkflowRun(client, 'p-once' as Ref, list) + await requestWorkflowRun(client, 'p-once' as Ref, list) + expect(updates).toHaveLength(1) + expect(updates[0][3]).toEqual({ runRequestedAt: expect.any(Number) }) + + const refusing: any = { + updateDoc: async () => { + throw new Error('forbidden') + } + } + await expect(requestWorkflowRun(refusing, 'p-refused' as Ref, list)).resolves.toBeUndefined() + // Nothing stored: nothing to ask + await requestWorkflowRun(client, 'p-empty' as Ref, resolveWorkflows([])) + expect(updates).toHaveLength(1) + }) +}) diff --git a/plugins/tracker-resources/src/workflows/preview.ts b/plugins/tracker-resources/src/workflows/preview.ts new file mode 100644 index 0000000000..7dc4622793 --- /dev/null +++ b/plugins/tracker-resources/src/workflows/preview.ts @@ -0,0 +1,56 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { archivedQuery, isWorkflowFilterUsable, WorkflowKind, type FilterWorkflowKind } from '@hcengineering/tracker' +import { filterGrammar } from '@hcengineering/view-resources' + +/** + * A filter of a workflow compiled for a preview: what to scan and how to count. It is the same grammar, schema and + * rules the server uses, so the count is what the next run would find (up to its cap). + */ +export type CompiledPreview = + | { + ok: true + // Selects the scan: the narrowing of the filter and the archive state the workflow looks at + query: Record + // Properties the evaluation reads + projection: string[] + matches: (doc: any) => boolean + } + | { ok: false, reason: 'empty' | 'invalid' } + +/** + * Compiles the filter text of a filter workflow. Auto-archive scans the items that are not archived, Auto-add (restore) + * the archived ones; Auto-archive with a filter that asks for archived items is invalid. + */ +export function compileWorkflowPreview ( + kind: FilterWorkflowKind, + text: string, + schema: filterGrammar.FieldSpec[], + ctx: filterGrammar.FilterContext +): CompiledPreview { + if (!isWorkflowFilterUsable(text)) return { ok: false, reason: 'empty' } + const res = filterGrammar.compileFilter(text, schema, ctx) + if (res.ok === false) return { ok: false, reason: 'invalid' } + const mentions = filterGrammar.mentionsArchived(res.value.ast) + if (kind === WorkflowKind.AutoArchive && mentions) return { ok: false, reason: 'invalid' } + return { + ok: true, + query: { ...res.value.query, ...archivedQuery(kind === WorkflowKind.AutoAddFromQuery) }, + projection: filterGrammar.referencedProperties(res.value.ast), + matches: res.value.matches + } +} + +/** + * How many of the scanned issues match; `over` when the scan hit its limit, so the number is a lower bound. + */ +export function countPreviewMatches ( + compiled: Extract, + docs: readonly unknown[], + scanLimit: number +): { count: number, over: boolean } { + return { count: docs.filter((d) => compiled.matches(d)).length, over: docs.length >= scanLimit } +} diff --git a/plugins/tracker-resources/src/workflows/save.ts b/plugins/tracker-resources/src/workflows/save.ts new file mode 100644 index 0000000000..8d72e3bab2 --- /dev/null +++ b/plugins/tracker-resources/src/workflows/save.ts @@ -0,0 +1,114 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Data, DocumentUpdate, Ref, TxOperations } from '@hcengineering/core' +import { + isFilterWorkflowKind, + MAX_WORKFLOW_FILTER_LENGTH, + type EffectiveWorkflow, + type Project, + type Workflow, + type WorkflowConfig +} from '@hcengineering/tracker' + +import tracker from '../plugin' + +/** + * What the user changed in a workflow. + */ +export interface WorkflowPatch { + enabled?: boolean + filter?: string + config?: WorkflowConfig +} + +/** + * What saving a patch writes: a new doc (the project never stored this workflow) or an update of the stored one. + */ +export type WorkflowSaveOp = + | { type: 'create', data: Data } + | { type: 'update', id: Ref, ops: DocumentUpdate } + +function sameConfig (a: WorkflowConfig, b: WorkflowConfig): boolean { + return a.target?.field === b.target?.field && a.target?.option === b.target?.option +} + +/** + * Turns a patch into the write that saves it, or undefined when it changes nothing of a stored workflow. A workflow + * that was never stored is created with the patch applied to its defaults, even when the patch equals them, so that the + * choice of the user is kept if the defaults change later. The filter is trimmed and bounded; it is only kept for a + * workflow that has one. + */ +export function planWorkflowSave (current: EffectiveWorkflow, patch: WorkflowPatch): WorkflowSaveOp | undefined { + const enabled = patch.enabled ?? current.enabled + const filter = isFilterWorkflowKind(current.kind) + ? (patch.filter ?? current.filter).trim().slice(0, MAX_WORKFLOW_FILTER_LENGTH) + : '' + const config = patch.config ?? current.config + if (current.doc === undefined) { + const data: Data = { name: current.name, kind: current.kind, enabled } + if (isFilterWorkflowKind(current.kind)) data.filter = filter + else data.config = config + return { type: 'create', data } + } + const ops: DocumentUpdate = {} + if (enabled !== current.enabled) ops.enabled = enabled + if (isFilterWorkflowKind(current.kind)) { + if (filter !== current.filter) ops.filter = filter + } else if (!sameConfig(config, current.config)) { + ops.config = config + } + return Object.keys(ops).length === 0 ? undefined : { type: 'update', id: current.doc._id, ops } +} + +/** + * Saves a patch of a workflow. + */ +export async function saveWorkflow ( + client: TxOperations, + project: Ref, + current: EffectiveWorkflow, + patch: WorkflowPatch +): Promise { + const op = planWorkflowSave(current, patch) + if (op === undefined) return + if (op.type === 'create') await client.createDoc(tracker.class.Workflow, project, op.data) + else await client.updateDoc(tracker.class.Workflow, project, op.id, op.ops) +} + +/** + * The stored, enabled filter workflows: what a client asks the server to evaluate when the project is opened. + */ +export function workflowsToKick (workflows: readonly EffectiveWorkflow[]): Array> { + return workflows + .filter((w) => w.enabled && w.doc !== undefined && isFilterWorkflowKind(w.kind)) + .map((w) => (w.doc as Workflow)._id) +} + +const requested = new Set() + +/** + * Asks the server to evaluate the filter workflows of a project, once per session and project. There is no timer on + * the server, so a project in which nothing happens would never see items age into a filter such as + * `updated:<@today-2w`; opening the project is the moment to look. The request is a harmless touch of the workflow doc + * (`runRequestedAt`); a viewer who may not write simply does not send it. + */ +export async function requestWorkflowRun ( + client: TxOperations, + project: Ref, + workflows: readonly EffectiveWorkflow[] +): Promise { + if (requested.has(project)) return + const ids = workflowsToKick(workflows) + if (ids.length === 0) return + requested.add(project) + const now = Date.now() + try { + for (const id of ids) await client.updateDoc(tracker.class.Workflow, project, id, { runRequestedAt: now }) + } catch (err: any) { + // Not allowed or offline: the server evaluates on the next change instead + console.debug('[requestWorkflowRun] skipped', err?.message) + } +} diff --git a/plugins/tracker-resources/src/workflows/store.ts b/plugins/tracker-resources/src/workflows/store.ts new file mode 100644 index 0000000000..16f62d750c --- /dev/null +++ b/plugins/tracker-resources/src/workflows/store.ts @@ -0,0 +1,27 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import { createQuery } from '@hcengineering/presentation' +import { resolveWorkflows, type EffectiveWorkflow, type Project } from '@hcengineering/tracker' +import { readable, type Readable } from 'svelte/store' + +import tracker from '../plugin' + +/** + * Live list of the workflows of a project, one per kind, with the defaults of a kind that was never stored. The query is + * started on first subscription and stopped when the last subscriber leaves. + */ +export function workflowsStore (project: Ref): Readable { + return readable(resolveWorkflows([]), (set) => { + const query = createQuery(true) + query.query(tracker.class.Workflow, { space: project }, (result) => { + set(resolveWorkflows(result)) + }) + return () => { + query.unsubscribe() + } + }) +} diff --git a/plugins/tracker-resources/src/workload/__tests__/axis.test.ts b/plugins/tracker-resources/src/workload/__tests__/axis.test.ts new file mode 100644 index 0000000000..ed6d1abf3c --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/axis.test.ts @@ -0,0 +1,196 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { dayOf, weekStartOf } from '../../calendar/grid' +import { dayParts } from '../../roadmap/timeScale' +import { + bucketIndexOfDay, + bucketStart, + COLUMN_WIDTH, + computeAxis, + formatBucketLabel, + formatBucketRange, + formatHeaderGroup, + headerGroups, + isWorkloadZoom, + MAX_BUCKETS, + nextBucketStart, + scrollLeftForBucket, + visibleBucketRange +} from '../axis' + +// 5 October 2026 is a Monday +const TODAY = dayOf(2026, 9, 5) +const MON = 1 +const SUN = 0 + +describe('buckets', () => { + it('starts a week on the first day of the week of the platform', () => { + const wednesday = dayOf(2026, 9, 7) + expect(bucketStart('week', wednesday, MON)).toBe(TODAY) + expect(bucketStart('week', wednesday, SUN)).toBe(TODAY - 1) + // A garbage first day falls back to Monday + expect(bucketStart('week', wednesday, Number.NaN)).toBe(TODAY) + }) + + it('starts a month on the 1st and a day on itself', () => { + expect(bucketStart('month', dayOf(2026, 9, 17), MON)).toBe(dayOf(2026, 9, 1)) + expect(bucketStart('day', 123, MON)).toBe(123) + }) + + it('knows the next bucket, also over a month with 28, 30 and 31 days', () => { + expect(nextBucketStart('day', 10)).toBe(11) + expect(nextBucketStart('week', 10)).toBe(17) + expect(nextBucketStart('month', dayOf(2026, 1, 1))).toBe(dayOf(2026, 2, 1)) + expect(nextBucketStart('month', dayOf(2026, 8, 1))).toBe(dayOf(2026, 9, 1)) + expect(nextBucketStart('month', dayOf(2026, 11, 1))).toBe(dayOf(2027, 0, 1)) + }) + + it('recognises the zoom levels', () => { + expect(isWorkloadZoom('week')).toBe(true) + expect(isWorkloadZoom('quarter')).toBe(false) + }) +}) + +describe('computeAxis', () => { + for (const zoom of ['day', 'week', 'month'] as const) { + it(`covers today with whole contiguous buckets (${zoom})`, () => { + const axis = computeAxis(zoom, [], TODAY, MON) + expect(axis.todayIndex).toBeGreaterThanOrEqual(0) + const today = axis.buckets[axis.todayIndex] + expect(TODAY >= today.start && TODAY < today.end).toBe(true) + axis.buckets.forEach((b, i) => { + expect(b.index).toBe(i) + expect(b.end).toBeGreaterThan(b.start) + if (i > 0) expect(b.start).toBe(axis.buckets[i - 1].end) + }) + expect(axis.startDay).toBe(axis.buckets[0].start) + expect(axis.endDay).toBe(axis.buckets[axis.buckets.length - 1].end) + expect(axis.trimmed).toBe(false) + // More room ahead of today than behind it + expect(axis.endDay - TODAY).toBeGreaterThan(TODAY - axis.startDay) + }) + } + + it('has week buckets of seven days that start on the first day of the week', () => { + const axis = computeAxis('week', [], TODAY, SUN) + for (const b of axis.buckets) { + expect(b.end - b.start).toBe(7) + expect(dayParts(b.start).weekday).toBe(0) + } + expect(weekStartOf(TODAY, SUN)).toBe(axis.buckets[axis.todayIndex].start) + }) + + it('has month buckets that follow the calendar', () => { + const axis = computeAxis('month', [], TODAY, MON) + for (const b of axis.buckets) expect(dayParts(b.start).date).toBe(1) + expect(new Set(axis.buckets.map((b) => b.end - b.start)).size).toBeGreaterThan(1) + }) + + it('extends to the days of the items', () => { + const far = TODAY + 400 + const early = TODAY - 200 + const axis = computeAxis('week', [far, early], TODAY, MON) + expect(axis.startDay).toBeLessThanOrEqual(early) + expect(axis.endDay).toBeGreaterThan(far) + }) + + it('ignores days that are far from today and values that are not days', () => { + const base = computeAxis('week', [], TODAY, MON) + const axis = computeAxis('week', [TODAY - 100000, TODAY + 100000, Number.NaN, Infinity], TODAY, MON) + expect(axis.startDay).toBe(base.startDay) + expect(axis.endDay).toBe(base.endDay) + }) + + it('cuts a range that is too long around today and says so', () => { + const axis = computeAxis('day', [TODAY - 3000, TODAY + 3000], TODAY, MON) + expect(axis.buckets.length).toBe(MAX_BUCKETS.day) + expect(axis.trimmed).toBe(true) + expect(axis.todayIndex).toBeGreaterThanOrEqual(0) + // Today is closer to the start than to the end + expect(axis.todayIndex).toBeLessThan(MAX_BUCKETS.day / 2) + axis.buckets.forEach((b, i) => { + expect(b.index).toBe(i) + }) + }) + + it('finds the bucket of a day, and none for a day outside', () => { + const axis = computeAxis('week', [], TODAY, MON) + expect(bucketIndexOfDay(axis, TODAY)).toBe(axis.todayIndex) + expect(bucketIndexOfDay(axis, TODAY + 6)).toBe(axis.todayIndex) + expect(bucketIndexOfDay(axis, TODAY + 7)).toBe(axis.todayIndex + 1) + expect(bucketIndexOfDay(axis, axis.startDay - 1)).toBe(-1) + expect(bucketIndexOfDay(axis, axis.endDay)).toBe(-1) + expect(bucketIndexOfDay({ buckets: [] }, 5)).toBe(-1) + for (const b of axis.buckets) { + expect(bucketIndexOfDay(axis, b.start)).toBe(b.index) + expect(bucketIndexOfDay(axis, b.end - 1)).toBe(b.index) + } + }) +}) + +describe('header', () => { + it('groups days and weeks by month and months by year', () => { + const days = computeAxis('day', [], TODAY, MON) + const groups = headerGroups(days) + expect(groups.reduce((n, g) => n + g.count, 0)).toBe(days.buckets.length) + expect(groups[0].from).toBe(0) + groups.forEach((g, i) => { + if (i > 0) expect(g.from).toBe(groups[i - 1].from + groups[i - 1].count) + }) + expect(groups.every((g) => g.month >= 0)).toBe(true) + + const months = headerGroups(computeAxis('month', [], TODAY, MON)) + expect(months.every((g) => g.month === -1)).toBe(true) + expect(months.length).toBeGreaterThanOrEqual(1) + }) + + it('puts a week into the month of its first day', () => { + const axis = computeAxis('week', [], TODAY, MON) + const groups = headerGroups(axis) + for (const g of groups) { + const first = axis.buckets[g.from] + expect(dayParts(first.start).month).toBe(g.month) + } + }) + + it('labels in the locale of the user', () => { + const [group] = headerGroups({ zoom: 'week', buckets: [{ index: 0, start: TODAY, end: TODAY + 7 }] }) + expect(formatHeaderGroup(group, 'en')).toBe('October 2026') + expect(formatHeaderGroup({ ...group, month: -1 }, 'en')).toBe('2026') + expect(formatBucketLabel('month', { start: TODAY }, 'en')).toBe('Oct') + expect(formatBucketLabel('week', { start: TODAY }, 'en')).toBe('Oct 5') + expect(formatBucketLabel('day', { start: TODAY }, 'en')).toBe('Mon 5') + expect(formatBucketRange('day', { start: TODAY, end: TODAY + 1 }, 'en')).toBe('Oct 5, 2026') + expect(formatBucketRange('week', { start: TODAY, end: TODAY + 7 }, 'en')).toBe('Oct 5, 2026 – Oct 11, 2026') + expect(formatBucketRange('month', { start: dayOf(2026, 9, 1), end: dayOf(2026, 10, 1) }, 'en')).toBe('October 2026') + }) +}) + +describe('viewport', () => { + it('draws the visible columns with some overscan', () => { + const w = COLUMN_WIDTH.week + const range = visibleBucketRange(w * 10, w * 5, w, 100, 2) + expect(range).toEqual({ from: 8, to: 17 }) + }) + + it('stays inside the axis', () => { + expect(visibleBucketRange(0, 500, 50, 4, 3)).toEqual({ from: 0, to: 3 }) + expect(visibleBucketRange(100000, 500, 50, 4, 3)).toEqual({ from: 3, to: 3 }) + }) + + it('is safe for bad numbers and an empty axis', () => { + expect(visibleBucketRange(Number.NaN, Number.NaN, 50, 10, 1)).toEqual({ from: 0, to: 1 }) + expect(visibleBucketRange(0, 100, 50, 0).to).toBe(-1) + expect(visibleBucketRange(0, 100, 0, 10).to).toBe(-1) + }) + + it('scrolls a bucket into view without leaving the content', () => { + expect(scrollLeftForBucket(20, 80, 800, 10000)).toBe(20 * 80 - 200) + expect(scrollLeftForBucket(0, 80, 800, 10000)).toBe(0) + expect(scrollLeftForBucket(500, 80, 800, 10000)).toBe(9200) + expect(scrollLeftForBucket(3, 80, 800, 100)).toBe(0) + }) +}) diff --git a/plugins/tracker-resources/src/workload/__tests__/compute.test.ts b/plugins/tracker-resources/src/workload/__tests__/compute.test.ts new file mode 100644 index 0000000000..117e10157a --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/compute.test.ts @@ -0,0 +1,523 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { dayOf } from '../../calendar/grid' +import type { ItemSchedule } from '../../roadmap/dates' +import { toDay } from '../../roadmap/timeScale' +import { computeAxis, type WorkloadAxis } from '../axis' +import { + bucketCapacities, + cellItems, + computeWorkload, + distribute, + emptyRowLoad, + loadState, + NEAR_RATIO, + scheduleSpan, + summarizeRow, + utilization, + type LoadEntry +} from '../compute' +import { planReassign } from '../reassign' +import { rowKeyOf } from '../rows' +import { WorkdayIndex } from '../workdays' + +const DAY_MS = 86400000 +// 5 October 2026 is a Monday +const MON = dayOf(2026, 9, 5) +const TODAY = MON +const MON_TO_FRI = { weekdayMask: 31, holidays: [] as number[] } + +const range = (start: number, target: number): ItemSchedule => ({ kind: 'range', start, target, inverted: false }) +const marker = (day: number): ItemSchedule => ({ kind: 'marker', day, role: 'target' }) +const unscheduled: ItemSchedule = { kind: 'unscheduled' } + +function setup ( + zoom: 'day' | 'week' | 'month' = 'week', + calendar: { weekdayMask: number, holidays: number[] } = MON_TO_FRI, + days: number[] = [] +): { axis: WorkloadAxis, index: WorkdayIndex } { + return { axis: computeAxis(zoom, days, TODAY, 1), index: WorkdayIndex.around(TODAY, calendar) } +} + +function sum (values: ArrayLike): number { + let total = 0 + for (let i = 0; i < values.length; i++) total += values[i] + return total +} + +const entry = (id: string, row: string, load: number, schedule: ItemSchedule): LoadEntry => ({ id, row, load, schedule }) + +describe('scheduleSpan', () => { + it('gives the first and last day, one day for a marker and nothing without a day', () => { + expect(scheduleSpan(range(3, 9))).toEqual({ start: 3, end: 9 }) + expect(scheduleSpan(marker(4))).toEqual({ start: 4, end: 4 }) + expect(scheduleSpan(unscheduled)).toBeUndefined() + }) +}) + +describe('distribute', () => { + it('spreads the load evenly over the working days of a week', () => { + const { axis, index } = setup('day') + const shares = new Map() + distribute(range(MON, MON + 4), 40, axis, index, (b, share) => shares.set(axis.buckets[b].start, share)) + expect([...shares.keys()]).toEqual([MON, MON + 1, MON + 2, MON + 3, MON + 4]) + for (const v of shares.values()) expect(v).toBeCloseTo(8, 9) + }) + + it('gives the weekend no share and divides by the working days only', () => { + const { axis, index } = setup('day') + const shares = new Map() + // Monday of one week to Friday of the next: 10 working days + distribute(range(MON, MON + 11), 100, axis, index, (b, share) => shares.set(axis.buckets[b].start, share)) + expect(shares.size).toBe(10) + expect(shares.has(MON + 5)).toBe(false) + expect(shares.has(MON + 6)).toBe(false) + expect(shares.get(MON + 7)).toBeCloseTo(10, 9) + expect(sum([...shares.values()])).toBeCloseTo(100, 9) + }) + + it('skips the holidays of the calendar', () => { + const { axis, index } = setup('day', { weekdayMask: 31, holidays: [(MON + 2) * DAY_MS] }) + const shares = new Map() + distribute(range(MON, MON + 4), 32, axis, index, (b, share) => shares.set(axis.buckets[b].start, share)) + expect(shares.has(MON + 2)).toBe(false) + expect(shares.size).toBe(4) + expect(shares.get(MON)).toBeCloseTo(8, 9) + }) + + it('splits the load between the weeks of a span by their working days', () => { + const { axis, index } = setup('week') + const shares = new Map() + // Friday of week 1 (1 working day) to Tuesday of week 2 (2 working days): 3 working days + distribute(range(MON + 4, MON + 8), 30, axis, index, (b, share) => shares.set(axis.buckets[b].start, share)) + expect(shares.get(MON)).toBeCloseTo(10, 9) + expect(shares.get(MON + 7)).toBeCloseTo(20, 9) + }) + + it('puts all the load of a one date item on that day', () => { + const { axis, index } = setup('day') + const shares: number[] = [] + distribute(marker(MON + 2), 6, axis, index, (b, share) => shares.push(share, axis.buckets[b].start)) + expect(shares).toEqual([6, MON + 2]) + }) + + it('plans a span that has no working day on its calendar days', () => { + const { axis, index } = setup('day') + const shares = new Map() + // Saturday and Sunday + distribute(range(MON + 5, MON + 6), 10, axis, index, (b, share) => shares.set(axis.buckets[b].start, share)) + expect(shares.get(MON + 5)).toBeCloseTo(5, 9) + expect(shares.get(MON + 6)).toBeCloseTo(5, 9) + // A one day marker on a holiday keeps its load too + const holiday = setup('day', { weekdayMask: 31, holidays: [MON * DAY_MS] }) + const got: number[] = [] + distribute(marker(MON), 3, holiday.axis, holiday.index, (_b, share) => got.push(share)) + expect(got).toEqual([3]) + }) + + it('does nothing for an item without a day, and for a span outside the axis', () => { + const { axis, index } = setup('week') + const visit = jest.fn() + expect(distribute(unscheduled, 10, axis, index, visit)).toBe(false) + expect(distribute(range(axis.endDay + 10, axis.endDay + 20), 10, axis, index, visit)).toBe(false) + expect(distribute(range(axis.startDay - 30, axis.startDay - 2), 10, axis, index, visit)).toBe(false) + expect(visit).not.toHaveBeenCalled() + }) + + it('keeps only the part of a span that is on the axis', () => { + const { axis, index } = setup('week') + const shares: number[] = [] + // The span starts before the axis, the days after it are on the axis + distribute(range(axis.startDay - 7, axis.startDay + 6), 20, axis, index, (_b, share) => shares.push(share)) + expect(sum(shares)).toBeCloseTo(10, 9) + }) + + it('is safe for load that is not a number', () => { + const { axis, index } = setup('day') + for (const load of [Number.NaN, -5, Infinity, 0]) { + const shares: number[] = [] + distribute(range(MON, MON + 2), load, axis, index, (_b, share) => shares.push(share)) + for (const s of shares) { + expect(Number.isFinite(s)).toBe(true) + expect(s).toBeGreaterThanOrEqual(0) + } + } + }) + + it('conserves the load of an item inside the axis', () => { + for (const zoom of ['day', 'week', 'month'] as const) { + const { axis, index } = setup(zoom, MON_TO_FRI, [MON + 3, MON + 70]) + const shares: number[] = [] + distribute(range(MON + 3, MON + 70), 123.456, axis, index, (_b, share) => shares.push(share)) + expect(sum(shares)).toBeCloseTo(123.456, 9) + } + }) +}) + +describe('computeWorkload', () => { + it('adds the loads of a person up per bucket and counts the items', () => { + const { axis, index } = setup('week') + const { rows, outOfRange } = computeWorkload( + [ + entry('a', 'ann', 20, range(MON, MON + 4)), + entry('b', 'ann', 10, range(MON + 2, MON + 3)), + entry('c', 'bob', 8, range(MON + 7, MON + 11)) + ], + axis, + index + ) + expect(outOfRange).toBe(0) + const ann = rows.get('ann')! + expect(ann.loads[axis.todayIndex]).toBeCloseTo(30, 9) + expect(ann.counts[axis.todayIndex]).toBe(2) + expect(ann.itemCount).toBe(2) + const bob = rows.get('bob')! + expect(bob.loads[axis.todayIndex]).toBe(0) + expect(bob.loads[axis.todayIndex + 1]).toBeCloseTo(8, 9) + }) + + it('keeps a row per assignee, the unassigned items included', () => { + const { axis, index } = setup('week') + const { rows } = computeWorkload( + [entry('a', 'ann', 1, range(MON, MON)), entry('b', '__unassigned__', 1, range(MON, MON))], + axis, + index + ) + expect([...rows.keys()].sort()).toEqual(['__unassigned__', 'ann']) + }) + + it('collects the items without a day with their whole load', () => { + const { axis, index } = setup('week') + const { rows } = computeWorkload( + [entry('a', 'ann', 5, unscheduled), entry('b', 'ann', 7.5, unscheduled), entry('c', 'ann', 2, range(MON, MON))], + axis, + index + ) + const ann = rows.get('ann')! + expect(ann.unscheduledLoad).toBeCloseTo(12.5, 9) + expect(ann.unscheduledCount).toBe(2) + // The unscheduled load is not in any bucket + expect(sum(ann.loads)).toBeCloseTo(2, 9) + }) + + it('counts the items without a load value and still shows them in the cells', () => { + const { axis, index } = setup('week') + const { rows } = computeWorkload( + [entry('a', 'ann', 0, range(MON, MON + 4)), entry('b', 'ann', Number.NaN, unscheduled)], + axis, + index + ) + const ann = rows.get('ann')! + expect(ann.noLoadCount).toBe(2) + expect(ann.counts[axis.todayIndex]).toBe(1) + expect(ann.loads[axis.todayIndex]).toBe(0) + expect(Number.isNaN(ann.unscheduledLoad)).toBe(false) + }) + + it('counts a scheduled item outside the axis as out of range, once', () => { + const { axis, index } = setup('week') + const { rows, outOfRange } = computeWorkload( + [entry('a', 'ann', 10, range(axis.endDay + 40, axis.endDay + 50)), entry('b', 'ann', 1, range(MON, MON))], + axis, + index + ) + expect(outOfRange).toBe(1) + expect(rows.get('ann')!.itemCount).toBe(2) + }) + + it('returns nothing for no items', () => { + const { axis, index } = setup('week') + const res = computeWorkload([], axis, index) + expect(res.rows.size).toBe(0) + expect(res.outOfRange).toBe(0) + }) + + it('does not lose or add load over several people, spans and zoom levels', () => { + for (const zoom of ['day', 'week', 'month'] as const) { + const { axis, index } = setup(zoom, MON_TO_FRI, [MON, MON + 120]) + const entries: LoadEntry[] = [] + let expected = 0 + for (let i = 0; i < 60; i++) { + const load = (i % 7) + 0.25 + expected += load + entries.push(entry(`i${i}`, `p${i % 5}`, load, range(MON + i, MON + i + (i % 23)))) + } + const { rows } = computeWorkload(entries, axis, index) + let total = 0 + for (const row of rows.values()) total += sum(row.loads) + expect(total).toBeCloseTo(expected, 6) + } + }) + + it('gives the same totals in every zoom level for the same capacity of a person', () => { + const entries = [entry('a', 'ann', 100, range(MON, MON + 40))] + const totals = (['day', 'week', 'month'] as const).map((zoom) => { + const { axis, index } = setup(zoom, MON_TO_FRI, [MON, MON + 40]) + return sum(computeWorkload(entries, axis, index).rows.get('ann')!.loads) + }) + expect(totals[0]).toBeCloseTo(100, 9) + expect(totals[1]).toBeCloseTo(100, 9) + expect(totals[2]).toBeCloseTo(100, 9) + }) +}) + +describe('capacity and states', () => { + it('has the capacity per day times the working days of the bucket', () => { + const { axis, index } = setup('week') + const caps = bucketCapacities(axis, index, 8) + expect(caps[axis.todayIndex]).toBe(40) + const holiday = setup('week', { weekdayMask: 31, holidays: [(MON + 1) * DAY_MS, (MON + 2) * DAY_MS] }) + expect(bucketCapacities(holiday.axis, holiday.index, 8)[holiday.axis.todayIndex]).toBe(24) + expect(bucketCapacities(axis, index, 4)[axis.todayIndex]).toBe(20) + }) + + it('counts the weeks of a month in the capacity of the month', () => { + const { axis, index } = setup('month') + // October 2026 has 22 working days (Mon-Fri) + expect(bucketCapacities(axis, index, 8)[axis.todayIndex]).toBe(22 * 8) + }) + + it('has no capacity for a bad number', () => { + const { axis, index } = setup('week') + for (const bad of [0, -1, Number.NaN, Infinity]) { + const caps = bucketCapacities(axis, index, bad) + if (bad === Infinity) expect(caps[0]).toBe(0) + else expect(sum(caps)).toBe(0) + } + }) + + it('tells under, near and over capacity apart', () => { + expect(loadState(0, 40)).toBe('empty') + expect(loadState(10, 40)).toBe('under') + expect(loadState(40 * NEAR_RATIO, 40)).toBe('near') + expect(loadState(39.9, 40)).toBe('near') + expect(loadState(40, 40)).toBe('near') + expect(loadState(40.5, 40)).toBe('over') + }) + + it('does not call a sum of fractions that is exactly full over capacity', () => { + let load = 0 + for (let i = 0; i < 10; i++) load += 0.1 * 4 + expect(loadState(load, 4)).toBe('near') + expect(loadState(0.1 + 0.2, 0.3)).toBe('near') + }) + + it('treats load without any capacity as over', () => { + expect(loadState(1, 0)).toBe('over') + expect(loadState(0, 0)).toBe('empty') + expect(loadState(1, Number.NaN)).toBe('over') + }) + + it('is safe for load that is not a number', () => { + expect(loadState(Number.NaN, 40)).toBe('empty') + expect(loadState(-3, 40)).toBe('empty') + expect(loadState(Infinity, 40)).toBe('over') + }) + + it('reads the utilization as a share of the capacity', () => { + expect(utilization(20, 40)).toBe(0.5) + expect(utilization(60, 40)).toBe(1.5) + expect(utilization(0, 40)).toBe(0) + expect(utilization(5, 0)).toBeUndefined() + expect(utilization(Number.NaN, 40)).toBeUndefined() + expect(utilization(5, Infinity)).toBeUndefined() + }) +}) + +describe('summarizeRow', () => { + it('measures a person over the weeks in which they have load', () => { + const { axis, index } = setup('week') + const caps = bucketCapacities(axis, index, 8) + // Full for two weeks + const { rows } = computeWorkload([entry('a', 'ann', 80, range(MON, MON + 11))], axis, index) + const s = summarizeRow(rows.get('ann')!, caps) + expect(s.total).toBeCloseTo(80, 9) + expect(s.activeFrom).toBe(axis.todayIndex) + expect(s.activeTo).toBe(axis.todayIndex + 1) + expect(s.capacity).toBe(80) + expect(s.utilization).toBeCloseTo(1, 9) + expect(s.state).toBe('near') + expect(s.overCount).toBe(0) + }) + + it('flags a row over capacity as soon as one bucket is, whatever the average is', () => { + const { axis, index } = setup('week') + const caps = bucketCapacities(axis, index, 8) + const { rows } = computeWorkload( + [entry('a', 'ann', 60, range(MON, MON + 4)), entry('b', 'ann', 5, range(MON + 14, MON + 18))], + axis, + index + ) + const s = summarizeRow(rows.get('ann')!, caps) + expect(s.overCount).toBe(1) + expect(s.state).toBe('over') + expect(s.utilization).toBeLessThan(1) + }) + + it('has no utilization without load', () => { + const { axis, index } = setup('week') + const caps = bucketCapacities(axis, index, 8) + const { rows } = computeWorkload([entry('a', 'ann', 0, range(MON, MON + 4)), entry('b', 'ann', 9, unscheduled)], axis, index) + const s = summarizeRow(rows.get('ann')!, caps) + expect(s.total).toBe(0) + expect(s.activeFrom).toBe(-1) + expect(s.utilization).toBeUndefined() + expect(s.state).toBe('empty') + }) + + it('is over when the load is on days without capacity', () => { + // A weekend item is planned on the weekend, where nobody has capacity + const { axis, index } = setup('day') + const caps = bucketCapacities(axis, index, 8) + const { rows } = computeWorkload([entry('a', 'ann', 6, range(MON + 5, MON + 6))], axis, index) + const s = summarizeRow(rows.get('ann')!, caps) + expect(s.state).toBe('over') + expect(s.overCount).toBe(2) + }) +}) + +describe('cellItems', () => { + const { axis, index } = setup('week') + const entries = [ + entry('a', 'ann', 20, range(MON, MON + 4)), + entry('b', 'ann', 30, range(MON + 2, MON + 9)), + entry('c', 'bob', 99, range(MON, MON + 4)), + entry('d', 'ann', 0, range(MON, MON)), + entry('e', 'ann', 6, unscheduled), + entry('f', 'ann', 1, range(MON + 21, MON + 22)) + ] + + it('lists the items of a person in a bucket with their share, the biggest first', () => { + const items = cellItems(entries, axis, index, 'ann', axis.todayIndex) + expect(items.map((i) => i.id)).toEqual(['a', 'b', 'd']) + expect(items[0].share).toBeCloseTo(20, 9) + // 3 of the 6 working days of b are in the first week + expect(items[1].share).toBeCloseTo(15, 9) + expect(items[2].share).toBe(0) + }) + + it('agrees with the cell total', () => { + const { rows } = computeWorkload(entries, axis, index) + for (const bucket of [axis.todayIndex, axis.todayIndex + 1, axis.todayIndex + 3]) { + const total = sum(cellItems(entries, axis, index, 'ann', bucket).map((i) => i.share)) + expect(total).toBeCloseTo(rows.get('ann')!.loads[bucket], 9) + } + }) + + it('lists the items without a day of a person with their whole load', () => { + expect(cellItems(entries, axis, index, 'ann', 'unscheduled')).toEqual([{ id: 'e', share: 6 }]) + expect(cellItems(entries, axis, index, 'bob', 'unscheduled')).toEqual([]) + }) + + it('is empty for a cell without items', () => { + expect(cellItems(entries, axis, index, 'ann', axis.todayIndex + 10)).toEqual([]) + expect(cellItems(entries, axis, index, 'nobody', axis.todayIndex)).toEqual([]) + }) + + it('has a stable order for equal shares', () => { + const same = [entry('z', 'ann', 5, marker(MON)), entry('m', 'ann', 5, marker(MON))] + expect(cellItems(same, axis, index, 'ann', axis.todayIndex).map((i) => i.id)).toEqual(['m', 'z']) + }) +}) + +describe('rows without items and reassigning', () => { + it('has an empty row for a person without work', () => { + const { axis, index } = setup('week') + const row = emptyRowLoad('idle', axis.buckets.length) + expect(row.loads).toHaveLength(axis.buckets.length) + expect(sum(row.loads)).toBe(0) + const s = summarizeRow(row, bucketCapacities(axis, index, 8)) + expect(s.state).toBe('empty') + expect(s.utilization).toBeUndefined() + }) + + it('moves the load from one row to the other when an item is reassigned', () => { + const { axis, index } = setup('week') + const issue = { assignee: 'ann' as string | null } + const make = (): LoadEntry[] => [entry('a', rowKeyOf(issue.assignee), 40, range(MON, MON + 4))] + const before = computeWorkload(make(), axis, index).rows + expect(before.get('ann')!.loads[axis.todayIndex]).toBeCloseTo(40, 9) + const plan = planReassign(issue, 'bob', { readonly: false }) + expect(plan.ok).toBe(true) + if (plan.ok === false) return + issue.assignee = plan.assignee + const after = computeWorkload(make(), axis, index).rows + expect(after.has('ann')).toBe(false) + expect(after.get('bob')!.loads[axis.todayIndex]).toBeCloseTo(40, 9) + // Dropping on the unassigned row clears the assignee and keeps the load in that row + const cleared = planReassign(issue, '__unassigned__', { readonly: false }) + if (cleared.ok === false) return + issue.assignee = cleared.assignee + expect(computeWorkload(make(), axis, index).rows.get('__unassigned__')!.loads[axis.todayIndex]).toBeCloseTo(40, 9) + }) +}) + +describe('time zones', () => { + it('plans the same working days whatever the zone of the viewer is', () => { + // The days are the local calendar dates, so a date picked at 23:30 is still the same day + const late = new Date(2026, 9, 5, 23, 30).getTime() + const early = new Date(2026, 9, 9, 0, 15).getTime() + expect(toDay(late)).toBe(MON) + expect(toDay(early)).toBe(MON + 4) + const { axis, index } = setup('day') + const shares = new Map() + distribute(range(toDay(late), toDay(early)), 40, axis, index, (b, s) => shares.set(axis.buckets[b].start, s)) + expect(shares.size).toBe(5) + for (const v of shares.values()) expect(v).toBeCloseTo(8, 9) + }) + + it('has weeks of seven days over a daylight saving change', () => { + // Between the end of March and the end of October the clocks change in many zones + const around = (y: number, m: number, d: number): number => dayOf(y, m, d) + for (const day of [around(2026, 2, 29), around(2026, 9, 25), around(2026, 10, 1), around(2026, 2, 8)]) { + const axis = computeAxis('week', [], day, 1) + for (const b of axis.buckets) expect(b.end - b.start).toBe(7) + } + }) +}) + +describe('performance', () => { + it('plans 5000 items over 52 weeks quickly', () => { + const axis = computeAxis('week', [TODAY, TODAY + 364 + 40], TODAY, 1) + const index = WorkdayIndex.around(TODAY, MON_TO_FRI) + const entries: LoadEntry[] = [] + let seed = 7 + const next = (): number => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff + return seed / 0x7fffffff + } + let expected = 0 + for (let i = 0; i < 5000; i++) { + const start = TODAY + Math.floor(next() * 364) + const length = Math.floor(next() * 40) + const load = Math.floor(next() * 40) + expected += load + entries.push(entry(`i${i}`, `p${i % 60}`, load, i % 20 === 0 ? unscheduled : range(start, start + length))) + } + const started = Date.now() + const { rows } = computeWorkload(entries, axis, index) + const elapsed = Date.now() - started + expect(rows.size).toBe(60) + let total = 0 + for (const row of rows.values()) total += sum(row.loads) + row.unscheduledLoad + // The axis covers every item, so nothing is lost + expect(total).toBeCloseTo(expected, 4) + expect(elapsed).toBeLessThan(2000) + const cells = Date.now() + cellItems(entries, axis, index, 'p3', axis.todayIndex + 5) + expect(Date.now() - cells).toBeLessThan(500) + }) + + it('plans 5000 items at day zoom too', () => { + const axis = computeAxis('day', [TODAY, TODAY + 330], TODAY, 1) + const index = WorkdayIndex.around(TODAY, MON_TO_FRI) + const entries: LoadEntry[] = [] + for (let i = 0; i < 5000; i++) entries.push(entry(`i${i}`, `p${i % 60}`, 8, range(TODAY + (i % 300), TODAY + (i % 300) + 30))) + const started = Date.now() + computeWorkload(entries, axis, index) + expect(Date.now() - started).toBeLessThan(2000) + }) +}) diff --git a/plugins/tracker-resources/src/workload/__tests__/config.test.ts b/plugins/tracker-resources/src/workload/__tests__/config.test.ts new file mode 100644 index 0000000000..29a2df3401 --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/config.test.ts @@ -0,0 +1,216 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import path from 'path' +import { NO_DATE_SOURCE } from '../../calendar/config' +import { + DEFAULT_CAPACITY_PER_DAY, + DEFAULT_WORKLOAD_CONFIG, + isDefaultWorkloadConfig, + isLoadMeasure, + MAX_CAPACITY_PER_DAY, + normalizeWorkloadConfig, + readWorkloadConfig, + sanitizeWorkloadConfig, + validCapacity, + withWorkloadConfig, + workloadSelection, + WORKLOAD_OPTION_KEY +} from '../config' + +// The saved views logic is a pure module of view-resources. The path is built at run time so that the type +// checkers do not pull view-resources sources into this package. +const { isViewDirty } = require(path.resolve(__dirname, '../../../../view-resources/src/savedViews')) as { + isViewDirty: (baseline: Record, current: Record) => boolean +} + +describe('defaults', () => { + it('is a week view of the estimate with 8 hours a day on the dates of the roadmap', () => { + expect(DEFAULT_WORKLOAD_CONFIG).toEqual({ + start: 'issue:startDate', + target: 'issue:dueDate', + zoom: 'week', + measure: 'estimate', + capacity: 8 + }) + expect(DEFAULT_CAPACITY_PER_DAY).toBe(8) + }) +}) + +describe('validCapacity', () => { + it('accepts a finite number above zero, also as text', () => { + expect(validCapacity(6)).toBe(6) + expect(validCapacity(7.5)).toBe(7.5) + expect(validCapacity(' 6.25 ')).toBe(6.25) + expect(validCapacity(0.126)).toBe(0.13) + expect(validCapacity(MAX_CAPACITY_PER_DAY)).toBe(MAX_CAPACITY_PER_DAY) + }) + + it('refuses zero, negative, huge, not finite and not numbers', () => { + for (const bad of [0, -1, MAX_CAPACITY_PER_DAY + 1, Number.NaN, Infinity, '', 'abc', null, undefined, {}, [], true]) { + expect(validCapacity(bad)).toBeUndefined() + } + }) +}) + +describe('normalizeWorkloadConfig', () => { + it('gives the defaults for nothing or garbage', () => { + expect(normalizeWorkloadConfig(undefined)).toEqual(DEFAULT_WORKLOAD_CONFIG) + expect(normalizeWorkloadConfig(null)).toEqual(DEFAULT_WORKLOAD_CONFIG) + expect(normalizeWorkloadConfig('x')).toEqual(DEFAULT_WORKLOAD_CONFIG) + expect( + normalizeWorkloadConfig({ start: 5, target: 'nope', zoom: 'year', measure: 'hours', capacity: -2, field: 4 }) + ).toEqual(DEFAULT_WORKLOAD_CONFIG) + }) + + it('keeps valid values, a switched off date field included', () => { + const config = { + start: NO_DATE_SOURCE, + target: 'field:release', + zoom: 'month', + measure: 'field', + field: 'points', + capacity: 6 + } + expect(normalizeWorkloadConfig(config)).toEqual(config) + expect(normalizeWorkloadConfig({ measure: 'count', zoom: 'day', capacity: 3 })).toEqual({ + ...DEFAULT_WORKLOAD_CONFIG, + measure: 'count', + zoom: 'day', + capacity: 3 + }) + }) + + it('needs a field for the measure number field', () => { + expect(normalizeWorkloadConfig({ measure: 'field' }).measure).toBe('estimate') + expect(normalizeWorkloadConfig({ measure: 'field', field: '' }).measure).toBe('estimate') + }) + + it('drops a field that no measure uses', () => { + expect(normalizeWorkloadConfig({ measure: 'remaining', field: 'points' })).toEqual({ + ...DEFAULT_WORKLOAD_CONFIG, + measure: 'remaining' + }) + }) + + it('needs one date field: two switched off give the default pair and keep the rest', () => { + expect(normalizeWorkloadConfig({ start: 'none', target: 'none', zoom: 'day', capacity: 5 })).toEqual({ + ...DEFAULT_WORKLOAD_CONFIG, + zoom: 'day', + capacity: 5 + }) + }) + + it('recognises the measures', () => { + expect(isLoadMeasure('count')).toBe(true) + expect(isLoadMeasure('points')).toBe(false) + }) + + it('does not share state with the defaults', () => { + const res = normalizeWorkloadConfig(undefined) + res.start = 'x' + expect(DEFAULT_WORKLOAD_CONFIG.start).toBe('issue:startDate') + }) +}) + +describe('storing the config in the view options', () => { + const options = { groupBy: ['#no_category'], orderBy: ['rank', 1] } + + it('stores a customized config and reads it back next to the other options', () => { + const config = { ...DEFAULT_WORKLOAD_CONFIG, zoom: 'month' as const, capacity: 7 } + const stored = withWorkloadConfig(options, config) + expect(stored.groupBy).toEqual(options.groupBy) + expect(readWorkloadConfig(stored)).toEqual(config) + }) + + it('does not store the default config and removes a stored one that returns to it', () => { + const custom = withWorkloadConfig(options, { ...DEFAULT_WORKLOAD_CONFIG, measure: 'count' }) + expect(WORKLOAD_OPTION_KEY in custom).toBe(true) + const back = withWorkloadConfig(custom, { ...DEFAULT_WORKLOAD_CONFIG }) + expect(WORKLOAD_OPTION_KEY in back).toBe(false) + expect(back).toEqual(options) + }) + + it('does not store a field for a measure that does not use it', () => { + const stored = withWorkloadConfig(options, { ...DEFAULT_WORKLOAD_CONFIG, measure: 'count', field: 'points' }) + expect((stored as any)[WORKLOAD_OPTION_KEY].field).toBeUndefined() + }) + + it('does not modify the options it is given', () => { + const frozen = Object.freeze({ ...options }) + expect(() => withWorkloadConfig(frozen, { ...DEFAULT_WORKLOAD_CONFIG, zoom: 'day' })).not.toThrow() + }) + + it('does not collide with the roadmap, board or calendar settings', () => { + const stored = withWorkloadConfig( + { roadmap: { zoom: 'year' }, board: { columnField: 'x' }, calendar: { mode: 'week' } }, + { ...DEFAULT_WORKLOAD_CONFIG, zoom: 'day' } + ) + expect(stored.roadmap).toEqual({ zoom: 'year' }) + expect(stored.board).toEqual({ columnField: 'x' }) + expect(stored.calendar).toEqual({ mode: 'week' }) + }) + + it('tells the default from a changed config', () => { + expect(isDefaultWorkloadConfig(normalizeWorkloadConfig(undefined))).toBe(true) + expect(isDefaultWorkloadConfig({ ...DEFAULT_WORKLOAD_CONFIG, capacity: 6 })).toBe(false) + expect(isDefaultWorkloadConfig({ ...DEFAULT_WORKLOAD_CONFIG, start: NO_DATE_SOURCE })).toBe(false) + }) + + it('makes a saved view dirty only when the workload settings changed', () => { + const saved = { viewOptions: withWorkloadConfig(options, DEFAULT_WORKLOAD_CONFIG) as any } + expect(isViewDirty(saved, { viewOptions: withWorkloadConfig(options, DEFAULT_WORKLOAD_CONFIG) as any })).toBe(false) + const week = withWorkloadConfig(options, { ...DEFAULT_WORKLOAD_CONFIG, capacity: 6 }) + expect(isViewDirty(saved, { viewOptions: week as any })).toBe(true) + // Changing the setting and changing it back is not a change + const again = withWorkloadConfig(week, { ...DEFAULT_WORKLOAD_CONFIG }) + expect(isViewDirty(saved, { viewOptions: again as any })).toBe(false) + // A view saved with a workload config is clean while it is unchanged + const customSaved = { viewOptions: week as any } + expect(isViewDirty(customSaved, { viewOptions: withWorkloadConfig(options, readWorkloadConfig(week)) as any })).toBe(false) + // Every setting is a change + for (const change of [{ zoom: 'day' }, { measure: 'remaining' }, { target: 'field:x' }, { start: 'none' }]) { + const changed = withWorkloadConfig(options, normalizeWorkloadConfig({ ...DEFAULT_WORKLOAD_CONFIG, ...change })) + expect(isViewDirty(saved, { viewOptions: changed as any })).toBe(true) + } + }) +}) + +describe('workloadSelection', () => { + it('gives the date sources of the view', () => { + expect(workloadSelection({ ...DEFAULT_WORKLOAD_CONFIG, target: 'field:x' })).toEqual({ + start: 'issue:startDate', + target: 'field:x' + }) + }) +}) + +describe('sanitizeWorkloadConfig', () => { + const available = { sourceIds: new Set(['issue:startDate', 'issue:dueDate', 'field:release']), numberFieldKeys: new Set(['points']) } + + it('keeps a config that is fully available', () => { + const config = { ...DEFAULT_WORKLOAD_CONFIG, start: NO_DATE_SOURCE, target: 'field:release', measure: 'field' as const, field: 'points' } + expect(sanitizeWorkloadConfig(config, available)).toEqual(config) + }) + + it('falls back for a date field that is gone', () => { + const res = sanitizeWorkloadConfig({ ...DEFAULT_WORKLOAD_CONFIG, start: 'field:deleted', target: 'field:release' }, available) + expect(workloadSelection(res)).toEqual({ start: 'issue:startDate', target: 'field:release' }) + }) + + it('falls back to the default measure for a number field that is gone', () => { + const res = sanitizeWorkloadConfig({ ...DEFAULT_WORKLOAD_CONFIG, measure: 'field', field: 'deleted' }, available) + expect(res.measure).toBe('estimate') + expect(res.field).toBeUndefined() + }) + + it('does not end up with no date field at all', () => { + const res = sanitizeWorkloadConfig( + { ...DEFAULT_WORKLOAD_CONFIG, start: NO_DATE_SOURCE, target: 'field:deleted' }, + { sourceIds: new Set(['issue:startDate']), numberFieldKeys: new Set() } + ) + expect(res.start === NO_DATE_SOURCE && res.target === NO_DATE_SOURCE).toBe(false) + }) +}) diff --git a/plugins/tracker-resources/src/workload/__tests__/format.test.ts b/plugins/tracker-resources/src/workload/__tests__/format.test.ts new file mode 100644 index 0000000000..43a9151859 --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/format.test.ts @@ -0,0 +1,36 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { formatAmount, formatLoadValue, formatPercent } from '../format' + +describe('format', () => { + it('prints amounts with at most one decimal and no trailing zeros', () => { + expect(formatAmount(8, 'en')).toBe('8') + expect(formatAmount(2.5, 'en')).toBe('2.5') + expect(formatAmount(2.549, 'en')).toBe('2.5') + expect(formatAmount(2.55, 'en', 2)).toBe('2.55') + expect(formatAmount(1234.5, 'en')).toBe('1,234.5') + }) + + it('is safe for numbers that are not finite and for negative zero', () => { + expect(formatAmount(Number.NaN, 'en')).toBe('0') + expect(formatAmount(Infinity, 'en')).toBe('0') + expect(formatAmount(-0.01, 'en')).toBe('0') + expect(formatLoadValue(Number.NaN, 'estimate', 'en')).toBe('0h') + }) + + it('adds hours for the time measures only', () => { + expect(formatLoadValue(12, 'estimate', 'en')).toBe('12h') + expect(formatLoadValue(12.5, 'remaining', 'en')).toBe('12.5h') + expect(formatLoadValue(12, 'count', 'en')).toBe('12') + expect(formatLoadValue(12, 'field', 'en')).toBe('12') + }) + + it('prints percents', () => { + expect(formatPercent(1, 'en')).toBe('100%') + expect(formatPercent(0.834, 'en')).toBe('83%') + expect(formatPercent(Number.NaN, 'en')).toBe('0%') + }) +}) diff --git a/plugins/tracker-resources/src/workload/__tests__/load.test.ts b/plugins/tracker-resources/src/workload/__tests__/load.test.ts new file mode 100644 index 0000000000..58631da0a1 --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/load.test.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { loadProjection, readLoad } from '../load' + +describe('readLoad', () => { + const issue = { estimation: 12, remainingTime: 5, customFields: { points: 3, text: 'x', bad: Number.NaN, neg: -4, str: '2.5' } } + + it('reads the estimate, the remaining time, the count and a number field', () => { + expect(readLoad(issue, 'estimate')).toBe(12) + expect(readLoad(issue, 'remaining')).toBe(5) + expect(readLoad(issue, 'count')).toBe(1) + expect(readLoad(issue, 'field', 'points')).toBe(3) + }) + + it('counts an item even when it has no values', () => { + expect(readLoad({}, 'count')).toBe(1) + }) + + it('gives no load for what is not a usable number', () => { + expect(readLoad({}, 'estimate')).toBe(0) + expect(readLoad({ estimation: null }, 'estimate')).toBe(0) + expect(readLoad({ estimation: Number.NaN }, 'estimate')).toBe(0) + expect(readLoad({ remainingTime: Infinity }, 'remaining')).toBe(0) + expect(readLoad({ estimation: -2 }, 'estimate')).toBe(0) + expect(readLoad(issue, 'field', 'text')).toBe(0) + expect(readLoad(issue, 'field', 'bad')).toBe(0) + expect(readLoad(issue, 'field', 'neg')).toBe(0) + expect(readLoad(issue, 'field', 'missing')).toBe(0) + expect(readLoad(issue, 'field')).toBe(0) + expect(readLoad({ estimation: 3 }, 'field', 'points')).toBe(0) + }) + + it('accepts a number that was stored as text', () => { + expect(readLoad(issue, 'field', 'str')).toBe(2.5) + }) + + it('names the properties it reads', () => { + expect(loadProjection('estimate')).toEqual(['estimation']) + expect(loadProjection('remaining')).toEqual(['remainingTime']) + expect(loadProjection('count')).toEqual([]) + expect(loadProjection('field')).toEqual(['customFields']) + }) +}) diff --git a/plugins/tracker-resources/src/workload/__tests__/reassign.test.ts b/plugins/tracker-resources/src/workload/__tests__/reassign.test.ts new file mode 100644 index 0000000000..1066832a35 --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/reassign.test.ts @@ -0,0 +1,42 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { planReassign } from '../reassign' +import { UNASSIGNED_ROW } from '../rows' + +describe('planReassign', () => { + it('writes the person of the row the item was dropped on', () => { + const plan = planReassign({ assignee: 'ann' }, 'bob', { readonly: false }) + expect(plan).toEqual({ ok: true, assignee: 'bob', patch: { assignee: 'bob' } }) + }) + + it('assigns an unassigned item', () => { + expect(planReassign({ assignee: null }, 'bob', { readonly: false })).toEqual({ + ok: true, + assignee: 'bob', + patch: { assignee: 'bob' } + }) + expect(planReassign({}, 'bob', { readonly: false }).ok).toBe(true) + }) + + it('clears the assignee when the item is dropped on the unassigned row', () => { + expect(planReassign({ assignee: 'ann' }, UNASSIGNED_ROW, { readonly: false })).toEqual({ + ok: true, + assignee: null, + patch: { assignee: null } + }) + }) + + it('changes nothing for the row the item is in', () => { + expect(planReassign({ assignee: 'ann' }, 'ann', { readonly: false })).toEqual({ ok: false, reason: 'same' }) + expect(planReassign({ assignee: null }, UNASSIGNED_ROW, { readonly: false })).toEqual({ ok: false, reason: 'same' }) + expect(planReassign({}, UNASSIGNED_ROW, { readonly: false })).toEqual({ ok: false, reason: 'same' }) + }) + + it('does not let a read-only viewer change anything', () => { + expect(planReassign({ assignee: 'ann' }, 'bob', { readonly: true })).toEqual({ ok: false, reason: 'readonly' }) + expect(planReassign({ assignee: 'ann' }, 'ann', { readonly: true })).toEqual({ ok: false, reason: 'readonly' }) + }) +}) diff --git a/plugins/tracker-resources/src/workload/__tests__/rows.test.ts b/plugins/tracker-resources/src/workload/__tests__/rows.test.ts new file mode 100644 index 0000000000..46c75cf793 --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/rows.test.ts @@ -0,0 +1,41 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { assigneeOfRow, orderRows, rowKeyOf, UNASSIGNED_ROW } from '../rows' + +describe('rows', () => { + it('maps assignees to rows and back', () => { + expect(rowKeyOf('p1')).toBe('p1') + expect(rowKeyOf(null)).toBe(UNASSIGNED_ROW) + expect(rowKeyOf(undefined)).toBe(UNASSIGNED_ROW) + expect(rowKeyOf('')).toBe(UNASSIGNED_ROW) + expect(assigneeOfRow('p1')).toBe('p1') + expect(assigneeOfRow(UNASSIGNED_ROW)).toBeNull() + }) + + it('orders people by name and puts the unassigned row last', () => { + const names: Record = { a: 'Zoe Adams', b: 'adam Brown', c: 'Émile Cole', d: 'Anna 10', e: 'Anna 2' } + const rows = orderRows([UNASSIGNED_ROW, 'a', 'b', 'c', 'd', 'e'], (k) => names[k], 'Unassigned', 'en') + expect(rows.map((r) => r.name)).toEqual(['adam Brown', 'Anna 2', 'Anna 10', 'Émile Cole', 'Zoe Adams', 'Unassigned']) + expect(rows[rows.length - 1].unassigned).toBe(true) + expect(rows[0].unassigned).toBe(false) + }) + + it('shows the key of a person that is not known', () => { + const rows = orderRows(['x', 'y'], (k) => (k === 'x' ? ' ' : undefined), 'Unassigned') + expect(rows.map((r) => r.name)).toEqual(['x', 'y']) + }) + + it('has no unassigned row when there are no unassigned items', () => { + expect(orderRows(['a'], () => 'A', 'Unassigned').map((r) => r.key)).toEqual(['a']) + expect(orderRows([], () => 'A', 'Unassigned')).toEqual([]) + }) + + it('does not depend on the order the people came in', () => { + const name = (k: string): string => 'Same' + expect(orderRows(['b', 'a'], name, 'U').map((r) => r.key)).toEqual(['a', 'b']) + expect(orderRows(['a', 'b'], name, 'U').map((r) => r.key)).toEqual(['a', 'b']) + }) +}) diff --git a/plugins/tracker-resources/src/workload/__tests__/workdays.test.ts b/plugins/tracker-resources/src/workload/__tests__/workdays.test.ts new file mode 100644 index 0000000000..40e3b7a924 --- /dev/null +++ b/plugins/tracker-resources/src/workload/__tests__/workdays.test.ts @@ -0,0 +1,111 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { isWorkingDay } from '@hcengineering/gantt' +import { dayOf } from '../../calendar/grid' +import { + DEFAULT_WEEKDAY_MASK, + DEFAULT_WORKING_CALENDAR, + effectiveWorkingCalendar, + WORKDAY_WINDOW_DAYS, + WorkdayIndex +} from '../workdays' + +const DAY_MS = 86400000 +// 5 October 2026 is a Monday +const MON = dayOf(2026, 9, 5) +const holiday = (day: number): number => day * DAY_MS + +describe('effectiveWorkingCalendar', () => { + it('plans a project without a calendar on Monday to Friday, unlike the Gantt', () => { + const cal = effectiveWorkingCalendar(undefined, [holiday(MON)]) + expect(cal.weekdayMask).toBe(DEFAULT_WEEKDAY_MASK) + // The holidays belong to a project that opted in + expect(cal.holidays).toEqual([]) + expect(DEFAULT_WORKING_CALENDAR.holidays).toEqual([]) + }) + + it('takes the weekdays and the holidays of the project', () => { + const cal = effectiveWorkingCalendar({ weekdayMask: 63 }, [holiday(MON)]) + expect(cal).toEqual({ weekdayMask: 63, holidays: [holiday(MON)] }) + }) + + it('does not share the list of holidays', () => { + const list = [holiday(MON)] + const cal = effectiveWorkingCalendar({ weekdayMask: 31 }, list) + list.push(1) + expect(cal.holidays).toHaveLength(1) + }) + + it('falls back for a damaged mask', () => { + expect(effectiveWorkingCalendar({ weekdayMask: Number.NaN }, []).weekdayMask).toBe(DEFAULT_WEEKDAY_MASK) + expect(effectiveWorkingCalendar({ weekdayMask: 'x' as any }, []).weekdayMask).toBe(DEFAULT_WEEKDAY_MASK) + }) +}) + +describe('WorkdayIndex', () => { + const cal = { weekdayMask: 31, holidays: [] } + + it('classifies weekdays', () => { + const index = new WorkdayIndex(MON - 14, MON + 28, cal) + for (let i = 0; i < 5; i++) expect(index.isWorkday(MON + i)).toBe(true) + expect(index.isWorkday(MON + 5)).toBe(false) + expect(index.isWorkday(MON + 6)).toBe(false) + expect(index.isWorkday(MON + 7)).toBe(true) + }) + + it('treats holidays as non-working days', () => { + const index = new WorkdayIndex(MON - 14, MON + 28, { weekdayMask: 31, holidays: [holiday(MON + 1), holiday(MON + 5)] }) + expect(index.isWorkday(MON + 1)).toBe(false) + expect(index.count(MON, MON + 4)).toBe(4) + // A holiday on a weekend changes nothing + expect(index.count(MON, MON + 6)).toBe(4) + }) + + it('honours another weekday mask (Monday to Saturday, all days, none)', () => { + expect(new WorkdayIndex(MON, MON + 7, { weekdayMask: 63, holidays: [] }).count(MON, MON + 6)).toBe(6) + expect(new WorkdayIndex(MON, MON + 7, { weekdayMask: 127, holidays: [] }).count(MON, MON + 6)).toBe(7) + expect(new WorkdayIndex(MON, MON + 7, { weekdayMask: 0, holidays: [] }).count(MON, MON + 6)).toBe(0) + // Sunday only + expect(new WorkdayIndex(MON, MON + 7, { weekdayMask: 64, holidays: [] }).isWorkday(MON + 6)).toBe(true) + }) + + it('counts both ends of a span', () => { + const index = new WorkdayIndex(MON - 14, MON + 28, cal) + expect(index.count(MON, MON)).toBe(1) + expect(index.count(MON, MON + 4)).toBe(5) + expect(index.count(MON + 5, MON + 6)).toBe(0) + expect(index.count(MON, MON + 13)).toBe(10) + expect(index.count(MON + 3, MON)).toBe(0) + }) + + it('does not count days outside the window', () => { + const index = new WorkdayIndex(MON, MON + 7, cal) + expect(index.count(MON - 100, MON + 100)).toBe(5) + expect(index.isWorkday(MON - 1)).toBe(false) + expect(index.has(MON + 7)).toBe(false) + }) + + it('agrees with the working day rule of the Gantt for every day', () => { + const calendar = { weekdayMask: 0b0101101, holidays: [holiday(MON + 3), holiday(MON + 10)] } + const index = new WorkdayIndex(MON - 50, MON + 50, calendar) + for (let day = MON - 50; day < MON + 50; day++) { + expect(index.isWorkday(day)).toBe(isWorkingDay(day * DAY_MS, calendar)) + } + }) + + it('is empty and safe for a reversed window', () => { + const index = new WorkdayIndex(MON + 5, MON, cal) + expect(index.size).toBe(0) + expect(index.count(MON, MON + 10)).toBe(0) + }) + + it('covers a window around today', () => { + const index = WorkdayIndex.around(MON, cal) + expect(index.has(MON - WORKDAY_WINDOW_DAYS)).toBe(true) + expect(index.has(MON + WORKDAY_WINDOW_DAYS)).toBe(true) + expect(index.has(MON + WORKDAY_WINDOW_DAYS + 1)).toBe(false) + }) +}) diff --git a/plugins/tracker-resources/src/workload/axis.ts b/plugins/tracker-resources/src/workload/axis.ts new file mode 100644 index 0000000000..5f11dbdefb --- /dev/null +++ b/plugins/tracker-resources/src/workload/axis.ts @@ -0,0 +1,252 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { addMonths, monthStartOf, normalizeFirstDay, weekStartOf } from '../calendar/grid' +import { dayParts, MAX_DISTANCE_DAYS } from '../roadmap/timeScale' + +// Time axis of the Workload layout: whole buckets (a day, a week or a month) in a row. Everything is computed on +// whole local calendar days (the day index of roadmap/timeScale.ts), so a daylight saving change never gives a week +// of 6 or 8 days. + +export type WorkloadZoom = 'day' | 'week' | 'month' + +export const WORKLOAD_ZOOMS: readonly WorkloadZoom[] = ['day', 'week', 'month'] + +export const DEFAULT_WORKLOAD_ZOOM: WorkloadZoom = 'week' + +export function isWorkloadZoom (value: unknown): value is WorkloadZoom { + return value === 'day' || value === 'week' || value === 'month' +} + +/** Pixels of a bucket column at each zoom level. */ +export const COLUMN_WIDTH: Readonly> = { + day: 44, + week: 84, + month: 108 +} + +// Most buckets one axis shows. A longer range is cut to this many buckets around today; the items outside are counted +// as out of range, never silently dropped. +export const MAX_BUCKETS: Readonly> = { + day: 366, + week: 156, + month: 72 +} + +// The span an axis shows at least, in days before and after today, so that a view with few dates still has room +const MIN_BEHIND_DAYS: Readonly> = { day: 14, week: 56, month: 92 } +const MIN_AHEAD_DAYS: Readonly> = { day: 42, week: 126, month: 276 } + +export interface Bucket { + index: number + // First day + start: number + // First day after the bucket + end: number +} + +export interface WorkloadAxis { + zoom: WorkloadZoom + buckets: Bucket[] + // First day of the axis and the first day after it + startDay: number + endDay: number + // Index of the bucket that contains today, -1 when today is not on the axis + todayIndex: number + // Whether the range of the items was longer than the axis can show + trimmed: boolean +} + +/** First day of the bucket of the zoom level that contains the day. */ +export function bucketStart (zoom: WorkloadZoom, day: number, firstDay: number): number { + switch (zoom) { + case 'day': + return day + case 'week': + return weekStartOf(day, normalizeFirstDay(firstDay)) + case 'month': + return monthStartOf(day) + } +} + +/** First day of the bucket that follows the one that starts at `start`. */ +export function nextBucketStart (zoom: WorkloadZoom, start: number): number { + switch (zoom) { + case 'day': + return start + 1 + case 'week': + return start + 7 + case 'month': + return addMonths(monthStartOf(start), 1) + } +} + +function buildBuckets (zoom: WorkloadZoom, fromDay: number, toDay: number, firstDay: number): Bucket[] { + const res: Bucket[] = [] + let cursor = bucketStart(zoom, fromDay, firstDay) + const last = bucketStart(zoom, Math.max(toDay, fromDay), firstDay) + while (cursor <= last) { + const next = nextBucketStart(zoom, cursor) + res.push({ index: res.length, start: cursor, end: next }) + cursor = next + } + return res +} + +/** + * The axis for the days that items occupy: it covers all of them and today, is at least as long as the minimal span of + * the zoom level (more room ahead of today than behind it) and is made of whole buckets. Days too far from today + * are ignored, like on the roadmap, and a range of more than `MAX_BUCKETS` buckets is cut around today. + */ +export function computeAxis ( + zoom: WorkloadZoom, + days: Iterable, + today: number, + firstDay: number +): WorkloadAxis { + const lowest = today - MAX_DISTANCE_DAYS + const highest = today + MAX_DISTANCE_DAYS + let from = today - MIN_BEHIND_DAYS[zoom] + let to = today + MIN_AHEAD_DAYS[zoom] + for (const d of days) { + if (!Number.isFinite(d) || d < lowest || d > highest) continue + if (d < from) from = d + if (d > to) to = d + } + let buckets = buildBuckets(zoom, from, to, firstDay) + let trimmed = false + const cap = MAX_BUCKETS[zoom] + if (buckets.length > cap) { + trimmed = true + const todayAt = buckets.findIndex((b) => today >= b.start && today < b.end) + let first = Math.max(0, (todayAt >= 0 ? todayAt : 0) - Math.floor(cap / 4)) + if (first + cap > buckets.length) first = buckets.length - cap + buckets = buckets.slice(first, first + cap).map((b, index) => ({ ...b, index })) + } + const todayIndex = buckets.findIndex((b) => today >= b.start && today < b.end) + return { + zoom, + buckets, + startDay: buckets[0].start, + endDay: buckets[buckets.length - 1].end, + todayIndex, + trimmed + } +} + +/** Index of the bucket that contains the day (binary search), -1 when it is not on the axis. */ +export function bucketIndexOfDay (axis: Pick, day: number): number { + const list = axis.buckets + if (list.length === 0 || day < list[0].start || day >= list[list.length - 1].end) return -1 + let lo = 0 + let hi = list.length - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (list[mid].start <= day) lo = mid + else hi = mid - 1 + } + return lo +} + +// ---- header ---- + +export interface HeaderGroup { + // Index of the first bucket of the group and the number of buckets + from: number + count: number + // The first day of the group: a month (day and week zoom) or a year (month zoom) + day: number + // Month 0-11 for a month group, -1 for a year group + month: number + year: number +} + +/** + * The coarse header row over the buckets: months over days and weeks, years over months. A week belongs to the month + * its first day is in. + */ +export function headerGroups (axis: Pick): HeaderGroup[] { + const res: HeaderGroup[] = [] + for (const bucket of axis.buckets) { + const p = dayParts(bucket.start) + const month = axis.zoom === 'month' ? -1 : p.month + const last = res[res.length - 1] + if (last !== undefined && last.year === p.year && last.month === month) { + last.count++ + } else { + res.push({ from: bucket.index, count: 1, day: bucket.start, month, year: p.year }) + } + } + return res +} + +const DAY_MS = 86400000 + +const utcDate = (day: number): Date => new Date(day * DAY_MS) + +/** Label of a group of the coarse header: "October 2026" or "2026". */ +export function formatHeaderGroup (group: HeaderGroup, locale?: string): string { + if (group.month < 0) return String(group.year) + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(utcDate(group.day)) +} + +/** Short label of a bucket: "Mon 5" for a day, "5 Oct" style for a week, "Oct" for a month. */ +export function formatBucketLabel (zoom: WorkloadZoom, bucket: Pick, locale?: string): string { + switch (zoom) { + case 'day': { + const weekday = new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(utcDate(bucket.start)) + const date = new Intl.DateTimeFormat(locale, { day: 'numeric', timeZone: 'UTC' }).format(utcDate(bucket.start)) + return `${weekday} ${date}` + } + case 'week': + return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format( + utcDate(bucket.start) + ) + case 'month': + return new Intl.DateTimeFormat(locale, { month: 'short', timeZone: 'UTC' }).format(utcDate(bucket.start)) + } +} + +/** Long description of a bucket for tooltips and the panel: a date, a date range or a month. */ +export function formatBucketRange (zoom: WorkloadZoom, bucket: Pick, locale?: string): string { + if (zoom === 'month') { + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format( + utcDate(bucket.start) + ) + } + const format = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }) + if (zoom === 'day') return format.format(utcDate(bucket.start)) + return `${format.format(utcDate(bucket.start))} – ${format.format(utcDate(bucket.end - 1))}` +} + +// ---- viewport ---- + +/** The range of bucket indexes to draw for a scroll position: the visible ones plus `overscan` on each side. */ +export function visibleBucketRange ( + scrollLeft: number, + viewportWidth: number, + columnWidth: number, + count: number, + overscan: number = 4 +): { from: number, to: number } { + if (count <= 0 || !(columnWidth > 0)) return { from: 0, to: -1 } + const safeLeft = Number.isFinite(scrollLeft) ? Math.max(0, scrollLeft) : 0 + const safeWidth = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : 0 + const from = Math.max(0, Math.floor(safeLeft / columnWidth) - overscan) + const to = Math.min(count - 1, Math.ceil((safeLeft + safeWidth) / columnWidth) + overscan) + return { from: Math.min(from, count - 1), to } +} + +/** Scroll offset that puts a bucket at `anchor` (0..1) of a viewport. */ +export function scrollLeftForBucket ( + index: number, + columnWidth: number, + viewportWidth: number, + contentWidth: number, + anchor: number = 0.25 +): number { + const x = index * columnWidth - viewportWidth * anchor + return Math.max(0, Math.min(x, Math.max(0, contentWidth - viewportWidth))) +} diff --git a/plugins/tracker-resources/src/workload/compute.ts b/plugins/tracker-resources/src/workload/compute.ts new file mode 100644 index 0000000000..31a90b11a7 --- /dev/null +++ b/plugins/tracker-resources/src/workload/compute.ts @@ -0,0 +1,253 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ItemSchedule } from '../roadmap/dates' +import { bucketIndexOfDay, type WorkloadAxis } from './axis' +import type { WorkdayIndex } from './workdays' + +// The load of people over time. The load of an item is spread evenly over the working days between its first and its +// last day and added up per person and bucket. All days are indexes of local calendar days (roadmap/timeScale.ts). + +/** An item to plan: whose it is, how much load it has and where it is scheduled. */ +export interface LoadEntry { + id: string + // Key of the row of the assignee (see rows.ts) + row: string + // Load in the unit of the measure, a finite number that is not negative + load: number + schedule: ItemSchedule +} + +/** First and last day (both included) of a schedule, undefined for an item without a day. */ +export function scheduleSpan (schedule: ItemSchedule): { start: number, end: number } | undefined { + switch (schedule.kind) { + case 'range': + return { start: schedule.start, end: schedule.target } + case 'marker': + return { start: schedule.day, end: schedule.day } + case 'unscheduled': + return undefined + } +} + +/** + * Spreads the load of a scheduled item over the buckets of the axis: `visit(bucketIndex, share)` is called for every + * bucket that holds working days of the item, with the share of the load that falls into it. The shares of the + * buckets add up to the load, except for the part of the item that lies outside the axis. + * + * A span without any working day (an item that is only on a weekend or on holidays) is planned on the calendar + * days of the span instead, so that its load is not lost. Days outside the window of the working day index + * are not planned. + * + * Returns whether any bucket was touched. + */ +export function distribute ( + schedule: ItemSchedule, + load: number, + axis: WorkloadAxis, + index: WorkdayIndex, + visit: (bucket: number, share: number) => void +): boolean { + const span = scheduleSpan(schedule) + if (span === undefined) return false + const safeLoad = Number.isFinite(load) && load > 0 ? load : 0 + const s = Math.max(span.start, index.fromDay) + const t = Math.min(span.end, index.toDay - 1) + if (t < s || t < axis.startDay || s >= axis.endDay) return false + const working = index.count(s, t) + const onCalendar = working === 0 + const total = onCalendar ? t - s + 1 : working + let touched = false + for (let i = bucketIndexOfDay(axis, Math.max(s, axis.startDay)); i >= 0 && i < axis.buckets.length; i++) { + const bucket = axis.buckets[i] + if (bucket.start > t) break + const a = Math.max(s, bucket.start) + const z = Math.min(t, bucket.end - 1) + const days = onCalendar ? z - a + 1 : index.count(a, z) + if (days <= 0) continue + visit(i, (safeLoad * days) / total) + touched = true + } + return touched +} + +/** The load of one row: a person or the unassigned items. */ +export interface RowLoad { + key: string + // Load per bucket of the axis + loads: Float64Array + // Items that hold working days of the bucket (an item without a load value is counted here with no load) + counts: Int32Array + // Items without a day + unscheduledLoad: number + unscheduledCount: number + // Items of the row that have no load value (a zero estimate, an empty number field) + noLoadCount: number + // All items of the row + itemCount: number +} + +export interface WorkloadResult { + rows: Map + // Scheduled items that are not on the axis at all: they are not part of any bucket + outOfRange: number +} + +/** A row without any item, e.g. a member of the project who has no work yet. */ +export function emptyRowLoad (key: string, buckets: number): RowLoad { + return { + key, + loads: new Float64Array(buckets), + counts: new Int32Array(buckets), + unscheduledLoad: 0, + unscheduledCount: 0, + noLoadCount: 0, + itemCount: 0 + } +} + +/** Adds the load of the items up per row and bucket. Rows exist for the rows that have items. */ +export function computeWorkload ( + entries: readonly LoadEntry[], + axis: WorkloadAxis, + index: WorkdayIndex +): WorkloadResult { + const rows = new Map() + let outOfRange = 0 + for (const entry of entries) { + let row = rows.get(entry.row) + if (row === undefined) { + row = emptyRowLoad(entry.row, axis.buckets.length) + rows.set(entry.row, row) + } + const load = Number.isFinite(entry.load) && entry.load > 0 ? entry.load : 0 + row.itemCount++ + if (load === 0) row.noLoadCount++ + if (entry.schedule.kind === 'unscheduled') { + row.unscheduledLoad += load + row.unscheduledCount++ + continue + } + const target = row + const touched = distribute(entry.schedule, load, axis, index, (bucket, share) => { + target.loads[bucket] += share + target.counts[bucket]++ + }) + if (!touched) outOfRange++ + } + return { rows, outOfRange } +} + +/** The capacity of every bucket: what one person can take per working day times the working days of the bucket. */ +export function bucketCapacities (axis: WorkloadAxis, index: WorkdayIndex, perDay: number): Float64Array { + const res = new Float64Array(axis.buckets.length) + const safe = Number.isFinite(perDay) && perDay > 0 ? perDay : 0 + for (const bucket of axis.buckets) res[bucket.index] = safe * index.count(bucket.start, bucket.end - 1) + return res +} + +// ---- states ---- + +/** How loaded a person is compared with the capacity. */ +export type LoadState = 'empty' | 'under' | 'near' | 'over' + +/** A load of this share of the capacity or more is near the capacity. */ +export const NEAR_RATIO = 0.8 + +// Sums of fractions are never exact, so a load that is over by less than this is not over +const OVER_TOLERANCE = 1e-9 +const EMPTY_LOAD = 1e-9 + +/** + * `empty` without load, `under`, `near` (from `NEAR_RATIO` of the capacity up to the capacity) or `over`. A bucket + * without any capacity (a week of holidays, a weekend) is over as soon as it has load. + */ +export function loadState (load: number, capacity: number): LoadState { + if (!(load > EMPTY_LOAD)) return 'empty' + if (!(capacity > 0)) return 'over' + const ratio = load / capacity + if (ratio > 1 + OVER_TOLERANCE) return 'over' + return ratio >= NEAR_RATIO ? 'near' : 'under' +} + +/** Load as a share of the capacity (1 is full), undefined when there is no capacity to compare with. */ +export function utilization (load: number, capacity: number): number | undefined { + if (!Number.isFinite(load) || !(capacity > 0) || !Number.isFinite(capacity)) return undefined + return load / capacity +} + +export interface RowSummary { + // Load inside the axis, the unscheduled load is not part of it + total: number + // First and last bucket that hold load, -1 when there is none + activeFrom: number + activeTo: number + // Capacity of the buckets from the first to the last that hold load + capacity: number + // Total as a share of that capacity, undefined without load or without capacity + utilization: number | undefined + // Buckets that are over their capacity + overCount: number + // `over` as soon as one bucket is over, otherwise the state of the whole + state: LoadState +} + +/** + * The header of a row: the total load and the utilization over the span in which the person has load (the first to + * the last loaded bucket), so that a person booked full for two weeks reads 100 % and not a share of the whole axis. + */ +export function summarizeRow (row: RowLoad, capacities: ArrayLike): RowSummary { + let total = 0 + let activeFrom = -1 + let activeTo = -1 + let overCount = 0 + for (let i = 0; i < row.loads.length; i++) { + const load = row.loads[i] + if (!(load > EMPTY_LOAD)) continue + total += load + if (activeFrom < 0) activeFrom = i + activeTo = i + if (loadState(load, capacities[i]) === 'over') overCount++ + } + let capacity = 0 + if (activeFrom >= 0) for (let i = activeFrom; i <= activeTo; i++) capacity += capacities[i] + const state = overCount > 0 ? 'over' : loadState(total, capacity) + return { total, activeFrom, activeTo, capacity, utilization: utilization(total, capacity), overCount, state } +} + +// ---- the items behind a cell ---- + +/** An item that adds to a cell and the part of its load that does. */ +export interface CellItem { + id: string + share: number +} + +/** + * The items behind a cell of a row: the items that hold working days of the bucket with their share of the load, the + * biggest share first, or the items without a day with their whole load. Items without a load value are listed with a + * share of 0. + */ +export function cellItems ( + entries: readonly LoadEntry[], + axis: WorkloadAxis, + index: WorkdayIndex, + row: string, + bucket: number | 'unscheduled' +): CellItem[] { + const res: CellItem[] = [] + for (const entry of entries) { + if (entry.row !== row) continue + const load = Number.isFinite(entry.load) && entry.load > 0 ? entry.load : 0 + if (bucket === 'unscheduled') { + if (entry.schedule.kind === 'unscheduled') res.push({ id: entry.id, share: load }) + continue + } + distribute(entry.schedule, load, axis, index, (i, share) => { + if (i === bucket) res.push({ id: entry.id, share }) + }) + } + return res.sort((a, b) => b.share - a.share || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) +} diff --git a/plugins/tracker-resources/src/workload/config.ts b/plugins/tracker-resources/src/workload/config.ts new file mode 100644 index 0000000000..afeb6a79e9 --- /dev/null +++ b/plugins/tracker-resources/src/workload/config.ts @@ -0,0 +1,157 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { deepEqual } from 'fast-equals' +import { NO_DATE_SOURCE } from '../calendar/config' +import { ISSUE_DUE_SOURCE, ISSUE_START_SOURCE, parseSourceId, type DateSelection } from '../roadmap/dates' +import { DEFAULT_WORKLOAD_ZOOM, isWorkloadZoom, type WorkloadZoom } from './axis' + +/** What the load of an item is. */ +export type LoadMeasure = 'estimate' | 'remaining' | 'count' | 'field' + +export const LOAD_MEASURES: readonly LoadMeasure[] = ['estimate', 'remaining', 'count', 'field'] + +export function isLoadMeasure (value: unknown): value is LoadMeasure { + return value === 'estimate' || value === 'remaining' || value === 'count' || value === 'field' +} + +/** + * Settings of a workload view. They are stored in the view options under `WORKLOAD_OPTION_KEY`, like the settings + * of the roadmap and the calendar, so that they are saved with the saved view, restored when it is opened and take + * part in its unsaved-changes tracking. + */ +export interface WorkloadConfig { + // Id of the date source of the start of an item (see roadmap/dates.ts), or NO_DATE_SOURCE + start: string + // Id of the date source of the end of an item, or NO_DATE_SOURCE + target: string + zoom: WorkloadZoom + measure: LoadMeasure + // Key of the Number custom field when the measure is `field` + field?: string + // What one person can take per working day, in the unit of the measure (hours for the estimate and the remaining time) + capacity: number +} + +export const WORKLOAD_OPTION_KEY = 'workload' + +export const DEFAULT_CAPACITY_PER_DAY = 8 + +export const MAX_CAPACITY_PER_DAY = 1000 + +export const DEFAULT_WORKLOAD_CONFIG: Readonly = { + start: ISSUE_START_SOURCE, + target: ISSUE_DUE_SOURCE, + zoom: DEFAULT_WORKLOAD_ZOOM, + measure: 'estimate', + capacity: DEFAULT_CAPACITY_PER_DAY +} + +/** A capacity that is a finite number above zero (and not absurdly large), or undefined. */ +export function validCapacity (raw: unknown): number | undefined { + const n = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : raw + if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0 || n > MAX_CAPACITY_PER_DAY) return undefined + // Two decimals are enough, a longer fraction is typing noise + return Math.round(n * 100) / 100 +} + +function validSource (raw: unknown, fallback: string): string { + if (typeof raw !== 'string') return fallback + return raw === NO_DATE_SOURCE || parseSourceId(raw) !== undefined ? raw : fallback +} + +function build (partial: { + start: string + target: string + zoom: WorkloadZoom + measure: LoadMeasure + field?: string + capacity: number +}): WorkloadConfig { + // Two switched off date fields leave an item without any day: the default pair is used + const none = partial.start === NO_DATE_SOURCE && partial.target === NO_DATE_SOURCE + const res: WorkloadConfig = { + start: none ? DEFAULT_WORKLOAD_CONFIG.start : partial.start, + target: none ? DEFAULT_WORKLOAD_CONFIG.target : partial.target, + zoom: partial.zoom, + measure: partial.measure, + capacity: partial.capacity + } + if (partial.measure === 'field' && partial.field !== undefined) res.field = partial.field + return res +} + +/** + * Turns whatever is stored in the view options into a valid config; missing or damaged parts get the defaults. + * The measure `field` needs the key of a field, without it the default measure is used. + */ +export function normalizeWorkloadConfig (raw: unknown): WorkloadConfig { + if (raw === null || typeof raw !== 'object') return { ...DEFAULT_WORKLOAD_CONFIG } + const r = raw as Record + const field = typeof r.field === 'string' && r.field !== '' ? r.field : undefined + const wanted = isLoadMeasure(r.measure) ? r.measure : DEFAULT_WORKLOAD_CONFIG.measure + const measure = wanted === 'field' && field === undefined ? DEFAULT_WORKLOAD_CONFIG.measure : wanted + return build({ + start: validSource(r.start, DEFAULT_WORKLOAD_CONFIG.start), + target: validSource(r.target, DEFAULT_WORKLOAD_CONFIG.target), + zoom: isWorkloadZoom(r.zoom) ? r.zoom : DEFAULT_WORKLOAD_CONFIG.zoom, + measure, + field, + capacity: validCapacity(r.capacity) ?? DEFAULT_WORKLOAD_CONFIG.capacity + }) +} + +export function readWorkloadConfig (viewOptions: Record | undefined): WorkloadConfig { + return normalizeWorkloadConfig(viewOptions?.[WORKLOAD_OPTION_KEY]) +} + +export function isDefaultWorkloadConfig (config: WorkloadConfig): boolean { + return deepEqual(normalizeWorkloadConfig(config), DEFAULT_WORKLOAD_CONFIG) +} + +/** + * The view options with the workload config stored in them. The default config is not stored at all, so that + * a view that was never customized stays equal to its saved state (no false "unsaved changes" dot). + */ +export function withWorkloadConfig> (options: O, config: WorkloadConfig): O { + const next: Record = { ...options } + const normalized = normalizeWorkloadConfig(config) + if (isDefaultWorkloadConfig(normalized)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete next[WORKLOAD_OPTION_KEY] + } else { + next[WORKLOAD_OPTION_KEY] = { ...normalized } + } + return next as O +} + +/** The start and target sources of a view; a switched off field has no source and gives no day. */ +export function workloadSelection (config: WorkloadConfig): DateSelection { + return { start: config.start, target: config.target } +} + +export interface WorkloadAvailability { + // Ids of the date sources that exist + sourceIds: ReadonlySet + // Keys of the Number fields that exist + numberFieldKeys: ReadonlySet +} + +/** + * Drops what no longer exists (a deleted field) from the config. A date source that is gone falls back to the + * default, a measure on a number field that is gone falls back to the default measure. + */ +export function sanitizeWorkloadConfig (config: WorkloadConfig, available: WorkloadAvailability): WorkloadConfig { + const keep = (id: string, fallback: string): string => + id === NO_DATE_SOURCE || available.sourceIds.has(id) ? id : fallback + const fieldGone = config.measure === 'field' && (config.field === undefined || !available.numberFieldKeys.has(config.field)) + return build({ + ...config, + start: keep(config.start, DEFAULT_WORKLOAD_CONFIG.start), + target: keep(config.target, DEFAULT_WORKLOAD_CONFIG.target), + measure: fieldGone ? DEFAULT_WORKLOAD_CONFIG.measure : config.measure, + field: fieldGone ? undefined : config.field + }) +} diff --git a/plugins/tracker-resources/src/workload/format.ts b/plugins/tracker-resources/src/workload/format.ts new file mode 100644 index 0000000000..4f3a5db15f --- /dev/null +++ b/plugins/tracker-resources/src/workload/format.ts @@ -0,0 +1,37 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { LoadMeasure } from './config' + +/** A number for a cell or a tooltip: finite, with at most `digits` decimals and no trailing zeros. */ +export function formatAmount (value: number, locale?: string, digits: number = 1): string { + const safe = Number.isFinite(value) ? value : 0 + // Avoid "-0" + const rounded = Math.round(safe * 10 ** digits) / 10 ** digits + return new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(rounded === 0 ? 0 : rounded) +} + +/** An amount with its unit: hours for the estimate and the remaining time, a plain number for the others. */ +export function formatLoadValue (value: number, measure: LoadMeasure, locale?: string, digits: number = 1): string { + const safe = Number.isFinite(value) ? value : 0 + const hours = measure === 'estimate' || measure === 'remaining' + if (!hours) return formatAmount(safe, locale, digits) + try { + return new Intl.NumberFormat(locale, { + style: 'unit', + unit: 'hour', + unitDisplay: 'narrow', + maximumFractionDigits: digits + }).format(Math.round(safe * 10 ** digits) / 10 ** digits || 0) + } catch { + return `${formatAmount(safe, locale, digits)}h` + } +} + +/** A share of the capacity as a whole percent, "100%" for 1. */ +export function formatPercent (ratio: number, locale?: string): string { + const safe = Number.isFinite(ratio) ? ratio : 0 + return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 }).format(Math.round(safe * 100) / 100) +} diff --git a/plugins/tracker-resources/src/workload/load.ts b/plugins/tracker-resources/src/workload/load.ts new file mode 100644 index 0000000000..0ab0dd9be1 --- /dev/null +++ b/plugins/tracker-resources/src/workload/load.ts @@ -0,0 +1,54 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { toSummable } from '../fieldSum/sum' +import type { LoadMeasure } from './config' + +/** The part of an issue the load is read from. */ +export interface LoadIssue { + // Estimation in man hours (0 is how an issue without an estimate is stored) + estimation?: number | null + // Remaining time in man hours + remainingTime?: number | null + customFields?: Record +} + +/** + * The load an item puts on its assignee, in the unit of the measure: hours for the estimation and the remaining time, + * one for the item count, the number of a Number custom field. It is never negative, `NaN`, infinite or text: such a + * value is no load (0), so a damaged value cannot poison the sums. + */ +export function readLoad (issue: LoadIssue, measure: LoadMeasure, fieldKey?: string): number { + let raw: unknown + switch (measure) { + case 'estimate': + raw = issue.estimation + break + case 'remaining': + raw = issue.remainingTime + break + case 'count': + return 1 + case 'field': + raw = fieldKey !== undefined ? issue.customFields?.[fieldKey] : undefined + break + } + const n = toSummable(raw) + return n !== undefined && n > 0 ? n : 0 +} + +/** Document properties that the load of a measure reads. */ +export function loadProjection (measure: LoadMeasure): string[] { + switch (measure) { + case 'estimate': + return ['estimation'] + case 'remaining': + return ['remainingTime'] + case 'count': + return [] + case 'field': + return ['customFields'] + } +} diff --git a/plugins/tracker-resources/src/workload/reassign.ts b/plugins/tracker-resources/src/workload/reassign.ts new file mode 100644 index 0000000000..bbf2e9694c --- /dev/null +++ b/plugins/tracker-resources/src/workload/reassign.ts @@ -0,0 +1,27 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { assigneeOfRow, rowKeyOf } from './rows' + +export type ReassignFailure = 'same' | 'readonly' + +export type ReassignPlan = + | { ok: true, assignee: string | null, patch: { assignee: string | null } } + | { ok: false, reason: ReassignFailure } + +/** + * What dropping an item on a row writes: the assignee of the row (null for the unassigned row). Dropping on the row + * the item is in changes nothing, and a read-only viewer cannot change anything. + */ +export function planReassign ( + issue: { assignee?: string | null }, + targetRow: string, + options: { readonly: boolean } +): ReassignPlan { + if (options.readonly) return { ok: false, reason: 'readonly' } + if (rowKeyOf(issue.assignee) === targetRow) return { ok: false, reason: 'same' } + const assignee = assigneeOfRow(targetRow) + return { ok: true, assignee, patch: { assignee } } +} diff --git a/plugins/tracker-resources/src/workload/rows.ts b/plugins/tracker-resources/src/workload/rows.ts new file mode 100644 index 0000000000..fc1c5a67c2 --- /dev/null +++ b/plugins/tracker-resources/src/workload/rows.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** Key of the row of the items without an assignee. */ +export const UNASSIGNED_ROW = '__unassigned__' + +/** Key of the row an assignee belongs to. */ +export function rowKeyOf (assignee: string | null | undefined): string { + return typeof assignee === 'string' && assignee !== '' ? assignee : UNASSIGNED_ROW +} + +/** The assignee a row stands for: the person, or null for the unassigned row. */ +export function assigneeOfRow (row: string): string | null { + return row === UNASSIGNED_ROW ? null : row +} + +export interface RowInfo { + key: string + // Already translated; the key itself when the person is not known + name: string + unassigned: boolean +} + +/** The rows of a workload: the people by name, the unassigned items last. */ +export function orderRows ( + keys: Iterable, + nameOf: (key: string) => string | undefined, + unassignedName: string, + locale?: string +): RowInfo[] { + const people: RowInfo[] = [] + let unassigned: RowInfo | undefined + for (const key of keys) { + if (key === UNASSIGNED_ROW) { + unassigned = { key, name: unassignedName, unassigned: true } + continue + } + const name = nameOf(key) + people.push({ key, name: name !== undefined && name.trim() !== '' ? name : key, unassigned: false }) + } + const collator = new Intl.Collator(locale, { sensitivity: 'base', numeric: true }) + people.sort((a, b) => collator.compare(a.name, b.name) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + return unassigned !== undefined ? [...people, unassigned] : people +} diff --git a/plugins/tracker-resources/src/workload/workdays.ts b/plugins/tracker-resources/src/workload/workdays.ts new file mode 100644 index 0000000000..d35159a206 --- /dev/null +++ b/plugins/tracker-resources/src/workload/workdays.ts @@ -0,0 +1,89 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { isWorkingDay, type WorkingCalendar } from '@hcengineering/gantt' +import { MAX_DISTANCE_DAYS } from '../roadmap/timeScale' + +// Working days of a project, for the capacity of the Workload layout. The rule is the one of the Gantt (it is the +// `isWorkingDay` of `@hcengineering/gantt`): `weekdayMask` has bit 0 for Monday and bit 6 for Sunday, a holiday is the +// UTC midnight timestamp of a calendar day. A day of the layouts is the index of the local calendar date (days since +// 1970-01-01), so the UTC midnight of the same date is exactly `day * DAY_MS` and the two frames agree. + +const DAY_MS = 86400000 + +/** Monday to Friday. */ +export const DEFAULT_WEEKDAY_MASK = 31 + +/** + * What a project without `workingDaysConfig` works on. The Gantt takes such a project for "every day is a working + * day", but a person has no capacity on a weekend, so the workload plans on Monday to Friday and no holidays. + */ +export const DEFAULT_WORKING_CALENDAR: Readonly = { weekdayMask: DEFAULT_WEEKDAY_MASK, holidays: [] } + +/** Days around today that the index covers; a day outside it is not planned (see `MAX_DISTANCE_DAYS`). */ +export const WORKDAY_WINDOW_DAYS = MAX_DISTANCE_DAYS + 800 + +/** The calendar to plan on: the one of the project when it has one, the default otherwise. */ +export function effectiveWorkingCalendar ( + config: { weekdayMask: number } | undefined, + holidays: readonly number[] | undefined +): WorkingCalendar { + if (config === undefined || typeof config.weekdayMask !== 'number' || !Number.isFinite(config.weekdayMask)) { + return { ...DEFAULT_WORKING_CALENDAR, holidays: [] } + } + return { weekdayMask: config.weekdayMask, holidays: [...(holidays ?? [])] } +} + +/** + * Which days of a window are working days, with prefix counts so that the working days of a span are counted in + * constant time. Built once per calendar; the classification is the Gantt's `isWorkingDay`. + */ +export class WorkdayIndex { + readonly fromDay: number + // First day after the window + readonly toDay: number + private readonly flags: Uint8Array + // prefix[i] is the number of working days among the first i days of the window + private readonly prefix: Int32Array + + constructor (fromDay: number, toDay: number, calendar: WorkingCalendar) { + this.fromDay = fromDay + this.toDay = Math.max(toDay, fromDay) + const size = this.toDay - this.fromDay + this.flags = new Uint8Array(size) + this.prefix = new Int32Array(size + 1) + // The holiday scan of the Gantt is linear, so the holidays are looked up once per day of the window + for (let i = 0; i < size; i++) { + const working = isWorkingDay((this.fromDay + i) * DAY_MS, calendar) + this.flags[i] = working ? 1 : 0 + this.prefix[i + 1] = this.prefix[i] + (working ? 1 : 0) + } + } + + /** The index that covers the window of days around today. */ + static around (today: number, calendar: WorkingCalendar): WorkdayIndex { + return new WorkdayIndex(today - WORKDAY_WINDOW_DAYS, today + WORKDAY_WINDOW_DAYS + 1, calendar) + } + + get size (): number { + return this.toDay - this.fromDay + } + + has (day: number): boolean { + return day >= this.fromDay && day < this.toDay + } + + isWorkday (day: number): boolean { + return this.has(day) && this.flags[day - this.fromDay] === 1 + } + + /** Working days among the days `first` to `last`, both included; days outside the window do not count. */ + count (first: number, last: number): number { + const a = Math.max(first, this.fromDay) + const b = Math.min(last, this.toDay - 1) + if (b < a) return 0 + return this.prefix[b - this.fromDay + 1] - this.prefix[a - this.fromDay] + } +} diff --git a/plugins/tracker/src/__tests__/archive.test.ts b/plugins/tracker/src/__tests__/archive.test.ts new file mode 100644 index 0000000000..653e21a183 --- /dev/null +++ b/plugins/tracker/src/__tests__/archive.test.ts @@ -0,0 +1,26 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { archivedQuery, archiveUpdate, isIssueArchived, restoreUpdate } from '../archive' + +describe('archive', () => { + it('treats only a timestamp as archived', () => { + expect(isIssueArchived({})).toBe(false) + expect(isIssueArchived({ archivedAt: null })).toBe(false) + expect(isIssueArchived({ archivedAt: 1 })).toBe(true) + }) + + it('archives with a timestamp and restores with null', () => { + expect(archiveUpdate(42)).toEqual({ archivedAt: 42 }) + expect(archiveUpdate().archivedAt).toBeGreaterThan(0) + expect(restoreUpdate()).toEqual({ archivedAt: null }) + expect(isIssueArchived(restoreUpdate())).toBe(false) + }) + + it('builds the queries for archived and active issues', () => { + expect(archivedQuery(true)).toEqual({ archivedAt: { $ne: null } }) + expect(archivedQuery(false)).toEqual({ archivedAt: null }) + }) +}) diff --git a/plugins/tracker/src/__tests__/draft.test.ts b/plugins/tracker/src/__tests__/draft.test.ts new file mode 100644 index 0000000000..c7e72add95 --- /dev/null +++ b/plugins/tracker/src/__tests__/draft.test.ts @@ -0,0 +1,87 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + convertDraftUpdate, + draftIdentifier, + draftLinkSegment, + draftQuery, + DRAFT_NUMBER, + isIssueDraft, + issueLinkSegment, + MAX_ITEM_TITLE_LENGTH, + parseAddItemInput, + parseDraftLinkSegment +} from '../draft' + +describe('draft items', () => { + it('treats only true as a draft', () => { + expect(isIssueDraft({})).toBe(false) + expect(isIssueDraft({ isDraft: false })).toBe(false) + expect(isIssueDraft({ isDraft: null })).toBe(false) + expect(isIssueDraft({ isDraft: true })).toBe(true) + }) + + it('builds the queries for drafts and for items that are not drafts', () => { + expect(draftQuery(true)).toEqual({ isDraft: true }) + expect(draftQuery(false)).toEqual({ isDraft: { $ne: true } }) + }) + + it('gives a draft a number the sequence never hands out and an identifier that is no issue id', () => { + expect(DRAFT_NUMBER).toBe(0) + const identifier = draftIdentifier('PROJ') + expect(identifier).toBe('PROJ-Draft') + expect(/^\S+-\d+$/.test(identifier)).toBe(false) + }) + + it('converts with the next number of the project and clears the flag', () => { + expect(convertDraftUpdate(7, 'PROJ')).toEqual({ number: 7, identifier: 'PROJ-7', isDraft: false }) + expect(isIssueDraft(convertDraftUpdate(7, 'PROJ'))).toBe(false) + }) +}) + +describe('draft links', () => { + it('addresses a draft by its document id and an issue by its identifier', () => { + expect(draftLinkSegment('abc123')).toBe('draft-abc123') + expect(parseDraftLinkSegment('draft-abc123')).toBe('abc123') + expect(issueLinkSegment({ _id: 'abc123', identifier: 'PROJ-Draft', isDraft: true })).toBe('draft-abc123') + expect(issueLinkSegment({ _id: 'abc123', identifier: 'PROJ-7' })).toBe('PROJ-7') + expect(issueLinkSegment({ _id: 'abc123', identifier: 'PROJ-7', isDraft: false })).toBe('PROJ-7') + }) + + it('does not take other segments for a draft link', () => { + expect(parseDraftLinkSegment(undefined)).toBeUndefined() + expect(parseDraftLinkSegment('')).toBeUndefined() + expect(parseDraftLinkSegment('draft-')).toBeUndefined() + expect(parseDraftLinkSegment('PROJ-12')).toBeUndefined() + expect(parseDraftLinkSegment('issues')).toBeUndefined() + }) +}) + +describe('parseAddItemInput', () => { + it('has nothing to add for blank text', () => { + expect(parseAddItemInput('')).toEqual({ kind: 'empty' }) + expect(parseAddItemInput(' \t ')).toEqual({ kind: 'empty' }) + }) + + it('makes a draft of a trimmed title', () => { + expect(parseAddItemInput(' Write the docs ')).toEqual({ kind: 'draft', title: 'Write the docs' }) + expect(parseAddItemInput('Fix #12 later')).toEqual({ kind: 'draft', title: 'Fix #12 later' }) + }) + + it('searches issues when the text starts with #', () => { + expect(parseAddItemInput('#')).toEqual({ kind: 'search', query: '' }) + expect(parseAddItemInput(' # login bug ')).toEqual({ kind: 'search', query: 'login bug' }) + expect(parseAddItemInput('#PROJ-12')).toEqual({ kind: 'search', query: 'PROJ-12' }) + }) + + it('refuses a title that is too long', () => { + expect(parseAddItemInput('a'.repeat(MAX_ITEM_TITLE_LENGTH)).kind).toBe('draft') + expect(parseAddItemInput('a'.repeat(MAX_ITEM_TITLE_LENGTH + 1))).toEqual({ + kind: 'too-long', + length: MAX_ITEM_TITLE_LENGTH + 1 + }) + }) +}) diff --git a/plugins/tracker/src/__tests__/iteration-rollup.test.ts b/plugins/tracker/src/__tests__/iteration-rollup.test.ts new file mode 100644 index 0000000000..000d021a1f --- /dev/null +++ b/plugins/tracker/src/__tests__/iteration-rollup.test.ts @@ -0,0 +1,47 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { computeIterationRollups, computeRollup } from '../iterationRollup' + +const done = new Set(['won']) + +describe('computeRollup', () => { + it('is empty for no issues', () => { + expect(computeRollup([], done)).toEqual({ count: 0, done: 0, estimation: 0 }) + }) + + it('counts, counts done and sums the estimates', () => { + const res = computeRollup( + [ + { status: 'won', estimation: 2 }, + { status: 'open', estimation: 3.5 }, + { status: 'won' }, + { status: 'open', estimation: Number.NaN } + ], + done + ) + expect(res).toEqual({ count: 4, done: 2, estimation: 5.5 }) + }) +}) + +describe('computeIterationRollups', () => { + it('groups by the iteration of the field, issues without one under undefined', () => { + const res = computeIterationRollups( + [ + { status: 'won', estimation: 1, customFields: { sprint: 'a' } }, + { status: 'open', estimation: 2, customFields: { sprint: 'a' } }, + { status: 'open', estimation: 4, customFields: { sprint: 'b', other: 'a' } }, + { status: 'open', customFields: { other: 'a' } }, + { status: 'won' } + ], + 'sprint', + done + ) + expect(res.get('a')).toEqual({ count: 2, done: 1, estimation: 3 }) + expect(res.get('b')).toEqual({ count: 1, done: 0, estimation: 4 }) + expect(res.get(undefined)).toEqual({ count: 2, done: 1, estimation: 0 }) + expect(res.size).toBe(3) + }) +}) diff --git a/plugins/tracker/src/__tests__/iteration.test.ts b/plugins/tracker/src/__tests__/iteration.test.ts new file mode 100644 index 0000000000..cafdce0c24 --- /dev/null +++ b/plugins/tracker/src/__tests__/iteration.test.ts @@ -0,0 +1,392 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + addDays, + buildIterationGroupCategories, + daysBetween, + daysToDuration, + durationToDays, + findOverlap, + formatIterationRange, + generateInitialIterations, + getAssignableIterations, + getIterationState, + groupIterationsByState, + iterationEnd, + iterationEndExclusive, + planAddIteration, + planIterationChange, + planMoveItems, + replaceIterationValue, + resolveRelativeIteration, + sortIterations, + stripIterationValues, + toIterationRanges +} from '../iteration' + +// Local calendar days, so that the tests do not depend on the time zone of the machine +const day = (m: number, d: number, y = 2026): number => new Date(y, m - 1, d).getTime() +const noon = (m: number, d: number): number => new Date(2026, m - 1, d, 12, 0, 0).getTime() + +interface It { + _id: any + label: string + number: number + startDate: number + duration: number + isBreak?: boolean +} + +function mk (id: string, number: number, start: number, duration = 7, isBreak = false): It { + return { _id: id, label: id, number, startDate: start, duration, ...(isBreak ? { isBreak } : {}) } +} + +// Oct 5 - Oct 11, Oct 12 - Oct 18, Oct 19 - Oct 25 +const three = (): It[] => [mk('a', 1, day(10, 5)), mk('b', 2, day(10, 12)), mk('c', 3, day(10, 19))] + +describe('date math', () => { + it('adds calendar days across a month end', () => { + expect(addDays(day(10, 30), 3)).toBe(day(11, 2)) + expect(addDays(day(3, 1), -1)).toBe(day(2, 28)) + }) + + it('counts calendar days', () => { + expect(daysBetween(day(10, 5), day(10, 12))).toBe(7) + expect(daysBetween(noon(10, 5), day(10, 6))).toBe(1) + expect(daysBetween(day(10, 12), day(10, 5))).toBe(-7) + }) + + it('computes the end of an iteration', () => { + const a = mk('a', 1, day(10, 5)) + expect(iterationEndExclusive(a)).toBe(day(10, 12)) + expect(iterationEnd(a)).toBe(day(10, 12) - 1) + expect(new Date(iterationEnd(a)).getDate()).toBe(11) + }) +}) + +describe('getIterationState', () => { + const a = mk('a', 1, day(10, 5)) + it('is planned before the first day', () => { + expect(getIterationState(a, day(10, 5) - 1)).toBe('planned') + }) + it('is current on the first and on the last day', () => { + expect(getIterationState(a, day(10, 5))).toBe('current') + expect(getIterationState(a, noon(10, 11))).toBe('current') + expect(getIterationState(a, day(10, 12) - 1)).toBe('current') + }) + it('is completed after the last day', () => { + expect(getIterationState(a, day(10, 12))).toBe('completed') + }) +}) + +describe('ordering helpers', () => { + it('sorts by start date and drops breaks from the assignable list', () => { + const list = [mk('c', 3, day(10, 19)), mk('brk', 0, day(10, 12), 3, true), mk('a', 1, day(10, 5))] + expect(sortIterations(list).map((x) => x._id)).toEqual(['a', 'brk', 'c']) + expect(getAssignableIterations(list).map((x) => x._id)).toEqual(['a', 'c']) + }) + + it('exposes the assignable iterations as grammar ranges', () => { + const ranges = toIterationRanges([...three(), mk('brk', 0, day(10, 26), 3, true)]) + expect(ranges.map((r) => r.id)).toEqual(['a', 'b', 'c']) + expect(ranges[0]).toEqual({ id: 'a', title: 'a', start: day(10, 5), end: day(10, 12) - 1 }) + }) +}) + +describe('resolveRelativeIteration', () => { + const list = three() + const id = (x: ReturnType): string | undefined => (x as It | undefined)?._id + + it('resolves current, next and previous', () => { + const now = noon(10, 14) + expect(id(resolveRelativeIteration(list, 'current', 0, now))).toBe('b') + expect(id(resolveRelativeIteration(list, 'next', 0, now))).toBe('c') + expect(id(resolveRelativeIteration(list, 'previous', 0, now))).toBe('a') + }) + + it('supports arithmetic', () => { + const now = noon(10, 7) + expect(id(resolveRelativeIteration(list, 'current', 1, now))).toBe('b') + expect(id(resolveRelativeIteration(list, 'current', 2, now))).toBe('c') + expect(id(resolveRelativeIteration(list, 'current', 3, now))).toBeUndefined() + expect(id(resolveRelativeIteration(list, 'current', -1, now))).toBeUndefined() + expect(id(resolveRelativeIteration(list, 'next', 1, now))).toBe('c') + expect(id(resolveRelativeIteration(list, 'previous', 0, noon(10, 20)))).toBe('b') + expect(id(resolveRelativeIteration(list, 'previous', -1, noon(10, 20)))).toBe('a') + }) + + it('has no current iteration before, after or during a break', () => { + expect(id(resolveRelativeIteration(list, 'current', 0, noon(9, 1)))).toBeUndefined() + expect(id(resolveRelativeIteration(list, 'current', 0, noon(11, 20)))).toBeUndefined() + const withBreak = [mk('a', 1, day(10, 5)), mk('brk', 0, day(10, 12), 7, true), mk('c', 2, day(10, 19))] + const now = noon(10, 14) + expect(id(resolveRelativeIteration(withBreak, 'current', 0, now))).toBeUndefined() + // The break is skipped: next is the iteration after it, previous the one before it + expect(id(resolveRelativeIteration(withBreak, 'next', 0, now))).toBe('c') + expect(id(resolveRelativeIteration(withBreak, 'previous', 0, now))).toBe('a') + }) + + it('finds the neighbours of today when nothing is running', () => { + expect(id(resolveRelativeIteration(list, 'next', 0, noon(9, 1)))).toBe('a') + expect(id(resolveRelativeIteration(list, 'previous', 0, noon(11, 20)))).toBe('c') + expect(id(resolveRelativeIteration(list, 'previous', 0, noon(9, 1)))).toBeUndefined() + }) +}) + +describe('groupIterationsByState', () => { + it('splits into completed, current and planned', () => { + const list = [...three(), mk('brk', 0, day(10, 26), 7, true)] + const res = groupIterationsByState(list, noon(10, 13)) + expect(res.completed.map((x) => x._id)).toEqual(['a']) + expect(res.current?._id).toBe('b') + expect(res.planned.map((x) => x._id)).toEqual(['c', 'brk']) + }) + + it('has no current iteration between iterations', () => { + const res = groupIterationsByState([mk('a', 1, day(10, 5)), mk('b', 2, day(10, 20))], noon(10, 15)) + expect(res.current).toBeUndefined() + expect(res.completed).toHaveLength(1) + expect(res.planned).toHaveLength(1) + }) +}) + +describe('duration units', () => { + it('converts weeks and days', () => { + expect(durationToDays(2, 'weeks')).toBe(14) + expect(durationToDays(5, 'days')).toBe(5) + expect(daysToDuration(14)).toEqual({ amount: 2, unit: 'weeks' }) + expect(daysToDuration(10)).toEqual({ amount: 10, unit: 'days' }) + expect(daysToDuration(7)).toEqual({ amount: 1, unit: 'weeks' }) + expect(daysToDuration(3)).toEqual({ amount: 3, unit: 'days' }) + }) +}) + +describe('generateInitialIterations', () => { + it('creates three consecutive one-week iterations starting today', () => { + const res = generateInitialIterations({ now: noon(10, 3) }) + expect(res).toHaveLength(3) + expect(res.map((x) => x.label)).toEqual(['Iteration 1', 'Iteration 2', 'Iteration 3']) + expect(res.map((x) => x.number)).toEqual([1, 2, 3]) + expect(res.map((x) => x.duration)).toEqual([7, 7, 7]) + expect(res[0].startDate).toBe(day(10, 3)) + expect(res[1].startDate).toBe(day(10, 10)) + expect(res[2].startDate).toBe(day(10, 17)) + expect(findOverlap(res.map((x, i) => ({ ...x, _id: String(i) as any })))).toBeUndefined() + }) + + it('honours the duration, start date, count and titles', () => { + const res = generateInitialIterations({ + now: noon(10, 3), + duration: 14, + startDate: noon(10, 5), + count: 2, + labelOf: (n) => `Sprint ${n}` + }) + expect(res.map((x) => x.label)).toEqual(['Sprint 1', 'Sprint 2']) + expect(res.map((x) => x.startDate)).toEqual([day(10, 5), day(10, 19)]) + }) + + it('falls back to a week for an invalid duration', () => { + expect(generateInitialIterations({ now: day(10, 3), duration: 0 })[0].duration).toBe(7) + }) +}) + +describe('findOverlap', () => { + it('detects overlapping ranges and allows gaps', () => { + expect(findOverlap(three())).toBeUndefined() + expect(findOverlap([mk('a', 1, day(10, 5)), mk('b', 2, day(10, 20))])).toBeUndefined() + expect(findOverlap([mk('a', 1, day(10, 5)), mk('b', 2, day(10, 11))])).toEqual(['a', 'b']) + }) +}) + +describe('planIterationChange', () => { + const apply = (list: It[], updates: Array<{ id: string, update: any }>): It[] => + list.map((x) => ({ ...x, ...(updates.find((u) => u.id === x._id)?.update ?? {}) })) + + it('only renames without shifting', () => { + const res = planIterationChange(three(), 'b' as any, { label: ' Sprint ' }) + expect(res).toEqual({ ok: true, updates: [{ id: 'b', update: { label: 'Sprint' } }] }) + }) + + it('shifts the following iterations when the duration grows', () => { + const res = planIterationChange(three(), 'a' as any, { duration: 14 }) + expect(res.ok).toBe(true) + if (!res.ok) return + const next = apply(three(), res.updates as any) + expect(next.map((x) => x.startDate)).toEqual([day(10, 5), day(10, 19), day(10, 26)]) + expect(findOverlap(next)).toBeUndefined() + }) + + it('shifts back when the duration shrinks and keeps gaps', () => { + const list = [mk('a', 1, day(10, 5)), mk('b', 2, day(10, 14)), mk('c', 3, day(10, 25))] + const res = planIterationChange(list, 'a' as any, { duration: 3 }) + expect(res.ok).toBe(true) + if (!res.ok) return + const next = apply(list, res.updates as any) + expect(next.map((x) => x.startDate)).toEqual([day(10, 5), day(10, 10), day(10, 21)]) + }) + + it('moves the following iterations with a later start date', () => { + const res = planIterationChange(three(), 'b' as any, { startDate: noon(10, 14) }) + expect(res.ok).toBe(true) + if (!res.ok) return + const next = apply(three(), res.updates as any) + expect(next.map((x) => x.startDate)).toEqual([day(10, 5), day(10, 14), day(10, 21)]) + }) + + it('rejects a start date that runs into the previous iteration', () => { + expect(planIterationChange(three(), 'b' as any, { startDate: day(10, 10) })).toEqual({ + ok: false, + error: 'overlap' + }) + }) + + it('allows an earlier start right after the previous iteration', () => { + const list = [mk('a', 1, day(10, 5)), mk('b', 2, day(10, 14))] + const res = planIterationChange(list, 'b' as any, { startDate: day(10, 12) }) + expect(res.ok).toBe(true) + }) + + it('rejects invalid input', () => { + expect(planIterationChange(three(), 'a' as any, { duration: 0 })).toEqual({ ok: false, error: 'invalidDuration' }) + expect(planIterationChange(three(), 'a' as any, { duration: 1.5 })).toEqual({ ok: false, error: 'invalidDuration' }) + expect(planIterationChange(three(), 'a' as any, { label: ' ' })).toEqual({ ok: false, error: 'emptyLabel' }) + expect(planIterationChange(three(), 'zzz' as any, { label: 'x' })).toEqual({ ok: false, error: 'unknown' }) + }) + + it('shifts across breaks as well', () => { + const list = [mk('a', 1, day(10, 5)), mk('brk', 0, day(10, 12), 7, true), mk('c', 2, day(10, 19))] + const res = planIterationChange(list, 'a' as any, { duration: 8 }) + expect(res.ok).toBe(true) + if (!res.ok) return + const next = apply(list, res.updates as any) + expect(next.map((x) => x.startDate)).toEqual([day(10, 5), day(10, 13), day(10, 20)]) + expect(findOverlap(next)).toBeUndefined() + }) +}) + +describe('planAddIteration', () => { + it('appends after the last iteration with its duration', () => { + const res = planAddIteration(three(), { now: day(10, 3) }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.updates).toEqual([]) + expect(res.draft).toEqual({ label: 'Iteration 4', number: 4, startDate: day(10, 26), duration: 7 }) + }) + + it('starts the first iteration today', () => { + const res = planAddIteration([], { now: noon(10, 3) }) + expect(res.ok && res.draft.startDate).toBe(day(10, 3)) + expect(res.ok && res.draft.number).toBe(1) + }) + + it('inserts a break and shifts what follows', () => { + const res = planAddIteration(three(), { afterId: 'a' as any, isBreak: true, duration: 3, label: 'Holiday', now: day(10, 3) }) + expect(res.ok).toBe(true) + if (!res.ok) return + expect(res.draft).toEqual({ label: 'Holiday', number: 0, startDate: day(10, 12), duration: 3, isBreak: true }) + expect(res.updates).toEqual([ + { id: 'b', update: { startDate: day(10, 15) } }, + { id: 'c', update: { startDate: day(10, 22) } } + ]) + }) + + it('names a break and numbers iterations without counting breaks', () => { + const list = [...three(), mk('brk', 0, day(10, 26), 7, true)] + const brk = planAddIteration(list, { isBreak: true, now: day(10, 3) }) + expect(brk.ok && brk.draft.label).toBe('Break') + const next = planAddIteration(list, { now: day(10, 3) }) + expect(next.ok && next.draft.number).toBe(4) + // The duration is the one of the last iteration, not of the break + expect(next.ok && next.draft.duration).toBe(7) + }) + + it('rejects an unknown anchor and an invalid duration', () => { + expect(planAddIteration(three(), { afterId: 'zzz' as any, now: day(10, 3) })).toEqual({ ok: false, error: 'unknown' }) + expect(planAddIteration(three(), { duration: -1, now: day(10, 3) })).toEqual({ ok: false, error: 'invalidDuration' }) + }) +}) + +describe('moving items between iterations', () => { + const issues = [ + { _id: '1', customFields: { sprint: 'a', other: 1 } }, + { _id: '2', customFields: { sprint: 'b' } }, + { _id: '3', customFields: { other: 2 } }, + { _id: '4' } + ] + + it('replaces or removes the value', () => { + expect(replaceIterationValue({ sprint: 'a', other: 1 }, 'sprint', 'a', 'b')).toEqual({ sprint: 'b', other: 1 }) + expect(replaceIterationValue({ sprint: 'a', other: 1 }, 'sprint', 'a', null)).toEqual({ other: 1 }) + expect(replaceIterationValue({ sprint: 'b' }, 'sprint', 'a', null)).toBeUndefined() + expect(replaceIterationValue(undefined, 'sprint', 'a', null)).toBeUndefined() + }) + + it('plans the move of all items of one iteration', () => { + const plan = planMoveItems(issues, 'sprint', 'a', 'b') + expect(plan.map((p) => p.issue._id)).toEqual(['1']) + expect(plan[0].customFields).toEqual({ sprint: 'b', other: 1 }) + }) + + it('moves to no iteration and ignores a move onto itself', () => { + expect(planMoveItems(issues, 'sprint', 'b', null)[0].customFields).toEqual({}) + expect(planMoveItems(issues, 'sprint', 'a', 'a')).toEqual([]) + }) +}) + +describe('stripIterationValues', () => { + const dropped = new Map([ + ['sprint', new Set(['a'])], + ['release', new Set(['x', 'y'])] + ]) + + it('removes only the values of deleted iterations', () => { + expect(stripIterationValues({ sprint: 'a', release: 'z', size: 3 }, dropped)).toEqual({ release: 'z', size: 3 }) + expect(stripIterationValues({ sprint: 'a', release: 'y' }, dropped)).toEqual({}) + }) + + it('does not touch the input and reports no change as undefined', () => { + const input = { sprint: 'a' } + stripIterationValues(input, dropped) + expect(input).toEqual({ sprint: 'a' }) + expect(stripIterationValues({ sprint: 'b', size: 1 }, dropped)).toBeUndefined() + expect(stripIterationValues({ size: 1 }, dropped)).toBeUndefined() + expect(stripIterationValues(undefined, dropped)).toBeUndefined() + }) +}) + +describe('buildIterationGroupCategories', () => { + const iterations = [...three(), mk('brk', 0, day(10, 26), 7, true)] + + it('orders groups by start date, with the empty group last', () => { + const docs = [ + { customFields: { sprint: 'c' } }, + { customFields: { sprint: 'a' } }, + { customFields: {} }, + { customFields: { sprint: 'gone' } } + ] + expect(buildIterationGroupCategories(iterations, docs, 'sprint', false)).toEqual(['a', 'c', 'gone', undefined]) + }) + + it('lists unused iterations only on request and never lists breaks', () => { + const docs = [{ customFields: { sprint: 'b' } }] + expect(buildIterationGroupCategories(iterations, docs, 'sprint', false)).toEqual(['b']) + expect(buildIterationGroupCategories(iterations, docs, 'sprint', true)).toEqual(['a', 'b', 'c', undefined]) + }) +}) + +describe('formatIterationRange', () => { + it('shows the first and the last day', () => { + const text = formatIterationRange(mk('a', 1, day(10, 5)), noon(10, 6), 'en-US') + expect(text).toBe('Oct 5 – Oct 11') + }) + + it('adds the year when it differs from the current one', () => { + const text = formatIterationRange(mk('a', 1, day(12, 28)), noon(10, 6), 'en-US') + expect(text).toBe('Dec 28, 2026 – Jan 3, 2027') + expect(formatIterationRange(mk('a', 1, day(10, 5, 2025)), noon(10, 6), 'en-US')).toBe('Oct 5, 2025 – Oct 11, 2025') + }) +}) diff --git a/plugins/tracker/src/__tests__/project-copy.test.ts b/plugins/tracker/src/__tests__/project-copy.test.ts new file mode 100644 index 0000000000..c372dd0c19 --- /dev/null +++ b/plugins/tracker/src/__tests__/project-copy.test.ts @@ -0,0 +1,311 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import tracker from '../index' +import view from '@hcengineering/view' +import { buildProjectCopyPlan, type ProjectCopySource } from '../projectCopy' +import { MAX_PROJECT_FIELDS, ProjectFieldType } from '../projectField' +import { WorkflowKind } from '../workflow' + +const SOURCE = 'aaaaaaaaaaaaaaaaaaaaaaaa' +const TARGET = 'bbbbbbbbbbbbbbbbbbbbbbbb' +const FIELD_SPRINT = 'f0000000000000000000sprint' +const FIELD_STATUS = 'f0000000000000000000status' +const IT1 = 'i00000000000000000000001' +const IT2 = 'i00000000000000000000002' +const IT3 = 'i00000000000000000000003' +const NOW = new Date(2026, 5, 10, 15, 30).getTime() +const DAY = 86400000 + +function day (y: number, m: number, d: number): number { + return new Date(y, m, d).getTime() +} + +function ids (): () => string { + let n = 0 + return () => `new-${String(++n).padStart(4, '0')}-xxxxxxxx` +} + +function source (): ProjectCopySource { + return { + project: { _id: SOURCE as any, shortDescription: 'Short', readme: '{"type":"doc"}', workingDaysConfig: { weekdayMask: 31 } }, + fields: [ + { + _id: FIELD_SPRINT, + label: 'Sprint', + key: 'sprint', + type: ProjectFieldType.Iteration, + position: 2 + }, + { + _id: FIELD_STATUS, + label: 'Status', + key: 'status', + type: ProjectFieldType.SingleSelect, + position: 1, + defaultValue: 'o-todo', + options: [ + { value: 'o-todo', label: 'Todo', color: 1 }, + { value: 'o-done', label: 'Done', color: 2 } + ] + } + ] as any, + iterations: [ + { _id: IT2, field: FIELD_SPRINT, label: 'Break', number: 0, startDate: day(2026, 0, 15), duration: 7, isBreak: true }, + { _id: IT1, field: FIELD_SPRINT, label: 'Iteration 1', number: 1, startDate: day(2026, 0, 1), duration: 14 }, + { _id: IT3, field: FIELD_SPRINT, label: 'Iteration 2', number: 2, startDate: day(2026, 0, 22), duration: 7 } + ] as any, + views: [ + { + _id: 'v2', + name: 'Board', + location: { path: ['workbench', 'ws', 'tracker', SOURCE, 'issues'] }, + filters: '[]', + viewOptions: { columnLimits: { [IT1]: 3, 'o-todo': 5 }, groupBy: ['customFields.sprint'] }, + viewletId: 'viewlet-kanban', + filterClass: tracker.class.Issue, + config: ['title', 'customFields.status'], + extra: `[{"field":"${FIELD_STATUS}"}]`, + filterQuery: 'sprint:@current', + order: 2, + users: ['old-user'], + createdBy: 'old-person', + attachedTo: SOURCE, + project: SOURCE + }, + { + _id: 'v1', + name: 'Table', + location: { path: ['workbench', 'ws', 'tracker', SOURCE, 'issues'] }, + filters: '[]', + order: 1, + users: [], + createdBy: 'old-person', + attachedTo: SOURCE, + project: SOURCE + } + ] as any, + workflows: [ + { _id: 'w1', kind: WorkflowKind.SetStatusDoneOnClose, name: 'Item closed', enabled: false, createdOn: 1, config: { target: { field: 'status', option: 'o-done' } } }, + { _id: 'w2', kind: WorkflowKind.AutoArchive, name: 'Auto-archive items', enabled: true, createdOn: 2, filter: 'is:closed' }, + { _id: 'w3', kind: WorkflowKind.AutoArchive, name: 'Auto-archive items', enabled: false, createdOn: 3, filter: 'duplicate' } + ] as any, + charts: [ + { _id: 'c2', name: 'B', layout: 'bar', xField: 'customFields.status', yAggregate: { type: 'count' }, filter: '', position: 2 }, + { _id: 'c1', name: 'A', layout: 'line', xField: 'dueDate', xBucket: 'week', groupField: 'customFields.status', yAggregate: { type: 'sum', field: 'estimation' }, filter: 'is:open', position: 1 } + ] as any + } +} + +function build (src = source()): ReturnType { + return buildProjectCopyPlan(src, { + target: TARGET as any, + now: NOW, + generateId: ids(), + classes: { + field: tracker.class.ProjectField, + iteration: tracker.class.Iteration, + view: view.class.FilteredView, + workflow: tracker.class.Workflow, + chart: tracker.class.InsightChart + }, + author: { account: 'new-user' as any, person: 'new-person' as any } + }) +} + +function ofClass (plan: ReturnType, cls: string): any[] { + return plan.ops.filter((op) => op._class === cls) +} + +describe('buildProjectCopyPlan', () => { + it('creates everything in the target project, in a usable order', () => { + const plan = build() + expect(plan.ops.every((op) => op.space === TARGET)).toBe(true) + const order = plan.ops.map((op) => op._class) + const last = (cls: string): number => order.lastIndexOf(cls as any) + const first = (cls: string): number => order.indexOf(cls as any) + expect(last(tracker.class.ProjectField)).toBeLessThan(first(tracker.class.Iteration)) + expect(last(tracker.class.Iteration)).toBeLessThan(first(view.class.FilteredView)) + // all new ids are unique + expect(new Set(plan.ops.map((op) => op.id)).size).toBe(plan.ops.length) + }) + + it('copies the fields with new ids and the same keys and options', () => { + const plan = build() + const fields = ofClass(plan, tracker.class.ProjectField) + expect(fields.map((f) => f.data.key)).toEqual(['status', 'sprint']) + const status = fields[0] + expect(status.id).toBe(plan.fieldIds.get(FIELD_STATUS)) + expect(status.id).not.toBe(FIELD_STATUS) + expect(status.data.options).toEqual(source().fields[1].options) + expect(status.data.defaultValue).toBe('o-todo') + expect(status.data).not.toHaveProperty('_id') + // the options are copies, not shared with the source + const src = source() + const plan2 = build(src) + ofClass(plan2, tracker.class.ProjectField)[0].data.options[0].label = 'changed' + expect(src.fields[1].options?.[0].label).toBe('Todo') + }) + + it('recreates the iterations for the new field, the first one starting today', () => { + const plan = build() + const its = ofClass(plan, tracker.class.Iteration) + expect(its).toHaveLength(3) + const newField = plan.fieldIds.get(FIELD_SPRINT) + expect(its.every((it) => it.data.field === newField)).toBe(true) + const byLabel = (label: string): any => its.find((it) => it.data.label === label).data + const today = day(2026, 5, 10) + expect(byLabel('Iteration 1').startDate).toBe(today) + // gaps, durations and breaks stay as they were: the break starts 14 days after the first, the next one 21 + expect(byLabel('Break').startDate).toBe(day(2026, 5, 24)) + expect(byLabel('Break').isBreak).toBe(true) + expect(byLabel('Iteration 2').startDate).toBe(day(2026, 6, 1)) + expect(byLabel('Iteration 1').duration).toBe(14) + expect(plan.iterationIds.size).toBe(3) + }) + + it('shifts each iteration field on its own', () => { + const src = source() + src.fields = [ + ...src.fields, + { _id: 'f0000000000000000000second', label: 'Phase', key: 'phase', type: ProjectFieldType.Iteration, position: 3 } as any + ] + src.iterations = [ + ...src.iterations, + { _id: 'i00000000000000000000009', field: 'f0000000000000000000second', label: 'Phase 1', number: 1, startDate: day(2025, 2, 3), duration: 7 } as any + ] + const plan = build(src) + const phase = ofClass(plan, tracker.class.Iteration).find((it) => it.data.label === 'Phase 1') + expect(phase.data.startDate).toBe(day(2026, 5, 10)) + }) + + it('does not move an iteration that already starts today', () => { + const src = source() + src.iterations = [{ _id: IT1, field: FIELD_SPRINT, label: 'I', number: 1, startDate: day(2026, 5, 10), duration: 7 } as any] + const plan = build(src) + expect(ofClass(plan, tracker.class.Iteration)[0].data.startDate).toBe(day(2026, 5, 10)) + }) + + it('keeps the shift in whole days across a daylight saving change', () => { + const src = source() + // A winter start moved to a summer day must still be local midnight + src.iterations = [{ _id: IT1, field: FIELD_SPRINT, label: 'I', number: 1, startDate: day(2026, 0, 5), duration: 7 } as any] + const plan = build(src) + const start = ofClass(plan, tracker.class.Iteration)[0].data.startDate + expect(new Date(start).getHours()).toBe(0) + expect(Math.abs(start - day(2026, 5, 10))).toBeLessThan(DAY) + }) + + it('drops the iterations of a field that was not copied', () => { + const src = source() + src.iterations = [...src.iterations, { _id: 'i00000000000000000000008', field: 'unknown-field-id', label: 'Lost', number: 1, startDate: 1, duration: 1 } as any] + const plan = build(src) + expect(ofClass(plan, tracker.class.Iteration)).toHaveLength(3) + expect(plan.iterationIds.has('i00000000000000000000008')).toBe(false) + }) + + it('copies the views in tab order with the ids they mention remapped', () => { + const plan = build() + const views = ofClass(plan, view.class.FilteredView) + expect(views.map((v) => v.data.name)).toEqual(['Table', 'Board']) + const board = views[1].data + const newIt1 = plan.iterationIds.get(IT1) as string + expect(board.viewOptions.columnLimits).toEqual({ [newIt1]: 3, 'o-todo': 5 }) + expect(board.viewOptions.groupBy).toEqual(['customFields.sprint']) + expect(board.extra).toBe(`[{"field":"${plan.fieldIds.get(FIELD_STATUS)}"}]`) + expect(board.location.path).toEqual(['workbench', 'ws', 'tracker', TARGET, 'issues']) + expect(board.config).toEqual(['title', 'customFields.status']) + expect(board.filterQuery).toBe('sprint:@current') + expect(board.viewletId).toBe('viewlet-kanban') + expect(board.project).toBe(TARGET) + expect(board.attachedTo).toBe(TARGET) + expect(board.users).toEqual(['new-user']) + expect(board.createdBy).toBe('new-person') + expect(board.sharable).toBe(true) + expect(board.order).toBe(2) + expect(JSON.stringify(plan.ops)).not.toContain(SOURCE) + expect(plan.viewIds.size).toBe(2) + }) + + it('leaves out what a view does not have', () => { + const table = ofClass(build(), view.class.FilteredView)[0].data + expect(table).not.toHaveProperty('viewOptions') + expect(table).not.toHaveProperty('extra') + expect(table).not.toHaveProperty('config') + }) + + it('copies the workflows with their state, one per kind', () => { + const wfs = ofClass(build(), tracker.class.Workflow) + expect(wfs.map((w) => w.data.kind)).toEqual([WorkflowKind.SetStatusDoneOnClose, WorkflowKind.AutoArchive]) + expect(wfs[0].data).toEqual({ + name: 'Item closed', + enabled: false, + kind: WorkflowKind.SetStatusDoneOnClose, + config: { target: { field: 'status', option: 'o-done' } } + }) + // the oldest of two workflows of a kind is the one that counts + expect(wfs[1].data.filter).toBe('is:closed') + expect(wfs[1].data.enabled).toBe(true) + expect(wfs[1].data).not.toHaveProperty('runRequestedAt') + }) + + it('copies the Insights charts in order', () => { + const charts = ofClass(build(), tracker.class.InsightChart) + expect(charts.map((c) => c.data.name)).toEqual(['A', 'B']) + expect(charts[0].data).toEqual({ + name: 'A', + layout: 'line', + xField: 'dueDate', + xBucket: 'week', + groupField: 'customFields.status', + yAggregate: { type: 'sum', field: 'estimation' }, + filter: 'is:open', + position: 1 + }) + expect(charts[1].data).not.toHaveProperty('xBucket') + }) + + it('gives the details the new project starts with', () => { + const plan = build() + expect(plan.projectData).toEqual({ + shortDescription: 'Short', + readme: '{"type":"doc"}', + workingDaysConfig: { weekdayMask: 31 } + }) + expect(build({ ...source(), project: { _id: SOURCE as any } }).projectData).toEqual({}) + }) + + it('never copies issues', () => { + const plan = build() + expect(plan.ops.some((op) => op._class === tracker.class.Issue)).toBe(false) + }) + + it('keeps at most the field limit', () => { + const src = source() + src.fields = Array.from({ length: MAX_PROJECT_FIELDS + 5 }, (_, i) => ({ + _id: `field-id-${i}-xxxxxxxx`, + label: `F${i}`, + key: `f${i}`, + type: ProjectFieldType.Text, + position: i + })) as any + src.iterations = [] + const plan = build(src) + expect(ofClass(plan, tracker.class.ProjectField)).toHaveLength(MAX_PROJECT_FIELDS) + }) + + it('copies an empty project to an empty plan', () => { + const plan = build({ project: { _id: SOURCE as any }, fields: [], iterations: [], views: [], workflows: [], charts: [] }) + expect(plan.ops).toEqual([]) + expect(plan.projectData).toEqual({}) + }) + + it('does not touch the source documents', () => { + const src = source() + const before = JSON.stringify(src) + build(src) + expect(JSON.stringify(src)).toBe(before) + }) +}) diff --git a/plugins/tracker/src/__tests__/project-field.test.ts b/plugins/tracker/src/__tests__/project-field.test.ts new file mode 100644 index 0000000000..e416debe48 --- /dev/null +++ b/plugins/tracker/src/__tests__/project-field.test.ts @@ -0,0 +1,98 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + MAX_PROJECT_FIELDS, + MAX_PROJECT_FIELD_OPTIONS, + ProjectFieldType, + generateFieldKey, + getFieldValue, + normalizeFieldValue, + overLimitFields, + slugifyFieldLabel, + validateProjectField +} from '../projectField' + +const opts = [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' } +] + +describe('slugifyFieldLabel', () => { + it('camel-cases words', () => expect(slugifyFieldLabel('Story points')).toBe('storyPoints')) + it('strips punctuation', () => expect(slugifyFieldLabel(' Due -- date! ')).toBe('dueDate')) + it('prefixes leading digits', () => expect(slugifyFieldLabel('2nd pass')).toBe('f2ndPass')) + it('returns empty for symbols only', () => expect(slugifyFieldLabel('!!!')).toBe('')) +}) + +describe('generateFieldKey', () => { + it('avoids collisions', () => expect(generateFieldKey('Size', ['size', 'size2'])).toBe('size3')) + it('keeps free key', () => expect(generateFieldKey('Size', [])).toBe('size')) +}) + +describe('validateProjectField', () => { + it('rejects empty label', () => + expect(validateProjectField({ label: ' ', type: ProjectFieldType.Text }, [])).toBe('emptyLabel')) + it('rejects duplicate label case-insensitively', () => + expect(validateProjectField({ label: 'size', type: ProjectFieldType.Text }, [{ label: 'Size' }])).toBe( + 'duplicateLabel' + )) + it('enforces the field limit', () => { + const existing = Array.from({ length: MAX_PROJECT_FIELDS }, (_, i) => ({ label: `f${i}` })) + expect(validateProjectField({ label: 'x', type: ProjectFieldType.Text }, existing)).toBe('tooManyFields') + }) + it('requires options for selects', () => + expect(validateProjectField({ label: 'x', type: ProjectFieldType.SingleSelect }, [])).toBe('optionsRequired')) + it('enforces the option limit', () => { + const options = Array.from({ length: MAX_PROJECT_FIELD_OPTIONS + 1 }, (_, i) => ({ value: `${i}`, label: `o${i}` })) + expect(validateProjectField({ label: 'x', type: ProjectFieldType.MultiSelect, options }, [])).toBe( + 'tooManyOptions' + ) + }) + it('rejects duplicate options', () => + expect( + validateProjectField( + { label: 'x', type: ProjectFieldType.SingleSelect, options: [...opts, { value: 'c', label: 'a' }] }, + [] + ) + ).toBe('duplicateOption')) + it('forbids default on date', () => + expect(validateProjectField({ label: 'x', type: ProjectFieldType.Date, defaultValue: 1 }, [])).toBe( + 'defaultNotAllowed' + )) + it('checks default against options', () => + expect( + validateProjectField( + { label: 'x', type: ProjectFieldType.SingleSelect, options: opts, defaultValue: 'zzz' }, + [] + ) + ).toBe('invalidDefault')) + it('accepts a valid number field', () => + expect(validateProjectField({ label: 'x', type: ProjectFieldType.Number, defaultValue: 3 }, [])).toBeUndefined()) +}) + +describe('normalizeFieldValue', () => { + it('drops removed select options', () => + expect(normalizeFieldValue({ type: ProjectFieldType.SingleSelect, options: opts }, 'gone')).toBeNull()) + it('filters multi-select to known options', () => + expect(normalizeFieldValue({ type: ProjectFieldType.MultiSelect, options: opts }, ['a', 'gone'])).toEqual(['a'])) + it('rejects NaN numbers', () => expect(normalizeFieldValue({ type: ProjectFieldType.Number }, NaN)).toBeNull()) + it('rejects wrong types', () => expect(normalizeFieldValue({ type: ProjectFieldType.Text }, 5)).toBeNull()) +}) + +describe('getFieldValue', () => { + it('reads by key and tolerates missing record', () => { + const f = { key: 'size', type: ProjectFieldType.Number } + expect(getFieldValue({ size: 5 }, f)).toBe(5) + expect(getFieldValue(undefined, f)).toBeNull() + }) +}) + +describe('overLimitFields', () => { + it('keeps the oldest MAX fields', () => { + const fields = Array.from({ length: MAX_PROJECT_FIELDS + 2 }, (_, i) => ({ _id: i, position: i, createdOn: i })) + expect(overLimitFields(fields).map((f) => f._id)).toEqual([MAX_PROJECT_FIELDS, MAX_PROJECT_FIELDS + 1]) + }) +}) diff --git a/plugins/tracker/src/__tests__/project-settings.test.ts b/plugins/tracker/src/__tests__/project-settings.test.ts new file mode 100644 index 0000000000..5fab63abfa --- /dev/null +++ b/plugins/tracker/src/__tests__/project-settings.test.ts @@ -0,0 +1,81 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + canMakeProjectPrivate, + isDeleteConfirmed, + isProjectItemLimitReached, + itemsOverProjectLimit, + MAX_PROJECT_ITEMS, + MAX_PROJECT_README, + MAX_PROJECT_SHORT_DESCRIPTION, + projectShortDescription, + validateProjectDetails +} from '../projectSettings' + +describe('project item limit', () => { + it('is GitHub 50,000', () => { + expect(MAX_PROJECT_ITEMS).toBe(50000) + }) + + it('is reached when the project is full', () => { + expect(isProjectItemLimitReached(0)).toBe(false) + expect(isProjectItemLimitReached(MAX_PROJECT_ITEMS - 1)).toBe(false) + expect(isProjectItemLimitReached(MAX_PROJECT_ITEMS)).toBe(true) + expect(isProjectItemLimitReached(MAX_PROJECT_ITEMS + 10)).toBe(true) + expect(isProjectItemLimitReached(3, 3)).toBe(true) + }) + + it('gives back the newest created items beyond the limit', () => { + const created = ['a', 'b', 'c'] + expect(itemsOverProjectLimit(created, 10, 10)).toEqual([]) + expect(itemsOverProjectLimit(created, 11, 10)).toEqual(['c']) + expect(itemsOverProjectLimit(created, 12, 10)).toEqual(['b', 'c']) + expect(itemsOverProjectLimit(created, 13, 10)).toEqual(['a', 'b', 'c']) + // older items are never removed, even when the project is far over the limit + expect(itemsOverProjectLimit(created, 50, 10)).toEqual(['a', 'b', 'c']) + expect(itemsOverProjectLimit([], 50, 10)).toEqual([]) + }) +}) + +describe('project details', () => { + it('limits the short description to 256 characters', () => { + expect(MAX_PROJECT_SHORT_DESCRIPTION).toBe(256) + expect(validateProjectDetails({ shortDescription: 'a'.repeat(256) })).toBeUndefined() + expect(validateProjectDetails({ shortDescription: 'a'.repeat(257) })).toBe('shortDescriptionTooLong') + }) + + it('limits the README', () => { + expect(validateProjectDetails({ readme: 'a'.repeat(MAX_PROJECT_README) })).toBeUndefined() + expect(validateProjectDetails({ readme: 'a'.repeat(MAX_PROJECT_README + 1) })).toBe('readmeTooLong') + expect(validateProjectDetails({})).toBeUndefined() + }) + + it('shows the short description, or the old description of the space', () => { + expect(projectShortDescription({ shortDescription: ' Plan ', description: 'old' })).toBe('Plan') + expect(projectShortDescription({ description: ' old ' })).toBe('old') + expect(projectShortDescription({ shortDescription: ' ', description: 'old' })).toBe('old') + expect(projectShortDescription({ description: '' })).toBe('') + }) + + it('asks for the name to delete a project', () => { + expect(isDeleteConfirmed('Roadmap', 'Roadmap')).toBe(true) + expect(isDeleteConfirmed('Roadmap', ' Roadmap ')).toBe(true) + expect(isDeleteConfirmed('Roadmap', 'roadmap')).toBe(false) + expect(isDeleteConfirmed('Roadmap', '')).toBe(false) + expect(isDeleteConfirmed('', '')).toBe(false) + }) +}) + +describe('project visibility', () => { + it('needs an owner among the members to become private', () => { + expect(canMakeProjectPrivate({ members: ['a'], owners: ['a'] } as any)).toBe(true) + expect(canMakeProjectPrivate({ members: ['a', 'b'], owners: ['b'] } as any)).toBe(true) + expect(canMakeProjectPrivate({ members: [], owners: ['a'] } as any)).toBe(false) + expect(canMakeProjectPrivate({ members: ['a'], owners: ['b'] } as any)).toBe(false) + expect(canMakeProjectPrivate({ members: ['a'], owners: [] } as any)).toBe(false) + expect(canMakeProjectPrivate({ members: ['a'] } as any)).toBe(false) + }) +}) diff --git a/plugins/tracker/src/__tests__/project-status.test.ts b/plugins/tracker/src/__tests__/project-status.test.ts new file mode 100644 index 0000000000..ff98f34c82 --- /dev/null +++ b/plugins/tracker/src/__tests__/project-status.test.ts @@ -0,0 +1,69 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + canModifyStatusUpdate, + latestStatusUpdate, + MAX_STATUS_UPDATE_BODY, + PROJECT_STATUSES, + ProjectStatus, + sortStatusUpdates, + validateStatusUpdate +} from '../projectStatus' + +describe('project status', () => { + it('lists the five GitHub statuses', () => { + expect(PROJECT_STATUSES).toEqual(['INACTIVE', 'ON_TRACK', 'AT_RISK', 'OFF_TRACK', 'COMPLETE']) + }) + + it('accepts a plain update and a dated one', () => { + expect(validateStatusUpdate({ status: ProjectStatus.OnTrack, body: '' })).toBeUndefined() + expect( + validateStatusUpdate({ status: ProjectStatus.AtRisk, startDate: 1, targetDate: 1, body: 'x' }) + ).toBeUndefined() + expect( + validateStatusUpdate({ status: ProjectStatus.AtRisk, startDate: null, targetDate: 5, body: 'x' }) + ).toBeUndefined() + }) + + it('rejects an unknown status, a target before the start and a huge body', () => { + expect(validateStatusUpdate({ status: 'nope' as any, body: '' })).toBe('invalidStatus') + expect(validateStatusUpdate({ status: ProjectStatus.OnTrack, startDate: 10, targetDate: 9, body: '' })).toBe( + 'invalidDates' + ) + expect( + validateStatusUpdate({ status: ProjectStatus.OnTrack, body: 'a'.repeat(MAX_STATUS_UPDATE_BODY + 1) }) + ).toBe('bodyTooLong') + }) + + it('orders the updates newest first and takes the latest as the status', () => { + const list = [ + { _id: 'a', createdOn: 10 }, + { _id: 'c', createdOn: 30 }, + { _id: 'b', createdOn: 20 } + ] as any[] + expect(sortStatusUpdates(list).map((it) => it._id)).toEqual(['c', 'b', 'a']) + expect(latestStatusUpdate(list)?._id).toBe('c') + expect(latestStatusUpdate([])).toBeUndefined() + // the input is not reordered + expect(list.map((it) => it._id)).toEqual(['a', 'c', 'b']) + }) + + it('breaks a tie of the creation time by the id', () => { + const list = [ + { _id: 'a', createdOn: 5 }, + { _id: 'b', createdOn: 5 } + ] as any[] + expect(sortStatusUpdates(list).map((it) => it._id)).toEqual(['b', 'a']) + }) + + it('lets the author and the managers modify an update', () => { + const update = { createdBy: 'p1' } as any + expect(canModifyStatusUpdate(update, ['p1', 'p2'], false)).toBe(true) + expect(canModifyStatusUpdate(update, ['p3'], false)).toBe(false) + expect(canModifyStatusUpdate(update, ['p3'], true)).toBe(true) + expect(canModifyStatusUpdate({} as any, [], false)).toBe(false) + }) +}) diff --git a/plugins/tracker/src/__tests__/view-export.test.ts b/plugins/tracker/src/__tests__/view-export.test.ts new file mode 100644 index 0000000000..1972afbedd --- /dev/null +++ b/plugins/tracker/src/__tests__/view-export.test.ts @@ -0,0 +1,199 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { buildViewTsv, formatTsvCell, orderRowsByGroups, viewExportFileName, type GroupLevel } from '../viewExport' + +describe('formatTsvCell', () => { + it('writes empty values as nothing', () => { + expect(formatTsvCell(undefined)).toBe('') + expect(formatTsvCell(null)).toBe('') + expect(formatTsvCell('')).toBe('') + expect(formatTsvCell(Number.NaN)).toBe('') + }) + + it('writes numbers, booleans and ISO dates', () => { + expect(formatTsvCell(12.5)).toBe('12.5') + expect(formatTsvCell(0)).toBe('0') + expect(formatTsvCell(true)).toBe('true') + expect(formatTsvCell(new Date(Date.UTC(2026, 0, 15, 10, 30)))).toBe('2026-01-15T10:30:00.000Z') + expect(formatTsvCell(new Date(Number.NaN))).toBe('') + }) + + it('joins lists with a comma and a space and skips empty items', () => { + expect(formatTsvCell(['bug', 'ui', ''])).toBe('bug, ui') + expect(formatTsvCell([['a', 'b'], null, 'c'])).toBe('a, b, c') + expect(formatTsvCell([])).toBe('') + }) + + it('replaces tabs and line breaks so that a row stays one line', () => { + expect(formatTsvCell('a\tb')).toBe('a b') + expect(formatTsvCell('line 1\r\nline 2\nline 3\rline 4')).toBe('line 1 line 2 line 3 line 4') + expect(formatTsvCell('x\u2028y')).toBe('x y') + expect(formatTsvCell(['one\ttwo', 'three\nfour'])).toBe('one two, three four') + }) + + it('keeps unicode as it is', () => { + expect(formatTsvCell('Привет, мир 你好 🚀')).toBe('Привет, мир 你好 🚀') + }) + + it('neutralises formulas with a quote, but not numbers', () => { + expect(formatTsvCell('=SUM(A1:A2)')).toBe("'=SUM(A1:A2)") + expect(formatTsvCell('+1 555')).toBe("'+1 555") + expect(formatTsvCell('-2+3')).toBe("'-2+3") + expect(formatTsvCell('@cmd')).toBe("'@cmd") + // The text after a separator is moved to the start of the cell before the check + expect(formatTsvCell('\t=HYPERLINK("x")').startsWith("' ")).toBe(false) + expect(formatTsvCell('=a\t=b')).toBe("'=a =b") + expect(formatTsvCell(-5)).toBe('-5') + expect(formatTsvCell('1 - 2')).toBe('1 - 2') + expect(formatTsvCell('2026-01-15')).toBe('2026-01-15') + }) + + it('can leave formulas alone', () => { + expect(formatTsvCell('=1+1', false)).toBe('=1+1') + }) +}) + +describe('buildViewTsv', () => { + const columns = [{ header: 'Title' }, { header: 'Status' }, { header: 'Labels' }] + + it('writes the header row and one line per row', () => { + const tsv = buildViewTsv(columns, [ + ['Fix login', 'Todo', ['bug', 'auth']], + ['Write docs', 'Done', []] + ]) + expect(tsv).toBe('Title\tStatus\tLabels\nFix login\tTodo\tbug, auth\nWrite docs\tDone\t\n') + }) + + it('writes only the header for no rows', () => { + expect(buildViewTsv(columns, [])).toBe('Title\tStatus\tLabels\n') + }) + + it('pads short rows and ignores extra cells', () => { + expect(buildViewTsv(columns, [['only title'], ['a', 'b', 'c', 'd']])).toBe( + 'Title\tStatus\tLabels\nonly title\t\t\na\tb\tc\n' + ) + }) + + it('keeps every row on one line whatever the values hold', () => { + const tsv = buildViewTsv(columns, [['multi\nline\ttitle', '=cmd|calc', 'a\r\nb']]) + const lines = tsv.split('\n') + expect(lines).toHaveLength(3) + expect(lines[1]).toBe("multi line title\t'=cmd|calc\ta b") + expect(lines[1].split('\t')).toHaveLength(3) + }) + + it('neutralises formulas in headers and cells, unless asked not to', () => { + expect(buildViewTsv([{ header: '=h' }], [['=v']])).toBe("'=h\n'=v\n") + expect(buildViewTsv([{ header: '=h' }], [['=v']], { escapeFormulas: false })).toBe('=h\n=v\n') + }) + + it('builds a large export quickly and completely', () => { + const rows: Array> = [] + for (let i = 0; i < 50000; i++) rows.push([`Item ${i}`, 'Todo', i]) + const started = Date.now() + const tsv = buildViewTsv(columns, rows) + const lines = tsv.split('\n') + expect(lines).toHaveLength(50002) + expect(lines[50000]).toBe('Item 49999\tTodo\t49999') + expect(lines[50001]).toBe('') + expect(Date.now() - started).toBeLessThan(5000) + }) +}) + +describe('viewExportFileName', () => { + it('joins project and view', () => { + expect(viewExportFileName('Platform', 'Current iteration')).toBe('Platform - Current iteration.tsv') + }) + + it('removes characters that file systems refuse', () => { + expect(viewExportFileName('A/B: C', 'x*y?"z"')).toBe('A B C - x y z.tsv') + expect(viewExportFileName('', '')).toBe('view.tsv') + expect(viewExportFileName(' ', 'Board')).toBe('Board.tsv') + }) +}) + +describe('orderRowsByGroups', () => { + interface Row { + id: string + status?: string + team?: string + tags?: string[] + } + const status: GroupLevel = { + categories: ['todo', 'doing', 'done', undefined], + valueOf: (r) => r.status + } + + it('returns the rows as they are without grouping', () => { + const rows: Row[] = [{ id: 'a' }, { id: 'b' }] + expect(orderRowsByGroups(rows, [])).toEqual(rows) + expect(orderRowsByGroups(rows, [])).not.toBe(rows) + }) + + it('shows group after group in the order of the categories, keeping the order inside a group', () => { + const rows: Row[] = [ + { id: '1', status: 'done' }, + { id: '2', status: 'todo' }, + { id: '3' }, + { id: '4', status: 'todo' }, + { id: '5', status: 'doing' }, + { id: '6', status: 'done' } + ] + expect(orderRowsByGroups(rows, [status]).map((r) => r.id)).toEqual(['2', '4', '5', '1', '6', '3']) + }) + + it('groups by a category that stands for several values', () => { + const rows: Row[] = [ + { id: '1', status: 's-b' }, + { id: '2', status: 's-a' }, + { id: '3', status: 's-c' } + ] + const level: GroupLevel = { + categories: [{ name: 'Open', values: [{ _id: 's-a' }, { _id: 's-b' }] }, { name: 'Closed', values: [{ _id: 's-c' }] }], + valueOf: (r) => r.status + } + expect(orderRowsByGroups(rows, [level]).map((r) => r.id)).toEqual(['1', '2', '3']) + }) + + it('nests the next level inside the first', () => { + const rows: Row[] = [ + { id: '1', status: 'done', team: 'b' }, + { id: '2', status: 'todo', team: 'b' }, + { id: '3', status: 'done', team: 'a' }, + { id: '4', status: 'todo', team: 'a' } + ] + const team: GroupLevel = { categories: ['a', 'b'], valueOf: (r) => r.team } + expect(orderRowsByGroups(rows, [status, team]).map((r) => r.id)).toEqual(['4', '2', '3', '1']) + }) + + it('puts a row with several values in its first group only', () => { + const rows: Row[] = [ + { id: '1', tags: ['x', 'y'] }, + { id: '2', tags: ['y'] }, + { id: '3', tags: [] } + ] + const tags: GroupLevel = { categories: ['y', 'x', undefined], valueOf: (r) => r.tags } + const ordered = orderRowsByGroups(rows, [tags]) + expect(ordered.map((r) => r.id)).toEqual(['1', '2', '3']) + }) + + it('puts rows that fit no group at the end, without losing any', () => { + const rows: Row[] = [ + { id: '1', status: 'unknown' }, + { id: '2', status: 'todo' } + ] + expect(orderRowsByGroups(rows, [status]).map((r) => r.id)).toEqual(['2', '1']) + }) + + it('handles a big list', () => { + const rows: Row[] = [] + for (let i = 0; i < 50000; i++) rows.push({ id: String(i), status: i % 2 === 0 ? 'done' : 'todo' }) + const ordered = orderRowsByGroups(rows, [status]) + expect(ordered).toHaveLength(50000) + expect(ordered[0].status).toBe('todo') + expect(ordered[49999].status).toBe('done') + }) +}) diff --git a/plugins/tracker/src/__tests__/webhook.test.ts b/plugins/tracker/src/__tests__/webhook.test.ts new file mode 100644 index 0000000000..4408efd9bc --- /dev/null +++ b/plugins/tracker/src/__tests__/webhook.test.ts @@ -0,0 +1,187 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { isPublicAddress, normalizeWebhookEvents, parseIp, validateWebhookUrl } from '../webhook' + +describe('validateWebhookUrl', () => { + const ok = (url: string, allowInsecure = false): boolean => validateWebhookUrl(url, { allowInsecure }).ok + const error = (url: string, allowInsecure = false): string | undefined => { + const res = validateWebhookUrl(url, { allowInsecure }) + return res.ok === false ? res.error : undefined + } + + it('accepts a public https URL', () => { + expect(ok('https://hooks.example.com/huly?x=1')).toBe(true) + expect(ok('https://93.184.216.34/hook')).toBe(true) + expect(ok('https://[2606:2800:220:1:248:1893:25c8:1946]/hook')).toBe(true) + expect(ok(' https://example.com:8443/a ')).toBe(true) + }) + + it('rejects empty, malformed and over long input', () => { + expect(error('')).toBe('empty') + expect(error(' ')).toBe('empty') + expect(error('not a url')).toBe('invalid') + expect(error('https://')).toBe('invalid') + expect(error(`https://example.com/${'a'.repeat(2100)}`)).toBe('tooLong') + }) + + it('allows https only, unless development mode allows http', () => { + expect(error('http://example.com/hook')).toBe('protocol') + expect(error('ftp://example.com/hook')).toBe('protocol') + expect(error('file:///etc/passwd')).toBe('protocol') + expect(error('javascript:alert(1)')).toBe('protocol') + expect(ok('http://example.com/hook', true)).toBe(true) + expect(error('ftp://example.com', true)).toBe('protocol') + }) + + it('rejects credentials in the URL', () => { + expect(error('https://user:pw@example.com/')).toBe('credentials') + expect(error('https://user@example.com/')).toBe('credentials') + }) + + it('rejects internal host names', () => { + for (const url of [ + 'https://localhost/hook', + 'https://LOCALHOST./hook', + 'https://app.localhost/hook', + 'https://printer.local/hook', + 'https://db.internal/hook', + 'https://intranet/hook', + 'https://host/hook' + ]) { + expect(error(url)).toBe('host') + } + }) + + it('rejects private and loopback IP literals, also in disguise', () => { + for (const url of [ + 'https://127.0.0.1/', + 'https://10.1.2.3/', + 'https://192.168.1.1/', + 'https://172.16.0.1/', + 'https://169.254.169.254/latest/meta-data', + 'https://0.0.0.0/', + 'https://[::1]/', + 'https://[fe80::1]/', + 'https://[fd00::1]/', + 'https://[::ffff:127.0.0.1]/', + 'https://[::ffff:10.0.0.1]/', + // The URL parser turns these forms into dotted addresses + 'https://2130706433/', + 'https://0x7f.1/', + 'https://0177.0.0.1/', + 'https://127.1/' + ]) { + expect(error(url)).toBe('address') + } + }) + + it('lets development mode target local servers', () => { + expect(ok('http://localhost:3000/hook', true)).toBe(true) + expect(ok('http://127.0.0.1:9000/hook', true)).toBe(true) + }) +}) + +describe('isPublicAddress', () => { + it('accepts public IPv4 addresses', () => { + for (const ip of ['8.8.8.8', '1.1.1.1', '93.184.216.34', '172.15.255.255', '172.32.0.1', '100.63.255.255', '100.128.0.1', '11.0.0.1']) { + expect(isPublicAddress(ip)).toBe(true) + } + }) + + it('rejects reserved IPv4 ranges', () => { + for (const ip of [ + '0.0.0.0', + '0.1.2.3', + '10.0.0.0', + '10.255.255.255', + '100.64.0.1', + '100.127.255.255', + '127.0.0.1', + '127.255.255.254', + '169.254.169.254', + '172.16.0.0', + '172.31.255.255', + '192.0.0.1', + '192.0.2.1', + '192.88.99.1', + '192.168.0.1', + '198.18.0.1', + '198.19.255.255', + '198.51.100.7', + '203.0.113.9', + '224.0.0.1', + '239.255.255.255', + '240.0.0.1', + '255.255.255.255' + ]) { + expect(isPublicAddress(ip)).toBe(false) + } + }) + + it('accepts global unicast IPv6 addresses, with or without brackets', () => { + expect(isPublicAddress('2606:4700:4700::1111')).toBe(true) + expect(isPublicAddress('[2606:4700:4700::1111]')).toBe(true) + expect(isPublicAddress('2a00:1450:4001:81b::200e')).toBe(true) + }) + + it('rejects reserved IPv6 ranges', () => { + for (const ip of [ + '::', + '::1', + 'fe80::1', + 'febf::1', + 'fc00::1', + 'fd12:3456::1', + 'fec0::1', + 'ff02::1', + '2001:db8::1', + '2001:0:4136:e378:8000:63bf:3fff:fdd2', + '100::1', + '64:ff9b:1::1', + '::ffff:127.0.0.1', + '::ffff:7f00:1', + '::ffff:169.254.169.254', + '::127.0.0.1', + '64:ff9b::7f00:1', + '2002:7f00:1::1', + '2002:a9fe:a9fe::1', + '4000::1', + '[::1]' + ]) { + expect(isPublicAddress(ip)).toBe(false) + } + }) + + it('judges embedded IPv4 addresses by the IPv4 part', () => { + expect(isPublicAddress('::ffff:8.8.8.8')).toBe(true) + expect(isPublicAddress('64:ff9b::808:808')).toBe(true) + expect(isPublicAddress('2002:808:808::1')).toBe(true) + }) + + it('fails closed for anything that is not an address', () => { + for (const text of ['', 'example.com', 'localhost', '1.2.3', '1.2.3.4.5', '256.1.1.1', '01.2.3.4', '1.2.3.4/8', 'fe80::1%eth0', ':::', '1::2::3', 'g::1', '12345::1']) { + expect(isPublicAddress(text)).toBe(false) + } + }) +}) + +describe('parseIp', () => { + it('parses both versions', () => { + expect(parseIp('1.2.3.4')).toEqual({ version: 4, bytes: [1, 2, 3, 4] }) + expect(parseIp('::1')).toEqual({ version: 6, groups: [0, 0, 0, 0, 0, 0, 0, 1] }) + expect(parseIp('1:2:3:4:5:6:7:8')).toEqual({ version: 6, groups: [1, 2, 3, 4, 5, 6, 7, 8] }) + expect(parseIp('::ffff:1.2.3.4')).toEqual({ version: 6, groups: [0, 0, 0, 0, 0, 0xffff, 0x102, 0x304] }) + expect(parseIp('nope')).toBeUndefined() + }) +}) + +describe('normalizeWebhookEvents', () => { + it('keeps known events once, in a fixed order', () => { + expect(normalizeWebhookEvents(['deleted', 'created', 'created', 'bogus', 5])).toEqual(['created', 'deleted']) + expect(normalizeWebhookEvents('created')).toEqual([]) + expect(normalizeWebhookEvents(undefined)).toEqual([]) + }) +}) diff --git a/plugins/tracker/src/__tests__/workflow.test.ts b/plugins/tracker/src/__tests__/workflow.test.ts new file mode 100644 index 0000000000..ca4c4a6087 --- /dev/null +++ b/plugins/tracker/src/__tests__/workflow.test.ts @@ -0,0 +1,272 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Ref } from '@hcengineering/core' +import type { Project } from '../index' +import { ProjectFieldType, type ProjectField } from '../projectField' +import { + DEFAULT_AUTO_ARCHIVE_FILTER, + fieldWorkflowApplies, + isAutomationAuthor, + isWorkflowFilterUsable, + MAX_WORKFLOW_ITEMS_PER_RUN, + planFieldWorkflow, + planIssueWorkflows, + resolveFieldTarget, + resolveWorkflows, + restoreCandidates, + selectWorkflowItems, + statusTransition, + validateWorkflow, + WorkflowKind, + type EffectiveWorkflow, + type Workflow +} from '../workflow' + +const SPACE = 'project-1' as Ref + +function doc (kind: WorkflowKind, props: Partial = {}): Workflow { + return { + _id: `wf-${kind}-${props.createdOn ?? 0}` as any, + _class: 'tracker:class:Workflow' as any, + space: SPACE, + modifiedOn: 0, + modifiedBy: 'u' as any, + createdBy: 'u' as any, + createdOn: 0, + name: 'x', + enabled: true, + kind, + ...props + } +} + +const statusField = { + key: 'status', + label: 'Status', + type: ProjectFieldType.SingleSelect, + position: 0, + options: [ + { value: 'o-todo', label: 'Todo' }, + { value: 'o-prog', label: 'In progress' }, + { value: 'o-done', label: 'Done' } + ] +} as unknown as ProjectField + +const closed = new Set(['st-done', 'st-canceled']) + +describe('resolveWorkflows', () => { + it('lists every kind with the defaults of a project that never changed one', () => { + const list = resolveWorkflows([]) + expect(list.map((w) => w.kind)).toEqual([ + WorkflowKind.SetStatusDoneOnClose, + WorkflowKind.ItemReopened, + WorkflowKind.ItemAdded, + WorkflowKind.AutoArchive, + WorkflowKind.AutoAddFromQuery + ]) + expect(list.map((w) => w.enabled)).toEqual([true, true, true, false, false]) + expect(list[3].filter).toBe(DEFAULT_AUTO_ARCHIVE_FILTER) + expect(list[4].filter).toBe('') + expect(list.every((w) => w.doc === undefined)).toBe(true) + }) + + it('uses the stored doc of a kind', () => { + const stored = doc(WorkflowKind.AutoArchive, { enabled: true, filter: 'is:closed', name: 'Mine' }) + const archive = resolveWorkflows([stored])[3] + expect(archive).toMatchObject({ enabled: true, filter: 'is:closed', name: 'Mine', doc: stored }) + }) + + it('lets the oldest doc of a kind win, and ignores unknown kinds', () => { + const older = doc(WorkflowKind.ItemAdded, { createdOn: 1, enabled: false }) + const newer = doc(WorkflowKind.ItemAdded, { createdOn: 2, enabled: true }) + const unknown = doc('weird' as WorkflowKind) + expect(resolveWorkflows([newer, unknown, older])[2].enabled).toBe(false) + expect(resolveWorkflows([older, newer])[2].doc).toBe(older) + }) +}) + +describe('resolveFieldTarget', () => { + it('finds the Status field and the option of the kind', () => { + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, undefined, [statusField])?.option.value).toBe('o-done') + expect(resolveFieldTarget(WorkflowKind.ItemReopened, {}, [statusField])?.option.value).toBe('o-todo') + expect(resolveFieldTarget(WorkflowKind.ItemAdded, {}, [statusField])?.option.value).toBe('o-todo') + }) + + it('does nothing without a Status single select with the option', () => { + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, undefined, [])).toBeUndefined() + const text = { ...statusField, type: ProjectFieldType.Text } as ProjectField + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, undefined, [text])).toBeUndefined() + const noDone = { ...statusField, options: [{ value: 'o-todo', label: 'Todo' }] } as ProjectField + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, undefined, [noDone])).toBeUndefined() + const other = { ...statusField, label: 'Stage' } as ProjectField + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, undefined, [other])).toBeUndefined() + }) + + it('uses a configured target and never falls back when it is gone', () => { + const stage = { ...statusField, key: 'stage', label: 'Stage' } as ProjectField + const config = { target: { field: 'stage', option: 'o-prog' } } + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, config, [statusField, stage])?.option.value).toBe('o-prog') + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, { target: { field: 'gone', option: 'o-done' } }, [statusField])).toBeUndefined() + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, { target: { field: 'status', option: 'gone' } }, [statusField])).toBeUndefined() + }) + + it('prefers the first Status field by position', () => { + const second = { ...statusField, key: 'status2', position: 5 } as ProjectField + const first = { ...statusField, key: 'status1', position: 1 } as ProjectField + expect(resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, undefined, [second, first])?.field.key).toBe('status1') + }) +}) + +describe('statusTransition', () => { + it('detects closing and reopening', () => { + expect(statusTransition('st-todo', 'st-done', closed)).toBe('closed') + expect(statusTransition(undefined, 'st-canceled', closed)).toBe('closed') + expect(statusTransition('st-done', 'st-todo', closed)).toBe('reopened') + expect(statusTransition('st-todo', 'st-progress', closed)).toBe('none') + expect(statusTransition('st-done', 'st-canceled', closed)).toBe('none') + expect(statusTransition(undefined, 'st-progress', closed)).toBe('none') + }) +}) + +describe('fieldWorkflowApplies', () => { + it('maps changes to workflows', () => { + const toDone = { type: 'status', prev: 'st-todo', next: 'st-done' } as const + const reopen = { type: 'status', prev: 'st-done', next: 'st-todo' } as const + expect(fieldWorkflowApplies(WorkflowKind.SetStatusDoneOnClose, toDone, closed)).toBe(true) + expect(fieldWorkflowApplies(WorkflowKind.ItemReopened, toDone, closed)).toBe(false) + expect(fieldWorkflowApplies(WorkflowKind.ItemReopened, reopen, closed)).toBe(true) + expect(fieldWorkflowApplies(WorkflowKind.ItemAdded, toDone, closed)).toBe(false) + expect(fieldWorkflowApplies(WorkflowKind.ItemAdded, { type: 'created' }, closed)).toBe(true) + expect(fieldWorkflowApplies(WorkflowKind.SetStatusDoneOnClose, { type: 'created' }, closed)).toBe(false) + }) +}) + +describe('planFieldWorkflow (idempotency)', () => { + const target = resolveFieldTarget(WorkflowKind.SetStatusDoneOnClose, undefined, [statusField]) + if (target === undefined) throw new Error('target') + + it('writes the value once', () => { + const first = planFieldWorkflow(WorkflowKind.SetStatusDoneOnClose, { other: 1 }, target) + expect(first).toEqual({ other: 1, status: 'o-done' }) + expect(planFieldWorkflow(WorkflowKind.SetStatusDoneOnClose, first, target)).toBeUndefined() + }) + + it('does not modify its input', () => { + const input = { a: 1 } + planFieldWorkflow(WorkflowKind.SetStatusDoneOnClose, input, target) + expect(input).toEqual({ a: 1 }) + }) + + it('"item added" only fills an empty value', () => { + expect(planFieldWorkflow(WorkflowKind.ItemAdded, undefined, target)).toEqual({ status: 'o-done' }) + expect(planFieldWorkflow(WorkflowKind.ItemAdded, { status: null }, target)).toEqual({ status: 'o-done' }) + expect(planFieldWorkflow(WorkflowKind.ItemAdded, { status: 'o-prog' }, target)).toBeUndefined() + }) +}) + +describe('planIssueWorkflows', () => { + const workflows = resolveWorkflows([]) + const fields = [statusField] + + it('sets Done when an item is closed', () => { + const res = planIssueWorkflows(workflows, { type: 'status', prev: 'st-todo', next: 'st-done' }, { customFields: { status: 'o-prog' } }, closed, fields) + expect(res).toEqual({ status: 'o-done' }) + }) + + it('sets the default open value when an item is reopened and when it is added', () => { + expect( + planIssueWorkflows(workflows, { type: 'status', prev: 'st-done', next: 'st-progress' }, { customFields: { status: 'o-done' } }, closed, fields) + ).toEqual({ status: 'o-todo' }) + expect(planIssueWorkflows(workflows, { type: 'created' }, {}, closed, fields)).toEqual({ status: 'o-todo' }) + }) + + it('does nothing when a workflow is disabled, has no target or nothing changes', () => { + const off = workflows.map((w) => ({ ...w, enabled: false })) + expect(planIssueWorkflows(off, { type: 'created' }, {}, closed, fields)).toBeUndefined() + expect(planIssueWorkflows(workflows, { type: 'created' }, {}, closed, [])).toBeUndefined() + expect(planIssueWorkflows(workflows, { type: 'status', prev: 'st-todo', next: 'st-done' }, { customFields: { status: 'o-done' } }, closed, fields)).toBeUndefined() + expect(planIssueWorkflows(workflows, { type: 'status', prev: 'st-todo', next: 'st-progress' }, {}, closed, fields)).toBeUndefined() + }) + + it('ignores the filter workflows', () => { + const filterOnly: EffectiveWorkflow[] = [{ kind: WorkflowKind.AutoArchive, name: 'a', enabled: true, filter: 'is:closed', config: {} }] + expect(planIssueWorkflows(filterOnly, { type: 'created' }, {}, closed, fields)).toBeUndefined() + }) +}) + +describe('selectWorkflowItems (caps)', () => { + const items = Array.from({ length: 250 }, (_, i) => ({ _id: `i${i}`, n: i })) + + it('takes the first matches up to the cap', () => { + expect(selectWorkflowItems(items, () => true)).toHaveLength(MAX_WORKFLOW_ITEMS_PER_RUN) + expect(selectWorkflowItems(items, (i) => i.n % 2 === 0, { cap: 3 }).map((i) => i._id)).toEqual(['i0', 'i2', 'i4']) + }) + + it('never exceeds the global cap, whatever is asked', () => { + expect(selectWorkflowItems(items, () => true, { cap: 10_000 })).toHaveLength(MAX_WORKFLOW_ITEMS_PER_RUN) + expect(selectWorkflowItems(items, () => true, { cap: 0 })).toEqual([]) + expect(selectWorkflowItems(items, () => true, { cap: -5 })).toEqual([]) + }) + + it('takes an id once and leaves excluded ids alone', () => { + const dup = [{ _id: 'a' }, { _id: 'a' }, { _id: 'b' }, { _id: 'c' }] + expect(selectWorkflowItems(dup, () => true).map((i) => i._id)).toEqual(['a', 'b', 'c']) + expect(selectWorkflowItems(dup, () => true, { exclude: new Set(['b']) }).map((i) => i._id)).toEqual(['a', 'c']) + }) + + it('works the backlog off in order over several runs', () => { + let rest = items + let runs = 0 + while (rest.length > 0 && runs < 10) { + const taken = new Set(selectWorkflowItems(rest, () => true).map((i) => i._id)) + rest = rest.filter((i) => !taken.has(i._id)) + runs++ + } + expect(runs).toBe(3) + expect(rest).toEqual([]) + }) + + it('does not modify its input', () => { + const copy = [...items] + selectWorkflowItems(items, () => true) + expect(items).toEqual(copy) + }) +}) + +describe('restoreCandidates', () => { + it('leaves out what the archive filter would archive again', () => { + const archived = [{ _id: 'a', closed: true }, { _id: 'b', closed: false }, { _id: 'c', closed: false }] + const res = restoreCandidates(archived, () => true, (i) => i.closed) + expect(res.map((i) => i._id)).toEqual(['b', 'c']) + expect(restoreCandidates(archived, (i) => i._id === 'c', undefined).map((i) => i._id)).toEqual(['c']) + }) +}) + +describe('automation marker', () => { + it('recognises the system author only', () => { + expect(isAutomationAuthor('sys', 'sys')).toBe(true) + expect(isAutomationAuthor('person-1', 'sys')).toBe(false) + expect(isAutomationAuthor(undefined, 'sys')).toBe(false) + }) +}) + +describe('filter validation', () => { + it('needs a usable filter', () => { + expect(isWorkflowFilterUsable(undefined)).toBe(false) + expect(isWorkflowFilterUsable(' ')).toBe(false) + expect(isWorkflowFilterUsable('is:closed')).toBe(true) + expect(isWorkflowFilterUsable('x'.repeat(1001))).toBe(false) + }) + + it('validates what can be enabled', () => { + const [done, , , archive] = resolveWorkflows([]) + expect(validateWorkflow(done, [statusField])).toBeUndefined() + expect(validateWorkflow({ ...archive, filter: '' }, [])).toBe('filterRequired') + expect(validateWorkflow({ ...archive, filter: 'x'.repeat(1001) }, [])).toBe('filterTooLong') + expect(validateWorkflow(archive, [])).toBeUndefined() + expect(validateWorkflow({ ...done, config: { target: { field: 'gone', option: 'x' } } }, [statusField])).toBe('targetMissing') + }) +}) diff --git a/plugins/tracker/src/archive.ts b/plugins/tracker/src/archive.ts new file mode 100644 index 0000000000..41f1846a9b --- /dev/null +++ b/plugins/tracker/src/archive.ts @@ -0,0 +1,42 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Timestamp } from '@hcengineering/core' + +// Archiving an item (GitHub Projects "Archive item"): the issue stays where it is and keeps all of its field +// values, it only gets a timestamp that makes the project views, slice and Insights skip it. Restoring clears it. +// (Huly's `hideArchived` option is about archived *projects*, so issues need a marker of their own.) + +/** + * Whether an issue is archived: a restored issue carries `null`, an issue that was never archived has no value. + * @public + */ +export function isIssueArchived (issue: { archivedAt?: Timestamp | null }): boolean { + return issue.archivedAt !== undefined && issue.archivedAt !== null +} + +/** + * The update that archives an issue. + * @public + */ +export function archiveUpdate (now: Timestamp = Date.now()): { archivedAt: Timestamp } { + return { archivedAt: now } +} + +/** + * The update that restores an archived issue. It sets `null`, because an update cannot remove a property. + * @public + */ +export function restoreUpdate (): { archivedAt: null } { + return { archivedAt: null } +} + +/** + * The part of a `DocumentQuery` that selects archived issues (`true`), or the ones that are not archived (`false`). + * @public + */ +export function archivedQuery (archived: boolean): { archivedAt: null } | { archivedAt: { $ne: null } } { + return archived ? { archivedAt: { $ne: null } } : { archivedAt: null } +} diff --git a/plugins/tracker/src/draft.ts b/plugins/tracker/src/draft.ts new file mode 100644 index 0000000000..31aa7ff81f --- /dev/null +++ b/plugins/tracker/src/draft.ts @@ -0,0 +1,110 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Draft items (GitHub Projects "draft issue"): a real project item that is not an issue yet. A draft is an issue document +// with `isDraft: true`, so that it takes part in every view, filter, field, board and roadmap of the project unchanged. +// It has no number in the project sequence (it would waste one for every idea that is never converted): its number is +// 0, which the sequence never hands out, and its identifier is a placeholder. Converting it gives it the next number of +// the project and clears the flag. + +/** Longest title of an item, like GitHub (256 characters). @public */ +export const MAX_ITEM_TITLE_LENGTH = 256 + +/** Number of a draft. The project sequence starts at 1, so no issue has it. @public */ +export const DRAFT_NUMBER = 0 + +/** + * Whether an issue is a draft item: a converted draft carries `false`, an issue that was never a draft has no value. + * @public + */ +export function isIssueDraft (issue: { isDraft?: boolean | null }): boolean { + return issue.isDraft === true +} + +/** + * The part of a `DocumentQuery` that selects drafts (`true`), or the items that are not drafts (`false`). The second + * one also matches issues that never had the property. + * @public + */ +export function draftQuery (draft: boolean): { isDraft: true } | { isDraft: { $ne: true } } { + return draft ? { isDraft: true } : { isDraft: { $ne: true } } +} + +/** + * Identifier stored on a draft. It is only a placeholder (the views show "Draft"): it does not look like an issue id + * (`PROJ-12`), so no link or search resolves it to an issue, and a draft is opened by its document id. + * @public + */ +export function draftIdentifier (projectIdentifier: string): string { + return `${projectIdentifier}-Draft` +} + +const DRAFT_LINK_PREFIX = 'draft-' + +/** + * The part of a link that opens a draft. A draft has no issue id (`PROJ-12`), so it is addressed by its document id, + * with a prefix that no issue id and no project id has. + * @public + */ +export function draftLinkSegment (id: string): string { + return `${DRAFT_LINK_PREFIX}${id}` +} + +/** + * The document id a link segment of a draft stands for, `undefined` when the segment is not a draft link. + * @public + */ +export function parseDraftLinkSegment (segment: string | undefined): string | undefined { + if (segment === undefined || !segment.startsWith(DRAFT_LINK_PREFIX) || segment.length === DRAFT_LINK_PREFIX.length) { + return undefined + } + return segment.slice(DRAFT_LINK_PREFIX.length) +} + +/** + * The part of a link that opens an item: the identifier of an issue, the prefixed document id of a draft. + * @public + */ +export function issueLinkSegment (issue: { _id: string, identifier: string, isDraft?: boolean | null }): string { + return issue.isDraft === true ? draftLinkSegment(issue._id) : issue.identifier +} + +/** + * The update that turns a draft into an issue: the number taken from the project sequence, the identifier built from it + * and the cleared flag. `false` (not removal), because an update cannot remove a property. + * @public + */ +export function convertDraftUpdate ( + number: number, + projectIdentifier: string +): { number: number, identifier: string, isDraft: false } { + return { number, identifier: `${projectIdentifier}-${number}`, isDraft: false } +} + +/** + * What was typed into the "Add item" row. + * - `empty`: nothing to add yet. + * - `draft`: a title; Enter creates a draft item. + * - `search`: text that starts with `#`; the rest searches existing issues. + * - `too-long`: the title is longer than an item title can be. + * @public + */ +export type AddItemInput = + | { kind: 'empty' } + | { kind: 'draft', title: string } + | { kind: 'search', query: string } + | { kind: 'too-long', length: number } + +/** + * Reads the text of the "Add item" row (GitHub: a title makes a draft, `#` searches issues). + * @public + */ +export function parseAddItemInput (text: string): AddItemInput { + const trimmed = text.trim() + if (trimmed === '') return { kind: 'empty' } + if (trimmed.startsWith('#')) return { kind: 'search', query: trimmed.slice(1).trim() } + if (trimmed.length > MAX_ITEM_TITLE_LENGTH) return { kind: 'too-long', length: trimmed.length } + return { kind: 'draft', title: trimmed } +} diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index 6636bacb1f..41b5712d39 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -49,8 +49,27 @@ import { } from '@hcengineering/task' import { AnyComponent, ComponentExtensionId, Location, ResolvedLocation } from '@hcengineering/ui' import { Action, ActionCategory, IconProps } from '@hcengineering/view' +import type { ProjectField } from './projectField' +import type { Iteration } from './iteration' +import type { InsightChart } from './insightChart' +import type { Workflow } from './workflow' +import type { ProjectWebhook, ProjectWebhookSecret } from './webhook' +import type { ProjectStatusUpdate } from './projectStatus' export * from './analytics' +export * from './projectField' +export * from './iteration' +export * from './insightChart' +export * from './archive' +export * from './draft' +export * from './viewExport' +export * from './workflow' +export * from './webhook' +export * from './projectStatus' +export * from './projectSettings' +export * from './projectCopy' +export * from './issueFilterSchema' +export * from './iterationRollup' /** * @public @@ -122,6 +141,12 @@ export interface Project extends TaskProject, IconProps { * `undefined` means "every day is a working day" (legacy behaviour). */ workingDaysConfig?: WorkingDaysConfig + // Short description (GitHub: at most 256 characters), shown in the project details + shortDescription?: string + // README of the project (GitHub: Markdown), shown in the project details + readme?: Markup + // A template can be used to create new projects (GitHub "Make template") + isTemplate?: boolean } /** @@ -270,6 +295,14 @@ export interface Issue extends Task { startDate: Timestamp | null // for Gantt scheduling; null = unscheduled + // Set while the issue is archived (GitHub Projects "Archive item"), `null` once restored. Archived issues keep all of + // their values and are left out of the project views, slice and Insights unless a filter asks for them (`is:archived`) + archivedAt?: Timestamp | null + + // Set while the issue is a draft item (GitHub Projects "draft issue"): a project item that is not an issue yet. A draft + // has no number in the project sequence (`number` is 0) until it is converted, which clears the flag (`false`) + isDraft?: boolean + // Soft deadline, independent of dueDate. The Gantt renders // a flag marker at this date and flags the issue as overdue when // dueDate > deadline. Undefined for issues that haven't opted in. @@ -279,6 +312,9 @@ export interface Issue extends Task { milestone?: Ref | null + // Values of user-defined project fields, keyed by ProjectField.key + customFields?: Record + // Estimation in man hours estimation: number @@ -575,6 +611,13 @@ const pluginState = plugin(trackerId, { IssueStatus: '' as Ref>, TypeIssuePriority: '' as Ref>>, Milestone: '' as Ref>, + ProjectField: '' as Ref>, + Iteration: '' as Ref>, + InsightChart: '' as Ref>, + Workflow: '' as Ref>, + ProjectWebhook: '' as Ref>, + ProjectWebhookSecret: '' as Ref>, + ProjectStatusUpdate: '' as Ref>, TypeMilestoneStatus: '' as Ref>>, TimeSpendReport: '' as Ref>, TypeReportedTime: '' as Ref>>, @@ -672,6 +715,9 @@ const pluginState = plugin(trackerId, { TimeReport: '' as Asset, Estimation: '' as Asset, Gantt: '' as Asset, + Roadmap: '' as Asset, + Calendar: '' as Asset, + Workload: '' as Asset, // Project icons Home: '' as Asset, diff --git a/plugins/tracker/src/insightChart.ts b/plugins/tracker/src/insightChart.ts new file mode 100644 index 0000000000..147c32aa92 --- /dev/null +++ b/plugins/tracker/src/insightChart.ts @@ -0,0 +1,57 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref } from '@hcengineering/core' +import type { Project } from './index' + +/** + * Layouts of an Insights chart, the same six as in GitHub Projects. + * @public + */ +export type InsightLayout = 'bar' | 'column' | 'stackedBar' | 'stackedColumn' | 'stackedArea' | 'line' + +/** + * How the values of the items of a bucket are combined into the Y value. + * @public + */ +export type InsightAggregate = 'count' | 'sum' | 'avg' | 'min' | 'max' + +/** + * Y-axis of a chart: the number of items, or an aggregate of a number field. + * @public + */ +export interface InsightYAxis { + type: InsightAggregate + // Id of the number field (see `chartFieldId` in tracker-resources); absent for `count` + field?: string +} + +/** + * Size of the buckets of a date X-axis. + * @public + */ +export type InsightDateBucket = 'day' | 'week' | 'month' + +/** + * A saved chart of the project Insights (current charts only: the chart is computed from the issues as they are + * now, there is no history). The `space` is the project. + * @public + */ +export interface InsightChart extends Doc { + space: Ref + name: string + layout: InsightLayout + // Id of the field of the X-axis (see `chartFieldId` in tracker-resources) + xField: string + // Buckets of a date X-axis; null once cleared (an update cannot remove a property) + xBucket?: InsightDateBucket | null + // Id of the field that makes the series; absent or null = a single series + groupField?: string | null + yAggregate: InsightYAxis + // GitHub-style filter string + filter: string + // Order in the sidebar + position: number +} diff --git a/plugins/tracker/src/issueFilterSchema.ts b/plugins/tracker/src/issueFilterSchema.ts new file mode 100644 index 0000000000..30ff46b9d1 --- /dev/null +++ b/plugins/tracker/src/issueFilterSchema.ts @@ -0,0 +1,215 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { getAssignableIterations } from './iteration' +import { ProjectFieldType, type ProjectField } from './projectField' +import type { Iteration } from './iteration' + +// The fields the issues of a project can be filtered by with the filter grammar (view-resources). It is used by +// the project views on the client and by the server workflows, so that a filter means the same in both places. +// The types below repeat the shape of the grammar's `FieldSpec` (that package is not available to the server and +// to this one); a value is assignable to it. + +/** + * @public + */ +export type IssueFilterFieldType = 'text' | 'number' | 'date' | 'select' | 'multi' | 'user' | 'iteration' | 'presence' + +/** + * @public + */ +export interface IssueFilterOption { + id: string | number + name: string +} + +/** + * Same shape as the grammar's `FieldSpec`. + * @public + */ +export interface IssueFilterField { + name: string + label: string + type: IssueFilterFieldType + source: 'attribute' | 'custom' + key: string + options?: IssueFilterOption[] + read?: (doc: any) => unknown + resolveDocIds?: (optionIds: Array) => string[] + presenceQuery?: (present: boolean) => Record + dependsOn?: string[] + clientOnly?: boolean +} + +/** + * @public + */ +export interface NamedOption { + id: string | number + name: string +} + +/** + * @public + */ +export interface IssueFilterSchemaInput { + statuses: NamedOption[] + priorities: NamedOption[] + assignees: NamedOption[] + components: NamedOption[] + milestones: NamedOption[] + // Label (tag element) options + labels: NamedOption[] + // Tag references: which issue carries which label + labelRefs: Array<{ issue: string, label: string }> + customFields: ProjectField[] + // Iterations of the iteration fields among the custom fields + iterations?: readonly Iteration[] + // Value of Issue.attachedTo for an issue without a parent + noParentId: string +} + +const NAME_PATTERN = /^\p{L}[\p{L}\p{N}_.-]*$/u + +/** + * Name of a user-defined field in a filter: its label lowercased with spaces replaced by hyphens + * (GitHub's convention). A label that does not make a valid name falls back to the field key. + */ +export function fieldFilterName (label: string, key: string): string { + const slug = label.trim().toLowerCase().replace(/\s+/g, '-') + return NAME_PATTERN.test(slug) ? slug : key.toLowerCase() +} + +function customFieldType (type: ProjectFieldType): IssueFilterFieldType { + switch (type) { + case ProjectFieldType.Number: + return 'number' + case ProjectFieldType.Date: + return 'date' + case ProjectFieldType.SingleSelect: + return 'select' + case ProjectFieldType.MultiSelect: + return 'multi' + case ProjectFieldType.Iteration: + return 'iteration' + default: + return 'text' + } +} + +/** + * Fields a project's issues can be filtered by: the built-in attributes first, then the project's + * custom fields. A custom field whose name collides with an earlier one is not filterable by string. + */ +export function buildIssueFilterSchema (input: IssueFilterSchemaInput): IssueFilterField[] { + const labelsByIssue = new Map() + for (const ref of input.labelRefs) { + const list = labelsByIssue.get(ref.issue) + if (list === undefined) labelsByIssue.set(ref.issue, [ref.label]) + else list.push(ref.label) + } + const options = (list: NamedOption[]): IssueFilterOption[] => list.map((o) => ({ id: o.id, name: o.name })) + + const schema: IssueFilterField[] = [ + { name: 'title', label: 'Title', type: 'text', source: 'attribute', key: 'title' }, + { name: 'status', label: 'Status', type: 'select', source: 'attribute', key: 'status', options: options(input.statuses) }, + { + name: 'priority', + label: 'Priority', + type: 'select', + source: 'attribute', + key: 'priority', + options: options(input.priorities) + }, + { + name: 'assignee', + label: 'Assignee', + type: 'user', + source: 'attribute', + key: 'assignee', + options: options(input.assignees) + }, + { + name: 'label', + label: 'Label', + type: 'multi', + source: 'attribute', + key: 'labels', + options: options(input.labels), + // Labels are separate documents: the issues that carry them are looked up by id + read: (doc) => labelsByIssue.get(doc._id) ?? [], + resolveDocIds: (ids) => { + const wanted = new Set(ids.map(String)) + const res: string[] = [] + for (const [issue, labels] of labelsByIssue) { + if (labels.some((l) => wanted.has(l))) res.push(issue) + } + return res + } + }, + { + name: 'component', + label: 'Component', + type: 'select', + source: 'attribute', + key: 'component', + options: options(input.components) + }, + { + name: 'milestone', + label: 'Milestone', + type: 'select', + source: 'attribute', + key: 'milestone', + options: options(input.milestones) + }, + { name: 'due', label: 'Due date', type: 'date', source: 'attribute', key: 'dueDate' }, + { name: 'start', label: 'Start date', type: 'date', source: 'attribute', key: 'startDate' }, + { name: 'deadline', label: 'Deadline', type: 'date', source: 'attribute', key: 'deadline' }, + { name: 'estimate', label: 'Estimate', type: 'number', source: 'attribute', key: 'estimation' }, + // When the issue was last changed (GitHub's `updated:`), e.g. `updated:<@today-2w` + { name: 'updated', label: 'Updated', type: 'date', source: 'attribute', key: 'modifiedOn' }, + { + name: 'parent-issue', + label: 'Parent issue', + type: 'presence', + source: 'attribute', + key: 'attachedTo', + read: (doc) => (doc.attachedTo === input.noParentId ? undefined : doc.attachedTo), + presenceQuery: (present) => ({ attachedTo: present ? { $ne: input.noParentId } : input.noParentId }) + }, + { + name: 'sub-issues', + label: 'Sub-issues', + type: 'presence', + source: 'attribute', + key: 'subIssues', + read: (doc) => (typeof doc.subIssues === 'number' && doc.subIssues > 0 ? doc.subIssues : undefined), + presenceQuery: (present) => ({ subIssues: present ? { $gt: 0 } : 0 }) + } + ] + + const taken = new Set(schema.map((f) => f.name)) + for (const field of input.customFields) { + const name = fieldFilterName(field.label, field.key) + if (taken.has(name)) continue + taken.add(name) + schema.push({ + name, + label: field.label, + type: customFieldType(field.type), + source: 'custom', + key: field.key, + options: + field.type === ProjectFieldType.Iteration + ? getAssignableIterations((input.iterations ?? []).filter((it) => it.field === field._id)).map((it) => ({ + id: it._id, + name: it.label + })) + : field.options?.map((o) => ({ id: o.value, name: o.label })) + }) + } + return schema +} diff --git a/plugins/tracker/src/iteration.ts b/plugins/tracker/src/iteration.ts new file mode 100644 index 0000000000..5568488610 --- /dev/null +++ b/plugins/tracker/src/iteration.ts @@ -0,0 +1,529 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref, Timestamp } from '@hcengineering/core' +import type { Project } from './index' +import type { ProjectField } from './projectField' + +/** + * A time box of an Iteration field (GitHub Projects v2 iteration). The value of an issue is the id + * of the iteration, stored in `Issue.customFields[field.key]`. Dates are whole local days: `startDate` + * is the start of the first day and the iteration covers `duration` days. Whether an iteration is + * planned, current or completed is never stored, it follows from the dates (`getIterationState`). + * @public + */ +export interface Iteration extends Doc { + // The project of the owning field + space: Ref + // The Iteration field this iteration belongs to + field: Ref + label: string + // Ordinal used for default titles (`Iteration 4`); 0 for breaks + number: number + // Start of the first day (local midnight) + startDate: Timestamp + // Length in days, at least 1 + duration: number + // A break occupies a date range but items cannot be assigned to it + isBreak?: boolean +} + +/** + * @public + */ +export type IterationState = 'completed' | 'current' | 'planned' + +/** The part of an iteration that describes where it is in time. */ +export type IterationSpan = Pick + +/** + * Values of a new iteration, before it gets an id. + * @public + */ +export type IterationDraft = Pick & { isBreak?: boolean } + +/** + * An iteration as the filter grammar sees it: `end` is the last millisecond of the last day. + * @public + */ +export interface IterationRange { + id: string + title: string + start: number + end: number +} + +/** @public */ +export const DEFAULT_ITERATION_DURATION = 7 +/** @public */ +export const DEFAULT_INITIAL_ITERATIONS = 3 + +const DAY_MS = 86400000 + +// ---- date math (calendar days in the local time zone, so that daylight saving shifts do not matter) ---- + +/** + * Start of the local day containing the timestamp. + * @public + */ +export function startOfDay (ts: number): number { + return new Date(ts).setHours(0, 0, 0, 0) +} + +/** + * The start of the day `days` calendar days after the day containing `ts` (negative goes back). + * @public + */ +export function addDays (ts: number, days: number): number { + const d = new Date(ts) + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days).getTime() +} + +/** + * Number of calendar days from the day of `a` to the day of `b`. + * @public + */ +export function daysBetween (a: number, b: number): number { + return Math.round((startOfDay(b) - startOfDay(a)) / DAY_MS) +} + +/** + * Start of the day after the last day of the iteration (exclusive end). + * @public + */ +export function iterationEndExclusive (it: IterationSpan): number { + return addDays(it.startDate, it.duration) +} + +/** + * Last millisecond of the last day of the iteration. + * @public + */ +export function iterationEnd (it: IterationSpan): number { + return iterationEndExclusive(it) - 1 +} + +/** + * Where the iteration is in time at `now`. + * @public + */ +export function getIterationState (it: IterationSpan, now: number): IterationState { + if (now < it.startDate) return 'planned' + if (now > iterationEnd(it)) return 'completed' + return 'current' +} + +/** + * Iterations in calendar order. + * @public + */ +export function sortIterations> (list: readonly T[]): T[] { + return [...list].sort((a, b) => a.startDate - b.startDate || a.number - b.number) +} + +/** + * Iterations an issue can be assigned to (everything but breaks), in calendar order. + * @public + */ +export function getAssignableIterations> ( + list: readonly T[] +): T[] { + return sortIterations(list.filter((it) => it.isBreak !== true)) +} + +/** + * The assignable iterations in the form the filter grammar resolves `@current` and friends against. + * @public + */ +export function toIterationRanges ( + list: ReadonlyArray> +): IterationRange[] { + return getAssignableIterations(list).map((it) => ({ + id: it._id, + title: it.label, + start: it.startDate, + end: iterationEnd(it) + })) +} + +/** + * @public + */ +export type IterationKeyword = 'current' | 'next' | 'previous' + +/** + * Resolves `@current`, `@next` and `@previous` with a `+N` / `-N` offset against the assignable + * iterations. Breaks are skipped: during a break there is no current iteration, the next one is the + * first that starts later and the previous one the last that has ended. Undefined when there is none. + * @public + */ +export function resolveRelativeIteration< + T extends Pick +> (list: readonly T[], keyword: IterationKeyword, offset: number, now: number): T | undefined { + const sorted = getAssignableIterations(list) + const current = sorted.findIndex((it) => getIterationState(it, now) === 'current') + let base: number + if (current >= 0) { + base = keyword === 'current' ? current : keyword === 'next' ? current + 1 : current - 1 + } else if (keyword === 'current') { + return undefined + } else if (keyword === 'next') { + base = sorted.findIndex((it) => it.startDate > now) + } else { + base = -1 + sorted.forEach((it, i) => { + if (getIterationState(it, now) === 'completed') base = i + }) + } + if (base < 0) return undefined + return sorted[base + offset] +} + +/** + * Iterations split by where they are in time. At most one iteration (or break) is current. + * Every list is in calendar order and may contain breaks. + * @public + */ +export interface IterationsByState { + completed: T[] + current: T | undefined + planned: T[] +} + +/** + * @public + */ +export function groupIterationsByState> ( + list: readonly T[], + now: number +): IterationsByState { + const res: IterationsByState = { completed: [], current: undefined, planned: [] } + for (const it of sortIterations(list)) { + switch (getIterationState(it, now)) { + case 'completed': + res.completed.push(it) + break + case 'current': + res.current = res.current ?? it + break + default: + res.planned.push(it) + } + } + return res +} + +// ---- duration units ---- + +/** + * @public + */ +export type IterationDurationUnit = 'days' | 'weeks' + +/** + * @public + */ +export function durationToDays (amount: number, unit: IterationDurationUnit): number { + return Math.round(amount) * (unit === 'weeks' ? 7 : 1) +} + +/** + * The most natural unit of a duration: whole weeks when possible, days otherwise. + * @public + */ +export function daysToDuration (days: number): { amount: number, unit: IterationDurationUnit } { + return days >= 7 && days % 7 === 0 ? { amount: days / 7, unit: 'weeks' } : { amount: days, unit: 'days' } +} + +// ---- planning changes ---- + +/** + * @public + */ +export type IterationPlanError = 'overlap' | 'invalidDuration' | 'emptyLabel' | 'unknown' + +/** + * @public + */ +export interface IterationUpdate { + id: Ref + update: Partial> +} + +/** + * @public + */ +export type IterationPlan = + | ({ ok: true, updates: IterationUpdate[] } & T) + | { ok: false, error: IterationPlanError } + +/** + * @public + */ +export type IterationChange = Partial> + +type Planned = Pick + +function isValidDuration (days: number): boolean { + return Number.isInteger(days) && days >= 1 +} + +/** + * Whether two iterations of one field share a day. Returns the ids of the first pair found. + * @public + */ +export function findOverlap ( + list: ReadonlyArray> +): [Ref, Ref] | undefined { + const sorted = sortIterations(list) + for (let i = 1; i < sorted.length; i++) { + if (sorted[i].startDate < iterationEndExclusive(sorted[i - 1])) return [sorted[i - 1]._id, sorted[i]._id] + } + return undefined +} + +/** + * Change the title, start date or duration of one iteration. When its end moves, every later + * iteration moves by the same number of days, so the gaps between them stay and nothing overlaps. + * A new start date that would run into the previous iteration is rejected. + * @public + */ +export function planIterationChange ( + list: readonly Planned[], + id: Ref, + change: IterationChange +): IterationPlan { + const sorted = sortIterations(list) + const index = sorted.findIndex((it) => it._id === id) + if (index < 0) return { ok: false, error: 'unknown' } + const old = sorted[index] + const label = change.label !== undefined ? change.label.trim() : old.label + if (label === '') return { ok: false, error: 'emptyLabel' } + const startDate = change.startDate !== undefined ? startOfDay(change.startDate) : old.startDate + const duration = change.duration ?? old.duration + if (!isValidDuration(duration)) return { ok: false, error: 'invalidDuration' } + if (index > 0 && startDate < iterationEndExclusive(sorted[index - 1])) return { ok: false, error: 'overlap' } + + const own: IterationUpdate['update'] = {} + if (label !== old.label) own.label = label + if (startDate !== old.startDate) own.startDate = startDate + if (duration !== old.duration) own.duration = duration + const updates: IterationUpdate[] = Object.keys(own).length > 0 ? [{ id, update: own }] : [] + + const shift = daysBetween(iterationEndExclusive(old), iterationEndExclusive({ startDate, duration })) + if (shift !== 0) { + for (const next of sorted.slice(index + 1)) { + updates.push({ id: next._id, update: { startDate: addDays(next.startDate, shift) } }) + } + } + return { ok: true, updates } +} + +/** + * @public + */ +export interface AddIterationOptions { + // Insert after this iteration; at the end when absent + afterId?: Ref + isBreak?: boolean + // Days; defaults to the duration of the last iteration, or a week + duration?: number + label?: string + // Start of the first iteration when there is none yet; defaults to today + startDate?: number + now: number +} + +/** + * A new iteration or break right after another one (or at the end). Everything that starts at or + * after the new start moves by its duration. The result carries the values of the new document. + * @public + */ +export function planAddIteration ( + list: readonly Planned[], + options: AddIterationOptions +): IterationPlan<{ draft: IterationDraft }> { + const sorted = sortIterations(list) + const isBreak = options.isBreak === true + const lastIteration = [...sorted].reverse().find((it) => it.isBreak !== true) + const duration = options.duration ?? lastIteration?.duration ?? DEFAULT_ITERATION_DURATION + if (!isValidDuration(duration)) return { ok: false, error: 'invalidDuration' } + + let startDate: number + let following: Planned[] = [] + if (options.afterId === undefined) { + const last = sorted[sorted.length - 1] + startDate = last !== undefined ? iterationEndExclusive(last) : startOfDay(options.startDate ?? options.now) + } else { + const index = sorted.findIndex((it) => it._id === options.afterId) + if (index < 0) return { ok: false, error: 'unknown' } + startDate = iterationEndExclusive(sorted[index]) + following = sorted.slice(index + 1) + } + + const number = isBreak ? 0 : Math.max(0, ...sorted.filter((it) => it.isBreak !== true).map((it) => it.number)) + 1 + const defaultLabel = isBreak ? 'Break' : `Iteration ${number}` + const label = options.label?.trim() !== undefined && options.label.trim() !== '' ? options.label.trim() : defaultLabel + const updates: IterationUpdate[] = following.map((it) => ({ + id: it._id, + update: { startDate: addDays(it.startDate, duration) } + })) + const draft: IterationDraft = isBreak + ? { label, number, startDate, duration, isBreak: true } + : { label, number, startDate, duration } + return { ok: true, updates, draft } +} + +/** + * @public + */ +export interface InitialIterationsOptions { + now: number + // Days, default a week + duration?: number + // Default 3, like GitHub + count?: number + // First day, default today + startDate?: number + // Title of the n-th iteration (1-based), default `Iteration n` + labelOf?: (n: number) => string +} + +/** + * The iterations a new Iteration field starts with: consecutive, without gaps, the first one starting today. + * @public + */ +export function generateInitialIterations (options: InitialIterationsOptions): IterationDraft[] { + const duration = options.duration !== undefined && isValidDuration(options.duration) ? options.duration : DEFAULT_ITERATION_DURATION + const count = options.count ?? DEFAULT_INITIAL_ITERATIONS + let start = startOfDay(options.startDate ?? options.now) + const res: IterationDraft[] = [] + for (let n = 1; n <= count; n++) { + res.push({ label: options.labelOf?.(n) ?? `Iteration ${n}`, number: n, startDate: start, duration }) + start = addDays(start, duration) + } + return res +} + +// ---- values stored on issues ---- + +type CustomFields = Record | undefined + +/** + * New `customFields` for an issue after the iteration was deleted or the item moved: the value of + * the field is replaced by `to` (a removal when `to` is null). Returns undefined when the issue does + * not have `from` in that field, i.e. nothing has to change. + * @public + */ +export function replaceIterationValue ( + customFields: CustomFields, + fieldKey: string, + from: string, + to: string | null +): Record | undefined { + if (customFields === undefined || customFields[fieldKey] !== from) return undefined + const { [fieldKey]: _removed, ...rest } = customFields + return to === null ? rest : { ...rest, [fieldKey]: to } +} + +/** + * New `customFields` for an issue after iterations were deleted: every field in `dropped` (field key to + * the ids of its deleted iterations) loses its value when it is one of them. Undefined when nothing changes. + * @public + */ +export function stripIterationValues ( + customFields: CustomFields, + dropped: ReadonlyMap> +): Record | undefined { + if (customFields === undefined) return undefined + let result: Record | undefined + for (const [key, ids] of dropped) { + const value = (result ?? customFields)[key] + if (typeof value !== 'string' || !ids.has(value)) continue + result = result ?? { ...customFields } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete result[key] + } + return result +} + +/** + * The updates that move every item of an iteration to another one (or to none). + * @public + */ +export function planMoveItems }> ( + issues: readonly T[], + fieldKey: string, + from: string, + to: string | null +): Array<{ issue: T, customFields: Record }> { + if (from === to) return [] + const res: Array<{ issue: T, customFields: Record }> = [] + for (const issue of issues) { + const customFields = replaceIterationValue(issue.customFields, fieldKey, from, to) + if (customFields !== undefined) res.push({ issue, customFields }) + } + return res +} + +// ---- group by / sort by ---- + +/** + * Group values for an issue list grouped by an Iteration field: iterations in calendar order (only + * those with issues unless `includeEmpty`), then ids that no iteration answers to (a deleted + * iteration), and `undefined` (the "No iteration" group) last. + * @public + */ +export function buildIterationGroupCategories ( + iterations: ReadonlyArray>, + docs: ReadonlyArray<{ customFields?: Record }>, + fieldKey: string, + includeEmpty: boolean +): Array { + const used = new Set() + let hasEmpty = false + for (const doc of docs) { + const v = doc.customFields?.[fieldKey] + if (typeof v === 'string' && v !== '') used.add(v) + else hasEmpty = true + } + const result: Array = [] + for (const it of getAssignableIterations(iterations)) { + if (includeEmpty || used.has(it._id)) result.push(it._id) + used.delete(it._id) + } + result.push(...used) + if (hasEmpty || includeEmpty) result.push(undefined) + return result +} + +/** + * Start dates by iteration id, to order issues by iteration. + * @public + */ +export function iterationStartMap ( + iterations: ReadonlyArray> +): ReadonlyMap { + return new Map(iterations.map((it) => [it._id, it.startDate])) +} + +// ---- display ---- + +/** + * "Oct 3 – Oct 9", with the year when a date is not in the year of `now`. + * @public + */ +export function formatIterationRange (it: IterationSpan, now: number, locale?: string): string { + const start = new Date(it.startDate) + const end = new Date(iterationEnd(it)) + const thisYear = new Date(now).getFullYear() + const withYear = start.getFullYear() !== thisYear || end.getFullYear() !== thisYear + const format = new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + ...(withYear ? { year: 'numeric' } : {}) + }) + return `${format.format(start)} – ${format.format(end)}` +} diff --git a/plugins/tracker/src/iterationRollup.ts b/plugins/tracker/src/iterationRollup.ts new file mode 100644 index 0000000000..29f51a1a87 --- /dev/null +++ b/plugins/tracker/src/iterationRollup.ts @@ -0,0 +1,65 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +/** + * Totals of the issues in one iteration group. + * @public + */ +export interface IterationRollup { + // Issues in the group + count: number + // Issues whose status is completed (category Won) + done: number + // Sum of the estimations, in hours + estimation: number +} + +/** + * @public + */ +export interface RollupIssue { + status: string + estimation?: number + customFields?: Record +} + +/** @public */ +export const EMPTY_ROLLUP: IterationRollup = { count: 0, done: 0, estimation: 0 } + +/** + * Totals of a list of issues. `doneStatuses` are the ids of the statuses of category Won. + * @public + */ +export function computeRollup (issues: readonly RollupIssue[], doneStatuses: ReadonlySet): IterationRollup { + const res: IterationRollup = { count: 0, done: 0, estimation: 0 } + for (const issue of issues) { + res.count++ + if (doneStatuses.has(issue.status)) res.done++ + if (typeof issue.estimation === 'number' && Number.isFinite(issue.estimation)) res.estimation += issue.estimation + } + return res +} + +/** + * Totals per iteration of one Iteration field. The key `undefined` is the group of issues without an iteration. + * @public + */ +export function computeIterationRollups ( + issues: readonly RollupIssue[], + fieldKey: string, + doneStatuses: ReadonlySet +): Map { + const groups = new Map() + for (const issue of issues) { + const raw = issue.customFields?.[fieldKey] + const key = typeof raw === 'string' && raw !== '' ? raw : undefined + const list = groups.get(key) + if (list === undefined) groups.set(key, [issue]) + else list.push(issue) + } + const res = new Map() + for (const [key, list] of groups) res.set(key, computeRollup(list, doneStatuses)) + return res +} diff --git a/plugins/tracker/src/projectCopy.ts b/plugins/tracker/src/projectCopy.ts new file mode 100644 index 0000000000..1d13e659f9 --- /dev/null +++ b/plugins/tracker/src/projectCopy.ts @@ -0,0 +1,264 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { AccountUuid, Class, Doc, PersonId, Ref, Space } from '@hcengineering/core' +import type { FilteredView } from '@hcengineering/view' +import type { Project, WorkingDaysConfig } from './index' +import type { InsightChart } from './insightChart' +import { addDays, daysBetween, startOfDay, type Iteration } from './iteration' +import { MAX_PROJECT_FIELDS, type ProjectField } from './projectField' +import type { Workflow } from './workflow' + +// Copying a project (GitHub "Copy project", "Use this template"). GitHub copies the fields, views, workflows and +// Insights charts of a project (and, as an option, its draft items). The copy is built as a plan first, a plain list +// of documents to create, so that the id remapping can be tested without a server and the whole copy can be applied +// in one batch together with the new project. +// +// - Fields keep their `key` (items and views address a custom field by its key, `customFields.`) and the +// values of their select options, only the document ids are new. +// - Iterations are recreated for the new Iteration fields, moved by whole days so that the first one of each field +// starts today (durations, gaps and breaks stay as they are). +// - Views keep everything they are made of. Whatever refers to a document id of the source (iteration ids in column +// limits and filters, the project in the location) is rewritten to the new ids. +// - Workflows keep their enabled state, filter and target (they refer to the field key and the option value). +// - Insights charts are copied as they are (they refer to the field key too). +// - Issues are NOT copied (`includeIssues` is not supported): Huly has no draft items, an issue belongs to exactly +// one project and numbering, status and parent links would have to be rebuilt. + +/** The project documents a copy is made from. @public */ +export interface ProjectCopySource { + project: Pick + fields: readonly ProjectField[] + iterations: readonly Iteration[] + views: readonly FilteredView[] + workflows: readonly Workflow[] + charts: readonly InsightChart[] +} + +/** + * The classes of the documents a copy creates. They are passed in so that this module stays free of runtime imports + * (the plugin ids live in `tracker` and `view`, which would make a cycle here). + * @public + */ +export interface ProjectCopyClasses { + field: Ref> + iteration: Ref> + view: Ref> + workflow: Ref> + chart: Ref> +} + +/** @public */ +export interface ProjectCopyOptions { + classes: ProjectCopyClasses + // The new project + target: Ref + now: number + // Source of the ids of the new documents + generateId: () => string + // Owner of the copied views + author: { account: AccountUuid, person: PersonId } +} + +/** One document to create. @public */ +export interface ProjectCopyOp { + _class: Ref> + space: Ref + id: Ref + data: Record +} + +/** @public */ +export interface ProjectCopyPlan { + // Properties of the new project that come from the source + projectData: { shortDescription?: string, readme?: string, workingDaysConfig?: WorkingDaysConfig } + // Everything to create after the project, in the order it has to be created + ops: ProjectCopyOp[] + // Old id -> new id + fieldIds: ReadonlyMap + iterationIds: ReadonlyMap + viewIds: ReadonlyMap +} + +// Ids shorter than this are never replaced inside text (they could be part of anything) +const MIN_REPLACED_ID_LENGTH = 8 + +function clone (value: T): T { + return value === undefined ? value : JSON.parse(JSON.stringify(value)) +} + +/** + * Replace every occurrence of the old ids in the text by the new ones. The ids are random, so a plain replacement is + * safe; it also reaches ids inside the JSON strings the views store their filters in. + */ +function replaceIds (text: string, pairs: ReadonlyArray): string { + let result = text + for (const [from, to] of pairs) { + if (from.length < MIN_REPLACED_ID_LENGTH || from === to) continue + result = result.split(from).join(to) + } + return result +} + +function remapJson (value: T, pairs: ReadonlyArray): T { + if (value === undefined) return value + return JSON.parse(replaceIds(JSON.stringify(value), pairs)) +} + +function withoutUndefined (data: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) result[key] = value + } + return result +} + +/** + * Plan of the copy of a project: the documents to create in the new project, with every id remapped. + * @public + */ +export function buildProjectCopyPlan (source: ProjectCopySource, options: ProjectCopyOptions): ProjectCopyPlan { + const { target, now, generateId, author, classes } = options + const ops: ProjectCopyOp[] = [] + + // ---- fields ---- + const fieldIds = new Map() + const fields = [...source.fields].sort((a, b) => a.position - b.position).slice(0, MAX_PROJECT_FIELDS) + for (const field of fields) { + const id = generateId() + fieldIds.set(field._id, id) + ops.push({ + _class: classes.field, + space: target, + id: id as Ref, + data: withoutUndefined({ + label: field.label, + key: field.key, + type: field.type, + position: field.position, + description: field.description, + defaultValue: field.defaultValue, + options: clone(field.options) + }) + }) + } + + // ---- iterations ---- + const iterationIds = new Map() + const today = startOfDay(now) + const firstStart = new Map() + for (const it of source.iterations) { + const first = firstStart.get(it.field) + if (first === undefined || it.startDate < first) firstStart.set(it.field, it.startDate) + } + const iterations = [...source.iterations].sort((a, b) => a.startDate - b.startDate || a.number - b.number) + for (const it of iterations) { + const newField = fieldIds.get(it.field) + // The iterations of a field that was not copied have nowhere to go + if (newField === undefined) continue + const id = generateId() + iterationIds.set(it._id, id) + const shift = daysBetween(firstStart.get(it.field) ?? it.startDate, today) + ops.push({ + _class: classes.iteration, + space: target, + id: id as Ref, + data: withoutUndefined({ + field: newField, + label: it.label, + number: it.number, + startDate: addDays(it.startDate, shift), + duration: it.duration, + isBreak: it.isBreak + }) + }) + } + + const idPairs: Array = [ + [source.project._id, target], + ...fieldIds.entries(), + ...iterationIds.entries() + ] + + // ---- views ---- + const viewIds = new Map() + const views = [...source.views].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + for (const doc of views) { + const id = generateId() + viewIds.set(doc._id, id) + ops.push({ + _class: classes.view, + space: target, + id: id as Ref, + data: withoutUndefined({ + name: doc.name, + location: remapJson(doc.location, idPairs), + filters: replaceIds(doc.filters, idPairs), + viewOptions: remapJson(doc.viewOptions, idPairs), + filterClass: doc.filterClass, + viewletId: doc.viewletId, + sharable: true, + users: [author.account], + createdBy: author.person, + attachedTo: target, + project: target, + config: remapJson(doc.config, idPairs), + order: doc.order, + extra: doc.extra !== undefined ? replaceIds(doc.extra, idPairs) : undefined, + filterQuery: doc.filterQuery + }) + }) + } + + // ---- workflows (one per kind, the oldest wins like everywhere else) ---- + const seenKinds = new Set() + for (const wf of [...source.workflows].sort((a, b) => (a.createdOn ?? 0) - (b.createdOn ?? 0))) { + if (seenKinds.has(wf.kind)) continue + seenKinds.add(wf.kind) + ops.push({ + _class: classes.workflow, + space: target, + id: generateId() as Ref, + data: withoutUndefined({ + name: wf.name, + enabled: wf.enabled, + kind: wf.kind, + filter: wf.filter, + config: clone(wf.config) + }) + }) + } + + // ---- Insights charts ---- + for (const chart of [...source.charts].sort((a, b) => a.position - b.position)) { + ops.push({ + _class: classes.chart, + space: target, + id: generateId() as Ref, + data: withoutUndefined({ + name: chart.name, + layout: chart.layout, + xField: chart.xField, + xBucket: chart.xBucket, + groupField: chart.groupField, + yAggregate: clone(chart.yAggregate), + filter: chart.filter, + position: chart.position + }) + }) + } + + return { + projectData: withoutUndefined({ + shortDescription: source.project.shortDescription, + readme: source.project.readme, + workingDaysConfig: clone(source.project.workingDaysConfig) + }), + ops, + fieldIds, + iterationIds, + viewIds + } +} diff --git a/plugins/tracker/src/projectField.ts b/plugins/tracker/src/projectField.ts new file mode 100644 index 0000000000..c4fd640d4a --- /dev/null +++ b/plugins/tracker/src/projectField.ts @@ -0,0 +1,206 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref } from '@hcengineering/core' +import type { Project } from './index' + +/** + * Custom field kinds, mirroring GitHub's `ProjectV2CustomFieldType`. + * @public + */ +export enum ProjectFieldType { + Text = 'text', + Number = 'number', + Date = 'date', + SingleSelect = 'singleSelect', + MultiSelect = 'multiSelect', + Iteration = 'iteration' +} + +/** + * @public + */ +export interface ProjectFieldOption { + // Stable id stored in issue.customFields; never changes when the label is renamed + value: string + label: string + color?: number + description?: string +} + +/** + * User-defined field of a tracker project. Values live in `Issue.customFields[key]`. + * @public + */ +export interface ProjectField extends Doc { + space: Ref + label: string + // Slug, unique per project + key: string + type: ProjectFieldType + position: number + description?: string + // Not supported for Date fields (GitHub parity) + defaultValue?: string | number | null + // SingleSelect / MultiSelect only + options?: ProjectFieldOption[] +} + +/** + * Value kinds stored in `Issue.customFields`. + * @public + */ +export type ProjectFieldValue = string | number | string[] | null + +/** @public */ +export const MAX_PROJECT_FIELDS = 50 +/** @public */ +export const MAX_PROJECT_FIELD_OPTIONS = 50 + +/** + * Turn a label into a camelCase key, e.g. "Story points" -> "storyPoints". + * Returns an empty string when the label has no alphanumerics. + * @public + */ +export function slugifyFieldLabel (label: string): string { + const words = label + .normalize('NFKD') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .split(/\s+/) + .filter((w) => w.length > 0) + if (words.length === 0) return '' + const [first, ...rest] = words + const slug = [first.toLowerCase(), ...rest.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase())].join('') + // Keys must not start with a digit so they stay usable as property names + return /^\d/.test(slug) ? `f${slug}` : slug +} + +/** + * Pick a key based on the label that does not collide with `taken`. + * @public + */ +export function generateFieldKey (label: string, taken: Iterable): string { + const used = new Set(taken) + const base = slugifyFieldLabel(label) + if (base === '') return '' + if (!used.has(base)) return base + let i = 2 + while (used.has(`${base}${i}`)) i++ + return `${base}${i}` +} + +/** + * @public + */ +export type ProjectFieldValidationError = + | 'emptyLabel' + | 'duplicateLabel' + | 'tooManyFields' + | 'tooManyOptions' + | 'duplicateOption' + | 'emptyOption' + | 'optionsRequired' + | 'defaultNotAllowed' + | 'invalidDefault' + +/** + * Validate a field definition against the other fields of the same project. + * `existing` must not include the field being edited. + * @public + */ +export function validateProjectField ( + field: Pick, + existing: Array> +): ProjectFieldValidationError | undefined { + const label = field.label.trim() + if (label === '') return 'emptyLabel' + if (existing.length >= MAX_PROJECT_FIELDS) return 'tooManyFields' + if (existing.some((f) => f.label.trim().toLowerCase() === label.toLowerCase())) return 'duplicateLabel' + + const isSelect = field.type === ProjectFieldType.SingleSelect || field.type === ProjectFieldType.MultiSelect + if (isSelect) { + const options = field.options ?? [] + if (options.length === 0) return 'optionsRequired' + if (options.length > MAX_PROJECT_FIELD_OPTIONS) return 'tooManyOptions' + const seen = new Set() + for (const o of options) { + const l = o.label.trim().toLowerCase() + if (l === '') return 'emptyOption' + if (seen.has(l)) return 'duplicateOption' + seen.add(l) + } + } + + const def = field.defaultValue + if (def !== undefined && def !== null) { + switch (field.type) { + case ProjectFieldType.Text: + if (typeof def !== 'string') return 'invalidDefault' + break + case ProjectFieldType.Number: + if (typeof def !== 'number' || !Number.isFinite(def)) return 'invalidDefault' + break + case ProjectFieldType.SingleSelect: + if (typeof def !== 'string' || !(field.options ?? []).some((o) => o.value === def)) return 'invalidDefault' + break + default: + return 'defaultNotAllowed' + } + } + return undefined +} + +/** + * Coerce an arbitrary stored value to the shape the field type expects. + * Returns `null` for anything that does not fit, so stale values (e.g. a removed + * select option) never crash a presenter. + * @public + */ +export function normalizeFieldValue (field: Pick, value: unknown): ProjectFieldValue { + if (value === undefined || value === null) return null + switch (field.type) { + case ProjectFieldType.Text: + return typeof value === 'string' ? value : null + case ProjectFieldType.Number: + return typeof value === 'number' && Number.isFinite(value) ? value : null + case ProjectFieldType.Date: + return typeof value === 'number' && Number.isFinite(value) ? value : null + case ProjectFieldType.SingleSelect: + return typeof value === 'string' && (field.options ?? []).some((o) => o.value === value) ? value : null + case ProjectFieldType.MultiSelect: { + if (!Array.isArray(value)) return null + const valid = new Set((field.options ?? []).map((o) => o.value)) + const kept = value.filter((v): v is string => typeof v === 'string' && valid.has(v)) + return kept.length > 0 ? kept : null + } + case ProjectFieldType.Iteration: + return typeof value === 'string' ? value : null + } +} + +/** + * Read one field value from an issue's `customFields` record. + * @public + */ +export function getFieldValue ( + customFields: Record | undefined, + field: Pick +): ProjectFieldValue { + return normalizeFieldValue(field, customFields?.[field.key]) +} + +/** + * Return the part of `fields` that exceeds the per-project limit, oldest kept. + * Used by the server to drop fields created by a client that bypassed the UI check. + * @public + */ +export function overLimitFields & { _id: unknown, createdOn?: number }> ( + fields: T[] +): T[] { + if (fields.length <= MAX_PROJECT_FIELDS) return [] + const sorted = [...fields].sort((a, b) => (a.createdOn ?? 0) - (b.createdOn ?? 0)) + return sorted.slice(MAX_PROJECT_FIELDS) +} diff --git a/plugins/tracker/src/projectSettings.ts b/plugins/tracker/src/projectSettings.ts new file mode 100644 index 0000000000..7f31ba1aae --- /dev/null +++ b/plugins/tracker/src/projectSettings.ts @@ -0,0 +1,86 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Markup } from '@hcengineering/core' +import type { Project } from './index' + +/** Items one project can hold, like GitHub Projects. @public */ +export const MAX_PROJECT_ITEMS = 50000 +/** Longest short description of a project, like GitHub. @public */ +export const MAX_PROJECT_SHORT_DESCRIPTION = 256 +/** Longest README (characters of the stored markup). Our own limit, GitHub does not state one. @public */ +export const MAX_PROJECT_README = 100000 + +/** + * Whether a project that holds `count` items cannot take another one. + * @public + */ +export function isProjectItemLimitReached (count: number, limit: number = MAX_PROJECT_ITEMS): boolean { + return count >= limit +} + +/** + * The newly created items a project has to give up again because it holds more than `limit` after the batch. + * `created` are the ids of the items the batch created, in the order they were created; the newest go first. + * @public + */ +export function itemsOverProjectLimit ( + created: readonly T[], + total: number, + limit: number = MAX_PROJECT_ITEMS +): T[] { + const excess = total - limit + if (excess <= 0 || created.length === 0) return [] + return created.slice(Math.max(0, created.length - excess)) +} + +/** + * @public + */ +export type ProjectDetailsError = 'shortDescriptionTooLong' | 'readmeTooLong' + +/** + * Check the short description and the README of a project. + * @public + */ +export function validateProjectDetails (details: { + shortDescription?: string + readme?: Markup +}): ProjectDetailsError | undefined { + if ((details.shortDescription ?? '').length > MAX_PROJECT_SHORT_DESCRIPTION) return 'shortDescriptionTooLong' + if ((details.readme ?? '').length > MAX_PROJECT_README) return 'readmeTooLong' + return undefined +} + +/** + * The short description to show: the one of the project, or the description of the space of projects that were + * created before the short description existed. + * @public + */ +export function projectShortDescription (project: Pick): string { + const own = project.shortDescription?.trim() ?? '' + if (own !== '') return own + return (project.description ?? '').trim() +} + +/** + * Whether the text typed to confirm the deletion is the name of the project (GitHub asks for the name). + * @public + */ +export function isDeleteConfirmed (projectName: string, typed: string): boolean { + const name = projectName.trim() + return name !== '' && typed.trim() === name +} + +/** + * A private project needs members, and at least one owner who is a member, or nobody could open it (the same rule as + * the form that creates a project). + * @public + */ +export function canMakeProjectPrivate (project: Pick): boolean { + const members = project.members ?? [] + if (members.length === 0) return false + return (project.owners ?? []).some((owner) => members.includes(owner)) +} diff --git a/plugins/tracker/src/projectStatus.ts b/plugins/tracker/src/projectStatus.ts new file mode 100644 index 0000000000..7622f66dac --- /dev/null +++ b/plugins/tracker/src/projectStatus.ts @@ -0,0 +1,95 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Markup, Ref, Timestamp } from '@hcengineering/core' +import type { Project } from './index' + +/** + * Status of a project, the values of GitHub's `ProjectV2StatusUpdateStatus`. + * @public + */ +export enum ProjectStatus { + Inactive = 'INACTIVE', + OnTrack = 'ON_TRACK', + AtRisk = 'AT_RISK', + OffTrack = 'OFF_TRACK', + Complete = 'COMPLETE' +} + +/** + * A status update of a project (GitHub "Project status updates"). `space` is the project, the author is + * `createdBy` / `modifiedBy` of the document. The latest update gives the status of the project. + * @public + */ +export interface ProjectStatusUpdate extends Doc { + space: Ref + status: ProjectStatus + // Start and target of the period the update is about; null once cleared (an update cannot remove a property) + startDate?: Timestamp | null + targetDate?: Timestamp | null + body: Markup +} + +/** All statuses in the order GitHub lists them. @public */ +export const PROJECT_STATUSES: readonly ProjectStatus[] = [ + ProjectStatus.Inactive, + ProjectStatus.OnTrack, + ProjectStatus.AtRisk, + ProjectStatus.OffTrack, + ProjectStatus.Complete +] + +/** Longest body of a status update (characters of the stored markup). Our own limit, GitHub does not state one. @public */ +export const MAX_STATUS_UPDATE_BODY = 65536 + +/** + * @public + */ +export type ProjectStatusUpdateError = 'invalidStatus' | 'invalidDates' | 'bodyTooLong' + +/** + * Check the values of a status update before it is stored. + * @public + */ +export function validateStatusUpdate ( + update: Pick +): ProjectStatusUpdateError | undefined { + if (!PROJECT_STATUSES.includes(update.status)) return 'invalidStatus' + const { startDate, targetDate } = update + if (startDate != null && targetDate != null && targetDate < startDate) return 'invalidDates' + if ((update.body ?? '').length > MAX_STATUS_UPDATE_BODY) return 'bodyTooLong' + return undefined +} + +/** + * Newest first (by creation time, the id breaks ties so the order is stable). + * @public + */ +export function sortStatusUpdates> (list: readonly T[]): T[] { + return [...list].sort((a, b) => (b.createdOn ?? 0) - (a.createdOn ?? 0) || String(b._id).localeCompare(String(a._id))) +} + +/** + * The latest update, which gives the status of the project. Undefined when there is none. + * @public + */ +export function latestStatusUpdate> ( + list: readonly T[] +): T | undefined { + return sortStatusUpdates(list)[0] +} + +/** + * Whether the person may edit or delete the update: its author, or someone who manages the project. + * @public + */ +export function canModifyStatusUpdate ( + update: Pick, + authorIds: readonly string[], + canManageProject: boolean +): boolean { + if (canManageProject) return true + return update.createdBy !== undefined && authorIds.includes(update.createdBy as string) +} diff --git a/plugins/tracker/src/viewExport.ts b/plugins/tracker/src/viewExport.ts new file mode 100644 index 0000000000..52deda8f2d --- /dev/null +++ b/plugins/tracker/src/viewExport.ts @@ -0,0 +1,152 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// "Export view data" (GitHub Projects): the rows of a view as a tab separated file. Pure helpers, kept free of the +// platform and the UI so that they can be unit tested. + +/** Largest number of rows an export holds: the most items a project can have. @public */ +export const MAX_EXPORT_ROWS = 50000 + +/** + * A value of an exported cell. Lists are joined with `, `, `Date` is written as an ISO 8601 timestamp, `null` and + * `undefined` are empty. A number stays a plain number (it is never taken for a formula, even a negative one). + * @public + */ +export type TsvValue = string | number | boolean | Date | null | undefined | readonly TsvValue[] + +/** A column of the exported table. @public */ +export interface TsvColumn { + header: string +} + +/** @public */ +export interface TsvOptions { + /** + * Prefix text cells that a spreadsheet would read as a formula (they start with `=`, `+`, `-` or `@`) with a single + * quote. On by default. GitHub does not do this; it is a safe default for files that end up in Excel or Sheets. + */ + escapeFormulas?: boolean +} + +const FORMULA_START = /^[=+\-@]/ +// A tab or a line break would move the text to another cell or row +const SEPARATORS = /[\t\r\n\u2028\u2029]+/g + +function textOf (value: TsvValue): { text: string, numeric: boolean } { + if (value === null || value === undefined) return { text: '', numeric: false } + if (typeof value === 'number') return { text: Number.isFinite(value) ? String(value) : '', numeric: true } + if (typeof value === 'boolean') return { text: value ? 'true' : 'false', numeric: false } + if (value instanceof Date) return { text: Number.isNaN(value.getTime()) ? '' : value.toISOString(), numeric: false } + if (typeof value === 'string') return { text: value, numeric: false } + const parts: string[] = [] + for (const item of value) { + const part = textOf(item).text.replace(SEPARATORS, ' ').trim() + if (part !== '') parts.push(part) + } + return { text: parts.join(', '), numeric: false } +} + +/** + * The text of one cell: separators replaced by a space, lists joined, formulas neutralised. + * @public + */ +export function formatTsvCell (value: TsvValue, escapeFormulas: boolean = true): string { + const { text, numeric } = textOf(value) + const clean = text.replace(SEPARATORS, ' ') + return escapeFormulas && !numeric && FORMULA_START.test(clean) ? `'${clean}` : clean +} + +/** + * Builds the file: a header row of the column names, then one line per row, cells separated by a tab. A tab or a line + * break inside a value is replaced by a space, so every row is exactly one line. A row with fewer cells than there are + * columns is padded with empty cells, extra cells are ignored. The text ends with a line break. + * @public + */ +export function buildViewTsv ( + columns: readonly TsvColumn[], + rows: ReadonlyArray, + options: TsvOptions = {} +): string { + const escape = options.escapeFormulas !== false + const width = columns.length + const lines: string[] = new Array(rows.length + 1) + lines[0] = columns.map((c) => formatTsvCell(c.header, escape)).join('\t') + for (let r = 0; r < rows.length; r++) { + const row = rows[r] + const cells: string[] = new Array(width) + for (let c = 0; c < width; c++) cells[c] = formatTsvCell(row[c], escape) + lines[r + 1] = cells.join('\t') + } + return lines.join('\n') + '\n' +} + +/** + * Name of the downloaded file: the project and the view, without characters that file systems refuse. + * @public + */ +export function viewExportFileName (project: string, view: string): string { + const clean = (text: string): string => + text + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + const parts = [clean(project), clean(view)].filter((it) => it !== '') + return `${parts.length > 0 ? parts.join(' - ') : 'view'}.tsv` +} + +/** + * A group of a list as the list builds it: a plain value (`undefined` for "no value"), or a category that stands for + * several values (the same status of several projects) and lists the documents that are its values. + * @public + */ +export type GroupCategory = undefined | null | string | number | { name?: unknown, values: ReadonlyArray<{ _id: unknown }> } + +/** + * One level of grouping: the groups in the order the list shows them and how to read the group value of a row. + * @public + */ +export interface GroupLevel { + categories: readonly GroupCategory[] + valueOf: (row: T) => unknown +} + +function isEmpty (value: unknown): boolean { + return value === undefined || value === null || value === '' +} + +function inCategory (category: GroupCategory, value: unknown): boolean { + const candidates = Array.isArray(value) ? value : [value] + if (category !== null && typeof category === 'object') { + return candidates.some((v) => category.values.some((it) => it._id === v)) + } + if (category === undefined || category === null) return candidates.every(isEmpty) + return candidates.some((v) => v === category) +} + +/** + * Puts rows in the order a grouped list shows them: group after group (the first level in the order of its + * categories, inside it the next level), and inside the last group the order the rows come in. Every row appears + * once, in the first group it fits; rows that fit none go to the end. Without levels the rows are returned as they are. + * @public + */ +export function orderRowsByGroups (rows: readonly T[], levels: ReadonlyArray>): T[] { + if (levels.length === 0) return [...rows] + const [level, ...rest] = levels + const buckets: T[][] = level.categories.map(() => []) + const unplaced: T[] = [] + for (const row of rows) { + const value = level.valueOf(row) + const index = level.categories.findIndex((c) => inCategory(c, value)) + if (index === -1) unplaced.push(row) + else buckets[index].push(row) + } + // Appended one by one: spreading a big array into `push` would overflow the call stack + const result: T[] = [] + for (const bucket of [...buckets, unplaced]) { + for (const row of orderRowsByGroups(bucket, rest)) result.push(row) + } + return result +} diff --git a/plugins/tracker/src/webhook.ts b/plugins/tracker/src/webhook.ts new file mode 100644 index 0000000000..8ae337a0ac --- /dev/null +++ b/plugins/tracker/src/webhook.ts @@ -0,0 +1,240 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref } from '@hcengineering/core' +import type { Project } from './index' + +// Project webhooks, modelled after GitHub's `projects_v2_item` event. This file has what the settings UI and the +// server share: the stored docs, and the URL rules (the server adds the DNS check, see `isPublicAddress`). + +/** + * What happened to an item; the `action` of GitHub's `projects_v2_item` event (`converted` and `reordered` have no + * counterpart here). + * @public + */ +export type WebhookEvent = 'created' | 'edited' | 'archived' | 'restored' | 'deleted' + +/** @public */ +export const WEBHOOK_EVENTS: readonly WebhookEvent[] = ['created', 'edited', 'archived', 'restored', 'deleted'] + +/** Webhooks one project can have. @public */ +export const MAX_PROJECT_WEBHOOKS = 20 + +/** Longest URL of a webhook. @public */ +export const MAX_WEBHOOK_URL_LENGTH = 2048 + +/** Longest secret; the secret is any string, GitHub recommends a long random one. @public */ +export const MAX_WEBHOOK_SECRET_LENGTH = 256 + +/** + * A webhook of a project (`space`). The secret is not part of it: see `ProjectWebhookSecret`. + * @public + */ +export interface ProjectWebhook extends Doc { + space: Ref + url: string + enabled: boolean + // Events that are delivered; an empty list delivers nothing + events: WebhookEvent[] + // Whether a secret exists, so that deliveries are signed. The secret itself is never part of this doc. + hasSecret: boolean + description?: string +} + +/** + * The secret of a webhook. It is created in the *personal space* of the person who set it, so no other member of the + * project can read it, and the settings UI never shows it again after it was typed. The server reads it to sign + * deliveries (the newest one of a webhook counts, so setting a new secret rotates it). + * @public + */ +export interface ProjectWebhookSecret extends Doc { + webhook: Ref + secret: string +} + +/** + * Keeps the known events once each, in the order of `WEBHOOK_EVENTS`. + * @public + */ +export function normalizeWebhookEvents (events: unknown): WebhookEvent[] { + if (!Array.isArray(events)) return [] + return WEBHOOK_EVENTS.filter((e) => events.includes(e)) +} + +// ---- URL rules ---- + +/** + * @public + */ +export type WebhookUrlError = 'empty' | 'tooLong' | 'invalid' | 'protocol' | 'credentials' | 'host' | 'address' + +/** + * @public + */ +export type WebhookUrlResult = { ok: true, url: URL } | { ok: false, error: WebhookUrlError } + +/** + * @public + */ +export interface WebhookUrlOptions { + // Development: allow http and private / loopback targets + allowInsecure?: boolean +} + +// Names that only make sense inside a network. The DNS check on the server is what decides, this catches the obvious +// ones early (and in the form). +const INTERNAL_SUFFIXES = ['.localhost', '.local', '.internal', '.intranet', '.lan', '.home', '.corp', '.localdomain'] + +function isInternalHostname (host: string): boolean { + const h = host.toLowerCase().replace(/\.$/, '') + return h === 'localhost' || h === '' || !h.includes('.') || INTERNAL_SUFFIXES.some((s) => h.endsWith(s)) +} + +/** + * Checks the syntax of a webhook URL: https only (http when `allowInsecure`), no credentials in the URL, a host that + * is not obviously internal and, for an IP address, one that is public. The names still have to be resolved and + * checked on the server. + * @public + */ +export function validateWebhookUrl (raw: string, options: WebhookUrlOptions = {}): WebhookUrlResult { + const text = raw.trim() + if (text === '') return { ok: false, error: 'empty' } + if (text.length > MAX_WEBHOOK_URL_LENGTH) return { ok: false, error: 'tooLong' } + let url: URL + try { + url = new URL(text) + } catch { + return { ok: false, error: 'invalid' } + } + const insecure = options.allowInsecure === true + if (url.protocol !== 'https:' && !(insecure && url.protocol === 'http:')) return { ok: false, error: 'protocol' } + if (url.username !== '' || url.password !== '') return { ok: false, error: 'credentials' } + if (insecure) return { ok: true, url } + const host = url.hostname + const ip = parseIp(host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host) + if (ip !== undefined) return isPublicAddress(host) ? { ok: true, url } : { ok: false, error: 'address' } + if (isInternalHostname(host)) return { ok: false, error: 'host' } + return { ok: true, url } +} + +// ---- addresses ---- + +/** + * @public + */ +export type ParsedIp = { version: 4, bytes: number[] } | { version: 6, groups: number[] } + +function parseIpv4 (text: string): number[] | undefined { + const parts = text.split('.') + if (parts.length !== 4) return undefined + const bytes: number[] = [] + for (const part of parts) { + if (!/^(0|[1-9]\d{0,2})$/.test(part)) return undefined + const n = Number(part) + if (n > 255) return undefined + bytes.push(n) + } + return bytes +} + +function parseIpv6 (input: string): number[] | undefined { + let text = input + // The zone id (`fe80::1%eth0`) is never part of a public address + if (text.includes('%')) return undefined + // An embedded IPv4 tail takes the place of the last two groups + const lastColon = text.lastIndexOf(':') + if (lastColon !== -1 && text.includes('.', lastColon)) { + const v4 = parseIpv4(text.slice(lastColon + 1)) + if (v4 === undefined) return undefined + text = `${text.slice(0, lastColon + 1)}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}` + } + const halves = text.split('::') + if (halves.length > 2) return undefined + const toGroups = (part: string): number[] | undefined => { + if (part === '') return [] + const groups: number[] = [] + for (const g of part.split(':')) { + if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return undefined + groups.push(parseInt(g, 16)) + } + return groups + } + const head = toGroups(halves[0]) + if (head === undefined) return undefined + if (halves.length === 1) return head.length === 8 ? head : undefined + const tail = toGroups(halves[1]) + if (tail === undefined) return undefined + const missing = 8 - head.length - tail.length + if (missing < 1) return undefined + return [...head, ...new Array(missing).fill(0), ...tail] +} + +/** + * Parses an IPv4 (dotted decimal, no shortcuts) or IPv6 address (brackets not included); undefined for anything else. + * @public + */ +export function parseIp (text: string): ParsedIp | undefined { + const v4 = parseIpv4(text) + if (v4 !== undefined) return { version: 4, bytes: v4 } + if (!text.includes(':')) return undefined + const v6 = parseIpv6(text) + return v6 !== undefined ? { version: 6, groups: v6 } : undefined +} + +function isPublicIpv4 (b: readonly number[]): boolean { + const [a, c, d] = b + if (a === 0 || a === 10 || a === 127) return false // this network, private, loopback + if (a === 100 && c >= 64 && c <= 127) return false // carrier grade NAT + if (a === 169 && c === 254) return false // link local, cloud metadata + if (a === 172 && c >= 16 && c <= 31) return false // private + if (a === 192 && c === 0 && (d === 0 || d === 2)) return false // protocol assignments, documentation + if (a === 192 && c === 88 && d === 99) return false // 6to4 relay + if (a === 192 && c === 168) return false // private + if (a === 198 && (c === 18 || c === 19)) return false // benchmarking + if (a === 198 && c === 51 && d === 100) return false // documentation + if (a === 203 && c === 0 && d === 113) return false // documentation + if (a >= 224) return false // multicast, reserved, broadcast + return true +} + +function ipv4FromGroups (hi: number, lo: number): number[] { + return [hi >> 8, hi & 255, lo >> 8, lo & 255] +} + +function isPublicIpv6 (g: readonly number[]): boolean { + const allZeroUntil = (n: number): boolean => g.slice(0, n).every((x) => x === 0) + if (allZeroUntil(7) && (g[7] === 0 || g[7] === 1)) return false // :: and ::1 + // IPv4 mapped (::ffff:a.b.c.d) and the deprecated IPv4 compatible (::a.b.c.d) addresses are judged by the IPv4 part + if (allZeroUntil(5) && (g[5] === 0xffff || g[5] === 0)) return isPublicIpv4(ipv4FromGroups(g[6], g[7])) + // NAT64 well-known prefix 64:ff9b::/96 and the local-use prefix 64:ff9b:1::/48 + if (g[0] === 0x64 && g[1] === 0xff9b) { + if (g[2] === 1) return false + if (g.slice(2, 6).every((x) => x === 0)) return isPublicIpv4(ipv4FromGroups(g[6], g[7])) + } + if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return false // discard prefix + if (g[0] === 0x2001 && g[1] === 0) return false // Teredo + if (g[0] === 0x2001 && g[1] === 0xdb8) return false // documentation + if (g[0] === 0x2002) return isPublicIpv4(ipv4FromGroups(g[1], g[2])) // 6to4: judged by the embedded IPv4 + if ((g[0] & 0xfe00) === 0xfc00) return false // unique local + if ((g[0] & 0xffc0) === 0xfe80) return false // link local + if ((g[0] & 0xffc0) === 0xfec0) return false // site local (deprecated) + if ((g[0] & 0xff00) === 0xff00) return false // multicast + // Only global unicast 2000::/3 is assigned to the internet + return (g[0] & 0xe000) === 0x2000 +} + +/** + * Whether an address may be the target of a webhook: a public unicast address. Loopback, private, link local (the + * cloud metadata address 169.254.169.254 among them), carrier grade NAT, documentation, multicast and reserved + * ranges are not, and neither is anything that is not an IP address (fail closed). IPv6 addresses that embed an + * IPv4 address (mapped, NAT64, 6to4) are judged by it. A host written with brackets (`[::1]`) is accepted. + * @public + */ +export function isPublicAddress (address: string): boolean { + const text = address.startsWith('[') && address.endsWith(']') ? address.slice(1, -1) : address + const ip = parseIp(text) + if (ip === undefined) return false + return ip.version === 4 ? isPublicIpv4(ip.bytes) : isPublicIpv6(ip.groups) +} diff --git a/plugins/tracker/src/workflow.ts b/plugins/tracker/src/workflow.ts new file mode 100644 index 0000000000..21af675e56 --- /dev/null +++ b/plugins/tracker/src/workflow.ts @@ -0,0 +1,469 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { Doc, Ref, Timestamp } from '@hcengineering/core' +import type { Project } from './index' +import { buildIssueFilterSchema, type IssueFilterField, type IssueFilterSchemaInput } from './issueFilterSchema' +import { ProjectFieldType, type ProjectField, type ProjectFieldOption } from './projectField' + +// Built-in project workflows, GitHub Projects style (there is no rule builder). The pure logic lives here so that +// the server trigger and the settings UI agree on what a workflow does: +// +// - the three "set a field" workflows (item closed / reopened / added) write a value of a single-select custom +// field, the analogue of GitHub's Status field (Huly's own issue status already *is* the open/closed state); +// - Auto-archive archives the items that match a filter string; +// - Auto-add (Huly analogue) restores archived items that match a filter string. Huly issues belong to exactly one +// project and moving one renumbers it, so nothing is ever pulled in from another project. + +/** + * @public + */ +export enum WorkflowKind { + // GitHub "Item closed": the field is set to Done + SetStatusDoneOnClose = 'setStatusDoneOnClose', + // GitHub "Item reopened": the field goes back to the default open value + ItemReopened = 'itemReopened', + // GitHub "Item added to project": a new item gets the default value + ItemAdded = 'itemAdded', + // GitHub "Auto-archive items" + AutoArchive = 'autoArchive', + // GitHub "Auto-add to project", see the note above + AutoAddFromQuery = 'autoAddFromQuery' +} + +/** + * Kinds that write a custom field value. + * @public + */ +export type FieldWorkflowKind = + | WorkflowKind.SetStatusDoneOnClose + | WorkflowKind.ItemReopened + | WorkflowKind.ItemAdded + +/** + * Kinds that act on the items that match a filter string. + * @public + */ +export type FilterWorkflowKind = WorkflowKind.AutoArchive | WorkflowKind.AutoAddFromQuery + +/** + * Which field and option a field workflow writes. Absent: the default (see `resolveFieldTarget`). + * @public + */ +export interface WorkflowFieldTarget { + // `ProjectField.key` of a single-select field + field: string + // `ProjectFieldOption.value` + option: string +} + +/** + * @public + */ +export interface WorkflowConfig { + target?: WorkflowFieldTarget +} + +/** + * Stored settings of one workflow of a project (`space` is the project). A project has at most one per kind; a kind + * without a doc has its defaults (see `resolveWorkflows`). + * @public + */ +export interface Workflow extends Doc { + space: Ref + name: string + enabled: boolean + kind: WorkflowKind + // Filter string (the grammar of the project views) of the filter workflows + filter?: string + config?: WorkflowConfig + // Set by a client that opens the project to ask the server to evaluate the filter workflows (there is no + // periodic runner, see the Phase 10 notes); the value itself is never read + runRequestedAt?: Timestamp +} + +/** All kinds in the order they are listed. @public */ +export const WORKFLOW_KINDS: readonly WorkflowKind[] = [ + WorkflowKind.SetStatusDoneOnClose, + WorkflowKind.ItemReopened, + WorkflowKind.ItemAdded, + WorkflowKind.AutoArchive, + WorkflowKind.AutoAddFromQuery +] + +/** + * Defaults of a project that never changed a workflow. Like GitHub, the three item workflows are on: they only act + * when the project has a single-select Status field with the usual options, otherwise they do nothing. The two + * filter workflows are off, and need a filter before they can be switched on. + * @public + */ +export const DEFAULT_WORKFLOW_ENABLED: Readonly> = { + [WorkflowKind.SetStatusDoneOnClose]: true, + [WorkflowKind.ItemReopened]: true, + [WorkflowKind.ItemAdded]: true, + [WorkflowKind.AutoArchive]: false, + [WorkflowKind.AutoAddFromQuery]: false +} + +/** Names stored in `Workflow.name`; the UI shows a translated label for the kind. @public */ +export const WORKFLOW_NAMES: Readonly> = { + [WorkflowKind.SetStatusDoneOnClose]: 'Item closed', + [WorkflowKind.ItemReopened]: 'Item reopened', + [WorkflowKind.ItemAdded]: 'Item added to project', + [WorkflowKind.AutoArchive]: 'Auto-archive items', + [WorkflowKind.AutoAddFromQuery]: 'Auto-add items' +} + +/** Filter a new Auto-archive workflow starts with: closed items that were not touched for two weeks. @public */ +export const DEFAULT_AUTO_ARCHIVE_FILTER = 'is:closed updated:<@today-2w' + +/** Items one workflow changes in one evaluation; the rest waits for the next one. @public */ +export const MAX_WORKFLOW_ITEMS_PER_RUN = 100 + +/** Issues one evaluation looks at, oldest first. @public */ +export const WORKFLOW_SCAN_LIMIT = 2000 + +/** Longest filter string a workflow stores. @public */ +export const MAX_WORKFLOW_FILTER_LENGTH = 1000 + +/** + * @public + */ +export function isFieldWorkflowKind (kind: WorkflowKind): kind is FieldWorkflowKind { + return ( + kind === WorkflowKind.SetStatusDoneOnClose || kind === WorkflowKind.ItemReopened || kind === WorkflowKind.ItemAdded + ) +} + +/** + * @public + */ +export function isFilterWorkflowKind (kind: WorkflowKind): kind is FilterWorkflowKind { + return kind === WorkflowKind.AutoArchive || kind === WorkflowKind.AutoAddFromQuery +} + +/** + * A workflow as it applies to a project: the stored doc, or the defaults of its kind. + * @public + */ +export interface EffectiveWorkflow { + kind: WorkflowKind + name: string + enabled: boolean + filter: string + config: WorkflowConfig + // Absent while the project never changed this workflow + doc?: Workflow +} + +function isWorkflowKind (value: unknown): value is WorkflowKind { + return typeof value === 'string' && (WORKFLOW_KINDS as readonly string[]).includes(value) +} + +function olderFirst (a: Workflow, b: Workflow): number { + return (a.createdOn ?? 0) - (b.createdOn ?? 0) || (a._id < b._id ? -1 : a._id > b._id ? 1 : 0) +} + +/** + * One workflow per kind in the order of `WORKFLOW_KINDS`. A kind without a doc has its defaults. Should a project + * have several docs of one kind (two clients created it at the same time), the oldest wins, the same everywhere. + * Docs of an unknown kind are ignored. + * @public + */ +export function resolveWorkflows (docs: readonly Workflow[]): EffectiveWorkflow[] { + const byKind = new Map() + for (const doc of [...docs].sort(olderFirst)) { + if (!isWorkflowKind(doc.kind) || byKind.has(doc.kind)) continue + byKind.set(doc.kind, doc) + } + return WORKFLOW_KINDS.map((kind) => { + const doc = byKind.get(kind) + if (doc === undefined) { + return { + kind, + name: WORKFLOW_NAMES[kind], + enabled: DEFAULT_WORKFLOW_ENABLED[kind], + filter: kind === WorkflowKind.AutoArchive ? DEFAULT_AUTO_ARCHIVE_FILTER : '', + config: {} + } + } + return { + kind, + name: doc.name !== undefined && doc.name !== '' ? doc.name : WORKFLOW_NAMES[kind], + enabled: doc.enabled === true, + filter: typeof doc.filter === 'string' ? doc.filter : '', + config: doc.config ?? {}, + doc + } + }) +} + +/** + * A filter workflow with no filter would act on every item, so it does nothing until it has one. + * @public + */ +export function isWorkflowFilterUsable (filter: string | undefined): boolean { + if (filter === undefined) return false + const text = filter.trim() + return text !== '' && text.length <= MAX_WORKFLOW_FILTER_LENGTH +} + +// ---- the field the "set a field" workflows write ---- + +/** + * What a field workflow writes: the field and the option. + * @public + */ +export interface ResolvedFieldTarget { + field: Pick + option: ProjectFieldOption +} + +// GitHub's defaults: the field is called Status, the options Todo / Done +const DEFAULT_FIELD_NAMES = ['status'] +const DEFAULT_OPTION_NAMES: Readonly> = { + [WorkflowKind.SetStatusDoneOnClose]: ['done'], + [WorkflowKind.ItemReopened]: ['todo', 'to do', 'open'], + [WorkflowKind.ItemAdded]: ['todo', 'to do', 'open'] +} + +function sameName (label: string, names: readonly string[]): boolean { + return names.includes(label.trim().toLowerCase()) +} + +/** + * The field and option a field workflow writes. A configured target is used as it is: when its field or option no + * longer exists the workflow does nothing, it never falls back to something else. Without a target, the default is + * the first single-select field called Status that has the option the kind expects (Done, or Todo). Undefined when + * there is nothing to write. + * @public + */ +export function resolveFieldTarget ( + kind: FieldWorkflowKind, + config: WorkflowConfig | undefined, + fields: ReadonlyArray & { position?: number }> +): ResolvedFieldTarget | undefined { + const singleSelects = fields.filter((f) => f.type === ProjectFieldType.SingleSelect) + const target = config?.target + if (target !== undefined) { + const field = singleSelects.find((f) => f.key === target.field) + const option = field?.options?.find((o) => o.value === target.option) + return field !== undefined && option !== undefined ? { field, option } : undefined + } + const ordered = [...singleSelects].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + for (const field of ordered) { + if (!sameName(field.label, DEFAULT_FIELD_NAMES)) continue + const option = field.options?.find((o) => sameName(o.label, DEFAULT_OPTION_NAMES[kind])) + if (option !== undefined) return { field, option } + } + return undefined +} + +// ---- when a field workflow acts ---- + +/** + * What happened to an issue. + * @public + */ +export type IssueChange = + | { type: 'created' } + // The status changed; `prev` is unknown when it could not be read back + | { type: 'status', prev?: string, next: string } + +/** + * @public + */ +export type StatusTransition = 'closed' | 'reopened' | 'none' + +/** + * A status change is a close when it enters a closed status (done or canceled) from an open one, and a reopen when + * it leaves a closed status for an open one. An unknown previous status counts as open. + * @public + */ +export function statusTransition ( + prev: string | undefined, + next: string, + closedStatuses: ReadonlySet +): StatusTransition { + const wasClosed = prev !== undefined && closedStatuses.has(prev) + const isClosed = closedStatuses.has(next) + if (isClosed && !wasClosed) return 'closed' + if (!isClosed && wasClosed) return 'reopened' + return 'none' +} + +/** + * Whether a change makes a field workflow act. + * @public + */ +export function fieldWorkflowApplies ( + kind: FieldWorkflowKind, + change: IssueChange, + closedStatuses: ReadonlySet +): boolean { + if (change.type === 'created') return kind === WorkflowKind.ItemAdded + const transition = statusTransition(change.prev, change.next, closedStatuses) + if (kind === WorkflowKind.SetStatusDoneOnClose) return transition === 'closed' + if (kind === WorkflowKind.ItemReopened) return transition === 'reopened' + return false +} + +function isEmptyFieldValue (value: unknown): boolean { + return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0) +} + +/** + * The new `customFields` of an issue after a field workflow, or undefined when nothing has to be written: the + * value is already there (so a repeated run does nothing), or, for "item added", the issue already has a value. + * @public + */ +export function planFieldWorkflow ( + kind: FieldWorkflowKind, + customFields: Record | undefined, + target: ResolvedFieldTarget +): Record | undefined { + const current = customFields?.[target.field.key] + if (current === target.option.value) return undefined + if (kind === WorkflowKind.ItemAdded && !isEmptyFieldValue(current)) return undefined + return { ...(customFields ?? {}), [target.field.key]: target.option.value } +} + +/** + * The `customFields` an issue gets from the enabled field workflows for a change, or undefined when none changes it. + * Workflows are applied in the order of `WORKFLOW_KINDS`, each on the result of the previous one. + * @public + */ +export function planIssueWorkflows ( + workflows: readonly EffectiveWorkflow[], + change: IssueChange, + issue: { customFields?: Record }, + closedStatuses: ReadonlySet, + fields: ReadonlyArray & { position?: number }> +): Record | undefined { + let customFields = issue.customFields + let changed = false + for (const workflow of workflows) { + if (!workflow.enabled || !isFieldWorkflowKind(workflow.kind)) continue + if (!fieldWorkflowApplies(workflow.kind, change, closedStatuses)) continue + const target = resolveFieldTarget(workflow.kind, workflow.config, fields) + if (target === undefined) continue + const next = planFieldWorkflow(workflow.kind, customFields, target) + if (next === undefined) continue + customFields = next + changed = true + } + return changed ? customFields : undefined +} + +// ---- the filter workflows ---- + +/** + * The items a filter workflow changes in one run: the first `cap` candidates that match, in the order given (the + * server passes the oldest first, so a backlog is worked off over several runs). `exclude` holds ids that must be left + * alone, an id is taken once. The input is not modified. + * @public + */ +export function selectWorkflowItems ( + candidates: readonly T[], + matches: (item: T) => boolean, + options: { cap?: number, exclude?: ReadonlySet } = {} +): T[] { + const cap = Math.max(0, Math.min(options.cap ?? MAX_WORKFLOW_ITEMS_PER_RUN, MAX_WORKFLOW_ITEMS_PER_RUN)) + const taken = new Set() + const result: T[] = [] + for (const item of candidates) { + if (result.length >= cap) break + if (taken.has(item._id) || options.exclude?.has(item._id) === true) continue + if (!matches(item)) continue + taken.add(item._id) + result.push(item) + } + return result +} + +/** + * Auto-add restores archived items that the project's Auto-archive would archive again at once; that would only + * undo itself every run. Archive wins: the items that match the archive filter are left out. + * @public + */ +export function restoreCandidates ( + archived: readonly T[], + matchesRestore: (item: T) => boolean, + matchesArchive: ((item: T) => boolean) | undefined, + cap: number = MAX_WORKFLOW_ITEMS_PER_RUN +): T[] { + return selectWorkflowItems(archived, (item) => matchesRestore(item) && !(matchesArchive?.(item) ?? false), { cap }) +} + +// ---- loop protection ---- + +/** + * Whether a change was made by the project automation. Every write of a workflow is authored by the system + * account, and workflows never react to such a change, so a workflow can not wake itself or another workflow up. + * (All writes are idempotent as well, so a loop would end after one round anyway.) + * @public + */ +export function isAutomationAuthor (modifiedBy: string | undefined, systemId: string): boolean { + return modifiedBy !== undefined && modifiedBy === systemId +} + +/** + * Validation of what a user edits. + * @public + */ +export type WorkflowValidationError = 'filterRequired' | 'filterTooLong' | 'targetMissing' + +/** + * Whether a workflow may be enabled as it is: a filter workflow needs a filter, a field workflow whose target was + * chosen needs it to exist. + * @public + */ +export function validateWorkflow ( + workflow: Pick, + fields: ReadonlyArray> +): WorkflowValidationError | undefined { + if (isFilterWorkflowKind(workflow.kind)) { + if (workflow.filter.trim() === '') return 'filterRequired' + if (workflow.filter.length > MAX_WORKFLOW_FILTER_LENGTH) return 'filterTooLong' + return undefined + } + if (isFieldWorkflowKind(workflow.kind) && workflow.config.target !== undefined) { + return resolveFieldTarget(workflow.kind, workflow.config, fields) === undefined ? 'targetMissing' : undefined + } + return undefined +} + +// ---- the filter of the filter workflows ---- + +/** + * Priorities as a workflow filter names them. The server can not translate, so these names are English whatever the + * language of the viewer: `priority:urgent`. + * @public + */ +export const WORKFLOW_PRIORITY_NAMES: ReadonlyArray<{ id: number, name: string }> = [ + // The values of `IssuePriority` (not imported: this module must not depend on the index at load time) + { id: 0, name: 'No priority' }, + { id: 1, name: 'Urgent' }, + { id: 2, name: 'High' }, + { id: 3, name: 'Medium' }, + { id: 4, name: 'Low' } +] + +/** + * The fields a workflow filter can use: those of the project views without labels (a label is a separate document + * per issue that a workflow scan does not load) and with English priority names. The settings form and the server + * build the schema with this function, so that a filter that is valid in the form is valid in the run. + * @public + */ +export function buildWorkflowFilterSchema ( + input: Omit +): IssueFilterField[] { + return buildIssueFilterSchema({ + ...input, + priorities: WORKFLOW_PRIORITY_NAMES.map((p) => ({ id: p.id, name: p.name })), + labels: [], + labelRefs: [] + }).filter((f) => f.name !== 'label') +} diff --git a/plugins/view-assets/lang/cs.json b/plugins/view-assets/lang/cs.json index 02ac373065..78094ddba4 100644 --- a/plugins/view-assets/lang/cs.json +++ b/plugins/view-assets/lang/cs.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "Obsahuje", "NotContains": "Neobsahuje", "MoveClass": "Přesunout {class}", @@ -84,6 +91,7 @@ "NewFilteredView": "Nové filtrované zobrazení", "FilteredViewName": "Název filtrovaného zobrazení", "Then": "Poté", + "ThenBy": "Then by", "ShowPreviewOnClick": "Klikněte pro zobrazení náhledu indexu dokumentu...", "Shown": "zobrazeno {len} výsledků {total, plural, =-1 {} other {z filtrovaných # položek}}", "Total": "Celkem: {total, plural, other{#}}", @@ -162,6 +170,33 @@ "RelationshipTable": "Tabulka vztahů", "FilterOverflowBadge": "+{count}", "AddFilter": "Přidat filtr", - "HiddenFilters": "Skryté filtry" + "HiddenFilters": "Skryté filtry", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/de.json b/plugins/view-assets/lang/de.json index 63b114166e..afa14ee05a 100644 --- a/plugins/view-assets/lang/de.json +++ b/plugins/view-assets/lang/de.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "enthält", "NotContains": "enthält nicht", "MoveClass": "{class} verschieben", @@ -84,6 +91,7 @@ "NewFilteredView": "Neue gefilterte Ansicht", "FilteredViewName": "Name der gefilterten Ansicht", "Then": "Dann", + "ThenBy": "Then by", "ShowPreviewOnClick": "Bitte klicken Sie, um die Dokumentindexvorschau anzuzeigen...", "Shown": "zeige {len} Ergebnisse {total, plural, =-1 {} other {von gefilterten # Elementen}}", "Total": "Gesamt: {total, plural, other{#}}", @@ -162,6 +170,33 @@ "RelationshipTable": "Beziehungstabelle", "FilterOverflowBadge": "+{count}", "AddFilter": "Filter hinzufügen", - "HiddenFilters": "Versteckte Filter" + "HiddenFilters": "Versteckte Filter", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/en.json b/plugins/view-assets/lang/en.json index e943f6995c..432c0900c6 100644 --- a/plugins/view-assets/lang/en.json +++ b/plugins/view-assets/lang/en.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "contains", "NotContains": "does not contain", "MoveClass": "Move {class}", @@ -84,6 +91,7 @@ "NewFilteredView": "New filtered view", "FilteredViewName": "Filtered view name", "Then": "Then", + "ThenBy": "Then by", "ShowPreviewOnClick": "Please click to show document index preview...", "Shown": "showing {len} results {total, plural, =-1 {} other {of filtered # items}}", "Total": "Total: {total, plural, other{#}}", @@ -162,6 +170,33 @@ "RelationshipTable": "Traceability Matrix", "FilterOverflowBadge": "+{count}", "AddFilter": "Add filter", - "HiddenFilters": "Hidden filters" + "HiddenFilters": "Hidden filters", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/es.json b/plugins/view-assets/lang/es.json index 0db97c2355..2a49829172 100644 --- a/plugins/view-assets/lang/es.json +++ b/plugins/view-assets/lang/es.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "Contiene", "NotContains": "No contiene", "MoveClass": "Mover {class}", @@ -80,6 +87,7 @@ "NewFilteredView": "Nueva vista filtrada", "FilteredViewName": "Nombre de la vista filtrada", "Then": "Entonces", + "ThenBy": "Then by", "ShowPreviewOnClick": "Haga clic para mostrar la vista previa del índice del documento...", "Shown": "mostrando {len} resultados {total, plural, =-1 {} other {de # elementos filtrados}}", "Total": "Total: {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "Tabla de relaciones", "FilterOverflowBadge": "+{count}", "AddFilter": "Añadir filtro", - "HiddenFilters": "Filtros ocultos" + "HiddenFilters": "Filtros ocultos", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/fr.json b/plugins/view-assets/lang/fr.json index c0b0f44074..92eed1f4da 100644 --- a/plugins/view-assets/lang/fr.json +++ b/plugins/view-assets/lang/fr.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "contient", "NotContains": "ne contient pas", "MoveClass": "Déplacer {class}", @@ -80,6 +87,7 @@ "NewFilteredView": "Nouvelle vue filtrée", "FilteredViewName": "Nom de la vue filtrée", "Then": "Puis", + "ThenBy": "Then by", "ShowPreviewOnClick": "Cliquez pour afficher l'aperçu de l'index du document...", "Shown": "affiche {len} résultats {total, plural, =-1 {} other {sur # éléments filtrés}}", "Total": "Total : {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "Matrice de traçabilité", "FilterOverflowBadge": "+{count}", "AddFilter": "Ajouter un filtre", - "HiddenFilters": "Filtres masqués" + "HiddenFilters": "Filtres masqués", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/it.json b/plugins/view-assets/lang/it.json index 399c5f8a44..7a7d67885b 100644 --- a/plugins/view-assets/lang/it.json +++ b/plugins/view-assets/lang/it.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "contiene", "NotContains": "non contiene", "MoveClass": "Sposta {class}", @@ -80,6 +87,7 @@ "NewFilteredView": "Nuova vista filtrata", "FilteredViewName": "Nome vista filtrata", "Then": "Poi", + "ThenBy": "Then by", "ShowPreviewOnClick": "Clicca per mostrare l'anteprima dell'indice del documento...", "Shown": "mostrando {len} risultati {total, plural, =-1 {} other {di # elementi filtrati}}", "Total": "Totale: {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "Tabella delle relazioni", "FilterOverflowBadge": "+{count}", "AddFilter": "Aggiungi filtro", - "HiddenFilters": "Filtri nascosti" + "HiddenFilters": "Filtri nascosti", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/ja.json b/plugins/view-assets/lang/ja.json index e43b1d74c5..65801ada68 100644 --- a/plugins/view-assets/lang/ja.json +++ b/plugins/view-assets/lang/ja.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "含む", "NotContains": "含まない", "MoveClass": "{class} を移動", @@ -80,6 +87,7 @@ "NewFilteredView": "新しいフィルターされたビュー", "FilteredViewName": "フィルターされたビュー名", "Then": "次に", + "ThenBy": "Then by", "ShowPreviewOnClick": "クリックしてドキュメントインデックスプレビューを表示してください...", "Shown": " {total, plural, =-1 {} other {フィルターされた # 件のアイテムのうち}} {len} 件の結果を表示中", "Total": "合計: {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "関係テーブル", "FilterOverflowBadge": "+{count}", "AddFilter": "フィルターを追加", - "HiddenFilters": "非表示のフィルター" + "HiddenFilters": "非表示のフィルター", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/ko.json b/plugins/view-assets/lang/ko.json index eb852ff9d0..52b2a955a4 100644 --- a/plugins/view-assets/lang/ko.json +++ b/plugins/view-assets/lang/ko.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "포함", "NotContains": "포함하지 않음", "MoveClass": "{class} 이동", @@ -80,6 +87,7 @@ "NewFilteredView": "새 필터링된 뷰", "FilteredViewName": "필터링된 뷰 이름", "Then": "다음", + "ThenBy": "Then by", "ShowPreviewOnClick": "클릭하면 문서 인덱스 미리보기가 표시됩니다...", "Shown": "{total, plural, =-1 {} other {필터링된 #개 항목 중}} {len}개 결과 표시 중", "Total": "합계: {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "추적 매트릭스", "FilterOverflowBadge": "+{count}", "AddFilter": "필터 추가", - "HiddenFilters": "숨겨진 필터" + "HiddenFilters": "숨겨진 필터", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/pl.json b/plugins/view-assets/lang/pl.json index 4997e9f5d6..8b9287dc14 100644 --- a/plugins/view-assets/lang/pl.json +++ b/plugins/view-assets/lang/pl.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "zawiera", "NotContains": "nie zawiera", "MoveClass": "Przenieś {class}", @@ -83,6 +90,7 @@ "NewFilteredView": "Nowy widok filtrowany", "FilteredViewName": "Nazwa filtrowanego widoku", "Then": "Następnie po", + "ThenBy": "Then by", "ShowPreviewOnClick": "Aby wyświetlić podgląd indeksu dokumentu, należy kliknąć...", "Shown": "{len, plural, =1 {wyświetlany # wynik} few {wyświetlane # wyniki} other {wyświetlanych # wyników}} {total, plural, =-1 {} =1 {z # przefiltrowanego wyniku} other {z # przefiltrowanych wyników}}", "Total": "Razem: {total, plural, other{#}}", @@ -158,6 +166,33 @@ "RoleLabel": "Rola: {role}", "ForbidAttributeChanges": "Zablokuj zmiany: ", "AllowAttributeChanges": "Zezwól na zmiany: ", - "RelationshipTable": "Macierz powiązań" + "RelationshipTable": "Macierz powiązań", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/pt-br.json b/plugins/view-assets/lang/pt-br.json index 9234e0c390..8a2b3c058a 100644 --- a/plugins/view-assets/lang/pt-br.json +++ b/plugins/view-assets/lang/pt-br.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "Contém", "NotContains": "Não contém", "MoveClass": "Mover {class}", @@ -80,6 +87,7 @@ "NewFilteredView": "Nova visualização com filtros", "FilteredViewName": "Nome da visualização com filtros", "Then": "Então", + "ThenBy": "Then by", "ShowPreviewOnClick": "Por favor, clique para mostrar a pré-visualização do índice do documento...", "Shown": "mostrando {len} resultados {total, plural, =-1 {} other {de # itens filtrados}}", "Total": "Total: {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "Tabela de relacionamentos", "FilterOverflowBadge": "+{count}", "AddFilter": "Adicionar filtro", - "HiddenFilters": "Filtros ocultos" + "HiddenFilters": "Filtros ocultos", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/pt.json b/plugins/view-assets/lang/pt.json index 9eb0d6ccca..9400aac507 100644 --- a/plugins/view-assets/lang/pt.json +++ b/plugins/view-assets/lang/pt.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "Contém", "NotContains": "Não contém", "MoveClass": "Mover {class}", @@ -80,6 +87,7 @@ "NewFilteredView": "Nova vista filtrada", "FilteredViewName": "Nome da vista filtrada", "Then": "Então", + "ThenBy": "Then by", "ShowPreviewOnClick": "Por favor, clique para mostrar a pré-visualização do índice do documento...", "Shown": "mostrando {len} resultados {total, plural, =-1 {} other {de # itens filtrados}}", "Total": "Total: {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "Tabela de relacionamentos", "FilterOverflowBadge": "+{count}", "AddFilter": "Adicionar filtro", - "HiddenFilters": "Filtros ocultos" + "HiddenFilters": "Filtros ocultos", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/ru.json b/plugins/view-assets/lang/ru.json index 37f95e9c9c..986095003c 100644 --- a/plugins/view-assets/lang/ru.json +++ b/plugins/view-assets/lang/ru.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, one {Обновлён # элемент} few {Обновлено # элемента} other {Обновлено # элементов}}", + "BulkItemsUpdatedSkipped": "{count, plural, one {Обновлён # элемент} few {Обновлено # элемента} other {Обновлено # элементов}}, {skipped, plural, one {# ячейка пропущена} few {# ячейки пропущены} other {# ячеек пропущено}}", + "BulkCellsSkipped": "Ничего не изменено: {skipped, plural, one {# ячейка не принимает это значение} few {# ячейки не принимают это значение} other {# ячеек не принимают это значение}}", + "BulkUndo": "Отменить", + "BulkUndone": "{count, plural, one {Восстановлен # элемент} few {Восстановлено # элемента} other {Восстановлено # элементов}}", + "BulkFailed": "Не удалось обновить элементы", + "BulkUndoFailed": "Не удалось отменить изменения", "Contains": "содержит", "NotContains": "не содержит", "MoveClass": "Переместить {class}", @@ -80,6 +87,7 @@ "NewFilteredView": "Новое фильтрованное отображение", "FilteredViewName": "Имя фильтрованного отображения", "Then": "Затем", + "ThenBy": "Затем по", "ShowPreviewOnClick": "Пожалуйста, нажмите, чтобы увидеть предпросмотр...", "Shown": "показано {len} результатов {total, plural, =-1 {} other {из #}}", "Total": "Всего: {total, plural, other{#}}", @@ -159,6 +167,33 @@ "RelationshipTable": "Таблица связей", "FilterOverflowBadge": "+{count}", "AddFilter": "Добавить фильтр", - "HiddenFilters": "Скрытые фильтры" + "HiddenFilters": "Скрытые фильтры", + "SavedViewNew": "Новое представление", + "SavedViewDefaultName": "Представление", + "SavedViewDuplicate": "Дублировать представление", + "SavedViewDelete": "Удалить представление", + "SavedViewDeleteConfirm": "Удалить представление «{name}»? Это действие нельзя отменить.", + "SavedViewSave": "Сохранить изменения", + "SavedViewSaveAsNew": "Сохранить как новое представление", + "SavedViewDiscard": "Отменить", + "SavedViewUnsaved": "Несохранённые изменения", + "SavedViewLayoutTable": "Таблица", + "SavedViewLayoutTo": "Вид: {layout}", + "FilterQueryPlaceholder": "Фильтр по ключевому слову или по полю", + "FilterQueryClear": "Сбросить фильтр", + "FilterQueryDidYouMean": "Возможно, вы имели в виду: {hints}", + "FilterQueryErrorQuote": "Не закрыта кавычка", + "FilterQueryErrorParenthesis": "Несбалансированные скобки", + "FilterQueryErrorUnexpected": "Неожиданный ввод", + "FilterQueryErrorUnknownField": "Неизвестное поле «{token}»", + "FilterQueryErrorMissingValue": "Не указано значение", + "FilterQueryErrorOperator": "Этот оператор нельзя использовать с этим полем", + "FilterQueryErrorValue": "Недопустимое значение «{token}»", + "FilterQueryErrorDate": "«{token}» не является датой. Используйте ГГГГ-ММ-ДД или @today-7d", + "FilterQueryErrorNumber": "«{token}» не является числом", + "FilterQueryErrorRange": "Диапазон должен иметь ровно две границы, например 1..5", + "FilterQueryErrorKeyword": "Неизвестное ключевое слово «{token}»", + "SavedViewCopyLink": "Скопировать ссылку на представление", + "SavedViewLinkCopied": "Ссылка на представление скопирована" } } diff --git a/plugins/view-assets/lang/tr.json b/plugins/view-assets/lang/tr.json index 10a1f83cfc..65ec91bd14 100644 --- a/plugins/view-assets/lang/tr.json +++ b/plugins/view-assets/lang/tr.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "içeriyor", "NotContains": "içermiyor", "MoveClass": "{class} taşı", @@ -80,6 +87,7 @@ "NewFilteredView": "Yeni filtreli görünüm", "FilteredViewName": "Filtreli görünüm adı", "Then": "Sonra", + "ThenBy": "Then by", "ShowPreviewOnClick": "Doküman indeks önizlemesini göstermek için lütfen tıklayın...", "Shown": "{len} sonuç gösteriliyor {total, plural, =-1 {} other {filtrelenmiş # öğeden}}", "Total": "Toplam: {total, plural, other{#}}", @@ -157,6 +165,33 @@ "RelationshipTable": "İlişki tablosu", "FilterOverflowBadge": "+{count}", "AddFilter": "Filtre ekle", - "HiddenFilters": "Gizli filtreler" + "HiddenFilters": "Gizli filtreler", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-assets/lang/zh.json b/plugins/view-assets/lang/zh.json index 6ec5f3eaaf..3de5202b94 100644 --- a/plugins/view-assets/lang/zh.json +++ b/plugins/view-assets/lang/zh.json @@ -1,5 +1,12 @@ { "string": { + "BulkItemsUpdated": "{count, plural, =1 {1 item updated} other {# items updated}}", + "BulkItemsUpdatedSkipped": "{count, plural, =1 {1 item updated} other {# items updated}}, {skipped, plural, =1 {1 cell skipped} other {# cells skipped}}", + "BulkCellsSkipped": "Nothing changed: {skipped, plural, =1 {1 cell does not accept this value} other {# cells do not accept this value}}", + "BulkUndo": "Undo", + "BulkUndone": "{count, plural, =1 {1 item restored} other {# items restored}}", + "BulkFailed": "Could not update the items", + "BulkUndoFailed": "Could not undo the changes", "Contains": "包含", "NotContains": "不包含", "MoveClass": "移动 {class}", @@ -84,6 +91,7 @@ "NewFilteredView": "新筛选视图", "FilteredViewName": "筛选视图名称", "Then": "然后", + "ThenBy": "Then by", "ShowPreviewOnClick": "请点击显示文档索引预览...", "Shown": "显示 {len} 结果 {total, plural, =-1 {} other {共筛选 # 个项目}}", "Total": "总计: {total, plural, other {#}}", @@ -162,6 +170,33 @@ "RelationshipTable": "关系表", "FilterOverflowBadge": "+{count}", "AddFilter": "添加过滤器", - "HiddenFilters": "隐藏的过滤器" + "HiddenFilters": "隐藏的过滤器", + "SavedViewNew": "New view", + "SavedViewDefaultName": "View", + "SavedViewDuplicate": "Duplicate view", + "SavedViewDelete": "Delete view", + "SavedViewDeleteConfirm": "Delete the view \"{name}\"? This cannot be undone.", + "SavedViewSave": "Save changes", + "SavedViewSaveAsNew": "Save to new view", + "SavedViewDiscard": "Discard", + "SavedViewUnsaved": "Unsaved changes", + "SavedViewLayoutTable": "Table", + "SavedViewLayoutTo": "Layout: {layout}", + "FilterQueryPlaceholder": "Filter by keyword or by field", + "FilterQueryClear": "Clear filter", + "FilterQueryDidYouMean": "Did you mean: {hints}", + "FilterQueryErrorQuote": "Missing closing quote", + "FilterQueryErrorParenthesis": "Unbalanced parenthesis", + "FilterQueryErrorUnexpected": "Unexpected input", + "FilterQueryErrorUnknownField": "Unknown field \"{token}\"", + "FilterQueryErrorMissingValue": "A value is missing", + "FilterQueryErrorOperator": "This operator cannot be used with this field", + "FilterQueryErrorValue": "Invalid value \"{token}\"", + "FilterQueryErrorDate": "\"{token}\" is not a valid date. Use YYYY-MM-DD or @today-7d", + "FilterQueryErrorNumber": "\"{token}\" is not a number", + "FilterQueryErrorRange": "A range needs exactly two bounds, like 1..5", + "FilterQueryErrorKeyword": "Unknown keyword \"{token}\"", + "SavedViewCopyLink": "Copy link to view", + "SavedViewLinkCopied": "The link to the view is copied" } } diff --git a/plugins/view-resources/src/__tests__/hierarchy.test.ts b/plugins/view-resources/src/__tests__/hierarchy.test.ts new file mode 100644 index 0000000000..75a60a8a38 --- /dev/null +++ b/plugins/view-resources/src/__tests__/hierarchy.test.ts @@ -0,0 +1,135 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { buildHierarchyRows, expandableIds, MAX_HIERARCHY_DEPTH } from '../hierarchy' + +interface Item { + id: string + parent?: string +} + +const item = (id: string, parent?: string): Item => ({ id, parent }) +const idOf = (it: Item): string => it.id +const parentOf = (it: Item): string | undefined => it.parent + +function rows (items: Item[], expanded: string[] = [], maxDepth?: number): string[] { + return buildHierarchyRows(items, { idOf, parentOf, expanded: new Set(expanded), maxDepth }).map( + (r) => `${'-'.repeat(r.depth)}${r.item.id}` + ) +} + +describe('buildHierarchyRows', () => { + const tree = [item('a'), item('a1', 'a'), item('a2', 'a'), item('a11', 'a1'), item('b'), item('b1', 'b')] + + it('shows only the top level rows while everything is collapsed', () => { + expect(rows(tree)).toEqual(['a', 'b']) + }) + + it('shows the children of an expanded row right after it', () => { + expect(rows(tree, ['a'])).toEqual(['a', '-a1', '-a2', 'b']) + }) + + it('nests several levels and keeps the sibling order', () => { + expect(rows(tree, ['a', 'a1', 'b'])).toEqual(['a', '-a1', '--a11', '-a2', 'b', '-b1']) + }) + + it('does not show the children of a collapsed row even if its child is expanded', () => { + expect(rows(tree, ['a1'])).toEqual(['a', 'b']) + }) + + it('reports the state of every row', () => { + const res = buildHierarchyRows(tree, { idOf, parentOf, expanded: new Set(['a']) }) + const byId = new Map(res.map((r) => [r.item.id, r])) + expect(byId.get('a')).toMatchObject({ depth: 0, hasChildren: true, expanded: true, childCount: 2 }) + expect(byId.get('a1')).toMatchObject({ depth: 1, hasChildren: true, expanded: false, childCount: 1 }) + expect(byId.get('a2')).toMatchObject({ depth: 1, hasChildren: false, expanded: false, childCount: 0 }) + expect(byId.get('b')).toMatchObject({ hasChildren: true, expanded: false }) + }) + + it('shows a child whose parent is not in the list as a top level row', () => { + // `p` was filtered out + expect(rows([item('x', 'p'), item('y'), item('z', 'x')], ['x'])).toEqual(['x', '-z', 'y']) + }) + + it('treats a missing parent and a self reference as top level', () => { + expect(rows([item('a', 'a'), item('b', undefined)])).toEqual(['a', 'b']) + }) + + it('returns nothing for an empty list', () => { + expect(rows([])).toEqual([]) + }) + + it('keeps the first of duplicated ids', () => { + expect(rows([item('a'), item('a'), item('b')])).toEqual(['a', 'b']) + }) + + it('does not depend on the parent coming first', () => { + expect(rows([item('c', 'p'), item('p')], ['p'])).toEqual(['p', '-c']) + }) + + it('never loops on a cycle and does not lose its items', () => { + const cyclic = [item('a', 'b'), item('b', 'a'), item('c')] + const res = rows(cyclic, ['a', 'b']) + expect([...res].map((r) => r.replace(/-/g, '')).sort()).toEqual(['a', 'b', 'c']) + expect(res).toContain('c') + }) + + it('stops nesting at the deepest level and keeps the deeper items as top level rows after their tree', () => { + const chain: Item[] = [] + for (let i = 0; i < 12; i++) chain.push(item(`n${i}`, i === 0 ? undefined : `n${i - 1}`)) + chain.push(item('other')) + const all = chain.map((c) => c.id) + const res = rows(chain, all) + // n0..n7 are nested (8 levels), n8 starts again at the top level, `other` comes last + expect(res.slice(0, MAX_HIERARCHY_DEPTH)).toEqual(['n0', '-n1', '--n2', '---n3', '----n4', '-----n5', '------n6', '-------n7']) + expect(res[MAX_HIERARCHY_DEPTH]).toBe('n8') + expect(res[res.length - 1]).toBe('other') + expect(res.map((r) => r.replace(/-/g, '')).sort()).toEqual([...all].sort()) + // the deepest nested row has nothing to expand + const deepest = buildHierarchyRows(chain, { idOf, parentOf, expanded: new Set(all) })[MAX_HIERARCHY_DEPTH - 1] + expect(deepest.hasChildren).toBe(false) + }) + + it('honours a custom depth', () => { + expect(rows(tree, ['a', 'a1', 'b'], 2)).toEqual(['a', '-a1', '-a2', 'a11', 'b', '-b1']) + }) + + it('keeps the descendants of a collapsed deep branch hidden', () => { + const chain: Item[] = [] + for (let i = 0; i < 12; i++) chain.push(item(`n${i}`, i === 0 ? undefined : `n${i - 1}`)) + // n0 is collapsed: only it is shown, the deep items must not turn up as top level rows + expect(rows(chain, [])).toEqual(['n0']) + }) + + it('handles a large flat list and a large tree quickly', () => { + const items: Item[] = [] + for (let i = 0; i < 20000; i++) items.push(item(`r${i}`)) + for (let i = 0; i < 20000; i++) items.push(item(`c${i}`, `r${i}`)) + const start = Date.now() + const collapsed = buildHierarchyRows(items, { idOf, parentOf, expanded: new Set() }) + expect(collapsed).toHaveLength(20000) + const expanded = buildHierarchyRows(items, { + idOf, + parentOf, + expanded: new Set(items.filter((it) => it.id.startsWith('r')).map((it) => it.id)) + }) + expect(expanded).toHaveLength(40000) + expect(Date.now() - start).toBeLessThan(3000) + }) + + it('does not overflow the stack for a very long chain', () => { + const items: Item[] = [] + for (let i = 0; i < 50000; i++) items.push(item(`n${i}`, i === 0 ? undefined : `n${i - 1}`)) + const res = buildHierarchyRows(items, { idOf, parentOf, expanded: new Set(items.map(idOf)) }) + expect(res).toHaveLength(50000) + }) +}) + +describe('expandableIds', () => { + it('lists the items that have children in the list', () => { + const items = [item('a'), item('a1', 'a'), item('b'), item('c1', 'gone'), item('s', 's')] + expect(expandableIds(items, idOf, parentOf)).toEqual(['a']) + }) +}) diff --git a/plugins/view-resources/src/__tests__/nestedGroups.test.ts b/plugins/view-resources/src/__tests__/nestedGroups.test.ts new file mode 100644 index 0000000000..fc4daca452 --- /dev/null +++ b/plugins/view-resources/src/__tests__/nestedGroups.test.ts @@ -0,0 +1,132 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { isViewDirty } from '../savedViews' +import { + categoriesWithDocs, + emptyCategoryLast, + groupByFromRows, + groupByRows, + isEmptyCategory, + NO_GROUPING, + selectGroupLevel +} from '../nestedGroups' + +describe('group-by rows', () => { + it('offers a row for the next level while the layout allows one', () => { + expect(groupByRows([NO_GROUPING], 3)).toEqual([NO_GROUPING]) + expect(groupByRows(['status'], 3)).toEqual(['status', NO_GROUPING]) + expect(groupByRows(['status', 'assignee'], 3)).toEqual(['status', 'assignee', NO_GROUPING]) + expect(groupByRows(['status', 'assignee', 'priority'], 3)).toEqual(['status', 'assignee', 'priority']) + expect(groupByRows(['status'], 1)).toEqual(['status']) + expect(groupByRows(['status'])).toEqual(['status', NO_GROUPING]) + expect(groupByRows([], 3)).toEqual([NO_GROUPING]) + }) + + it('stops at a layout of two levels', () => { + expect(groupByRows(['status'], 2)).toEqual(['status', NO_GROUPING]) + expect(groupByRows(['status', 'assignee'], 2)).toEqual(['status', 'assignee']) + }) +}) + +describe('selecting a level', () => { + it('adds a level and offers the next one up to the depth', () => { + let rows = groupByRows(['status'], 3) + rows = selectGroupLevel(rows, 1, 'assignee', 3) + expect(rows).toEqual(['status', 'assignee', NO_GROUPING]) + expect(groupByFromRows(rows)).toEqual(['status', 'assignee']) + rows = selectGroupLevel(rows, 2, 'priority', 3) + // the third level is the last: no fourth row + expect(rows).toEqual(['status', 'assignee', 'priority']) + expect(groupByFromRows(rows)).toEqual(['status', 'assignee', 'priority']) + }) + + it('never goes beyond the depth when levels are changed in a popup that stays open', () => { + let rows = groupByRows(['a'], 3) + for (const [index, key] of [[1, 'b'], [2, 'c'], [2, 'd'], [1, 'e']] as Array<[number, string]>) { + rows = selectGroupLevel(rows, index, key, 3) + expect(groupByFromRows(rows).length).toBeLessThanOrEqual(3) + expect(rows.length).toBeLessThanOrEqual(3) + } + }) + + it('ends the levels at "No grouping"', () => { + const rows = selectGroupLevel(['status', 'assignee', 'priority'], 1, NO_GROUPING, 3) + expect(rows).toEqual(['status', NO_GROUPING]) + expect(groupByFromRows(rows)).toEqual(['status']) + expect(groupByFromRows(selectGroupLevel(['status', NO_GROUPING], 0, NO_GROUPING, 3))).toEqual([NO_GROUPING]) + }) + + it('keeps the levels below a changed one and drops a repeated key', () => { + expect(selectGroupLevel(['a', 'b', 'c'], 0, 'x', 3)).toEqual(['x', 'b', 'c']) + expect(selectGroupLevel(['a', 'b', 'c'], 0, 'c', 3)).toEqual(['c', 'b', NO_GROUPING]) + expect(selectGroupLevel(['a', 'b', NO_GROUPING], 1, 'z', 3)).toEqual(['a', 'z', NO_GROUPING]) + }) + + it('works for a layout of one level like it always did', () => { + expect(selectGroupLevel(['status'], 0, 'assignee', 1)).toEqual(['assignee']) + expect(selectGroupLevel(['status'], 0, NO_GROUPING, 1)).toEqual([NO_GROUPING]) + }) + + it('has no limit without a depth', () => { + expect(selectGroupLevel(['a', 'b', 'c', 'd'], 3, 'e')).toEqual(['a', 'b', 'c', 'e', NO_GROUPING]) + }) +}) + +describe('group-by and the unsaved changes of a view', () => { + const saved = (groupBy: string[]): any => ({ viewOptions: { groupBy, orderBy: ['modifiedOn', -1] } }) + + it('a stored single group-by stays clean when it is set again', () => { + const rows = groupByRows(['status'], 3) + // the controls offer "No grouping" for the next level; choosing nothing leaves the view options as they were + expect(groupByFromRows(rows)).toEqual(['status']) + expect(isViewDirty(saved(['status']), saved(groupByFromRows(rows)))).toBe(false) + }) + + it('adding and removing a level changes the view and changing it back cleans it', () => { + const baseline = saved(['status']) + const nested = saved(groupByFromRows(selectGroupLevel(['status', NO_GROUPING], 1, 'assignee', 3))) + expect(isViewDirty(baseline, nested)).toBe(true) + const back = saved(groupByFromRows(selectGroupLevel(['status', 'assignee', NO_GROUPING], 1, NO_GROUPING, 3))) + expect(isViewDirty(baseline, back)).toBe(false) + }) + + it('the order of the levels is a change', () => { + expect(isViewDirty(saved(['status', 'assignee']), saved(['assignee', 'status']))).toBe(true) + }) +}) + +describe('the group without a value', () => { + it('is recognised in every form a list uses', () => { + expect(isEmptyCategory(undefined)).toBe(true) + expect(isEmptyCategory(null)).toBe(true) + expect(isEmptyCategory({ name: undefined, values: [] })).toBe(true) + expect(isEmptyCategory({ name: 'x', values: [] })).toBe(false) + expect(isEmptyCategory('a')).toBe(false) + expect(isEmptyCategory(0)).toBe(false) + }) + + it('goes last and the other groups keep their order', () => { + expect(emptyCategoryLast([undefined, 'a', 'b'])).toEqual(['a', 'b', undefined]) + expect(emptyCategoryLast(['a', undefined, 'b'])).toEqual(['a', 'b', undefined]) + const none = { name: undefined, values: [] } + const x = { name: 'x', values: [] } + expect(emptyCategoryLast([none, x])).toEqual([x, none]) + }) + + it('returns the same array when nothing has to move', () => { + const list = ['a', 'b', undefined] + expect(emptyCategoryLast(list)).toBe(list) + const noEmpty = ['a', 'b'] + expect(emptyCategoryLast(noEmpty)).toBe(noEmpty) + }) +}) + +describe('groups below the first level', () => { + it('keeps the groups that hold documents', () => { + const counts: Record = { a: 2, b: 0, undefined: 1 } + expect(categoriesWithDocs(['a', 'b', undefined], (c) => counts[String(c)])).toEqual(['a', undefined]) + }) +}) diff --git a/plugins/view-resources/src/__tests__/savedViews.test.ts b/plugins/view-resources/src/__tests__/savedViews.test.ts new file mode 100644 index 0000000000..c1a392642a --- /dev/null +++ b/plugins/view-resources/src/__tests__/savedViews.test.ts @@ -0,0 +1,237 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + compareViews, + duplicateViewName, + getEffectiveViewConfig, + isViewDirty, + nextOrder, + nextViewName, + orderForMove, + parseStoredFilters, + sortViews, + viewFromLink, + viewIdFromQuery, + viewLinkQuery, + VIEW_LINK_PARAM +} from '../savedViews' + +const opts = (groupBy: string): any => ({ groupBy: [groupBy], orderBy: ['modifiedOn', -1] }) + +describe('getEffectiveViewConfig', () => { + it('falls back to defaults when nothing else is set', () => { + const res = getEffectiveViewConfig({ + defaults: { viewletId: 'list', viewOptions: opts('status'), config: ['a'] } + }) + expect(res.viewletId).toBe('list') + expect(res.config).toEqual(['a']) + expect(res.viewOptions).toEqual(opts('status')) + expect(res.filters).toBe('[]') + }) + + it('global preference overrides defaults for columns', () => { + const res = getEffectiveViewConfig({ defaults: { config: ['a'] }, preference: ['b'] }) + expect(res.config).toEqual(['b']) + }) + + it('saved view overrides the global preference', () => { + const res = getEffectiveViewConfig({ preference: ['b'], saved: { config: ['c'], filters: '[1]' } }) + expect(res.config).toEqual(['c']) + expect(res.filters).toBe('[1]') + }) + + it('local edits override the saved view', () => { + const res = getEffectiveViewConfig({ + preference: ['b'], + saved: { config: ['c'], viewOptions: opts('status'), filters: '[1]', extra: 'x' }, + local: { config: ['d'], viewOptions: opts('priority'), filters: '[2]', extra: 'y' } + }) + expect(res.config).toEqual(['d']) + expect(res.viewOptions).toEqual(opts('priority')) + expect(res.filters).toBe('[2]') + expect(res.extra).toBe('y') + }) + + it('merges field by field', () => { + const res = getEffectiveViewConfig({ + saved: { config: ['c'], viewOptions: opts('status') }, + local: { filters: '[2]' } + }) + expect(res.config).toEqual(['c']) + expect(res.viewOptions).toEqual(opts('status')) + expect(res.filters).toBe('[2]') + }) + + it('treats an empty column list as not specified', () => { + const res = getEffectiveViewConfig({ preference: ['b'], saved: { config: [] }, local: { config: [] } }) + expect(res.config).toEqual(['b']) + }) + + it('ignores layout specific parts of layers authored for another layout', () => { + const res = getEffectiveViewConfig({ + preference: ['b'], + saved: { viewletId: 'list', config: ['c'], viewOptions: opts('status'), filters: '[1]' }, + currentViewletId: 'kanban' + }) + expect(res.config).toEqual(['b']) + expect(res.viewOptions).toBeUndefined() + expect(res.filters).toBe('[1]') + }) + + it('keeps layers when the displayed layout matches', () => { + const res = getEffectiveViewConfig({ + saved: { viewletId: 'list', config: ['c'] }, + currentViewletId: 'list' + }) + expect(res.config).toEqual(['c']) + }) +}) + +describe('isViewDirty', () => { + const base = { viewletId: 'list', filters: '[]', viewOptions: opts('status'), config: ['a'] } + + it('is clean for an identical state', () => { + expect(isViewDirty(base, { ...base, viewOptions: opts('status'), config: ['a'] })).toBe(false) + }) + + it('normalizes absent and empty values', () => { + expect(isViewDirty({ viewletId: null }, { filters: '', config: [], extra: '[]', filterQuery: ' ' })).toBe(false) + }) + + it('detects a change of each part', () => { + expect(isViewDirty(base, { ...base, viewletId: 'kanban' })).toBe(true) + expect(isViewDirty(base, { ...base, filters: '[1]' })).toBe(true) + expect(isViewDirty(base, { ...base, viewOptions: opts('priority') })).toBe(true) + expect(isViewDirty(base, { ...base, config: ['a', 'b'] })).toBe(true) + expect(isViewDirty(base, { ...base, extra: '[{"k":1}]' })).toBe(true) + expect(isViewDirty(base, { ...base, filterQuery: 'status:Done' })).toBe(true) + }) + + it('ignores whitespace around the filter string only', () => { + const q = { ...base, filterQuery: 'status:Done' } + expect(isViewDirty(q, { ...base, filterQuery: ' status:Done ' })).toBe(false) + expect(isViewDirty(q, { ...base, filterQuery: 'status:Todo' })).toBe(true) + expect(isViewDirty(q, base)).toBe(true) + }) + + it('detects column reorder', () => { + expect(isViewDirty({ config: ['a', 'b'] }, { config: ['b', 'a'] })).toBe(true) + }) +}) + +describe('view naming', () => { + it('names new views View N', () => { + expect(nextViewName([])).toBe('View 1') + expect(nextViewName(['View 1'])).toBe('View 2') + }) + + it('skips taken numbers', () => { + expect(nextViewName(['View 2', 'Other'])).toBe('View 3') + expect(nextViewName(['View 1', 'view 3'])).toBe('View 4') + }) + + it('duplicates as X (copy)', () => { + expect(duplicateViewName('Bugs', ['Bugs'])).toBe('Bugs (copy)') + }) + + it('numbers repeated duplicates', () => { + expect(duplicateViewName('Bugs', ['Bugs', 'Bugs (copy)'])).toBe('Bugs (copy 2)') + expect(duplicateViewName('Bugs', ['Bugs', 'Bugs (copy)', 'Bugs (copy 2)'])).toBe('Bugs (copy 3)') + }) +}) + +describe('view order', () => { + const v = (_id: string, order?: number, createdOn?: number): any => ({ _id, order, createdOn }) + + it('sorts by order, unordered last, ties by creation', () => { + const sorted = sortViews([v('c'), v('b', 1), v('a', 0), v('d', 1, -5)]) + expect(sorted.map((it) => it._id)).toEqual(['a', 'd', 'b', 'c']) + expect(compareViews(v('x', 1), v('y', 2))).toBeLessThan(0) + }) + + it('appends after the last order', () => { + expect(nextOrder([])).toBe(0) + expect(nextOrder([v('a', 0), v('b', 4), v('c')])).toBe(5) + }) + + it('places a moved view between its new neighbours', () => { + const views = [v('a', 0), v('b', 1), v('c', 2)] + expect(orderForMove(views, 'c', 1)).toBe(0.5) + expect(orderForMove(views, 'a', 1)).toBe(1.5) + }) + + it('handles the ends', () => { + const views = [v('a', 0), v('b', 1), v('c', 2)] + expect(orderForMove(views, 'c', 0)).toBe(-1) + expect(orderForMove(views, 'a', 2)).toBe(3) + expect(orderForMove(views, 'a', 99)).toBe(3) + }) + + it('returns undefined when the position does not change or the view is unknown', () => { + const views = [v('a', 0), v('b', 1)] + expect(orderForMove(views, 'a', 0)).toBeUndefined() + expect(orderForMove(views, 'zz', 0)).toBeUndefined() + }) + + it('keeps the resulting order consistent after repeated moves', () => { + let views = [v('a', 0), v('b', 1), v('c', 2)] + const moved = orderForMove(views, 'c', 0) as number + views = views.map((it) => (it._id === 'c' ? { ...it, order: moved } : it)) + expect(sortViews(views).map((it) => it._id)).toEqual(['c', 'a', 'b']) + const moved2 = orderForMove(views, 'c', 1) as number + views = views.map((it) => (it._id === 'c' ? { ...it, order: moved2 } : it)) + expect(sortViews(views).map((it) => it._id)).toEqual(['a', 'c', 'b']) + }) +}) + +describe('parseStoredFilters', () => { + it('parses arrays and tolerates garbage', () => { + expect(parseStoredFilters('[{"a":1}]')).toEqual([{ a: 1 }]) + expect(parseStoredFilters(undefined)).toEqual([]) + expect(parseStoredFilters('{bad')).toEqual([]) + expect(parseStoredFilters('{"a":1}')).toEqual([]) + }) +}) + +describe('getEffectiveViewConfig filterQuery', () => { + it('is empty by default and for old views without a filter string', () => { + expect(getEffectiveViewConfig({}).filterQuery).toBe('') + expect(getEffectiveViewConfig({ saved: { filters: '[]' } }).filterQuery).toBe('') + }) + + it('prefers local edits over the saved view over defaults', () => { + expect(getEffectiveViewConfig({ defaults: { filterQuery: 'a' }, saved: { filterQuery: 'b' } }).filterQuery).toBe('b') + expect( + getEffectiveViewConfig({ saved: { filterQuery: 'b' }, local: { filterQuery: '' } }).filterQuery + ).toBe('') + }) +}) + +describe('links to a view', () => { + it('reads the view a location asks for', () => { + expect(VIEW_LINK_PARAM).toBe('view') + expect(viewIdFromQuery({ view: 'abc' })).toBe('abc') + expect(viewIdFromQuery({ view: ' abc ' })).toBe('abc') + expect(viewIdFromQuery({ mode: 'all' })).toBeUndefined() + expect(viewIdFromQuery({ view: null })).toBeUndefined() + expect(viewIdFromQuery({ view: '' })).toBeUndefined() + expect(viewIdFromQuery(undefined)).toBeUndefined() + }) + + it('builds the query of a link to a stored view only', () => { + expect(viewLinkQuery('abc')).toEqual({ view: 'abc' }) + expect(viewLinkQuery('default-view')).toBeUndefined() + expect(viewLinkQuery(undefined)).toBeUndefined() + }) + + it('finds the tab a link asks for and ignores a view the project does not have', () => { + const tabs = [{ _id: 'a' }, { _id: 'b' }] + expect(viewFromLink({ view: 'b' }, tabs)).toBe(tabs[1]) + expect(viewFromLink({ view: 'zzz' }, tabs)).toBeUndefined() + expect(viewFromLink({}, tabs)).toBeUndefined() + expect(viewFromLink({ view: 'a' }, [])).toBeUndefined() + }) +}) diff --git a/plugins/view-resources/src/__tests__/tableEdit.test.ts b/plugins/view-resources/src/__tests__/tableEdit.test.ts new file mode 100644 index 0000000000..f535d7d500 --- /dev/null +++ b/plugins/view-resources/src/__tests__/tableEdit.test.ts @@ -0,0 +1,444 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + EditJournal, + coalesceOps, + copyRect, + createBatch, + decodeTsv, + encodeTsv, + extendRectDown, + extendSelection, + fillDown, + formatDateValue, + formatMultiOptionValue, + invertOps, + isNoop, + movePos, + moveSelection, + parseDateValue, + parseMultiOptionValue, + parseNumberValue, + parseOptionValue, + parseTextValue, + placePaste, + planClear, + planEdits, + rectCells, + rectOf, + tabPos, + updateOp, + type CellAdapter, + type CellColumn, + type DocTarget, + type EditOp +} from '../tableEdit' + +const dims = { rows: 4, cols: 3 } + +describe('tsv', () => { + it('encodes rows with tabs and line breaks', () => { + expect( + encodeTsv([ + ['a', 'b'], + ['c', ''] + ]) + ).toBe('a\tb\nc\t') + }) + + it('quotes cells that contain separators or quotes', () => { + expect(encodeTsv([['a\tb', 'say "hi"', 'x\ny']])).toBe('"a\tb"\t"say ""hi"""\t"x\ny"') + }) + + it('decodes what it encodes', () => { + const block = [ + ['plain', 'with\ttab', 'with "quote"'], + ['multi\nline', '', 'end'] + ] + expect(decodeTsv(encodeTsv(block))).toEqual(block) + }) + + it('ignores one trailing line break and understands CRLF', () => { + expect(decodeTsv('a\tb\r\nc\td\r\n')).toEqual([ + ['a', 'b'], + ['c', 'd'] + ]) + expect(decodeTsv('a\n')).toEqual([['a']]) + }) + + it('keeps a trailing empty cell and empty rows in between', () => { + expect(decodeTsv('a\t\n\nb')).toEqual([['a', ''], [''], ['b']]) + }) + + it('gives no rows for empty text', () => { + expect(decodeTsv('')).toEqual([]) + }) + + it('does not treat a quote in the middle of a cell as quoting', () => { + expect(decodeTsv('5" pipe\tx')).toEqual([['5" pipe', 'x']]) + }) +}) + +describe('grid', () => { + it('moves and clamps at the edges', () => { + expect(movePos({ row: 0, col: 0 }, 'up', dims)).toEqual({ row: 0, col: 0 }) + expect(movePos({ row: 3, col: 2 }, 'down', dims)).toEqual({ row: 3, col: 2 }) + expect(movePos({ row: 1, col: 1 }, 'right', dims)).toEqual({ row: 1, col: 2 }) + expect(movePos({ row: 1, col: 1 }, 'down', dims, true)).toEqual({ row: 3, col: 1 }) + expect(movePos({ row: 1, col: 1 }, 'left', dims, true)).toEqual({ row: 1, col: 0 }) + }) + + it('collapses the selection on a plain move and keeps the anchor when extending', () => { + const sel = { anchor: { row: 1, col: 1 }, focus: { row: 2, col: 2 } } + expect(moveSelection(sel, 'up', dims)).toEqual({ anchor: { row: 1, col: 2 }, focus: { row: 1, col: 2 } }) + const ext = extendSelection({ anchor: { row: 1, col: 1 }, focus: { row: 1, col: 1 } }, 'down', dims) + expect(ext).toEqual({ anchor: { row: 1, col: 1 }, focus: { row: 2, col: 1 } }) + expect(rectOf(ext.anchor, ext.focus)).toEqual({ top: 1, bottom: 2, left: 1, right: 1 }) + }) + + it('tabs across rows', () => { + expect(tabPos({ row: 0, col: 1 }, dims, false)).toEqual({ row: 0, col: 2 }) + expect(tabPos({ row: 0, col: 2 }, dims, false)).toEqual({ row: 1, col: 0 }) + expect(tabPos({ row: 3, col: 2 }, dims, false)).toEqual({ row: 3, col: 2 }) + expect(tabPos({ row: 1, col: 0 }, dims, true)).toEqual({ row: 0, col: 2 }) + expect(tabPos({ row: 0, col: 0 }, dims, true)).toEqual({ row: 0, col: 0 }) + }) + + it('lists the cells of a rectangle row by row', () => { + expect(rectCells({ top: 0, bottom: 1, left: 1, right: 2 })).toEqual([ + { row: 0, col: 1 }, + { row: 0, col: 2 }, + { row: 1, col: 1 }, + { row: 1, col: 2 } + ]) + }) + + it('fills the whole selection with a single pasted value', () => { + const res = placePaste({ top: 0, bottom: 1, left: 0, right: 1 }, [['x']], dims) + expect(res.cells).toHaveLength(4) + expect(res.clipped).toBe(0) + }) + + it('places a pasted block from the top left of the selection and clips it', () => { + const res = placePaste({ top: 2, bottom: 2, left: 1, right: 1 }, [['a', 'b'], ['c', 'd'], ['e']], dims) + // Column 2 is the last one, row 3 the last row: b is at (2,2), c at (3,1), d (3,2) fits, e is below the grid + expect(res.cells.map((c) => [c.target.row, c.target.col, c.fromRow, c.fromCol])).toEqual([ + [2, 1, 0, 0], + [2, 2, 0, 1], + [3, 1, 1, 0], + [3, 2, 1, 1] + ]) + expect(res.clipped).toBe(1) + }) + + it('places nothing for an empty block', () => { + expect(placePaste({ top: 0, bottom: 0, left: 0, right: 0 }, [], dims).cells).toEqual([]) + }) + + it('fills down by repeating the selected rows', () => { + const sel = { top: 0, bottom: 1, left: 0, right: 0 } + const res = fillDown(sel, 4, { rows: 6, cols: 3 }) + expect(res.map((f) => [f.target.row, f.source.row])).toEqual([ + [2, 0], + [3, 1], + [4, 0] + ]) + }) + + it('copies a single row to every row below and covers several columns', () => { + const res = fillDown({ top: 1, bottom: 1, left: 0, right: 1 }, 2, dims) + expect(res).toEqual([ + { target: { row: 2, col: 0 }, source: { row: 1, col: 0 } }, + { target: { row: 2, col: 1 }, source: { row: 1, col: 1 } } + ]) + }) + + it('fills nothing when dragged up or inside the selection, and stops at the grid end', () => { + expect(fillDown({ top: 1, bottom: 2, left: 0, right: 0 }, 2, dims)).toEqual([]) + expect(fillDown({ top: 1, bottom: 2, left: 0, right: 0 }, 0, dims)).toEqual([]) + expect(fillDown({ top: 1, bottom: 2, left: 0, right: 0 }, 99, dims)).toHaveLength(1) + expect(extendRectDown({ top: 1, bottom: 2, left: 0, right: 0 }, 99, dims).bottom).toBe(3) + }) + + it('copies a rectangle through a reader', () => { + const rows = copyRect({ top: 1, bottom: 2, left: 0, right: 1 }, (r, c) => `${r}${c}`) + expect(rows).toEqual([ + ['10', '11'], + ['20', '21'] + ]) + }) +}) + +describe('values', () => { + it('parses text, empty means clear', () => { + expect(parseTextValue(' hi ')).toEqual({ ok: true, value: 'hi' }) + expect(parseTextValue(' ')).toEqual({ ok: true, value: null }) + }) + + it('parses numbers', () => { + expect(parseNumberValue('12.5')).toEqual({ ok: true, value: 12.5 }) + expect(parseNumberValue('-3')).toEqual({ ok: true, value: -3 }) + expect(parseNumberValue('')).toEqual({ ok: true, value: null }) + expect(parseNumberValue('abc')).toEqual({ ok: false, reason: 'invalid' }) + expect(parseNumberValue('1,5')).toEqual({ ok: false, reason: 'invalid' }) + expect(parseNumberValue('1.5', { integer: true })).toEqual({ ok: false, reason: 'invalid' }) + expect(parseNumberValue('-1', { min: 0 })).toEqual({ ok: false, reason: 'invalid' }) + }) + + it('parses dates as the start of the local day', () => { + const res = parseDateValue('2026-03-05') + expect(res).toEqual({ ok: true, value: new Date(2026, 2, 5).getTime() }) + expect(parseDateValue('2026-03-05 14:30')).toEqual(res) + expect(parseDateValue('2026/3/5')).toEqual(res) + expect(parseDateValue('')).toEqual({ ok: true, value: null }) + expect(parseDateValue('2026-02-31')).toEqual({ ok: false, reason: 'invalid' }) + expect(parseDateValue('tomorrow')).toEqual({ ok: false, reason: 'invalid' }) + }) + + it('understands @today with an offset', () => { + const now = new Date(2026, 0, 31, 15, 0).getTime() + expect(parseDateValue('@today+1d', now)).toEqual({ ok: true, value: new Date(2026, 1, 1).getTime() }) + expect(parseDateValue('@today', now)).toEqual({ ok: true, value: new Date(2026, 0, 31).getTime() }) + }) + + it('formats dates', () => { + expect(formatDateValue(new Date(2026, 2, 5, 18).getTime())).toBe('2026-03-05') + expect(formatDateValue(null)).toBe('') + expect(formatDateValue(undefined)).toBe('') + }) + + const options = [ + { id: 'a', label: 'Alpha' }, + { id: 'b', label: 'Beta' }, + { id: 'c', label: 'Beta' } + ] + + it('parses a single option by label, then by id', () => { + expect(parseOptionValue(' alpha ', options)).toEqual({ ok: true, value: 'a' }) + expect(parseOptionValue('A', options)).toEqual({ ok: true, value: 'a' }) + expect(parseOptionValue('', options)).toEqual({ ok: true, value: null }) + expect(parseOptionValue('Gamma', options)).toEqual({ ok: false, reason: 'unknown' }) + expect(parseOptionValue('beta', options)).toEqual({ ok: false, reason: 'ambiguous' }) + }) + + it('parses numeric ids', () => { + expect(parseOptionValue('High', [{ id: 2, label: 'High' }])).toEqual({ ok: true, value: 2 }) + }) + + it('parses several options and rejects the cell on an unknown one', () => { + expect(parseMultiOptionValue('Alpha, alpha; ', options)).toEqual({ ok: true, value: ['a'] }) + expect(parseMultiOptionValue('', options)).toEqual({ ok: true, value: [] }) + expect(parseMultiOptionValue('Alpha, Nope', options)).toEqual({ ok: false, reason: 'unknown' }) + expect(formatMultiOptionValue(['a', 'b'], options)).toBe('Alpha, Beta') + }) +}) + +const target = (id: string): DocTarget => ({ _id: id, _class: 'cls', space: 'sp' }) + +describe('journal', () => { + const doc = { _id: 'd1', title: 'Old', dueDate: undefined } + + it('records previous values, null when unset', () => { + const op = updateOp(target('d1'), doc, { title: 'New', dueDate: 5 }) + expect(op).toEqual({ + kind: 'update', + target: target('d1'), + before: { title: 'Old', dueDate: null }, + after: { title: 'New', dueDate: 5 } + }) + expect(isNoop(updateOp(target('d1'), doc, { title: 'Old' }))).toBe(true) + expect(isNoop(updateOp(target('d1'), doc, { dueDate: null }))).toBe(true) + }) + + it('coalesces updates of one document keeping the first before and the last after', () => { + const ops: EditOp[] = [ + { kind: 'update', target: target('d1'), before: { a: 1 }, after: { a: 2 } }, + { kind: 'update', target: target('d1'), before: { a: 2, b: 0 }, after: { a: 3, b: 1 } }, + { kind: 'update', target: target('d2'), before: { a: 1 }, after: { a: 1 } } + ] + expect(coalesceOps(ops)).toEqual([ + { kind: 'update', target: target('d1'), before: { a: 1, b: 0 }, after: { a: 3, b: 1 } } + ]) + }) + + it('inverts operations in reverse order', () => { + const ops: EditOp[] = [ + { kind: 'update', target: target('d1'), before: { a: 1 }, after: { a: 2 } }, + { kind: 'add', target: target('t1'), attributes: { tag: 'x' } } + ] + expect(invertOps(ops)).toEqual([ + { kind: 'remove', target: target('t1'), attributes: { tag: 'x' } }, + { kind: 'update', target: target('d1'), before: { a: 2 }, after: { a: 1 } } + ]) + }) + + it('undoes and redoes batches', async () => { + const journal = new EditJournal() + const ran: EditOp[][] = [] + const run = async (ops: readonly EditOp[]): Promise => { + ran.push([...ops]) + } + const batch = createBatch('b1', [ + { kind: 'update', target: target('d1'), before: { a: 1 }, after: { a: 2 } }, + { kind: 'update', target: target('d2'), before: { a: 1 }, after: { a: 2 } } + ]) + expect(batch.count).toBe(2) + journal.record(batch) + expect(journal.canUndo).toBe(true) + + expect(await journal.undo(run, 'other')).toBeUndefined() + expect(await journal.undo(run, 'b1')).toBe(batch) + expect(ran[0][0]).toEqual({ kind: 'update', target: target('d2'), before: { a: 2 }, after: { a: 1 } }) + expect(journal.canUndo).toBe(false) + expect(journal.canRedo).toBe(true) + + expect(await journal.redo(run)).toBe(batch) + expect(ran[1]).toEqual(batch.ops) + expect(journal.canUndo).toBe(true) + }) + + it('keeps the batch undoable when reverting fails and drops redo on a new batch', async () => { + const journal = new EditJournal(2) + journal.record(createBatch('b1', [{ kind: 'add', target: target('x'), attributes: {} }])) + await expect( + journal.undo(async () => { + throw new Error('offline') + }) + ).rejects.toThrow('offline') + expect(journal.canUndo).toBe(true) + + await journal.undo(async () => {}) + expect(journal.canRedo).toBe(true) + journal.record(createBatch('b2', [{ kind: 'add', target: target('y'), attributes: {} }])) + expect(journal.canRedo).toBe(false) + + journal.record(createBatch('b3', [{ kind: 'add', target: target('z'), attributes: {} }])) + journal.record(createBatch('b4', [{ kind: 'add', target: target('w'), attributes: {} }])) + // The oldest batch fell off the limit of two + expect(journal.lastBatch?.id).toBe('b4') + journal.clear() + expect(journal.canUndo).toBe(false) + }) + + it('ignores empty batches', () => { + const journal = new EditJournal() + journal.record(createBatch('e', [])) + expect(journal.canUndo).toBe(false) + }) +}) + +interface Item { + _id: string + title: string + estimate?: number + customFields?: Record +} + +const itemTarget = (item: Item): DocTarget => ({ _id: item._id, _class: 'item', space: 'sp' }) + +const columns: Record> = { + title: { + key: 'title', + format: (d) => d.title, + edit: (d, text) => { + const t = text.trim() + return t === '' ? { ok: false, reason: 'notClearable' } : { ok: true, value: [updateOp(itemTarget(d), d as any, { title: t })] } + } + }, + estimate: { + key: 'estimate', + clearable: true, + format: (d) => (d.estimate === undefined ? '' : String(d.estimate)), + edit: (d, text) => { + const n = parseNumberValue(text) + return n.ok ? { ok: true, value: [updateOp(itemTarget(d), d as any, { estimate: n.value })] } : n + } + }, + cfA: { + key: 'cfA', + clearable: true, + format: () => '', + edit: (d, text) => ({ + ok: true, + value: [updateOp(itemTarget(d), d as any, { customFields: { ...d.customFields, a: text } })] + }) + }, + cfB: { + key: 'cfB', + clearable: true, + format: () => '', + edit: (d, text) => ({ + ok: true, + value: [updateOp(itemTarget(d), d as any, { customFields: { ...d.customFields, b: text } })] + }) + }, + id: { key: 'id', format: (d) => d._id } +} +const adapter: Pick, 'column'> = { column: (key) => columns[key] } + +describe('planEdits', () => { + const d1: Item = { _id: 'd1', title: 'One', estimate: 1 } + const d2: Item = { _id: 'd2', title: 'Two' } + + it('applies valid cells and counts skipped ones', () => { + const plan = planEdits( + [ + { doc: d1, key: 'estimate', text: '5' }, + { doc: d2, key: 'estimate', text: 'abc' }, + { doc: d2, key: 'id', text: 'x' }, + { doc: d2, key: 'missing', text: 'x' }, + { doc: d2, key: 'title', text: 'Renamed' } + ], + adapter + ) + expect(plan.applied).toBe(2) + expect(plan.skipped).toBe(3) + expect(plan.skippedBy).toEqual({ invalid: 1, readonly: 1, noColumn: 1 }) + expect(plan.items).toBe(2) + expect(plan.ops).toHaveLength(2) + }) + + it('does not count cells that already hold the value', () => { + const plan = planEdits([{ doc: d1, key: 'estimate', text: '1' }], adapter) + expect(plan).toMatchObject({ applied: 0, unchanged: 1, skipped: 0, ops: [] }) + }) + + it('combines several cells of one document so that none is lost', () => { + const plan = planEdits( + [ + { doc: d1, key: 'cfA', text: 'x' }, + { doc: d1, key: 'cfB', text: 'y' } + ], + adapter + ) + expect(plan.ops).toEqual([ + { + kind: 'update', + target: itemTarget(d1), + before: { customFields: null }, + after: { customFields: { a: 'x', b: 'y' } } + } + ]) + expect(plan.applied).toBe(2) + expect(plan.items).toBe(1) + }) + + it('clears clearable cells and rejects the others', () => { + const plan = planClear( + [ + { doc: d1, key: 'estimate' }, + { doc: d1, key: 'title' }, + { doc: d1, key: 'id' } + ], + adapter + ) + expect(plan.applied).toBe(1) + expect(plan.skippedBy).toEqual({ notClearable: 1, readonly: 1 }) + expect(plan.ops).toEqual([ + { kind: 'update', target: itemTarget(d1), before: { estimate: 1 }, after: { estimate: null } } + ]) + }) +}) diff --git a/plugins/view-resources/src/clientViewExtension.ts b/plugins/view-resources/src/clientViewExtension.ts new file mode 100644 index 0000000000..318e91d3aa --- /dev/null +++ b/plugins/view-resources/src/clientViewExtension.ts @@ -0,0 +1,121 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { CategoryType, Doc, SortingOrder } from '@hcengineering/core' +import type { AnySvelteComponent } from '@hcengineering/ui' +import type { BuildModelKey, ViewOptions } from '@hcengineering/view' +import { get, writable, type Readable } from 'svelte/store' + +/** + * Option offered in the Group-by / Order-by dropdowns. + * @public + */ +export interface ClientViewKey { + id: string + // Plain (already translated) label + label: string +} + +/** + * Optional list column offered in "Configure columns". + * @public + */ +export interface ClientViewColumn { + key: BuildModelKey + // Plain (already translated) label + label: string +} + +/** + * Tree display of a list: the documents are shown nested under their parents. A list takes part only when + * `isEnabled` accepts its view options, so lists of other views are never affected. + * @public + */ +export interface ClientHierarchy { + // Whether the view options ask for the tree + isEnabled: (viewOptions: ViewOptions) => boolean + // Id of the parent of a document; undefined for a document without a parent + parentOf: (doc: Doc) => string | undefined + // Levels of nesting, 8 by default + maxDepth?: number + // Ids of the documents whose children are shown + expanded: Readable> + toggle: (id: string) => void + // Tooltip of the expander of a parent row, e.g. the progress of its children + describe?: Readable<(doc: Doc) => string | undefined> +} + +/** + * Summary that is shown at the end of every group header, e.g. the sums of number fields of the group. + * It is shown only when `isEnabled` accepts the view options of the list. + * @public + */ +export interface ClientGroupSummary { + isEnabled: (viewOptions: ViewOptions) => boolean + // Document properties the summary reads, they are loaded together with the group-by keys + projection: (viewOptions: ViewOptions) => string[] + // Receives `docs` (the documents of the group), `viewOptions`, `value` (the category), `groupKey` and `space` + component: AnySvelteComponent +} + +/** + * Lets a host plugin provide group-by / order-by keys and columns that are not model attributes + * (e.g. user-defined fields stored in an untyped record). Grouping and ordering by such keys is + * performed on the client over the loaded documents. + * @public + */ +export interface ClientViewExtension { + // True when the key (used in viewOptions.groupBy / orderBy) is owned by the extension + handlesKey: (key: string) => boolean + groupByKeys: () => ClientViewKey[] + orderByKeys: () => ClientViewKey[] + columns: () => ClientViewColumn[] + // Top-level document property that has to be loaded to evaluate the key + projectionKey: (key: string) => string + // Further properties of the documents that the group header needs (see `getGroupExtras`) + extraProjection?: (key: string) => string[] + // Ordered categories (group values) for the documents + getCategories: (key: string, docs: Doc[], viewOptions: ViewOptions) => CategoryType[] + // Presenter of a group header, receives the category as `value` + getGroupHeader: (key: string) => AnySvelteComponent | undefined + // Component shown at the end of a group header, e.g. totals and a menu of the group. It receives the + // category as `value`, the documents of the group as `docs` and the `space` of the list + getGroupExtras?: (key: string) => AnySvelteComponent | undefined + // Label of the group of documents without a value + emptyGroupLabel: (key: string) => string | undefined + // Client-side comparator, undefined when there is nothing to sort by + compare: (key: string, order: SortingOrder) => ((a: Doc, b: Doc) => number) | undefined + // Upper bound of documents to load per group when sorting on the client + scanLimit: number + // Nests documents under their parents, for lists that turn it on in their view options + hierarchy?: ClientHierarchy + // Totals shown in the group headers, for lists that turn it on in their view options + groupSummary?: ClientGroupSummary + // Identity of the displayed view (project and saved view). A list that groups on several levels keeps which of + // its groups the viewer collapsed under it, so that every view remembers its own groups. Lists that group on one + // level keep their own key, so the sub-issues of an issue and the other embedded lists are unaffected. + groupStateScope?: string +} + +/** + * Extension of the currently displayed view, if any. + * @public + */ +export const clientViewExtension = writable(undefined) + +/** + * @public + */ +export function getClientViewExtension (): ClientViewExtension | undefined { + return get(clientViewExtension) +} + +/** + * Whether the key is handled by the active extension. + * @public + */ +export function isClientViewKey (ext: ClientViewExtension | undefined, key: string | undefined): boolean { + return ext !== undefined && key !== undefined && ext.handlesKey(key) +} diff --git a/plugins/view-resources/src/components/ViewOptions.svelte b/plugins/view-resources/src/components/ViewOptions.svelte index ee94f92d0e..c5127d216d 100644 --- a/plugins/view-resources/src/components/ViewOptions.svelte +++ b/plugins/view-resources/src/components/ViewOptions.svelte @@ -1,12 +1,16 @@ {#if viewlet?.$lookup?.descriptor?.component} diff --git a/plugins/view-resources/src/components/ViewletSetting.svelte b/plugins/view-resources/src/components/ViewletSetting.svelte index 4ee033794b..7407f36c57 100644 --- a/plugins/view-resources/src/components/ViewletSetting.svelte +++ b/plugins/view-resources/src/components/ViewletSetting.svelte @@ -32,10 +32,15 @@ import { createEventDispatcher } from 'svelte' import view from '../plugin' import { buildConfigLookup, canResolveAttribute, getKeyLabel } from '../utils' + import { getClientViewExtension } from '../clientViewExtension' import ViewletClassSettings from './ViewletClassSettings.svelte' export let viewlet: Viewlet export let defaultConfig: (BuildModelKey | string)[] | undefined = undefined + // Columns of the active saved view; shown instead of the global preference of the viewlet + export let configOverride: (BuildModelKey | string)[] | undefined = undefined + // When set, column edits go to the host (the active saved view) instead of the global ViewletPreference + export let onSaveConfig: ((config: Array) => void) | undefined = undefined const dispatch = createEventDispatcher() @@ -449,6 +454,25 @@ await addAssociations(result, viewlet.attachTo, preference) } + // Optional columns provided by the host view (e.g. user-defined fields) + const extension = getClientViewExtension() + if (extension !== undefined) { + const clazz = hierarchy.getClass(viewlet.attachTo) + for (const column of extension.columns()) { + const columnConfig: AttributeConfig = { + type: 'attribute', + value: column.key, + label: getEmbeddedLabel(column.label), + enabled: false, + _class: viewlet.attachTo, + icon: clazz.icon + } + if (!isExist(result, columnConfig)) { + result.push(columnConfig) + } + } + } + return preference === undefined ? result : setStatus(result, preference) } @@ -537,6 +561,10 @@ } return value }) + if (onSaveConfig !== undefined && viewletId === viewlet._id) { + onSaveConfig(config) + return + } const selectedViewlet = viewlets.find((it) => it._id === viewletId) const previousSourceConfig = preferences.find((p) => p.attachedTo === viewletId)?.config ?? selectedViewlet?.config ?? [] @@ -548,7 +576,21 @@ } } + // The columns of the active saved view replace the stored preference of its viewlet + function withConfigOverride ( + viewletId: Ref, + stored: ViewletPreference | undefined, + override: (BuildModelKey | string)[] | undefined + ): ViewletPreference | undefined { + if (override === undefined || viewletId !== viewlet._id) return stored + return { ...(stored ?? ({} as ViewletPreference)), config: override } + } + async function restoreDefault (viewletId: Ref): Promise { + if (onSaveConfig !== undefined && viewletId === viewlet._id) { + onSaveConfig(defaultConfig ?? viewlet.config) + return + } const preference = preferences.find((p) => p.attachedTo === viewletId) if (preference !== undefined) { await client.remove(preference) @@ -596,7 +638,8 @@
{/if} {@const selectedViewlet = viewlets.find((it) => it._id === selected)} - {@const selectedPreferece = preferences.find((it) => it.attachedTo === selected)} + {@const storedPreference = preferences.find((it) => it.attachedTo === selected)} + {@const selectedPreferece = withConfigOverride(selected, storedPreference, configOverride)} {#if selectedViewlet} {#await getConfig(selectedViewlet, selectedPreferece)} diff --git a/plugins/view-resources/src/components/ViewletSettingButton.svelte b/plugins/view-resources/src/components/ViewletSettingButton.svelte index 10c9ff5c89..57e63b122f 100644 --- a/plugins/view-resources/src/components/ViewletSettingButton.svelte +++ b/plugins/view-resources/src/components/ViewletSettingButton.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> + +
+
+ + { + focused = true + updateSuggestions() + }} + on:blur={onBlur} + /> + {#if draft === ''} + + {/if} + {#if draft !== ''} +
+ {#if showError && error !== undefined} + + {/if} +
+ + diff --git a/plugins/view-resources/src/components/list/List.svelte b/plugins/view-resources/src/components/list/List.svelte index 859779d70f..d8f354ae34 100644 --- a/plugins/view-resources/src/components/list/List.svelte +++ b/plugins/view-resources/src/components/list/List.svelte @@ -30,6 +30,7 @@ import { createEventDispatcher, onDestroy } from 'svelte' import { SelectionFocusProvider } from '../../selection' import { claimResultCountOwner, releaseResultCountOwner, setResultCount } from '../../stores' + import { clientViewExtension, isClientViewKey } from '../../clientViewExtension' import { buildConfigLookup } from '../../utils' import { getResultOptions, getResultQuery } from '../../viewOptions' import ListCategories from './ListCategories.svelte' @@ -105,7 +106,10 @@ $: resultOptions = { ...configOptions, ...(Object.keys(lookup).length > 0 ? { lookup } : {}), - ...(orderBy !== undefined ? { sort: { [orderBy[0]]: orderBy[1] } } : {}) + // Ordering by a client-side key (e.g. a custom field) is applied by the list category after loading + ...(orderBy !== undefined && !isClientViewKey($clientViewExtension, orderBy[0]) + ? { sort: { [orderBy[0]]: orderBy[1] } } + : {}) } const updateOptions = reduceCalls(async function (options: FindOptions | undefined, viewOptions: ViewOptions) { @@ -141,6 +145,13 @@ let fastQueryIds = new Set>() + // The collapsed groups of a list that groups on several levels are kept per view; other lists keep the legacy key + $: groupPersistKey = viewOptions.groupBy.length > 1 ? ($clientViewExtension?.groupStateScope ?? '') : '' + + $: groupSummary = $clientViewExtension?.groupSummary + $: summaryProjection = + groupSummary !== undefined && groupSummary.isEnabled(viewOptions) ? groupSummary.projection(viewOptions) : undefined + let categoryQueryOptions: Partial> $: categoryQueryOptions = { ...noLookupSortingOptions(resultOptions), @@ -148,7 +159,19 @@ ...resultOptions.projection, _id: 1, _class: 1, - ...getProjection(viewOptions.groupBy, queryNoLookup, _class) + ...getProjection( + [ + ...viewOptions.groupBy.flatMap((it) => + isClientViewKey($clientViewExtension, it) + ? [$clientViewExtension?.projectionKey(it) ?? it, ...($clientViewExtension?.extraProjection?.(it) ?? [])] + : [it] + ), + // What the totals in the group headers read (only when the view asks for them) + ...(summaryProjection ?? []) + ], + queryNoLookup, + _class + ) } } @@ -261,7 +284,7 @@ {limiter} {listProvider} level={0} - groupPersistKey={''} + {groupPersistKey} {createItemDialog} {createItemDialogProps} {createItemLabel} diff --git a/plugins/view-resources/src/components/list/ListCategories.svelte b/plugins/view-resources/src/components/list/ListCategories.svelte index 3344c7e606..ceb54206e4 100644 --- a/plugins/view-resources/src/components/list/ListCategories.svelte +++ b/plugins/view-resources/src/components/list/ListCategories.svelte @@ -38,6 +38,7 @@ } from '@hcengineering/view' import { createEventDispatcher, onDestroy, SvelteComponentTyped } from 'svelte' import { SelectionFocusProvider } from '../../selection' + import { categoriesWithDocs, emptyCategoryLast } from '../../nestedGroups' import { buildModel, concatCategories, @@ -49,6 +50,7 @@ groupBy } from '../../utils' import { CategoryQuery, noCategory } from '../../viewOptions' + import { clientViewExtension, isClientViewKey, type ClientViewExtension } from '../../clientViewExtension' import ListCategory from './ListCategory.svelte' export let docs: Doc[] @@ -91,7 +93,7 @@ $: groupByKey = viewOptions.groupBy[level] ?? noCategory let categories: CategoryType[] = [] - $: void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig) + $: void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig, $clientViewExtension) $: groupByDocs = groupBy(docs, groupByKey, categories) @@ -110,9 +112,21 @@ docs: Doc[], groupByKey: string, viewOptions: ViewOptions, - viewOptionsModel: ViewOptionModel[] | undefined + viewOptionsModel: ViewOptionModel[] | undefined, + extension: ClientViewExtension | undefined ): Promise => { - categories = await getCategories(client, _class, space, docs, groupByKey) + if (extension !== undefined && isClientViewKey(extension, groupByKey)) { + // Grouping by a client-side key is computed from the loaded documents + let res = extension.getCategories(groupByKey, docs, viewOptions) + if (level > 0) { + // A level below the first lists what is in its parent group, not the groups listed for being empty + const present = groupBy(docs, groupByKey) + res = categoriesWithDocs(res, (c) => getGroupByValues(present, c).length) + } + categories = arrangeCategories(res, viewOptions) + return + } + categories = arrangeCategories(await getCategories(client, _class, space, docs, groupByKey), viewOptions) if (level === 0) { for (const viewOption of viewOptionsModel ?? []) { if (viewOption.actionTarget !== 'category') continue @@ -121,7 +135,7 @@ const f = await getResource(categoryFunc.action) const res = hierarchy.clone(await f(_class, query, space, groupByKey, update, queryId)) if (res !== undefined) { - categories = concatCategories(res, categories) + categories = arrangeCategories(concatCategories(res, categories), viewOptions) return } } @@ -130,22 +144,48 @@ } ) + // A view that groups on several levels lists the group without a value ("No ") last on every level + function arrangeCategories (list: CategoryType[], viewOptions: ViewOptions): CategoryType[] { + return viewOptions.groupBy.length > 1 ? emptyCategoryLast(list) : list + } + function update (): void { - void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig) + void updateCategories(_class, space, docs, groupByKey, viewOptions, viewOptionsConfig, $clientViewExtension) } let itemModels = new Map>, AttributeModel[]>() - const getHeader = reduceCalls(async function (_class: Ref>, groupByKey: string): Promise { + const getHeader = reduceCalls(async function ( + _class: Ref>, + groupByKey: string, + // Only a trigger: the extension is re-read from the store inside + extension?: unknown + ): Promise { + void extension if (groupByKey === noCategory) { headerComponent = undefined + } else if (isClientViewKey($clientViewExtension, groupByKey)) { + const presenter = $clientViewExtension?.getGroupHeader(groupByKey) + headerComponent = + presenter === undefined + ? undefined + : { + key: groupByKey, + sortingKey: groupByKey, + _class, + label: '' as IntlString, + presenter, + props: {}, + collectionAttr: false, + isLookup: false + } } else { await getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p)) } }) let headerComponent: AttributeModel | undefined - $: void getHeader(_class, groupByKey) + $: void getHeader(_class, groupByKey, $clientViewExtension) let configurationsVersion = 0 const buildModels = reduceCalls(async function ( @@ -367,8 +407,14 @@ function getGroupByKey ( docKeys: Partial>>, category: CategoryType, - resultQuery: DocumentQuery> + resultQuery: DocumentQuery>, + docsByGroup: Record, + extension: ClientViewExtension | undefined ): Partial> { + if (isClientViewKey(extension, groupByKey)) { + // Documents of a client-side group are addressed by id, the key is not queryable on the server + return { ...docKeys, _id: { $in: getGroupByValues(docsByGroup, category).map((it) => it._id) } } + } return { ...docKeys, [groupByKey]: @@ -385,7 +431,7 @@ {#each categories as category, i (typeof category === 'object' ? category.name : category)} {@const items = groupByKey === noCategory ? docs : getGroupByValues(groupByDocs, category)} - {@const categoryDocKeys = getGroupByKey(docKeys, category, resultQuery)} + {@const categoryDocKeys = getGroupByKey(docKeys, category, resultQuery, groupByDocs, $clientViewExtension)} { try { @@ -141,14 +154,18 @@ _class, { ...finalResultQuery, ...docKeys }, (res) => { - items = res + items = clientCompare !== undefined ? [...res].sort(clientCompare) : res loading = false const focusDoc = items.find((it) => it._id === $focusStore.focus?._id) if (focusDoc) { handleRowFocused(focusDoc) } }, - { ...resultOptions, limit: limit ?? 200 } + { + ...resultOptions, + // A tree needs the whole group: a child may come long after its parent + limit: clientSorted || hierarchyOn ? ($clientViewExtension?.scanLimit ?? 5000) : (limit ?? 200) + } ) } catch (e) { console.error(e) @@ -189,7 +206,28 @@ dispatch('row-focus', object) } - $: limited = limitGroup(items, limit) + const noExpanded: ReadonlySet = new Set() + $: expandedStore = hierarchyOn ? hierarchyExt?.expanded : undefined + $: expandedIds = (expandedStore !== undefined ? $expandedStore : undefined) ?? noExpanded + $: describeStore = hierarchyOn ? hierarchyExt?.describe : undefined + $: describe = describeStore !== undefined ? $describeStore : undefined + + let treeRows: Array> | undefined + $: treeRows = + hierarchyOn && hierarchyExt !== undefined + ? buildHierarchyRows(items, { + idOf: (it) => it._id, + parentOf: hierarchyExt.parentOf, + expanded: expandedIds, + maxDepth: hierarchyExt.maxDepth + }) + : undefined + $: treeRowById = treeRows !== undefined ? new Map(treeRows.map((r) => [r.item._id as string, r])) : undefined + // What the group shows: the rows of the tree, or the loaded documents + $: shown = treeRows !== undefined ? treeRows.map((r) => r.item) : items + $: shownTotal = treeRows !== undefined ? treeRows.length : itemProj.length + + $: limited = limitGroup(shown, limit) $: selectedObjectIdsSet = new Set>(selectedObjectIds.map((it) => it._id)) @@ -211,7 +249,7 @@ return { ...newObjectProps(doc), ...(doc ? { space: doc.space } : {}), - ...(groupValue !== undefined ? { [groupByKey]: groupValue } : {}) + ...(groupValue !== undefined && !isClientViewKey($clientViewExtension, groupByKey) ? { [groupByKey]: groupValue } : {}) } } @@ -226,7 +264,8 @@ let dragItemIndex: number | undefined function dragswap (ev: MouseEvent, i: number): boolean { - if (dragItemIndex === undefined || !byRank) return false + // Rows of a tree are not in the order of the loaded documents, so they cannot be swapped by their index + if (dragItemIndex === undefined || !byRank || hierarchyOn) return false const s = dragItemIndex if (i < s) { return ev.offsetY < (ev.target as HTMLElement).offsetHeight / 2 @@ -314,7 +353,7 @@ ev.stopPropagation() ev.preventDefault() const update: DocumentUpdate = {} - if (dragItemIndex !== undefined && viewOptions.orderBy?.[0] === 'rank') { + if (dragItemIndex !== undefined && viewOptions.orderBy?.[0] === 'rank' && !hierarchyOn) { const prev = limited[dragItemIndex - 1] as DocWithRank const next = limited[dragItemIndex + 1] as DocWithRank try { @@ -525,6 +564,7 @@ {#if limited} {#key configurationsVersion} {#each limited as docObject, i (docObject._id)} + {@const treeRow = treeRowById?.get(docObject._id)} = itemProj.length} - lastCat={i === limited.length - 1 && (oneCat || lastCat) && HLimited >= itemProj.length} + last={i === limited.length - 1 && HLimited >= shownTotal} + lastCat={i === limited.length - 1 && (oneCat || lastCat) && HLimited >= shownTotal} + tree={treeRow !== undefined + ? { + depth: treeRow.depth, + hasChildren: treeRow.hasChildren, + expanded: treeRow.expanded, + label: treeRow.hasChildren ? describe?.(docObject) : undefined + } + : undefined} + on:toggle-tree={() => { + hierarchyExt?.toggle(docObject._id) + }} on:dragstart={(e) => { dragStart(e, docObject, i) }} @@ -569,7 +620,7 @@ }} /> {/each} - {#if HLimited < itemProj.length} + {#if HLimited < shownTotal}
{:else} - ({HLimited} / {itemProj.length}) + ({HLimited} / {shownTotal}) {/if}
{/if} diff --git a/plugins/view-resources/src/components/list/ListHeader.svelte b/plugins/view-resources/src/components/list/ListHeader.svelte index 2fac066863..7418428a4f 100644 --- a/plugins/view-resources/src/components/list/ListHeader.svelte +++ b/plugins/view-resources/src/components/list/ListHeader.svelte @@ -39,6 +39,7 @@ import view from '../../plugin' import { SelectionFocusProvider, selectionLimit } from '../../selection' import { noCategory } from '../../viewOptions' + import { clientViewExtension, isClientViewKey } from '../../clientViewExtension' export let groupByKey: string export let category: PrimitiveType | AggregateValue @@ -91,6 +92,15 @@ const selection = listProvider.selection + $: groupExtras = isClientViewKey($clientViewExtension, groupByKey) + ? $clientViewExtension?.getGroupExtras?.(groupByKey) + : undefined + + // Totals of the group (e.g. sums of number fields), only for lists whose view options ask for them + $: summary = $clientViewExtension?.groupSummary + $: summaryComponent = + summary !== undefined && groupByKey !== noCategory && summary.isEnabled(viewOptions) ? summary.component : undefined + $: selectionIds = new Set($selection.map((it) => it._id)) $: selected = items.filter((it) => selectionIds.has(it._id)) // $: if (itemsProj.length === 0 && !collapsed) collapsed = true @@ -137,7 +147,11 @@ {:else if category === undefined} - {:else if headerComponent} {itemsProj.length} {/if} + {#if groupExtras !== undefined && category !== undefined} + i?.space === items[0]?.space) ? items[0]?.space : undefined)} + /> + {/if} + {#if summaryComponent !== undefined} + i?.space === items[0]?.space) ? items[0]?.space : undefined)} + /> + {/if}
{#each extraHeaders ?? [] as extra} diff --git a/plugins/view-resources/src/components/list/ListItem.svelte b/plugins/view-resources/src/components/list/ListItem.svelte index 7f3cc5ca05..58061cea77 100644 --- a/plugins/view-resources/src/components/list/ListItem.svelte +++ b/plugins/view-resources/src/components/list/ListItem.svelte @@ -15,8 +15,16 @@ + +
+ +
+ +{#if overlay !== undefined} + {#if overlay.range !== undefined} +
+ {/if} +
+ {#if overlay.handle !== undefined} + +
+ {/if} +{/if} +{#if fillPreview !== undefined} +
+{/if} +{#if toast !== undefined} +
+ + {#if toast.kind === 'updated'} + {@const batchId = toast.batchId} + + {/if} +
+{/if} + + diff --git a/plugins/view-resources/src/components/view/SavedViewBar.svelte b/plugins/view-resources/src/components/view/SavedViewBar.svelte new file mode 100644 index 0000000000..74c212a0a4 --- /dev/null +++ b/plugins/view-resources/src/components/view/SavedViewBar.svelte @@ -0,0 +1,784 @@ + + + +{#if tabs.length > 0} +
+
+ {#each tabs as tab, index (tab._id)} + {@const selected = activeTab?._id === tab._id} + + {/each} + {#if !$restrictionStore.readonly && layoutViewlets.length > 0} +
+ {#if dirty && !$restrictionStore.readonly} +
+
+ {/if} +
+{/if} + + diff --git a/plugins/view-resources/src/filter/grammar/__tests__/compile.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/compile.test.ts new file mode 100644 index 0000000000..5a2ee649fd --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/compile.test.ts @@ -0,0 +1,356 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + archiveScopeQuery, + compileFilter, + compileQuery, + mentionsArchived, + referencedProperties, + splitServerClient +} from '../compile' +import { parseFilter } from '../parser' +import type { Node } from '../types' +import { ctx, ISSUES, NOW, schema } from './fixtures' + +function ast (input: string): Node { + const res = parseFilter(input, schema) + if (!res.ok) throw new Error(`${input}: ${res.error.message}`) + return res.value +} + +// Minimal evaluator of the DocumentQuery subset the compiler emits +function like (pattern: string, value: unknown): boolean { + if (typeof value !== 'string') return false + const re = new RegExp(`^${pattern.split('%').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`, 'i') + return re.test(value) +} + +function matchSelector (sel: any, value: unknown): boolean { + if (typeof sel !== 'object' || sel === null) return (value ?? null) === sel + const v = value ?? null + for (const [op, arg] of Object.entries(sel)) { + switch (op) { + case '$in': + if (!arg.includes(v)) return false + break + case '$nin': + if (arg.includes(v)) return false + break + case '$ne': + if (v === arg) return false + break + case '$gt': + if (typeof v !== 'number' || !(v > arg)) return false + break + case '$gte': + if (typeof v !== 'number' || !(v >= arg)) return false + break + case '$lt': + if (typeof v !== 'number' || !(v < arg)) return false + break + case '$lte': + if (typeof v !== 'number' || !(v <= arg)) return false + break + case '$like': + if (!like(arg, v)) return false + break + default: + throw new Error(`unsupported operator ${op}`) + } + } + return true +} + +function matchQuery (query: Record, doc: any): boolean { + return Object.entries(query).every(([key, sel]) => matchSelector(sel, doc[key])) +} + +describe('splitServerClient', () => { + it('compiles an empty filter to an empty query', () => { + expect(splitServerClient(ast(''), ctx)).toEqual({ query: {}, residual: undefined }) + }) + + it('compiles select, user and priority fields to $in / $nin', () => { + expect(compileQuery(ast('status:Todo,Done'), ctx)).toEqual({ status: { $in: ['st-todo', 'st-done'] } }) + expect(compileQuery(ast('priority:urgent'), ctx)).toEqual({ priority: { $in: [1] } }) + expect(compileQuery(ast('assignee:@me'), ctx)).toEqual({ assignee: { $in: ['p-alice'] } }) + expect(compileQuery(ast('-status:Done'), ctx)).toEqual({ status: { $nin: ['st-done'] } }) + expect(compileQuery(ast('milestone:v1'), ctx)).toEqual({ milestone: { $in: ['m1'] } }) + }) + + it('selects nothing for an unknown option', () => { + expect(compileQuery(ast('status:zzz'), ctx)).toEqual({ status: { $in: [] } }) + }) + + it('compiles has: and no: to null checks', () => { + expect(compileQuery(ast('no:assignee'), ctx)).toEqual({ assignee: null }) + expect(compileQuery(ast('has:due'), ctx)).toEqual({ dueDate: { $ne: null } }) + expect(compileQuery(ast('no:parent-issue'), ctx)).toEqual({ attachedTo: 'no-parent' }) + expect(compileQuery(ast('has:parent-issue'), ctx)).toEqual({ attachedTo: { $ne: 'no-parent' } }) + }) + + it('compiles title text and field to $like', () => { + expect(compileQuery(ast('login'), ctx)).toEqual({ title: { $like: '%login%' } }) + expect(compileQuery(ast('title:"fix*bug"'), ctx)).toEqual({ title: { $like: '%fix%bug%' } }) + }) + + it('keeps LIKE metacharacters on the client', () => { + const res = splitServerClient(ast('100%'), ctx) + expect(res.query).toEqual({}) + expect(res.residual).toMatchObject({ type: 'text' }) + }) + + it('compiles numeric and date bounds', () => { + expect(compileQuery(ast('estimate:>5'), ctx)).toEqual({ estimation: { $gt: 5 } }) + expect(compileQuery(ast('estimate:2..5'), ctx)).toEqual({ estimation: { $gte: 2, $lte: 5 } }) + expect(compileQuery(ast('estimate:<=3'), ctx)).toEqual({ estimation: { $lte: 3 } }) + const date = compileQuery(ast('due:>=@today'), ctx) + expect(date).toEqual({ dueDate: { $gte: expect.any(Number) } }) + expect(compileQuery(ast('due:*..*'), ctx)).toEqual({ dueDate: { $ne: null } }) + }) + + it('merges two bounds on the same attribute', () => { + const q = compileQuery(ast('due:>@today-7d due:<@today+1d'), ctx) + expect(q).toEqual({ dueDate: { $gte: expect.any(Number), $lte: expect.any(Number) } }) + }) + + it('moves a colliding condition on the same attribute to the client', () => { + const res = splitServerClient(ast('status:Todo status:Done'), ctx) + expect(res.query).toEqual({ status: { $in: ['st-todo'] } }) + expect(res.residual).toMatchObject({ type: 'field', field: { name: 'status' } }) + }) + + it('compiles labels through the document id resolver', () => { + const q = compileQuery(ast('label:bug'), ctx) + expect(q?._id.$in).toHaveLength(2) + expect(compileQuery(ast('-label:bug'), ctx)?._id.$nin).toHaveLength(2) + }) + + it('compiles is: states', () => { + expect(compileQuery(ast('is:open'), ctx)).toEqual({ status: { $nin: ['st-done', 'st-canceled'] } }) + expect(compileQuery(ast('is:closed'), ctx)).toEqual({ status: { $in: ['st-done', 'st-canceled'] } }) + expect(compileQuery(ast('is:issue'), ctx)).toEqual({ isDraft: { $ne: true } }) + expect(compileQuery(ast('is:draft'), ctx)).toEqual({ isDraft: true }) + expect(compileQuery(ast('is:sub-issue'), ctx)).toEqual({ attachedTo: { $ne: 'no-parent' } }) + }) + + it('merges OR of the same field into one selector', () => { + expect(compileQuery(ast('status:Todo OR status:Done'), ctx)).toEqual({ status: { $in: ['st-todo', 'st-done'] } }) + }) + + it('sends custom fields to the client', () => { + const res = splitServerClient(ast('status:Todo story-points:>3'), ctx) + expect(res.query).toEqual({ status: { $in: ['st-todo'] } }) + expect(res.residual).toMatchObject({ type: 'field', field: { name: 'story-points' } }) + expect(compileQuery(ast('story-points:>3'), ctx)).toBeUndefined() + }) + + it('keeps an OR across fields whole on the client', () => { + const res = splitServerClient(ast('priority:urgent OR status:done'), ctx) + expect(res.query).toEqual({}) + expect(res.residual?.type).toBe('or') + }) + + it('keeps an OR across server and client fields whole on the client', () => { + const res = splitServerClient(ast('status:done OR story-points:>3'), ctx) + expect(res.query).toEqual({}) + expect(res.residual?.type).toBe('or') + }) + + it('splits a top level AND into server and client parts', () => { + const res = splitServerClient(ast('assignee:@me (priority:urgent OR size:L) login'), ctx) + expect(res.query).toEqual({ assignee: { $in: ['p-alice'] }, title: { $like: '%login%' } }) + expect(res.residual?.type).toBe('or') + }) + + it('does not compile iterations on the server', () => { + expect(compileQuery(ast('iteration:@current'), ctx)).toBeUndefined() + }) +}) + +describe('server query + client residual equals full client evaluation', () => { + const filters = [ + '', + 'login', + 'status:Todo,Done', + '-status:Done', + 'priority:urgent,high', + 'assignee:@me', + '-assignee:@me', + 'no:assignee', + 'has:due', + 'label:bug', + '-label:bug', + 'label:bug label:ui', + 'is:open', + 'is:closed', + 'is:sub-issue', + 'no:parent-issue', + 'estimate:>5', + 'estimate:<=5', + 'estimate:1..8', + 'estimate:8..*', + 'due:@today', + 'due:<@today', + 'due:>@today-7d', + 'due:>@today-14d due:<@today+1d', + 'due:@today-10d..@today', + 'status:Todo status:Done', + 'story-points:>3', + 'status:progress,todo story-points:<5', + 'priority:urgent OR status:done', + 'status:done OR story-points:>3', + '(priority:urgent OR status:done) assignee:@me', + '-(status:done OR status:canceled)', + 'assignee:@me size:M', + 'iteration:@current OR label:bug', + 'bug status:"in progress"', + '100%', + 'title:*coverage -is:closed', + 'no:size OR has:notes' + ] + + for (const f of filters) { + it(`"${f}"`, () => { + const res = compileFilter(f, schema, ctx) + if (!res.ok) throw new Error(res.error.message) + const combined = ISSUES.filter((d) => matchQuery(res.value.query, d) && res.value.predicate(d)).map((d) => d._id) + const full = ISSUES.filter((d) => res.value.matches(d)).map((d) => d._id) + expect(combined).toEqual(full) + }) + } +}) + +describe('compileFilter', () => { + it('returns typed errors instead of throwing', () => { + const res = compileFilter('nope:1', schema, ctx) + expect(res.ok).toBe(false) + if (!res.ok) expect(res.error).toMatchObject({ code: 'unknownField', pos: 0 }) + }) + + it('exposes whether the query is exact', () => { + const exact = compileFilter('status:Todo', schema, ctx) + expect(exact.ok && exact.value.residual).toBeUndefined() + const mixed = compileFilter('status:Todo story-points:>1', schema, ctx) + expect(mixed.ok && mixed.value.residual).toBeDefined() + }) +}) + +describe('reserved keys', () => { + it('leaves conditions on reserved attributes to the client', () => { + const res = splitServerClient(ast('status:Todo priority:urgent'), ctx, new Set(['status'])) + expect(res.query).toEqual({ priority: { $in: [1] } }) + expect(res.residual).toMatchObject({ type: 'field', field: { name: 'status' } }) + }) +}) + +describe('referencedProperties', () => { + it('lists what the client has to load', () => { + expect(referencedProperties(undefined)).toEqual(['_id']) + const props = referencedProperties(ast('login (priority:urgent OR story-points:>1) is:open has:parent-issue')) + expect(props).toEqual(expect.arrayContaining(['_id', 'title', 'priority', 'customFields', 'status', 'attachedTo'])) + }) +}) + +describe('archived items', () => { + const ARCHIVE_DOCS = [ + { _id: 'a1', title: 'Old', status: 'st-done', archivedAt: NOW - 1000 }, + { _id: 'a2', title: 'Restored', status: 'st-done', archivedAt: null }, + { _id: 'a3', title: 'Never archived', status: 'st-todo' } + ] + + it('compiles is:archived and -is:archived to archivedAt selectors', () => { + expect(compileQuery(ast('is:archived'), ctx)).toEqual({ archivedAt: { $ne: null } }) + expect(compileQuery(ast('-is:archived'), ctx)).toEqual({ archivedAt: null }) + }) + + it('selects the same documents on the server and on the client', () => { + for (const f of ['is:archived', '-is:archived', 'is:archived is:closed', 'is:archived OR status:Todo']) { + const res = compileFilter(f, schema, ctx) + if (!res.ok) throw new Error(res.error.message) + const combined = ARCHIVE_DOCS.filter((d) => matchQuery(res.value.query, d) && res.value.predicate(d)).map((d) => d._id) + const full = ARCHIVE_DOCS.filter((d) => res.value.matches(d)).map((d) => d._id) + expect(combined).toEqual(full) + } + const only = compileFilter('is:archived', schema, ctx) + expect(only.ok && ARCHIVE_DOCS.filter((d) => only.value.matches(d)).map((d) => d._id)).toEqual(['a1']) + }) + + it('tells whether a filter asks for archived items', () => { + expect(mentionsArchived(ast('is:archived'))).toBe(true) + expect(mentionsArchived(ast('-is:archived'))).toBe(true) + expect(mentionsArchived(ast('status:Todo (is:archived OR login)'))).toBe(true) + expect(mentionsArchived(ast('status:Todo is:closed'))).toBe(false) + expect(mentionsArchived(undefined)).toBe(false) + }) + + it('hides archived items unless the filter mentions them', () => { + expect(archiveScopeQuery(undefined)).toEqual({ archivedAt: null }) + expect(archiveScopeQuery(ast('status:Todo'))).toEqual({ archivedAt: null }) + expect(archiveScopeQuery(ast('is:archived'))).toEqual({}) + }) + + it('reads archivedAt for client evaluation', () => { + expect(referencedProperties(ast('is:archived'))).toEqual(expect.arrayContaining(['archivedAt'])) + }) +}) + +describe('draft items', () => { + const DRAFT_DOCS = [ + { _id: 'd1', title: 'Idea', status: 'st-todo', isDraft: true }, + { _id: 'd2', title: 'Converted', status: 'st-todo', isDraft: false }, + { _id: 'd3', title: 'Plain issue', status: 'st-todo' }, + { _id: 'd4', title: 'Closed draft', status: 'st-done', isDraft: true } + ] + + it('compiles is:draft, -is:draft, is:issue and -is:issue to isDraft selectors', () => { + expect(compileQuery(ast('is:draft'), ctx)).toEqual({ isDraft: true }) + expect(compileQuery(ast('-is:draft'), ctx)).toEqual({ isDraft: { $ne: true } }) + expect(compileQuery(ast('is:issue'), ctx)).toEqual({ isDraft: { $ne: true } }) + expect(compileQuery(ast('-is:issue'), ctx)).toEqual({ isDraft: true }) + }) + + it('selects the same documents on the server and on the client', () => { + for (const f of [ + 'is:draft', + '-is:draft', + 'is:issue', + '-is:issue', + 'is:draft is:closed', + 'is:issue is:open', + 'is:draft OR status:Todo', + 'is:draft -is:draft', + 'is:issue is:draft' + ]) { + const res = compileFilter(f, schema, ctx) + if (!res.ok) throw new Error(res.error.message) + const combined = DRAFT_DOCS.filter((d) => matchQuery(res.value.query, d) && res.value.predicate(d)).map((d) => d._id) + const full = DRAFT_DOCS.filter((d) => res.value.matches(d)).map((d) => d._id) + expect(combined).toEqual(full) + } + }) + + it('tells drafts from issues', () => { + const only = (f: string): string[] => { + const res = compileFilter(f, schema, ctx) + if (!res.ok) throw new Error(res.error.message) + return DRAFT_DOCS.filter((d) => res.value.matches(d)).map((d) => d._id) + } + expect(only('is:draft')).toEqual(['d1', 'd4']) + expect(only('-is:draft')).toEqual(['d2', 'd3']) + expect(only('is:issue')).toEqual(['d2', 'd3']) + expect(only('is:draft is:closed')).toEqual(['d4']) + }) + + it('reads isDraft for client evaluation', () => { + expect(referencedProperties(ast('is:draft'))).toEqual(expect.arrayContaining(['isDraft'])) + expect(referencedProperties(ast('is:issue'))).toEqual(expect.arrayContaining(['isDraft'])) + }) + + it('does not hide drafts the way archived items are hidden', () => { + expect(archiveScopeQuery(ast('is:draft'))).toEqual({ archivedAt: null }) + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/evaluate.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/evaluate.test.ts new file mode 100644 index 0000000000..80d9f62a51 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/evaluate.test.ts @@ -0,0 +1,193 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { evaluate } from '../evaluate' +import { parseFilter } from '../parser' +import { ctx, DAY, ISSUES, NOW, schema } from './fixtures' + +function titles (input: string): string[] { + const res = parseFilter(input, schema) + if (!res.ok) throw new Error(`${input}: ${res.error.message}`) + return ISSUES.filter((d) => evaluate(res.value, d, ctx)).map((d) => d.title) +} + +describe('evaluate: built-in fields', () => { + it('matches everything for an empty filter', () => { + expect(titles('')).toHaveLength(ISSUES.length) + }) + + it('matches free text in the title, case-insensitively', () => { + expect(titles('LOGIN')).toEqual(['Fix login bug']) + expect(titles('"login bug"')).toEqual(['Fix login bug']) + expect(titles('fix bug')).toEqual(['Fix login bug']) + expect(titles('title:"dark theme"')).toEqual(['Add dark theme']) + expect(titles('title:*coverage')).toEqual(['Refactor API 100% coverage']) + }) + + it('matches status by label with OR and wildcards', () => { + expect(titles('status:Done')).toEqual(['Release notes']) + expect(titles('status:todo,done')).toEqual(['Add dark theme', 'Release notes']) + expect(titles('status:"In Progress"')).toEqual(['Fix login bug', 'Plain issue']) + expect(titles('status:in*')).toEqual(['Fix login bug', 'Plain issue']) + expect(titles('status:nothing')).toEqual([]) + }) + + it('negates a field', () => { + expect(titles('-status:Done,Canceled,Todo')).toEqual(['Fix login bug', 'Plain issue']) + }) + + it('matches priority, assignee and @me', () => { + expect(titles('priority:Urgent')).toEqual(['Fix login bug']) + expect(titles('priority:urgent,high')).toEqual(['Fix login bug', 'Refactor API 100% coverage']) + expect(titles('assignee:@me')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('assignee:alice')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('assignee:"bob smith"')).toEqual(['Add dark theme', 'Plain issue']) + expect(titles('-assignee:@me')).toEqual(['Add dark theme', 'Refactor API 100% coverage', 'Plain issue']) + }) + + it('matches @me to nothing without a current user', () => { + const res = parseFilter('assignee:@me', schema) + if (!res.ok) throw new Error() + expect(ISSUES.some((d) => evaluate(res.value, d, { ...ctx, me: undefined }))).toBe(false) + }) + + it('matches labels with any-of and repeated fields as AND', () => { + expect(titles('label:bug')).toEqual(['Fix login bug', 'Refactor API 100% coverage']) + expect(titles('label:bug,ui')).toEqual(['Fix login bug', 'Add dark theme', 'Refactor API 100% coverage']) + expect(titles('label:bug label:ui')).toEqual(['Refactor API 100% coverage']) + expect(titles('label:"good first issue"')).toEqual(['Add dark theme']) + expect(titles('-label:bug')).toEqual(['Add dark theme', 'Release notes', 'Plain issue']) + }) + + it('matches milestone', () => { + expect(titles('milestone:v1')).toEqual(['Fix login bug']) + expect(titles('no:milestone')).toEqual(['Release notes', 'Refactor API 100% coverage', 'Plain issue']) + }) + + it('supports has: and no:', () => { + expect(titles('has:assignee')).toEqual(['Fix login bug', 'Add dark theme', 'Release notes', 'Plain issue']) + expect(titles('no:assignee')).toEqual(['Refactor API 100% coverage']) + expect(titles('no:label')).toEqual(['Release notes', 'Plain issue']) + expect(titles('has:label')).toHaveLength(3) + expect(titles('has:parent-issue')).toEqual(['Refactor API 100% coverage']) + expect(titles('no:parent-issue')).toHaveLength(4) + expect(titles('no:assignee,due')).toEqual(['Refactor API 100% coverage', 'Plain issue']) + }) + + it('supports is:open and is:closed', () => { + expect(titles('is:open')).toEqual(['Fix login bug', 'Add dark theme', 'Plain issue']) + expect(titles('is:closed')).toEqual(['Release notes', 'Refactor API 100% coverage']) + expect(titles('is:issue')).toHaveLength(5) + expect(titles('is:sub-issue')).toEqual(['Refactor API 100% coverage']) + expect(titles('-is:closed')).toHaveLength(3) + }) +}) + +describe('evaluate: numbers', () => { + it('compares with exact, strict and inclusive operators', () => { + expect(titles('estimate:5')).toEqual(['Fix login bug']) + expect(titles('estimate:>5')).toEqual(['Add dark theme', 'Refactor API 100% coverage']) + expect(titles('estimate:>=5')).toHaveLength(3) + expect(titles('estimate:<5')).toEqual(['Release notes', 'Plain issue']) + expect(titles('estimate:<=5')).toHaveLength(3) + }) + + it('supports inclusive ranges and wildcards', () => { + expect(titles('estimate:1..5')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('estimate:8..*')).toEqual(['Add dark theme', 'Refactor API 100% coverage']) + expect(titles('estimate:*..1')).toEqual(['Release notes', 'Plain issue']) + }) + + it('does not match a missing value', () => { + expect(titles('story-points:<100')).toEqual(['Fix login bug', 'Add dark theme', 'Release notes', 'Refactor API 100% coverage']) + }) +}) + +describe('evaluate: dates', () => { + it('matches a whole day for equality', () => { + expect(titles('due:@today')).toEqual(['Fix login bug']) + expect(titles(`due:${new Date(NOW + 20 * DAY).getFullYear()}-${String(new Date(NOW + 20 * DAY).getMonth() + 1).padStart(2, '0')}-${String(new Date(NOW + 20 * DAY).getDate()).padStart(2, '0')}`)).toEqual(['Release notes']) + }) + + it('treats comparisons on whole days (Phase 1 semantics)', () => { + expect(titles('due:<@today')).toEqual(['Add dark theme']) + expect(titles('due:<=@today')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('due:>@today')).toEqual(['Release notes']) + expect(titles('due:>=@today')).toEqual(['Fix login bug', 'Release notes']) + }) + + it('supports relative offsets', () => { + expect(titles('due:>@today-7d')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('due:>@today-14d due:<@today+1d')).toEqual(['Fix login bug', 'Add dark theme']) + }) + + it('includes both end days of a range', () => { + expect(titles('due:@today-10d..@today')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('due:@today..*')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('due:*..@today-10d')).toEqual(['Add dark theme']) + }) + + it('applies to custom date fields', () => { + expect(titles('target:>@today')).toEqual(['Fix login bug']) + expect(titles('no:target')).toHaveLength(4) + }) +}) + +describe('evaluate: custom fields', () => { + it('matches numbers', () => { + expect(titles('story-points:>=8')).toEqual(['Add dark theme', 'Refactor API 100% coverage']) + expect(titles('story-points:1..3')).toEqual(['Fix login bug', 'Release notes']) + }) + + it('matches text by substring', () => { + expect(titles('notes:qa')).toEqual(['Fix login bug']) + expect(titles('has:notes')).toEqual(['Fix login bug']) + }) + + it('matches single select by label', () => { + expect(titles('size:M')).toEqual(['Fix login bug']) + expect(titles('size:s,l')).toEqual(['Add dark theme', 'Release notes']) + expect(titles('-size:M')).toHaveLength(4) + expect(titles('no:size')).toEqual(['Refactor API 100% coverage', 'Plain issue']) + }) + + it('matches multi select any-of and treats an empty list as empty', () => { + expect(titles('area:frontend')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('area:backend,frontend')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('no:area')).toEqual(['Release notes', 'Refactor API 100% coverage', 'Plain issue']) + }) + + it('matches iterations by keyword, arithmetic and title', () => { + expect(titles('iteration:@current')).toEqual(['Fix login bug']) + expect(titles('iteration:@next')).toEqual(['Add dark theme']) + expect(titles('iteration:@previous')).toEqual(['Release notes']) + expect(titles('iteration:@current+1')).toEqual(['Add dark theme']) + expect(titles('iteration:@previous,@next')).toEqual(['Add dark theme', 'Release notes']) + expect(titles('iteration:"Sprint 3"')).toEqual(['Add dark theme']) + expect(titles('iteration:@current..@next')).toEqual(['Fix login bug', 'Add dark theme']) + expect(titles('iteration:>@current')).toEqual(['Add dark theme']) + expect(titles('iteration:<=@current')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('iteration:@current+5')).toEqual([]) + }) +}) + +describe('evaluate: boolean structure', () => { + it('supports OR across different fields', () => { + expect(titles('priority:urgent OR status:done')).toEqual(['Fix login bug', 'Release notes']) + }) + + it('supports parentheses and implicit AND', () => { + expect(titles('(priority:urgent OR status:done) assignee:@me')).toEqual(['Fix login bug', 'Release notes']) + expect(titles('(priority:urgent OR status:done) label:bug')).toEqual(['Fix login bug']) + }) + + it('supports negated groups', () => { + expect(titles('-(status:done OR status:canceled)')).toEqual(['Fix login bug', 'Add dark theme', 'Plain issue']) + }) + + it('combines free text with fields', () => { + expect(titles('bug status:"in progress"')).toEqual(['Fix login bug']) + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/fixtures.ts b/plugins/view-resources/src/filter/grammar/__tests__/fixtures.ts new file mode 100644 index 0000000000..e99e2ea606 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/fixtures.ts @@ -0,0 +1,159 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { FieldSpec, FilterContext, IterationInfo } from '../types' + +export const DAY = 24 * 60 * 60 * 1000 +// Wednesday, local noon +export const NOW = new Date(2026, 5, 17, 12, 0, 0).getTime() + +export const ITERATIONS: IterationInfo[] = [ + { id: 'it1', title: 'Sprint 1', start: new Date(2026, 5, 1).getTime(), end: new Date(2026, 5, 14, 23, 59, 59, 999).getTime() }, + { id: 'it2', title: 'Sprint 2', start: new Date(2026, 5, 15).getTime(), end: new Date(2026, 5, 28, 23, 59, 59, 999).getTime() }, + { id: 'it3', title: 'Sprint 3', start: new Date(2026, 5, 29).getTime(), end: new Date(2026, 6, 12, 23, 59, 59, 999).getTime() }, + { id: 'it4', title: 'Sprint 4', start: new Date(2026, 6, 13).getTime(), end: new Date(2026, 6, 26, 23, 59, 59, 999).getTime() } +] + +export const schema: FieldSpec[] = [ + { name: 'title', label: 'Title', type: 'text', source: 'attribute', key: 'title' }, + { + name: 'status', + label: 'Status', + type: 'select', + source: 'attribute', + key: 'status', + options: [ + { id: 'st-todo', name: 'Todo' }, + { id: 'st-progress', name: 'In Progress' }, + { id: 'st-done', name: 'Done' }, + { id: 'st-canceled', name: 'Canceled' } + ] + }, + { + name: 'priority', + label: 'Priority', + type: 'select', + source: 'attribute', + key: 'priority', + options: [ + { id: 0, name: 'No priority' }, + { id: 1, name: 'Urgent' }, + { id: 2, name: 'High' }, + { id: 3, name: 'Medium' }, + { id: 4, name: 'Low' } + ] + }, + { + name: 'assignee', + label: 'Assignee', + type: 'user', + source: 'attribute', + key: 'assignee', + options: [ + { id: 'p-alice', name: 'Alice' }, + { id: 'p-bob', name: 'Bob Smith' } + ] + }, + { + name: 'milestone', + label: 'Milestone', + type: 'select', + source: 'attribute', + key: 'milestone', + options: [ + { id: 'm1', name: 'v1' }, + { id: 'm2', name: 'v2' } + ] + }, + { name: 'due', label: 'Due date', type: 'date', source: 'attribute', key: 'dueDate' }, + { name: 'estimate', label: 'Estimate', type: 'number', source: 'attribute', key: 'estimation' }, + { + name: 'label', + label: 'Label', + type: 'multi', + source: 'attribute', + key: 'labels', + options: [ + { id: 'l-bug', name: 'bug' }, + { id: 'l-ui', name: 'ui' }, + { id: 'l-gfi', name: 'good first issue' } + ], + read: (doc) => doc.labelIds, + resolveDocIds: (optionIds) => LABELLED.filter((d) => d.labelIds.some((l: string) => optionIds.includes(l))).map((d) => d._id) + }, + { + name: 'parent-issue', + label: 'Parent issue', + type: 'presence', + source: 'attribute', + key: 'attachedTo', + read: (doc) => (doc.attachedTo === 'no-parent' ? undefined : doc.attachedTo), + presenceQuery: (present) => ({ attachedTo: present ? { $ne: 'no-parent' } : 'no-parent' }) + }, + { name: 'story-points', label: 'Story points', type: 'number', source: 'custom', key: 'storyPoints' }, + { name: 'notes', label: 'Notes', type: 'text', source: 'custom', key: 'notes' }, + { name: 'target', label: 'Target', type: 'date', source: 'custom', key: 'target' }, + { + name: 'size', + label: 'Size', + type: 'select', + source: 'custom', + key: 'size', + options: [ + { id: 'o-s', name: 'S' }, + { id: 'o-m', name: 'M' }, + { id: 'o-l', name: 'L' } + ] + }, + { + name: 'area', + label: 'Area', + type: 'multi', + source: 'custom', + key: 'area', + options: [ + { id: 'o-fe', name: 'Frontend' }, + { id: 'o-be', name: 'Backend' } + ] + }, + { name: 'iteration', label: 'Iteration', type: 'iteration', source: 'custom', key: 'iter' } +] + +export const ctx: FilterContext = { + now: NOW, + me: 'p-alice', + iterations: () => ITERATIONS, + closedStatuses: new Set(['st-done', 'st-canceled']), + noParentId: 'no-parent' +} + +let n = 0 +export function issue (props: Record): any { + n++ + return { + _id: `i${n}`, + title: 'Untitled', + status: 'st-todo', + priority: 0, + assignee: null, + milestone: null, + dueDate: null, + estimation: 0, + labelIds: [], + attachedTo: 'no-parent', + ...props + } +} + +export const ISSUES: any[] = [ + issue({ title: 'Fix login bug', status: 'st-progress', priority: 1, assignee: 'p-alice', labelIds: ['l-bug'], dueDate: NOW, estimation: 5, milestone: 'm1', customFields: { storyPoints: 3, size: 'o-m', notes: 'Needs QA', area: ['o-fe'], target: NOW + 3 * DAY, iter: 'it2' } }), + issue({ title: 'Add dark theme', status: 'st-todo', priority: 3, assignee: 'p-bob', labelIds: ['l-ui', 'l-gfi'], dueDate: NOW - 10 * DAY, estimation: 8, milestone: 'm2', customFields: { storyPoints: 8, size: 'o-l', area: ['o-fe', 'o-be'], iter: 'it3' } }), + issue({ title: 'Release notes', status: 'st-done', priority: 4, assignee: 'p-alice', labelIds: [], dueDate: NOW + 20 * DAY, estimation: 1, customFields: { storyPoints: 1, size: 'o-s', iter: 'it1' } }), + issue({ title: 'Refactor API 100% coverage', status: 'st-canceled', priority: 2, labelIds: ['l-bug', 'l-ui'], attachedTo: 'i1', estimation: 13, customFields: { storyPoints: 13, area: [] } }), + issue({ title: 'Plain issue', status: 'st-progress', priority: 0, assignee: 'p-bob' }) +] + +// Referenced by the label field before it is initialised +export const LABELLED = ISSUES diff --git a/plugins/view-resources/src/filter/grammar/__tests__/parser.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/parser.test.ts new file mode 100644 index 0000000000..79e0103659 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/parser.test.ts @@ -0,0 +1,199 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { parseFilter } from '../parser' +import type { Node, ParseError } from '../types' +import { schema } from './fixtures' + +function ok (input: string): Node { + const res = parseFilter(input, schema) + if (!res.ok) throw new Error(`${input}: ${res.error.message}`) + return res.value +} + +function bad (input: string): ParseError { + const res = parseFilter(input, schema) + if (res.ok) throw new Error(`expected an error for ${input}`) + return res.error +} + +describe('parseFilter structure', () => { + it('parses an empty string to an empty AND', () => { + expect(ok('')).toEqual({ type: 'and', children: [] }) + }) + + it('parses free text', () => { + expect(ok('login')).toMatchObject({ type: 'text', value: 'login', quoted: false }) + expect(ok('"two words"')).toMatchObject({ type: 'text', value: 'two words', quoted: true }) + }) + + it('parses a field with several values as one OR term', () => { + const n = ok('status:Todo,Done') + expect(n).toMatchObject({ type: 'field', field: { name: 'status' } }) + expect(n.type === 'field' && n.values).toHaveLength(2) + }) + + it('combines repeated fields and separate terms with AND', () => { + const n = ok('status:Todo label:bug label:ui') + expect(n.type === 'and' && n.children).toHaveLength(3) + }) + + it('wraps negated terms in NOT', () => { + expect(ok('-status:Done')).toMatchObject({ type: 'not', child: { type: 'field' } }) + expect(ok('-login')).toMatchObject({ type: 'not', child: { type: 'text' } }) + }) + + it('gives AND higher precedence than OR', () => { + const n = ok('a b OR c') + expect(n.type).toBe('or') + expect(n.type === 'or' && n.children.map((c) => c.type)).toEqual(['and', 'text']) + }) + + it('accepts explicit AND', () => { + expect(ok('a AND b').type).toBe('and') + }) + + it('honours parentheses', () => { + const n = ok('a (b OR c)') + expect(n.type === 'and' && n.children[1].type).toBe('or') + }) + + it('supports negated groups', () => { + expect(ok('-(a OR b)')).toMatchObject({ type: 'not', child: { type: 'or' } }) + }) + + it('treats field names case-insensitively', () => { + expect(ok('Status:Done')).toMatchObject({ type: 'field', field: { name: 'status' } }) + }) +}) + +describe('parseFilter pseudo fields', () => { + it('parses has: and no:', () => { + expect(ok('has:assignee')).toMatchObject({ type: 'presence', present: true, field: { name: 'assignee' } }) + expect(ok('no:due')).toMatchObject({ type: 'presence', present: false }) + expect(ok('-no:due')).toMatchObject({ type: 'not', child: { type: 'presence' } }) + }) + + it('treats a comma list in has: as OR', () => { + expect(ok('has:assignee,due').type).toBe('or') + }) + + it('parses is:', () => { + expect(ok('is:open')).toMatchObject({ type: 'is', state: 'open' }) + expect(ok('is:open,closed').type).toBe('or') + }) + + it('rejects unknown is: values with hints', () => { + const e = bad('is:merged') + expect(e.code).toBe('unknownKeyword') + expect(e.hints).toContain('open') + }) + + it('rejects an unknown field in has:', () => { + const e = bad('has:nothing') + expect(e).toMatchObject({ code: 'unknownField', pos: 4, end: 11 }) + }) + + it('rejects values for presence-only fields', () => { + expect(bad('parent-issue:TSK-1').code).toBe('invalidOperator') + expect(ok('has:parent-issue').type).toBe('presence') + }) +}) + +describe('parseFilter values', () => { + it('parses comparison operators on numbers and dates', () => { + expect(ok('estimate:>5')).toMatchObject({ values: [{ kind: 'compare', op: '>', value: { kind: 'number', value: 5 } }] }) + expect(ok('estimate:<=2')).toMatchObject({ values: [{ kind: 'compare', op: '<=' }] }) + expect(ok('due:>=@today-7d')).toMatchObject({ + values: [{ kind: 'compare', op: '>=', value: { kind: 'date', amount: -7, unit: 'd' } }] + }) + }) + + it('parses inclusive ranges and wildcards', () => { + expect(ok('estimate:2..5')).toMatchObject({ values: [{ kind: 'range', from: { value: 2 }, to: { value: 5 } }] }) + const open = ok('estimate:3..*') + expect(open.type === 'field' && open.values[0]).toEqual({ kind: 'range', from: { kind: 'number', value: 3 }, to: undefined }) + const lower = ok('estimate:*..3') + expect(lower.type === 'field' && lower.values[0]).toMatchObject({ kind: 'range', from: undefined }) + expect(ok('due:2026-01-01..2026-02-01')).toMatchObject({ values: [{ kind: 'range' }] }) + }) + + it('parses @me for users only', () => { + expect(ok('assignee:@me')).toMatchObject({ values: [{ kind: 'eq', value: { kind: 'me' } }] }) + expect(ok('assignee:@alice')).toMatchObject({ values: [{ value: { kind: 'text', text: 'alice' } }] }) + expect(ok('title:@me')).toMatchObject({ values: [{ value: { kind: 'text', text: '@me' } }] }) + }) + + it('parses iteration keywords with arithmetic', () => { + expect(ok('iteration:@current')).toMatchObject({ values: [{ value: { kind: 'iteration', keyword: 'current', offset: 0 } }] }) + expect(ok('iteration:@current+1')).toMatchObject({ values: [{ value: { offset: 1 } }] }) + expect(ok('iteration:@previous,@next').type).toBe('field') + expect(ok('iteration:"Sprint 2"')).toMatchObject({ values: [{ value: { kind: 'text', text: 'Sprint 2' } }] }) + expect(ok('iteration:@current..@next')).toMatchObject({ values: [{ kind: 'range' }] }) + }) + + it('keeps a quoted operator or range as literal text', () => { + expect(ok('title:">5"')).toMatchObject({ values: [{ value: { kind: 'text', text: '>5' } }] }) + expect(ok('title:a..b')).toMatchObject({ values: [{ kind: 'eq', value: { text: 'a..b' } }] }) + }) +}) + +describe('parseFilter errors', () => { + it('reports unknown fields with position and hints', () => { + const e = bad('status:Done stat:x') + expect(e).toMatchObject({ code: 'unknownField', pos: 12, end: 16 }) + expect(e.hints).toContain('status') + }) + + it('reports missing values', () => { + expect(bad('status:')).toMatchObject({ code: 'missingValue' }) + expect(bad('estimate:>')).toMatchObject({ code: 'missingValue' }) + expect(bad('status:Todo,')).toMatchObject({ code: 'missingValue', pos: 12 }) + }) + + it('reports comparison on non-orderable fields', () => { + expect(bad('status:>Todo')).toMatchObject({ code: 'invalidOperator', pos: 7, end: 8 }) + expect(bad('title: { + expect(bad('estimate:abc')).toMatchObject({ code: 'invalidNumber', pos: 9 }) + expect(bad('estimate:1..x')).toMatchObject({ code: 'invalidNumber', pos: 12 }) + expect(bad('due:tomorrow')).toMatchObject({ code: 'invalidDate', pos: 4 }) + expect(bad('due:2026-02-31').code).toBe('invalidDate') + }) + + it('reports ranges with too many bounds', () => { + expect(bad('estimate:1..2..3').code).toBe('invalidRange') + }) + + it('reports unknown iteration keywords', () => { + const e = bad('iteration:@soon') + expect(e.code).toBe('unknownKeyword') + expect(e.hints).toContain('@current') + }) + + it('reports unbalanced parentheses', () => { + expect(bad('(a OR b')).toMatchObject({ code: 'unbalancedParenthesis', pos: 0 }) + expect(bad('a OR b)')).toMatchObject({ code: 'unbalancedParenthesis', pos: 6 }) + expect(bad('()').code).toBe('unbalancedParenthesis') + }) + + it('reports dangling operators', () => { + expect(bad('AND a').code).toBe('unexpectedToken') + expect(bad('a OR').code).toBe('unexpectedToken') + expect(bad('a AND OR b').code).toBe('unexpectedToken') + }) + + it('passes tokenizer errors through', () => { + expect(bad('title:"x')).toMatchObject({ code: 'unterminatedQuote', pos: 6 }) + }) + + it('never throws', () => { + for (const s of ['(', ')', 'OR', '-', '-(', 'is:', 'has:', 'no:,', ':', 'a:', '"', '..', 'estimate:..', 'due:*..*']) { + expect(() => parseFilter(s, schema)).not.toThrow() + } + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/suggest.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/suggest.test.ts new file mode 100644 index 0000000000..eb7caea078 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/suggest.test.ts @@ -0,0 +1,104 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { appendTerm, applySuggestion, joinAnd, suggest } from '../suggest' +import { schema } from './fixtures' + +const labels = (text: string, caret = text.length): string[] => suggest(text, caret, schema).items.map((i) => i.label) + +describe('suggest', () => { + it('offers all field names for an empty input', () => { + const res = suggest('', 0, schema) + expect(res.items.length).toBeGreaterThan(0) + expect(res.items[0].kind).toBe('field') + }) + + it('filters field names by prefix, including has/no/is', () => { + expect(labels('sta')).toEqual(['status']) + expect(labels('-ass')).toEqual(['assignee']) + expect(labels('i')).toEqual(expect.arrayContaining(['is', 'iteration'])) + expect(suggest('sta', 3, schema).items[0].insert).toBe('status:') + }) + + it('offers option names after the colon', () => { + expect(labels('status:')).toEqual(['Todo', 'In Progress', 'Done', 'Canceled']) + expect(labels('status:do')).toEqual(['Done']) + }) + + it('quotes values with spaces', () => { + expect(suggest('status:in', 9, schema).items[0].insert).toBe('"In Progress"') + }) + + it('completes only the value after the last comma', () => { + const res = suggest('status:Todo,d', 13, schema) + expect(res.items.map((i) => i.label)).toEqual(['Done']) + expect(applySuggestion('status:Todo,d', res, res.items[0]).text).toBe('status:Todo,Done') + }) + + it('offers keywords per field type', () => { + expect(labels('assignee:')).toContain('@me') + expect(labels('due:@')).toEqual(['@today', '@today-7d', '@today+7d']) + expect(labels('iteration:@c')).toEqual(['@current']) + }) + + it('offers field names after has: and no:, and is: values', () => { + expect(labels('has:a')).toEqual(expect.arrayContaining(['assignee', 'area'])) + expect(labels('no:du')).toEqual(['due']) + expect(labels('is:')).toEqual(['open', 'closed', 'issue', 'sub-issue', 'archived', 'draft']) + }) + + it('ignores comparison operators when completing', () => { + const res = suggest('due:>@to', 8, schema) + expect(res.items.map((i) => i.label)).toEqual(['@today', '@today-7d', '@today+7d']) + expect(applySuggestion('due:>@to', res, res.items[0]).text).toBe('due:>@today') + }) + + it('completes the term at the caret in the middle of the input', () => { + const text = 'status:Done sta label:bug' + const res = suggest(text, 15, schema) + expect(res.items.map((i) => i.label)).toEqual(['status']) + expect(applySuggestion(text, res, res.items[0]).text).toBe('status:Done status: label:bug') + }) + + it('does not treat a space inside quotes as a term boundary', () => { + expect(labels('status:"In Pr')).toEqual(['In Progress']) + }) + + it('has no suggestions for unknown fields', () => { + expect(labels('foo:')).toEqual([]) + }) +}) + +describe('appendTerm', () => { + it('starts a filter', () => { + expect(appendTerm('', 'status', 'Done')).toBe('status:Done') + }) + + it('adds an AND term and quotes when needed', () => { + expect(appendTerm('label:bug', 'status', 'In Progress')).toBe('label:bug status:"In Progress"') + }) + + it('does not repeat an existing term', () => { + expect(appendTerm('status:Done label:bug', 'status', 'Done')).toBe('status:Done label:bug') + }) + + it('parenthesizes a filter that has a top level OR', () => { + expect(appendTerm('a OR b', 'status', 'Done')).toBe('(a OR b) status:Done') + }) +}) + +describe('joinAnd', () => { + it('joins with a space and skips empty parts', () => { + expect(joinAnd('a', 'b')).toBe('a b') + expect(joinAnd('', 'b')).toBe('b') + expect(joinAnd(' a ', '')).toBe('a') + }) + + it('parenthesizes parts with a top level OR', () => { + expect(joinAnd('a OR b', 'c')).toBe('(a OR b) c') + expect(joinAnd('a', 'b OR c')).toBe('a (b OR c)') + expect(joinAnd('(a OR b)', 'c')).toBe('(a OR b) c') + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/tokenizer.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/tokenizer.test.ts new file mode 100644 index 0000000000..d29684e101 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/tokenizer.test.ts @@ -0,0 +1,108 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { tokenize } from '../tokenizer' +import type { Token } from '../types' + +function ok (input: string): Token[] { + const res = tokenize(input) + if (!res.ok) throw new Error(res.error.message) + return res.value +} + +describe('tokenize', () => { + it('returns no tokens for blank input', () => { + expect(ok('')).toEqual([]) + expect(ok(' \t ')).toEqual([]) + }) + + it('splits free text words', () => { + const t = ok('login bug') + expect(t).toHaveLength(2) + expect(t[0]).toMatchObject({ type: 'term', field: undefined, values: [{ text: 'login' }] }) + }) + + it('parses field:value with positions', () => { + const [t] = ok('status:Done') + expect(t).toMatchObject({ + type: 'term', + pos: 0, + end: 11, + negated: false, + field: { name: 'status', pos: 0, end: 6 }, + values: [{ text: 'Done', quoted: false, pos: 7, end: 11 }] + }) + }) + + it('splits comma separated values', () => { + const [t] = ok('status:Todo,Done,"In Progress"') + expect(t.type === 'term' && t.values.map((v) => [v.text, v.quoted])).toEqual([ + ['Todo', false], + ['Done', false], + ['In Progress', true] + ]) + }) + + it('keeps commas, colons and parentheses inside quotes', () => { + const [t] = ok('title:"a, b: (c)"') + expect(t.type === 'term' && t.values).toHaveLength(1) + expect(t.type === 'term' && t.values[0].text).toBe('a, b: (c)') + }) + + it('unescapes quotes inside quotes', () => { + const [t] = ok('title:"say \\"hi\\""') + expect(t.type === 'term' && t.values[0].text).toBe('say "hi"') + }) + + it('marks negation', () => { + const [t] = ok('-label:bug') + expect(t).toMatchObject({ type: 'term', negated: true, field: { name: 'label' } }) + }) + + it('treats a lone dash as text', () => { + const [t] = ok('-') + expect(t).toMatchObject({ type: 'term', negated: false, values: [{ text: '-' }] }) + }) + + it('recognizes AND, OR and parentheses', () => { + expect(ok('(a OR b) AND c').map((t) => t.type)).toEqual(['lparen', 'term', 'or', 'term', 'rparen', 'and', 'term']) + }) + + it('does not treat lower case or quoted and/or as operators', () => { + expect(ok('and or').map((t) => t.type)).toEqual(['term', 'term']) + expect(ok('"OR"').map((t) => t.type)).toEqual(['term']) + }) + + it('negates a group', () => { + expect(ok('-(a b)').map((t) => t.type)).toEqual(['not', 'lparen', 'term', 'term', 'rparen']) + }) + + it('does not treat a digit-led prefix as a field (times, versions)', () => { + const [t] = ok('12:30') + expect(t).toMatchObject({ type: 'term', field: undefined, values: [{ text: '12:30' }] }) + }) + + it('accepts field names in any script', () => { + const [t] = ok('приоритет:высокий') + expect(t).toMatchObject({ type: 'term', field: { name: 'приоритет' }, values: [{ text: 'высокий' }] }) + }) + + it('keeps an empty value after the colon', () => { + const [t] = ok('status:') + expect(t.type === 'term' && t.values).toEqual([{ text: '', quoted: false, pos: 7, end: 7 }]) + }) + + it('reports an unterminated quote with its position', () => { + const res = tokenize('status:Done title:"oops') + expect(res.ok).toBe(false) + if (!res.ok) expect(res.error).toMatchObject({ code: 'unterminatedQuote', pos: 18 }) + }) + + it('never throws on arbitrary input', () => { + for (const s of ['"', '\\', '))((', ':', ',,,', '-:', '"a" "b', ':::', 'a:"', '\u0000']) { + expect(() => tokenize(s)).not.toThrow() + } + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/__tests__/values.test.ts b/plugins/view-resources/src/filter/grammar/__tests__/values.test.ts new file mode 100644 index 0000000000..b6f431d3c2 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/__tests__/values.test.ts @@ -0,0 +1,127 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + containsGlob, + endOfDay, + matchGlob, + parseDateScalar, + parseIterationScalar, + resolveDate, + resolveIteration, + startOfDay +} from '../values' +import { ITERATIONS, NOW } from './fixtures' + +function date (text: string): number { + const s = parseDateScalar(text) + if (s?.kind !== 'date') throw new Error(`not a date: ${text}`) + return resolveDate(s, NOW) +} + +describe('dates', () => { + it('parses absolute dates and rejects non-existing ones', () => { + expect(date('2026-02-03')).toBe(new Date(2026, 1, 3).getTime()) + expect(parseDateScalar('2026-02-30')).toBeUndefined() + expect(parseDateScalar('2026-13-01')).toBeUndefined() + expect(parseDateScalar('yesterday')).toBeUndefined() + }) + + it('resolves @today to the start of the current day', () => { + expect(date('@today')).toBe(startOfDay(NOW)) + }) + + it('applies day, week, month and year offsets', () => { + expect(date('@today-7d')).toBe(new Date(2026, 5, 10).getTime()) + expect(date('@today+1w')).toBe(new Date(2026, 5, 24).getTime()) + expect(date('@today-1m')).toBe(new Date(2026, 4, 17).getTime()) + expect(date('@today+1y')).toBe(new Date(2027, 5, 17).getTime()) + }) + + it('clamps month arithmetic to the end of a shorter month', () => { + const jan31 = new Date(2026, 0, 31, 12).getTime() + const s = parseDateScalar('@today+1m') + expect(s?.kind === 'date' && resolveDate(s, jan31)).toBe(new Date(2026, 1, 28).getTime()) + }) + + it('rejects an offset without unit', () => { + expect(parseDateScalar('@today-7')).toBeUndefined() + }) + + it('day bounds cover the whole day', () => { + expect(endOfDay(NOW) - startOfDay(NOW)).toBe(24 * 60 * 60 * 1000 - 1) + }) +}) + +describe('iterations', () => { + const resolve = (text: string): string | undefined => { + const s = parseIterationScalar(text) + if (s?.kind !== 'iteration') throw new Error(text) + return resolveIteration(s, ITERATIONS, NOW)?.id + } + + it('parses keywords with arithmetic', () => { + expect(parseIterationScalar('@current')).toEqual({ kind: 'iteration', keyword: 'current', offset: 0 }) + expect(parseIterationScalar('@current+1')).toEqual({ kind: 'iteration', keyword: 'current', offset: 1 }) + expect(parseIterationScalar('@next-2')).toEqual({ kind: 'iteration', keyword: 'next', offset: -2 }) + expect(parseIterationScalar('@soon')).toBeUndefined() + }) + + it('resolves current, next and previous', () => { + expect(resolve('@current')).toBe('it2') + expect(resolve('@next')).toBe('it3') + expect(resolve('@previous')).toBe('it1') + }) + + it('resolves arithmetic', () => { + expect(resolve('@current+1')).toBe('it3') + expect(resolve('@current+2')).toBe('it4') + expect(resolve('@current-1')).toBe('it1') + expect(resolve('@next+1')).toBe('it4') + }) + + it('returns undefined past the ends', () => { + expect(resolve('@current+3')).toBeUndefined() + expect(resolve('@current-2')).toBeUndefined() + expect(resolve('@previous-1')).toBeUndefined() + }) + + it('handles a gap between iterations', () => { + const gap = new Date(2026, 5, 14, 12).getTime() // between it1 end (14th 23:59) -> inside; use list without it2 + const list = ITERATIONS.filter((i) => i.id !== 'it2') + const now = new Date(2026, 5, 20).getTime() + const at = (kw: 'current' | 'next' | 'previous'): string | undefined => + resolveIteration({ kind: 'iteration', keyword: kw, offset: 0 }, list, now)?.id + expect(gap).toBeGreaterThan(0) + expect(at('current')).toBeUndefined() + expect(at('next')).toBe('it3') + expect(at('previous')).toBe('it1') + }) + + it('returns undefined without iterations', () => { + expect(resolveIteration({ kind: 'iteration', keyword: 'current', offset: 0 }, [], NOW)).toBeUndefined() + }) +}) + +describe('globs', () => { + it('matches whole labels case-insensitively without wildcard', () => { + expect(matchGlob('done', 'Done')).toBe(true) + expect(matchGlob('don', 'Done')).toBe(false) + }) + + it('supports wildcards at any position', () => { + expect(matchGlob('in*', 'In Progress')).toBe(true) + expect(matchGlob('*gress', 'In Progress')).toBe(true) + expect(matchGlob('i*s', 'In Progress')).toBe(true) + expect(matchGlob('a*a', 'a')).toBe(false) + expect(matchGlob('*', '')).toBe(true) + }) + + it('contains is a substring match', () => { + expect(containsGlob('login', 'Fix Login bug')).toBe(true) + expect(containsGlob('fix*bug', 'Fix login bug')).toBe(true) + expect(containsGlob('bug*fix', 'Fix login bug')).toBe(false) + }) +}) diff --git a/plugins/view-resources/src/filter/grammar/compile.d.ts.map b/plugins/view-resources/src/filter/grammar/compile.d.ts.map new file mode 100644 index 0000000000..904b7f5382 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/compile.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["compile.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAc,aAAa,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAQrF;;;GAGG;AACH,MAAM,WAAW,WAAW;IAE1B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAE1B,QAAQ,CAAC,EAAE,IAAI,CAAA;CAChB;AAgJD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,aAAa,EAAE,YAAY,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,WAAW,CASjH;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAG5F;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,IAAI,CAAA;IAET,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAE1B,QAAQ,CAAC,EAAE,IAAI,CAAA;IAEf,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAA;IAEhC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAA;CAC/B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,SAAS,SAAS,EAAE,EAC5B,GAAG,EAAE,aAAa,GACjB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,cAAc,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAexE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CA2BtE"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/compile.ts b/plugins/view-resources/src/filter/grammar/compile.ts new file mode 100644 index 0000000000..b7366646be --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/compile.ts @@ -0,0 +1,316 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createPredicate, numericMatch, optionIdsFor } from './evaluate' +import { parseFilter } from './parser' +import type { FieldSpec, FieldValue, FilterContext, Node, ParseError } from './types' + +// Compilation of the AST into a Huly `DocumentQuery`. A document query is a conjunction of one +// selector per attribute: it has no OR, so everything it cannot express stays in a residual AST +// that the client evaluates over the already narrowed result (see `splitServerClient`). + +type Clause = Record + +/** + * Server-side part of a filter and what is left for the client. + * @public + */ +export interface SplitResult { + // Selectors the server evaluates. Always a safe narrowing of the result + query: Record + // Part of the filter the server cannot evaluate; undefined when the query is exact + residual?: Node +} + +function conjuncts (node: Node): Node[] { + return node.type === 'and' ? node.children.flatMap(conjuncts) : [node] +} + +function isPlainSelector (v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +// A clause is added only when it does not collide with a selector already in the query +function mergeClause (query: Record, clause: Clause, reserved?: ReadonlySet): boolean { + for (const [key, sel] of Object.entries(clause)) { + if (reserved?.has(key) === true) return false + if (!(key in query)) continue + const existing = query[key] + if (!isPlainSelector(existing) || !isPlainSelector(sel)) return false + if (Object.keys(sel).some((op) => op in existing)) return false + } + for (const [key, sel] of Object.entries(clause)) { + query[key] = key in query ? { ...query[key], ...sel } : sel + } + return true +} + +// `%`, `_` and `\` are LIKE metacharacters; such text is left to the client instead of being escaped +const LIKE_UNSAFE = /[%_\\]/ + +function textClause (value: string): Clause | undefined { + if (LIKE_UNSAFE.test(value)) return undefined + return { title: { $like: `%${value.replaceAll('*', '%')}%` } } +} + +function serverField (field: FieldSpec): boolean { + return field.source === 'attribute' && field.clientOnly !== true +} + +function selectedIds (field: FieldSpec, values: readonly FieldValue[], ctx: FilterContext): Array | undefined { + const ids = new Set() + for (const v of values) { + if (v.kind !== 'eq') return undefined + for (const id of optionIdsFor(field, v.value, ctx)) ids.add(id) + } + return [...ids] +} + +function fieldClause (field: FieldSpec, values: readonly FieldValue[], negated: boolean, ctx: FilterContext): Clause | undefined { + if (!serverField(field)) return undefined + + if (field.resolveDocIds !== undefined) { + const ids = selectedIds(field, values, ctx) + if (ids === undefined) return undefined + const docs = field.resolveDocIds(ids) + return { _id: negated ? { $nin: docs } : { $in: docs } } + } + + switch (field.type) { + case 'text': { + if (negated || values.length !== 1) return undefined + const v = values[0] + return v.kind === 'eq' && v.value.kind === 'text' ? textClause(v.value.text) : undefined + } + case 'select': + case 'user': { + const ids = selectedIds(field, values, ctx) + if (ids === undefined) return undefined + return { [field.key]: negated ? { $nin: ids } : { $in: ids } } + } + case 'number': + case 'date': { + if (negated || values.length !== 1) return undefined + const m = numericMatch(field, values[0], ctx) + // A value that cannot match anything selects nothing + if (m === undefined) return { [field.key]: { $in: [] } } + const sel: Record = {} + if (m.lo !== undefined) sel[m.loExclusive === true ? '$gt' : '$gte'] = m.lo + if (m.hi !== undefined) sel[m.hiExclusive === true ? '$lt' : '$lte'] = m.hi + return { [field.key]: Object.keys(sel).length > 0 ? sel : { $ne: null } } + } + default: + return undefined + } +} + +function presenceClause (field: FieldSpec, present: boolean): Clause | undefined { + if (!serverField(field) && field.presenceQuery === undefined) return undefined + if (field.presenceQuery !== undefined) return field.presenceQuery(present) + switch (field.type) { + case 'select': + case 'user': + case 'number': + case 'date': + return { [field.key]: present ? { $ne: null } : null } + default: + return undefined + } +} + +// `a:x OR a:y` on one field is the same as `a:x,y` +function mergeSameField (node: Extract): Node | undefined { + const first = node.children[0] + if (first?.type !== 'field') return undefined + const values: FieldValue[] = [] + for (const c of node.children) { + if (c.type !== 'field' || c.field !== first.field) return undefined + values.push(...c.values) + } + return { type: 'field', field: first.field, values, pos: first.pos } +} + +function compileClause (node: Node, ctx: FilterContext): Clause | undefined { + switch (node.type) { + case 'text': + return textClause(node.value) + case 'field': + return fieldClause(node.field, node.values, false, ctx) + case 'presence': + return presenceClause(node.field, node.present) + case 'is': + switch (node.state) { + case 'issue': + return { isDraft: { $ne: true } } + case 'draft': + return { isDraft: true } + case 'open': + case 'closed': { + if (ctx.closedStatuses === undefined) return undefined + const ids = [...ctx.closedStatuses] + return { status: node.state === 'open' ? { $nin: ids } : { $in: ids } } + } + case 'archived': + return { archivedAt: { $ne: null } } + default: + return ctx.noParentId === undefined ? undefined : { attachedTo: { $ne: ctx.noParentId } } + } + case 'or': { + const merged = mergeSameField(node) + return merged !== undefined ? compileClause(merged, ctx) : undefined + } + case 'not': { + // `-is:archived` + if (node.child.type === 'is' && node.child.state === 'archived') return { archivedAt: null } + // `-is:draft` leaves drafts out, `-is:issue` keeps only drafts + if (node.child.type === 'is' && node.child.state === 'draft') return { isDraft: { $ne: true } } + if (node.child.type === 'is' && node.child.state === 'issue') return { isDraft: true } + const inner = node.child.type === 'or' ? (mergeSameField(node.child) ?? node.child) : node.child + return inner.type === 'field' ? fieldClause(inner.field, inner.values, true, ctx) : undefined + } + default: + return undefined + } +} + +/** + * Splits a filter into the part a `DocumentQuery` can express and the part the client has to evaluate. + * The top level AND is taken apart, each conjunct goes to the server when it is a plain condition on + * an indexed attribute. Anything else, in particular an OR across different fields or across server + * and client fields, is kept whole in the residual so that the combined result stays correct. + * Conditions on `reservedKeys` (attributes the host query already constrains) are left to the client too. + * @public + */ +export function splitServerClient (ast: Node, ctx: FilterContext, reservedKeys?: ReadonlySet): SplitResult { + const query: Record = {} + const rest: Node[] = [] + for (const c of conjuncts(ast)) { + const clause = compileClause(c, ctx) + if (clause === undefined || !mergeClause(query, clause, reservedKeys)) rest.push(c) + } + const residual = rest.length === 0 ? undefined : rest.length === 1 ? rest[0] : ({ type: 'and', children: rest } as Node) + return { query, residual } +} + +/** + * Query of a filter that is fully evaluated on the server, or undefined when part of it needs the client. + * @public + */ +export function compileQuery (ast: Node, ctx: FilterContext): Record | undefined { + const { query, residual } = splitServerClient(ast, ctx) + return residual === undefined ? query : undefined +} + +/** + * A parsed filter ready to be applied. + * @public + */ +export interface CompiledFilter { + ast: Node + // Narrowing the server applies + query: Record + // Client evaluation of what the server cannot do; undefined when the query is exact + residual?: Node + // Evaluates the residual (always true when there is none) + predicate: (doc: any) => boolean + // Evaluates the whole filter on the client, independently of the split + matches: (doc: any) => boolean +} + +/** + * Parses and compiles a filter string. An empty string matches everything. + * @public + */ +export function compileFilter ( + input: string, + schema: readonly FieldSpec[], + ctx: FilterContext +): { ok: true, value: CompiledFilter } | { ok: false, error: ParseError } { + const parsed = parseFilter(input, schema) + if (parsed.ok === false) return parsed + const ast = parsed.value + const { query, residual } = splitServerClient(ast, ctx) + return { + ok: true, + value: { + ast, + query, + residual, + predicate: createPredicate(residual, ctx), + matches: createPredicate(ast, ctx) + } + } +} + +/** + * Document properties a client evaluation of the node reads, for projecting a scan: + * attributes by name, plus `customFields` when a user-defined field is involved. + * @public + */ +export function referencedProperties (node: Node | undefined): string[] { + const props = new Set(['_id']) + const visit = (n: Node): void => { + switch (n.type) { + case 'and': + case 'or': + n.children.forEach(visit) + break + case 'not': + visit(n.child) + break + case 'text': + props.add('title') + break + case 'field': + case 'presence': + if (n.field.source === 'custom') props.add('customFields') + else props.add(n.field.key) + n.field.dependsOn?.forEach((d) => props.add(d)) + break + case 'is': + props.add( + n.state === 'sub-issue' + ? 'attachedTo' + : n.state === 'archived' + ? 'archivedAt' + : n.state === 'draft' || n.state === 'issue' + ? 'isDraft' + : 'status' + ) + break + } + } + if (node !== undefined) visit(node) + return [...props] +} + +/** + * Whether a filter talks about archived items anywhere (`is:archived`, `-is:archived`). Archived items are hidden + * unless the filter asks for them (GitHub's rule), see `archiveScopeQuery`. + * @public + */ +export function mentionsArchived (node: Node | undefined): boolean { + if (node === undefined) return false + switch (node.type) { + case 'and': + case 'or': + return node.children.some(mentionsArchived) + case 'not': + return mentionsArchived(node.child) + case 'is': + return node.state === 'archived' + default: + return false + } +} + +/** + * The query that keeps archived items out of a view: none when the filter mentions archived items (the filter + * decides then), otherwise `archivedAt` must be empty. + * @public + */ +export function archiveScopeQuery (node: Node | undefined): Record { + return mentionsArchived(node) ? {} : { archivedAt: null } +} diff --git a/plugins/view-resources/src/filter/grammar/evaluate.d.ts.map b/plugins/view-resources/src/filter/grammar/evaluate.d.ts.map new file mode 100644 index 0000000000..567c9be672 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/evaluate.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"evaluate.d.ts","sourceRoot":"","sources":["evaluate.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,aAAa,EAAiB,IAAI,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAMhG;;;GAGG;AACH,wBAAgB,cAAc,CAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAInE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAErD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,aAAa,GAAG,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAO1G;AAgFD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,EAAE,CAAC,EAAE,MAAM,CAAA;IAEX,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,WAAW,CAAC,EAAE,OAAO,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,GAAG,SAAS,CAS/G;AAyDD;;;GAGG;AACH,wBAAgB,YAAY,CAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,UAAU,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,aAAa,GAAG,OAAO,CAGpH;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,aAAa,GAAG,OAAO,CA6B3E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAE,IAAI,EAAE,IAAI,GAAG,SAAS,EAAE,GAAG,EAAE,aAAa,GAAG,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAGlG"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/evaluate.ts b/plugins/view-resources/src/filter/grammar/evaluate.ts new file mode 100644 index 0000000000..3970e9da5a --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/evaluate.ts @@ -0,0 +1,278 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { FieldSpec, FieldValue, FilterContext, IterationInfo, Node, Scalar } from './types' +import { containsGlob, endOfDay, matchGlob, resolveDate, resolveIteration } from './values' + +// Client-side evaluation of a parsed filter over a document. It defines the semantics of the +// grammar; the server query built by compile.ts must select the same documents. + +/** + * Whether a document is archived: it has an archive timestamp (`archivedAt`), a restored one carries `null`. + * @public + */ +export function isArchivedDoc (doc: any): boolean { + return doc?.archivedAt !== undefined && doc?.archivedAt !== null +} + +/** + * Whether a document is a draft item (`isDraft` is true), as opposed to an issue. + * @public + */ +export function isDraftDoc (doc: any): boolean { + return doc?.isDraft === true +} + +/** + * Raw value of a field in a document. + * @public + */ +export function readFieldValue (field: FieldSpec, doc: any): unknown { + if (field.read !== undefined) return field.read(doc) + if (field.source === 'custom') return doc?.customFields?.[field.key] + return doc?.[field.key] +} + +/** + * A value counts as empty when it is missing, null, an empty string or an empty list. + * @public + */ +export function isEmptyValue (value: unknown): boolean { + return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0) +} + +/** + * Option ids a select / user / multi value refers to. A text value is matched against the option + * labels (`*` wildcards) and, as a fallback, against the ids themselves. + * @public + */ +export function optionIdsFor (field: FieldSpec, scalar: Scalar, ctx: FilterContext): Array { + if (scalar.kind === 'me') return ctx.me !== undefined ? [ctx.me] : [] + if (scalar.kind !== 'text') return [] + const options = field.options ?? [] + const byName = options.filter((o) => matchGlob(scalar.text, o.name)).map((o) => o.id) + if (byName.length > 0) return byName + return options.filter((o) => String(o.id) === scalar.text).map((o) => o.id) +} + +/** + * Inclusive bounds of the days a date scalar covers; for one day they are its first and last millisecond. + */ +function dayBounds (scalar: Scalar, ctx: FilterContext): { start: number, end: number } | undefined { + if (scalar.kind !== 'date') return undefined + const start = resolveDate(scalar, ctx.now) + return { start, end: endOfDay(start) } +} + +function numberOf (scalar: Scalar | undefined): number | undefined { + return scalar?.kind === 'number' ? scalar.value : undefined +} + +function iterationOf ( + field: FieldSpec, + scalar: Scalar | undefined, + ctx: FilterContext +): IterationInfo | undefined { + if (scalar === undefined) return undefined + const list = ctx.iterations?.(field.key) ?? [] + if (scalar.kind === 'iteration') return resolveIteration(scalar, list, ctx.now) + if (scalar.kind === 'text') return list.find((it) => matchGlob(scalar.text, it.title) || it.id === scalar.text) + return undefined +} + +/** + * The window of the value a bound-based match works on: [lo, hi] where a missing bound is open. + * `exclusiveLo` / `exclusiveHi` are not needed, because every bound is expressed inclusively. + */ +interface Window { + lo?: number + hi?: number +} + +function windowOf (field: FieldSpec, value: FieldValue, ctx: FilterContext): Window | 'none' { + if (field.type === 'number') { + switch (value.kind) { + case 'eq': + return numberOf(value.value) === undefined ? 'none' : { lo: numberOf(value.value), hi: numberOf(value.value) } + case 'compare': { + const n = numberOf(value.value) + if (n === undefined) return 'none' + // Strict comparisons are expressed on the closed window by the caller + return value.op === '>' || value.op === '>=' ? { lo: n } : { hi: n } + } + case 'range': + return { lo: numberOf(value.from), hi: numberOf(value.to) } + } + } + // Dates: whole calendar days + switch (value.kind) { + case 'eq': { + const d = dayBounds(value.value, ctx) + return d === undefined ? 'none' : { lo: d.start, hi: d.end } + } + case 'compare': { + const d = dayBounds(value.value, ctx) + if (d === undefined) return 'none' + switch (value.op) { + case '>': + return { lo: d.end + 1 } + case '>=': + return { lo: d.start } + case '<': + return { hi: d.start - 1 } + default: + return { hi: d.end } + } + } + case 'range': { + const from = value.from !== undefined ? dayBounds(value.from, ctx) : undefined + const to = value.to !== undefined ? dayBounds(value.to, ctx) : undefined + if ((value.from !== undefined && from === undefined) || (value.to !== undefined && to === undefined)) return 'none' + return { lo: from?.start, hi: to?.end } + } + } +} + +/** + * Window of numeric / date comparisons for the compiler. Numbers compare exactly (so `>5` is `> 5`), + * which needs a flag; dates are widened to whole days and need none. + * @public + */ +export interface NumericMatch { + lo?: number + hi?: number + // Bounds exclude their value (numbers only) + loExclusive?: boolean + hiExclusive?: boolean +} + +/** + * Bounds of a number / date value, or undefined when the value cannot match anything. + * @public + */ +export function numericMatch (field: FieldSpec, value: FieldValue, ctx: FilterContext): NumericMatch | undefined { + const w = windowOf(field, value, ctx) + if (w === 'none') return undefined + const res: NumericMatch = { lo: w.lo, hi: w.hi } + if (field.type === 'number' && value.kind === 'compare') { + if (value.op === '>') res.loExclusive = true + if (value.op === '<') res.hiExclusive = true + } + return res +} + +function inWindow (v: number, m: NumericMatch): boolean { + if (m.lo !== undefined && (m.loExclusive === true ? v <= m.lo : v < m.lo)) return false + if (m.hi !== undefined && (m.hiExclusive === true ? v >= m.hi : v > m.hi)) return false + return true +} + +function matchValue (field: FieldSpec, value: FieldValue, raw: unknown, ctx: FilterContext): boolean { + switch (field.type) { + case 'text': { + if (value.kind !== 'eq' || value.value.kind !== 'text' || typeof raw !== 'string') return false + return containsGlob(value.value.text, raw) + } + case 'number': + case 'date': { + if (typeof raw !== 'number' || !Number.isFinite(raw)) return false + const m = numericMatch(field, value, ctx) + return m !== undefined && inWindow(raw, m) + } + case 'iteration': { + if (typeof raw !== 'string') return false + if (value.kind === 'eq') { + const target = iterationOf(field, value.value, ctx) + return target !== undefined && target.id === raw + } + const own = (ctx.iterations?.(field.key) ?? []).find((it) => it.id === raw) + if (own === undefined) return false + if (value.kind === 'compare') { + const ref = iterationOf(field, value.value, ctx) + if (ref === undefined) return false + switch (value.op) { + case '>': + return own.start > ref.start + case '>=': + return own.start >= ref.start + case '<': + return own.start < ref.start + default: + return own.start <= ref.start + } + } + const from = value.from !== undefined ? iterationOf(field, value.from, ctx) : undefined + const to = value.to !== undefined ? iterationOf(field, value.to, ctx) : undefined + if ((value.from !== undefined && from === undefined) || (value.to !== undefined && to === undefined)) return false + return (from === undefined || own.start >= from.start) && (to === undefined || own.start <= to.start) + } + default: { + // select, user, multi + if (value.kind !== 'eq') return false + const ids = new Set(optionIdsFor(field, value.value, ctx).map(String)) + if (Array.isArray(raw)) return raw.some((x) => ids.has(String(x))) + return !isEmptyValue(raw) && ids.has(String(raw)) + } + } +} + +/** + * Whether a document matches one `field:value` term (any of its comma separated values). + * @public + */ +export function matchesField (field: FieldSpec, values: readonly FieldValue[], doc: any, ctx: FilterContext): boolean { + const raw = readFieldValue(field, doc) + return values.some((v) => matchValue(field, v, raw, ctx)) +} + +/** + * Whether a document matches the AST. + * @public + */ +export function evaluate (node: Node, doc: any, ctx: FilterContext): boolean { + switch (node.type) { + case 'and': + return node.children.every((c) => evaluate(c, doc, ctx)) + case 'or': + return node.children.some((c) => evaluate(c, doc, ctx)) + case 'not': + return !evaluate(node.child, doc, ctx) + case 'text': + return containsGlob(node.value, String(doc?.title ?? '')) + case 'field': + return matchesField(node.field, node.values, doc, ctx) + case 'presence': { + const empty = isEmptyValue(readFieldValue(node.field, doc)) + return node.present ? !empty : empty + } + case 'is': { + switch (node.state) { + case 'open': + return !(ctx.closedStatuses?.has(String(doc?.status)) ?? false) + case 'closed': + return ctx.closedStatuses?.has(String(doc?.status)) ?? false + case 'sub-issue': + return ctx.noParentId !== undefined && doc?.attachedTo !== ctx.noParentId + case 'archived': + return isArchivedDoc(doc) + case 'draft': + return isDraftDoc(doc) + case 'issue': + return !isDraftDoc(doc) + default: + return true + } + } + } +} + +/** + * Predicate for a node, or for "everything" when there is nothing left to evaluate. + * @public + */ +export function createPredicate (node: Node | undefined, ctx: FilterContext): (doc: any) => boolean { + if (node === undefined) return () => true + return (doc) => evaluate(node, doc, ctx) +} diff --git a/plugins/view-resources/src/filter/grammar/index.d.ts.map b/plugins/view-resources/src/filter/grammar/index.d.ts.map new file mode 100644 index 0000000000..1c08d83113 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAKA,cAAc,WAAW,CAAA;AACzB,cAAc,YAAY,CAAA;AAC1B,cAAc,UAAU,CAAA;AACxB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/index.ts b/plugins/view-resources/src/filter/grammar/index.ts new file mode 100644 index 0000000000..f21f91031d --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/index.ts @@ -0,0 +1,12 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +export * from './compile' +export * from './evaluate' +export * from './parser' +export * from './suggest' +export * from './tokenizer' +export * from './types' +export * from './values' diff --git a/plugins/view-resources/src/filter/grammar/parser.d.ts.map b/plugins/view-resources/src/filter/grammar/parser.d.ts.map new file mode 100644 index 0000000000..565eeec553 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/parser.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["parser.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAEV,SAAS,EAET,UAAU,EACV,OAAO,EACP,IAAI,EACJ,UAAU,EAEV,MAAM,EAGN,UAAU,EACX,MAAM,SAAS,CAAA;AAGhB,oFAAoF;AACpF,eAAO,MAAM,SAAS,EAAE,OAAO,EAA6C,CAAA;AAE5E,qDAAqD;AACrD,eAAO,MAAM,eAAe,UAAsB,CAAA;AAIlD,KAAK,MAAM,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAA;AAM1E;;;GAGG;AACH,wBAAgB,iBAAiB,CAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAKpF;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAE,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAG5F;AA8LD;;;GAGG;AACH,wBAAgB,eAAe,CAAE,IAAI,EAAE,SAAS,EAAE,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,CAkCnF;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAItF"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/parser.ts b/plugins/view-resources/src/filter/grammar/parser.ts new file mode 100644 index 0000000000..ad193f12ae --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/parser.ts @@ -0,0 +1,299 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { tokenize } from './tokenizer' +import type { + CompareOp, + FieldSpec, + FieldType, + FieldValue, + IsState, + Node, + ParseError, + ParseErrorCode, + Result, + Scalar, + Token, + ValueToken +} from './types' +import { parseDateScalar, parseIterationScalar } from './values' + +/** + * Values accepted by `is:`. `issue`, `sub-issue` and `draft` mirror GitHub's item kinds (`is:issue` leaves drafts out), + * `archived` is GitHub's `is:archived`. + */ +export const IS_VALUES: IsState[] = ['open', 'closed', 'issue', 'sub-issue', 'archived', 'draft'] + +/** Pseudo fields that are not part of the schema. */ +export const RESERVED_FIELDS = ['has', 'no', 'is'] + +const ORDERED_TYPES: FieldType[] = ['number', 'date', 'iteration'] + +type Tagged = { ok: true, value: T } | { ok: false, error: ParseError } + +function err (code: ParseErrorCode, message: string, pos: number, end: number, hints?: string[]): Tagged { + return { ok: false, error: { code, message, pos, end, hints } } +} + +/** + * Field names that look like `typed`, best matches first, for "unknown field" hints. + * @public + */ +export function similarFieldNames (typed: string, names: readonly string[]): string[] { + const t = typed.toLowerCase() + const starts = names.filter((n) => n.startsWith(t)) + const contains = names.filter((n) => !n.startsWith(t) && (n.includes(t) || t.includes(n))) + return [...starts, ...contains].slice(0, 5) +} + +/** + * Resolves a typed field name in the schema (case-insensitive). + * @public + */ +export function findField (schema: readonly FieldSpec[], name: string): FieldSpec | undefined { + const n = name.toLowerCase() + return schema.find((f) => f.name === n) +} + +class Parser { + private i = 0 + + constructor ( + private readonly tokens: Token[], + private readonly schema: readonly FieldSpec[] + ) {} + + parse (): Tagged { + if (this.tokens.length === 0) return { ok: true, value: { type: 'and', children: [] } } + const res = this.parseOr() + if (res.ok === false) return res + const rest = this.tokens[this.i] + if (rest !== undefined) { + if (rest.type === 'rparen') return err('unbalancedParenthesis', 'Unmatched ")"', rest.pos, rest.end) + return err('unexpectedToken', 'Unexpected input', rest.pos, rest.end) + } + return res + } + + private peek (): Token | undefined { + return this.tokens[this.i] + } + + private parseOr (): Tagged { + const first = this.parseAnd() + if (first.ok === false) return first + const children: Node[] = [first.value] + while (this.peek()?.type === 'or') { + this.i++ + const next = this.parseAnd() + if (next.ok === false) return next + children.push(next.value) + } + return { ok: true, value: children.length === 1 ? children[0] : { type: 'or', children } } + } + + private parseAnd (): Tagged { + const first = this.parseUnary() + if (first.ok === false) return first + const children: Node[] = [first.value] + for (;;) { + const t = this.peek() + if (t === undefined || t.type === 'or' || t.type === 'rparen') break + if (t.type === 'and') this.i++ + const next = this.parseUnary() + if (next.ok === false) return next + children.push(next.value) + } + return { ok: true, value: children.length === 1 ? children[0] : { type: 'and', children } } + } + + private parseUnary (): Tagged { + const t = this.peek() + if (t === undefined) { + const last = this.tokens[this.tokens.length - 1] + return err('unexpectedToken', 'Expected a filter term', last?.end ?? 0, last?.end ?? 0) + } + switch (t.type) { + case 'lparen': + case 'not': { + const negated = t.type === 'not' + if (negated) { + this.i++ + if (this.peek()?.type !== 'lparen') return err('unexpectedToken', 'Expected "("', t.end, t.end) + } + const open = this.tokens[this.i] + this.i++ + const inner = this.parseOr() + if (inner.ok === false) return inner + const close = this.peek() + if (close?.type !== 'rparen') return err('unbalancedParenthesis', 'Missing ")"', open.pos, open.end) + this.i++ + return { ok: true, value: negated ? { type: 'not', child: inner.value } : inner.value } + } + case 'term': + this.i++ + return this.parseTerm(t) + case 'rparen': + return err('unbalancedParenthesis', 'Unmatched ")"', t.pos, t.end) + default: + return err('unexpectedToken', `Unexpected ${t.type.toUpperCase()}`, t.pos, t.end) + } + } + + private parseTerm (t: Extract): Tagged { + const wrap = (node: Node): Tagged => ({ ok: true, value: t.negated ? { type: 'not', child: node } : node }) + + if (t.field === undefined) { + const v = t.values[0] + if (v.text === '') return err('missingValue', 'Empty search text', t.pos, t.end) + return wrap({ type: 'text', value: v.text, quoted: v.quoted, pos: t.pos }) + } + + const name = t.field.name.toLowerCase() + if (name === 'has' || name === 'no') return this.parsePresence(t, name === 'has', wrap) + if (name === 'is') return this.parseIs(t, wrap) + + const spec = findField(this.schema, name) + if (spec === undefined) { + const known = [...this.schema.map((f) => f.name), ...RESERVED_FIELDS] + return err('unknownField', `Unknown field "${t.field.name}"`, t.field.pos, t.field.end, similarFieldNames(name, known)) + } + if (spec.type === 'presence') { + return err('invalidOperator', `"${spec.name}" can only be used with has: or no:`, t.pos, t.end) + } + const values: FieldValue[] = [] + for (const v of t.values) { + const parsed = parseFieldValue(spec, v) + if (parsed.ok === false) return parsed + values.push(parsed.value) + } + return wrap({ type: 'field', field: spec, values, pos: t.pos }) + } + + private parsePresence ( + t: Extract, + present: boolean, + wrap: (n: Node) => Tagged + ): Tagged { + const nodes: Node[] = [] + for (const v of t.values) { + if (v.text === '') return err('missingValue', 'Expected a field name', v.pos, Math.max(v.end, v.pos)) + const spec = findField(this.schema, v.text) + if (spec === undefined) { + return err( + 'unknownField', + `Unknown field "${v.text}"`, + v.pos, + v.end, + similarFieldNames( + v.text, + this.schema.map((f) => f.name) + ) + ) + } + nodes.push({ type: 'presence', field: spec, present, pos: t.pos }) + } + return wrap(nodes.length === 1 ? nodes[0] : { type: 'or', children: nodes }) + } + + private parseIs (t: Extract, wrap: (n: Node) => Tagged): Tagged { + const nodes: Node[] = [] + for (const v of t.values) { + const state = v.text.toLowerCase() as IsState + if (!IS_VALUES.includes(state)) { + return err('unknownKeyword', `Unknown value "${v.text}" for is:`, v.pos, v.end, IS_VALUES) + } + nodes.push({ type: 'is', state, pos: t.pos }) + } + return wrap(nodes.length === 1 ? nodes[0] : { type: 'or', children: nodes }) + } +} + +const OPERATORS: CompareOp[] = ['>=', '<=', '>', '<'] + +function parseScalar (spec: FieldSpec, text: string, quoted: boolean, pos: number, end: number): Tagged { + switch (spec.type) { + case 'number': { + const value = text.trim() === '' ? NaN : Number(text) + if (!Number.isFinite(value)) return err('invalidNumber', `"${text}" is not a number`, pos, end) + return { ok: true, value: { kind: 'number', value } } + } + case 'date': { + const date = parseDateScalar(text) + if (date === undefined) { + return err('invalidDate', `"${text}" is not a date, use YYYY-MM-DD or @today-7d`, pos, end, ['@today', '@today-7d']) + } + return { ok: true, value: date } + } + case 'iteration': { + if (!quoted && text.startsWith('@')) { + const it = parseIterationScalar(text) + if (it === undefined) { + return err('unknownKeyword', `Unknown keyword "${text}"`, pos, end, ['@current', '@next', '@previous']) + } + return { ok: true, value: it } + } + return { ok: true, value: { kind: 'text', text, quoted } } + } + case 'user': + if (!quoted && text === '@me') return { ok: true, value: { kind: 'me' } } + return { ok: true, value: { kind: 'text', text: !quoted && text.startsWith('@') ? text.slice(1) : text, quoted } } + default: + return { ok: true, value: { kind: 'text', text, quoted } } + } +} + +/** + * Parses one comma separated value of `field:value` according to the field type. + * @public + */ +export function parseFieldValue (spec: FieldSpec, v: ValueToken): Tagged { + const ordered = ORDERED_TYPES.includes(spec.type) + let text = v.text + if (text === '') return err('missingValue', `Expected a value for "${spec.name}"`, v.pos, Math.max(v.end, v.pos + 1)) + + if (!v.quoted) { + const op = OPERATORS.find((o) => text.startsWith(o)) + if (op !== undefined) { + if (!ordered) { + return err('invalidOperator', `"${op}" cannot be used with "${spec.name}"`, v.pos, v.pos + op.length) + } + text = text.slice(op.length) + if (text === '') return err('missingValue', `Expected a value after "${op}"`, v.pos, v.end) + const scalar = parseScalar(spec, text, false, v.pos + op.length, v.end) + if (scalar.ok === false) return scalar + return { ok: true, value: { kind: 'compare', op, value: scalar.value } } + } + const dots = text.indexOf('..') + if (dots !== -1 && ordered) { + const left = text.slice(0, dots) + const right = text.slice(dots + 2) + const bound = (part: string, offset: number): Tagged => { + if (part === '' || part === '*') return { ok: true, value: undefined } + return parseScalar(spec, part, false, v.pos + offset, v.pos + offset + part.length) + } + if (right.includes('..')) return err('invalidRange', 'A range has exactly two bounds', v.pos, v.end) + const from = bound(left, 0) + if (from.ok === false) return from + const to = bound(right, dots + 2) + if (to.ok === false) return to + return { ok: true, value: { kind: 'range', from: from.value, to: to.value } } + } + } + const scalar = parseScalar(spec, text, v.quoted, v.pos, v.end) + if (scalar.ok === false) return scalar + return { ok: true, value: { kind: 'eq', value: scalar.value } } +} + +/** + * Parses a filter string into an AST. Never throws; errors carry the offending position. + * An empty string yields an empty AND that matches everything. + * @public + */ +export function parseFilter (input: string, schema: readonly FieldSpec[]): Result { + const tokens = tokenize(input) + if (tokens.ok === false) return tokens + return new Parser(tokens.value, schema).parse() +} diff --git a/plugins/view-resources/src/filter/grammar/suggest.d.ts.map b/plugins/view-resources/src/filter/grammar/suggest.d.ts.map new file mode 100644 index 0000000000..378a29e523 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/suggest.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"suggest.d.ts","sourceRoot":"","sources":["suggest.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAS,MAAM,SAAS,CAAA;AAE/C;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IAEvB,KAAK,EAAE,MAAM,CAAA;IAEb,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,UAAU,EAAE,CAAA;CACpB;AA8CD;;;;GAIG;AACH,wBAAgB,OAAO,CAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,gBAAgB,CAqDpG;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,UAAU,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAGjC;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAUrD"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/suggest.ts b/plugins/view-resources/src/filter/grammar/suggest.ts new file mode 100644 index 0000000000..26b0efc5f9 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/suggest.ts @@ -0,0 +1,180 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { findField, IS_VALUES } from './parser' +import { tokenize } from './tokenizer' +import type { FieldSpec, Token } from './types' + +/** + * @public + */ +export interface Suggestion { + kind: 'field' | 'value' + // Shown in the list + label: string + // Replaces the typed fragment [from, to) of the input + insert: string +} + +/** + * @public + */ +export interface SuggestionResult { + from: number + to: number + items: Suggestion[] +} + +const MAX_ITEMS = 12 +const PSEUDO_FIELDS = ['has', 'no', 'is'] + +// Start of the term the caret is in: after the last whitespace or "(" that is not inside quotes +function termStart (text: string, caret: number): number { + let start = 0 + let quoted = false + for (let i = 0; i < caret; i++) { + const c = text[i] + if (c === '"' && text[i - 1] !== '\\') quoted = !quoted + else if (!quoted && (c === ' ' || c === '\t' || c === '\n' || c === '(' || c === ')')) start = i + 1 + } + return start +} + +// OR outside of any parentheses +function hasTopLevelOr (tokens: Token[]): boolean { + let depth = 0 + for (const t of tokens) { + if (t.type === 'lparen') depth++ + else if (t.type === 'rparen') depth-- + else if (t.type === 'or' && depth === 0) return true + } + return false +} + +function quoteIfNeeded (value: string): string { + return /[\s,:()"]/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value +} + +function valueSuggestions (field: FieldSpec): string[] { + const options = (field.options ?? []).map((o) => o.name) + switch (field.type) { + case 'user': + return ['@me', ...options] + case 'date': + return ['@today', '@today-7d', '@today+7d'] + case 'iteration': + return ['@current', '@next', '@previous', ...options] + default: + return options + } +} + +/** + * Autocomplete suggestions for the term at the caret: field names (with `has`, `no`, `is`) when the + * term has no colon yet, otherwise the known values of the typed field. + * @public + */ +export function suggest (text: string, caret: number, schema: readonly FieldSpec[]): SuggestionResult { + const start = termStart(text, caret) + let from = start + let word = text.slice(start, caret) + if (word.startsWith('-')) { + from++ + word = word.slice(1) + } + + const colon = word.indexOf(':') + if (colon === -1) { + const typed = word.toLowerCase() + const names = [...schema.map((f) => f.name), ...PSEUDO_FIELDS] + const items = names + .filter((n) => n.startsWith(typed) && n !== typed) + .slice(0, MAX_ITEMS) + .map((n): Suggestion => ({ kind: 'field', label: n, insert: `${n}:` })) + return { from, to: caret, items } + } + + const name = word.slice(0, colon).toLowerCase() + from += colon + 1 + let rest = word.slice(colon + 1) + // Only the value after the last comma outside quotes is completed + let comma = -1 + let quoted = false + for (let i = 0; i < rest.length; i++) { + if (rest[i] === '"') quoted = !quoted + else if (rest[i] === ',' && !quoted) comma = i + } + if (comma !== -1) { + from += comma + 1 + rest = rest.slice(comma + 1) + } + const opMatch = /^(>=|<=|>|<)/.exec(rest) + if (opMatch !== null) { + from += opMatch[0].length + rest = rest.slice(opMatch[0].length) + } + const typed = rest.replace(/^"/, '').toLowerCase() + + let candidates: string[] + if (name === 'has' || name === 'no') candidates = schema.map((f) => f.name) + else if (name === 'is') candidates = IS_VALUES + else { + const field = findField(schema, name) + candidates = field === undefined ? [] : valueSuggestions(field) + } + const items = [...new Set(candidates)] + .filter((c) => c.toLowerCase().startsWith(typed) && c.toLowerCase() !== typed) + .slice(0, MAX_ITEMS) + .map((c): Suggestion => ({ kind: 'value', label: c, insert: quoteIfNeeded(c) })) + return { from, to: caret, items } +} + +/** + * Applies a suggestion to the input; returns the new text and caret position. + * @public + */ +export function applySuggestion ( + text: string, + result: SuggestionResult, + item: Suggestion +): { text: string, caret: number } { + const next = text.slice(0, result.from) + item.insert + text.slice(result.to) + return { text: next, caret: result.from + item.insert.length } +} + +/** + * Adds `field:value` as one more AND condition to a filter string (the "click a value to filter" action). + * A term that is already there is not repeated; a filter with a top level OR is parenthesized first so + * that the new condition applies to the whole of it. + * @public + */ +export function appendTerm (query: string, field: string, value: string): string { + const term = `${field}:${quoteIfNeeded(value)}` + const trimmed = query.trim() + if (trimmed === '') return term + const tokens = tokenize(trimmed) + if (tokens.ok) { + const present = tokens.value.some((t) => t.type === 'term' && !t.negated && trimmed.slice(t.pos, t.end) === term) + if (present) return trimmed + if (hasTopLevelOr(tokens.value)) return `(${trimmed}) ${term}` + } + return `${trimmed} ${term}` +} + +/** + * Joins two filter strings with AND; a part with a top level OR is parenthesized so that it keeps its meaning. + * @public + */ +export function joinAnd (a: string, b: string): string { + const left = a.trim() + const right = b.trim() + if (left === '') return right + if (right === '') return left + const wrap = (text: string): string => { + const tokens = tokenize(text) + return tokens.ok && hasTopLevelOr(tokens.value) ? `(${text})` : text + } + return `${wrap(left)} ${wrap(right)}` +} diff --git a/plugins/view-resources/src/filter/grammar/tokenizer.d.ts.map b/plugins/view-resources/src/filter/grammar/tokenizer.d.ts.map new file mode 100644 index 0000000000..81394ca7ca --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/tokenizer.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"tokenizer.d.ts","sourceRoot":"","sources":["tokenizer.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAc,MAAM,EAAE,KAAK,EAAc,MAAM,SAAS,CAAA;AASpE;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,EAAE,CAAC,CA6FxD"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/tokenizer.ts b/plugins/view-resources/src/filter/grammar/tokenizer.ts new file mode 100644 index 0000000000..7fc6cf95d3 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/tokenizer.ts @@ -0,0 +1,120 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { ParseError, Result, Token, ValueToken } from './types' + +// Letters of any script: field names of user-defined fields come from their labels +const FIELD_NAME = /^\p{L}[\p{L}\p{N}_.-]*$/u + +function isSpace (c: string): boolean { + return c === ' ' || c === '\t' || c === '\n' || c === '\r' +} + +/** + * Splits a filter string into tokens. Never throws: a malformed string yields `{ ok: false, error }`. + * + * - `field:a,b` is one term with several comma separated values, `-field:a` is negated + * - double quotes keep spaces, commas, colons and parentheses inside a value; `\"` is a quote + * - `AND`, `OR` (upper case, standalone) and parentheses are operators + * @public + */ +export function tokenize (input: string): Result { + const tokens: Token[] = [] + const n = input.length + let i = 0 + + while (i < n) { + const c = input[i] + if (isSpace(c)) { + i++ + continue + } + if (c === '(') { + tokens.push({ type: 'lparen', pos: i, end: i + 1 }) + i++ + continue + } + if (c === ')') { + tokens.push({ type: 'rparen', pos: i, end: i + 1 }) + i++ + continue + } + if (c === '-' && input[i + 1] === '(') { + tokens.push({ type: 'not', pos: i, end: i + 1 }) + i++ + continue + } + + const start = i + let negated = false + if (c === '-' && i + 1 < n && !isSpace(input[i + 1])) { + negated = true + i++ + } + + let field: { name: string, pos: number, end: number } | undefined + const values: ValueToken[] = [] + let cur: ValueToken = { text: '', quoted: false, pos: i, end: i } + const textStart = i + + while (i < n) { + const ch = input[i] + if (isSpace(ch) || ch === ')') break + if (ch === '"') { + let j = i + 1 + let text = '' + let closed = false + while (j < n) { + if (input[j] === '\\' && input[j + 1] === '"') { + text += '"' + j += 2 + continue + } + if (input[j] === '"') { + closed = true + break + } + text += input[j] + j++ + } + if (!closed) return fail('unterminatedQuote', 'Unterminated quote', i, n) + cur.text += text + cur.quoted = true + i = j + 1 + continue + } + if (ch === ':' && field === undefined && !cur.quoted && FIELD_NAME.test(cur.text)) { + field = { name: cur.text, pos: textStart, end: i } + cur = { text: '', quoted: false, pos: i + 1, end: i + 1 } + i++ + continue + } + if (ch === ',' && field !== undefined) { + cur.end = i + values.push(cur) + cur = { text: '', quoted: false, pos: i + 1, end: i + 1 } + i++ + continue + } + cur.text += ch + i++ + } + cur.end = i + values.push(cur) + + const raw = input.slice(start, i) + if (field === undefined && !negated && !cur.quoted && (raw === 'AND' || raw === 'OR')) { + tokens.push({ type: raw === 'AND' ? 'and' : 'or', pos: start, end: i }) + continue + } + tokens.push({ type: 'term', pos: start, end: i, negated, field, values }) + } + + return { ok: true, value: tokens } +} + +function fail (code: ParseError['code'], message: string, pos: number, end: number): Result { + return { ok: false, error: { code, message, pos, end } } +} diff --git a/plugins/view-resources/src/filter/grammar/types.d.ts.map b/plugins/view-resources/src/filter/grammar/types.d.ts.map new file mode 100644 index 0000000000..2473c968e3 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AASA;;;GAGG;AACH,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,QAAQ,GACR,MAAM,GACN,QAAQ,GACR,OAAO,GACP,MAAM,GACN,WAAW,GACX,UAAU,CAAA;AAEd;;GAEG;AACH,MAAM,WAAW,WAAW;IAE1B,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IAEnB,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IAExB,IAAI,EAAE,MAAM,CAAA;IAEZ,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,SAAS,CAAA;IAEf,MAAM,EAAE,WAAW,GAAG,QAAQ,CAAA;IAE9B,GAAG,EAAE,MAAM,CAAA;IAEX,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;IAEvB,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAA;IAE5B,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,KAAK,MAAM,EAAE,CAAA;IAE/D,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAEzD,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;IAEpB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GACtB,mBAAmB,GACnB,uBAAuB,GACvB,iBAAiB,GACjB,cAAc,GACd,cAAc,GACd,iBAAiB,GACjB,cAAc,GACd,aAAa,GACb,eAAe,GACf,cAAc,GACd,gBAAgB,CAAA;AAEpB;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;IAEf,GAAG,EAAE,MAAM,CAAA;IAEX,GAAG,EAAE,MAAM,CAAA;IAEX,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB;AAED;;GAEG;AACH,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,CAAA;AAIjF;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;CACZ;AAED;;GAEG;AACH,MAAM,MAAM,KAAK,GACb;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAExC;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACzC;IACA,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,OAAO,CAAA;IAEhB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAA;IAClD,MAAM,EAAE,UAAU,EAAE,CAAA;CACrB,CAAA;AAIH;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;AAE5C;;GAEG;AACH,MAAM,MAAM,MAAM,GACd;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,GACd;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAEjC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC9D;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,SAAS,GAAG,MAAM,GAAG,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAEnF;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,GAAG,GAAG,IAAI,GAAG,GAAG,GAAG,IAAI,CAAA;AAE/C;;GAEG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAEjD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAIjD;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,GAAG,WAAW,CAAA;AAE/D;;GAEG;AACH,MAAM,MAAM,IAAI,GACZ;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,IAAI,EAAE,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,IAAI,EAAE,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GAE5B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAE7D;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,UAAU,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAEtE;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACrE;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAA;AAE/C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAE5B,GAAG,EAAE,MAAM,CAAA;IAEX,EAAE,CAAC,EAAE,MAAM,CAAA;IAEX,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,aAAa,EAAE,CAAA;IAElD,cAAc,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IAEpC,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IAEb,KAAK,EAAE,MAAM,CAAA;IAEb,GAAG,EAAE,MAAM,CAAA;CACZ"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/types.ts b/plugins/view-resources/src/filter/grammar/types.ts new file mode 100644 index 0000000000..7f06eb442e --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/types.ts @@ -0,0 +1,213 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Shared types of the GitHub-Projects style filter grammar (plan D3). The module is pure: +// it has no platform or UI imports so that the tokenizer, parser, compiler and evaluator +// can be unit tested and reused by any view. + +/** + * Kind of values a field holds. It decides which operators and value syntaxes are valid. + * @public + */ +export type FieldType = + | 'text' // contains-match, `*` wildcards + | 'number' // =, comparison, ranges + | 'date' // =, comparison, ranges, `@today[-7d]` + | 'select' // one of the options, matched by label + | 'multi' // any of the options (labels, multi-select) + | 'user' // like select, plus `@me` + | 'iteration' // like select, plus `@current`, `@next`, `@previous` and arithmetic + | 'presence' // only usable with has:/no: + +/** + * @public + */ +export interface FieldOption { + // Stored value (ref, enum value or option id) + id: string | number + // Label the user types in a filter + name: string +} + +/** + * Description of a filterable field. The schema is supplied by the host view. + * @public + */ +export interface FieldSpec { + // Name as typed in the filter, lowercase without spaces (`story-points`) + name: string + // Human readable label, for suggestions + label: string + type: FieldType + // Where the value lives: an attribute of the document or an entry of `customFields` + source: 'attribute' | 'custom' + // Attribute name or custom field key + key: string + // Choices of select / multi / user / iteration fields + options?: FieldOption[] + // Reads the value from a document. Defaults to the attribute / custom field named by `key` + read?: (doc: any) => unknown + // Document ids that have any of the option ids, for fields the server cannot index directly (labels) + resolveDocIds?: (optionIds: Array) => string[] + // Server query fragment for has:/no: on fields that are not plain attributes + presenceQuery?: (present: boolean) => Record + // Attributes `read` needs besides `key`, so that a client scan loads them + dependsOn?: string[] + // Never evaluated on the server (e.g. values that only exist on the client) + clientOnly?: boolean +} + +/** + * Where an error was found: character offsets into the filter string, end exclusive. + * @public + */ +export type ParseErrorCode = + | 'unterminatedQuote' + | 'unbalancedParenthesis' + | 'unexpectedToken' + | 'unknownField' + | 'missingValue' + | 'invalidOperator' + | 'invalidValue' + | 'invalidDate' + | 'invalidNumber' + | 'invalidRange' + | 'unknownKeyword' + +/** + * @public + */ +export interface ParseError { + code: ParseErrorCode + message: string + // Offset of the first offending character + pos: number + // Offset after the last offending character + end: number + // Suggestions for the offending token, e.g. known field names + hints?: string[] +} + +/** + * @public + */ +export type Result = { ok: true, value: T } | { ok: false, error: ParseError } + +// ---- tokens ---- + +/** + * One comma separated value of a `field:value` term. + * @public + */ +export interface ValueToken { + text: string + quoted: boolean + pos: number + end: number +} + +/** + * @public + */ +export type Token = + | { type: 'lparen', pos: number, end: number } + | { type: 'rparen', pos: number, end: number } + | { type: 'and', pos: number, end: number } + | { type: 'or', pos: number, end: number } + // `-(`: negation of a group + | { type: 'not', pos: number, end: number } + | { + type: 'term' + pos: number + end: number + negated: boolean + // Absent for free text + field?: { name: string, pos: number, end: number } + values: ValueToken[] + } + +// ---- values ---- + +/** + * Calendar unit of a relative date. + * @public + */ +export type DateUnit = 'd' | 'w' | 'm' | 'y' + +/** + * @public + */ +export type Scalar = + | { kind: 'text', text: string, quoted: boolean } + | { kind: 'me' } + | { kind: 'number', value: number } + // Absolute day (`abs`, start of the day) or relative to `@today` + | { kind: 'date', abs?: number, amount: number, unit: DateUnit } + | { kind: 'iteration', keyword: 'current' | 'next' | 'previous', offset: number } + +/** + * @public + */ +export type CompareOp = '>' | '>=' | '<' | '<=' + +/** + * @public + */ +export type FieldValue = + | { kind: 'eq', value: Scalar } + | { kind: 'compare', op: CompareOp, value: Scalar } + // Inclusive; a missing bound is the `*` wildcard + | { kind: 'range', from?: Scalar, to?: Scalar } + +// ---- AST ---- + +/** + * @public + */ +export type IsState = 'open' | 'closed' | 'issue' | 'sub-issue' | 'archived' | 'draft' + +/** + * @public + */ +export type Node = + | { type: 'and', children: Node[] } + | { type: 'or', children: Node[] } + | { type: 'not', child: Node } + // Free text, matches the title + | { type: 'text', value: string, quoted: boolean, pos: number } + // `field:a,b` matches when ANY value matches + | { type: 'field', field: FieldSpec, values: FieldValue[], pos: number } + // has:field (present = true) or no:field + | { type: 'presence', field: FieldSpec, present: boolean, pos: number } + | { type: 'is', state: IsState, pos: number } + +/** + * Environment of compilation and evaluation. + * @public + */ +export interface FilterContext { + // Timestamp that `@today` refers to + now: number + // Id of the current user for `@me` + me?: string + // Iterations of an iteration field (by field key); resolves @current, @next, @previous + iterations?: (fieldKey: string) => IterationInfo[] + // Option ids of statuses that count as closed (done or cancelled), for is:open / is:closed + closedStatuses?: ReadonlySet + // Value of `Issue.attachedTo` of an issue without a parent, for is:sub-issue + noParentId?: string +} + +/** + * @public + */ +export interface IterationInfo { + id: string + title: string + // Start of the first day + start: number + // Last millisecond of the last day + end: number +} diff --git a/plugins/view-resources/src/filter/grammar/values.d.ts.map b/plugins/view-resources/src/filter/grammar/values.d.ts.map new file mode 100644 index 0000000000..ec18692935 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/values.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"values.d.ts","sourceRoot":"","sources":["values.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAY,aAAa,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAI9D,gEAAgE;AAChE,wBAAgB,UAAU,CAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE5C;AAMD;;;;GAIG;AACH,wBAAgB,eAAe,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAejE;AAgCD;;;GAGG;AACH,wBAAgB,WAAW,CAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAG3F;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKtE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,CAAC,EAC9C,UAAU,EAAE,SAAS,aAAa,EAAE,EACpC,GAAG,EAAE,MAAM,GACV,aAAa,GAAG,SAAS,CAkB3B;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAgBlE;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEpE"} \ No newline at end of file diff --git a/plugins/view-resources/src/filter/grammar/values.ts b/plugins/view-resources/src/filter/grammar/values.ts new file mode 100644 index 0000000000..effdce4cc5 --- /dev/null +++ b/plugins/view-resources/src/filter/grammar/values.ts @@ -0,0 +1,155 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { DateUnit, IterationInfo, Scalar } from './types' + +// Pure helpers for the value syntaxes of the grammar: dates, iterations and wildcards. + +/** Start of the local calendar day containing the timestamp. */ +export function startOfDay (ts: number): number { + return new Date(ts).setHours(0, 0, 0, 0) +} + +/** Last millisecond of the local calendar day containing the timestamp. */ +export function endOfDay (ts: number): number { + return new Date(ts).setHours(23, 59, 59, 999) +} + +const ABSOLUTE_DATE = /^(\d{4})-(\d{2})-(\d{2})$/ +const RELATIVE_DATE = /^@today(?:([+-])(\d+)([dwmy]))?$/ +const ITERATION = /^@(current|next|previous)(?:([+-])(\d+))?$/ + +/** + * Parses `YYYY-MM-DD`, `@today` or `@today[+-]N(d|w|m|y)`. Returns undefined for anything else, + * including calendar dates that do not exist (2024-02-31). + * @public + */ +export function parseDateScalar (text: string): Scalar | undefined { + const abs = ABSOLUTE_DATE.exec(text) + if (abs !== null) { + const [y, m, d] = [Number(abs[1]), Number(abs[2]), Number(abs[3])] + const date = new Date(y, m - 1, d) + if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) return undefined + return { kind: 'date', abs: date.getTime(), amount: 0, unit: 'd' } + } + const rel = RELATIVE_DATE.exec(text) + if (rel !== null) { + if (rel[1] === undefined) return { kind: 'date', amount: 0, unit: 'd' } + const amount = Number(rel[2]) * (rel[1] === '-' ? -1 : 1) + return { kind: 'date', amount, unit: rel[3] as DateUnit } + } + return undefined +} + +function shiftDate (base: number, amount: number, unit: DateUnit): number { + const date = new Date(base) + switch (unit) { + case 'd': + date.setDate(date.getDate() + amount) + break + case 'w': + date.setDate(date.getDate() + amount * 7) + break + case 'm': { + // Clamp to the end of a shorter month instead of overflowing into the next one + const day = date.getDate() + date.setDate(1) + date.setMonth(date.getMonth() + amount) + const last = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() + date.setDate(Math.min(day, last)) + break + } + case 'y': { + const day = date.getDate() + date.setDate(1) + date.setFullYear(date.getFullYear() + amount) + const last = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() + date.setDate(Math.min(day, last)) + break + } + } + return date.getTime() +} + +/** + * Start of the day a date scalar refers to. + * @public + */ +export function resolveDate (scalar: Extract, now: number): number { + if (scalar.abs !== undefined) return scalar.abs + return startOfDay(shiftDate(startOfDay(now), scalar.amount, scalar.unit)) +} + +/** + * Parses `@current`, `@next`, `@previous` with optional `+N` / `-N` arithmetic. + * @public + */ +export function parseIterationScalar (text: string): Scalar | undefined { + const m = ITERATION.exec(text) + if (m === null) return undefined + const offset = m[2] === undefined ? 0 : Number(m[3]) * (m[2] === '-' ? -1 : 1) + return { kind: 'iteration', keyword: m[1] as 'current' | 'next' | 'previous', offset } +} + +/** + * Resolves an iteration keyword against the iterations of a field. `@current` is the iteration that + * contains `now`, `@next` and `@previous` its neighbours (in a gap between iterations the closest + * upcoming / finished one), `+N` / `-N` steps over further iterations. Undefined when there is none. + * @public + */ +export function resolveIteration ( + scalar: Extract, + iterations: readonly IterationInfo[], + now: number +): IterationInfo | undefined { + const sorted = [...iterations].sort((a, b) => a.start - b.start) + const current = sorted.findIndex((it) => it.start <= now && now <= it.end) + let base: number + if (current >= 0) { + base = scalar.keyword === 'current' ? current : scalar.keyword === 'next' ? current + 1 : current - 1 + } else if (scalar.keyword === 'current') { + return undefined + } else if (scalar.keyword === 'next') { + base = sorted.findIndex((it) => it.start > now) + } else { + base = -1 + sorted.forEach((it, i) => { + if (it.end < now) base = i + }) + } + if (base < 0) return undefined + return sorted[base + scalar.offset] +} + +/** + * Case-insensitive match of a label against a pattern where `*` matches any run of characters. + * Without a wildcard the whole label has to be equal. + * @public + */ +export function matchGlob (pattern: string, label: string): boolean { + const p = pattern.toLowerCase() + const l = label.toLowerCase() + if (!p.includes('*')) return p === l + const parts = p.split('*') + let pos = 0 + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (part === '') continue + const idx = l.indexOf(part, pos) + if (idx === -1) return false + if (i === 0 && idx !== 0) return false + pos = idx + part.length + } + const last = parts[parts.length - 1] + return last === '' || l.endsWith(last) +} + +/** + * Case-insensitive substring match where `*` matches any run of characters. + * @public + */ +export function containsGlob (pattern: string, text: string): boolean { + return matchGlob(`*${pattern}*`, text) +} diff --git a/plugins/view-resources/src/hierarchy.ts b/plugins/view-resources/src/hierarchy.ts new file mode 100644 index 0000000000..b83858b429 --- /dev/null +++ b/plugins/view-resources/src/hierarchy.ts @@ -0,0 +1,150 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Pure logic of the tree display of a list (GitHub-Projects style "Show hierarchy"): turns a flat list +// of items into the ordered rows of a tree. Kept free of UI and platform imports so that it can be unit tested. + +/** Levels of nesting a tree shows, the same as GitHub Projects. */ +export const MAX_HIERARCHY_DEPTH = 8 + +export interface HierarchyOptions { + idOf: (item: T) => string + // Id of the parent of an item; undefined (or an id that is not in the list) makes the item a top level row + parentOf: (item: T) => string | undefined + // Ids of the items whose children are shown + expanded: ReadonlySet + // Levels of nesting, defaults to MAX_HIERARCHY_DEPTH + maxDepth?: number +} + +export interface HierarchyRow { + item: T + // Nesting level, 0 for a top level row + depth: number + // Number of children of the item that are in the list + childCount: number + // Whether the row can be expanded (it has children that are nested under it) + hasChildren: boolean + expanded: boolean +} + +interface Frame { + item: T + depth: number + visible: boolean +} + +/** + * The visible rows of a tree, in display order (a parent is followed by its expanded children). + * + * - Only items of the list take part: an item whose parent is not in the list (the parent was filtered + * out, is in another group, ...) is a top level row, so nothing is lost. + * - The order of the list is kept among the siblings, so a sort applied before is preserved. + * - Items below the deepest level are not nested any more: they follow as top level rows right after + * the tree they belong to. + * - A broken reference (a cycle) never loops; the items of a cycle are shown as top level rows. + */ +export function buildHierarchyRows(items: readonly T[], options: HierarchyOptions): Array> { + const { idOf, parentOf, expanded } = options + const maxDepth = Math.max(1, Math.floor(options.maxDepth ?? MAX_HIERARCHY_DEPTH)) + + // The first occurrence of an id wins + const ids = new Set() + const unique: T[] = [] + for (const item of items) { + const id = idOf(item) + if (ids.has(id)) continue + ids.add(id) + unique.push(item) + } + + const children = new Map() + const roots: T[] = [] + for (const item of unique) { + const id = idOf(item) + const parent = parentOf(item) + if (parent === undefined || parent === id || !ids.has(parent)) { + roots.push(item) + continue + } + const list = children.get(parent) + if (list === undefined) children.set(parent, [item]) + else list.push(item) + } + + const rows: Array> = [] + const visited = new Set() + + // Walks one tree; returns the items that are too deep to be nested + function walk (root: T): T[] { + const overflow: T[] = [] + const stack: Array> = [{ item: root, depth: 0, visible: true }] + while (stack.length > 0) { + const frame = stack.pop() as Frame + const id = idOf(frame.item) + if (visited.has(id)) continue + visited.add(id) + const kids = (children.get(id) ?? []).filter((it) => !visited.has(idOf(it))) + const nestable = frame.depth < maxDepth - 1 + const hasChildren = nestable && kids.length > 0 + const isExpanded = hasChildren && expanded.has(id) + // Hidden rows are still visited, so that they are not picked up as stray top level rows + if (frame.visible) { + rows.push({ + item: frame.item, + depth: frame.depth, + childCount: nestable ? kids.length : 0, + hasChildren, + expanded: isExpanded + }) + } + if (nestable) { + for (let i = kids.length - 1; i >= 0; i--) { + stack.push({ item: kids[i], depth: frame.depth + 1, visible: frame.visible && isExpanded }) + } + } else if (frame.visible) { + for (const kid of kids) overflow.push(kid) + } else { + // Below a collapsed row: everything stays hidden, but is visited + for (let i = kids.length - 1; i >= 0; i--) stack.push({ item: kids[i], depth: frame.depth + 1, visible: false }) + } + } + return overflow + } + + // Places a top level row and the items that are too deep to be nested under it, right after its tree + const place = (root: T): void => { + const stack: T[] = [root] + while (stack.length > 0) { + const next = stack.pop() as T + if (visited.has(idOf(next))) continue + const overflow = walk(next) + for (let i = overflow.length - 1; i >= 0; i--) stack.push(overflow[i]) + } + } + for (const root of roots) place(root) + // Whatever is left is part of a cycle + for (const item of unique) { + if (!visited.has(idOf(item))) place(item) + } + return rows +} + +/** + * Ids of the items that have children in the list and are therefore worth expanding. + */ +export function expandableIds ( + items: readonly T[], + idOf: (item: T) => string, + parentOf: (item: T) => string | undefined +): string[] { + const ids = new Set(items.map(idOf)) + const parents = new Set() + for (const item of items) { + const parent = parentOf(item) + if (parent !== undefined && parent !== idOf(item) && ids.has(parent)) parents.add(parent) + } + return [...parents] +} diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index ddcc0177c0..68aa05edfb 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -67,6 +67,9 @@ import IdPresenter from './components/IdPresenter.svelte' import ReadOnlyNotification from './components/ReadOnlyNotification.svelte' import RolePresenter from './components/RolePresenter.svelte' import SearchSelector from './components/SearchSelector.svelte' +import SavedViewBar from './components/view/SavedViewBar.svelte' +import CellGrid from './components/table/CellGrid.svelte' +import FilterQueryBar from './components/filter/FilterQueryBar.svelte' import SpaceHeader from './components/SpaceHeader.svelte' import SpacePresenter from './components/SpacePresenter.svelte' import SpaceRefPresenter from './components/SpaceRefPresenter.svelte' @@ -230,6 +233,12 @@ export { type LoadingProps } from './utils' export * from './viewOptions' +export * from './clientViewExtension' +export * from './savedViews' +export * from './hierarchy' +export * from './nestedGroups' +export * as filterGrammar from './filter/grammar' +export * as tableEdit from './tableEdit' export * from './viewletContextStore' export { getViewletSpecialActions } from './viewletUtils' export { copyMarkdown } from './actionImpl' @@ -262,6 +271,9 @@ export { SortableDocList, SortableList, SortableListItem, + SavedViewBar, + CellGrid, + FilterQueryBar, SpaceHeader, SpacePresenter, StringEditor, diff --git a/plugins/view-resources/src/nestedGroups.ts b/plugins/view-resources/src/nestedGroups.ts new file mode 100644 index 0000000000..60e59263cd --- /dev/null +++ b/plugins/view-resources/src/nestedGroups.ts @@ -0,0 +1,68 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Pure logic of the nested (multi-level) grouping of a list: the rows of the "Group by / Then by" controls of the +// Customize View popup and the order of the groups of a level. Kept free of UI and platform imports so that it can be +// unit tested. + +/** The "No grouping" choice of a group-by control; it is `noCategory` of the view options. */ +export const NO_GROUPING = '#no_category' + +/** + * The rows of the group-by controls for the levels chosen so far: one row per level and, while the layout allows + * another level (`depth`; unlimited when absent), one more row to choose it. + */ +export function groupByRows (groupBy: readonly string[], depth?: number): string[] { + const rows = [...groupBy] + if (rows.length === 0) return [NO_GROUPING] + const complete = rows[rows.length - 1] === NO_GROUPING || (depth !== undefined && rows.length >= depth) + return complete ? rows : [...rows, NO_GROUPING] +} + +/** + * The rows after `value` was chosen for the row `index`. Choosing "No grouping" ends the levels there. Choosing a + * key keeps the levels below it, except a key that is now repeated, and offers a row for the next level while the + * layout allows one. + */ +export function selectGroupLevel (rows: readonly string[], index: number, value: string, depth?: number): string[] { + const above = rows.slice(0, index) + if (value === NO_GROUPING) return [...above, NO_GROUPING] + const below = rows.slice(index + 1).filter((key) => key !== NO_GROUPING && key !== value && !above.includes(key)) + const levels = [...above, value, ...below] + const capped = depth === undefined ? levels : levels.slice(0, depth) + return depth === undefined || capped.length < depth ? [...capped, NO_GROUPING] : capped +} + +/** The `groupBy` view option for the rows of the controls: the chosen levels, or the single "No grouping". */ +export function groupByFromRows (rows: readonly string[]): string[] { + return rows.length > 1 ? rows.filter((key) => key !== NO_GROUPING) : [...rows] +} + +/** Whether a group (category) stands for the documents without a value. */ +export function isEmptyCategory (category: unknown): boolean { + if (category === undefined || category === null) return true + return typeof category === 'object' && (category as { name?: unknown }).name === undefined +} + +/** + * The groups of a level with the group of the documents without a value last; the other groups keep their order. + * The same array is returned when it already is in that order. + */ +export function emptyCategoryLast (categories: readonly T[]): T[] { + const firstEmpty = categories.findIndex(isEmptyCategory) + if (firstEmpty === -1) return categories as T[] + const empty = categories.filter(isEmptyCategory) + const rest = categories.filter((it) => !isEmptyCategory(it)) + const res = [...rest, ...empty] + return res.every((it, i) => it === categories[i]) ? (categories as T[]) : res +} + +/** + * The groups of a level below the first that hold documents. A level under a group lists only what is in that group, + * so a group that the host listed for being empty (show empty groups) is left out there. + */ +export function categoriesWithDocs (categories: readonly T[], countOf: (category: T) => number): T[] { + return categories.filter((category) => countOf(category) > 0) +} diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index c82fbec831..e3d296fe9b 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -114,7 +114,44 @@ export default mergeIds(viewId, typedView, { NumberItems: '' as IntlString, ToViewCommands: '' as IntlString, NoRelations: '' as IntlString, - FilterOverflowBadge: '' as IntlString + FilterOverflowBadge: '' as IntlString, + + SavedViewNew: '' as IntlString, + SavedViewDefaultName: '' as IntlString, + SavedViewDuplicate: '' as IntlString, + SavedViewDelete: '' as IntlString, + SavedViewDeleteConfirm: '' as IntlString, + SavedViewSave: '' as IntlString, + SavedViewSaveAsNew: '' as IntlString, + SavedViewDiscard: '' as IntlString, + SavedViewUnsaved: '' as IntlString, + SavedViewLayoutTable: '' as IntlString, + SavedViewLayoutTo: '' as IntlString, + SavedViewCopyLink: '' as IntlString, + SavedViewLinkCopied: '' as IntlString, + + FilterQueryPlaceholder: '' as IntlString, + FilterQueryClear: '' as IntlString, + FilterQueryDidYouMean: '' as IntlString, + FilterQueryErrorQuote: '' as IntlString, + FilterQueryErrorParenthesis: '' as IntlString, + FilterQueryErrorUnexpected: '' as IntlString, + FilterQueryErrorUnknownField: '' as IntlString, + FilterQueryErrorMissingValue: '' as IntlString, + FilterQueryErrorOperator: '' as IntlString, + FilterQueryErrorValue: '' as IntlString, + FilterQueryErrorDate: '' as IntlString, + FilterQueryErrorNumber: '' as IntlString, + FilterQueryErrorRange: '' as IntlString, + FilterQueryErrorKeyword: '' as IntlString, + + BulkItemsUpdated: '' as IntlString, + BulkItemsUpdatedSkipped: '' as IntlString, + BulkCellsSkipped: '' as IntlString, + BulkUndo: '' as IntlString, + BulkUndone: '' as IntlString, + BulkFailed: '' as IntlString, + BulkUndoFailed: '' as IntlString }, function: { CreateDocMiddleware: '' as Resource, diff --git a/plugins/view-resources/src/savedViews.ts b/plugins/view-resources/src/savedViews.ts new file mode 100644 index 0000000000..8c46232e2e --- /dev/null +++ b/plugins/view-resources/src/savedViews.ts @@ -0,0 +1,240 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Pure logic of project saved views (GitHub-Projects style view tabs). Kept free of UI and +// platform imports so that it can be unit tested. + +import type { BuildModelKey, ViewOptions } from '@hcengineering/view' +import { deepEqual } from 'fast-equals' + +export type ViewColumns = Array + +/** + * One layer of view configuration: the saved view, or the unsaved local edits on top of it. + * Every field is optional; an absent field means "not specified by this layer". + */ +export interface ViewConfigLayer { + // Layout (viewlet) of the view + viewletId?: string | null + // JSON of Filter[] + filters?: string + viewOptions?: ViewOptions + // Ordered visible columns + config?: ViewColumns + // JSON of host-specific filter state + extra?: string + // GitHub-style filter string + filterQuery?: string +} + +export interface EffectiveViewConfig { + viewletId?: string | null + filters: string + viewOptions?: ViewOptions + config?: ViewColumns + extra?: string + filterQuery: string +} + +export interface EffectiveViewConfigParams { + // Built-in defaults of the viewlet + defaults?: ViewConfigLayer + // Global per-viewlet column preference (ViewletPreference.config) + preference?: ViewColumns + // The active saved view + saved?: ViewConfigLayer + // Unsaved local edits of the active view + local?: ViewConfigLayer + // Viewlet that is actually displayed. When it differs from the layout the layers were authored + // for, their layout specific parts (columns, view options) are ignored + currentViewletId?: string | null +} + +export const EMPTY_FILTERS = '[]' + +// Id of the view that is shown for a project without any saved view +export const DEFAULT_VIEW_ID = 'default-view' + +function hasColumns (config: ViewColumns | undefined): config is ViewColumns { + return config !== undefined && config.length > 0 +} + +/** + * The single accessor for what a view displays. Precedence, strongest first: + * unsaved local edits, the saved view, the global ViewletPreference (columns only), the defaults. + * An empty column list means "not specified". + */ +export function getEffectiveViewConfig (params: EffectiveViewConfigParams): EffectiveViewConfig { + const { defaults, preference, saved, local, currentViewletId } = params + const viewletId = local?.viewletId ?? saved?.viewletId ?? defaults?.viewletId + const layoutMatches = currentViewletId === undefined || viewletId == null || viewletId === currentViewletId + const layouts = layoutMatches ? [local, saved] : [] + + let config: ViewColumns | undefined + let viewOptions: ViewOptions | undefined + for (const layer of layouts) { + if (config === undefined && hasColumns(layer?.config)) config = layer?.config + if (viewOptions === undefined && layer?.viewOptions !== undefined) viewOptions = layer.viewOptions + } + if (config === undefined && hasColumns(preference)) config = preference + config ??= defaults?.config + viewOptions ??= defaults?.viewOptions + + return { + viewletId, + filters: local?.filters ?? saved?.filters ?? defaults?.filters ?? EMPTY_FILTERS, + viewOptions, + config: hasColumns(config) ? config : undefined, + extra: local?.extra ?? saved?.extra ?? defaults?.extra, + filterQuery: local?.filterQuery ?? saved?.filterQuery ?? defaults?.filterQuery ?? '' + } +} + +function normalize (layer: ViewConfigLayer): Required> & ViewConfigLayer { + return { + viewletId: layer.viewletId ?? undefined, + filters: layer.filters === undefined || layer.filters === '' ? EMPTY_FILTERS : layer.filters, + viewOptions: layer.viewOptions, + config: hasColumns(layer.config) ? layer.config : undefined, + extra: layer.extra === undefined || layer.extra === '' || layer.extra === '[]' ? undefined : layer.extra, + // Whitespace around the filter string is not a change + filterQuery: layer.filterQuery === undefined || layer.filterQuery.trim() === '' ? undefined : layer.filterQuery.trim() + } +} + +/** + * A view has unsaved changes when its current state differs from the baseline it was applied or saved with. + */ +export function isViewDirty (baseline: ViewConfigLayer, current: ViewConfigLayer): boolean { + return !deepEqual(normalize(baseline), normalize(current)) +} + +function nameKey (name: string): string { + return name.trim().toLowerCase() +} + +/** + * Default name of a new view: "View N" with the smallest free N, starting from the number of existing views + 1 + */ +export function nextViewName (existing: readonly string[], prefix: string = 'View'): string { + const taken = new Set(existing.map(nameKey)) + let n = existing.length + 1 + while (taken.has(nameKey(`${prefix} ${n}`))) n++ + return `${prefix} ${n}` +} + +/** + * Name of a duplicated view: "X (copy)", then "X (copy 2)", "X (copy 3)", ... + */ +export function duplicateViewName (name: string, existing: readonly string[]): string { + const taken = new Set(existing.map(nameKey)) + const first = `${name} (copy)` + if (!taken.has(nameKey(first))) return first + let n = 2 + while (taken.has(nameKey(`${name} (copy ${n})`))) n++ + return `${name} (copy ${n})` +} + +export interface OrderedView { + _id: string + order?: number + createdOn?: number +} + +/** + * Tab order: by `order`, views without one go last, ties by creation time + */ +export function compareViews (a: OrderedView, b: OrderedView): number { + const ao = a.order ?? Number.POSITIVE_INFINITY + const bo = b.order ?? Number.POSITIVE_INFINITY + if (ao !== bo) return ao < bo ? -1 : 1 + return (a.createdOn ?? 0) - (b.createdOn ?? 0) +} + +export function sortViews(views: readonly T[]): T[] { + return [...views].sort(compareViews) +} + +/** + * Order value for a view appended after all the others + */ +export function nextOrder (views: readonly OrderedView[]): number { + let max = -1 + for (const v of views) { + if (v.order !== undefined && v.order > max) max = v.order + } + return max + 1 +} + +/** + * Order value for the view `id` after it is moved to index `toIndex` of the tab row. + * Only the moved view needs to be updated: its order is placed between its new neighbours. + * Returns undefined when nothing changes. + */ +export function orderForMove (views: readonly OrderedView[], id: string, toIndex: number): number | undefined { + const sorted = sortViews(views) + const from = sorted.findIndex((v) => v._id === id) + if (from === -1) return undefined + const target = Math.max(0, Math.min(toIndex, sorted.length - 1)) + if (target === from) return undefined + + const rest = sorted.filter((v) => v._id !== id) + const prev = rest[target - 1] + const next = rest[target] + const prevOrder = prev?.order ?? (prev !== undefined ? target - 1 : undefined) + const nextOrderValue = next?.order ?? (next !== undefined ? target : undefined) + + if (prevOrder !== undefined && nextOrderValue !== undefined) { + // Neighbours without a stored order cannot be compared reliably, fall back to their index + return prevOrder < nextOrderValue ? (prevOrder + nextOrderValue) / 2 : prevOrder + 1 + } + if (prevOrder !== undefined) return prevOrder + 1 + if (nextOrderValue !== undefined) return nextOrderValue - 1 + return 0 +} + +/** + * Safe parse of a stored Filter[] JSON; a corrupt value yields no filters instead of breaking the page + */ +export function parseStoredFilters (json: string | undefined): any[] { + if (json === undefined || json === '') return [] + try { + const parsed = JSON.parse(json) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +/** Name of the location query parameter that carries the id of a saved view (a link to a view). */ +export const VIEW_LINK_PARAM = 'view' + +/** + * The saved view a location asks for (`?view=`), `undefined` when it does not name one. + */ +export function viewIdFromQuery (query: Record | undefined): string | undefined { + const id = query?.[VIEW_LINK_PARAM] + return typeof id === 'string' && id.trim() !== '' ? id.trim() : undefined +} + +/** + * The location query of a link to a view: the view id and nothing else. The default view that is not stored yet (no id) + * is the project itself, so its link has no query. + */ +export function viewLinkQuery (viewId: string | undefined): Record | undefined { + return viewId === undefined || viewId === DEFAULT_VIEW_ID ? undefined : { [VIEW_LINK_PARAM]: viewId } +} + +/** + * The tab a link asks for, when the project has it. A link to a view that was deleted, or that belongs to another + * project (the location query survives a switch of the project), gives nothing, so the caller falls back to its own choice. + */ +export function viewFromLink ( + query: Record | undefined, + tabs: readonly T[] +): T | undefined { + const id = viewIdFromQuery(query) + return id === undefined ? undefined : tabs.find((tab) => tab._id === id) +} diff --git a/plugins/view-resources/src/tableEdit/cells.ts b/plugins/view-resources/src/tableEdit/cells.ts new file mode 100644 index 0000000000..c60e558747 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/cells.ts @@ -0,0 +1,177 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Finding the cells of the rendered list. A cell has no wrapper element of its own: the presenter is +// bracketed by two comment nodes, `` and ``, and the cell is whatever elements +// sit between them. Rows carry `data-row-doc` with the id of the document. + +const START_PREFIX = 'cell:' +const END_DATA = '/cell' + +export const CELL_END_MARKER = `` +export const ROW_ATTRIBUTE = 'data-row-doc' + +/** Markup of the comment that opens a cell of the column `key`. */ +export function cellStartMarker (key: string): string { + return `` +} + +/** Column key out of the data of an opening comment; undefined for any other comment. */ +export function parseCellMarker (data: string): string | undefined { + if (!data.startsWith(START_PREFIX)) return undefined + try { + return decodeURIComponent(data.slice(START_PREFIX.length)) + } catch { + return undefined + } +} + +export function isCellEnd (data: string): boolean { + return data === END_DATA +} + +export interface RowCells { + // Id of the document of the row + id: string + element: Element + // Elements of each cell by column key + cells: Map +} + +export interface DomGrid { + rows: RowCells[] + // Column keys in the order they appear on screen + cols: string[] +} + +export function isRendered (el: Element): boolean { + return el.getClientRects().length > 0 +} + +/** + * Cells of one row. A row can render a column twice (a compact row repeats some cells in a hidden panel), + * the cell that is actually on screen wins. + */ +export function collectRowCells (row: Element): { keys: string[], cells: Map } { + const keys: string[] = [] + const cells = new Map() + const walker = document.createTreeWalker(row, NodeFilter.SHOW_COMMENT) + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + const key = parseCellMarker((node as Comment).data) + if (key === undefined) continue + const elements: Element[] = [] + for (let sibling = node.nextSibling; sibling !== null; sibling = sibling.nextSibling) { + if (sibling.nodeType === Node.COMMENT_NODE && isCellEnd((sibling as Comment).data)) break + if (sibling.nodeType === Node.ELEMENT_NODE) elements.push(sibling as Element) + } + const known = cells.get(key) + if (known === undefined) { + keys.push(key) + cells.set(key, elements) + } else if (!known.some(isRendered) && elements.some(isRendered)) { + cells.set(key, elements) + } + } + return { keys, cells } +} + +/** + * The visible rows (collapsed groups are skipped) with their cells, in screen order, and the columns. + */ +export function buildGrid (root: ParentNode): DomGrid { + const rows: RowCells[] = [] + const cols: string[] = [] + const seen = new Set() + root.querySelectorAll(`[${ROW_ATTRIBUTE}]`).forEach((element) => { + if (!isRendered(element)) return + const id = element.getAttribute(ROW_ATTRIBUTE) + if (id === null) return + const { keys, cells } = collectRowCells(element) + // Only the cells that are shown make a column + const shown = keys.filter((key) => cells.get(key)?.some(isRendered) === true) + for (const key of shown) { + if (!seen.has(key)) { + seen.add(key) + cols.push(key) + } + } + rows.push({ id, element, cells }) + }) + return { rows, cols } +} + +export interface ScreenRect { + left: number + top: number + right: number + bottom: number +} + +/** Screen box of a cell: the width of its elements and the height of its row. */ +export function cellBox (row: RowCells, key: string): ScreenRect | undefined { + const elements = (row.cells.get(key) ?? []).filter(isRendered) + if (elements.length === 0) return undefined + const rowRect = row.element.getBoundingClientRect() + let left = Infinity + let right = -Infinity + for (const el of elements) { + const r = el.getBoundingClientRect() + left = Math.min(left, r.left) + right = Math.max(right, r.right) + } + return { left, right, top: rowRect.top, bottom: rowRect.bottom } +} + +export function intersect (a: ScreenRect, b: ScreenRect): ScreenRect | undefined { + const res = { + left: Math.max(a.left, b.left), + top: Math.max(a.top, b.top), + right: Math.min(a.right, b.right), + bottom: Math.min(a.bottom, b.bottom) + } + return res.right > res.left && res.bottom > res.top ? res : undefined +} + +/** The part of the screen in which an element can be seen: the intersection of its clipping ancestors. */ +export function visibleArea (el: Element): ScreenRect { + let area: ScreenRect = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight } + for (let parent = el.parentElement; parent !== null; parent = parent.parentElement) { + const style = getComputedStyle(parent) + if (style.overflowX === 'visible' && style.overflowY === 'visible') continue + const r = parent.getBoundingClientRect() + const next = intersect(area, { left: r.left, top: r.top, right: r.right, bottom: r.bottom }) + if (next === undefined) return { left: 0, top: 0, right: 0, bottom: 0 } + area = next + } + return area +} + +const INTERACTIVE = 'button, a[href], input, textarea, select, [role="button"], .cursor-pointer, .editable, [tabindex]' + +/** Click the part of a cell that opens its editor: the first control, else the cell itself. */ +export function activateCell (row: RowCells, key: string): boolean { + const elements = (row.cells.get(key) ?? []).filter(isRendered) + for (const el of elements) { + const control = el.matches(INTERACTIVE) ? el : el.querySelector(INTERACTIVE) + if (control !== null) { + ;(control as HTMLElement).click() + return true + } + } + const first = elements[0] as HTMLElement | undefined + if (first === undefined) return false + first.click() + return true +} + +/** Plain text of a cell as it is shown. */ +export function cellText (row: RowCells, key: string): string { + return (row.cells.get(key) ?? []) + .filter(isRendered) + .map((el) => (el as HTMLElement).innerText ?? el.textContent ?? '') + .join(' ') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/plugins/view-resources/src/tableEdit/grid.ts b/plugins/view-resources/src/tableEdit/grid.ts new file mode 100644 index 0000000000..67f5b12109 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/grid.ts @@ -0,0 +1,199 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Pure geometry of a cell grid: focus movement, rectangular selection, paste placement and fill-down. +// Rows and columns are zero based indexes into the grid that is currently displayed. + +/** @public */ +export interface CellPos { + row: number + col: number +} + +/** Inclusive rectangle of cells. */ +export interface CellRect { + top: number + left: number + bottom: number + right: number +} + +/** Number of displayed rows and columns. */ +export interface GridDims { + rows: number + cols: number +} + +/** Anchor is where a selection started, focus is the moving end (and the cell that is edited). */ +export interface CellSelection { + anchor: CellPos + focus: CellPos +} + +export type MoveDirection = 'up' | 'down' | 'left' | 'right' + +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)) + +export function clampPos (pos: CellPos, dims: GridDims): CellPos { + return { row: clamp(pos.row, 0, Math.max(dims.rows - 1, 0)), col: clamp(pos.col, 0, Math.max(dims.cols - 1, 0)) } +} + +export function samePos (a: CellPos, b: CellPos): boolean { + return a.row === b.row && a.col === b.col +} + +export function rectOf (a: CellPos, b: CellPos): CellRect { + return { + top: Math.min(a.row, b.row), + bottom: Math.max(a.row, b.row), + left: Math.min(a.col, b.col), + right: Math.max(a.col, b.col) + } +} + +export function selectionRect (selection: CellSelection): CellRect { + return rectOf(selection.anchor, selection.focus) +} + +export function rectContains (rect: CellRect, pos: CellPos): boolean { + return pos.row >= rect.top && pos.row <= rect.bottom && pos.col >= rect.left && pos.col <= rect.right +} + +export function rectRows (rect: CellRect): number { + return rect.bottom - rect.top + 1 +} + +export function rectCols (rect: CellRect): number { + return rect.right - rect.left + 1 +} + +export function isSingleCell (rect: CellRect): boolean { + return rectRows(rect) === 1 && rectCols(rect) === 1 +} + +/** All cells of a rectangle, row by row. */ +export function rectCells (rect: CellRect): CellPos[] { + const res: CellPos[] = [] + for (let row = rect.top; row <= rect.bottom; row++) { + for (let col = rect.left; col <= rect.right; col++) res.push({ row, col }) + } + return res +} + +/** + * Move a cell by one step, or to the edge of the grid when `jump` is set. The result stays inside the grid. + */ +export function movePos (pos: CellPos, dir: MoveDirection, dims: GridDims, jump = false): CellPos { + const lastRow = Math.max(dims.rows - 1, 0) + const lastCol = Math.max(dims.cols - 1, 0) + switch (dir) { + case 'up': + return clampPos({ row: jump ? 0 : pos.row - 1, col: pos.col }, dims) + case 'down': + return clampPos({ row: jump ? lastRow : pos.row + 1, col: pos.col }, dims) + case 'left': + return clampPos({ row: pos.row, col: jump ? 0 : pos.col - 1 }, dims) + case 'right': + return clampPos({ row: pos.row, col: jump ? lastCol : pos.col + 1 }, dims) + } +} + +/** Arrow key: the selection collapses to the moved focus cell. */ +export function moveSelection (selection: CellSelection, dir: MoveDirection, dims: GridDims, jump = false): CellSelection { + const focus = movePos(selection.focus, dir, dims, jump) + return { anchor: focus, focus } +} + +/** Shift + arrow key: the anchor stays, the focus end moves, so the selection stays a rectangle. */ +export function extendSelection ( + selection: CellSelection, + dir: MoveDirection, + dims: GridDims, + jump = false +): CellSelection { + return { anchor: selection.anchor, focus: movePos(selection.focus, dir, dims, jump) } +} + +/** + * Tab / Shift+Tab: one cell to the right (left); past the end of a row it continues on the next (previous) row. + * Stays on the first (last) cell at the grid edge. + */ +export function tabPos (pos: CellPos, dims: GridDims, backwards: boolean): CellPos { + if (dims.rows === 0 || dims.cols === 0) return pos + if (!backwards) { + if (pos.col + 1 < dims.cols) return { row: pos.row, col: pos.col + 1 } + return pos.row + 1 < dims.rows ? { row: pos.row + 1, col: 0 } : pos + } + if (pos.col > 0) return { row: pos.row, col: pos.col - 1 } + return pos.row > 0 ? { row: pos.row - 1, col: dims.cols - 1 } : pos +} + +export interface PasteTarget { + target: CellPos + // Index of the clipboard row / column the text comes from + fromRow: number + fromCol: number +} + +export interface PastePlacement { + cells: PasteTarget[] + // Clipboard cells that did not fit into the grid + clipped: number +} + +/** + * Where the cells of a pasted block go. A single value fills every selected cell; a larger block is placed + * from the top left cell of the selection and is cut at the edge of the grid. + */ +export function placePaste (selection: CellRect, block: ReadonlyArray, dims: GridDims): PastePlacement { + const blockRows = block.length + const blockCols = block.reduce((max, row) => Math.max(max, row.length), 0) + if (blockRows === 0 || blockCols === 0) return { cells: [], clipped: 0 } + + if (blockRows === 1 && blockCols === 1) { + return { + cells: rectCells(selection).map((target) => ({ target, fromRow: 0, fromCol: 0 })), + clipped: 0 + } + } + const cells: PasteTarget[] = [] + let clipped = 0 + for (let r = 0; r < blockRows; r++) { + for (let c = 0; c < block[r].length; c++) { + const target = { row: selection.top + r, col: selection.left + c } + if (target.row >= dims.rows || target.col >= dims.cols) clipped++ + else cells.push({ target, fromRow: r, fromCol: c }) + } + } + return { cells, clipped } +} + +export interface FillTarget { + target: CellPos + source: CellPos +} + +/** + * Drag the fill handle of a selection down to `toRow`: the selected rows are repeated downwards + * (a one row selection is copied to every row). Dragging to or above the selection fills nothing. + */ +export function fillDown (selection: CellRect, toRow: number, dims: GridDims): FillTarget[] { + const last = Math.min(toRow, dims.rows - 1) + if (last <= selection.bottom) return [] + const height = rectRows(selection) + const res: FillTarget[] = [] + for (let row = selection.bottom + 1; row <= last; row++) { + const sourceRow = selection.top + ((row - selection.top) % height) + for (let col = selection.left; col <= selection.right; col++) { + res.push({ target: { row, col }, source: { row: sourceRow, col } }) + } + } + return res +} + +/** The selection after a fill: it grows to cover the filled rows. */ +export function extendRectDown (selection: CellRect, toRow: number, dims: GridDims): CellRect { + return { ...selection, bottom: Math.max(selection.bottom, Math.min(toRow, dims.rows - 1)) } +} diff --git a/plugins/view-resources/src/tableEdit/index.ts b/plugins/view-resources/src/tableEdit/index.ts new file mode 100644 index 0000000000..300006c888 --- /dev/null +++ b/plugins/view-resources/src/tableEdit/index.ts @@ -0,0 +1,11 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +export * from './grid' +export * from './journal' +export * from './plan' +export * from './tsv' +export * from './values' +export * from './cells' diff --git a/plugins/view-resources/src/tableEdit/journal.ts b/plugins/view-resources/src/tableEdit/journal.ts new file mode 100644 index 0000000000..1b1cbdf98b --- /dev/null +++ b/plugins/view-resources/src/tableEdit/journal.ts @@ -0,0 +1,163 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Edit operations of a bulk edit and the journal that makes a batch of them undoable. +// Free of platform imports: the operations are plain data, a host turns them into transactions. + +/** @public */ +export interface DocTarget { + _id: string + _class: string + space: string + // Set for documents that are attached to another document + attachedTo?: string + attachedToClass?: string + collection?: string +} + +/** + * One change. `before` and `after` hold the touched attributes, so that every operation can be reverted. + * An attribute that did not have a value is recorded as null. + * @public + */ +export type EditOp = + | { kind: 'update', target: DocTarget, before: Record, after: Record } + | { kind: 'add', target: DocTarget, attributes: Record } + | { kind: 'remove', target: DocTarget, attributes: Record } + +/** @public */ +export interface EditBatch { + id: string + ops: EditOp[] + // Number of distinct documents the batch touches + count: number +} + +const plainEqual = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b) + +/** Update of one document that records the previous values; null for attributes that are not set. */ +export function updateOp (target: DocTarget, doc: Record, patch: Record): EditOp { + const before: Record = {} + for (const key of Object.keys(patch)) before[key] = doc[key] ?? null + return { kind: 'update', target, before, after: { ...patch } } +} + +/** True when applying the operation would not change anything. */ +export function isNoop (op: EditOp): boolean { + if (op.kind !== 'update') return false + return Object.keys(op.after).every((key) => plainEqual(op.before[key] ?? null, op.after[key] ?? null)) +} + +/** + * Merge updates of the same document into one: the first `before` and the last `after` of each attribute win. + * Operations that are not updates keep their order and come after the updates. + */ +export function coalesceOps (ops: readonly EditOp[]): EditOp[] { + const updates = new Map>() + const rest: EditOp[] = [] + for (const op of ops) { + if (op.kind !== 'update') { + rest.push(op) + continue + } + const known = updates.get(op.target._id) + if (known === undefined) { + updates.set(op.target._id, { ...op, before: { ...op.before }, after: { ...op.after } }) + } else { + for (const key of Object.keys(op.after)) { + if (!(key in known.before)) known.before[key] = op.before[key] ?? null + known.after[key] = op.after[key] + } + } + } + return [...[...updates.values()].filter((op) => !isNoop(op)), ...rest] +} + +export function invertOp (op: EditOp): EditOp { + switch (op.kind) { + case 'update': + return { kind: 'update', target: op.target, before: op.after, after: op.before } + case 'add': + return { kind: 'remove', target: op.target, attributes: op.attributes } + case 'remove': + return { kind: 'add', target: op.target, attributes: op.attributes } + } +} + +/** Operations that revert `ops`, last operation first. */ +export function invertOps (ops: readonly EditOp[]): EditOp[] { + return [...ops].reverse().map(invertOp) +} + +export function countTargets (ops: readonly EditOp[]): number { + return new Set(ops.map((op) => op.target._id)).size +} + +export function createBatch (id: string, ops: readonly EditOp[]): EditBatch { + return { id, ops: [...ops], count: countTargets(ops) } +} + +/** + * Applies a list of operations atomically. The host implements it on top of a transaction builder. + * @public + */ +export type OpRunner = (ops: readonly EditOp[]) => Promise + +/** + * Undo and redo stacks of applied batches. A new batch drops the redo stack. + */ +export class EditJournal { + private undoStack: EditBatch[] = [] + private redoStack: EditBatch[] = [] + + constructor (private readonly limit: number = 50) {} + + get canUndo (): boolean { + return this.undoStack.length > 0 + } + + get canRedo (): boolean { + return this.redoStack.length > 0 + } + + /** The batch that undo would revert */ + get lastBatch (): EditBatch | undefined { + return this.undoStack[this.undoStack.length - 1] + } + + record (batch: EditBatch): void { + if (batch.ops.length === 0) return + this.undoStack.push(batch) + if (this.undoStack.length > this.limit) this.undoStack.shift() + this.redoStack = [] + } + + /** + * Revert the last batch (or the given one, when it is still the last). The batch stays undoable + * when reverting fails. + */ + async undo (run: OpRunner, id?: string): Promise { + const batch = this.lastBatch + if (batch === undefined || (id !== undefined && batch.id !== id)) return undefined + await run(invertOps(batch.ops)) + this.undoStack.pop() + this.redoStack.push(batch) + return batch + } + + async redo (run: OpRunner): Promise { + const batch = this.redoStack[this.redoStack.length - 1] + if (batch === undefined) return undefined + await run(batch.ops) + this.redoStack.pop() + this.undoStack.push(batch) + return batch + } + + clear (): void { + this.undoStack = [] + this.redoStack = [] + } +} diff --git a/plugins/view-resources/src/tableEdit/plan.ts b/plugins/view-resources/src/tableEdit/plan.ts new file mode 100644 index 0000000000..c7f78154be --- /dev/null +++ b/plugins/view-resources/src/tableEdit/plan.ts @@ -0,0 +1,140 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { coalesceOps, countTargets, isNoop, type EditOp } from './journal' +import type { CellRect } from './grid' +import type { ParseFailure, ParseResult } from './values' + +/** + * How one column of the table reads and writes its cells. + * @public + */ +export interface CellColumn { + // Key of the column as it is marked on the rendered cells + key: string + // Text of the cell on the clipboard + format: (doc: D) => string + // Operations that set the cell to the value of `text`. Empty text clears the cell. + // Absent for columns that cannot be edited. + edit?: (doc: D, text: string) => ParseResult + // Whether the cell may be cleared with Delete + clearable?: boolean +} + +/** @public */ +export interface CellAdapter { + column: (key: string) => CellColumn | undefined + // Applies the operations in one atomic batch + run: (ops: readonly EditOp[]) => Promise +} + +export interface CellEdit { + doc: D + key: string + text: string +} + +export interface EditPlan { + ops: EditOp[] + // Cells that changed + applied: number + // Cells that already held the value + unchanged: number + // Cells that were left alone because the text did not fit them + skipped: number + skippedBy: Partial> + // Documents touched + items: number +} + +function docId (doc: any): string { + return doc._id as string +} + +/** + * Work out the operations of a set of cell edits without applying them. Edits that cannot be applied + * (wrong type, unknown value, read-only column) are skipped and counted, the others still go through. + * Several cells of one document are combined, each seeing the result of the previous ones. + */ +export function planEdits (edits: ReadonlyArray>, adapter: Pick, 'column'>): EditPlan { + const plan: EditPlan = { ops: [], applied: 0, unchanged: 0, skipped: 0, skippedBy: {}, items: 0 } + const working = new Map() + const all: EditOp[] = [] + + const skip = (reason: ParseFailure): void => { + plan.skipped++ + plan.skippedBy[reason] = (plan.skippedBy[reason] ?? 0) + 1 + } + + for (const edit of edits) { + const column = adapter.column(edit.key) + if (column === undefined) { + skip('noColumn') + continue + } + if (column.edit === undefined) { + skip('readonly') + continue + } + const id = docId(edit.doc) + const current = working.get(id) ?? edit.doc + const res = column.edit(current, edit.text) + if (res.ok === false) { + skip(res.reason) + continue + } + if (res.value.every(isNoop) && !res.value.some((op) => op.kind !== 'update')) { + plan.unchanged++ + continue + } + let next = current + for (const op of res.value) { + if (op.kind === 'update') next = { ...next, ...op.after } + all.push(op) + } + working.set(id, next) + plan.applied++ + } + plan.ops = coalesceOps(all) + plan.items = countTargets(plan.ops) + return plan +} + +/** The cells of a rectangle to clear; a column that cannot be cleared is skipped and counted. */ +export function planClear ( + cells: ReadonlyArray<{ doc: D, key: string }>, + adapter: Pick, 'column'> +): EditPlan { + const clearable: Array> = [] + const rejected: EditPlan = { ops: [], applied: 0, unchanged: 0, skipped: 0, skippedBy: {}, items: 0 } + for (const cell of cells) { + const column = adapter.column(cell.key) + if (column?.edit !== undefined && column.clearable !== true) { + rejected.skipped++ + rejected.skippedBy.notClearable = (rejected.skippedBy.notClearable ?? 0) + 1 + continue + } + clearable.push({ ...cell, text: '' }) + } + const plan = planEdits(clearable, adapter) + plan.skipped += rejected.skipped + for (const [reason, count] of Object.entries(rejected.skippedBy)) { + plan.skippedBy[reason as ParseFailure] = (plan.skippedBy[reason as ParseFailure] ?? 0) + (count ?? 0) + } + return plan +} + +/** + * Text of the cells of a rectangle for the clipboard. `read` gives the text of one cell. + */ +export function copyRect (rect: CellRect, read: (row: number, col: number) => string): string[][] { + const rows: string[][] = [] + for (let row = rect.top; row <= rect.bottom; row++) { + const cells: string[] = [] + for (let col = rect.left; col <= rect.right; col++) cells.push(read(row, col)) + rows.push(cells) + } + return rows +} diff --git a/plugins/view-resources/src/tableEdit/tsv.ts b/plugins/view-resources/src/tableEdit/tsv.ts new file mode 100644 index 0000000000..d5cc648a2e --- /dev/null +++ b/plugins/view-resources/src/tableEdit/tsv.ts @@ -0,0 +1,76 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +// Tab separated clipboard text, as produced and understood by spreadsheets: cells are separated by a tab, +// rows by a line break, and a cell holding a tab, a line break or a quote is wrapped in quotes with inner quotes doubled. + +function encodeCell (cell: string): string { + return /[\t\r\n"]/.test(cell) ? `"${cell.replace(/"/g, '""')}"` : cell +} + +/** + * Encode a block of cells to clipboard text. + * @public + */ +export function encodeTsv (rows: ReadonlyArray): string { + return rows.map((row) => row.map(encodeCell).join('\t')).join('\n') +} + +/** + * Decode clipboard text to a block of cells. A single trailing line break (added by spreadsheets + * when copying) does not make an extra row. Empty text gives no rows. + * @public + */ +export function decodeTsv (text: string): string[][] { + if (text === '') return [] + const rows: string[][] = [] + let row: string[] = [] + let cell = '' + let quoted = false + let cellStart = true + + const endCell = (): void => { + row.push(cell) + cell = '' + cellStart = true + } + const endRow = (): void => { + endCell() + rows.push(row) + row = [] + } + + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (quoted) { + if (ch === '"') { + if (text[i + 1] === '"') { + cell += '"' + i++ + } else { + quoted = false + } + } else { + cell += ch + } + continue + } + if (ch === '"' && cellStart) { + quoted = true + cellStart = false + } else if (ch === '\t') { + endCell() + } else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i++ + endRow() + } else { + cell += ch + cellStart = false + } + } + // The last row has no line break after it, unless the text ended with one + if (cell !== '' || row.length > 0 || quoted) endRow() + return rows +} diff --git a/plugins/view-resources/src/tableEdit/values.ts b/plugins/view-resources/src/tableEdit/values.ts new file mode 100644 index 0000000000..c1b73e1cbf --- /dev/null +++ b/plugins/view-resources/src/tableEdit/values.ts @@ -0,0 +1,133 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { parseDateScalar, resolveDate } from '../filter/grammar/values' + +// Per-type parsing and formatting of cell text, used by paste, fill and clear. +// Empty text means "clear the value": every parser answers null (or an empty list) for it. + +/** @public */ +export type ParseFailure = 'invalid' | 'unknown' | 'ambiguous' | 'readonly' | 'notClearable' | 'noColumn' + +/** @public */ +export type ParseResult = { ok: true, value: T } | { ok: false, reason: ParseFailure } + +/** @public */ +export interface ValueOption { + id: Id + label: string +} + +const ok = (value: T): ParseResult => ({ ok: true, value }) +const fail = (reason: ParseFailure): ParseResult => ({ ok: false, reason }) + +export function parseTextValue (text: string): ParseResult { + const value = text.trim() + return ok(value === '' ? null : value) +} + +export interface NumberParseOptions { + integer?: boolean + min?: number +} + +export function parseNumberValue (text: string, options: NumberParseOptions = {}): ParseResult { + const raw = text.trim() + if (raw === '') return ok(null) + if (!/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(raw)) return fail('invalid') + const value = Number(raw) + if (!Number.isFinite(value)) return fail('invalid') + if (options.integer === true && !Number.isInteger(value)) return fail('invalid') + if (options.min !== undefined && value < options.min) return fail('invalid') + return ok(value) +} + +const ISO_PREFIX = /^(\d{4}-\d{2}-\d{2})(?:[T ].*)?$/ +const SLASH_DATE = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/ + +/** + * A date as the start of its local day. Understands `YYYY-MM-DD` (also followed by a time), `YYYY/M/D`, + * `@today` and `@today+3d`, the syntaxes of the filter string. + */ +export function parseDateValue (text: string, now: number = Date.now()): ParseResult { + let raw = text.trim() + if (raw === '') return ok(null) + const iso = ISO_PREFIX.exec(raw) + if (iso !== null) raw = iso[1] + const slash = SLASH_DATE.exec(raw) + if (slash !== null) raw = `${slash[1]}-${slash[2].padStart(2, '0')}-${slash[3].padStart(2, '0')}` + const scalar = parseDateScalar(raw.toLowerCase()) + if (scalar === undefined || scalar.kind !== 'date') return fail('invalid') + return ok(resolveDate(scalar, now)) +} + +/** `YYYY-MM-DD` of the local day, or empty text for no date. */ +export function formatDateValue (value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return '' + const d = new Date(value) + const pad = (n: number): string => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +export function formatNumberValue (value: number | null | undefined): string { + return value === null || value === undefined || !Number.isFinite(value) ? '' : String(value) +} + +const norm = (s: string): string => s.trim().toLowerCase() + +/** + * Pick one option by its label (case insensitive), or by its id. The label wins over an id of another option. + */ +export function parseOptionValue ( + text: string, + options: ReadonlyArray> +): ParseResult { + const raw = norm(text) + if (raw === '') return ok(null) + const byLabel = options.filter((o) => norm(o.label) === raw) + if (byLabel.length === 1) return ok(byLabel[0].id) + if (byLabel.length > 1) return fail('ambiguous') + const byId = options.filter((o) => norm(String(o.id)) === raw) + if (byId.length === 1) return ok(byId[0].id) + return fail('unknown') +} + +/** + * Several options separated by a comma or a semicolon. One unknown name rejects the whole cell, + * so that a paste never silently drops part of a value. + */ +export function parseMultiOptionValue ( + text: string, + options: ReadonlyArray> +): ParseResult { + if (text.trim() === '') return ok([]) + const res: Id[] = [] + for (const part of text.split(/[,;]/)) { + if (part.trim() === '') continue + const one = parseOptionValue(part, options) + if (one.ok === false) return fail(one.reason) + if (one.value !== null && !res.includes(one.value)) res.push(one.value) + } + return ok(res) +} + +export function formatOptionValue ( + id: Id | null | undefined, + options: ReadonlyArray> +): string { + if (id === null || id === undefined) return '' + return options.find((o) => o.id === id)?.label ?? '' +} + +export function formatMultiOptionValue ( + ids: readonly Id[] | null | undefined, + options: ReadonlyArray> +): string { + if (ids === null || ids === undefined) return '' + return ids + .map((id) => formatOptionValue(id, options)) + .filter((s) => s !== '') + .join(', ') +} diff --git a/plugins/view-resources/src/viewOptions.ts b/plugins/view-resources/src/viewOptions.ts index 463a596bd9..e4a8e8d68e 100644 --- a/plugins/view-resources/src/viewOptions.ts +++ b/plugins/view-resources/src/viewOptions.ts @@ -19,6 +19,7 @@ import { type ToggleViewOption, type ViewOptionModel, type ViewOptions, + type ViewOptionsModel, type Viewlet, type ViewletDescriptor, type BuildModelKey @@ -34,6 +35,22 @@ export const defaultOptions: ViewOptions = { orderBy: ['modifiedBy', SortingOrder.Descending] } +/** + * Options a viewlet starts with when the user has not changed anything yet + */ +export function getViewletDefaultOptions (viewlet: Viewlet | undefined): ViewOptions { + const model: ViewOptionsModel | undefined = viewlet?.viewOptions + if (model == null) return defaultOptions + const res: ViewOptions = { + groupBy: [model.defaultGroupBy ?? model.groupBy[0] ?? defaultOptions.groupBy[0]], + orderBy: model.orderBy?.[0] ?? defaultOptions.orderBy + } + for (const opt of model.other) { + res[opt.key] = opt.defaultValue + } + return res +} + export function isToggleType (viewOption: ViewOptionModel): viewOption is ToggleViewOption { return viewOption.type === 'toggle' } diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index a1d63f22b7..f2ecc8571e 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -226,6 +226,7 @@ const view = plugin(viewId, { Created: '' as IntlString, Delete: '' as IntlString, Then: '' as IntlString, + ThenBy: '' as IntlString, Or: '' as IntlString, Subscribed: '' as IntlString, HyperlinkPlaceholder: '' as IntlString, diff --git a/plugins/view/src/types.ts b/plugins/view/src/types.ts index a9e2649de0..babeeb4a89 100644 --- a/plugins/view/src/types.ts +++ b/plugins/view/src/types.ts @@ -118,6 +118,21 @@ export interface FilteredView extends Doc { users: AccountUuid[] createdBy: PersonId attachedTo: string + + // Project saved views (Tracker, GitHub-Projects style). All fields are optional and additive, + // so filters saved before they existed keep working unchanged. + + // Space (project) the view is a tab of. Project views are shared by every member of the project + project?: Ref + // Ordered visible columns of the view's layout (overrides the global ViewletPreference) + config?: (BuildModelKey | string)[] + // Position of the tab, ascending + order?: number + // JSON of host-specific filter state that is not a Filter[] (e.g. custom field filters) + extra?: string + // GitHub-style filter string (`status:Done label:bug`), see view-resources/src/filter/grammar. + // Filters saved before it existed keep using `filters`/`extra` and have no filter string + filterQuery?: string } /** @@ -879,6 +894,8 @@ export interface CustomObjectLinkProvider extends Class { */ export interface ViewOptionsModel { groupBy: string[] + // Group-by a new view starts with; the first of `groupBy` when absent + defaultGroupBy?: string orderBy: OrderOption[] other: ViewOptionModel[] groupDepth?: number diff --git a/pods/mcp/.eslintrc.js b/pods/mcp/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/pods/mcp/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/pods/mcp/Dockerfile b/pods/mcp/Dockerfile new file mode 100644 index 0000000000..b61fe55377 --- /dev/null +++ b/pods/mcp/Dockerfile @@ -0,0 +1,9 @@ +FROM platformcollective/base-slim:v20260811 + +WORKDIR /app + +COPY bundle/bundle.js ./ +COPY bundle/bundle.js.map ./ + +EXPOSE 4090 +CMD ["dumb-init", "node", "bundle.js"] diff --git a/pods/mcp/README.md b/pods/mcp/README.md new file mode 100644 index 0000000000..f2ada9165c --- /dev/null +++ b/pods/mcp/README.md @@ -0,0 +1,192 @@ +# Huly MCP Server + +A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes a +Huly workspace to AI agents. It speaks MCP over Streamable HTTP and is built as a +regular pod in the platform monorepo, so it ships in the same Docker release as +everything else and is version-locked to the Huly API it talks to. + +## How it works + +``` +Claude / Cursor / OpenCode + │ POST /mcp (JSON-RPC 2.0) + ▼ + @hcengineering/pod-mcp + │ REST over HTTP + ▼ + pods/server (transactor) ──► Mongo / Postgres +``` + +The pod never opens a WebSocket. It uses `@hcengineering/api-client`, the same +stateless REST path that `pod-print` and `pod-export` use, which means one +workspace client per account rather than one socket per session. + +## Configuration + +### Self-hosted (recommended for a single team) + +Give the pod a Huly URL and a credential once, in compose. Every MCP client then +connects to the pod's own URL and needs no Huly credential of its own — this is +what makes the endpoint usable from Claude Desktop or a phone. + +```yaml +services: + mcp: + image: platformcollective/mcp + extra_hosts: + - 'huly.local:host-gateway' + environment: + - SECRET= + - ACCOUNTS_URL=https://huly.your-company.com + - HULY_TOKEN= # or HULY_EMAIL + HULY_PASSWORD + - HULY_WORKSPACE= # required unless the token is workspace-bound + - COLLABORATOR_URL=http://collaborator:3078 # optional, enables issue descriptions + ports: + - 4090:4090 + restart: unless-stopped +``` + +Create the API token in Huly under **Settings → API Tokens**. The token carries +the full rights of the account that created it, so use a dedicated service +account rather than your own. + +The pod must be able to reach the transactor at the address the account service +advertises for the workspace (`TRANSACTOR_URL` on the account service, second +value), and the collaborator at `COLLABORATOR_URL`. In a compose network that +usually means the public host name plus `extra_hosts`, as above. + +### Multi-tenant + +Leave the credential out and callers present their own Huly API token: + +```yaml + environment: + - SECRET= + - ACCOUNTS_URL=https://huly.your-company.com + - MCP_ALLOWED_TOKENS=, # optional pin to specific tokens +``` + +Every request must then carry `Authorization: Bearer `, and +sessions are pinned to the account that opened them. + +### All environment variables + +| Variable | Default | Meaning | +| --- | --- | --- | +| `PORT` | `4090` | HTTP listen port | +| `HOST` | `0.0.0.0` | Bind address | +| `SECRET` | — | **Required.** The platform's signing secret (the same value the account and transactor use). The pod refuses to start on the well-known default `secret`. | +| `MCP_ALLOW_DEFAULT_SECRET` | `false` | Lets a throwaway development stack keep `SECRET=secret`. Never set in production. | +| `ACCOUNTS_URL` | `http://huly.local:3000` | Account service URL; the public Huly base URL in a self-hosted install | +| `SERVICE_ID` | `mcp` | Service name used in logs and metrics | +| `HULY_TOKEN` | — | Static Huly API token. Presence selects self-hosted mode. | +| `HULY_EMAIL` / `HULY_PASSWORD` | — | Static login, used when no token is set | +| `HULY_WORKSPACE` | — | Workspace url slug or id. Required with email/password or an account-level token: the pod asks the account service for a token scoped to it. | +| `COLLABORATOR_URL` | — | Collaborator service URL. Without it, tools that write rich text (issue `description`) refuse instead of silently dropping the text; comments and everything else are unaffected. | +| `MCP_READONLY` | `false` | Refuse every write tool regardless of credentials | +| `MCP_ALLOWED_TOKENS` | — | Comma-separated allowlist for multi-tenant mode | +| `MCP_SESSION_TTL_MS` | `1800000` | Idle session lifetime | +| `MCP_CLIENT_CACHE_TTL_MS` | `600000` | Idle workspace-client cache lifetime | +| `MCP_LOGIN_CACHE_TTL_MS` | `60000` | How long a login result is reused | +| `MCP_RATE_LIMIT` | `300` | Requests per window, per client | +| `MCP_RATE_WINDOW_MS` | `60000` | Rate limit window | +| `MCP_MAX_BODY_BYTES` | `1048576` | Max JSON-RPC request body | +| `MCP_STATS` | `false` | Serve `/api/v1/statistics`. Off by default: the endpoint is unauthenticated and reports CPU and memory, so it must be opted into explicitly | + +## Connecting a client + +```json +{ + "mcpServers": { + "huly": { + "type": "http", + "url": "https://mcp.your-company.com/mcp" + } + } +} +``` + +In self-hosted mode add the pod's URL only. In multi-tenant mode also add the +bearer token your client supports. + +## Tools + +Read: + +- `huly_search` — full-text search across issues, projects, documents, tasks, milestones, people +- `huly_list_projects`, `huly_get_project` +- `huly_list_issues`, `huly_get_issue`, `huly_list_issue_statuses` +- `huly_list_tasks`, `huly_find_people` +- `huly_list_spaces`, `huly_list_drives`, `huly_list_documents`, `huly_get_document` +- `huly_list_milestones`, `huly_list_components` +- `huly_get_workspace` (settings and the caller's own role), `huly_list_members` +- Generic access to any class, for everything the named tools do not cover (templates, space types, roles, + custom fields, other apps): `huly_list_classes` (discover what exists), `huly_describe_class` (fields and + types), `huly_find` (query with filters, sort, projection and a total count), `huly_get_doc` (one document, + with rich-text fields returned as Markdown). They run as the caller, so the transactor decides which + documents come back: a user who is not in a project sees none of its issues. + +Write (refused when read-only): + +- `huly_create_issue` (optionally as a sub-issue via `parentIssueId`, with a `componentId`), `huly_update_issue`, `huly_add_issue_comment` +- `huly_create_milestone`, `huly_update_milestone`, `huly_create_component`, `huly_create_person` +- Documents: `huly_create_document` (a page in a teamspace, optionally nested, body as Markdown) and + `huly_update_document` (title and/or replace the body). +- Generic writes for a verified set of classes: `huly_create_doc`, `huly_update_doc`, `huly_delete_doc`. Supported: + components, milestones, issue templates, labels and document teamspaces (create, update, delete), tracker + projects (update only: rename, archive, members and owners), plus delete for issues and documents. Counters, + defaults, ranks and rich text are filled in as the web app does, and any field outside a class's allowed list + is refused. `huly_update_doc` also takes `push`/`pull` to add or remove one member or owner. Other classes + stay readable through `huly_find` but are not writable yet. +- Workspace administration, enforced by the account service against the caller's role: `huly_update_workspace_name`, + `huly_update_workspace_guest_settings`, `huly_set_member_role`, `huly_remove_member`, `huly_invite_member`, + `huly_create_invite_link`. A refusal comes back as a plain sentence naming the action the role does not allow. + +## Security notes + +- **The generic write tools mirror the web app's ownership rules.** The transactor lets any member write to a public + space, so on its own it would let a plain user delete or rename a teamspace somebody else owns. The tools apply the + web app's rule on top: only a workspace owner or the creator may delete a document, and only a workspace owner, a + space owner or the creator may rename a space or change its members. + +- **Write access is the caller's, not the server's.** Every write goes through + `TxOperations`, which is permission-checked by the transactor. The pod never + uses the system account, so a tool can never exceed the caller's rights. +- **Sessions are pinned.** A session id is bound to the account and workspace + that created it; presenting it with a different token returns 403. +- **Revocation is enforced.** `verifyToken` plus a revocation checker wired to + the account service means a revoked API token stops working. +- **Guest tokens are rejected**, as are read-only tokens for write tools. +- **Rate limited** per client, because standalone pods get no limiting from the + platform. + +## Development + +```bash +rush install +rush build --to @hcengineering/pod-mcp +rushx test --to @hcengineering/pod-mcp +rushx run-local # needs SECRET, ACCOUNTS_URL and credentials +``` + +The MCP protocol layer has no dependency on Huly: `src/mcp/` is transport- and +platform-agnostic and unit tested on its own. + +### Smoke test against a running platform + +With the dev stack up (`cd dev && docker compose up -d`) and `HULY_TOKEN` (or +`HULY_EMAIL` + `HULY_PASSWORD`) set on the `mcp` service: + +```bash +curl -s localhost:4090/api/v1/health + +# initialize: expect HTTP 200, an Mcp-Session-Id response header and a protocolVersion echo +curl -si localhost:4090/mcp -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' + +# then reuse the returned session id +curl -s localhost:4090/mcp -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' -H "Mcp-Session-Id: $SID" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"huly_list_projects","arguments":{}}}' +``` diff --git a/pods/mcp/config/rig.json b/pods/mcp/config/rig.json new file mode 100644 index 0000000000..b94bbb0650 --- /dev/null +++ b/pods/mcp/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "node" +} diff --git a/pods/mcp/jest.config.js b/pods/mcp/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/pods/mcp/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/pods/mcp/package.json b/pods/mcp/package.json new file mode 100644 index 0000000000..81ee1c4f90 --- /dev/null +++ b/pods/mcp/package.json @@ -0,0 +1,80 @@ +{ + "name": "@hcengineering/pod-mcp", + "version": "0.7.0", + "main": "lib/index.js", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Anticrm Platform Contributors", + "template": "@hcengineering/node-package", + "license": "EPL-2.0", + "scripts": { + "start": "ts-node src/index.ts", + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "_phase:bundle": "rushx bundle", + "_phase:docker-build": "rushx docker:build", + "_phase:docker-staging": "rushx docker:staging", + "bundle": "node ../../common/scripts/esbuild.js --keep-names=true --sourcemap=external", + "docker:build": "../../common/scripts/docker_build.sh platformcollective/mcp", + "docker:staging": "../../common/scripts/docker_tag.sh platformcollective/mcp staging", + "docker:abuild": "docker build -t platformcollective/mcp . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh platformcollective/mcp", + "docker:push": "../../common/scripts/docker_tag.sh platformcollective/mcp", + "run-local": "ts-node src/index.ts", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "workspace:^0.7.21", + "@types/jest": "^29.5.5", + "@types/node": "^24.13.3", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "cross-env": "~7.0.3", + "esbuild": "^0.25.10", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.6.2", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.13" + }, + "dependencies": { + "@hcengineering/account-client": "workspace:^0.7.25", + "@hcengineering/analytics": "workspace:^0.7.19", + "@hcengineering/analytics-service": "workspace:^0.7.19", + "@hcengineering/api-client": "workspace:^0.7.25", + "@hcengineering/chunter": "workspace:^0.7.0", + "@hcengineering/collaborator-client": "workspace:^0.7.18", + "@hcengineering/contact": "workspace:^0.7.0", + "@hcengineering/core": "workspace:^0.7.26", + "@hcengineering/document": "workspace:^0.7.0", + "@hcengineering/drive": "workspace:^0.7.0", + "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/rank": "workspace:^0.7.18", + "@hcengineering/server-core": "workspace:^0.7.19", + "@hcengineering/server-token": "workspace:^0.7.18", + "@hcengineering/tags": "workspace:^0.7.0", + "@hcengineering/task": "workspace:^0.7.0", + "@hcengineering/text-core": "workspace:^0.7.19", + "@hcengineering/text-markdown": "workspace:^0.7.21", + "@hcengineering/tracker": "workspace:^0.7.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2" + } +} diff --git a/pods/mcp/src/__tests__/config-auth-gate.test.ts b/pods/mcp/src/__tests__/config-auth-gate.test.ts new file mode 100644 index 0000000000..9a50136f1b --- /dev/null +++ b/pods/mcp/src/__tests__/config-auth-gate.test.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { isLoopbackHost, loadConfig } from '../config' +import { fakeEnv } from './test-doubles' + +/** + * The reachability gate for `configured` mode. + * + * In that mode the pod holds a real Huly credential and the caller supplies + * none, so the endpoint's only protection is who can open a socket to it. A + * listener on a routable interface with no shared secret would hand that + * credential to anyone who found the port. + */ +const configured = (env: Record = {}): NodeJS.ProcessEnv => + fakeEnv({ SECRET: 'not-the-default', HULY_TOKEN: 'static-huly-token', ...env }) + +describe('configured-mode reachability gate', () => { + it('refuses to start on a routable host with no shared secret', () => { + expect(() => loadConfig(configured({ HOST: '0.0.0.0' }))).toThrow(/MCP_ALLOWED_TOKENS/) + }) + + it('names the two ways out of the failure so it is actionable', () => { + expect(() => loadConfig(configured({ HOST: '0.0.0.0' }))).toThrow(/MCP_ALLOWED_TOKENS[\s\S]*127\.0\.0\.1/) + }) + + it('starts when an allowlist supplies the shared secret', () => { + const config = loadConfig(configured({ HOST: '0.0.0.0', MCP_ALLOWED_TOKENS: 'client-secret' })) + expect(config.AllowedTokens).toEqual(['client-secret']) + }) + + it.each(['127.0.0.1', 'localhost', '::1', '[::1]'])('starts on loopback host %s without an allowlist', (host) => { + expect(loadConfig(configured({ HOST: host })).Host).toBe(host) + }) + + it('still requires the secret when an allowlist is present', () => { + expect(() => loadConfig(fakeEnv({ HULY_TOKEN: 'x', MCP_ALLOWED_TOKENS: 'y', HOST: '0.0.0.0' }))).toThrow( + /SECRET must be set/ + ) + }) + + it('does not apply to perRequest mode, where every caller brings its own token', () => { + const config = loadConfig(fakeEnv({ SECRET: 'not-the-default', HOST: '0.0.0.0' })) + expect(config.AuthMode).toBe('perRequest') + expect(config.AllowedTokens).toEqual([]) + }) +}) + +describe('isLoopbackHost', () => { + it.each([ + ['127.0.0.1', true], + ['127.0.0.53', false], + ['localhost', true], + ['LOCALHOST', true], + ['::1', true], + ['[::1]', true], + ['0.0.0.0', false], + ['::', false], + ['192.168.1.10', false], + ['example.internal', false], + ['', false] + ])('classifies %s as loopback=%s', (host, expected) => { + expect(isLoopbackHost(host)).toBe(expected) + }) +}) diff --git a/pods/mcp/src/__tests__/config.test.ts b/pods/mcp/src/__tests__/config.test.ts new file mode 100644 index 0000000000..6fc3ddc666 --- /dev/null +++ b/pods/mcp/src/__tests__/config.test.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { loadConfig } from '../config' +import { fakeEnv } from './test-doubles' + +describe('loadConfig secret handling', () => { + it('refuses to start without a secret', () => { + expect(() => loadConfig(fakeEnv())).toThrow(/SECRET must be set/) + }) + + it('refuses the well-known default secret', () => { + expect(() => loadConfig(fakeEnv({ SECRET: 'secret' }))).toThrow(/well-known default/) + }) + + it('accepts the default secret only when a dev stack opts in explicitly', () => { + const config = loadConfig(fakeEnv({ SECRET: 'secret', MCP_ALLOW_DEFAULT_SECRET: 'true' })) + expect(config.Secret).toBe('secret') + }) + + it('accepts a real secret and reads the collaborator url', () => { + const config = loadConfig(fakeEnv({ SECRET: 'a-real-one', COLLABORATOR_URL: 'http://collab:3078' })) + expect(config.CollaboratorUrl).toBe('http://collab:3078') + expect(config.AllowDefaultSecret).toBe(false) + }) +}) + +describe('loadConfig allowed origins', () => { + it('defaults to an empty allowlist, which fails closed for browsers', () => { + expect(loadConfig(fakeEnv({ SECRET: 'a-real-one' })).AllowedOrigins).toEqual([]) + }) + + it('splits a comma separated list and trims whitespace', () => { + const config = loadConfig( + fakeEnv({ SECRET: 'a-real-one', MCP_ALLOWED_ORIGINS: 'http://localhost:5173, https://huly.example.com,' }) + ) + expect(config.AllowedOrigins).toEqual(['http://localhost:5173', 'https://huly.example.com']) + }) +}) + +describe('loadConfig statistics endpoint', () => { + it('leaves /api/v1/statistics disabled by default, so nothing is exposed out of the box', () => { + // No MCP_STATS in the environment: a fresh deployment must not serve + // system figures to unauthenticated callers until an operator opts in. + expect(loadConfig(fakeEnv({ SECRET: 'a-real-one' })).EnableStats).toBe(false) + }) + + it('enables /api/v1/statistics only when MCP_STATS opts in', () => { + expect(loadConfig(fakeEnv({ SECRET: 'a-real-one', MCP_STATS: 'true' })).EnableStats).toBe(true) + }) +}) diff --git a/pods/mcp/src/__tests__/test-doubles.ts b/pods/mcp/src/__tests__/test-doubles.ts new file mode 100644 index 0000000000..cc975b5f07 --- /dev/null +++ b/pods/mcp/src/__tests__/test-doubles.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type AccountUuid, type MeasureContext, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' +import { type WorkspaceSession } from '../platform/workspace-client-provider' +import { toolContext, type ToolContext } from '../mcp/tool' + +/** + * Test doubles for the MCP layer. + * + * The assertions here deliberately go through a named variable rather than an + * inline object literal, because the repository's ESLint config forbids + * asserting an object literal (`consistent-type-assertions`). Keeping the casts + * in one file also means the protocol tests stay free of Huly specifics. + */ + +const ACCOUNT = 'account-1' as AccountUuid +const WORKSPACE = 'workspace-1' as WorkspaceUuid + +export const fakeIdentity = (overrides: Partial = {}): SessionIdentity => { + const base: SessionIdentity = { + account: ACCOUNT, + workspace: WORKSPACE, + token: { account: ACCOUNT, workspace: WORKSPACE }, + workspaceToken: 'raw-token', + transactorUrl: 'http://transactor.test', + readOnly: false + } + return { ...base, ...overrides } +} + +export const fakeWorkspaceSession = (identity: SessionIdentity = fakeIdentity()): WorkspaceSession => { + // `Object.create` rather than a literal: the repo's ESLint config forbids + // asserting an object literal, and an empty client is a legal stub here. + const base = { + identity, + client: Object.create(null) as TxOperations, + markup: { read: async (_ref: string) => '' } + } + return base as unknown as WorkspaceSession +} + +export const fakeToolContext = ( + identity: SessionIdentity = fakeIdentity(), + ctx: MeasureContext = fakeMeasureContext() +): ToolContext => toolContext(fakeWorkspaceSession(identity), ctx) + +/** + * A MeasureContext that satisfies the handful of methods the MCP layer calls. + * + * `with` is the important one: tools and the dispatcher wrap their work in it, + * so a stub that does not invoke the callback would silently skip every test. + */ +export const fakeMeasureContext = (): MeasureContext => { + const ctx: Record = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + setLevel: () => {}, + newChild: () => ctx, + with: async (_name: string, _params: unknown, fn: (child: MeasureContext) => Promise) => + await fn(ctx as unknown as MeasureContext) + } + return ctx as unknown as MeasureContext +} + +/** Builds a ProcessEnv without asserting an object literal. */ +export const fakeEnv = (values: Record = {}): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = { ...values } + return env +} diff --git a/pods/mcp/src/auth/__tests__/auth.test.ts b/pods/mcp/src/auth/__tests__/auth.test.ts new file mode 100644 index 0000000000..267a9c4802 --- /dev/null +++ b/pods/mcp/src/auth/__tests__/auth.test.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { resolveAuthMode } from '../../config' +import { fakeEnv } from '../../__tests__/test-doubles' +import { AuthenticationError, toTransactorHttpUrl } from '../authenticator' +import { extractBearerToken } from '../token-extractor' + +describe('toTransactorHttpUrl', () => { + it('maps both websocket schemes to their http twins', () => { + expect(toTransactorHttpUrl('ws://huly.local:3030')).toBe('http://huly.local:3030') + expect(toTransactorHttpUrl('wss://huly.example.com')).toBe('https://huly.example.com') + }) + + it('leaves an already-http url alone', () => { + expect(toTransactorHttpUrl('http://huly.local:3030')).toBe('http://huly.local:3030') + }) +}) + +describe('extractBearerToken', () => { + const header = (value: string | undefined): { authorization?: string } => ({ authorization: value }) + + it('reads a bearer credential', () => { + expect(extractBearerToken(header('Bearer abc'))).toBe('abc') + }) + + it('is case-insensitive about the scheme, as RFC 7235 requires', () => { + expect(extractBearerToken(header('bearer abc'))).toBe('abc') + expect(extractBearerToken(header('BEARER abc'))).toBe('abc') + }) + + it('tolerates surrounding whitespace', () => { + expect(extractBearerToken(header(' Bearer abc '))).toBe('abc') + }) + + it('rejects a missing, empty or non-bearer credential', () => { + expect(extractBearerToken(header(undefined))).toBeUndefined() + expect(extractBearerToken(header('Bearer'))).toBeUndefined() + expect(extractBearerToken(header('Bearer '))).toBeUndefined() + expect(extractBearerToken(header('Basic abc'))).toBeUndefined() + }) +}) + +describe('resolveAuthMode', () => { + it('defaults to perRequest when no credentials are configured', () => { + expect(resolveAuthMode(fakeEnv())).toBe('perRequest') + }) + + it('selects configured mode when an API token is present', () => { + expect(resolveAuthMode(fakeEnv({ HULY_TOKEN: 'abc' }))).toBe('configured') + }) + + it('selects configured mode when both email and password are present', () => { + expect(resolveAuthMode(fakeEnv({ HULY_EMAIL: 'a@b.c', HULY_PASSWORD: 'pw' }))).toBe('configured') + }) + + it('ignores an email with no password', () => { + expect(resolveAuthMode(fakeEnv({ HULY_EMAIL: 'a@b.c' }))).toBe('perRequest') + }) + + it('prefers configured mode when both are set', () => { + expect(resolveAuthMode(fakeEnv({ HULY_TOKEN: 'abc', MCP_ALLOWED_TOKENS: 'x' }))).toBe('configured') + }) +}) + +describe('AuthenticationError', () => { + it('carries a machine-readable reason', () => { + const err = new AuthenticationError('guest', 'nope') + expect(err.reason).toBe('guest') + expect(err).toBeInstanceOf(Error) + }) +}) diff --git a/pods/mcp/src/auth/__tests__/authenticator-factory.test.ts b/pods/mcp/src/auth/__tests__/authenticator-factory.test.ts new file mode 100644 index 0000000000..f38cffdefd --- /dev/null +++ b/pods/mcp/src/auth/__tests__/authenticator-factory.test.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type SessionIdentity } from '../authenticator' +import { AuthenticationError } from '../authenticator' +import { AllowlistedAuthenticator, createAuthenticator } from '../authenticator-factory' +import { loadConfig } from '../../config' +import { fakeEnv, fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' + +const config = (env: Record): ReturnType => + loadConfig(fakeEnv({ SECRET: 'not-the-default', ...env })) + +/** Records what reached the inner authenticator, so ordering is observable. */ +const innerSpy = (): { calls: string[], authenticator: { authenticate: (t: string) => Promise } } => { + const calls: string[] = [] + return { + calls, + authenticator: { + authenticate: async (rawToken: string) => { + calls.push(rawToken) + return fakeIdentity() + } + } + } +} + +const reasonOf = async (promise: Promise): Promise => { + try { + await promise + return undefined + } catch (err) { + return err instanceof AuthenticationError ? err.reason : 'not-an-AuthenticationError' + } +} + +/** + * The allowlist is the credential in `configured` mode, and a pre-filter in + * `perRequest` mode. Either way it must run *before* the real authenticator, + * so a rejected token can never reach the account service. + */ +describe('AllowlistedAuthenticator', () => { + const allowed = ['known-client', 'second-client'] + + it('refuses a request with no credential as missing, so the client is challenged', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, allowed) + + expect(await reasonOf(gate.authenticate(''))).toBe('missing') + // `missing` must never be reported for a token the gate already knows is + // absent; the inner authenticator must not have been consulted at all. + expect(calls).toEqual([]) + }) + + it('refuses an unknown credential as forbidden, without a login attempt', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, allowed) + + expect(await reasonOf(gate.authenticate('not-the-one'))).toBe('forbidden') + expect(calls).toEqual([]) + }) + + it('passes a listed credential through and returns the identity unchanged', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, allowed) + + const identity = await gate.authenticate('known-client') + + expect(calls).toEqual(['known-client']) + expect(identity.readOnly).toBe(false) + }) + + it('separates an empty allowlist from a populated one', async () => { + const { calls, authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, []) + + expect(await reasonOf(gate.authenticate('anything'))).toBe('forbidden') + expect(calls).toEqual([]) + }) + + it('treats each entry as an exact string, not a pattern', async () => { + const { authenticator } = innerSpy() + const gate = new AllowlistedAuthenticator(authenticator, ['known-client']) + + expect(await reasonOf(gate.authenticate('known-client-extra'))).toBe('forbidden') + expect(await reasonOf(gate.authenticate('Known-Client'))).toBe('forbidden') + }) +}) + +describe('configured-mode reachability through createAuthenticator', () => { + const env = { HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space', MCP_ALLOWED_TOKENS: 'known-client', HOST: '0.0.0.0' } + + it('rejects a missing credential before the configured authenticator runs', async () => { + const auth = createAuthenticator(fakeMeasureContext(), config(env)) + expect(await reasonOf(auth.authenticate(''))).toBe('missing') + }) + + it('rejects an unlisted credential before the configured authenticator runs', async () => { + const auth = createAuthenticator(fakeMeasureContext(), config(env)) + expect(await reasonOf(auth.authenticate('nope'))).toBe('forbidden') + }) + + // Pass-through is covered by `AllowlistedAuthenticator` above with a fake + // inner: proving it through `createAuthenticator` would need a real login + // attempt against the account service. +}) + +describe('read-only clamp ordering', () => { + it('is applied outside the allowlist, so a listed token can still be clamped', () => { + const auth = createAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', MCP_ALLOWED_TOKENS: 'known-client', MCP_READONLY: 'true', HOST: '0.0.0.0' }) + ) + expect(auth).toBeDefined() + }) +}) diff --git a/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts b/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts new file mode 100644 index 0000000000..fcd43738aa --- /dev/null +++ b/pods/mcp/src/auth/__tests__/configured-authenticator.test.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type AccountClient } from '@hcengineering/account-client' +import { type AccountUuid, type PersonUuid, type WorkspaceUuid } from '@hcengineering/core' +import { generateToken } from '@hcengineering/server-token' + +import { fakeMeasureContext } from '../../__tests__/test-doubles' +import { loadConfig } from '../../config' +import { ConfiguredAuthenticator } from '../configured-authenticator' + +const ACCOUNT = '11111111-1111-4111-8111-111111111111' +const WORKSPACE = '22222222-2222-4222-8222-222222222222' +const workspaceToken = generateToken(ACCOUNT as PersonUuid, WORKSPACE as WorkspaceUuid) + +const accountLevelInfo = { account: ACCOUNT as AccountUuid, token: 'account-level-token' } +const workspaceInfo = { + account: ACCOUNT as AccountUuid, + workspace: WORKSPACE as WorkspaceUuid, + workspaceUrl: 'my-space', + endpoint: 'ws://localhost:3333', + token: workspaceToken, + role: 1 +} + +interface Calls { + selectWorkspace: string[] + createdWith: Array +} + +function fakeClientFactory ( + overrides: Partial Promise>>, + calls: Calls +): (url: string, token?: string) => AccountClient { + return (_url, token) => { + calls.createdWith.push(token) + const client = { + getLoginInfoByToken: overrides.getLoginInfoByToken ?? (async () => accountLevelInfo), + login: overrides.login ?? (async () => accountLevelInfo), + selectWorkspace: async (workspaceUrl: string) => { + calls.selectWorkspace.push(workspaceUrl) + return workspaceInfo + } + } + return client as unknown as AccountClient + } +} + +/** + * Config for these tests. + * + * `HOST` is loopback on purpose: these cases exercise the login exchange, not + * endpoint reachability, and `configured` mode refuses to start on a routable + * HOST without an allowlist. The reachability gate itself is covered in + * `authenticator-factory.test.ts`. + */ +const config = (env: Record): ReturnType => + loadConfig({ SECRET: 'not-the-default', HOST: '127.0.0.1', ...env }) + +describe('ConfiguredAuthenticator', () => { + it('exchanges an account-level HULY_TOKEN for a workspace token', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({}, calls) + ) + + const identity = await auth.authenticate() + + expect(calls.selectWorkspace).toEqual(['my-space']) + expect(calls.createdWith).toEqual(['static', 'static']) + expect(identity.workspace).toBe(WORKSPACE) + expect(identity.workspaceToken).toBe(workspaceToken) + expect(identity.transactorUrl).toBe('http://localhost:3333') + }) + + it('logs in with email and password, then selects the workspace using the login token', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_EMAIL: 'a@example.test', HULY_PASSWORD: 'pw', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({}, calls) + ) + + await auth.authenticate() + + expect(calls.createdWith).toEqual([undefined, 'account-level-token']) + expect(calls.selectWorkspace).toEqual(['my-space']) + }) + + it('uses a workspace-scoped token as is, without selecting again', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({ getLoginInfoByToken: async () => workspaceInfo }, calls) + ) + + await auth.authenticate() + + expect(calls.selectWorkspace).toEqual([]) + }) + + it('explains that HULY_WORKSPACE is needed when the token is not workspace-bound', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator(fakeMeasureContext(), config({ HULY_TOKEN: 'static' }), fakeClientFactory({}, calls)) + + await expect(auth.authenticate()).rejects.toThrow(/HULY_WORKSPACE/) + }) + + it('rejects a login that returns no token, such as one that needs two-factor', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_EMAIL: 'a@example.test', HULY_PASSWORD: 'pw', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({ login: async () => ({ account: ACCOUNT, tfaRequired: true }) }, calls) + ) + + await expect(auth.authenticate()).rejects.toThrow(/rejected/) + }) + + it('caches the resolved identity', async () => { + const calls: Calls = { selectWorkspace: [], createdWith: [] } + const auth = new ConfiguredAuthenticator( + fakeMeasureContext(), + config({ HULY_TOKEN: 'static', HULY_WORKSPACE: 'my-space' }), + fakeClientFactory({}, calls) + ) + + await auth.authenticate() + await auth.authenticate() + + expect(calls.selectWorkspace).toHaveLength(1) + }) +}) diff --git a/pods/mcp/src/auth/authenticator-factory.ts b/pods/mcp/src/auth/authenticator-factory.ts new file mode 100644 index 0000000000..40f08ef140 --- /dev/null +++ b/pods/mcp/src/auth/authenticator-factory.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' + +import { type Config } from '../config' +import { AuthenticationError, type Authenticator, type SessionIdentity } from './authenticator' +import { ConfiguredAuthenticator } from './configured-authenticator' +import { HulyTokenAuthenticator } from './huly-token-authenticator' + +/** + * Allowlist checked before the real authenticator runs. + * + * It does two different jobs, and the two need different failure reasons: + * + * - In `perRequest` mode it narrows a shared endpoint down to the tokens an + * operator pinned to it, because Huly API tokens carry the full rights of + * their account and cannot be scoped any narrower. + * - In `configured` mode it is the *only* thing standing between an anonymous + * caller and the configured Huly account. The configured authenticator + * ignores the credential entirely, so without this gate anyone who could + * reach the port would act as that account. + * + * Checking before delegating also means an unknown token never triggers a + * login attempt against the account service. + */ +export class AllowlistedAuthenticator implements Authenticator { + private readonly inner: Authenticator + private readonly allowed: Set + + constructor (inner: Authenticator, allowed: string[]) { + this.inner = inner + this.allowed = new Set(allowed) + } + + async authenticate (rawToken: string): Promise { + // Distinguishing "no credential" from "wrong credential" matters to the + // client: only `missing` gets a WWW-Authenticate challenge back, which is + // what tells a client that it should present a credential at all. + if (rawToken === '') { + throw new AuthenticationError('missing', 'This MCP endpoint requires a bearer token') + } + if (!this.allowed.has(rawToken)) { + throw new AuthenticationError('forbidden', 'This token is not permitted to use this MCP endpoint') + } + return await this.inner.authenticate(rawToken) + } +} + +/** Forces every identity to be read-only, whatever the underlying token says. */ +class ReadOnlyAuthenticator implements Authenticator { + private readonly inner: Authenticator + + constructor (inner: Authenticator) { + this.inner = inner + } + + async authenticate (rawToken: string): Promise { + const identity = await this.inner.authenticate(rawToken) + return { ...identity, readOnly: true } + } +} + +/** + * Chooses the authenticator for a deployment. + * + * Decorators are applied outermost-last so the read-only clamp always wins: it + * is the operator's bluntest control and must not be bypassable by a token flag. + * + * `loadConfig` refuses to start in `configured` mode with an empty allowlist on + * a non-loopback HOST, so by the time this runs the shared-secret gate exists + * whenever it is needed. Logging it here makes that visible at boot rather than + * something an operator has to infer from an absent 401. + */ +export function createAuthenticator (ctx: MeasureContext, config: Config): Authenticator { + let authenticator: Authenticator + + if (config.AuthMode === 'configured') { + authenticator = new ConfiguredAuthenticator(ctx, config) + } else { + authenticator = new HulyTokenAuthenticator(ctx, { + accountsUrl: config.AccountsUrl, + cacheTtlMs: config.LoginCacheTtlMs + }) + } + + if (config.AllowedTokens.length > 0) { + authenticator = new AllowlistedAuthenticator(authenticator, config.AllowedTokens) + } + + if (config.ReadOnly) { + authenticator = new ReadOnlyAuthenticator(authenticator) + } + + ctx.info('mcp authenticator ready', { + mode: config.AuthMode, + allowlist: config.AllowedTokens.length > 0, + readOnly: config.ReadOnly + }) + + return authenticator +} diff --git a/pods/mcp/src/auth/authenticator.ts b/pods/mcp/src/auth/authenticator.ts new file mode 100644 index 0000000000..97dd27e4dd --- /dev/null +++ b/pods/mcp/src/auth/authenticator.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type AccountUuid, type WorkspaceUuid } from '@hcengineering/core' +import { type Token } from '@hcengineering/server-token' + +/** + * Everything the request pipeline needs to act on behalf of one caller. + * + * Resolved once per request, then frozen onto the MCP session: a session can + * never change identity halfway through, so a stolen `Mcp-Session-Id` is useless + * on its own and a token swap cannot escalate a live session. + */ +export interface SessionIdentity { + /** Global person id (Huly `AccountUuid`). */ + account: AccountUuid + /** Workspace the token is scoped to. */ + workspace: WorkspaceUuid + /** The verified, unexpired, non-revoked token. */ + token: Token + /** Re-signed token bound to `workspace`, used to talk to the transactor. */ + workspaceToken: string + /** Transactor endpoint in http(s) form, derived from the ws endpoint. */ + transactorUrl: string + /** True when the token carries `extra.readonly === 'true'`. */ + readOnly: boolean +} + +export interface Authenticator { + /** + * Verifies a raw bearer token and resolves it to a workspace-scoped identity. + * Rejects with `AuthenticationError` for any credential that must not be used + * against this server. + */ + authenticate: (rawToken: string) => Promise +} + +export type AuthenticationFailure = 'missing' | 'invalid' | 'guest' | 'forbidden' + +export class AuthenticationError extends Error { + readonly reason: AuthenticationFailure + + constructor (reason: AuthenticationFailure, message: string) { + super(message) + this.name = 'AuthenticationError' + this.reason = reason + } +} + +/** + * Turns a WebSocket transactor endpoint into its HTTP twin, which is what the + * REST client and the blob endpoint expect. + */ +export function toTransactorHttpUrl (endpoint: string): string { + return endpoint.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://') +} diff --git a/pods/mcp/src/auth/configured-authenticator.ts b/pods/mcp/src/auth/configured-authenticator.ts new file mode 100644 index 0000000000..ab5834189f --- /dev/null +++ b/pods/mcp/src/auth/configured-authenticator.ts @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { + getClient as getAccountClient, + isWorkspaceLoginInfo, + type AccountClient, + type LoginInfoByToken +} from '@hcengineering/account-client' +import { type MeasureContext } from '@hcengineering/core' +import { decodeToken } from '@hcengineering/server-token' + +import { type Config } from '../config' +import { AuthenticationError, type Authenticator, type SessionIdentity, toTransactorHttpUrl } from './authenticator' + +/** Builds an account-service client; injectable so the login flow can be unit tested. */ +export type AccountClientFactory = (accountsUrl: string, token?: string) => AccountClient + +/** + * Authenticates every caller as one account configured on the pod itself. + * + * This is the shape a self-hosted install actually needs. The operator supplies + * Huly's URL and a credential once, in compose; every MCP client (Claude + * Desktop, an IDE, a phone) then connects to this pod with nothing but the pod's + * own URL. Handing a Huly token to each agent instead would mean distributing + * workspace-wide write access to every tool the user has ever installed. + * + * The identity is resolved lazily and cached, so the first tool call pays the + * login round trip and later calls reuse it until the TTL expires. + */ +export class ConfiguredAuthenticator implements Authenticator { + private readonly ctx: MeasureContext + private readonly config: Config + private cached: { identity: SessionIdentity, expiresOn: number } | undefined + private inFlight: Promise | undefined + + private readonly createClient: AccountClientFactory + + constructor (ctx: MeasureContext, config: Config, createClient: AccountClientFactory = getAccountClient) { + this.ctx = ctx + this.config = config + this.createClient = createClient + } + + async authenticate (): Promise { + const now = Date.now() + if (this.cached !== undefined && this.cached.expiresOn > now) { + return this.cached.identity + } + + // Collapse a cold-start stampede: many MCP clients call initialize at once + // on reconnect, and each would otherwise trigger its own login. + if (this.inFlight !== undefined) return await this.inFlight + + const creation = this.resolve() + this.inFlight = creation + try { + return await creation + } finally { + this.inFlight = undefined + } + } + + private async resolve (): Promise { + const identity = this.toIdentity(await this.login()) + // Kept short on purpose: a configured password or token can be rotated out + // of band, and a long cache would keep using the old one after that. + this.cached = { identity, expiresOn: Date.now() + this.config.LoginCacheTtlMs } + this.ctx.info('mcp configured identity resolved', { + workspace: identity.workspace, + readOnly: identity.readOnly + }) + return identity + } + + private async login (): Promise { + try { + const { info, accountToken } = await this.loginAtAccountLevel() + if (isWorkspaceLoginInfo(info) || this.config.HulyWorkspace === '') { + return info + } + // A login or a plain account token is scoped to the account, not to a + // workspace, so the account service must be asked for a workspace token. + return await this.createClient(this.config.AccountsUrl, accountToken).selectWorkspace(this.config.HulyWorkspace) + } catch (err) { + this.ctx.error('mcp configured login failed', { error: (err as Error)?.message }) + throw new AuthenticationError( + 'invalid', + 'The configured Huly credentials were rejected. Check HULY_TOKEN or HULY_EMAIL/HULY_PASSWORD.' + ) + } + } + + private async loginAtAccountLevel (): Promise<{ info: LoginInfoByToken, accountToken: string }> { + if (this.config.HulyToken !== '') { + const client = this.createClient(this.config.AccountsUrl, this.config.HulyToken) + return { info: await client.getLoginInfoByToken(), accountToken: this.config.HulyToken } + } + const client = this.createClient(this.config.AccountsUrl) + const info = await client.login(this.config.HulyEmail, this.config.HulyPassword) + if (info?.token === undefined) { + throw new Error('The account service returned no token (two-factor authentication may be required)') + } + return { info, accountToken: info.token } + } + + private toIdentity (loginInfo: LoginInfoByToken): SessionIdentity { + if (!isWorkspaceLoginInfo(loginInfo)) { + throw new AuthenticationError( + 'invalid', + this.config.HulyWorkspace === '' + ? 'The configured account is not bound to a workspace. Set HULY_WORKSPACE to pick one.' + : `The configured account could not be resolved to workspace "${this.config.HulyWorkspace}".` + ) + } + + if (this.config.HulyWorkspace !== '' && !matchesWorkspace(loginInfo, this.config.HulyWorkspace)) { + throw new AuthenticationError( + 'forbidden', + `The configured account is bound to a different workspace than HULY_WORKSPACE="${this.config.HulyWorkspace}".` + ) + } + + // The account service just minted this token and scoped it to the + // workspace, so decoding it locally is enough to learn who we act as. + // `decodeToken` verifies the signature, which also proves the account + // service and this pod share SECRET. + let token + try { + token = decodeToken(loginInfo.token) + } catch { + throw new AuthenticationError('invalid', 'The account service returned a token we cannot verify') + } + + return { + account: token.account, + workspace: loginInfo.workspace, + token, + workspaceToken: loginInfo.token, + transactorUrl: toTransactorHttpUrl(loginInfo.endpoint), + readOnly: this.config.ReadOnly || token.extra?.readonly === 'true' + } + } +} + +/** Accepts a workspace id, its url slug, or a case-insensitive id. */ +function matchesWorkspace (loginInfo: { workspace: string, workspaceUrl: string }, wanted: string): boolean { + return ( + loginInfo.workspace === wanted || loginInfo.workspaceUrl === wanted || loginInfo.workspace === wanted.toLowerCase() + ) +} diff --git a/pods/mcp/src/auth/http-types.ts b/pods/mcp/src/auth/http-types.ts new file mode 100644 index 0000000000..c83a99fc13 --- /dev/null +++ b/pods/mcp/src/auth/http-types.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type Request } from 'express' +import { type Token } from '@hcengineering/server-token' + +/** Request shape used by the auth pipeline: a plain Express request. */ +export type RequestWithAuth = Request & { + token?: Token +} diff --git a/pods/mcp/src/auth/huly-token-authenticator.ts b/pods/mcp/src/auth/huly-token-authenticator.ts new file mode 100644 index 0000000000..897079931e --- /dev/null +++ b/pods/mcp/src/auth/huly-token-authenticator.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { + getClient as getAccountClient, + isWorkspaceLoginInfo, + type AccountClient, + type LoginInfoByToken +} from '@hcengineering/account-client' +import { type MeasureContext } from '@hcengineering/core' +import { setApiTokenRevocationChecker, verifyToken } from '@hcengineering/server-token' + +import { AuthenticationError, type Authenticator, type SessionIdentity, toTransactorHttpUrl } from './authenticator' + +export interface HulyTokenAuthenticatorOptions { + accountsUrl: string + /** TTL for the cached account-service round trip, in milliseconds. */ + cacheTtlMs?: number + now?: () => number +} + +interface CacheEntry { + identity: SessionIdentity + expiresOn: number +} + +const DEFAULT_CACHE_TTL_MS = 60_000 + +/** + * Authenticates MCP callers with a regular Huly API token. + * + * Two independent checks are performed on purpose: + * + * 1. `verifyToken` — signature, expiry and revocation. Revocation is only + * enforced when a process registers a checker, which is why + * `registerRevocationChecker` runs in the constructor. + * 2. `getLoginInfoByToken` — the account service is the authority on whether a + * token still maps to a live account and workspace, and it hands back a token + * already scoped to that workspace. + * + * The second check costs one round trip, so successful results are cached for a + * short TTL. The TTL is deliberately short: revocation is enforced fail-closed + * in the transactor, and a minute of staleness here is the price for not hitting + * the account service on every single tool call. + */ +export class HulyTokenAuthenticator implements Authenticator { + private readonly ctx: MeasureContext + private readonly accountsUrl: string + private readonly cacheTtlMs: number + private readonly now: () => number + private readonly cache = new Map() + + constructor (ctx: MeasureContext, options: HulyTokenAuthenticatorOptions) { + this.ctx = ctx + this.accountsUrl = options.accountsUrl + this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS + this.now = options.now ?? Date.now + + this.registerRevocationChecker() + } + + /** + * Makes `verifyToken` reject revoked API tokens. Without this, a revoked token + * is silently accepted by this process — see `setApiTokenRevocationChecker`. + */ + private registerRevocationChecker (): void { + setApiTokenRevocationChecker(async (_apiTokenId: string, _decoded: unknown, raw: string) => { + try { + await this.accountClient(raw).getLoginInfoByToken() + return false + } catch (err) { + // Only an explicit Unauthorized means "revoked". Anything else (network + // hiccup, account service restart) rethrows so the checker's own + // fail-closed path decides, instead of us guessing. + const status = (err as { status?: { code?: number } })?.status?.code + if (status === 401) return true + throw err + } + }) + } + + private accountClient (token: string): AccountClient { + return getAccountClient(this.accountsUrl, token) + } + + async authenticate (rawToken: string): Promise { + if (rawToken === '') { + throw new AuthenticationError('missing', 'Missing bearer token') + } + + const cached = this.cache.get(rawToken) + if (cached !== undefined && cached.expiresOn > this.now()) { + return cached.identity + } + + const token = await this.verify(rawToken) + this.assertUsable(token.extra) + + const identity = await this.resolveWorkspace(rawToken, token) + + this.cache.set(rawToken, { identity, expiresOn: this.now() + this.cacheTtlMs }) + this.sweepCache() + + return identity + } + + private async verify (rawToken: string): Promise { + try { + return await verifyToken(rawToken) + } catch (err) { + this.ctx.warn('mcp token rejected', { error: (err as Error)?.message }) + // Expired, revoked, malformed and unverifiable are indistinguishable from + // the outside by design — the client only learns that the token is bad. + throw new AuthenticationError('invalid', 'Invalid or expired token') + } + } + + private assertUsable (extra: Record | undefined): void { + if (extra?.guest === 'true') { + throw new AuthenticationError('guest', 'Guest tokens cannot be used against the MCP server') + } + if (extra?.readonly === 'true' && extra?.admin === 'true') { + throw new AuthenticationError('forbidden', 'Conflicting token flags') + } + } + + private async resolveWorkspace (rawToken: string, token: SessionIdentity['token']): Promise { + let loginInfo: LoginInfoByToken + try { + loginInfo = await this.accountClient(rawToken).getLoginInfoByToken() + } catch (err) { + this.ctx.warn('mcp account lookup failed', { error: (err as Error)?.message }) + throw new AuthenticationError('invalid', 'Invalid or revoked token') + } + + if (!isWorkspaceLoginInfo(loginInfo)) { + throw new AuthenticationError('invalid', 'Token is not bound to a workspace') + } + + if (loginInfo.workspace !== token.workspace) { + throw new AuthenticationError('forbidden', 'Token workspace mismatch') + } + + return { + account: token.account, + workspace: token.workspace, + token, + workspaceToken: loginInfo.token, + transactorUrl: toTransactorHttpUrl(loginInfo.endpoint), + readOnly: token.extra?.readonly === 'true' + } + } + + private sweepCache (): void { + if (this.cache.size <= 1024) return + const now = this.now() + for (const [key, entry] of this.cache) { + if (entry.expiresOn <= now) this.cache.delete(key) + } + } +} diff --git a/pods/mcp/src/auth/token-extractor.ts b/pods/mcp/src/auth/token-extractor.ts new file mode 100644 index 0000000000..a754f9bcfa --- /dev/null +++ b/pods/mcp/src/auth/token-extractor.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: EPL-2.0 + +const BEARER = 'bearer ' + +/** + * Reads the raw bearer credential out of a set of request headers. + * + * Takes headers rather than a Request so it stays a pure function of its input + * and is testable without constructing an Express object. + * + * `decodeToken` is deliberately not used here: it swallows the reason a token + * failed, and the HTTP layer needs to tell "no credential" (401 plus + * `WWW-Authenticate`) apart from "bad credential" (401) so clients can react. + */ +export function extractBearerToken (headers: { authorization?: string | string[] }): string | undefined { + const header = headers.authorization + if (typeof header !== 'string') return undefined + + const value = header.trim() + if (value.toLowerCase().startsWith(BEARER)) { + const token = value.slice(BEARER.length).trim() + return token.length > 0 ? token : undefined + } + + return undefined +} diff --git a/pods/mcp/src/config.ts b/pods/mcp/src/config.ts new file mode 100644 index 0000000000..b96594ff70 --- /dev/null +++ b/pods/mcp/src/config.ts @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { config as dotenv } from 'dotenv' + +dotenv() + +/** + * How the server decides who the caller is. + * + * - `configured`: a self-hosted deployment. Credentials come from the pod's own + * environment (HULY_TOKEN, or HULY_EMAIL + HULY_PASSWORD) and every session + * runs as that one account. The MCP client needs no Huly credential at all, + * which is what makes this usable from Claude Desktop or a phone. + * - `perRequest`: the MCP client presents its own Huly API token on every + * request. Used when several teams share one MCP endpoint and each must act as + * itself. + */ +export type AuthMode = 'configured' | 'perRequest' + +export interface Config { + Port: number + Host: string + Secret: string + ServiceID: string + /** Account service URL; the public Huly base URL in a self-hosted install. */ + AccountsUrl: string + AuthMode: AuthMode + /** Static API token for `configured` mode. */ + HulyToken: string + /** Static login for `configured` mode when no API token is supplied. */ + HulyEmail: string + HulyPassword: string + /** Restrict `configured` mode to one workspace, by id or url slug. */ + HulyWorkspace: string + /** Collaborator service URL; enables writing rich-text descriptions. */ + CollaboratorUrl: string + /** Lets a development stack keep the platform-wide default secret. Never enable in production. */ + AllowDefaultSecret: boolean + /** Drop write tools and refuse all mutations. */ + ReadOnly: boolean + /** Identifiers callers may present when `AuthMode` is `perRequest`. */ + AllowedTokens: string[] + /** + * Browser origins allowed to call the endpoint, for CORS and Origin checks. + * + * Empty means "no browser origin is allowed": non-browser clients never send + * `Origin`, so they are unaffected, while a web page is refused outright. + */ + AllowedOrigins: string[] + /** + * How many proxies sit in front of this pod. + * + * Express only trusts `X-Forwarded-For` when this is set. Leaving it unset + * makes every proxied request look like it came from the proxy, which collapses + * all clients into one rate-limit bucket. + */ + TrustProxy: number + SessionIdleTtlMs: number + ClientCacheTtlMs: number + LoginCacheTtlMs: number + RequestRateLimit: number + RequestRateWindowMs: number + MaxBodyBytes: number + /** + * Serve `/api/v1/statistics` (CPU/memory/metrics payload). + * + * Off by default: the endpoint answers without any credential, so shipping + * it enabled would hand system figures to anyone who knows the path. An + * operator who wants to scrape it opts in with MCP_STATS=true. + */ + EnableStats: boolean +} + +const int = (value: string | undefined, fallback: number): number => { + if (value === undefined || value === '') return fallback + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) { + throw Error(`Expected an integer but got "${value}"`) + } + return parsed +} + +const bool = (value: string | undefined, fallback: boolean): boolean => { + if (value === undefined || value === '') return fallback + return value === 'true' || value === '1' +} + +const list = (value: string | undefined): string[] => + (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + +const DEFAULT_ACCOUNTS_URL = 'http://huly.local:3000' + +/** + * True when `HOST` addresses only this machine. + * + * Loopback is the one situation where an unauthenticated `configured` endpoint is + * acceptable: nothing outside the host can reach it, so there is no remote caller + * to impersonate the configured account. + */ +export function isLoopbackHost (host: string): boolean { + const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, '') + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' +} + +/** + * Resolves the auth mode from the environment. + * + * Credentials win over `perRequest` when both are present: a self-hoster who + * pasted a token into compose clearly wants the simple single-tenant setup, and + * silently ignoring it in favour of per-request tokens would produce an endpoint + * that rejects every client. + */ +export function resolveAuthMode (env: NodeJS.ProcessEnv): AuthMode { + const hasStaticCredentials = + (env.HULY_TOKEN ?? '') !== '' || ((env.HULY_EMAIL ?? '') !== '' && (env.HULY_PASSWORD ?? '') !== '') + return hasStaticCredentials ? 'configured' : 'perRequest' +} + +function buildConfig (env: NodeJS.ProcessEnv): Config { + const authMode = resolveAuthMode(env) + + if (authMode === 'configured' && (env.HULY_TOKEN ?? '') === '' && (env.HULY_PASSWORD ?? '') === '') { + throw Error('Auth mode is "configured" but neither HULY_TOKEN nor HULY_PASSWORD is set') + } + + return { + Port: int(env.PORT, 4090), + Host: env.HOST ?? '0.0.0.0', + Secret: env.SECRET ?? '', + ServiceID: env.SERVICE_ID ?? 'mcp', + AccountsUrl: env.ACCOUNTS_URL ?? DEFAULT_ACCOUNTS_URL, + AuthMode: authMode, + HulyToken: env.HULY_TOKEN ?? '', + HulyEmail: env.HULY_EMAIL ?? '', + HulyPassword: env.HULY_PASSWORD ?? '', + HulyWorkspace: env.HULY_WORKSPACE ?? '', + CollaboratorUrl: env.COLLABORATOR_URL ?? '', + AllowDefaultSecret: bool(env.MCP_ALLOW_DEFAULT_SECRET, false), + ReadOnly: bool(env.MCP_READONLY, false), + AllowedTokens: list(env.MCP_ALLOWED_TOKENS), + AllowedOrigins: list(env.MCP_ALLOWED_ORIGINS), + TrustProxy: int(env.TRUST_PROXY, 0), + SessionIdleTtlMs: int(env.MCP_SESSION_TTL_MS, 30 * 60_000), + ClientCacheTtlMs: int(env.MCP_CLIENT_CACHE_TTL_MS, 10 * 60_000), + LoginCacheTtlMs: int(env.MCP_LOGIN_CACHE_TTL_MS, 60_000), + RequestRateLimit: int(env.MCP_RATE_LIMIT, 300), + RequestRateWindowMs: int(env.MCP_RATE_WINDOW_MS, 60_000), + MaxBodyBytes: int(env.MCP_MAX_BODY_BYTES, 1024 * 1024), + EnableStats: bool(env.MCP_STATS, false) + } +} + +/** + * Fails fast on configuration that would otherwise only break at first use. + * + * `SECRET` is the single most dangerous omission: without it every Huly token + * verifies against the literal string "secret" (see `server-token`), so a + * misconfigured deployment would accept forged tokens. Refusing to start is the + * only safe response. + */ +function validate (config: Config): Config { + if (config.Secret === '') { + throw Error('SECRET must be set; refusing to start without it') + } + if (config.Secret === 'secret' && !config.AllowDefaultSecret) { + throw Error( + 'SECRET is the well-known default; refusing to start. Use the platform secret, ' + + 'or set MCP_ALLOW_DEFAULT_SECRET=true for a throwaway development stack only' + ) + } + if (config.Port < 1 || config.Port > 65535) { + throw Error(`PORT is out of range: ${config.Port}`) + } + // `configured` mode authenticates nobody: the caller is whatever account the + // pod was configured with. Reachable from anywhere and open to anyone, that + // is an unauthenticated handle on a real account, so a shared secret is + // required unless the listener is loopback-only. AllowlistedAuthenticator + // checks this token before the configured authenticator runs, so + // MCP_ALLOWED_TOKENS doubles as that secret. + if (config.AuthMode === 'configured' && config.AllowedTokens.length === 0 && !isLoopbackHost(config.Host)) { + throw Error( + 'Auth mode is "configured" with no MCP_ALLOWED_TOKENS and HOST is not loopback. ' + + 'Anyone who can reach this port would act as the configured Huly account. ' + + 'Set MCP_ALLOWED_TOKENS, or bind HOST to 127.0.0.1 for a local-only server.' + ) + } + if (config.TrustProxy < 0) { + throw Error(`TRUST_PROXY must not be negative: ${config.TrustProxy}`) + } + return config +} + +/** + * Reads and validates configuration. + * + * Deliberately a function rather than a module-level constant: importing this + * file must not require environment variables, or every unit test that touches + * a helper here would fail at import time. `index.ts` calls it once at boot, + * which is where a misconfiguration should stop the process. + */ +export function loadConfig (env: NodeJS.ProcessEnv = process.env): Config { + return validate(buildConfig(env)) +} diff --git a/pods/mcp/src/error.ts b/pods/mcp/src/error.ts new file mode 100644 index 0000000000..bb7721f560 --- /dev/null +++ b/pods/mcp/src/error.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: EPL-2.0 + +export class HttpError extends Error { + readonly status: number + + constructor (status: number, message: string) { + super(message) + this.name = 'HttpError' + this.status = status + } +} diff --git a/pods/mcp/src/index.ts b/pods/mcp/src/index.ts new file mode 100644 index 0000000000..aa7e6ba657 --- /dev/null +++ b/pods/mcp/src/index.ts @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { Analytics } from '@hcengineering/analytics' +import { configureAnalytics, createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service' +import { newMetrics } from '@hcengineering/core' +import { setMetadata } from '@hcengineering/platform' +import { initStatisticsContext } from '@hcengineering/server-core' +import serverToken from '@hcengineering/server-token' +import { join } from 'node:path' + +import { createAuthenticator } from './auth/authenticator-factory' +import { loadConfig } from './config' +import { RateLimiter } from './middleware/rate-limiter' +import { createAccountApi } from './platform/account-api' +import { createCollaboratorWriter } from './platform/collaborator-writer' +import { CachingWorkspaceClientProvider } from './platform/workspace-client-provider' +import { createServer, listen } from './server' +import { buildRegistry } from './tools/register' + +/** How often idle sessions and rate-limit buckets are reclaimed. */ +const SWEEP_INTERVAL_MS = 60_000 + +async function main (): Promise { + // Boot-time config: a missing SECRET or an unusable credential combination + // must stop the process here, not on the first request. + const config = loadConfig() + const application = config.ServiceID + + // Both of these must be set before any token is touched: without the secret, + // server-token falls back to the literal string "secret" and would accept + // forged tokens. loadConfig already refuses to boot on a default secret. + setMetadata(serverToken.metadata.Secret, config.Secret) + setMetadata(serverToken.metadata.Service, application) + + configureAnalytics(application, process.env.VERSION ?? '0.7.0') + Analytics.setTag('application', application) + + const ctx = initStatisticsContext(application, { + factory: () => + createOpenTelemetryMetricsContext( + application, + {}, + {}, + newMetrics(), + new SplitLogger(application, { + root: join(process.cwd(), 'logs'), + enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' + }) + ) + }) + + const registry = buildRegistry() + const authenticator = createAuthenticator(ctx, config) + const clients = new CachingWorkspaceClientProvider({ + ctx, + idleTtlMs: config.ClientCacheTtlMs, + createAccounts: createAccountApi(config.AccountsUrl), + createMarkupWriter: createCollaboratorWriter(config.CollaboratorUrl) + }) + const limiter = new RateLimiter(ctx, config.RequestRateLimit, config.RequestRateWindowMs) + + const { app, sessions, transport } = createServer({ + ctx, + config, + registry, + clients, + authenticator, + limiter + }) + + // Unref'd so the sweeper never keeps the process alive by itself. + const sweeper = setInterval(() => { + sessions.sweep() + limiter.sweep() + }, SWEEP_INTERVAL_MS) + sweeper.unref() + + const server = listen(app, config.Port, config.Host) + + ctx.info('mcp server ready', { + port: config.Port, + authMode: config.AuthMode, + readOnly: config.ReadOnly, + tools: registry.size, + accountsUrl: config.AccountsUrl + }) + + let shuttingDown = false + const shutdown = async (signal: string): Promise => { + if (shuttingDown) return + shuttingDown = true + + ctx.info('mcp server shutting down', { signal }) + clearInterval(sweeper) + + transport.closeAll() + sessions.closeAll() + await new Promise((resolve) => { + server.close(() => { + resolve() + }) + }) + // Close the server sockets outright; an open SSE stream would otherwise + // keep `server.close` pending until the client disconnects. + server.closeAllConnections?.() + await clients.close() + + ctx.info('mcp shutdown complete') + process.exit(0) + } + + process.on('SIGINT', () => { + void shutdown('SIGINT') + }) + process.on('SIGTERM', () => { + void shutdown('SIGTERM') + }) + process.on('uncaughtException', (error: Error) => { + ctx.error('mcp uncaught exception', { error: error?.message, stack: error?.stack }) + }) + process.on('unhandledRejection', (reason: unknown) => { + ctx.error('mcp unhandled rejection', { error: String(reason) }) + }) +} + +void main().catch((err) => { + // The logger may not exist yet if the failure happened during bootstrap. + console.error('Failed to start the Huly MCP server', err) + process.exit(1) +}) diff --git a/pods/mcp/src/mcp/__tests__/dispatcher.test.ts b/pods/mcp/src/mcp/__tests__/dispatcher.test.ts new file mode 100644 index 0000000000..3cf9e7f1ed --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/dispatcher.test.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { fakeIdentity, fakeMeasureContext, fakeWorkspaceSession } from '../../__tests__/test-doubles' +import { McpDispatcher } from '../dispatcher' +import { McpSession } from '../session' +import { ToolRegistry } from '../tool' + +const ctx = fakeMeasureContext() + +function makeDispatcher (registry: ToolRegistry): McpDispatcher { + return new McpDispatcher({ + ctx, + registry, + serverName: 'huly-mcp', + serverVersion: '0.7.0', + instructions: 'hello', + resolveSession: async (session) => fakeWorkspaceSession(session.identity) + }) +} + +const makeSession = (): McpSession => new McpSession('sess-1', fakeIdentity(), 0) + +const request = (method: string, params?: unknown, id: number = 1): Record => { + const base: Record = { jsonrpc: '2.0', id, method } + return params === undefined ? base : { ...base, params } +} + +const registryWithEcho = (): ToolRegistry => + new ToolRegistry().register({ + name: 'echo', + title: 'Echo', + description: 'echoes', + readOnly: true, + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + handler: async (_c, args) => ({ content: [{ type: 'text', text: String(args.value) }] }) + }) + +/** Initializes a session so subsequent calls are allowed. */ +const initialized = async (dispatcher: McpDispatcher, session: McpSession): Promise => { + await dispatcher.dispatch(session, request('initialize')) +} + +describe('McpDispatcher', () => { + it('negotiates a supported protocol version', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch( + makeSession(), + request('initialize', { protocolVersion: '2025-03-26' }) + ) + expect(result).toMatchObject({ result: { protocolVersion: '2025-03-26' } }) + }) + + it('falls back to its own latest version for an unknown one', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch( + makeSession(), + request('initialize', { protocolVersion: '1999-01-01' }) + ) + expect(result).toMatchObject({ result: { protocolVersion: '2025-06-18' } }) + }) + + it('advertises tool capability and server info', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const result = await dispatcher.dispatch(makeSession(), request('initialize')) + const payload = (result as { result: Record }).result + expect(payload.capabilities).toEqual({ tools: { listChanged: false } }) + expect(payload.serverInfo).toEqual({ name: 'huly-mcp', version: '0.7.0' }) + expect(payload.instructions).toBe('hello') + }) + + it('refuses any method other than initialize before the handshake', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch(makeSession(), request('tools/list')) + expect(result).toMatchObject({ error: { code: -32600 } }) + }) + + it('allows tools/list once initialized', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + const result = await dispatcher.dispatch(session, request('tools/list')) + const tools = (result as { result: { tools: Array> } }).result.tools + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe('echo') + expect(tools[0].annotations).toMatchObject({ readOnlyHint: true }) + }) + + it('returns no response for a notification', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + const result = await dispatcher.dispatch(session, { jsonrpc: '2.0', method: 'notifications/initialized' }) + expect(result).toBeNull() + expect(session.initialized).toBe(true) + }) + + it('answers ping with an empty result', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + expect(await dispatcher.dispatch(session, request('ping'))).toEqual({ jsonrpc: '2.0', id: 1, result: {} }) + }) + + it('reports an unknown method as method-not-found', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + expect(await dispatcher.dispatch(session, request('nope/nope'))).toMatchObject({ error: { code: -32601 } }) + }) + + it('rejects a malformed envelope with a null id', async () => { + const result = await makeDispatcher(registryWithEcho()).dispatch(makeSession(), { hello: 'world' }) + expect(result).toMatchObject({ id: null, error: { code: -32600 } }) + }) + + it('requires a name for tools/call', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + expect(await dispatcher.dispatch(session, request('tools/call', {}))).toMatchObject({ + error: { code: -32602 } + }) + }) + + it('runs a tool and returns its content', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + const result = await dispatcher.dispatch( + session, + request('tools/call', { name: 'echo', arguments: { value: 'hi' } }) + ) + expect(result).toMatchObject({ result: { content: [{ type: 'text', text: 'hi' }] } }) + }) + + it('surfaces a tool failure as an isError result, not a protocol error', async () => { + const failing = new ToolRegistry().register({ + name: 'boom', + title: 'Boom', + description: 'always fails', + readOnly: true, + inputSchema: { type: 'object' }, + handler: async () => { + throw new Error('kaboom') + } + }) + const dispatcher = makeDispatcher(failing) + const session = makeSession() + await initialized(dispatcher, session) + + const result = (await dispatcher.dispatch(session, request('tools/call', { name: 'boom' }))) as { + error?: unknown + result: { isError: boolean, content: Array<{ text: string }> } + } + expect(result.error).toBeUndefined() + expect(result.result.isError).toBe(true) + expect(result.result.content[0].text).toContain('kaboom') + }) + + it('lists empty resources and prompts rather than erroring', async () => { + const dispatcher = makeDispatcher(registryWithEcho()) + const session = makeSession() + await initialized(dispatcher, session) + + expect(await dispatcher.dispatch(session, request('resources/list'))).toMatchObject({ + result: { resources: [] } + }) + expect(await dispatcher.dispatch(session, request('prompts/list'))).toMatchObject({ + result: { prompts: [] } + }) + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/protocol.test.ts b/pods/mcp/src/mcp/__tests__/protocol.test.ts new file mode 100644 index 0000000000..9176629467 --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/protocol.test.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { errorResponse, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcId, successResponse } from '../protocol' + +describe('isJsonRpcId', () => { + it('accepts strings and finite numbers only', () => { + expect(isJsonRpcId(1)).toBe(true) + expect(isJsonRpcId('abc')).toBe(true) + expect(isJsonRpcId(Number.NaN)).toBe(false) + expect(isJsonRpcId(null)).toBe(false) + expect(isJsonRpcId({})).toBe(false) + }) +}) + +describe('isJsonRpcRequest', () => { + it('accepts a well-formed request', () => { + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping' })).toBe(true) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 'a', method: 'ping', params: {} })).toBe(true) + }) + + it('rejects anything that is not a JSON-RPC 2.0 envelope', () => { + // Regression: an `&&`/`:?` precedence mistake once made every one of these + // validate, which let arbitrary objects through as requests. + expect(isJsonRpcRequest({ hello: 'world' })).toBe(false) + expect(isJsonRpcRequest({})).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '1.0', id: 1, method: 'ping' })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1 })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: '' })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 42 })).toBe(false) + }) + + it('requires an id, since a message without one is a notification', () => { + expect(isJsonRpcRequest({ jsonrpc: '2.0', method: 'ping' })).toBe(false) + }) + + it('rejects non-objects and arrays', () => { + expect(isJsonRpcRequest(null)).toBe(false) + expect(isJsonRpcRequest('ping')).toBe(false) + expect(isJsonRpcRequest(42)).toBe(false) + expect(isJsonRpcRequest([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).toBe(false) + }) + + it('rejects a non-structured params', () => { + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping', params: 'x' })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping', params: null })).toBe(false) + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping', params: [] })).toBe(false) + }) +}) + +describe('isJsonRpcNotification', () => { + it('accepts a method with no id', () => { + expect(isJsonRpcNotification({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBe(true) + }) + + it('rejects anything carrying an id', () => { + expect(isJsonRpcNotification({ jsonrpc: '2.0', id: 1, method: 'ping' })).toBe(false) + expect(isJsonRpcNotification({ jsonrpc: '2.0', id: null, method: 'ping' })).toBe(false) + }) + + it('rejects garbage', () => { + expect(isJsonRpcNotification({ hello: 'world' })).toBe(false) + expect(isJsonRpcNotification(null)).toBe(false) + expect(isJsonRpcNotification('notifications/initialized')).toBe(false) + }) +}) + +describe('response builders', () => { + it('builds a success envelope', () => { + expect(successResponse(7, { ok: true })).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } }) + }) + + it('builds an error envelope and omits absent data', () => { + expect(errorResponse(7, -32601, 'nope')).toEqual({ + jsonrpc: '2.0', + id: 7, + error: { code: -32601, message: 'nope' } + }) + expect(errorResponse(null, -32700, 'bad', { at: 1 }).error.data).toEqual({ at: 1 }) + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/readonly-write-gate.test.ts b/pods/mcp/src/mcp/__tests__/readonly-write-gate.test.ts new file mode 100644 index 0000000000..90ac9ced9a --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/readonly-write-gate.test.ts @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type TxOperations } from '@hcengineering/core' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { type SessionIdentity } from '../../auth/authenticator' +import { type AccountApi, type AccountApiFactory } from '../../platform/account-api' +import { CachingWorkspaceClientProvider, type ClientFactory } from '../../platform/workspace-client-provider' +import { McpDispatcher } from '../dispatcher' +import { type JsonRpcResponseMessage } from '../protocol' +import { McpSession } from '../session' +import { type HulyTool, ToolRegistry } from '../tool' + +const ctx = fakeMeasureContext() + +const request = (method: string, params?: unknown, id: number = 1): Record => { + const base: Record = { jsonrpc: '2.0', id, method } + return params === undefined ? base : { ...base, params } +} + +const resultOf = (response: JsonRpcResponseMessage | null): { isError?: boolean, content: Array<{ text: string }> } => { + const payload = response as { result: { isError?: boolean, content: Array<{ text: string }> } } + return payload.result +} + +interface Harness { + dispatcher: McpDispatcher + provider: CachingWorkspaceClientProvider + /** One entry per client build, in call order. */ + built: SessionIdentity[] + /** Every write that actually reached a tool handler. */ + writes: string[] +} + +const makeHarness = (): Harness => { + const built: SessionIdentity[] = [] + const writes: string[] = [] + + const createClient: ClientFactory = async (identity) => { + built.push(identity) + const client = { close: async () => {} } + return client as unknown as TxOperations + } + const createAccounts: AccountApiFactory = () => Object.create(null) as AccountApi + + const provider = new CachingWorkspaceClientProvider({ + ctx, + createClient, + createAccounts, + sweepIntervalMs: 600_000 + }) + + const echoTool: HulyTool = { + name: 'echo', + title: 'Echo', + description: 'echoes', + readOnly: true, + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + handler: async (_c, args) => ({ content: [{ type: 'text', text: String(args.value) }] }) + } + const writeTool: HulyTool = { + name: 'write', + title: 'Write', + description: 'writes', + readOnly: false, + inputSchema: { type: 'object' }, + handler: async () => { + writes.push('write') + return { content: [{ type: 'text', text: 'written' }] } + } + } + + const dispatcher = new McpDispatcher({ + ctx, + registry: new ToolRegistry().registerAll([echoTool, writeTool]), + serverName: 'huly-mcp', + serverVersion: '0.7.0', + // Mirrors server.ts: the workspace client comes from the shared cache, + // keyed by the identity the transport verified for this request. + resolveSession: async (session) => await provider.get(session.identity) + }) + + return { dispatcher, provider, built, writes } +} + +const initialize = async (dispatcher: McpDispatcher, session: McpSession): Promise => { + await dispatcher.dispatch(session, request('initialize')) +} + +describe('read-only token against a warm full-access cache', () => { + it('refuses the write and uses a separate cache entry instead of the full-access client', async () => { + const { dispatcher, provider, built, writes } = makeHarness() + + // Step 1: the user's full-access token fills the cache first. + const full = new McpSession('sess-full', fakeIdentity({ workspaceToken: 'full-token' }), 0) + await initialize(dispatcher, full) + const allowed = await dispatcher.dispatch(full, request('tools/call', { name: 'write' })) + expect(resultOf(allowed).content[0].text).toBe('written') + expect(writes).toHaveLength(1) + + // Step 2: a read-only token for the SAME account:workspace. Before the + // fix this reused the cached full-access session, so the gate saw + // readOnly === false and the write ran under the full-access token. + const readOnly = new McpSession( + 'sess-ro', + fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' }), + 0 + ) + await initialize(dispatcher, readOnly) + const refused = await dispatcher.dispatch(readOnly, request('tools/call', { name: 'write' })) + + expect(resultOf(refused).isError).toBe(true) + expect(resultOf(refused).content[0].text).toContain('read-only') + // The handler never ran: no write left the process for the read-only token. + expect(writes).toHaveLength(1) + // Two privilege classes, two cache entries, two clients. + expect(built.map((identity) => identity.readOnly)).toEqual([false, true]) + expect(provider.size).toBe(2) + + await provider.close() + }) + + it('shows each session only the tools its privilege class may run', async () => { + const { dispatcher, provider } = makeHarness() + const readOnly = new McpSession('sess-ro', fakeIdentity({ readOnly: true }), 0) + const full = new McpSession('sess-full', fakeIdentity(), 0) + await initialize(dispatcher, readOnly) + await initialize(dispatcher, full) + + const roList = await dispatcher.dispatch(readOnly, request('tools/list')) + const roTools = (roList as { result: { tools: Array<{ name: string }> } }).result.tools + expect(roTools.map((tool) => tool.name)).toEqual(['echo']) + + const fullList = await dispatcher.dispatch(full, request('tools/list')) + const fullTools = (fullList as { result: { tools: Array<{ name: string }> } }).result.tools + expect(fullTools.map((tool) => tool.name)).toEqual(['echo', 'write']) + + // Listing never resolves a workspace client, so the cache stays cold. + expect(provider.size).toBe(0) + + await provider.close() + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts new file mode 100644 index 0000000000..cdeedecbb7 --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/registry-and-session.test.ts @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { fakeIdentity, fakeMeasureContext, fakeToolContext, fakeWorkspaceSession } from '../../__tests__/test-doubles' +import { McpDispatcher } from '../dispatcher' +import { objectSchema } from '../schema' +import { SessionStore } from '../session' +import { toolContext, type HulyTool, ToolRegistry } from '../tool' + +const ctx = fakeMeasureContext() + +const echoTool: HulyTool = { + name: 'echo', + title: 'Echo', + description: 'echoes', + readOnly: true, + inputSchema: objectSchema({ value: { type: 'string' } }), + handler: async (_c, args) => ({ content: [{ type: 'text', text: String(args.value) }] }) +} + +const writeTool: HulyTool = { + name: 'write', + title: 'Write', + description: 'writes', + readOnly: false, + inputSchema: objectSchema({}), + handler: async () => ({ content: [{ type: 'text', text: 'written' }] }) +} + +describe('ToolRegistry', () => { + it('refuses a duplicate name rather than shadowing silently', () => { + const registry = new ToolRegistry().register(echoTool) + expect(() => registry.register({ ...echoTool })).toThrow(/already registered/) + expect(registry.size).toBe(1) + }) + + it('exposes MCP annotations', () => { + const listed = new ToolRegistry().registerAll([echoTool, writeTool]).list() + expect(listed[0].annotations).toEqual({ readOnlyHint: true, destructiveHint: false, idempotentHint: true }) + expect(listed[1].annotations?.readOnlyHint).toBe(false) + }) + + it('rejects an unknown tool with the list of valid ones', async () => { + const registry = new ToolRegistry().register(echoTool) + const result = await registry.call('nope', {}, fakeToolContext()) + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('echo') + }) + + it('rejects invalid arguments before the handler runs', async () => { + const registry = new ToolRegistry().register(echoTool) + const result = await registry.call('echo', { value: 42 }, fakeToolContext()) + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('must be of type string') + }) + + it('hides write tools from a read-only listing', () => { + const registry = new ToolRegistry().registerAll([echoTool, writeTool]) + + expect(registry.list({ readOnly: true }).map((tool) => tool.name)).toEqual([echoTool.name]) + expect(registry.list().map((tool) => tool.name)).toEqual([echoTool.name, writeTool.name]) + }) + + it('blocks a write tool for a read-only identity', async () => { + const registry = new ToolRegistry().registerAll([echoTool, writeTool]) + const identity = fakeIdentity({ readOnly: true }) + const result = await registry.call('write', {}, fakeToolContext(identity)) + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('read-only') + }) +}) + +describe('SessionStore', () => { + it('creates, finds and deletes a session', () => { + const store = new SessionStore({ ctx, generateId: () => 'fixed' }) + const created = store.create(fakeIdentity()) + expect(created.id).toBe('fixed') + expect(store.get('fixed')).toBe(created) + expect(store.delete('fixed')).toBe(true) + expect(store.get('fixed')).toBeUndefined() + expect(store.delete('fixed')).toBe(false) + }) + + it('generates unique ids by default', () => { + const store = new SessionStore({ ctx }) + const ids = new Set(Array.from({ length: 50 }, () => store.create(fakeIdentity()).id)) + expect(ids.size).toBe(50) + }) + + it('drops sessions idle beyond the TTL', () => { + let now = 0 + const store = new SessionStore({ ctx, idleTtlMs: 100, now: () => now }) + const session = store.create(fakeIdentity()) + + // 50ms later the session is still well inside the window. + now = 50 + expect(store.sweep()).toBe(0) + expect(store.get(session.id)).toBeDefined() + + // Reading it refreshed lastSeen to 50, so it survives until 50 + TTL. + now = 140 + expect(store.sweep()).toBe(0) + + now = 160 + expect(store.sweep()).toBe(1) + expect(store.size).toBe(0) + }) + + it('evicts the least recently used session when full', () => { + let now = 0 + const store = new SessionStore({ ctx, maxSessions: 2, now: () => now }) + const first = store.create(fakeIdentity()) + now = 1 + store.create(fakeIdentity()) + now = 2 + store.get(first.id) + now = 3 + store.create(fakeIdentity()) + + expect(store.get(first.id)).toBeDefined() + expect(store.size).toBe(2) + }) + + it('binds a session to one identity', () => { + const store = new SessionStore({ ctx }) + const session = store.create(fakeIdentity()) + expect(session.belongsTo(fakeIdentity())).toBe(true) + expect(session.belongsTo(fakeIdentity({ account: 'other' as never }))).toBe(false) + expect(session.belongsTo(fakeIdentity({ workspace: 'other' as never }))).toBe(false) + }) + + it('binds a session to one privilege class, not just account and workspace', () => { + const store = new SessionStore({ ctx }) + const readOnlySession = store.create(fakeIdentity({ readOnly: true })) + const fullSession = store.create(fakeIdentity({ readOnly: false })) + + // Same account and workspace, different read-only flag: letting the token + // through would authorize requests as the (more privileged) identity the + // session was initialized with. + expect(readOnlySession.belongsTo(fakeIdentity({ readOnly: true }))).toBe(true) + expect(readOnlySession.belongsTo(fakeIdentity({ readOnly: false }))).toBe(false) + expect(fullSession.belongsTo(fakeIdentity({ readOnly: true }))).toBe(false) + expect(fullSession.belongsTo(fakeIdentity({ readOnly: false }))).toBe(true) + }) +}) + +describe('toolContext', () => { + it('takes readOnly from the identity of this request, not from the cached session', () => { + const cachedFullAccess = fakeWorkspaceSession(fakeIdentity({ workspaceToken: 'full-token' })) + + const guarded = toolContext( + cachedFullAccess, + fakeMeasureContext(), + fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' }) + ) + expect(guarded.readOnly).toBe(true) + expect(guarded.identity.workspaceToken).toBe('read-only-token') + + // The opposite direction: a cached read-only session must not drag a + // full-access request down either — the passed identity always wins. + const cachedReadOnly = fakeWorkspaceSession(fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' })) + const allowed = toolContext(cachedReadOnly, fakeMeasureContext(), fakeIdentity({ workspaceToken: 'full-token' })) + expect(allowed.readOnly).toBe(false) + expect(allowed.identity.workspaceToken).toBe('full-token') + }) +}) + +describe('session and dispatcher integration', () => { + it('resolves the workspace client lazily, only when a tool is called', async () => { + const registry = new ToolRegistry().register(echoTool) + const store = new SessionStore({ ctx, generateId: () => 's1' }) + const session = store.create(fakeIdentity()) + + let resolved = 0 + const dispatcher = new McpDispatcher({ + ctx, + registry, + serverName: 'huly-mcp', + serverVersion: '0.7.0', + resolveSession: async (s) => { + resolved += 1 + return fakeWorkspaceSession(s.identity) + } + }) + + await dispatcher.dispatch(session, { jsonrpc: '2.0', id: 1, method: 'initialize' }) + await dispatcher.dispatch(session, { jsonrpc: '2.0', id: 2, method: 'tools/list' }) + expect(resolved).toBe(0) + + await dispatcher.dispatch(session, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'echo', arguments: { value: 'x' } } + }) + expect(resolved).toBe(1) + }) +}) diff --git a/pods/mcp/src/mcp/__tests__/validation.test.ts b/pods/mcp/src/mcp/__tests__/validation.test.ts new file mode 100644 index 0000000000..fb79fc5efe --- /dev/null +++ b/pods/mcp/src/mcp/__tests__/validation.test.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type JsonSchema, nullableStringProp, objectSchema } from '../schema' +import { type ValidationResult, validateArguments } from '../validation' + +const schema: JsonSchema = { + type: 'object', + additionalProperties: false, + properties: { + title: { type: 'string', minLength: 1, maxLength: 10 }, + count: { type: 'integer', minimum: 1, maximum: 5 }, + mode: { type: 'string', enum: ['a', 'b'] }, + flag: { type: 'boolean', default: false }, + tags: { type: 'array', items: { type: 'string' }, maxItems: 2 }, + nested: { type: 'object', properties: { deep: { type: 'number' } }, required: ['deep'] } + }, + required: ['title'] +} + +/** + * `ok` is compared explicitly rather than used as a bare condition. + * + * The repo's ESLint enables `strict-boolean-expressions`, and an explicit + * comparison also documents intent: these assertions care about *which* branch + * they took, not merely whether validation passed. + */ +const issuesOf = (result: ValidationResult): string[] => (result.ok ? [] : result.issues) + +const valueOf = (result: ValidationResult): Record => (result.ok ? result.value : {}) + +const isOk = (result: ValidationResult): boolean => result.ok + +describe('validateArguments', () => { + it('accepts a minimal valid payload', () => { + expect(isOk(validateArguments(schema, { title: 'ok' }))).toBe(true) + }) + + it('reports every problem at once so the model can fix them in one round trip', () => { + const issues = issuesOf(validateArguments(schema, { count: 99, mode: 'z' })) + expect(issues).toHaveLength(3) + expect(issues.join(' ')).toContain('title is required') + expect(issues.join(' ')).toContain('count') + expect(issues.join(' ')).toContain('mode') + }) + + it('rejects unknown properties when additionalProperties is false', () => { + const issues = issuesOf(validateArguments(schema, { title: 'ok', sneaky: 1 })) + expect(issues[0]).toContain('sneaky is not an accepted property') + }) + + it('applies declared defaults', () => { + expect(valueOf(validateArguments(schema, { title: 'ok' })).flag).toBe(false) + }) + + it('treats undefined as absent rather than invalid', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: undefined }))).toBe(true) + }) + + it('treats a whole-number float as an integer', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: 3.0 }))).toBe(true) + }) + + it('rejects a fractional value for an integer field', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: 3.5 }))).toBe(false) + }) + + it('enforces string length bounds', () => { + expect(isOk(validateArguments(schema, { title: '' }))).toBe(false) + expect(isOk(validateArguments(schema, { title: 'far too long' }))).toBe(false) + }) + + it('validates array items and their length', () => { + expect(isOk(validateArguments(schema, { title: 'ok', tags: ['a'] }))).toBe(true) + expect(isOk(validateArguments(schema, { title: 'ok', tags: ['a', 'b', 'c'] }))).toBe(false) + expect(isOk(validateArguments(schema, { title: 'ok', tags: ['a', 5] }))).toBe(false) + }) + + it('validates nested objects and their required fields', () => { + expect(isOk(validateArguments(schema, { title: 'ok', nested: { deep: 1 } }))).toBe(true) + const issues = issuesOf(validateArguments(schema, { title: 'ok', nested: {} })) + expect(issues.join(' ')).toContain('nested.deep is required') + }) + + it('treats a null or absent body as an empty object', () => { + // `title` is required, so an empty object still fails — but only for that + // reason, not because the body itself was rejected. + expect(issuesOf(validateArguments(schema, null))).toEqual(['title is required']) + expect(isOk(validateArguments(objectSchema({}), null))).toBe(true) + expect(isOk(validateArguments(objectSchema({}), undefined))).toBe(true) + }) + + it('rejects a body that is not an object', () => { + expect(isOk(validateArguments(schema, []))).toBe(false) + expect(isOk(validateArguments(schema, 'nope'))).toBe(false) + expect(isOk(validateArguments(schema, 7))).toBe(false) + }) + + it('checks numeric bounds', () => { + expect(isOk(validateArguments(schema, { title: 'ok', count: 1 }))).toBe(true) + expect(isOk(validateArguments(schema, { title: 'ok', count: 5 }))).toBe(true) + expect(isOk(validateArguments(schema, { title: 'ok', count: 0 }))).toBe(false) + expect(isOk(validateArguments(schema, { title: 'ok', count: 6 }))).toBe(false) + }) + + describe('nullable types', () => { + const nullable = objectSchema({ due: nullableStringProp('Due date, or null to clear.') }) + + it('accepts null and a string for a nullable field', () => { + expect(isOk(validateArguments(nullable, { due: null }))).toBe(true) + expect(isOk(validateArguments(nullable, { due: '2026-12-31' }))).toBe(true) + }) + + it('still rejects any other type and names both allowed types', () => { + const issues = issuesOf(validateArguments(nullable, { due: 5 })) + expect(issues).toEqual(['due must be of type string or null (received integer)']) + }) + + it('keeps null invalid for a plain string field', () => { + const plain = objectSchema({ name: { type: 'string' } }) + expect(isOk(validateArguments(plain, { name: null }))).toBe(false) + }) + }) +}) diff --git a/pods/mcp/src/mcp/dispatcher.ts b/pods/mcp/src/mcp/dispatcher.ts new file mode 100644 index 0000000000..7ef2a34aa5 --- /dev/null +++ b/pods/mcp/src/mcp/dispatcher.ts @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' + +import { type WorkspaceSession } from '../platform/workspace-client-provider' + +import { + ErrorCode, + isJsonRpcNotification, + isJsonRpcRequest, + LATEST_PROTOCOL_VERSION, + RpcError, + SUPPORTED_PROTOCOL_VERSIONS, + type JsonRpcRequest, + type JsonRpcResponseMessage, + successResponse +} from './protocol' +import { type McpSession } from './session' +import { toolContext, type ToolRegistry } from './tool' + +export interface McpDispatcherOptions { + ctx: MeasureContext + registry: ToolRegistry + serverName: string + serverVersion: string + /** Free-form guidance shown to the model after `initialize`. */ + instructions?: string + /** + * Resolves the workspace client for the session. Injected rather than + * constructed so the dispatcher stays free of platform dependencies and can be + * tested with a fake. + * + * By the time this runs, the transport has matched `session.identity` + * against the current request's token — account, workspace and read-only + * flag — so implementations may trust it as THIS request's identity. + */ + resolveSession: (session: McpSession) => Promise +} + +interface InitializeResult { + protocolVersion: string + capabilities: Record + serverInfo: { name: string, version: string } + instructions?: string +} + +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 + +/** + * Routes MCP JSON-RPC methods to handlers. + * + * Knows nothing about HTTP, SSE or sessions-on-the-wire: it takes a message and + * a session and returns a response (or `null` for notifications). That keeps the + * protocol rules unit-testable without a socket, and lets the transport layer + * change without touching any MCP semantics. + */ +export class McpDispatcher { + private readonly ctx: MeasureContext + private readonly registry: ToolRegistry + private readonly serverName: string + private readonly serverVersion: string + private readonly instructions: string | undefined + private readonly resolveSession: McpDispatcherOptions['resolveSession'] + + constructor (options: McpDispatcherOptions) { + this.ctx = options.ctx + this.registry = options.registry + this.serverName = options.serverName + this.serverVersion = options.serverVersion + this.instructions = options.instructions + this.resolveSession = options.resolveSession + } + + /** + * @returns a response, or `null` when the message was a notification (which + * by JSON-RPC rules must not be answered). + */ + async dispatch (session: McpSession, message: unknown): Promise { + if (isJsonRpcNotification(message)) { + await this.handleNotification(session, message.method) + return null + } + + if (!isJsonRpcRequest(message)) { + return { + jsonrpc: '2.0', + id: null, + error: { code: ErrorCode.InvalidRequest, message: 'Not a valid JSON-RPC 2.0 request' } + } + } + + const request = message + + // Guard every method except initialize: a client that skips the handshake + // would otherwise be able to call tools with no negotiated protocol version. + if (request.method !== 'initialize' && session.protocolVersion === undefined) { + return this.fail(request.id, ErrorCode.InvalidRequest, 'Session is not initialized') + } + + try { + const result = await this.handleRequest(session, request) + return successResponse(request.id, result) + } catch (err) { + if (err instanceof RpcError) { + return { jsonrpc: '2.0', id: request.id, error: { code: err.code, message: err.message } } + } + const error = err as Error + this.ctx.error('mcp dispatch failed', { method: request.method, error: error?.message }) + return { + jsonrpc: '2.0', + id: request.id, + error: { code: ErrorCode.InternalError, message: 'Internal server error' } + } + } + } + + private async handleNotification (session: McpSession, method: string): Promise { + if (method === 'notifications/initialized' || method === 'initialized') { + session.initialized = true + this.ctx.info('mcp session initialized', { session: session.id }) + } + } + + private async handleRequest (session: McpSession, request: JsonRpcRequest): Promise { + switch (request.method) { + case 'initialize': + return this.initialize(session, request.params) + case 'ping': + return {} + case 'tools/list': + // The transport checks `session.identity` against THIS request's + // token on every call — including the read-only flag, see + // `McpSession.belongsTo` — so this is the current request's privilege + // class, not a stale one from `initialize`. + return { tools: this.registry.list({ readOnly: session.identity.readOnly }) } + case 'tools/call': + return await this.callTool(session, request.params) + case 'resources/list': + return { resources: [] } + case 'prompts/list': + return { prompts: [] } + default: + throw new RpcError(ErrorCode.MethodNotFound, `Method not found: ${request.method}`) + } + } + + private initialize (session: McpSession, params: unknown): InitializeResult { + const requested = isNonEmptyString((params as { protocolVersion?: unknown })?.protocolVersion) + ? (params as { protocolVersion: string }).protocolVersion + : undefined + + // Per spec: echo the client's version when we support it, otherwise answer + // with our own latest and let the client decide whether to continue. + const protocolVersion = + requested !== undefined && (SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(requested) + ? requested + : LATEST_PROTOCOL_VERSION + + session.protocolVersion = protocolVersion + + return { + protocolVersion, + capabilities: { + tools: { listChanged: false } + }, + serverInfo: { name: this.serverName, version: this.serverVersion }, + ...(this.instructions === undefined ? {} : { instructions: this.instructions }) + } + } + + private async callTool (session: McpSession, params: unknown): Promise { + const args = params as { name?: unknown, arguments?: unknown } | undefined + if (!isNonEmptyString(args?.name)) { + throw new RpcError(ErrorCode.InvalidParams, 'tools/call requires a "name" parameter') + } + + const workspaceSession = await this.resolveSession(session) + // Pass the identity authenticated for this request explicitly: the write + // gate must not read `readOnly` from whatever identity sits in the shared + // client cache, because a more privileged request may have filled it first. + const context = toolContext( + workspaceSession, + this.ctx.newChild(args.name, {}, { span: false }), + session.identity + ) + + return await this.registry.call(args.name, args.arguments, context) + } + + private fail (id: JsonRpcRequest['id'], code: number, message: string): JsonRpcResponseMessage { + return { jsonrpc: '2.0', id, error: { code, message } } + } +} diff --git a/pods/mcp/src/mcp/protocol.ts b/pods/mcp/src/mcp/protocol.ts new file mode 100644 index 0000000000..4a8b0212ab --- /dev/null +++ b/pods/mcp/src/mcp/protocol.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: EPL-2.0 + +// JSON-RPC 2.0 + MCP message shapes. +// +// The MCP server side of the wire format is small and stable, so it is +// implemented here directly instead of pulling a protocol SDK (and its +// transitive zod dependency) into the monorepo. Everything below is pure data +// plus guards: no I/O, no framework coupling, trivially unit tested. + +export const JSONRPC_VERSION = '2.0' as const + +/** + * Protocol revisions this server understands, newest first. `initialize` picks + * the client's revision when it is listed here, otherwise we answer with + * LATEST_PROTOCOL_VERSION and let the client decide whether to continue. + */ +export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26'] as const +export const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0] + +export type JsonRpcId = string | number + +export interface JsonRpcRequest { + jsonrpc: typeof JSONRPC_VERSION + id: JsonRpcId + method: string + params?: unknown +} + +export interface JsonRpcNotification { + jsonrpc: typeof JSONRPC_VERSION + method: string + params?: unknown +} + +export interface JsonRpcErrorObject { + code: number + message: string + data?: unknown +} + +export interface JsonRpcResponse { + jsonrpc: typeof JSONRPC_VERSION + id: JsonRpcId + result: unknown +} + +export interface JsonRpcErrorResponse { + jsonrpc: typeof JSONRPC_VERSION + id: JsonRpcId | null + error: JsonRpcErrorObject +} + +export type JsonRpcResponseMessage = JsonRpcResponse | JsonRpcErrorResponse + +/** Standard JSON-RPC 2.0 error codes plus the MCP/HTTP-relevant additions. */ +export const ErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603 +} as const + +/** + * A JSON-RPC level failure. Anything thrown that is *not* a RpcError is + * reported to the client as InternalError, so internal messages never leak. + */ +export class RpcError extends Error { + readonly code: number + readonly data: unknown + + constructor (code: number, message: string, data?: unknown) { + super(message) + this.name = 'RpcError' + this.code = code + this.data = data + } +} + +export function isJsonRpcId (value: unknown): value is JsonRpcId { + return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)) +} + +export function isJsonRpcRequest (value: unknown): value is JsonRpcRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const candidate = value as Record + + if (candidate.jsonrpc !== JSONRPC_VERSION) return false + if (typeof candidate.method !== 'string' || candidate.method.length === 0) return false + if (!isJsonRpcId(candidate.id)) return false + + // `params` is optional; when present it must be a structured value. + if (candidate.params !== undefined) { + return typeof candidate.params === 'object' && candidate.params !== null && !Array.isArray(candidate.params) + } + return true +} + +export function isJsonRpcNotification (value: unknown): value is JsonRpcNotification { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const candidate = value as Record + + if (candidate.jsonrpc !== JSONRPC_VERSION) return false + if (typeof candidate.method !== 'string' || candidate.method.length === 0) return false + // The absence of `id` is what makes it a notification rather than a request. + return candidate.id === undefined +} + +export function successResponse (id: JsonRpcId, result: unknown): JsonRpcResponse { + return { jsonrpc: JSONRPC_VERSION, id, result } +} + +export function errorResponse ( + id: JsonRpcId | null, + code: number, + message: string, + data?: unknown +): JsonRpcErrorResponse { + return { jsonrpc: JSONRPC_VERSION, id, error: { code, message, ...(data === undefined ? {} : { data }) } } +} + +/* -------------------------------------------------------------------------- */ +/* MCP content blocks */ +/* -------------------------------------------------------------------------- */ + +export interface McpTextContent { + type: 'text' + text: string +} + +export type McpContentBlock = McpTextContent + +export interface McpToolCallResult { + content: McpContentBlock[] + isError?: boolean + structuredContent?: Record +} + +export function textResult (text: string, structuredContent?: Record): McpToolCallResult { + return { + content: [{ type: 'text', text }], + ...(structuredContent === undefined ? {} : { structuredContent }) + } +} + +export function errorResult (message: string): McpToolCallResult { + return { content: [{ type: 'text', text: message }], isError: true } +} + +export function isMcpTextContent (value: unknown): value is McpTextContent { + return ( + typeof value === 'object' && + value !== null && + (value as Record).type === 'text' && + typeof (value as Record).text === 'string' + ) +} diff --git a/pods/mcp/src/mcp/schema.ts b/pods/mcp/src/mcp/schema.ts new file mode 100644 index 0000000000..329aaf8c4f --- /dev/null +++ b/pods/mcp/src/mcp/schema.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: EPL-2.0 + +// The subset of JSON Schema that MCP tool `inputSchema` values use. +// +// This is intentionally a closed subset: tool authors get a small surface with +// good error messages instead of a general purpose JSON Schema engine. The +// validator in ./validation.ts and the types in this file must stay in sync — +// anything not listed here is rejected by validation instead of silently +// ignored, which is what keeps tools and validators from drifting apart. + +export type JsonSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null' + +export interface JsonSchema { + /** A single type, or a list when the value may be one of several (e.g. `['string', 'null']`). */ + type?: JsonSchemaType | JsonSchemaType[] + description?: string + title?: string + + properties?: Record + required?: string[] + additionalProperties?: boolean + + items?: JsonSchema + + enum?: Array + + default?: unknown + + minimum?: number + maximum?: number + minLength?: number + maxLength?: number + minItems?: number + maxItems?: number + pattern?: string + + format?: string +} + +export type ToolArguments = Record + +/** Shape returned to clients by `tools/list`. */ +export interface McpToolDescriptor { + name: string + title: string + description: string + inputSchema: JsonSchema + annotations?: { + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + } +} + +/** Small helpers so tool definitions read declaratively and stay DRY. */ +export const objectSchema = (properties: Record, required: string[] = []): JsonSchema => ({ + type: 'object', + properties, + required, + additionalProperties: false +}) + +export const stringProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: 'string', + description, + ...extra +}) + +export const numberProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: 'number', + description, + ...extra +}) + +export const booleanProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: 'boolean', + description, + ...extra +}) + +/** A string that may also be `null`, for fields where `null` means "clear this value". */ +export const nullableStringProp = (description: string, extra: Partial = {}): JsonSchema => ({ + type: ['string', 'null'], + description, + ...extra +}) diff --git a/pods/mcp/src/mcp/session.ts b/pods/mcp/src/mcp/session.ts new file mode 100644 index 0000000000..e10e07d291 --- /dev/null +++ b/pods/mcp/src/mcp/session.ts @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' +import { randomUUID as cryptoRandomUUID } from 'node:crypto' + +import { type SessionIdentity } from '../auth/authenticator' + +/** + * One MCP client conversation. + * + * The identity is resolved at `initialize` and never changes afterwards. A + * request carrying a different identity than the session's is rejected by the + * transport, so a session id cannot be replayed under another account. The + * read-only flag is part of that match too (see `belongsTo`), which is what + * lets `session.identity.readOnly` be trusted as the CURRENT request's + * privilege class on every request the transport lets through. + */ +export class McpSession { + readonly id: string + readonly identity: SessionIdentity + readonly createdOn: number + protocolVersion: string | undefined + initialized = false + lastSeen: number + + constructor (id: string, identity: SessionIdentity, now: number) { + this.id = id + this.identity = identity + this.createdOn = now + this.lastSeen = now + } + + touch (now: number): void { + this.lastSeen = now + } + + /** + * Whether this request's identity may keep using this session. + * + * Account and workspace must match (a leaked session id is useless under + * another user), and so must the read-only flag: a session opened with a + * full-access token must never answer a read-only token, or its pinned + * `identity` — which authorization falls back to — would describe a MORE + * privileged token than the one presented right now. The reverse direction + * matters too: a full token must not ride a read-only session either, or + * `tools/list` would hide tools the caller may actually use. + */ + belongsTo (identity: SessionIdentity): boolean { + return ( + this.identity.account === identity.account && + this.identity.workspace === identity.workspace && + this.identity.readOnly === identity.readOnly + ) + } +} + +export interface SessionStoreOptions { + ctx: MeasureContext + /** Idle time after which a session is dropped, in milliseconds. */ + idleTtlMs?: number + /** Maximum concurrent sessions, to bound memory. */ + maxSessions?: number + now?: () => number + generateId?: () => string +} + +const DEFAULT_IDLE_TTL_MS = 30 * 60_000 +const DEFAULT_MAX_SESSIONS = 1000 + +/** + * In-memory session store. + * + * Deliberately not persisted: a restart drops all sessions, and clients recover + * by re-initializing. Session state here is a cache of an authenticated + * identity, not a source of truth, so there is nothing worth writing to disk. + */ +export class SessionStore { + private readonly ctx: MeasureContext + private readonly sessions = new Map() + private readonly idleTtlMs: number + private readonly maxSessions: number + private readonly now: () => number + private readonly generateId: () => string + + constructor (options: SessionStoreOptions) { + this.ctx = options.ctx + this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS + this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS + this.now = options.now ?? Date.now + this.generateId = options.generateId ?? (() => randomUUID()) + } + + create (identity: SessionIdentity): McpSession { + if (this.sessions.size >= this.maxSessions) { + // Evict the least recently used rather than refuse service: an agent + // reconnecting after a network blip should not be locked out. + this.evictOldest() + } + + const session = new McpSession(this.generateId(), identity, this.now()) + this.sessions.set(session.id, session) + this.ctx.info('mcp session opened', { session: session.id, workspace: identity.workspace }) + return session + } + + get (id: string): McpSession | undefined { + const session = this.sessions.get(id) + if (session === undefined) return undefined + session.touch(this.now()) + return session + } + + delete (id: string): boolean { + const session = this.sessions.get(id) + if (session === undefined) return false + this.sessions.delete(id) + this.ctx.info('mcp session closed', { session: id }) + return true + } + + /** Drops idle sessions. Returns how many were removed. */ + sweep (): number { + const cutoff = this.now() - this.idleTtlMs + let removed = 0 + for (const [id, session] of [...this.sessions]) { + if (session.lastSeen <= cutoff) { + this.sessions.delete(id) + removed += 1 + } + } + return removed + } + + private evictOldest (): void { + let oldest: McpSession | undefined + for (const session of this.sessions.values()) { + if (oldest === undefined || session.lastSeen < oldest.lastSeen) oldest = session + } + if (oldest !== undefined) this.sessions.delete(oldest.id) + } + + get size (): number { + return this.sessions.size + } + + closeAll (): void { + this.sessions.clear() + } +} + +/** + * Session ids are bearer-equivalent: whoever holds one inherits the session's + * authenticated identity, so they must be unguessable. `crypto.randomUUID` is + * the right primitive here, not a hand-rolled Math.random. + */ +function randomUUID (): string { + return cryptoRandomUUID() +} diff --git a/pods/mcp/src/mcp/streamable-http.ts b/pods/mcp/src/mcp/streamable-http.ts new file mode 100644 index 0000000000..e61ea74912 --- /dev/null +++ b/pods/mcp/src/mcp/streamable-http.ts @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' +import { type Request, type Response } from 'express' + +import { AuthenticationError, type Authenticator, type SessionIdentity } from '../auth/authenticator' +import { extractBearerToken } from '../auth/token-extractor' +import { type McpDispatcher } from './dispatcher' +import { ErrorCode, isJsonRpcRequest } from './protocol' +import { type McpSession, type SessionStore } from './session' + +export const SESSION_HEADER = 'mcp-session-id' +export const PROTOCOL_VERSION_HEADER = 'mcp-protocol-version' + +/** Content types a client must be willing to accept on a POST. */ +const ACCEPTABLE = ['application/json', 'text/event-stream'] as const + +export interface StreamableHttpOptions { + ctx: MeasureContext + store: SessionStore + dispatcher: McpDispatcher + authenticator: Authenticator + /** Interval between SSE keepalive comments, in milliseconds. */ + keepAliveIntervalMs?: number +} + +const DEFAULT_KEEPALIVE_MS = 25_000 + +/** + * The MCP "Streamable HTTP" transport. + * + * One endpoint, three verbs: + * POST — client to server JSON-RPC (initialize, tools/call, ...) + * GET — server to client SSE stream (keepalive today, notifications later) + * DELETE — terminate the session + * + * The transport owns authentication, session binding and the HTTP status codes; + * all MCP semantics live in the dispatcher. + */ +export class StreamableHttpTransport { + private readonly ctx: MeasureContext + private readonly store: SessionStore + private readonly dispatcher: McpDispatcher + private readonly authenticator: Authenticator + private readonly keepAliveIntervalMs: number + private readonly streams = new Set() + + constructor (options: StreamableHttpOptions) { + this.ctx = options.ctx + this.store = options.store + this.dispatcher = options.dispatcher + this.authenticator = options.authenticator + this.keepAliveIntervalMs = options.keepAliveIntervalMs ?? DEFAULT_KEEPALIVE_MS + } + + handlePost = async (req: Request, res: Response): Promise => { + if (!this.acceptsSupportedContent(req)) { + res.status(406).json({ + jsonrpc: '2.0', + id: null, + error: { + code: ErrorCode.InvalidRequest, + message: `Not Acceptable: clients must accept ${ACCEPTABLE.join(' or ')}` + } + }) + return + } + + let identity: SessionIdentity + try { + identity = await this.authenticate(req) + } catch (err) { + this.sendAuthError(res, err) + return + } + + const body: unknown = req.body + const isInitialize = isJsonRpcRequest(body) && body.method === 'initialize' + + let session: McpSession | undefined + const sessionId = headerValue(req, SESSION_HEADER) + + if (sessionId !== undefined) { + session = this.store.get(sessionId) + if (session === undefined) { + // A stale id after a server restart is normal. Reporting 404 (rather + // than silently creating a new session) makes the client re-initialize + // instead of continuing against a session it believes is authenticated. + res.status(404).json({ error: 'Session not found or expired, re-initialize' }) + return + } + if (!session.belongsTo(identity)) { + this.ctx.warn('mcp session identity mismatch', { session: session.id }) + res.status(403).json({ error: 'Session was created with different credentials; re-initialize' }) + return + } + } else if (!isInitialize) { + res.status(400).json({ error: `Missing ${SESSION_HEADER} header` }) + return + } + + if (isInitialize && session === undefined) { + session = this.store.create(identity) + res.setHeader(SESSION_HEADER, session.id) + } + + const active = session as McpSession + const response = await this.dispatcher.dispatch(active, body) + + if (response === null) { + // Notification acknowledged, no body — JSON-RPC forbids responding. + res.status(202).end() + return + } + + res.status(200).json(response) + } + + handleGet = async (req: Request, res: Response): Promise => { + const sessionId = headerValue(req, SESSION_HEADER) + if (sessionId === undefined) { + res.status(400).json({ error: `Missing ${SESSION_HEADER} header` }) + return + } + + if (!(await this.authorizeSession(req, res, sessionId))) return + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + // Nginx buffers SSE by default, which stalls server-initiated messages. + 'X-Accel-Buffering': 'no' + }) + res.flushHeaders?.() + + this.streams.add(res) + const keepAlive = setInterval(() => { + // An SSE comment keeps proxies and clients from reaping an idle stream. + res.write(': keepalive\n\n') + }, this.keepAliveIntervalMs) + keepAlive.unref?.() + + const cleanup = (): void => { + clearInterval(keepAlive) + this.streams.delete(res) + } + req.on('close', cleanup) + res.on('close', cleanup) + } + + handleDelete = async (req: Request, res: Response): Promise => { + const sessionId = headerValue(req, SESSION_HEADER) + if (sessionId === undefined) { + res.status(400).json({ error: `Missing ${SESSION_HEADER} header` }) + return + } + + if (!(await this.authorizeSession(req, res, sessionId))) return + + this.store.delete(sessionId) + res.status(204).end() + } + + /** + * Authenticates and confirms the session belongs to the caller. + * + * Shared by GET and DELETE so a session id can never be used by, or ended by, + * a different account. + * + * @returns true when the request may proceed; otherwise the response has + * already been written. + */ + private async authorizeSession (req: Request, res: Response, sessionId: string): Promise { + let identity: SessionIdentity + try { + identity = await this.authenticate(req) + } catch (err) { + this.sendAuthError(res, err) + return false + } + + const session = this.store.get(sessionId) + if (session === undefined) { + res.status(404).json({ error: 'Session not found or expired' }) + return false + } + if (!session.belongsTo(identity)) { + this.ctx.warn('mcp session identity mismatch', { session: session.id }) + res.status(403).json({ error: 'Session was created with different credentials; re-initialize' }) + return false + } + + return true + } + + private async authenticate (req: Request): Promise { + const rawToken = extractBearerToken(req.headers) + if (rawToken === undefined) { + // In `configured` mode the authenticator ignores the token entirely, so a + // missing header is only fatal for per-request auth. The concrete + // authenticators decide; this only guarantees a string to hand them. + return await this.authenticator.authenticate('') + } + return await this.authenticator.authenticate(rawToken) + } + + private sendAuthError (res: Response, err: unknown): void { + const reason = err instanceof AuthenticationError ? err.reason : 'invalid' + const message = err instanceof Error ? err.message : 'Unauthorized' + + if (reason === 'missing') { + res.setHeader('WWW-Authenticate', 'Bearer realm="huly-mcp"') + } + + this.ctx.warn('mcp request unauthorized', { reason, message }) + res.status(401).json({ error: message }) + } + + private acceptsSupportedContent (req: Request): boolean { + const accept = req.headers.accept + // An absent Accept header means "anything", which is legal HTTP. + if (accept === undefined) return true + return ACCEPTABLE.some((type) => accept.includes(type)) + } + + /** Closes every open SSE stream; used on shutdown. */ + closeAll (): void { + for (const res of this.streams) { + try { + res.end() + } catch { + // Already gone. + } + } + this.streams.clear() + } +} + +function headerValue (req: Request, name: string): string | undefined { + const value = req.headers[name] + if (Array.isArray(value)) return value[0] + if (typeof value === 'string' && value.length > 0) return value + return undefined +} diff --git a/pods/mcp/src/mcp/tool.ts b/pods/mcp/src/mcp/tool.ts new file mode 100644 index 0000000000..03b488e667 --- /dev/null +++ b/pods/mcp/src/mcp/tool.ts @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type AccountUuid, type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' +import { type WorkspaceSession } from '../platform/workspace-client-provider' +import { errorResult, type McpToolCallResult } from './protocol' +import { type JsonSchema, type McpToolDescriptor, type ToolArguments } from './schema' +import { validateArguments } from './validation' + +/** Everything a tool handler is allowed to touch. */ +export interface ToolContext extends WorkspaceSession { + /** Per-request child context, so tool spans are attributed correctly. */ + ctx: MeasureContext + account: AccountUuid + workspace: WorkspaceUuid + /** Read-only flag of THIS request's identity — the write gate reads it, not the cached one. */ + readOnly: boolean +} + +export interface HulyTool { + name: string + title: string + description: string + /** + * Read-only tools are the default. A tool must opt in to writing by setting + * this to false, which also makes it unavailable to read-only tokens. + */ + readOnly: boolean + /** True when a call can destroy data; surfaced as the MCP destructive hint. */ + destructive?: boolean + inputSchema: JsonSchema + handler: (ctx: ToolContext, args: ToolArguments) => Promise +} + +/** + * Builds the context for one tool call. + * + * `identity` must be the identity authenticated for the CURRENT request — it + * is the source of the `readOnly` flag that `ToolRegistry.call` uses to refuse + * writes. It must never be pulled from the shared client cache: a full-access + * request could have populated that cache first, and the write would then run + * under the wrong token. + * + * The parameter defaults to `session.identity` for older call sites. That + * fallback cannot cross privilege classes in practice, because the provider + * keys its cache by the read-only flag too, but production code (the + * dispatcher) passes the identity explicitly so the authorization source is + * visible at the call site. + */ +export function toolContext ( + session: WorkspaceSession, + ctx: MeasureContext, + identity: SessionIdentity = session.identity +): ToolContext { + return { + ...session, + ctx, + // The whole context describes THIS request: identity first, then the + // fields derived from it, so nothing can be read off the shared cache. + identity, + account: identity.account, + workspace: identity.workspace, + readOnly: identity.readOnly + } +} + +/** + * Registry of MCP tools. + * + * Registration fails fast on duplicates: a silently shadowed tool is the kind + * of bug that only shows up as "the agent called the wrong thing". + */ +export class ToolRegistry { + private readonly tools = new Map() + + register (tool: HulyTool): this { + if (this.tools.has(tool.name)) { + throw new Error(`Tool ${tool.name} is already registered`) + } + this.tools.set(tool.name, tool) + return this + } + + registerAll (tools: HulyTool[]): this { + tools.forEach((tool) => this.register(tool)) + return this + } + + get (name: string): HulyTool | undefined { + return this.tools.get(name) + } + + has (name: string): boolean { + return this.tools.has(name) + } + + get size (): number { + return this.tools.size + } + + /** Lists tools; a read-only caller is not shown tools it could never run. */ + list (options: { readOnly?: boolean } = {}): McpToolDescriptor[] { + const visible = [...this.tools.values()].filter((tool) => options.readOnly !== true || tool.readOnly) + return visible.map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: { + readOnlyHint: tool.readOnly, + destructiveHint: tool.destructive ?? false, + idempotentHint: tool.readOnly + } + })) + } + + /** + * Validates and runs a tool. + * + * Returns (rather than throws) for the three failures that are the agent's + * fault and are worth retrying with different arguments: unknown tool, bad + * arguments, and a write attempt from a read-only token. Reporting those as + * `isError` results lets the model self-correct; the transport stays a + * successful JSON-RPC call. + */ + async call (name: string, rawArgs: unknown, context: ToolContext): Promise { + const tool = this.tools.get(name) + if (tool === undefined) { + const available = [...this.tools.keys()].join(', ') + return errorResult(`Unknown tool "${name}". Available tools: ${available}`) + } + + if (!tool.readOnly && context.readOnly) { + return errorResult(`Tool "${name}" modifies data and this token is read-only.`) + } + + const validation = validateArguments(tool.inputSchema, rawArgs) + if (!validation.ok) { + return errorResult(`Invalid arguments for "${name}": ${validation.issues.join('; ')}`) + } + + return await context.ctx.with(tool.name, { tool: tool.name, readOnly: tool.readOnly }, async (ctx) => { + try { + return await tool.handler({ ...context, ctx }, validation.value) + } catch (err) { + // Surfaced as a tool error, never as an HTTP 500: the agent needs the + // reason in order to try a different approach. + const message = err instanceof Error ? err.message : String(err) + ctx.error('mcp tool failed', { tool: tool.name, error: message }) + return errorResult(`Tool "${name}" failed: ${message}`) + } + }) + } +} diff --git a/pods/mcp/src/mcp/validation.ts b/pods/mcp/src/mcp/validation.ts new file mode 100644 index 0000000000..6aa13a6d5c --- /dev/null +++ b/pods/mcp/src/mcp/validation.ts @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type JsonSchema, type JsonSchemaType, type ToolArguments } from './schema' + +// Minimal JSON Schema validator for the closed subset declared in ./schema.ts. +// +// Tool arguments arrive from a language model, so validation is a trust +// boundary: it is the only thing standing between a hallucinated argument and +// a malformed Huly query. It therefore fails fast and reports every issue at +// once, so an agent can correct the whole call in one round trip. + +export type ValidationResult = { ok: true, value: ToolArguments } | { ok: false, issues: string[] } + +const MAX_PATTERN_LENGTH = 512 +const patternCache = new Map() + +function isPlainObject (value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function typeOf (value: unknown): JsonSchemaType { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number' + if (typeof value === 'string') return 'string' + if (typeof value === 'boolean') return 'boolean' + // bigint, symbol, function and undefined can all arrive from a JS caller + // even though JSON cannot carry them; treat them all as a type mismatch. + return 'object' +} + +function matchesType (value: unknown, type: JsonSchemaType): boolean { + const actual = typeOf(value) + // A JSON `1.0` is a valid `integer` per JSON Schema. + if (type === 'number') return actual === 'number' || actual === 'integer' + if (type === 'integer') return actual === 'integer' + return actual === type +} + +function compilePattern (pattern: string): RegExp | undefined { + const cached = patternCache.get(pattern) + if (cached !== undefined) return cached + if (pattern.length > MAX_PATTERN_LENGTH) return undefined + try { + const compiled = new RegExp(pattern) + patternCache.set(pattern, compiled) + return compiled + } catch { + // An uncompilable pattern is a bug in the tool definition, not in the + // arguments. Fail fast at call time rather than rejecting every request. + throw new Error(`Tool schema declares an invalid pattern: ${pattern}`) + } +} + +/** Validates a single value against a schema node, collecting issues. */ +function validateValue (value: unknown, schema: JsonSchema, path: string, issues: string[]): void { + if (schema.type !== undefined) { + const allowed = Array.isArray(schema.type) ? schema.type : [schema.type] + if (!allowed.some((type) => matchesType(value, type))) { + issues.push(`${path} must be of type ${allowed.join(' or ')} (received ${typeOf(value)})`) + return + } + } + + if (schema.enum !== undefined) { + if (!schema.enum.some((allowed) => allowed === value)) { + issues.push(`${path} must be one of: ${schema.enum.map((entry) => JSON.stringify(entry)).join(', ')}`) + } + } + + if (typeof value === 'string') { + if (schema.minLength !== undefined && value.length < schema.minLength) { + issues.push(`${path} must be at least ${schema.minLength} characters long`) + } + if (schema.maxLength !== undefined && value.length > schema.maxLength) { + issues.push(`${path} must be at most ${schema.maxLength} characters long`) + } + if (schema.pattern !== undefined) { + const regex = compilePattern(schema.pattern) + if (regex !== undefined && !regex.test(value)) { + issues.push(`${path} must match the pattern ${schema.pattern}`) + } + } + } + + if (typeof value === 'number') { + if (schema.minimum !== undefined && value < schema.minimum) { + issues.push(`${path} must be greater than or equal to ${schema.minimum}`) + } + if (schema.maximum !== undefined && value > schema.maximum) { + issues.push(`${path} must be less than or equal to ${schema.maximum}`) + } + } + + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) { + issues.push(`${path} must contain at least ${schema.minItems} items`) + } + if (schema.maxItems !== undefined && value.length > schema.maxItems) { + issues.push(`${path} must contain at most ${schema.maxItems} items`) + } + if (schema.items !== undefined) { + value.forEach((entry, index) => { + validateValue(entry, schema.items as JsonSchema, `${path}[${index}]`, issues) + }) + } + } + + if (isPlainObject(value) && schema.properties !== undefined) { + validateObject(value, schema, path, issues) + } +} + +function validateObject (value: Record, schema: JsonSchema, path: string, issues: string[]): void { + const properties = schema.properties ?? {} + const required = schema.required ?? [] + + for (const key of required) { + if (value[key] === undefined) { + issues.push(`${path === '' ? key : `${path}.${key}`} is required`) + } + } + + for (const [key, entry] of Object.entries(value)) { + const childPath = path === '' ? key : `${path}.${key}` + const propertySchema = properties[key] + if (propertySchema === undefined) { + if (schema.additionalProperties === false) { + issues.push(`${childPath} is not an accepted property. Accepted: ${Object.keys(properties).join(', ')}`) + } + continue + } + if (entry === undefined) continue + validateValue(entry, propertySchema, childPath, issues) + } +} + +/** + * Validates untyped tool arguments against the tool's input schema and applies + * declared defaults, so handlers can rely on required properties being present. + */ +export function validateArguments (schema: JsonSchema, args: unknown): ValidationResult { + const issues: string[] = [] + const input = args ?? {} + + if (!isPlainObject(input)) { + return { ok: false, issues: ['arguments must be an object'] } + } + + const properties = schema.properties ?? {} + const required = schema.required ?? [] + + for (const key of required) { + if (input[key] === undefined) { + issues.push(`${key} is required`) + } + } + + for (const [key, entry] of Object.entries(input)) { + const propertySchema = properties[key] + if (propertySchema === undefined) { + if (schema.additionalProperties === false) { + issues.push(`${key} is not an accepted property. Accepted: ${Object.keys(properties).join(', ')}`) + } + continue + } + if (entry === undefined) continue + validateValue(entry, propertySchema, key, issues) + } + + if (issues.length > 0) { + return { ok: false, issues } + } + + const value: ToolArguments = { ...input } + for (const [key, propertySchema] of Object.entries(properties)) { + if (value[key] === undefined && propertySchema.default !== undefined) { + value[key] = propertySchema.default + } + } + + return { ok: true, value } +} diff --git a/pods/mcp/src/middleware/__tests__/endpoints.test.ts b/pods/mcp/src/middleware/__tests__/endpoints.test.ts new file mode 100644 index 0000000000..f73e981132 --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/endpoints.test.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' +import { type Express } from 'express' +import http from 'node:http' + +import { fakeEnv } from '../../__tests__/test-doubles' +import { type Authenticator } from '../../auth/authenticator' +import { loadConfig } from '../../config' +import { ToolRegistry } from '../../mcp/tool' +import { type WorkspaceClientProvider } from '../../platform/workspace-client-provider' +import { createServer } from '../../server' +import { RateLimiter } from '../rate-limiter' + +interface Reply { + status: number + body: string +} + +/** + * A MeasureContext stub for the HTTP layer. + * + * `newChild` backs the request logger, and `metrics` backs the statistics + * handler — both are read while the server is assembled, so a stub without + * them would crash before the first request rather than fail an assertion. + */ +function fakeCtx (): MeasureContext { + const ctx: Record = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + newChild: () => ctx, + metrics: { measurements: {}, value: 0 } + } + return ctx as unknown as MeasureContext +} + +/** + * Builds the real app from `createServer`, the same wiring production uses. + * + * Only the pieces the tested routes touch are real; the client provider and + * authenticator are empty stubs because no request here reaches the MCP + * transport where they would be called. + */ +function testApp (env: Record = {}): Express { + const config = loadConfig(fakeEnv({ SECRET: 'a-real-one', ...env })) + const ctx = fakeCtx() + const { app } = createServer({ + ctx, + config, + registry: new ToolRegistry(), + clients: Object.create(null) as WorkspaceClientProvider, + authenticator: Object.create(null) as Authenticator, + limiter: new RateLimiter(ctx, config.RequestRateLimit, config.RequestRateWindowMs) + }) + return app +} + +/** + * Sends one GET against a throwaway listener, with no credentials of any kind. + * + * node:http rather than fetch, matching origin-guard.test.ts: the test controls + * every header verbatim, and closing the listener in the callback keeps jest + * from hanging on a kept-alive socket. + */ +function call (app: Express, path: string): Promise { + return new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => { + const done = (fn: () => void): void => { + server.closeAllConnections() + server.close() + fn() + } + const address = server.address() + if (address === null || typeof address === 'string') { + done(() => { + reject(new Error('test server did not bind a TCP port')) + }) + return + } + + const req = http.request( + { host: '127.0.0.1', port: address.port, path, method: 'GET', agent: false }, + (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { + body += chunk + }) + res.on('end', () => { + done(() => { + resolve({ status: res.statusCode ?? 0, body }) + }) + }) + } + ) + req.on('error', (err) => { + done(() => { + reject(err) + }) + }) + req.end() + }) + server.on('error', reject) + }) +} + +describe('unauthenticated endpoints', () => { + it('answers /api/v1/health with 200 and no credential, so container probes keep working', async () => { + const reply = await call(testApp(), '/api/v1/health') + expect(reply.status).toBe(200) + expect(JSON.parse(reply.body).status).toBe('ok') + }) + + it('exposes only probe-safe fields on /api/v1/health — no authMode, readOnly or sessions', async () => { + const reply = await call(testApp(), '/api/v1/health') + const body: unknown = JSON.parse(reply.body) + // The exact key set matters more than individual omissions: any future + // field added here has to be a conscious decision, not an oversight. + expect(Object.keys(body as Record).sort()).toEqual(['status', 'tools', 'version']) + expect(body).not.toHaveProperty('authMode') + }) + + it('answers 404 identical to an unknown path while MCP_STATS is unset (the default)', async () => { + const reply = await call(testApp(), '/api/v1/statistics') + expect(reply.status).toBe(404) + // Same body the catch-all produces, so probing reveals nothing about + // whether the route exists. + expect(JSON.parse(reply.body)).toEqual({ error: 'Not Found' }) + }) + + it('serves statistics once MCP_STATS=true opts in', async () => { + const reply = await call(testApp({ MCP_STATS: 'true' }), '/api/v1/statistics') + expect(reply.status).toBe(200) + const body = JSON.parse(reply.body) + expect(body.statistics).toHaveProperty('cpu') + expect(body.statistics).toHaveProperty('memory') + expect(body).toHaveProperty('metrics') + }) + + it('keeps the auth mode off the / landing page while it still points at the MCP endpoint', async () => { + // fakeEnv carries no HULY_TOKEN, so the mode in play is `perRequest`; the + // page must not name it (nor the `Auth mode:` line at all). + const reply = await call(testApp(), '/') + expect(reply.status).toBe(200) + expect(reply.body).not.toMatch(/auth mode/i) + expect(reply.body).not.toContain('perRequest') + expect(reply.body).toContain('POST /mcp') + }) +}) diff --git a/pods/mcp/src/middleware/__tests__/error-handler.test.ts b/pods/mcp/src/middleware/__tests__/error-handler.test.ts new file mode 100644 index 0000000000..b81f93c510 --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/error-handler.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type NextFunction, type Request, type Response } from 'express' + +import { fakeMeasureContext } from '../../__tests__/test-doubles' +import { HttpError } from '../../error' +import { errorHandler } from '../index' + +interface Captured { + status: number + body: unknown +} + +function run (err: unknown): Captured { + const captured: Captured = { status: 0, body: undefined } + const res = { + headersSent: false, + status (code: number) { + captured.status = code + return this + }, + json (body: unknown) { + captured.body = body + return this + } + } + const req: Pick = { path: '/mcp' } + errorHandler(fakeMeasureContext())(err, req as Request, res as unknown as Response, (() => {}) as NextFunction) + return captured +} + +describe('errorHandler', () => { + it('maps HttpError to its own status', () => { + expect(run(new HttpError(403, 'nope')).status).toBe(403) + }) + + it('reports malformed JSON as 400', () => { + expect(run(Object.assign(new SyntaxError('bad'), { type: 'entity.parse.failed' })).status).toBe(400) + }) + + it('reports an oversized body as 413, not 500', () => { + const err = Object.assign(new Error('too large'), { type: 'entity.too.large', status: 413, expose: true }) + expect(run(err).status).toBe(413) + }) + + it('passes through other exposed client errors', () => { + const err = Object.assign(new Error('unsupported'), { status: 415, expose: true }) + expect(run(err).status).toBe(415) + }) + + it('hides unexpected failures behind a 500', () => { + const captured = run(new Error('db password is hunter2')) + expect(captured.status).toBe(500) + expect(JSON.stringify(captured.body)).not.toContain('hunter2') + }) +}) diff --git a/pods/mcp/src/middleware/__tests__/origin-guard.test.ts b/pods/mcp/src/middleware/__tests__/origin-guard.test.ts new file mode 100644 index 0000000000..bb7a89b72e --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/origin-guard.test.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: EPL-2.0 + +import cors from 'cors' +import express, { type Express } from 'express' +import http from 'node:http' + +import { originGuard } from '../index' + +const ALLOWED_ORIGIN = 'http://localhost:5173' +const STRANGER_ORIGIN = 'https://evil.example.com' + +interface Reply { + status: number + headers: http.IncomingHttpHeaders + body: string +} + +interface CallOptions { + method?: string + origin?: string + /** Sends the browser preflight headers (OPTIONS + Access-Control-*). */ + preflight?: boolean +} + +/** + * Rebuilds the middleware order from server.ts: the guard runs before cors, + * so the two only ever interact the way production wires them. + */ +function testApp (allowed: string[]): Express { + const app = express() + app.use(originGuard(allowed)) + app.use( + cors({ + origin: allowed, + maxAge: 86400, + exposedHeaders: ['X-RateLimit-Remaining'], + allowedHeaders: ['Content-Type'] + }) + ) + app.post('/mcp', (_req, res) => { + res.status(200).json({ ok: true }) + }) + return app +} + +/** + * Sends one request against a throwaway listener. + * + * node:http rather than fetch so the test controls every header verbatim: + * fetch implementations may rewrite or refuse headers such as `Origin`, and + * its keep-alive sockets would keep the listener open past the assertion. + */ +function call (app: Express, options: CallOptions = {}): Promise { + return new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => { + const done = (fn: () => void): void => { + server.closeAllConnections() + server.close() + fn() + } + const address = server.address() + if (address === null || typeof address === 'string') { + done(() => { + reject(new Error('test server did not bind a TCP port')) + }) + return + } + + const headers: Record = {} + if (options.origin !== undefined) headers.origin = options.origin + if (options.preflight === true) { + headers['Access-Control-Request-Method'] = 'POST' + headers['Access-Control-Request-Headers'] = 'content-type' + } + + const req = http.request( + { host: '127.0.0.1', port: address.port, path: '/mcp', method: options.method ?? 'POST', headers, agent: false }, + (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { + body += chunk + }) + res.on('end', () => { + done(() => { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body }) + }) + }) + } + ) + req.on('error', (err) => { + done(() => { + reject(err) + }) + }) + req.end() + }) + server.on('error', reject) + }) +} + +describe('originGuard', () => { + it('allows requests with no Origin header, so non-browser MCP clients keep working', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN])) + expect(reply.status).toBe(200) + expect(reply.body).toContain('"ok"') + }) + + it('allows an origin from the allowlist and reflects it in the CORS header', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { origin: ALLOWED_ORIGIN }) + expect(reply.status).toBe(200) + expect(reply.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + }) + + it('rejects an origin that is not on the allowlist with 403 and no CORS header', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { origin: STRANGER_ORIGIN }) + expect(reply.status).toBe(403) + expect(JSON.parse(reply.body)).toEqual({ error: 'Origin not allowed' }) + expect(reply.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('fails closed when the allowlist is empty: any Origin is refused', async () => { + const reply = await call(testApp([]), { origin: ALLOWED_ORIGIN }) + expect(reply.status).toBe(403) + expect(JSON.parse(reply.body)).toEqual({ error: 'Origin not allowed' }) + }) + + it('still serves non-browser clients when the allowlist is empty', async () => { + const reply = await call(testApp([])) + expect(reply.status).toBe(200) + }) + + it('answers preflight with 204 for an allowed origin', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { + method: 'OPTIONS', + origin: ALLOWED_ORIGIN, + preflight: true + }) + expect(reply.status).toBe(204) + expect(reply.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(reply.headers['access-control-max-age']).toBe('86400') + }) + + it('refuses preflight from a disallowed origin before cors can answer it', async () => { + const reply = await call(testApp([ALLOWED_ORIGIN]), { + method: 'OPTIONS', + origin: STRANGER_ORIGIN, + preflight: true + }) + expect(reply.status).toBe(403) + expect(JSON.parse(reply.body)).toEqual({ error: 'Origin not allowed' }) + expect(reply.headers['access-control-allow-origin']).toBeUndefined() + }) +}) diff --git a/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts b/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts new file mode 100644 index 0000000000..d02a587d63 --- /dev/null +++ b/pods/mcp/src/middleware/__tests__/rate-limiter.test.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { fakeMeasureContext } from '../../__tests__/test-doubles' +import { RateLimiter } from '../rate-limiter' + +const ctx = fakeMeasureContext() + +describe('RateLimiter', () => { + it('allows requests up to the limit and then refuses', () => { + const now = 0 + const limiter = new RateLimiter(ctx, 3, 1000, () => now) + + expect(limiter.check('a')).toBe(2) + expect(limiter.check('a')).toBe(1) + expect(limiter.check('a')).toBe(0) + expect(limiter.check('a')).toBe(-1) + }) + + it('keeps separate buckets per key', () => { + const limiter = new RateLimiter(ctx, 1, 1000, () => 0) + expect(limiter.check('a')).toBe(0) + expect(limiter.check('b')).toBe(0) + expect(limiter.check('a')).toBe(-1) + }) + + it('lets the window slide', () => { + let now = 0 + const limiter = new RateLimiter(ctx, 2, 1000, () => now) + + limiter.check('a') + limiter.check('a') + expect(limiter.check('a')).toBe(-1) + + now = 1001 + expect(limiter.check('a')).toBe(1) + }) + + it('reports how long to wait before retrying', () => { + let now = 0 + const limiter = new RateLimiter(ctx, 1, 1000, () => now) + limiter.check('a') + expect(limiter.retryAfterMs('a')).toBe(1000) + + now = 400 + expect(limiter.retryAfterMs('a')).toBe(600) + }) + + it('reclaims buckets on sweep', () => { + let now = 0 + const limiter = new RateLimiter(ctx, 5, 100, () => now) + limiter.check('a') + limiter.check('b') + expect(limiter.size).toBe(2) + + now = 200 + limiter.sweep() + expect(limiter.size).toBe(0) + }) +}) diff --git a/pods/mcp/src/middleware/index.ts b/pods/mcp/src/middleware/index.ts new file mode 100644 index 0000000000..3c873c629a --- /dev/null +++ b/pods/mcp/src/middleware/index.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { Analytics } from '@hcengineering/analytics' +import { type MeasureContext, metricsAggregate } from '@hcengineering/core' +import { getCPUInfo, getMemoryInfo } from '@hcengineering/server-core' +import { type ErrorRequestHandler, type NextFunction, type Request, type RequestHandler, type Response } from 'express' + +import { type Config } from '../config' +import { HttpError } from '../error' +import { type RateLimiter } from './rate-limiter' + +export const KEEP_ALIVE_TIMEOUT = 5 +export const KEEP_ALIVE_MAX = 1000 + +export const keepAlive = (options: { timeout: number, max: number }): RequestHandler => { + const { timeout, max } = options + return (req: Request, res: Response, next: NextFunction) => { + res.setHeader('Connection', 'keep-alive') + res.setHeader('Keep-Alive', `timeout=${timeout}, max=${max}`) + next() + } +} + +/** + * Logs one line per completed request. + * + * Built on `res.on('finish')` rather than copying the morgan + LogStream + * idiom the other pods use: that pattern needs a writable stream shim, and + * nothing here streams. The output format is the same. + */ +export const requestLogger = (ctx: MeasureContext): RequestHandler => { + const requests = ctx.newChild('requests', {}, { span: false }) + return (req: Request, res: Response, next: NextFunction) => { + const startedAt = Date.now() + res.on('finish', () => { + requests.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - startedAt}ms`) + }) + next() + } +} + +/** + * Rejects browser requests whose `Origin` is not on the allowlist. + * + * Why this exists: the MCP spec requires servers to validate `Origin` as a + * defence against DNS rebinding. Without the check, any web page the user + * happens to visit can resolve `http://localhost:4090`, send a request that + * carries the browser's implicit trust of localhost, and drive this server as + * if it were a same-origin local service. The preflight would even pass, + * because a permissive CORS setup answers "yes" to everyone. + * + * Requests without an `Origin` header are allowed through on purpose: + * non-browser clients (Claude Desktop, curl, MCP CLIs) never send it, and the + * header is the only signal a browser-originated call carries. An empty + * allowlist therefore means "no browser origin is accepted" (fail closed) + * rather than "accept everything" — desktop clients keep working either way. + */ +export const originGuard = (allowed: string[]): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + const origin = req.headers.origin + if (origin === undefined || origin === '') { + next() + return + } + // Exact string match only: a browser origin has no trailing slash and no + // wildcard meaning, so anything not explicitly listed is a stranger. + if (typeof origin !== 'string' || !allowed.includes(origin)) { + res.status(403).json({ error: 'Origin not allowed' }) + return + } + next() + } +} + +/** + * Per-caller rate limiting. + * + * The key comes from `resolveKey`, which reads the client address — the only + * thing available before authentication runs. The transactor's own limiter is + * coupled to a live Session, which a stateless MCP endpoint never has, so this + * is the only limit in front of the tools. + */ +export const rateLimit = (limiter: RateLimiter, resolveKey: (req: Request) => string): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + const key = resolveKey(req) + const remaining = limiter.check(key) + if (remaining < 0) { + const retryAfter = Math.ceil(limiter.retryAfterMs(key) / 1000) + res.setHeader('Retry-After', `${retryAfter}`) + res.status(429).json({ error: 'Rate limit exceeded', retryAfterSeconds: retryAfter }) + return + } + res.setHeader('X-RateLimit-Remaining', `${remaining}`) + next() + } +} + +/** + * Unauthenticated metrics endpoint — only mounted while `MCP_STATS=true`. + * + * It answers with process metrics plus CPU/memory figures, which is useful for + * a scraper but is still information disclosure to anyone who can reach the + * port, so it must be opted into rather than on by default. When disabled the + * handler passes the request along instead of answering itself: the catch-all + * then replies with the exact 404 every unknown path gets, so probing the URL + * reveals nothing about whether the route exists (a bespoke 403/404 body would + * confirm it does). + */ +export const statistics = (ctx: MeasureContext, config: Config): RequestHandler => { + return (req: Request, res: Response, next: NextFunction) => { + if (!config.EnableStats) { + next() + return + } + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'public, no-store, no-cache, must-revalidate, max-age=0') + res.status(200).json({ + metrics: metricsAggregate((ctx as unknown as { metrics: never }).metrics), + statistics: { cpu: getCPUInfo(), memory: getMemoryInfo() } + }) + } +} + +export const errorHandler = (ctx: MeasureContext): ErrorRequestHandler => { + return (err: any, req: Request, res: Response, _next: NextFunction) => { + if (res.headersSent) return + + if (err instanceof HttpError) { + ctx.warn('mcp http error', { status: err.status, message: err.message, path: req.path }) + res.status(err.status).json({ error: err.message }) + return + } + + // Express' JSON body parser raises this for malformed payloads. It is the + // client's mistake, not ours, and the raw body is not useful to echo back. + if (err?.type === 'entity.parse.failed' || err instanceof SyntaxError) { + res.status(400).json({ error: 'Request body is not valid JSON' }) + return + } + + // Body parser rejections (payload too large, unsupported charset, aborted + // request) carry a 4xx status and are safe to report as-is. + if (err?.type === 'entity.too.large') { + res.status(413).json({ error: 'Request body is too large' }) + return + } + const status = typeof err?.status === 'number' ? err.status : 0 + if (err?.expose === true && status >= 400 && status < 500) { + res.status(status).json({ error: 'Bad request' }) + return + } + + ctx.error('mcp unhandled error', { error: err?.message, path: req.path }) + Analytics.handleError(err) + res.status(500).json({ error: 'Internal Server Error' }) + } +} diff --git a/pods/mcp/src/middleware/rate-limiter.ts b/pods/mcp/src/middleware/rate-limiter.ts new file mode 100644 index 0000000000..5071856d27 --- /dev/null +++ b/pods/mcp/src/middleware/rate-limiter.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' + +/** + * Sliding-window rate limiter. + * + * Standalone pods get no rate limiting from the platform — the transactor's + * limiter is coupled to having a live Session — so an MCP endpoint that accepts + * internet traffic needs its own. + * + * Buckets are keyed by client address, taken from `req.ip`. That is a coarse + * key: it limits per address, not per account or per token, so everyone behind + * one NAT or proxy shares a bucket. The alternative (keying by account) would + * need authentication to run first, and an unauthenticated flood would then cost + * a login attempt per request before anything was rejected. + * + * Two consequences worth knowing when deploying: + * + * - Behind a reverse proxy `req.ip` is the proxy's address unless `TRUST_PROXY` + * is set, which would put the whole deployment in a single bucket. + * - A distributed denial of service from many addresses needs an edge limiter; + * this one only bounds a single noisy client. + */ +export class RateLimiter { + private readonly ctx: MeasureContext + private readonly max: number + private readonly windowMs: number + private readonly now: () => number + private readonly buckets = new Map() + + constructor (ctx: MeasureContext, max: number, windowMs: number, now: () => number = Date.now) { + this.ctx = ctx + this.max = max + this.windowMs = windowMs + this.now = now + } + + /** @returns the number of requests left in the window, or -1 when limited. */ + check (key: string): number { + const now = this.now() + const cutoff = now - this.windowMs + const hits = (this.buckets.get(key) ?? []).filter((at) => at > cutoff) + + if (hits.length >= this.max) { + this.buckets.set(key, hits) + this.ctx.warn('mcp rate limit exceeded', { key, limit: this.max, windowMs: this.windowMs }) + return -1 + } + + hits.push(now) + this.buckets.set(key, hits) + return this.max - hits.length + } + + /** Milliseconds until the oldest hit leaves the window. */ + retryAfterMs (key: string): number { + const hits = this.buckets.get(key) ?? [] + const oldest = hits[0] + if (oldest === undefined) return 0 + return Math.max(0, this.windowMs - (this.now() - oldest)) + } + + sweep (): void { + const cutoff = this.now() - this.windowMs + for (const [key, hits] of [...this.buckets]) { + const live = hits.filter((at) => at > cutoff) + if (live.length === 0) this.buckets.delete(key) + else this.buckets.set(key, live) + } + } + + get size (): number { + return this.buckets.size + } +} diff --git a/pods/mcp/src/platform/__tests__/markup.test.ts b/pods/mcp/src/platform/__tests__/markup.test.ts new file mode 100644 index 0000000000..594f01a3a9 --- /dev/null +++ b/pods/mcp/src/platform/__tests__/markup.test.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { fromMarkup, toMarkup } from '../markup' + +describe('markup conversion', () => { + it('round-trips Markdown through the stored rich-text format', () => { + const stored = toMarkup('# Title\n\nSome **bold** text\n\n- one\n- two') + + expect(stored).toContain('"type":"doc"') + const back = fromMarkup(stored) + expect(back).toContain('# Title') + expect(back).toContain('**bold**') + expect(back).toContain('one') + }) + + it('never returns ProseMirror JSON to the agent', () => { + expect(fromMarkup(toMarkup('plain text'))).not.toContain('"type"') + }) + + it('returns non-markup text unchanged instead of throwing', () => { + expect(fromMarkup('just a legacy string')).toBe('just a legacy string') + }) + + it('treats an empty blob as an empty description', () => { + expect(fromMarkup('')).toBe('') + expect(fromMarkup(' ')).toBe('') + }) +}) diff --git a/pods/mcp/src/platform/__tests__/workspace-client-provider.test.ts b/pods/mcp/src/platform/__tests__/workspace-client-provider.test.ts new file mode 100644 index 0000000000..83a8ca051d --- /dev/null +++ b/pods/mcp/src/platform/__tests__/workspace-client-provider.test.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type TxOperations } from '@hcengineering/core' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { type SessionIdentity } from '../../auth/authenticator' +import { type AccountApi, type AccountApiFactory } from '../account-api' +import { CachingWorkspaceClientProvider, type ClientFactory } from '../workspace-client-provider' + +/** A client stub that is only good enough to be closed during teardown. */ +const fakeClient = (): TxOperations => { + const client = { close: async () => {} } + return client as unknown as TxOperations +} + +const createAccounts: AccountApiFactory = () => Object.create(null) as AccountApi + +interface ProviderHarness { + provider: CachingWorkspaceClientProvider + /** One entry per client build, in call order — proves how many cache entries were filled. */ + built: SessionIdentity[] +} + +const makeProvider = (): ProviderHarness => { + const built: SessionIdentity[] = [] + const createClient: ClientFactory = async (identity) => { + built.push(identity) + return fakeClient() + } + + const provider = new CachingWorkspaceClientProvider({ + ctx: fakeMeasureContext(), + createClient, + createAccounts, + // Long enough that the sweeper never fires inside a test. + sweepIntervalMs: 600_000 + }) + return { provider, built } +} + +describe('CachingWorkspaceClientProvider cache key', () => { + it('serves a read-only identity from its own entry, not from a full-access one', async () => { + const { provider, built } = makeProvider() + const full = fakeIdentity({ workspaceToken: 'full-token' }) + const readOnly = fakeIdentity({ readOnly: true, workspaceToken: 'read-only-token' }) + + // The full-access identity fills the cache first — the scenario from the + // review: the read-only request must NOT inherit that entry. + await provider.get(full) + const readOnlySession = await provider.get(readOnly) + + expect(readOnlySession.identity.readOnly).toBe(true) + expect(readOnlySession.identity.workspaceToken).toBe('read-only-token') + expect(provider.size).toBe(2) + expect(built.map((identity) => identity.readOnly)).toEqual([false, true]) + + await provider.close() + }) + + it('hands out the requesting identity even when the cache is already warm', async () => { + const { provider, built } = makeProvider() + await provider.get(fakeIdentity({ workspaceToken: 'first-token' })) + const session = await provider.get(fakeIdentity({ workspaceToken: 'second-token' })) + + // Same privilege class means one shared client, but the identity a tool + // sees (and the write gate reads) belongs to THIS request. + expect(built).toHaveLength(1) + expect(session.identity.workspaceToken).toBe('second-token') + expect(provider.size).toBe(1) + + await provider.close() + }) + + it('still collapses concurrent first-hits into a single build', async () => { + const { provider, built } = makeProvider() + const identity = fakeIdentity() + + const [first, second] = await Promise.all([provider.get(identity), provider.get(identity)]) + + expect(built).toHaveLength(1) + expect(first.client).toBe(second.client) + + await provider.close() + }) +}) diff --git a/pods/mcp/src/platform/account-api.ts b/pods/mcp/src/platform/account-api.ts new file mode 100644 index 0000000000..c26e97d54c --- /dev/null +++ b/pods/mcp/src/platform/account-api.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { getClient as getAccountClient } from '@hcengineering/account-client' +import { + type AccountRole, + type AccountUuid, + type WorkspaceInfoWithStatus, + type WorkspaceMemberInfo +} from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' + +/** + * The slice of the account service the tools use. + * + * Kept narrow on purpose: the full `AccountClient` has a hundred methods, most + * of which (merging accounts, deleting a workspace, minting tokens) an agent + * should not reach. Every call runs with the caller's own workspace token, so + * the account service applies its own role checks; nothing here grants rights. + */ +export interface AccountApi { + getWorkspaceInfo: () => Promise + getWorkspaceMembers: () => Promise + updateWorkspaceName: (name: string) => Promise + updateWorkspaceRole: (account: AccountUuid, role: AccountRole) => Promise + /** Removes a member (the account service calls this "leaving"). */ + removeMember: (account: AccountUuid) => Promise + sendInvite: (email: string, role: AccountRole) => Promise + createInviteLink: (email: string, role: AccountRole, expHours: number) => Promise + updateAllowReadOnlyGuests: (allowed: boolean) => Promise + updateAllowGuestSignUp: (allowed: boolean) => Promise +} + +export type AccountApiFactory = (identity: SessionIdentity) => AccountApi + +/** Builds the account API over the real account client, authenticated as the caller. */ +export const createAccountApi = (accountsUrl: string): AccountApiFactory => { + return (identity) => { + const client = getAccountClient(accountsUrl, identity.workspaceToken) + return { + getWorkspaceInfo: async () => await client.getWorkspaceInfo(), + getWorkspaceMembers: async () => await client.getWorkspaceMembers(), + updateWorkspaceName: async (name) => { + await client.updateWorkspaceName(name) + }, + updateWorkspaceRole: async (account, role) => { + await client.updateWorkspaceRole(account, role) + }, + removeMember: async (account) => { + await client.leaveWorkspace(account) + }, + sendInvite: async (email, role) => { + await client.sendInvite(email, role) + }, + // Auto-join is off: the invitee must accept, rather than being enrolled by whoever holds the link. + createInviteLink: async (email, role, expHours) => + await client.createInviteLink(email, role, false, '', '', undefined, expHours), + updateAllowReadOnlyGuests: async (allowed) => { + await client.updateAllowReadOnlyGuests(allowed) + }, + updateAllowGuestSignUp: async (allowed) => { + await client.updateAllowGuestSignUp(allowed) + } + } + } +} diff --git a/pods/mcp/src/platform/collaborator-writer.ts b/pods/mcp/src/platform/collaborator-writer.ts new file mode 100644 index 0000000000..4991bb51c1 --- /dev/null +++ b/pods/mcp/src/platform/collaborator-writer.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { getClient as getCollaboratorClient } from '@hcengineering/collaborator-client' +import { makeCollabId, type Class, type Doc, type Ref } from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' +import { toMarkup } from './markup' +import { type MarkupWriter } from './markup-reader' + +/** + * Builds a writer that stores rich text through the collaborator service. + * + * Returns undefined when no collaborator URL is configured, so tools can refuse + * a description explicitly instead of dropping it. + */ +export function createCollaboratorWriter ( + collaboratorUrl: string +): ((identity: SessionIdentity) => MarkupWriter | undefined) { + if (collaboratorUrl === '') return () => undefined + + return (identity) => { + const client = getCollaboratorClient(identity.workspace, identity.workspaceToken, collaboratorUrl) + const collabIdOf = (objectClass: string, objectId: string, attribute: string): ReturnType => + makeCollabId(objectClass as Ref>, objectId as Ref, attribute) + + return { + write: async (objectClass, objectId, attribute, markdown) => + await client.createMarkup(collabIdOf(objectClass, objectId, attribute), toMarkup(markdown)), + update: async (objectClass, objectId, attribute, markdown) => { + await client.updateMarkup(collabIdOf(objectClass, objectId, attribute), toMarkup(markdown)) + } + } + } +} diff --git a/pods/mcp/src/platform/markup-reader.ts b/pods/mcp/src/platform/markup-reader.ts new file mode 100644 index 0000000000..e914b6ca98 --- /dev/null +++ b/pods/mcp/src/platform/markup-reader.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: EPL-2.0 + +/** + * Resolves a markup blob reference to its text content. + * + * An interface rather than a concrete class so tool handlers depend on the + * capability, not on HTTP. + */ +export interface MarkupReader { + read: (ref: string) => Promise +} + +/** + * Stores rich text in the workspace and returns the blob reference to keep on + * the owning document. Writing goes through the collaborator service, which is + * optional infrastructure, so a session may have no writer at all. + */ +export interface MarkupWriter { + write: (objectClass: string, objectId: string, attribute: string, markdown: string) => Promise + /** Replaces the text behind an existing blob reference; the reference itself does not change. */ + update: (objectClass: string, objectId: string, attribute: string, markdown: string) => Promise +} + +/** Reads a bounded number of characters, appending a marker when it truncates. */ +export function truncate (value: string, maxChars: number): string { + if (value.length <= maxChars) return value + return `${value.slice(0, maxChars)}\n… [truncated, ${value.length} characters total]` +} diff --git a/pods/mcp/src/platform/markup.ts b/pods/mcp/src/platform/markup.ts new file mode 100644 index 0000000000..2fdc1f7bc2 --- /dev/null +++ b/pods/mcp/src/platform/markup.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type Markup } from '@hcengineering/core' +import { jsonToMarkup, markupToJSON } from '@hcengineering/text-core' +import { markdownToMarkup, markupToMarkdown } from '@hcengineering/text-markdown' + +/** Converts agent-supplied Markdown (or plain text) into Huly's stored rich-text format. */ +export function toMarkup (markdown: string): Markup { + return jsonToMarkup(markdownToMarkup(markdown)) +} + +/** + * Renders stored rich text as Markdown for an agent. + * + * Huly stores rich text as ProseMirror JSON, which is noisy and wastes tokens. + * Anything that is not valid markup (legacy plain text, an empty blob) is + * returned unchanged instead of failing the tool call. + */ +export function fromMarkup (markup: string): string { + if (markup.trim() === '') return '' + try { + return markupToMarkdown(markupToJSON(markup)) + } catch { + return markup + } +} diff --git a/pods/mcp/src/platform/workspace-client-provider.ts b/pods/mcp/src/platform/workspace-client-provider.ts new file mode 100644 index 0000000000..826006ebf3 --- /dev/null +++ b/pods/mcp/src/platform/workspace-client-provider.ts @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { createRestTxOperations } from '@hcengineering/api-client' +import { type MeasureContext, type TxOperations } from '@hcengineering/core' + +import { type SessionIdentity } from '../auth/authenticator' +import { type AccountApi, type AccountApiFactory } from './account-api' +import { type MarkupReader, type MarkupWriter } from './markup-reader' +import { fromMarkup } from './markup' + +/** Everything a tool needs to talk to one workspace on behalf of one user. */ +export interface WorkspaceSession { + client: TxOperations + /** Account-service calls (members, roles, workspace settings), made as the caller. */ + accounts: AccountApi + identity: SessionIdentity + markup: MarkupReader + /** Undefined when no collaborator service is configured. */ + markupWriter?: MarkupWriter +} + +export interface WorkspaceClientProvider { + get: (identity: SessionIdentity) => Promise + close: () => Promise +} + +/** Injected so tests can supply a fake TxOperations without a live platform. */ +export type ClientFactory = (identity: SessionIdentity) => Promise + +export interface WorkspaceClientProviderOptions { + ctx: MeasureContext + createClient?: ClientFactory + createAccounts: AccountApiFactory + createMarkupReader?: (identity: SessionIdentity) => MarkupReader + createMarkupWriter?: (identity: SessionIdentity) => MarkupWriter | undefined + /** Idle time after which a cached client is closed, in milliseconds. */ + idleTtlMs?: number + /** How often idle entries are swept, in milliseconds. */ + sweepIntervalMs?: number + now?: () => number +} + +interface CacheEntry { + session: WorkspaceSession + lastUsed: number +} + +const DEFAULT_IDLE_TTL_MS = 10 * 60_000 +const DEFAULT_SWEEP_INTERVAL_MS = 60_000 + +/** + * Cache key for one client: account, workspace, privilege class. + * + * The read-only flag is part of the key on purpose. A `WorkspaceSession` + * carries the identity (and the workspace token) of whoever populated the + * cache, so a read-only token sharing a key with a full-access token would get + * a session whose `readOnly` says `false` and whose token can write — the + * read-only gate would open for the wrong request. `ro` / `rw` keeps the key + * short but readable in log output. + */ +const keyOf = (identity: SessionIdentity): string => + `${identity.account}:${identity.workspace}:${identity.readOnly ? 'ro' : 'rw'}` + +/** + * Re-labels a cached session with the identity of the request being served. + * + * The expensive parts (client, account API, markup reader) stay shared — the + * key already guarantees the two identities have the same account, workspace + * and privilege class. What must NOT be shared is the identity itself: tools + * and the read-only gate read `readOnly` and `workspaceToken` off it, and + * those must describe this request, not whichever request filled the cache. + */ +const withCallerIdentity = (session: WorkspaceSession, identity: SessionIdentity): WorkspaceSession => + identity === session.identity ? session : { ...session, identity } + +/** + * Caches one `TxOperations` per (account, workspace, read-only flag). + * + * Building a client is not cheap: `createRestTxOperations` fetches the account + * and the full workspace model over HTTP. MCP sessions are long lived and many + * tools fire back to back, so a per-request client would multiply that cost by + * an order of magnitude. + * + * Caching per *account* (not just per workspace) matters for correctness, not + * just speed: a client is bound to a social id, and every write is attributed + * to that social id. Sharing one client between users would attribute their + * edits to whoever happened to populate the cache first. The same logic + * applies to the read-only flag: two entries, two privilege classes, no + * cross-contamination. + */ +export class CachingWorkspaceClientProvider implements WorkspaceClientProvider { + private readonly ctx: MeasureContext + private readonly createClient: ClientFactory + private readonly createAccounts: AccountApiFactory + private readonly createMarkupReader: (identity: SessionIdentity) => MarkupReader + private readonly createMarkupWriter: (identity: SessionIdentity) => MarkupWriter | undefined + private readonly idleTtlMs: number + private readonly now: () => number + private readonly cache = new Map() + private readonly inFlight = new Map>() + private readonly sweeper: ReturnType + + constructor (options: WorkspaceClientProviderOptions) { + this.ctx = options.ctx + this.createClient = options.createClient ?? defaultClientFactory + this.createAccounts = options.createAccounts + this.createMarkupReader = options.createMarkupReader ?? defaultMarkupReaderFactory + this.createMarkupWriter = options.createMarkupWriter ?? (() => undefined) + this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS + this.now = options.now ?? Date.now + + this.sweeper = setInterval(() => { + void this.sweep() + }, options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS) + // Never hold the event loop open just for the sweeper. + this.sweeper.unref?.() + } + + async get (identity: SessionIdentity): Promise { + const key = keyOf(identity) + + const cached = this.cache.get(key) + if (cached !== undefined) { + cached.lastUsed = this.now() + return withCallerIdentity(cached.session, identity) + } + + // Collapse concurrent first-hits for the same identity into one build, + // otherwise a burst of parallel tool calls each loads the whole model. + const existing = this.inFlight.get(key) + if (existing !== undefined) return withCallerIdentity(await existing, identity) + + const creation = (async (): Promise => { + const client = await this.createClient(identity) + const session: WorkspaceSession = { + client, + accounts: this.createAccounts(identity), + identity, + markup: this.createMarkupReader(identity), + markupWriter: this.createMarkupWriter(identity) + } + this.cache.set(key, { session, lastUsed: this.now() }) + return session + })() + + this.inFlight.set(key, creation) + try { + return await creation + } finally { + this.inFlight.delete(key) + } + } + + private async sweep (): Promise { + const cutoff = this.now() - this.idleTtlMs + for (const [key, entry] of [...this.cache]) { + if (entry.lastUsed > cutoff) continue + this.cache.delete(key) + try { + await entry.session.client.close() + } catch (err) { + this.ctx.warn('mcp workspace client close failed', { key, error: (err as Error)?.message }) + } + } + } + + async close (): Promise { + clearInterval(this.sweeper) + const entries = [...this.cache.values()] + this.cache.clear() + await Promise.all( + entries.map(async (entry) => { + try { + await entry.session.client.close() + } catch { + // Shutdown is best effort; a stuck client must not block the exit. + } + }) + ) + } + + /** Test and diagnostic helper. */ + get size (): number { + return this.cache.size + } +} + +const defaultClientFactory: ClientFactory = async (identity) => + await createRestTxOperations(identity.transactorUrl, identity.workspace, identity.workspaceToken, true) + +const defaultMarkupReaderFactory = (identity: SessionIdentity): MarkupReader => ({ + read: async (ref: string) => fromMarkup(await fetchMarkup(identity.transactorUrl, identity.workspaceToken, ref)) +}) + +/** + * Reads a markup blob through the transactor's blob endpoint. + * + * Issue and document bodies are `MarkupBlobRef`s pointing at the blob store, + * not inline text, so a tool that wants to show a description has to resolve it. + * Failures resolve to an empty string: a missing description blob is a data + * consistency issue, not a reason to fail the whole tool call. + */ +async function fetchMarkup (transactorUrl: string, token: string, ref: string): Promise { + const url = `${transactorUrl.replace(/\/$/, '')}/api/v1/blob?name=${encodeURIComponent(ref)}` + const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + if (!response.ok) return '' + return await response.text() +} diff --git a/pods/mcp/src/server.ts b/pods/mcp/src/server.ts new file mode 100644 index 0000000000..6fb0f966aa --- /dev/null +++ b/pods/mcp/src/server.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type MeasureContext } from '@hcengineering/core' +import cors from 'cors' +import express, { type Express, type Request, type RequestHandler, type Response } from 'express' +import { type Server } from 'node:http' + +import { type Authenticator } from './auth/authenticator' +import { type Config } from './config' +import { McpDispatcher } from './mcp/dispatcher' +import { SessionStore } from './mcp/session' +import { SESSION_HEADER, StreamableHttpTransport } from './mcp/streamable-http' +import { type ToolRegistry } from './mcp/tool' +import { + errorHandler, + keepAlive, + KEEP_ALIVE_MAX, + KEEP_ALIVE_TIMEOUT, + originGuard, + rateLimit, + requestLogger, + statistics +} from './middleware' +import { type RateLimiter } from './middleware/rate-limiter' +import { type WorkspaceClientProvider } from './platform/workspace-client-provider' +import { MCP_INSTRUCTIONS } from './tools/register' + +export const MCP_ENDPOINT = '/mcp' + +/** Replaced at bundle time by esbuild. */ +const VERSION = process.env.VERSION ?? '0.7.0' + +export interface ServerDependencies { + ctx: MeasureContext + config: Config + registry: ToolRegistry + clients: WorkspaceClientProvider + authenticator: Authenticator + limiter: RateLimiter + sessions?: SessionStore +} + +export interface McpServer { + app: Express + sessions: SessionStore + transport: StreamableHttpTransport +} + +/** + * Adapts an async handler to Express. + * + * Express 4 ignores the returned promise, so an unhandled rejection inside a + * handler would otherwise be invisible. This routes it to `next`, which is what + * the error handler middleware is watching for. + */ +const asyncHandler = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req, res, next) => { + fn(req, res).catch(next) + } + +/** + * Assembles the HTTP layer. + * + * The dispatcher and transport are constructed here, from injected + * collaborators, rather than passed in pre-built. That keeps the wiring visible + * in one place and lets tests swap the authenticator or the client provider + * without having to construct a transport by hand. + */ +export function createServer (deps: ServerDependencies): McpServer { + const { ctx, config, registry, clients, authenticator, limiter } = deps + + const sessions = deps.sessions ?? new SessionStore({ ctx, idleTtlMs: config.SessionIdleTtlMs }) + + const dispatcher = new McpDispatcher({ + ctx, + registry, + serverName: 'huly-mcp', + serverVersion: VERSION, + instructions: MCP_INSTRUCTIONS, + resolveSession: async (session) => await clients.get(session.identity) + }) + + const transport = new StreamableHttpTransport({ ctx, store: sessions, dispatcher, authenticator }) + + const app = express() + app.disable('x-powered-by') + + // Behind nginx/ingress every connection physically arrives from the proxy's + // address, so req.ip would report the proxy for all callers and the rate + // limiter (which keys on req.ip) would collapse every client into one + // bucket. TRUST_PROXY=0 — the default — trusts no X-Forwarded-For, so a + // direct connection behaves exactly as it did before this line. + app.set('trust proxy', config.TrustProxy) + + // Must sit before cors(): a disallowed origin has to be refused outright, + // not answered with a failing CORS response, so the browser never even sees + // a preflight result for a site we do not trust. + app.use(originGuard(config.AllowedOrigins)) + + app.use( + cors({ + // Reflect only the configured origins instead of the wildcard `*`. An + // empty list leaves every browser request without an + // Access-Control-Allow-Origin header (cors skips false values), while + // non-browser clients, which send no Origin, are unaffected. + origin: config.AllowedOrigins, + maxAge: 86400, + // A browser-based MCP client must be able to read the session id, or + // every request after initialize would be rejected as session-less. + exposedHeaders: [SESSION_HEADER, 'WWW-Authenticate', 'Retry-After', 'X-RateLimit-Remaining'], + allowedHeaders: ['Content-Type', 'Authorization', SESSION_HEADER, 'Mcp-Protocol-Version', 'Last-Event-ID'] + }) + ) + + // Bodies are JSON-RPC envelopes; a megabyte is generous and still bounded. + app.use(express.json({ limit: config.MaxBodyBytes })) + app.use(keepAlive({ timeout: KEEP_ALIVE_TIMEOUT, max: KEEP_ALIVE_MAX })) + app.use(requestLogger(ctx)) + + // Limiting sits in front of the MCP routes only, so /health keeps working + // when a client manages to exhaust the window. + // + // The key is the client address, not the account: authentication has not run + // yet at this point, and resolving an account first would cost a login attempt + // per request before any flood was rejected. That makes the limit per address, + // so callers behind one NAT share a bucket — and, without `trust proxy` set + // above, so would every client behind one reverse proxy. + const rateKey = (req: Request): string => req.ip ?? req.socket.remoteAddress ?? 'unknown' + app.use(MCP_ENDPOINT, rateLimit(limiter, rateKey)) + + app.post(MCP_ENDPOINT, asyncHandler(transport.handlePost)) + app.get(MCP_ENDPOINT, asyncHandler(transport.handleGet)) + app.delete(MCP_ENDPOINT, asyncHandler(transport.handleDelete)) + + // Liveness probe: must answer 200 with no credential, because that is all a + // Docker/k8s healthcheck can present. What it reports is deliberately narrow: + // `authMode` and `readOnly` described how the server authenticates and + // authorizes — a roadmap for an unauthenticated caller — and `sessions` was a + // live activity counter. `version` and `tools` are fixed at boot and only + // tell an operator that the new image and the tool registry are the ones + // actually serving. + app.get('/api/v1/health', (_req: Request, res: Response) => { + res.status(200).json({ + status: 'ok', + version: VERSION, + tools: registry.size + }) + }) + + // Mounted unconditionally: while MCP_STATS is unset the handler defers to the + // catch-all below, which answers exactly like any unknown path (see + // `statistics` in middleware/index.ts). + app.get('/api/v1/statistics', statistics(ctx, config)) + + // A friendly landing page for a human who opens the pod in a browser. Same + // disclosure rule as /api/v1/health: it lists where the MCP endpoint and the + // health probe are, but not the auth mode or the read-only flag — those + // describe the server's security posture and are nobody's business but the + // operator's (who reads them from the environment that set them). + app.get('/', (_req: Request, res: Response) => { + res + .type('text/plain') + .send( + [ + 'Huly MCP Server', + '', + `MCP endpoint: POST ${MCP_ENDPOINT}`, + 'Health: GET /api/v1/health', + `Tools: ${registry.size}`, + '' + ].join('\n') + ) + }) + + app.use((_req: Request, res: Response) => { + res.status(404).json({ error: 'Not Found' }) + }) + + app.use(errorHandler(ctx)) + + return { app, sessions, transport } +} + +export function listen (app: Express, port: number, host: string): Server { + const server = app.listen(port, host, () => { + console.log(`Huly MCP server listening on ${host}:${port}`) + }) + // SSE responses are long-lived by design, so the socket timeouts have to sit + // above the keepalive interval or an idle stream gets reaped mid-conversation. + server.keepAliveTimeout = (KEEP_ALIVE_TIMEOUT + 30) * 1000 + server.headersTimeout = (KEEP_ALIVE_TIMEOUT + 35) * 1000 + // SSE streams are long-lived; capping requests per socket would throttle them. + server.maxRequestsPerSocket = 0 + return server +} diff --git a/pods/mcp/src/tools/__tests__/account-tools.test.ts b/pods/mcp/src/tools/__tests__/account-tools.test.ts new file mode 100644 index 0000000000..f037959bc9 --- /dev/null +++ b/pods/mcp/src/tools/__tests__/account-tools.test.ts @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { AccountRole, type AccountUuid, type TxOperations } from '@hcengineering/core' +import platform, { PlatformError, Severity, Status } from '@hcengineering/platform' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type AccountApi } from '../../platform/account-api' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { + accountTools, + createInviteLinkTool, + getWorkspaceTool, + inviteMemberTool, + listMembersTool, + removeMemberTool, + setMemberRoleTool, + updateGuestSettingsTool, + updateWorkspaceNameTool +} from '../account-tools' + +const ME = 'account-1' as AccountUuid +const OTHER = 'account-2' as AccountUuid + +const forbidden = (): PlatformError> => new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + +function fakeAccounts (overrides: Partial = {}): { accounts: AccountApi, calls: Array<[string, unknown[]]> } { + const calls: Array<[string, unknown[]]> = [] + const record = (name: string, result?: unknown) => async (...args: unknown[]) => { + calls.push([name, args]) + return result + } + const accounts = { + getWorkspaceInfo: record('getWorkspaceInfo', { + uuid: 'ws-1', + name: 'Acme', + url: 'acme', + createdOn: Date.parse('2026-01-01T00:00:00Z'), + allowReadOnlyGuest: true + }), + getWorkspaceMembers: record('getWorkspaceMembers', [ + { person: ME, role: AccountRole.Owner }, + { person: OTHER, role: AccountRole.User } + ]), + updateWorkspaceName: record('updateWorkspaceName'), + updateWorkspaceRole: record('updateWorkspaceRole'), + removeMember: record('removeMember'), + sendInvite: record('sendInvite'), + createInviteLink: record('createInviteLink', 'https://huly.test/invite/abc'), + updateAllowReadOnlyGuests: record('updateAllowReadOnlyGuests'), + updateAllowGuestSignUp: record('updateAllowGuestSignUp'), + ...overrides + } + return { accounts: accounts as unknown as AccountApi, calls } +} + +const PERSONS = [ + { _id: 'p1', name: 'Grace Hopper', personUuid: ME }, + { _id: 'p2', name: 'Alan Turing', personUuid: OTHER } +] + +/** A client whose only capability is the Person lookup the member list performs. */ +function personClient (persons: unknown[] = PERSONS): { client: TxOperations, queries: Array> } { + const queries: Array> = [] + const client = { + findAll: async (_cls: string, query: Record) => { + queries.push(query) + return persons + } + } + return { client: client as unknown as TxOperations, queries } +} + +function context (accounts: AccountApi, client: TxOperations = personClient().client): ToolContext { + const session: WorkspaceSession = { + client, + accounts, + identity: fakeIdentity({ account: ME }), + markup: { read: async () => '' } + } + return toolContext(session, fakeMeasureContext()) +} + +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text + +describe('workspace administration tools', () => { + it('reports settings together with the caller role', async () => { + const { accounts } = fakeAccounts() + + const result = await getWorkspaceTool.handler(context(accounts), {}) + + expect(JSON.parse(textOf(result))).toMatchObject({ + name: 'Acme', + url: 'acme', + allowReadOnlyGuests: true, + allowGuestSignUp: false, + yourRole: 'Owner' + }) + }) + + it('lists members with names, role names and a marker for the caller', async () => { + const { accounts } = fakeAccounts() + + const result = await listMembersTool.handler(context(accounts), {}) + + expect(JSON.parse(textOf(result)).members).toEqual([ + { accountId: ME, name: 'Grace Hopper', role: 'Owner', you: true }, + { accountId: OTHER, name: 'Alan Turing', role: 'User', you: false } + ]) + }) + + it('resolves names with one workspace query and leaves unknown members unnamed', async () => { + const { accounts } = fakeAccounts() + const { client, queries } = personClient([PERSONS[0]]) + + const result = await listMembersTool.handler(context(accounts, client), {}) + + expect(queries).toEqual([{ personUuid: { $in: [ME, OTHER] } }]) + expect(JSON.parse(textOf(result)).members[1]).toMatchObject({ accountId: OTHER, name: null }) + }) + + it('maps a role name to the account role when changing a member', async () => { + const { accounts, calls } = fakeAccounts() + + await setMemberRoleTool.handler(context(accounts), { accountId: OTHER, role: 'Maintainer' }) + + expect(calls).toContainEqual(['updateWorkspaceRole', [OTHER, AccountRole.Maintainer]]) + }) + + it('never offers the platform Admin role', () => { + const role = (setMemberRoleTool.inputSchema.properties as Record).role + expect(role.enum).toEqual(['ReadOnlyGuest', 'DocGuest', 'Guest', 'User', 'Maintainer', 'Owner']) + }) + + it('translates a Forbidden status into a message naming the action', async () => { + const { accounts } = fakeAccounts({ + updateWorkspaceRole: async () => { + throw forbidden() + } + }) + + const result = await setMemberRoleTool.handler(context(accounts), { accountId: OTHER, role: 'Owner' }) + + expect(result.isError).toBe(true) + expect(textOf(result)).toContain('does not allow you to change member roles') + }) + + it('lets unexpected errors propagate so the registry reports them', async () => { + const { accounts } = fakeAccounts({ + removeMember: async () => { + throw new Error('network down') + } + }) + + await expect(removeMemberTool.handler(context(accounts), { accountId: OTHER })).rejects.toThrow('network down') + }) + + it('removes a member, marks the tool destructive and invites with the User role by default', async () => { + const { accounts, calls } = fakeAccounts() + + await removeMemberTool.handler(context(accounts), { accountId: OTHER }) + await inviteMemberTool.handler(context(accounts), { email: 'new@example.test' }) + + expect(removeMemberTool.destructive).toBe(true) + expect(calls).toContainEqual(['removeMember', [OTHER]]) + expect(calls).toContainEqual(['sendInvite', ['new@example.test', AccountRole.User]]) + }) + + it('points at the link tool when the invitation email cannot be sent, but still reports Forbidden as a role problem', async () => { + const broken = fakeAccounts({ + sendInvite: async () => { + throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {})) + } + }) + const failed = await inviteMemberTool.handler(context(broken.accounts), { email: 'new@example.test' }) + expect(failed.isError).toBe(true) + expect(textOf(failed)).toContain('huly_create_invite_link') + + const denied = fakeAccounts({ + sendInvite: async () => { + throw forbidden() + } + }) + const refused = await inviteMemberTool.handler(context(denied.accounts), { email: 'new@example.test' }) + expect(textOf(refused)).toContain('does not allow you to invite members') + }) + + it('creates an invite link with the requested expiry', async () => { + const { accounts, calls } = fakeAccounts() + + const result = await createInviteLinkTool.handler(context(accounts), { + email: 'new@example.test', + role: 'Guest', + expiresInHours: 24 + }) + + expect(calls).toContainEqual(['createInviteLink', ['new@example.test', AccountRole.Guest, 24]]) + expect(JSON.parse(textOf(result)).link).toBe('https://huly.test/invite/abc') + }) + + it('changes only the guest settings that were passed and refuses an empty call', async () => { + const { accounts, calls } = fakeAccounts() + + await updateGuestSettingsTool.handler(context(accounts), { allowGuestSignUp: true }) + expect(calls.map(([name]) => name)).toEqual(['updateAllowGuestSignUp']) + + const empty = await updateGuestSettingsTool.handler(context(accounts), {}) + expect(textOf(empty)).toContain('Nothing to change') + expect(calls).toHaveLength(1) + }) + + it('renames the workspace', async () => { + const { accounts, calls } = fakeAccounts() + + await updateWorkspaceNameTool.handler(context(accounts), { name: 'Acme Inc' }) + + expect(calls).toContainEqual(['updateWorkspaceName', ['Acme Inc']]) + }) + + it('exposes only read tools as read-only', () => { + expect(accountTools.filter((tool) => tool.readOnly).map((tool) => tool.name)).toEqual([ + 'huly_get_workspace', + 'huly_list_members' + ]) + }) +}) diff --git a/pods/mcp/src/tools/__tests__/document-write-tools.test.ts b/pods/mcp/src/tools/__tests__/document-write-tools.test.ts new file mode 100644 index 0000000000..54605ae3a4 --- /dev/null +++ b/pods/mcp/src/tools/__tests__/document-write-tools.test.ts @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { type TxOperations } from '@hcengineering/core' +import document from '@hcengineering/document' +import { makeRank } from '@hcengineering/rank' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type AccountApi } from '../../platform/account-api' +import { type MarkupWriter } from '../../platform/markup-reader' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { createDocumentTool, updateDocumentTool } from '../document-write-tools' + +type Row = Record + +interface Recorded { + createDoc: Array<{ cls: string, space: string, values: Row, id: string }> + updateDoc: Array<{ id: string, ops: Row }> + findOne: Array<{ cls: string, query: Row, options?: Row }> +} + +interface Script { + teamspace?: Row + parent?: Row + lastSibling?: Row + page?: Row +} + +function setup (script: Script, writer?: MarkupWriter): { ctx: ToolContext, recorded: Recorded } { + const recorded: Recorded = { createDoc: [], updateDoc: [], findOne: [] } + const client = { + findOne: async (cls: string, query: Row, options?: Row) => { + recorded.findOne.push({ cls, query, options }) + if (cls === document.class.Teamspace) return script.teamspace + // The parent lookup filters by _id, the sibling lookup sorts by rank, the page lookup is by _id alone. + if (options?.sort !== undefined) return script.lastSibling + if (query.space !== undefined) return script.parent + return script.page + }, + createDoc: async (cls: string, space: string, values: Row, id: string) => { + recorded.createDoc.push({ cls, space, values, id }) + }, + updateDoc: async (_cls: string, _space: string, id: string, ops: Row) => { + recorded.updateDoc.push({ id, ops }) + } + } + const session: WorkspaceSession = { + client: client as unknown as TxOperations, + accounts: Object.create(null) as AccountApi, + identity: fakeIdentity(), + markup: { read: async () => '' }, + markupWriter: writer + } + return { ctx: toolContext(session, fakeMeasureContext()), recorded } +} + +function fakeWriter (): { writer: MarkupWriter, writes: string[][], updates: string[][] } { + const writes: string[][] = [] + const updates: string[][] = [] + const writer: MarkupWriter = { + write: async (cls, id, attribute, markdown) => { + writes.push([cls, id, attribute, markdown]) + return `blob-${id}` + }, + update: async (cls, id, attribute, markdown) => { + updates.push([cls, id, attribute, markdown]) + } + } + return { writer, writes, updates } +} + +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text +const TEAMSPACE = { _id: 'ts-1' } + +describe('huly_create_document', () => { + it('creates a top-level page after its siblings with zeroed counters', async () => { + const { ctx, recorded } = setup({ teamspace: TEAMSPACE, lastSibling: { rank: '0|hzzzzz:' } }) + + const result = await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'Handbook' }) + + expect(recorded.createDoc).toHaveLength(1) + const { cls, space, values, id } = recorded.createDoc[0] + expect(cls).toBe(document.class.Document) + expect(space).toBe('ts-1') + expect(values).toMatchObject({ + title: 'Handbook', + content: null, + parent: document.ids.NoParent, + attachments: 0, + embeddings: 0, + labels: 0, + comments: 0, + references: 0 + }) + expect(String(values.rank) > '0|hzzzzz:').toBe(true) + expect(values.rank).toBe(makeRank('0|hzzzzz:', undefined)) + expect(JSON.parse(textOf(result)).id).toBe(id) + }) + + it('ranks the first page of an empty teamspace without a previous rank', async () => { + const { ctx, recorded } = setup({ teamspace: TEAMSPACE }) + + await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'First' }) + + expect(recorded.createDoc[0].values.rank).toBe(makeRank(undefined, undefined)) + }) + + it('stores the body through the writer and keeps the returned reference', async () => { + const { writer, writes } = fakeWriter() + const { ctx, recorded } = setup({ teamspace: TEAMSPACE }, writer) + + await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'Page', content: '# Hi\n\nBody' }) + + const { id, values } = recorded.createDoc[0] + expect(writes).toEqual([[document.class.Document, id, 'content', '# Hi\n\nBody']]) + expect(values.content).toBe(`blob-${id}`) + }) + + it('refuses a body without a writer before creating anything', async () => { + const { ctx, recorded } = setup({ teamspace: TEAMSPACE }) + + const result = await createDocumentTool.handler(ctx, { teamspaceId: 'ts-1', title: 'Page', content: 'Body' }) + + expect(textOf(result)).toContain('COLLABORATOR_URL') + expect(recorded.createDoc).toHaveLength(0) + }) + + it('nests under a parent in the same teamspace and refuses an unknown parent or teamspace', async () => { + const nested = setup({ teamspace: TEAMSPACE, parent: { _id: 'p1' } }) + await createDocumentTool.handler(nested.ctx, { teamspaceId: 'ts-1', title: 'Child', parentDocumentId: 'p1' }) + expect(nested.recorded.createDoc[0].values.parent).toBe('p1') + expect(nested.recorded.findOne.find((call) => call.query._id === 'p1')?.query).toEqual({ _id: 'p1', space: 'ts-1' }) + + const orphan = setup({ teamspace: TEAMSPACE }) + const refusedParent = await createDocumentTool.handler(orphan.ctx, { teamspaceId: 'ts-1', title: 'x', parentDocumentId: 'nope' }) + expect(textOf(refusedParent)).toContain('No page with id nope exists in teamspace ts-1') + + const noSpace = setup({}) + const refusedSpace = await createDocumentTool.handler(noSpace.ctx, { teamspaceId: 'zzz', title: 'x' }) + expect(textOf(refusedSpace)).toContain('No teamspace with id zzz') + + expect(orphan.recorded.createDoc).toHaveLength(0) + expect(noSpace.recorded.createDoc).toHaveLength(0) + }) +}) + +describe('huly_update_document', () => { + it('replaces existing text in place and leaves the reference alone', async () => { + const { writer, writes, updates } = fakeWriter() + const { ctx, recorded } = setup({ page: { _id: 'd1', space: 'ts-1', content: 'blob-old' } }, writer) + + const result = await updateDocumentTool.handler(ctx, { documentId: 'd1', content: 'New body' }) + + expect(updates).toEqual([[document.class.Document, 'd1', 'content', 'New body']]) + expect(writes).toHaveLength(0) + expect(recorded.updateDoc).toHaveLength(0) + expect(textOf(result)).toContain('content') + }) + + it('writes a first body into an empty page and stores the new reference with the title', async () => { + const { writer, writes, updates } = fakeWriter() + const { ctx, recorded } = setup({ page: { _id: 'd1', space: 'ts-1', content: null } }, writer) + + await updateDocumentTool.handler(ctx, { documentId: 'd1', content: 'First text', title: 'Renamed' }) + + expect(writes).toEqual([[document.class.Document, 'd1', 'content', 'First text']]) + expect(updates).toHaveLength(0) + expect(recorded.updateDoc).toEqual([{ id: 'd1', ops: { title: 'Renamed', content: 'blob-d1' } }]) + }) + + it('changes only the title without touching the collaborator', async () => { + const { writer, writes, updates } = fakeWriter() + const { ctx, recorded } = setup({ page: { _id: 'd1', space: 'ts-1', content: 'blob-old' } }, writer) + + await updateDocumentTool.handler(ctx, { documentId: 'd1', title: 'Only title' }) + + expect(recorded.updateDoc).toEqual([{ id: 'd1', ops: { title: 'Only title' } }]) + expect(writes).toHaveLength(0) + expect(updates).toHaveLength(0) + }) + + it('refuses an empty call, a missing page and content without a writer', async () => { + const page = { _id: 'd1', space: 'ts-1', content: 'blob-old' } + + expect(textOf(await updateDocumentTool.handler(setup({ page }).ctx, { documentId: 'd1' }))).toContain('Nothing to change') + expect(textOf(await updateDocumentTool.handler(setup({}).ctx, { documentId: 'zzz', title: 'x' }))).toContain('No document with id zzz') + + const noWriter = setup({ page }) + expect(textOf(await updateDocumentTool.handler(noWriter.ctx, { documentId: 'd1', content: 'x' }))).toContain('COLLABORATOR_URL') + expect(noWriter.recorded.updateDoc).toHaveLength(0) + }) +}) diff --git a/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts new file mode 100644 index 0000000000..106a895183 --- /dev/null +++ b/pods/mcp/src/tools/__tests__/generic-write-tools.test.ts @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: EPL-2.0 + +import contact from '@hcengineering/contact' +import core, { AccountRole, type AccountUuid, type Hierarchy, type TxOperations } from '@hcengineering/core' +import document from '@hcengineering/document' +import tags from '@hcengineering/tags' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type AccountApi } from '../../platform/account-api' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { createDocTool, deleteDocTool, updateDocTool } from '../generic-write-tools' + +type Row = Record + +const ME = 'account-1' as AccountUuid +const PROJECT = 'proj-1' + +const str = { _class: 'core:class:TypeString' } +const markup = { _class: core.class.TypeMarkup } +const ref = { _class: 'core:class:RefTo', to: 'x' } +const date = { _class: 'core:class:TypeDate' } +const bool = { _class: 'core:class:TypeBoolean' } +const arr = { _class: 'core:class:ArrOf', of: str } +const num = { _class: 'core:class:TypeNumber' } +const attrs = (entries: Record): Map => new Map(Object.entries(entries)) + +// `members` and `owners` are ArrOf(TypeAccountUuid()) in the model, so push and +// pull must accept one account id and nothing else. +const accountId = { _class: 'core:class:TypeAccountUuid' } +const idArr = { _class: 'core:class:ArrOf', of: accountId } + +const ATTRIBUTES: Record> = { + [tracker.class.Component]: attrs({ label: { type: str }, description: { type: markup }, lead: { type: ref } }), + [tracker.class.Milestone]: attrs({ + label: { type: str }, + description: { type: markup }, + status: { type: { _class: 'tracker:class:TypeMilestoneStatus' } }, + startDate: { type: date }, + targetDate: { type: date } + }), + [tracker.class.IssueTemplate]: attrs({ + title: { type: str }, + description: { type: markup }, + priority: { type: { _class: 'tracker:class:TypeIssuePriority' } }, + assignee: { type: ref }, + component: { type: ref }, + milestone: { type: ref }, + estimation: { type: num }, + labels: { type: arr } + }), + [tags.class.TagElement]: attrs({ title: { type: str }, description: { type: str }, color: { type: num }, targetClass: { type: ref }, category: { type: ref } }), + [document.class.Teamspace]: attrs({ + name: { type: str }, + description: { type: str }, + private: { type: bool }, + archived: { type: bool }, + members: { type: idArr }, + owners: { type: idArr }, + autoJoin: { type: bool } + }), + [tracker.class.Project]: attrs({ + name: { type: str }, + description: { type: str }, + archived: { type: bool }, + members: { type: idArr }, + owners: { type: idArr } + }) +} + +const DERIVED: Record = { + [tracker.class.Project]: [core.class.Space, tracker.class.Project], + [document.class.Teamspace]: [core.class.Space, document.class.Teamspace] +} + +const hierarchy = (): Hierarchy => + ({ + getAllAttributes: (id: string) => ATTRIBUTES[id] ?? new Map(), + isDerived: (id: string, from: string) => id === from || (DERIVED[id] ?? []).includes(from) + }) as unknown as Hierarchy + +interface Calls { + createDoc: Array<{ cls: string, space: string, values: Row, id: string }> + updateDoc: Array<{ cls: string, space: string, id: string, ops: Row }> + removeDoc: Array<[string, string, string]> + removeCollection: unknown[][] +} + +type FindAll = (cls: string, query: Row, options?: { limit?: number }) => Row[] + +interface Script { + findOne?: Record + /** Per class, or a function for fakes that must react to the query. */ + findAll?: Record | FindAll + /** Lets a fake apply a recorded write, as the workspace would. */ + onOps?: (id: string, ops: Row) => void + role?: AccountRole +} + +function setup (script: Script = {}): { ctx: ToolContext, calls: Calls } { + const calls: Calls = { createDoc: [], updateDoc: [], removeDoc: [], removeCollection: [] } + const client = { + getHierarchy: hierarchy, + findOne: async (cls: string) => script.findOne?.[cls], + findAll: async (cls: string, query: Row, options?: { limit?: number }) => { + if (typeof script.findAll === 'function') { + return script.findAll(cls, query, options) + } + return script.findAll?.[cls] ?? [] + }, + createDoc: async (cls: string, space: string, values: Row, id: string) => { + calls.createDoc.push({ cls, space, values, id }) + }, + updateDoc: async (cls: string, space: string, id: string, ops: Row) => { + calls.updateDoc.push({ cls, space, id, ops }) + script.onOps?.(id, ops) + }, + removeDoc: async (cls: string, space: string, id: string) => { + calls.removeDoc.push([cls, space, id]) + }, + removeCollection: async (...args: unknown[]) => { + calls.removeCollection.push(args) + } + } + const accounts = { + getWorkspaceMembers: async () => [{ person: ME, role: script.role ?? AccountRole.User }] + } + const session: WorkspaceSession = { + client: client as unknown as TxOperations, + accounts: accounts as unknown as AccountApi, + identity: fakeIdentity({ account: ME }), + markup: { read: async () => '' } + } + return { ctx: toolContext(session, fakeMeasureContext()), calls } +} + +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text +const projectSpace = { _id: PROJECT, _class: tracker.class.Project } + +/** + * A query-aware fake for issues linked to one document: findAll only returns + * issues that still carry the reference, and the recorded updates clear it, so + * a sweep has to come back batch after batch until nothing matches. That is the + * contract detachFromIssues relies on to know when it is finished. + */ +function linkedIssues ( + ids: string[], + field: 'milestone' | 'component', + target: string +): { findAll: FindAll, onOps: (id: string, ops: Row) => void, queries: Row[], limits: number[] } { + const issues: Row[] = ids.map((id) => ({ _id: id, space: PROJECT, [field]: target })) + const queries: Row[] = [] + const limits: number[] = [] + return { + queries, + limits, + findAll: (_cls, query, options) => { + queries.push(query) + limits.push(options?.limit ?? 0) + return issues.filter((issue) => issue[field] === query[field]).slice(0, options?.limit) + }, + onOps: (id, ops) => { + const issue = issues.find((row) => row._id === id) + if (issue !== undefined) Object.assign(issue, ops) + } + } +} + +describe('huly_create_doc', () => { + it('refuses a class without a create profile and lists what is supported', async () => { + const { ctx, calls } = setup() + + const result = await createDocTool.handler(ctx, { classId: 'contact:class:Person', data: {} }) + + expect(textOf(result)).toContain('cannot be created') + expect(textOf(result)).toContain(tracker.class.Component) + expect(calls.createDoc).toHaveLength(0) + }) + + it('refuses fields outside the profile, unknown fields and missing required fields', async () => { + const { ctx, calls } = setup({ findOne: { [core.class.Space]: projectSpace } }) + + const counter = await createDocTool.handler(ctx, { classId: tracker.class.Component, spaceId: PROJECT, data: { label: 'x', comments: 9 } }) + const missing = await createDocTool.handler(ctx, { classId: tracker.class.Component, spaceId: PROJECT, data: {} }) + const wrongType = await createDocTool.handler(ctx, { classId: tracker.class.Component, spaceId: PROJECT, data: { label: 5 } }) + + expect(textOf(counter)).toContain('"comments" cannot be set') + expect(textOf(missing)).toContain('Missing required field(s) for tracker:class:Component: label') + expect(textOf(wrongType)).toContain('"label" must be a string') + expect(calls.createDoc).toHaveLength(0) + }) + + it('needs a space of the right class for project-scoped classes', async () => { + const noSpace = setup() + expect(textOf(await createDocTool.handler(noSpace.ctx, { classId: tracker.class.Component, data: { label: 'x' } }))).toContain('spaceId is required') + + const missing = setup() + expect(textOf(await createDocTool.handler(missing.ctx, { classId: tracker.class.Component, spaceId: 'nope', data: { label: 'x' } }))).toContain('No space with id nope') + + const wrongClass = setup({ findOne: { [core.class.Space]: { _id: 'w', _class: core.class.Space } } }) + const refused = await createDocTool.handler(wrongClass.ctx, { classId: tracker.class.Component, spaceId: 'w', data: { label: 'x' } }) + expect(textOf(refused)).toContain('can only be created in a tracker:class:Project') + expect(wrongClass.calls.createDoc).toHaveLength(0) + }) + + it('creates a component with defaults, rich text converted and the generated id returned', async () => { + const { ctx, calls } = setup({ findOne: { [core.class.Space]: projectSpace } }) + + const result = await createDocTool.handler(ctx, { + classId: tracker.class.Component, + spaceId: PROJECT, + data: { label: 'Backend', description: 'Core **services**' } + }) + + expect(calls.createDoc).toHaveLength(1) + const [call] = calls.createDoc + expect(call).toMatchObject({ cls: tracker.class.Component, space: PROJECT }) + expect(call.values).toMatchObject({ label: 'Backend', lead: null, comments: 0, attachments: 0 }) + expect(String(call.values.description)).toContain('"type":"doc"') + expect(JSON.parse(textOf(result)).id).toBe(call.id) + }) + + it('converts milestone status names and ISO dates to their stored form', async () => { + const { ctx, calls } = setup({ findOne: { [core.class.Space]: projectSpace } }) + + await createDocTool.handler(ctx, { + classId: tracker.class.Milestone, + spaceId: PROJECT, + data: { label: 'Beta', status: 'InProgress', targetDate: '2026-12-31T00:00:00Z' } + }) + const bad = await createDocTool.handler(ctx, { classId: tracker.class.Milestone, spaceId: PROJECT, data: { label: 'x', status: 'Done' } }) + + expect(calls.createDoc[0].values).toMatchObject({ status: 1, targetDate: Date.parse('2026-12-31T00:00:00Z'), startDate: null }) + expect(textOf(bad)).toContain('"status" must be one of: Planned, InProgress, Completed, Canceled') + }) + + it('derives the kind of an issue template from the project task type, or refuses without one', async () => { + const withType = setup({ + findOne: { [core.class.Space]: projectSpace, [tracker.class.Project]: { type: 'pt' }, [task.class.TaskType]: { _id: 'kind-1' } } + }) + await createDocTool.handler(withType.ctx, { classId: tracker.class.IssueTemplate, spaceId: PROJECT, data: { title: 'Bug', priority: 'High' } }) + expect(withType.calls.createDoc[0].values).toMatchObject({ kind: 'kind-1', priority: 2, comments: 0 }) + + const withoutType = setup({ findOne: { [core.class.Space]: projectSpace, [tracker.class.Project]: { type: 'pt' } } }) + const refused = await createDocTool.handler(withoutType.ctx, { classId: tracker.class.IssueTemplate, spaceId: PROJECT, data: { title: 'Bug' } }) + expect(textOf(refused)).toContain('no issue task type') + expect(withoutType.calls.createDoc).toHaveLength(0) + }) + + it('creates a label in the workspace space and a teamspace with the creator as member and owner', async () => { + const { ctx, calls } = setup() + + await createDocTool.handler(ctx, { classId: tags.class.TagElement, data: { title: 'urgent' } }) + await createDocTool.handler(ctx, { classId: document.class.Teamspace, data: { name: 'Handbook' } }) + + expect(calls.createDoc[0]).toMatchObject({ cls: tags.class.TagElement, space: core.space.Workspace }) + expect(calls.createDoc[0].values).toMatchObject({ title: 'urgent', targetClass: tracker.class.Issue, category: tags.category.NoCategory }) + expect(typeof calls.createDoc[0].values.color).toBe('number') + expect(calls.createDoc[1]).toMatchObject({ cls: document.class.Teamspace, space: core.space.Space }) + expect(calls.createDoc[1].values).toMatchObject({ name: 'Handbook', members: [ME], owners: [ME], private: false }) + }) + + it('keeps the creator in members and owners when the caller passes empty lists', async () => { + const { ctx, calls } = setup() + + await createDocTool.handler(ctx, { + classId: document.class.Teamspace, + data: { name: 'Handbook', members: [], owners: [] } + }) + + expect(calls.createDoc).toHaveLength(1) + expect(calls.createDoc[0].values).toMatchObject({ members: [ME], owners: [ME] }) + }) + + it('adds the creator to caller-supplied members and owners instead of replacing them', async () => { + const { ctx, calls } = setup() + + await createDocTool.handler(ctx, { + classId: document.class.Teamspace, + data: { name: 'Handbook', members: ['a2', 'a3'], owners: ['a2'] } + }) + + expect(calls.createDoc[0].values).toMatchObject({ members: [ME, 'a2', 'a3'], owners: [ME, 'a2'] }) + }) +}) + +describe('huly_update_doc', () => { + const component = { _id: 'c1', space: PROJECT } + + it('sets converted fields on an existing document', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Component]: component } }) + + await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1', data: { label: 'Renamed', lead: null } }) + + expect(calls.updateDoc).toEqual([{ cls: tracker.class.Component, space: PROJECT, id: 'c1', ops: { label: 'Renamed', lead: null } }]) + }) + + it('refuses unsupported classes, read-only fields, empty updates and missing documents', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Component]: component } }) + + expect(textOf(await updateDocTool.handler(ctx, { classId: tracker.class.Issue, id: 'i', data: { title: 'x' } }))).toContain('cannot be updated') + expect(textOf(await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1', data: { comments: 1 } }))).toContain('cannot be set') + expect(textOf(await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1' }))).toContain('Nothing to change') + expect(textOf(await updateDocTool.handler(setup().ctx, { classId: tracker.class.Component, id: 'zzz', data: { label: 'x' } }))).toContain('No tracker:class:Component with id zzz') + expect(calls.updateDoc).toHaveLength(0) + }) + + it('adds and removes array items only on allowed fields', async () => { + const space = { _id: 's1', space: core.space.Space, owners: [ME] } + const { ctx, calls } = setup({ findOne: { [document.class.Teamspace]: space } }) + + await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { members: 'a2' }, pull: { owners: 'a3' } }) + const bad = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { name: 'x' } }) + + expect(calls.updateDoc[0].ops).toEqual({ $push: { members: 'a2' }, $pull: { owners: 'a3' } }) + expect(textOf(bad)).toContain('"name" cannot be added to or removed from') + expect(calls.updateDoc).toHaveLength(1) + }) + + it('refuses a push or pull item that is not the field element type, and writes nothing', async () => { + const space = { _id: 's1', space: core.space.Space, owners: [ME] } + const { ctx, calls } = setup({ findOne: { [document.class.Teamspace]: space } }) + + const numberItem = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { members: 123 } }) + const objectItem = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { owners: { nested: true } } }) + const wholeList = await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', pull: { members: ['a2'] } }) + + expect(textOf(numberItem)).toContain('"members" expects a single account id') + expect(textOf(objectItem)).toContain('"owners" expects a single account id') + expect(textOf(wholeList)).toContain('push and pull take ONE item at a time') + expect(calls.updateDoc).toHaveLength(0) + + // Only the value was refused: a correctly typed item still goes through. + await updateDocTool.handler(ctx, { classId: document.class.Teamspace, id: 's1', push: { members: 'a4' } }) + expect(calls.updateDoc).toHaveLength(1) + expect(calls.updateDoc[0].ops).toEqual({ $push: { members: 'a4' } }) + }) + + it('lets only a workspace owner, a space owner or the creator manage a space', async () => { + const someoneElses = { _id: 's1', space: core.space.Space, owners: ['other'], createdBy: 'not-me' } + const call = { classId: document.class.Teamspace, id: 's1', data: { name: 'New name' } } + + const plain = setup({ findOne: { [document.class.Teamspace]: someoneElses } }) + expect(textOf(await updateDocTool.handler(plain.ctx, call))).toContain('Only a workspace owner') + expect(plain.calls.updateDoc).toHaveLength(0) + + const owner = setup({ findOne: { [document.class.Teamspace]: someoneElses }, role: AccountRole.Owner }) + await updateDocTool.handler(owner.ctx, call) + expect(owner.calls.updateDoc).toHaveLength(1) + + const spaceOwner = setup({ findOne: { [document.class.Teamspace]: { ...someoneElses, owners: [ME] } } }) + await updateDocTool.handler(spaceOwner.ctx, call) + expect(spaceOwner.calls.updateDoc).toHaveLength(1) + + const creator = setup({ + findOne: { [document.class.Teamspace]: { ...someoneElses, createdBy: 'soc-1' }, [contact.class.Person]: { _id: 'p1' } }, + findAll: { [contact.class.SocialIdentity]: [{ _id: 'soc-1' }] } + }) + await updateDocTool.handler(creator.ctx, call) + expect(creator.calls.updateDoc).toHaveLength(1) + }) + + it('does not apply the space rule to ordinary documents', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Component]: { _id: 'c1', space: PROJECT, createdBy: 'not-me' } } }) + + await updateDocTool.handler(ctx, { classId: tracker.class.Component, id: 'c1', data: { label: 'x' } }) + + expect(calls.updateDoc).toHaveLength(1) + }) +}) + +describe('huly_delete_doc', () => { + const component = { _id: 'c1', space: PROJECT, createdBy: 'not-me' } + + it('refuses classes without a delete profile, including whole projects', async () => { + const { ctx, calls } = setup({ findOne: { [tracker.class.Project]: { _id: PROJECT, space: 's' } } }) + + const result = await deleteDocTool.handler(ctx, { classId: tracker.class.Project, id: PROJECT }) + + expect(textOf(result)).toContain('cannot be deleted') + expect(calls.removeDoc).toHaveLength(0) + }) + + it('reports a document that is not visible', async () => { + const { ctx, calls } = setup() + + const result = await deleteDocTool.handler(ctx, { classId: tracker.class.Component, id: 'zzz' }) + + expect(textOf(result)).toContain('No tracker:class:Component with id zzz') + expect(calls.removeDoc).toHaveLength(0) + }) + + it("applies the web app's rule: only a workspace owner or the creator may delete", async () => { + const plain = setup({ findOne: { [tracker.class.Component]: component } }) + expect(textOf(await deleteDocTool.handler(plain.ctx, { classId: tracker.class.Component, id: 'c1' }))).toContain('Only a workspace owner or the creator') + expect(plain.calls.removeDoc).toHaveLength(0) + + const owner = setup({ findOne: { [tracker.class.Component]: component }, role: AccountRole.Owner }) + await deleteDocTool.handler(owner.ctx, { classId: tracker.class.Component, id: 'c1' }) + expect(owner.calls.removeDoc).toEqual([[tracker.class.Component, PROJECT, 'c1']]) + + const creator = setup({ + findOne: { [tracker.class.Component]: { ...component, createdBy: 'soc-1' }, [contact.class.Person]: { _id: 'p1' } }, + findAll: { [contact.class.SocialIdentity]: [{ _id: 'soc-1' }] } + }) + await deleteDocTool.handler(creator.ctx, { classId: tracker.class.Component, id: 'c1' }) + expect(creator.calls.removeDoc).toHaveLength(1) + }) + + it('detaches issues from a milestone before deleting it', async () => { + const sweep = linkedIssues(['i1', 'i2'], 'milestone', 'm1') + const { ctx, calls } = setup({ + role: AccountRole.Owner, + findOne: { [tracker.class.Milestone]: { _id: 'm1', space: PROJECT } }, + findAll: sweep.findAll, + onOps: sweep.onOps + }) + + await deleteDocTool.handler(ctx, { classId: tracker.class.Milestone, id: 'm1' }) + + expect(calls.updateDoc.map((call) => [call.id, call.ops])).toEqual([ + ['i1', { milestone: null }], + ['i2', { milestone: null }] + ]) + expect(calls.removeDoc).toEqual([[tracker.class.Milestone, PROJECT, 'm1']]) + }) + + it('detaches every issue in batches, however many point at the milestone', async () => { + const ids = Array.from({ length: 1500 }, (_unused, index) => `i${index}`) + const sweep = linkedIssues(ids, 'milestone', 'm1') + const { ctx, calls } = setup({ + role: AccountRole.Owner, + findOne: { [tracker.class.Milestone]: { _id: 'm1', space: PROJECT } }, + findAll: sweep.findAll, + onOps: sweep.onOps + }) + + await deleteDocTool.handler(ctx, { classId: tracker.class.Milestone, id: 'm1' }) + + // The same predicate is re-run until it comes back empty: 1000, then 500, then none. + expect(sweep.queries).toEqual([{ milestone: 'm1' }, { milestone: 'm1' }, { milestone: 'm1' }]) + expect(sweep.limits).toEqual([1000, 1000, 1000]) + expect(calls.updateDoc).toHaveLength(1500) + expect(calls.removeDoc).toEqual([[tracker.class.Milestone, PROJECT, 'm1']]) + }) + + it('refuses the delete instead of leaving issues pointing at a milestone it cannot detach', async () => { + const { ctx, calls } = setup({ + role: AccountRole.Owner, + findOne: { [tracker.class.Milestone]: { _id: 'm1', space: PROJECT } }, + // The same issue comes back on every round, as if the clearing never took effect. + findAll: { [tracker.class.Issue]: [{ _id: 'i1', space: PROJECT }] } + }) + + const result = await deleteDocTool.handler(ctx, { classId: tracker.class.Milestone, id: 'm1' }) + + expect(textOf(result)).toContain('did not finish') + expect(textOf(result)).toContain('nothing was deleted') + expect(calls.removeDoc).toHaveLength(0) + }) + + it('removes an issue through its parent collection', async () => { + const issue = { _id: 'i1', space: PROJECT, attachedTo: 'parent', attachedToClass: tracker.class.Issue, collection: 'subIssues' } + const { ctx, calls } = setup({ role: AccountRole.Owner, findOne: { [tracker.class.Issue]: issue } }) + + await deleteDocTool.handler(ctx, { classId: tracker.class.Issue, id: 'i1' }) + + expect(calls.removeDoc).toHaveLength(0) + expect(calls.removeCollection).toEqual([[tracker.class.Issue, PROJECT, 'i1', 'parent', tracker.class.Issue, 'subIssues']]) + }) + + it('is marked destructive', () => { + expect(deleteDocTool.destructive).toBe(true) + }) +}) diff --git a/pods/mcp/src/tools/__tests__/model-tools.test.ts b/pods/mcp/src/tools/__tests__/model-tools.test.ts new file mode 100644 index 0000000000..4a3494355c --- /dev/null +++ b/pods/mcp/src/tools/__tests__/model-tools.test.ts @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: EPL-2.0 + +import core, { type Hierarchy, type TxOperations } from '@hcengineering/core' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { describeClassTool, findTool, getDocTool, listClassesTool } from '../model-tools' + +type Row = Record + +const ISSUE = 'tracker:class:Issue' +const SPACE = 'core:class:Space' +const ABSTRACT = 'core:class:Abstract' + +const CLASSES: Record = { + [core.class.Doc]: {}, + [SPACE]: { extends: core.class.Doc, domain: 'space' }, + [ISSUE]: { extends: core.class.Doc, domain: 'task' }, + 'tracker:mixin:Extra': { extends: ISSUE, domain: 'task', mixin: true }, + [ABSTRACT]: { extends: core.class.Doc } +} + +const ATTRIBUTES = new Map([ + ['title', { name: 'title', type: { _class: 'core:class:TypeString' }, required: true }], + ['assignee', { name: 'assignee', type: { _class: 'core:class:RefTo', to: 'contact:class:Person' } }], + ['labels', { name: 'labels', type: { _class: 'core:class:Collection', of: 'tags:class:TagReference' } }], + ['tags', { name: 'tags', type: { _class: 'core:class:ArrOf', of: { _class: 'core:class:TypeString' } } }], + ['description', { name: 'description', type: { _class: core.class.TypeCollaborativeDoc } }], + ['notes', { name: 'notes', type: { _class: core.class.TypeMarkup } }], + ['size', { name: 'size', type: { _class: 'core:class:TypeNumber' }, isCustom: true }], + ['secretField', { name: 'secretField', type: { _class: 'core:class:TypeString' }, hidden: true }] +]) + +const fakeHierarchy = (): Hierarchy => { + const hierarchy = { + hasClass: (id: string) => id in CLASSES, + findClass: (id: string) => (id in CLASSES ? { _id: id, extends: CLASSES[id].extends } : undefined), + findDomain: (id: string) => { + let current: string | undefined = id + while (current !== undefined && current in CLASSES) { + if (CLASSES[current].domain !== undefined) return CLASSES[current].domain + current = CLASSES[current].extends + } + return undefined + }, + isMixin: (id: string) => CLASSES[id]?.mixin === true, + getDescendants: (id: string) => Object.keys(CLASSES).filter((key) => key !== id && (id === core.class.Doc || CLASSES[key].extends === id)), + getAllAttributes: () => ATTRIBUTES + } + return hierarchy as unknown as Hierarchy +} + +interface Recorded { + findAll: Array<{ cls: string, query: Row, options: Row }> +} + +function scripted (docs: Row[] = [], total?: number, one?: Row): { client: TxOperations, recorded: Recorded } { + const recorded: Recorded = { findAll: [] } + const client = { + getHierarchy: fakeHierarchy, + findAll: async (cls: string, query: Row, options: Row) => { + recorded.findAll.push({ cls, query, options }) + return Object.assign([...docs], { total: total ?? docs.length }) + }, + findOne: async () => one + } + return { client: client as unknown as TxOperations, recorded } +} + +function context (client: TxOperations, read: (ref: string) => Promise = async () => ''): ToolContext { + const session: WorkspaceSession = { + client, + accounts: Object.create(null) as WorkspaceSession['accounts'], + identity: fakeIdentity(), + markup: { read } + } + return toolContext(session, fakeMeasureContext()) +} + +const payload = (result: { content: unknown[] }): any => JSON.parse((result.content[0] as { text: string }).text) +const textOf = (result: { content: unknown[] }): string => (result.content[0] as { text: string }).text + +describe('huly_list_classes', () => { + it('lists only queryable classes by default and reports the total', async () => { + const result = await listClassesTool.handler(context(scripted().client), {}) + + const ids = payload(result).classes.map((row: Row) => row.id) + expect(ids).toEqual([SPACE, ISSUE, 'tracker:mixin:Extra'].sort()) + expect(ids).not.toContain(ABSTRACT) + expect(ids).not.toContain(core.class.Doc) + }) + + it('filters by search, kind and ancestor, and can include abstract classes', async () => { + const ctx = context(scripted().client) + + const bySearch = payload(await listClassesTool.handler(ctx, { search: 'TRACKER' })) + expect(bySearch.classes.map((row: Row) => row.id)).toEqual([ISSUE, 'tracker:mixin:Extra']) + + const mixins = payload(await listClassesTool.handler(ctx, { kind: 'mixin' })) + expect(mixins.classes.map((row: Row) => row.id)).toEqual(['tracker:mixin:Extra']) + + const withAbstract = payload(await listClassesTool.handler(ctx, { includeAbstract: true, search: 'abstract' })) + expect(withAbstract.classes[0]).toMatchObject({ id: ABSTRACT, queryable: false, domain: null }) + + const derived = payload(await listClassesTool.handler(ctx, { extends: ISSUE })) + expect(derived.classes.map((row: Row) => row.id)).toEqual([ISSUE, 'tracker:mixin:Extra']) + }) + + it('caps the page and still reports the full match count', async () => { + const result = payload(await listClassesTool.handler(context(scripted().client), { limit: 2 })) + + expect(result.returned).toBe(2) + expect(result.total).toBe(3) + }) + + it('explains an unknown extends class', async () => { + const result = await listClassesTool.handler(context(scripted().client), { extends: 'nope:class:Nope' }) + + expect(textOf(result)).toContain('not a class in this workspace') + }) +}) + +describe('huly_describe_class', () => { + it('describes field types, separates collections, flags custom fields and hides hidden ones', async () => { + const result = payload(await describeClassTool.handler(context(scripted().client), { classId: ISSUE })) + + expect(result).toMatchObject({ id: ISSUE, kind: 'class', extends: core.class.Doc, domain: 'task', queryable: true }) + const byName = Object.fromEntries(result.fields.map((field: Row) => [field.name, field])) + expect(byName.title).toMatchObject({ type: 'String', required: true, custom: false }) + expect(byName.assignee).toMatchObject({ type: 'Ref', to: 'contact:class:Person' }) + expect(byName.tags).toMatchObject({ type: 'Array', of: { type: 'String' } }) + expect(byName.size).toMatchObject({ custom: true }) + expect(byName.secretField).toBeUndefined() + expect(result.collections).toEqual([{ name: 'labels', of: 'tags:class:TagReference' }]) + }) + + it('points at the discovery tool for an unknown class', async () => { + const result = await describeClassTool.handler(context(scripted().client), { classId: 'nope:class:Nope' }) + + expect(textOf(result)).toContain('huly_list_classes') + }) +}) + +describe('huly_find', () => { + const docs = [ + { _id: 'a', _class: ISSUE, title: 'One', extra: 1 }, + { _id: 'b', _class: ISSUE, title: 'Two', extra: 2 } + ] + + it('passes the query through, sorts by modification time by default and reports the total', async () => { + const { client, recorded } = scripted(docs, 42) + + const result = payload(await findTool.handler(context(client), { classId: ISSUE, query: { space: 's1' }, limit: 2 })) + + expect(recorded.findAll[0]).toMatchObject({ + cls: ISSUE, + query: { space: 's1' }, + options: { limit: 2, sort: { modifiedOn: -1 }, total: true } + }) + expect(result).toMatchObject({ total: 42, returned: 2 }) + }) + + it('honours an explicit sort direction', async () => { + const { client, recorded } = scripted(docs) + + await findTool.handler(context(client), { classId: ISSUE, sortBy: 'title', ascending: true }) + + expect(recorded.findAll[0].options.sort).toEqual({ title: 1 }) + }) + + it('projects fields but always keeps the id and class', async () => { + const result = payload(await findTool.handler(context(scripted(docs).client), { classId: ISSUE, fields: ['title'] })) + + expect(result.documents[0]).toEqual({ _id: 'a', _class: ISSUE, title: 'One' }) + }) + + it('refuses an abstract or unknown class without querying', async () => { + const { client, recorded } = scripted(docs) + + const abstract = await findTool.handler(context(client), { classId: ABSTRACT }) + const unknown = await findTool.handler(context(client), { classId: 'nope:class:Nope' }) + + expect(textOf(abstract)).toContain('abstract class') + expect(textOf(unknown)).toContain('huly_list_classes') + expect(recorded.findAll).toHaveLength(0) + }) + + it('cuts an oversized result to fit and says so', async () => { + const big = Array.from({ length: 40 }, (_, index) => ({ _id: String(index), _class: ISSUE, body: 'x'.repeat(5000) })) + + const result = payload(await findTool.handler(context(scripted(big).client), { classId: ISSUE })) + + expect(result.truncated).toBe(true) + expect(result.returned).toBeLessThan(40) + expect(result.total).toBe(40) + }) + + it('suggests checking field names when nothing matches', async () => { + const result = await findTool.handler(context(scripted([]).client), { classId: ISSUE, query: { nope: 1 } }) + + expect(textOf(result)).toContain('huly_describe_class') + }) +}) + +describe('huly_get_doc', () => { + const inline = JSON.stringify({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inline body' }] }] }) + const doc = { _id: 'a', _class: ISSUE, title: 'One', description: 'blob-ref-1', notes: inline, size: 3 } + + it('resolves blob references and inline markup to Markdown and leaves other fields alone', async () => { + const read = jest.fn(async (ref: string) => (ref === 'blob-ref-1' ? 'Blob body' : '')) + + const result = await getDocTool.handler(context(scripted([], 0, doc).client, read), { classId: ISSUE, id: 'a' }) + + expect(payload(result)).toMatchObject({ title: 'One', description: 'Blob body', notes: 'Inline body', size: 3 }) + expect(read).toHaveBeenCalledTimes(1) + }) + + it('reports a missing document without throwing', async () => { + const result = await getDocTool.handler(context(scripted([], 0, undefined).client), { classId: ISSUE, id: 'zzz' }) + + expect(textOf(result)).toContain('No tracker:class:Issue with id zzz') + }) + + it('skips empty rich-text fields', async () => { + const read = jest.fn(async () => 'never') + + await getDocTool.handler(context(scripted([], 0, { ...doc, description: '', notes: '' }).client, read), { + classId: ISSUE, + id: 'a' + }) + + expect(read).not.toHaveBeenCalled() + }) +}) diff --git a/pods/mcp/src/tools/__tests__/tools.test.ts b/pods/mcp/src/tools/__tests__/tools.test.ts new file mode 100644 index 0000000000..9220420ad5 --- /dev/null +++ b/pods/mcp/src/tools/__tests__/tools.test.ts @@ -0,0 +1,546 @@ +// SPDX-License-Identifier: EPL-2.0 + +import core, { type TxOperations } from '@hcengineering/core' +import chunter from '@hcengineering/chunter' +import contact from '@hcengineering/contact' +import { makeRank } from '@hcengineering/rank' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { fakeIdentity, fakeMeasureContext } from '../../__tests__/test-doubles' +import { toolContext, type ToolContext } from '../../mcp/tool' +import { type MarkupWriter } from '../../platform/markup-reader' +import { type WorkspaceSession } from '../../platform/workspace-client-provider' +import { createComponentTool, listComponentsTool } from '../component-tools' +import { + addCommentTool, + createIssueTool, + createMilestoneTool, + getIssueTool, + listIssuesTool, + updateIssueTool, + updateMilestoneTool +} from '../issue-tools' +import { findPeopleTool, listMilestonesTool } from '../people-tools' +import { listProjectsTool } from '../project-tools' +import { likePattern, projectNames } from '../shared' + +type Row = Record + +interface Recorded { + findAll: Array<{ cls: string, query: Row }> + updateDoc: Row[] + createDoc: Array<{ cls: string, space: string, attributes: Row }> + addCollection: Array<{ cls: string, space: string, attachedTo: string, collection: string, attributes: Row }> +} + +interface Script { + findAll?: Record Row[])> + findOne?: Record + sequence?: number + /** Makes the increment return no number, as a workspace that withholds the next issue number would. */ + failIncrement?: boolean +} + +function scriptedClient (script: Script): { client: TxOperations, recorded: Recorded } { + const recorded: Recorded = { findAll: [], updateDoc: [], createDoc: [], addCollection: [] } + const client = { + findAll: async (cls: string, query: Row) => { + recorded.findAll.push({ cls, query }) + const rows = script.findAll?.[cls] + return typeof rows === 'function' ? rows(query) : (rows ?? []) + }, + findOne: async (cls: string) => script.findOne?.[cls], + createDoc: async (cls: string, space: string, attributes: Row) => { + recorded.createDoc.push({ cls, space, attributes }) + }, + updateDoc: async (...args: unknown[]) => { + recorded.updateDoc.push({ args }) + if (script.failIncrement === true) return {} + return { object: { sequence: script.sequence ?? 1 } } + }, + addCollection: async (cls: string, space: string, attachedTo: string, _attachedToClass: string, collection: string, attributes: Row) => { + recorded.addCollection.push({ cls, space, attachedTo, collection, attributes }) + } + } + return { client: client as unknown as TxOperations, recorded } +} + +function context (client: TxOperations, markupWriter?: MarkupWriter): ToolContext { + const session: WorkspaceSession = { + client, + accounts: Object.create(null) as WorkspaceSession['accounts'], + identity: fakeIdentity(), + markup: { read: async () => '' }, + markupWriter + } + return toolContext(session, fakeMeasureContext()) +} + +const PROJECT = { _id: 'proj-1', identifier: 'HULY', type: 'type-1', defaultIssueStatus: null } +const TASK_TYPE = { _id: 'kind-1', statuses: ['status-backlog', 'status-todo'] } + +describe('likePattern', () => { + it('wraps the needle and escapes LIKE wildcards', () => { + expect(likePattern('abc')).toBe('%abc%') + expect(likePattern('50%_off')).toBe('%50\\%\\_off%') + }) +}) + +describe('huly_create_issue', () => { + const script = (): Script => ({ + findOne: { [tracker.class.Project]: PROJECT, [task.class.TaskType]: TASK_TYPE }, + sequence: 7 + }) + + it('creates through addCollection, numbers via the atomic sequence and falls back to the task type status', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Hello' }) + + expect(recorded.updateDoc).toHaveLength(1) + expect(recorded.addCollection).toHaveLength(1) + const call = recorded.addCollection[0] + expect(call.cls).toBe(tracker.class.Issue) + expect(call.space).toBe('proj-1') + expect(call.collection).toBe('subIssues') + expect(call.attributes).toMatchObject({ + number: 7, + identifier: 'HULY-7', + kind: 'kind-1', + status: 'status-backlog', + description: null + }) + expect(result.content[0]).toMatchObject({ type: 'text' }) + expect(JSON.stringify(result.content)).toContain('HULY-7') + }) + + it('prefers an explicit status, then the project default', async () => { + const withDefault = scriptedClient({ + ...script(), + findOne: { [tracker.class.Project]: { ...PROJECT, defaultIssueStatus: 'status-todo' }, [task.class.TaskType]: TASK_TYPE } + }) + await createIssueTool.handler(context(withDefault.client), { projectId: 'proj-1', title: 'A' }) + expect(withDefault.recorded.addCollection[0].attributes.status).toBe('status-todo') + + const explicit = scriptedClient(script()) + await createIssueTool.handler(context(explicit.client), { projectId: 'proj-1', title: 'B', statusId: 'status-x' }) + expect(explicit.recorded.addCollection[0].attributes.status).toBe('status-x') + }) + + it('refuses a description without a markup writer and consumes no issue number', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Hello', + description: 'Body' + }) + + expect(JSON.stringify(result.content)).toContain('COLLABORATOR_URL') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('stores the description through the writer and keeps its reference', async () => { + const { client, recorded } = scriptedClient(script()) + const written: string[][] = [] + const writer: MarkupWriter = { + write: async (cls, id, attribute, markdown) => { + written.push([cls, attribute, markdown]) + return `blob-for-${id}` + }, + update: async () => {} + } + + await createIssueTool.handler(context(client, writer), { projectId: 'proj-1', title: 'Hello', description: 'Body' }) + + expect(written).toEqual([[tracker.class.Issue, 'description', 'Body']]) + expect(String(recorded.addCollection[0].attributes.description)).toMatch(/^blob-for-/) + }) + + it('does not create anything for an unknown project', async () => { + const { client, recorded } = scriptedClient({}) + await createIssueTool.handler(context(client), { projectId: 'missing', title: 'Hello' }) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('gives the created issue a real rank produced by makeRank', async () => { + const { client, recorded } = scriptedClient({ + ...script(), + findOne: { ...script().findOne, [tracker.class.Issue]: { rank: '0|hzzzzz:' } } + }) + + await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Hello' }) + + const rank = recorded.addCollection[0].attributes.rank + expect(rank).toBe(makeRank('0|hzzzzz:', undefined)) + expect(rank).not.toBe('') + expect(rank).not.toBeUndefined() + }) + + it('produces a valid rank when the project has no issues yet', async () => { + const { client, recorded } = scriptedClient(script()) + + await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Hello' }) + + const rank = recorded.addCollection[0].attributes.rank + expect(rank).toBe(makeRank(undefined, undefined)) + expect(typeof rank).toBe('string') + expect(rank).not.toBe('') + }) + + it('refuses a milestone from outside the project without incrementing the counter', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Hello', + milestone: 'ms-other' + }) + + expect(result.structuredContent).toEqual({ created: false }) + expect(JSON.stringify(result.content)).toContain('huly_list_milestones') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('refuses an assignee who is not a person in the workspace without incrementing the counter', async () => { + const { client, recorded } = scriptedClient(script()) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Hello', + assignee: 'ghost' + }) + + expect(result.structuredContent).toEqual({ created: false }) + expect(JSON.stringify(result.content)).toContain('huly_find_people') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('writes no description blob when reserving the issue number fails', async () => { + const { client, recorded } = scriptedClient({ ...script(), failIncrement: true }) + const written: string[][] = [] + const writer: MarkupWriter = { + write: async (cls, id, attribute, markdown) => { + written.push([cls, attribute, markdown]) + return `blob-for-${id}` + }, + update: async () => {} + } + + await expect( + createIssueTool.handler(context(client, writer), { projectId: 'proj-1', title: 'Hello', description: 'Body' }) + ).rejects.toThrow('The workspace did not return the next issue number') + + // The reservation ran before the blob write, so nothing was persisted to leak. + expect(recorded.updateDoc).toHaveLength(1) + expect(written).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) +}) + +describe('huly_add_issue_comment', () => { + it('attaches the comment to the issue as rich text instead of creating a free-standing doc', async () => { + const { client, recorded } = scriptedClient({ + findOne: { [tracker.class.Issue]: { _id: 'issue-1', space: 'proj-1' } } + }) + + await addCommentTool.handler(context(client), { issueId: 'issue-1', text: 'A **bold** note' }) + + expect(recorded.addCollection).toHaveLength(1) + const call = recorded.addCollection[0] + expect(call.cls).toBe(chunter.class.ChatMessage) + expect(call.space).toBe('proj-1') + expect(call.attachedTo).toBe('issue-1') + expect(call.collection).toBe('comments') + expect(String(call.attributes.message)).toContain('"type":"doc"') + }) +}) + +describe('huly_list_issues', () => { + const statuses = [ + { _id: 's-todo', name: 'Todo' }, + { _id: 's-done', name: 'Done' }, + { _id: 's-lost', name: 'Canceled' } + ] + + function scripted (): ReturnType { + return scriptedClient({ + findAll: { + [tracker.class.IssueStatus]: statuses, + [core.class.Status]: statuses, + [tracker.class.Issue]: [] + } + }) + } + + it('searches titles with a database LIKE, not a regular expression', async () => { + const { client, recorded } = scripted() + await listIssuesTool.handler(context(client), { search: 'Smoke' }) + + const issueQuery = recorded.findAll.find((call) => call.cls === tracker.class.Issue)?.query + expect(issueQuery?.title).toEqual({ $like: '%Smoke%' }) + }) + + it('does not apply the search text as a regular expression', async () => { + const { client, recorded } = scripted() + await listIssuesTool.handler(context(client), { search: '(a+)+' }) + + const title = recorded.findAll.find((call) => call.cls === tracker.class.Issue)?.query.title + expect(JSON.stringify(title)).not.toContain('regex') + }) +}) + +describe('project listings', () => { + it('does not list a tracker project a second time as a task project', async () => { + const row = { _id: 'proj-1', name: 'Huly', identifier: 'HULY', archived: false, private: false } + const { client } = scriptedClient({ + findAll: { [tracker.class.Project]: [row], [task.class.Project]: [row, { _id: 'proj-2', name: 'Board' }] } + }) + + const result = await listProjectsTool.handler(context(client), {}) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { projects: Array<{ id: string, kind: string }> } + + expect(payload.projects.map((project) => project.id)).toEqual(['proj-1', 'proj-2']) + expect(payload.projects.map((project) => project.kind)).toEqual(['tracker', 'task']) + }) + + it('keeps the tracker identifier when the same id also comes back as a task project', async () => { + const { client } = scriptedClient({ + findAll: { + [tracker.class.Project]: [{ _id: 'proj-1', name: 'Huly', identifier: 'HULY' }], + [task.class.Project]: [{ _id: 'proj-1', name: 'Huly' }] + } + }) + + const names = await projectNames(client, ['proj-1']) + + expect(names.get('proj-1')?.identifier).toBe('HULY') + }) +}) + +describe('huly_find_people', () => { + it('filters by name and email in the database, before any limit', async () => { + const { client, recorded } = scriptedClient({ + findAll: { + [contact.class.Person]: [{ _id: 'p-1', name: 'Hopper,Grace' }], + [contact.class.SocialIdentity]: [], + [contact.class.Channel]: [] + } + }) + + await findPeopleTool.handler(context(client), { query: 'hopper' }) + + const personQuery = recorded.findAll.find((call) => call.cls === contact.class.Person)?.query + expect(personQuery?.name).toEqual({ $like: '%hopper%' }) + const identityQuery = recorded.findAll.find((call) => call.cls === contact.class.SocialIdentity)?.query + expect(identityQuery?.value).toEqual({ $like: '%hopper%' }) + }) + + it('reads emails from identities attached to the person, not by matching ids', async () => { + const { client } = scriptedClient({ + findAll: { + [contact.class.Person]: [{ _id: 'p-1', name: 'Hopper,Grace' }], + [contact.class.SocialIdentity]: [{ attachedTo: 'p-1', type: 'email', value: 'grace@example.test' }], + [contact.class.Channel]: [] + } + }) + + const result = await findPeopleTool.handler(context(client), {}) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { people: Array<{ email: string | null }> } + + expect(payload.people[0].email).toBe('grace@example.test') + }) +}) + +describe('huly_create_issue relations', () => { + const PARENT = { + _id: 'issue-parent', + title: 'Parent', + identifier: 'HULY-1', + space: 'proj-1', + parents: [{ parentId: 'issue-root', parentTitle: 'Root', space: 'proj-1', identifier: 'HULY-0' }] + } + const base = (): Script => ({ + findOne: { [tracker.class.Project]: PROJECT, [task.class.TaskType]: TASK_TYPE, [tracker.class.Issue]: PARENT }, + sequence: 9 + }) + + it('attaches a sub-issue to its parent and records the ancestor chain nearest first', async () => { + const { client, recorded } = scriptedClient(base()) + + await createIssueTool.handler(context(client), { projectId: 'proj-1', title: 'Child', parentIssueId: 'issue-parent' }) + + const call = recorded.addCollection[0] + expect(call.attachedTo).toBe('issue-parent') + expect(call.attributes.parents).toEqual([ + { parentId: 'issue-parent', parentTitle: 'Parent', space: 'proj-1', identifier: 'HULY-1' }, + { parentId: 'issue-root', parentTitle: 'Root', space: 'proj-1', identifier: 'HULY-0' } + ]) + }) + + it('refuses a parent from another project before consuming a number', async () => { + const { client, recorded } = scriptedClient({ + ...base(), + findOne: { ...base().findOne, [tracker.class.Issue]: { ...PARENT, space: 'other' } } + }) + + const result = await createIssueTool.handler(context(client), { + projectId: 'proj-1', + title: 'Child', + parentIssueId: 'issue-parent' + }) + + expect(JSON.stringify(result.content)).toContain('sub-issue was not created') + expect(recorded.updateDoc).toHaveLength(0) + expect(recorded.addCollection).toHaveLength(0) + }) + + it('sets a component that exists in the project and refuses one that does not', async () => { + const found = scriptedClient({ + ...base(), + findOne: { ...base().findOne, [tracker.class.Component]: { _id: 'comp-1' } } + }) + await createIssueTool.handler(context(found.client), { projectId: 'proj-1', title: 'A', componentId: 'comp-1' }) + expect(found.recorded.addCollection[0].attributes.component).toBe('comp-1') + + const missing = scriptedClient(base()) + const result = await createIssueTool.handler(context(missing.client), { + projectId: 'proj-1', + title: 'B', + componentId: 'comp-x' + }) + expect(JSON.stringify(result.content)).toContain('huly_list_components') + expect(missing.recorded.updateDoc).toHaveLength(0) + }) +}) + +describe('huly_update_issue component', () => { + const issue = { _id: 'issue-1', space: 'proj-1' } + + it('validates the component against the issue project and allows clearing it', async () => { + const ok = scriptedClient({ + findOne: { [tracker.class.Issue]: issue, [tracker.class.Component]: { _id: 'comp-1' } } + }) + await updateIssueTool.handler(context(ok.client), { issueId: 'issue-1', componentId: 'comp-1' }) + expect(ok.recorded.updateDoc[0]).toMatchObject({ args: [tracker.class.Issue, 'proj-1', 'issue-1', { component: 'comp-1' }] }) + + const cleared = scriptedClient({ findOne: { [tracker.class.Issue]: issue } }) + await updateIssueTool.handler(context(cleared.client), { issueId: 'issue-1', componentId: null }) + expect(cleared.recorded.updateDoc[0]).toMatchObject({ args: [tracker.class.Issue, 'proj-1', 'issue-1', { component: null }] }) + + const bad = scriptedClient({ findOne: { [tracker.class.Issue]: issue } }) + const result = await updateIssueTool.handler(context(bad.client), { issueId: 'issue-1', componentId: 'nope' }) + expect(JSON.stringify(result.content)).toContain('nothing was changed') + expect(bad.recorded.updateDoc).toHaveLength(0) + }) +}) + +describe('milestones', () => { + it('creates a milestone with the model fields, in the project space', async () => { + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Project]: PROJECT } }) + + await createMilestoneTool.handler(context(client), { + projectId: 'proj-1', + name: 'Beta', + targetDate: '2026-12-31T00:00:00Z' + }) + + expect(recorded.createDoc).toHaveLength(1) + const { cls, space, attributes } = recorded.createDoc[0] + expect(cls).toBe(tracker.class.Milestone) + expect(space).toBe('proj-1') + expect(attributes).toMatchObject({ + label: 'Beta', + status: 0, + comments: 0, + startDate: null, + targetDate: Date.parse('2026-12-31T00:00:00Z') + }) + expect(attributes).not.toHaveProperty('name') + expect(attributes).not.toHaveProperty('project') + }) + + it('lists milestones by space and reports status and dates', async () => { + const { client, recorded } = scriptedClient({ + findAll: { + [tracker.class.Milestone]: [ + { _id: 'ms-1', label: 'Beta', status: 1, startDate: null, targetDate: Date.parse('2026-12-31T00:00:00Z') } + ] + } + }) + + const result = await listMilestonesTool.handler(context(client), { projectId: 'proj-1' }) + + expect(recorded.findAll[0].query).toEqual({ space: 'proj-1' }) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { milestones: Row[] } + expect(payload.milestones[0]).toEqual({ + id: 'ms-1', + name: 'Beta', + status: 'InProgress', + startDate: null, + targetDate: '2026-12-31T00:00:00.000Z' + }) + }) + + it('updates only the passed fields and refuses to clear the target date', async () => { + const milestone = { _id: 'ms-1', space: 'proj-1' } + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Milestone]: milestone } }) + + await updateMilestoneTool.handler(context(client), { milestoneId: 'ms-1', name: 'GA', status: 'Completed' }) + expect(recorded.updateDoc[0]).toMatchObject({ + args: [tracker.class.Milestone, 'proj-1', 'ms-1', { label: 'GA', status: 2 }] + }) + + const result = await updateMilestoneTool.handler(context(client), { milestoneId: 'ms-1', targetDate: null }) + expect(JSON.stringify(result.content)).toContain('cannot be cleared') + expect(recorded.updateDoc).toHaveLength(1) + + const unknown = await updateMilestoneTool.handler(context(client), { milestoneId: 'ms-1', status: 'Done' }) + expect(JSON.stringify(unknown.content)).toContain('Unknown milestone status') + }) +}) + +describe('components', () => { + it('lists components with lead names', async () => { + const { client, recorded } = scriptedClient({ + findAll: { + [tracker.class.Component]: [{ _id: 'comp-1', label: 'API', lead: 'person-1', space: 'proj-1' }], + [contact.class.Person]: [{ _id: 'person-1', name: 'Grace Hopper' }] + } + }) + + const result = await listComponentsTool.handler(context(client), { projectId: 'proj-1' }) + + expect(recorded.findAll[0].query).toEqual({ space: 'proj-1' }) + const payload = JSON.parse((result.content[0] as { text: string }).text) as { components: Row[] } + expect(payload.components[0]).toEqual({ id: 'comp-1', name: 'API', projectId: 'proj-1', lead: 'Grace Hopper' }) + }) + + it('creates a component through createDoc in the project space', async () => { + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Project]: PROJECT } }) + + await createComponentTool.handler(context(client), { projectId: 'proj-1', name: 'API', lead: 'person-1' }) + + expect(recorded.createDoc[0]).toMatchObject({ + cls: tracker.class.Component, + space: 'proj-1', + attributes: { label: 'API', lead: 'person-1', comments: 0 } + }) + }) +}) + +describe('huly_get_issue', () => { + it('finds sub-issues by attachment to the parent, not by a nonexistent parent field', async () => { + const issue = { _id: 'issue-1', number: 1, title: 'P', status: 's', priority: 0, assignee: null, space: 'proj-1', labels: 0, rank: '', createdOn: 0, modifiedOn: 0, startDate: null, dueDate: null, milestone: null, estimation: 0 } + const { client, recorded } = scriptedClient({ findOne: { [tracker.class.Issue]: issue } }) + + await getIssueTool.handler(context(client), { issueId: 'issue-1' }) + + const subtaskQuery = recorded.findAll.find((call) => call.cls === tracker.class.Issue)?.query + expect(subtaskQuery).toEqual({ space: 'proj-1', attachedTo: 'issue-1' }) + }) +}) diff --git a/pods/mcp/src/tools/account-tools.ts b/pods/mcp/src/tools/account-tools.ts new file mode 100644 index 0000000000..d9714ea007 --- /dev/null +++ b/pods/mcp/src/tools/account-tools.ts @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: EPL-2.0 + +import contact from '@hcengineering/contact' +import { AccountRole, type AccountUuid } from '@hcengineering/core' +import platform, { PlatformError } from '@hcengineering/platform' + +import { errorResult, textResult, type McpToolCallResult } from '../mcp/protocol' +import { booleanProp, numberProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { toIso } from './shared' + +/** + * Workspace administration: settings, members and roles. + * + * These go to the account service, not the workspace transactor, and run with + * the caller's own token. The service enforces the role rules (for example a + * maintainer cannot remove an owner), so a refusal is reported as a plain + * sentence the agent can relay instead of an opaque platform status. + */ + +/** Roles an agent may assign. `Admin` is the platform operator role and is never grantable here. */ +const ASSIGNABLE_ROLES = Object.keys(AccountRole).filter((name) => name !== 'Admin') + +const roleFromName = (name: string): AccountRole => AccountRole[name as keyof typeof AccountRole] + +const roleName = (role: AccountRole): string => + Object.entries(AccountRole).find(([, value]) => value === role)?.[0] ?? String(role) + +const ROLE_PROP = { + type: 'string' as const, + enum: ASSIGNABLE_ROLES +} + +/** Maximum member names resolved in one call; members past this are listed by id only. */ +const MAX_NAMED_MEMBERS = 200 + +/** Runs an account call, turning a Forbidden status into an answer the agent can act on. */ +async function asCaller (action: string, run: () => Promise): Promise { + try { + return await run() + } catch (err) { + if (err instanceof PlatformError && err.status.code === platform.status.Forbidden) { + return errorResult( + `Your role in this workspace does not allow you to ${action}. Call huly_get_workspace to see your role.` + ) + } + throw err + } +} + +async function callerRole (ctx: ToolContext): Promise { + const members = await ctx.accounts.getWorkspaceMembers() + const me = members.find((member) => member.person === ctx.account) + return me === undefined ? null : roleName(me.role) +} + +export const getWorkspaceTool: HulyTool = { + name: 'huly_get_workspace', + title: 'Get workspace settings', + description: + 'Show this workspace\'s settings (name, url, region, guest access) and the caller\'s own role in it. ' + + 'Call this first to learn what the caller is allowed to change.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const [info, role] = await Promise.all([ctx.accounts.getWorkspaceInfo(), callerRole(ctx)]) + const result = { + id: info.uuid, + name: info.name, + url: info.url, + region: info.region ?? null, + createdOn: toIso(info.createdOn), + allowReadOnlyGuests: info.allowReadOnlyGuest ?? false, + allowGuestSignUp: info.allowGuestSignUp ?? false, + yourRole: role + } + return textResult(JSON.stringify(result, null, 2), result) + } +} + +export const listMembersTool: HulyTool = { + name: 'huly_list_members', + title: 'List workspace members', + description: + 'List everyone with access to this workspace, with their role. ' + + 'The accountId is what huly_set_member_role and huly_remove_member expect. ' + + 'A name can be null for a member who has just joined and has no profile in the workspace yet.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const members = await ctx.accounts.getWorkspaceMembers() + + // Names live on the workspace's own Person records, which link to the + // account through personUuid. The account service's person lookup is + // service-only, so a user token cannot use it. + const query: Record = { personUuid: { $in: members.map((member) => member.person) } } + const persons = (await ctx.client.findAll(contact.class.Person, query as never, { + limit: MAX_NAMED_MEMBERS + })) as unknown as Array<{ name: string, personUuid?: string }> + const names = new Map(persons.map((person) => [person.personUuid, person.name])) + + const rows = members.map((member) => ({ + accountId: member.person, + name: names.get(member.person) ?? null, + role: roleName(member.role), + you: member.person === ctx.account + })) + return textResult(JSON.stringify({ members: rows }, null, 2), { members: rows }) + } +} + +export const updateWorkspaceNameTool: HulyTool = { + name: 'huly_update_workspace_name', + title: 'Rename workspace', + description: 'Change the display name of the workspace. The workspace url does not change.', + readOnly: false, + inputSchema: objectSchema({ name: stringProp('New workspace name.', { minLength: 1, maxLength: 100 }) }, ['name']), + handler: async (ctx, args) => + await asCaller('rename the workspace', async () => { + await ctx.accounts.updateWorkspaceName(args.name as string) + return textResult(`Renamed the workspace to "${String(args.name)}".`, { updated: true }) + }) +} + +export const updateGuestSettingsTool: HulyTool = { + name: 'huly_update_workspace_guest_settings', + title: 'Change guest access', + description: + 'Turn read-only guest access and guest sign-up on or off for the workspace. Only the fields you pass change.', + readOnly: false, + inputSchema: objectSchema({ + allowReadOnlyGuests: booleanProp('Let people without an account view the workspace read-only.'), + allowGuestSignUp: booleanProp('Let guests sign up for an account on their own.') + }), + handler: async (ctx, args) => { + if (args.allowReadOnlyGuests === undefined && args.allowGuestSignUp === undefined) { + return textResult('Nothing to change. Pass allowReadOnlyGuests and/or allowGuestSignUp.', { updated: false }) + } + return await asCaller('change guest access', async () => { + const changed: string[] = [] + if (args.allowReadOnlyGuests !== undefined) { + await ctx.accounts.updateAllowReadOnlyGuests(args.allowReadOnlyGuests as boolean) + changed.push('allowReadOnlyGuests') + } + if (args.allowGuestSignUp !== undefined) { + await ctx.accounts.updateAllowGuestSignUp(args.allowGuestSignUp as boolean) + changed.push('allowGuestSignUp') + } + return textResult(`Updated guest access: ${changed.join(', ')}.`, { updated: true, changed }) + }) + } +} + +export const setMemberRoleTool: HulyTool = { + name: 'huly_set_member_role', + title: 'Change a member\'s role', + description: + 'Change the role of a workspace member. Get the accountId from huly_list_members. ' + + 'The account service decides who may grant which role.', + readOnly: false, + inputSchema: objectSchema( + { + accountId: stringProp('Member account id from huly_list_members.'), + role: { ...ROLE_PROP, description: 'New role.' } + }, + ['accountId', 'role'] + ), + handler: async (ctx, args) => + await asCaller('change member roles', async () => { + const role = roleFromName(args.role as string) + await ctx.accounts.updateWorkspaceRole(args.accountId as AccountUuid, role) + return textResult(`Set the role of ${String(args.accountId)} to ${String(args.role)}.`, { + updated: true, + role: args.role + }) + }) +} + +export const removeMemberTool: HulyTool = { + name: 'huly_remove_member', + title: 'Remove a member', + description: + 'Remove a member from the workspace. Their account is not deleted, they just lose access here. ' + + 'Get the accountId from huly_list_members. The last owner cannot be removed.', + readOnly: false, + destructive: true, + inputSchema: objectSchema({ accountId: stringProp('Member account id from huly_list_members.') }, ['accountId']), + handler: async (ctx, args) => + await asCaller('remove members', async () => { + await ctx.accounts.removeMember(args.accountId as AccountUuid) + return textResult(`Removed ${String(args.accountId)} from the workspace.`, { removed: true }) + }) +} + +export const inviteMemberTool: HulyTool = { + name: 'huly_invite_member', + title: 'Invite a member by email', + description: + 'Send an email invitation to join the workspace with the given role. This sends a real email. ' + + 'To get a link instead, use huly_create_invite_link.', + readOnly: false, + inputSchema: objectSchema( + { + email: stringProp('Email address to invite.', { minLength: 3, maxLength: 320 }), + role: { ...ROLE_PROP, description: 'Role the invitee gets. Defaults to User.', default: 'User' } + }, + ['email'] + ), + handler: async (ctx, args) => + await asCaller('invite members', async () => { + const role = (args.role as string | undefined) ?? 'User' + try { + await ctx.accounts.sendInvite(args.email as string, roleFromName(role)) + } catch (err) { + // A Forbidden still means "your role", so leave that to asCaller. Anything else at this + // point is almost always the account service failing to send mail. + if (err instanceof PlatformError && err.status.code === platform.status.Forbidden) throw err + return errorResult( + `The invitation to ${String(args.email)} could not be sent. The account service may have no email ` + + 'service configured. Use huly_create_invite_link to get a link to share instead.' + ) + } + return textResult(`Invited ${String(args.email)} as ${role}.`, { invited: true }) + }) +} + +export const createInviteLinkTool: HulyTool = { + name: 'huly_create_invite_link', + title: 'Create an invite link', + description: + 'Create a link that lets one email address join the workspace with the given role. ' + + 'Anyone holding the link can use it until it expires, so treat it as a credential.', + readOnly: false, + inputSchema: objectSchema( + { + email: stringProp('Email address the link is for.', { minLength: 3, maxLength: 320 }), + role: { ...ROLE_PROP, description: 'Role the invitee gets. Defaults to User.', default: 'User' }, + expiresInHours: numberProp('Hours until the link expires (1-720, default 48).', { + minimum: 1, + maximum: 720, + default: 48 + }) + }, + ['email'] + ), + handler: async (ctx, args) => + await asCaller('create invite links', async () => { + const role = (args.role as string | undefined) ?? 'User' + const hours = typeof args.expiresInHours === 'number' ? args.expiresInHours : 48 + const link = await ctx.accounts.createInviteLink(args.email as string, roleFromName(role), hours) + return textResult(JSON.stringify({ link, email: args.email, role, expiresInHours: hours }, null, 2), { + created: true + }) + }) +} + +export const accountTools: HulyTool[] = [ + getWorkspaceTool, + listMembersTool, + updateWorkspaceNameTool, + updateGuestSettingsTool, + setMemberRoleTool, + removeMemberTool, + inviteMemberTool, + createInviteLinkTool +] diff --git a/pods/mcp/src/tools/caller-rights.ts b/pods/mcp/src/tools/caller-rights.ts new file mode 100644 index 0000000000..c0a541e5dd --- /dev/null +++ b/pods/mcp/src/tools/caller-rights.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: EPL-2.0 + +import contact from '@hcengineering/contact' +import { AccountRole, type Doc } from '@hcengineering/core' + +import { type ToolContext } from '../mcp/tool' + +/** + * Ownership rules the web app applies in the browser but the transactor does not. + * + * The transactor lets any workspace member write to a public space, so a plain + * user could delete or rename a teamspace someone else owns through the API even + * though the web app would refuse. The generic write tools mirror the web app's + * rule so an agent never has more reach than the person using the UI: only a + * workspace owner, or the creator of a document, may delete it. + */ + +/** True when the caller holds the workspace Owner role. */ +export async function callerIsOwner (ctx: ToolContext): Promise { + const members = await ctx.accounts.getWorkspaceMembers() + return members.some((member) => member.person === ctx.account && member.role === AccountRole.Owner) +} + +/** The caller's own social ids, which is what `createdBy` records. */ +async function callerSocialIds (ctx: ToolContext): Promise> { + const personQuery: Record = { personUuid: ctx.account } + const person = await ctx.client.findOne(contact.class.Person, personQuery as never) + if (person === undefined) return new Set() + + const identityQuery: Record = { attachedTo: person._id } + const identities = await ctx.client.findAll(contact.class.SocialIdentity, identityQuery as never) + return new Set(identities.map((identity) => identity._id as string)) +} + +async function callerCreated (ctx: ToolContext, doc: Doc): Promise { + return (await callerSocialIds(ctx)).has(doc.createdBy as string) +} + +/** The web app's delete rule: a workspace owner, or whoever created the document. */ +export async function mayDelete (ctx: ToolContext, doc: Doc): Promise { + return (await callerIsOwner(ctx)) || (await callerCreated(ctx, doc)) +} + +/** + * Who may rename a space, change its members or archive it: a workspace owner, + * an owner of that space, or its creator. + */ +export async function mayManageSpace (ctx: ToolContext, space: Doc & { owners?: string[] }): Promise { + if (space.owners?.includes(ctx.account) === true) return true + return (await callerIsOwner(ctx)) || (await callerCreated(ctx, space)) +} diff --git a/pods/mcp/src/tools/component-tools.ts b/pods/mcp/src/tools/component-tools.ts new file mode 100644 index 0000000000..f7eda20b75 --- /dev/null +++ b/pods/mcp/src/tools/component-tools.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { generateId } from '@hcengineering/core' +import tracker, { type Component } from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { toMarkup } from '../platform/markup' +import { clampLimit, personNames } from './shared' + +interface ComponentRow { + _id: string + label: string + lead: string | null + space: string +} + +export const listComponentsTool: HulyTool = { + name: 'huly_list_components', + title: 'List components', + description: + 'List the components of a project (the areas of a product that issues are grouped under). ' + + 'Component ids are what huly_create_issue and huly_update_issue expect as componentId.', + readOnly: true, + inputSchema: objectSchema({ + projectId: stringProp('Restrict to one project id from huly_list_projects.'), + limit: { type: 'integer', description: 'Maximum components to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const query: Record = {} + if (args.projectId !== undefined) query.space = args.projectId + + const components = (await ctx.client.findAll(tracker.class.Component, query as never, { + limit: clampLimit(args.limit) + })) as unknown as ComponentRow[] + + if (components.length === 0) { + return textResult('No components found.', { components: [] }) + } + + const leads = await personNames( + ctx.client, + components.map((component) => component.lead) + ) + const rows = components.map((component) => ({ + id: component._id, + name: component.label, + projectId: component.space, + lead: component.lead === null ? null : (leads.get(component.lead) ?? 'Unknown') + })) + + return textResult(JSON.stringify({ components: rows }, null, 2), { components: rows }) + } +} + +export const createComponentTool: HulyTool = { + name: 'huly_create_component', + title: 'Create component', + description: 'Create a component in a project. Use huly_find_people to look up a lead.', + readOnly: false, + inputSchema: objectSchema( + { + projectId: stringProp('Project id from huly_list_projects.'), + name: stringProp('Component name.', { minLength: 1, maxLength: 200 }), + description: stringProp('Component description. Plain text or Markdown.', { maxLength: 20_000 }), + lead: stringProp('Person id of the component lead.') + }, + ['projectId', 'name'] + ), + handler: async (ctx, args) => { + const projectId = args.projectId as string + const projectQuery: Record = { _id: projectId } + const project = await ctx.client.findOne(tracker.class.Project, projectQuery as never) + + if (project === undefined) { + return textResult(`No project with id ${projectId} is visible to you, so nothing was created.`, { + created: false + }) + } + + const componentId = generateId() + const attributes: Record = { + label: args.name, + description: toMarkup((args.description as string | undefined) ?? ''), + lead: (args.lead as string | undefined) ?? null, + comments: 0, + attachments: 0 + } + await ctx.client.createDoc(tracker.class.Component, projectId as never, attributes as never, componentId) + + return textResult(JSON.stringify({ componentId, name: args.name }, null, 2), { created: true, componentId }) + } +} + +export const componentTools: HulyTool[] = [listComponentsTool, createComponentTool] diff --git a/pods/mcp/src/tools/document-tools.ts b/pods/mcp/src/tools/document-tools.ts new file mode 100644 index 0000000000..ee1b7a1024 --- /dev/null +++ b/pods/mcp/src/tools/document-tools.ts @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { SortingOrder } from '@hcengineering/core' +import doc from '@hcengineering/document' + +import { truncate } from '../platform/markup-reader' +import { textResult } from '../mcp/protocol' +import { numberProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { clampLimit, likePattern, toIso } from './shared' + +/** Bodies can be very large; keep responses inside a model's context window. */ +const BODY_CHAR_LIMIT = 20_000 + +interface DocumentRow { + _id: string + title: string + space: string + modifiedOn: number + createdOn: number + size: number + content?: string | null +} + +export const listDocumentsTool: HulyTool = { + name: 'huly_list_documents', + title: 'List documents', + description: + 'List documents in a teamspace or drive, most recently modified first. ' + + 'Use the id with huly_get_document to read the body. ' + + 'Get space ids from huly_list_spaces.', + readOnly: true, + inputSchema: objectSchema({ + spaceId: stringProp('Teamspace, project or drive id to list documents from.'), + search: stringProp('Case-insensitive substring match on the document title.'), + limit: { type: 'integer', description: 'Maximum documents to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const query: Record = {} + if (args.spaceId !== undefined) query.space = args.spaceId + if (args.search !== undefined) query.title = { $like: likePattern(String(args.search)) } + + const documents = (await ctx.client.findAll(doc.class.Document, query as never, { + limit: clampLimit(args.limit), + sort: { modifiedOn: SortingOrder.Descending } + })) as unknown as DocumentRow[] + + const rows = documents.map((document) => ({ + id: document._id, + title: document.title, + spaceId: document.space, + size: document.size, + modifiedOn: toIso(document.modifiedOn) + })) + + if (rows.length === 0) { + return textResult('No documents matched.', { documents: [] }) + } + + return textResult(JSON.stringify({ documents: rows }, null, 2), { documents: rows }) + } +} + +export const getDocumentTool: HulyTool = { + name: 'huly_get_document', + title: 'Read document', + description: + 'Read one document and return its text content. Long documents are truncated, and the tool ' + + 'result reports whether truncation happened.', + readOnly: true, + inputSchema: objectSchema( + { + documentId: stringProp('Document id from huly_list_documents.'), + maxChars: numberProp('Maximum characters of body to return (default 20000).', { + minimum: 100, + maximum: 100_000, + default: BODY_CHAR_LIMIT + }) + }, + ['documentId'] + ), + handler: async (ctx, args) => { + const documentId = args.documentId as string + + const documentIdQuery: Record = { _id: documentId } + const document = (await ctx.client.findOne(doc.class.Document, documentIdQuery as never)) as unknown as + | DocumentRow + | undefined + + if (document === undefined) { + return textResult( + `No document with id ${documentId} is visible to you. It may have been deleted or live in a ` + + 'space you are not a member of.', + { found: false } + ) + } + + const maxChars = typeof args.maxChars === 'number' ? args.maxChars : BODY_CHAR_LIMIT + const body = await ctx.markup.read(document.content as string) + const truncatedBody = truncate(body.trim(), maxChars) + + return textResult( + JSON.stringify( + { + id: document._id, + title: document.title, + spaceId: document.space, + modifiedOn: toIso(document.modifiedOn), + content: truncatedBody + }, + null, + 2 + ), + { + found: true, + documentId: document._id, + title: document.title, + truncated: body.length > maxChars + } + ) + } +} + +export const documentTools: HulyTool[] = [listDocumentsTool, getDocumentTool] diff --git a/pods/mcp/src/tools/document-write-tools.ts b/pods/mcp/src/tools/document-write-tools.ts new file mode 100644 index 0000000000..d932ce3183 --- /dev/null +++ b/pods/mcp/src/tools/document-write-tools.ts @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { generateId, SortingOrder } from '@hcengineering/core' +import document, { type Document } from '@hcengineering/document' +import { makeRank } from '@hcengineering/rank' + +import { textResult } from '../mcp/protocol' +import { objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' + +const NO_WRITER = + 'Document text was given but this server has no COLLABORATOR_URL configured, so nothing was changed. ' + + 'Retry without content, or ask the administrator to set COLLABORATOR_URL.' + +export const createDocumentTool: HulyTool = { + name: 'huly_create_document', + title: 'Create a document', + description: + 'Create a page in a document teamspace, optionally nested under another page. The content is Markdown. ' + + 'Get teamspace ids from huly_list_spaces and page ids from huly_list_documents. ' + + 'The new page is placed after its siblings.', + readOnly: false, + inputSchema: objectSchema( + { + teamspaceId: stringProp('Teamspace id from huly_list_spaces.'), + title: stringProp('Page title.', { minLength: 1, maxLength: 500 }), + content: stringProp('Page body. Markdown or plain text.', { maxLength: 500_000 }), + parentDocumentId: stringProp('Nest the page under this page id. Must be in the same teamspace.') + }, + ['teamspaceId', 'title'] + ), + handler: async (ctx, args) => { + const teamspaceId = args.teamspaceId as string + + const teamspaceQuery: Record = { _id: teamspaceId } + const teamspace = await ctx.client.findOne(document.class.Teamspace, teamspaceQuery as never) + if (teamspace === undefined) { + return textResult(`No teamspace with id ${teamspaceId} is visible to you, so nothing was created.`, { + created: false + }) + } + + let parent: string = document.ids.NoParent + if (args.parentDocumentId !== undefined) { + const parentQuery: Record = { _id: args.parentDocumentId, space: teamspaceId } + const found = await ctx.client.findOne(document.class.Document, parentQuery as never) + if (found === undefined) { + return textResult( + `No page with id ${String(args.parentDocumentId)} exists in teamspace ${teamspaceId}, so nothing was created.`, + { created: false } + ) + } + parent = args.parentDocumentId as string + } + + // Refuse before creating anything: a page without its text would look like success. + const content = ((args.content as string | undefined) ?? '').trim() + if (content !== '' && ctx.markupWriter === undefined) return textResult(NO_WRITER, { created: false }) + + const siblingQuery: Record = { space: teamspaceId, parent } + const last = await ctx.client.findOne(document.class.Document, siblingQuery as never, { + sort: { rank: SortingOrder.Descending }, + projection: { rank: 1 } + }) + const rank = makeRank(last?.rank, undefined) + + const id = generateId() + const contentRef = + content === '' || ctx.markupWriter === undefined + ? null + : await ctx.markupWriter.write(document.class.Document, id, 'content', content) + + const attributes: Record = { + title: args.title, + content: contentRef, + attachments: 0, + embeddings: 0, + labels: 0, + comments: 0, + references: 0, + rank, + parent + } + await ctx.client.createDoc(document.class.Document, teamspaceId as never, attributes as never, id) + + return textResult(JSON.stringify({ id, title: args.title, teamspaceId, parentDocumentId: parent }, null, 2), { + created: true, + documentId: id + }) + } +} + +export const updateDocumentTool: HulyTool = { + name: 'huly_update_document', + title: 'Edit a document', + description: + 'Change the title and/or replace the whole body of a page. Content is Markdown and REPLACES the existing text, ' + + 'so call huly_get_document first and send the full new body. Only the fields you pass change.', + readOnly: false, + inputSchema: objectSchema( + { + documentId: stringProp('Page id from huly_list_documents.'), + title: stringProp('New title.', { minLength: 1, maxLength: 500 }), + content: stringProp('New full body. Markdown or plain text.', { maxLength: 500_000 }) + }, + ['documentId'] + ), + handler: async (ctx, args) => { + const documentId = args.documentId as string + + if (args.title === undefined && args.content === undefined) { + return textResult('Nothing to change. Pass title and/or content.', { updated: false }) + } + + const query: Record = { _id: documentId } + const page = await ctx.client.findOne(document.class.Document, query as never) + if (page === undefined) { + return textResult(`No document with id ${documentId} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + const changed: string[] = [] + const operations: Record = {} + if (args.title !== undefined) operations.title = args.title + + if (args.content !== undefined) { + if (ctx.markupWriter === undefined) return textResult(NO_WRITER, { updated: false }) + const content = args.content as string + if (page.content === null || page.content === undefined || page.content === '') { + operations.content = await ctx.markupWriter.write(document.class.Document, documentId, 'content', content) + } else { + await ctx.markupWriter.update(document.class.Document, documentId, 'content', content) + changed.push('content') + } + } + + if (Object.keys(operations).length > 0) { + await ctx.client.updateDoc(document.class.Document, page.space, page._id, operations as never) + changed.push(...Object.keys(operations)) + } + + return textResult(`Updated document ${documentId}: ${[...new Set(changed)].sort().join(', ')}.`, { + updated: true, + changed + }) + } +} + +export const documentWriteTools: HulyTool[] = [createDocumentTool, updateDocumentTool] diff --git a/pods/mcp/src/tools/generic-write-tools.ts b/pods/mcp/src/tools/generic-write-tools.ts new file mode 100644 index 0000000000..eabda017d1 --- /dev/null +++ b/pods/mcp/src/tools/generic-write-tools.ts @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: EPL-2.0 + +import core, { generateId, type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' + +import { textResult } from '../mcp/protocol' +import { objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { mayDelete, mayManageSpace } from './caller-rights' +import { + checkArrayItem, + coerceFields, + creatableClasses, + profileFor, + removableClasses, + updatableClasses, + type WriteData, + type WriteProfile +} from './write-profiles' + +/** + * Generic create, update and delete for the classes that have a write profile. + * + * All three run as the caller through the workspace client, so the transactor + * still decides whether the caller may touch the target space or document. + */ + +const unsupported = (action: string, classId: string, supported: string[]): string => + `${classId} cannot be ${action} through the generic tools. Supported: ${supported.join(', ')}. ` + + 'Other classes can be read with huly_find but not written yet.' + +const isObject = (value: unknown): value is WriteData => + typeof value === 'object' && value !== null && !Array.isArray(value) + +/** Narrows a field value to the id list that array fields such as members hold. */ +const idList = (value: unknown): string[] | undefined => (Array.isArray(value) ? (value as string[]) : undefined) + +export const createDocTool: HulyTool = { + name: 'huly_create_doc', + title: 'Create a document of a supported class', + description: + 'Create a component, milestone, issue template, label or document teamspace. data holds the fields ' + + '(see huly_describe_class for names and types); dates are ISO-8601, rich text is Markdown, and ' + + 'counters and defaults are filled in for you. spaceId is required for project-scoped classes ' + + '(component, milestone, issue template) and ignored for labels and teamspaces. ' + + 'Supported classes: ' + + creatableClasses().join(', ') + + '. Issues and documents have their own tools.', + readOnly: false, + inputSchema: objectSchema( + { + classId: stringProp('Class id to create.'), + spaceId: stringProp('Project id for project-scoped classes.'), + data: { type: 'object', description: 'Field values, e.g. {"label": "Backend"}.' } + }, + ['classId', 'data'] + ), + handler: async (ctx, args) => { + const classId = args.classId as string + const profile = profileFor(classId) + if (profile?.create === undefined) { + return textResult(unsupported('created', classId, creatableClasses()), { created: false }) + } + const create = profile.create + + const hierarchy = ctx.client.getHierarchy() + const coerced = coerceFields(hierarchy, profile, args.data as WriteData) + if (!coerced.ok) return textResult(coerced.error, { created: false }) + + const missing = (profile.required ?? []).filter((field) => coerced.data[field] === undefined) + if (missing.length > 0) { + return textResult(`Missing required field(s) for ${classId}: ${missing.join(', ')}.`, { created: false }) + } + + let spaceId: string + if (create.space === 'given') { + if (typeof args.spaceId !== 'string') { + return textResult(`spaceId is required to create a ${classId}. Use huly_list_projects to find one.`, { + created: false + }) + } + const query: Record = { _id: args.spaceId } + const space = (await ctx.client.findOne(core.class.Space, query as never)) as unknown as + | { _class: string } + | undefined + if (space === undefined) { + return textResult(`No space with id ${args.spaceId} is visible to you, so nothing was created.`, { + created: false + }) + } + if (create.spaceClass !== undefined && !hierarchy.isDerived(space._class as Ref>, create.spaceClass as Ref>)) { + return textResult(`${classId} can only be created in a ${create.spaceClass}; ${args.spaceId} is a ${space._class}.`, { + created: false + }) + } + spaceId = args.spaceId + } else { + spaceId = create.space + } + + // The caller's data wins over the defaults, except for the array fields the + // profile lists in unionDefaults: there the default items are kept as well, + // so a caller passing `members: []` cannot create a teamspace they are not + // a member or owner of — and therefore cannot manage afterwards. + const defaults = create.defaults(ctx, coerced.data) + let values: WriteData = { ...defaults, ...coerced.data } + for (const field of create.unionDefaults ?? []) { + const base = idList(defaults[field]) + const given = idList(coerced.data[field]) + if (base !== undefined && given !== undefined) { + values[field] = [...new Set([...base, ...given])] + } + } + if (create.prepare !== undefined) { + const prepared = await create.prepare(ctx, spaceId, values) + if (typeof prepared === 'string') return textResult(prepared, { created: false }) + values = { ...values, ...prepared } + } + + const id = generateId() + await ctx.client.createDoc(classId as Ref>, spaceId as never, values as never, id) + + return textResult(JSON.stringify({ id, classId, spaceId }, null, 2), { created: true, id }) + } +} + +/** + * Builds the `$push` / `$pull` operations, refusing fields the profile does not + * allow and items whose type does not match what the field holds — `$push` and + * `$pull` each carry ONE item, never a whole replacement list. + */ +function arrayOperations ( + hierarchy: Hierarchy, + profile: WriteProfile, + push: WriteData | undefined, + pull: WriteData | undefined +): { ok: true, operations: WriteData } | { ok: false, error: string } { + const operations: WriteData = {} + for (const [key, source] of [['$push', push], ['$pull', pull]] as const) { + if (source === undefined) continue + for (const [field, item] of Object.entries(source)) { + if (!(profile.pushable ?? []).includes(field)) { + const pushable = profile.pushable ?? [] + const allowed = pushable.length === 0 ? 'none' : pushable.join(', ') + return { ok: false, error: `"${field}" cannot be added to or removed from on ${profile.classId}. Allowed: ${allowed}.` } + } + const wrongType = checkArrayItem(hierarchy, profile, field, item) + if (wrongType !== undefined) return { ok: false, error: wrongType } + } + operations[key] = source + } + return { ok: true, operations } +} + +export const updateDocTool: HulyTool = { + name: 'huly_update_doc', + title: 'Update a document of a supported class', + description: + 'Change fields on a component, milestone, issue template, label, document teamspace or tracker project. ' + + 'data sets fields; push and pull add or remove one item from an array field such as members or owners ' + + '(e.g. push {"members": ""} to add a member to a project or teamspace). ' + + 'Only the fields you pass change. Supported classes: ' + + updatableClasses().join(', ') + + '. Issues and documents have their own update tools.', + readOnly: false, + inputSchema: objectSchema( + { + classId: stringProp('Class id of the document.'), + id: stringProp('Document id from huly_find.'), + data: { type: 'object', description: 'Fields to set.' }, + push: { type: 'object', description: 'Array field -> item to add, e.g. {"members": ""}.' }, + pull: { type: 'object', description: 'Array field -> item to remove.' } + }, + ['classId', 'id'] + ), + handler: async (ctx, args) => { + const classId = args.classId as string + const profile = profileFor(classId) + if (profile === undefined || profile.writable.length === 0) { + return textResult(unsupported('updated', classId, updatableClasses()), { updated: false }) + } + + const data = isObject(args.data) ? args.data : {} + const hierarchy = ctx.client.getHierarchy() + const coerced = coerceFields(hierarchy, profile, data) + if (!coerced.ok) return textResult(coerced.error, { updated: false }) + + const arrays = arrayOperations( + hierarchy, + profile, + isObject(args.push) ? args.push : undefined, + isObject(args.pull) ? args.pull : undefined + ) + if (!arrays.ok) return textResult(arrays.error, { updated: false }) + + const operations: WriteData = { ...coerced.data, ...arrays.operations } + if (Object.keys(operations).length === 0) { + return textResult('Nothing to change. Pass data, push or pull.', { updated: false }) + } + + const query: Record = { _id: args.id } + const doc = (await ctx.client.findOne(classId as Ref>, query as never)) as unknown as Doc | undefined + if (doc === undefined) { + return textResult(`No ${classId} with id ${String(args.id)} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + // The transactor lets any member write to a public space; the web app only lets its owners manage it. + if (hierarchy.isDerived(classId as Ref>, core.class.Space as Ref>) && !(await mayManageSpace(ctx, doc))) { + return textResult( + `Only a workspace owner, an owner of this ${profile.label.toLowerCase()}, or its creator may change it. ` + + 'Nothing was changed.', + { updated: false } + ) + } + + await ctx.client.updateDoc(classId as Ref>, doc.space as never, doc._id as never, operations as never) + + return textResult(`Updated ${classId} ${String(args.id)}: ${Object.keys(operations).sort().join(', ')}.`, { + updated: true, + changed: Object.keys(operations) + }) + } +} + +export const deleteDocTool: HulyTool = { + name: 'huly_delete_doc', + title: 'Delete a document of a supported class', + description: + 'Permanently delete an issue, document, component, milestone, issue template, label or document teamspace. ' + + 'This cannot be undone. Deleting a component or milestone detaches the issues that used it first. ' + + 'Supported classes: ' + + removableClasses().join(', ') + + '. A tracker project is archived with huly_update_doc {"archived": true} instead.', + readOnly: false, + destructive: true, + inputSchema: objectSchema({ classId: stringProp('Class id of the document.'), id: stringProp('Document id.') }, [ + 'classId', + 'id' + ]), + handler: async (ctx, args) => { + const classId = args.classId as string + const profile = profileFor(classId) + if (profile?.remove === undefined) { + return textResult(unsupported('deleted', classId, removableClasses()), { deleted: false }) + } + const remove = profile.remove + + const query: Record = { _id: args.id } + const doc = (await ctx.client.findOne(classId as Ref>, query as never)) as unknown as + | (Doc & { attachedTo?: string, attachedToClass?: string, collection?: string }) + | undefined + if (doc === undefined) { + return textResult(`No ${classId} with id ${String(args.id)} is visible to you, so nothing was deleted.`, { + deleted: false + }) + } + + // Same rule as the web app's delete action: a workspace owner, or the creator. + if (!(await mayDelete(ctx, doc))) { + return textResult( + `Only a workspace owner or the creator of this ${profile.label.toLowerCase()} may delete it. Nothing was deleted.`, + { deleted: false } + ) + } + + // A string from beforeRemove refuses the delete, as a string from prepare refuses a create. + const refusal = await remove.beforeRemove?.(ctx, doc) + if (typeof refusal === 'string') { + return textResult(refusal, { deleted: false }) + } + + if (remove.attached === true) { + await ctx.client.removeCollection( + classId as Ref>, + doc.space as never, + doc._id as never, + doc.attachedTo as never, + doc.attachedToClass as never, + doc.collection as string + ) + } else { + await ctx.client.removeDoc(classId as Ref>, doc.space as never, doc._id as never) + } + + return textResult(`Deleted ${classId} ${String(args.id)}.`, { deleted: true }) + } +} + +export const genericWriteTools: HulyTool[] = [createDocTool, updateDocTool, deleteDocTool] diff --git a/pods/mcp/src/tools/issue-tools.ts b/pods/mcp/src/tools/issue-tools.ts new file mode 100644 index 0000000000..fd83e1289d --- /dev/null +++ b/pods/mcp/src/tools/issue-tools.ts @@ -0,0 +1,807 @@ +// SPDX-License-Identifier: EPL-2.0 + +import chunter, { type ChatMessage } from '@hcengineering/chunter' +import core, { type Class, type Doc, generateId, type Ref, SortingOrder } from '@hcengineering/core' +import { makeRank } from '@hcengineering/rank' +import task from '@hcengineering/task' +import tracker, { IssuePriority, MilestoneStatus, type Issue, type Milestone } from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { toMarkup } from '../platform/markup' +import { booleanProp, nullableStringProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { clampLimit, likePattern, MILESTONE_STATUS_NAMES, personNames, projectNames, statusNames, toIso } from './shared' + +/** The slice of an Issue the tools read. */ +interface IssueRow { + _id: string + number: number + title: string + status: string + priority: number + assignee: string | null + space: string + labels: number + rank: string + createdOn: number + modifiedOn: number + startDate: number | null + dueDate: number | null + milestone: string | null + component?: string | null + description?: string | null + estimation: number +} + +/** How far ahead a milestone's target date lands when the caller gives none; matches the web client. */ +const DEFAULT_MILESTONE_SPAN_MS = 14 * 24 * 60 * 60 * 1000 + +const PRIORITY_NAMES = Object.keys(IssuePriority).filter((key) => Number.isNaN(Number(key))) + +const priorityName = (value: number): string => PRIORITY_NAMES[value] ?? `Unknown(${value})` + +const priorityIndex = (name: string | undefined): number => { + if (name === undefined) return IssuePriority.NoPriority + const index = PRIORITY_NAMES.indexOf(name) + return index < 0 ? IssuePriority.NoPriority : index +} + +/** + * Turns raw issues into the shape an agent can act on: human names instead of + * ids, ISO dates instead of epoch millis, and an `identifier-number` key. + */ +async function formatIssues (ctx: ToolContext, issues: IssueRow[]): Promise>> { + const [people, statuses, projects] = await Promise.all([ + personNames( + ctx.client, + issues.map((issue) => issue.assignee) + ), + statusNames( + ctx.client, + issues.map((issue) => issue.status) + ), + projectNames( + ctx.client, + issues.map((issue) => issue.space) + ) + ]) + + return issues.map((issue) => { + const project = projects.get(issue.space) + const status = statuses.get(issue.status) + return { + id: issue._id, + key: project?.identifier != null ? `${project.identifier}-${issue.number}` : `${issue.number}`, + title: issue.title, + status: status?.name ?? 'Unknown', + statusCategory: status?.category ?? null, + priority: priorityName(issue.priority), + assignee: issue.assignee === null ? null : (people.get(issue.assignee) ?? 'Unknown'), + projectId: issue.space, + project: project?.name ?? null, + labels: issue.labels, + startDate: toIso(issue.startDate), + dueDate: toIso(issue.dueDate), + milestoneId: issue.milestone ?? null, + componentId: issue.component ?? null, + estimation: issue.estimation, + modifiedOn: toIso(issue.modifiedOn) + } + }) +} + +/** Status categories that mean the work is finished, whether completed or canceled. */ +const DONE_CATEGORIES = new Set(['won', 'lost']) + +/** Ids of the issue statuses matching a name and/or excluding finished ones. */ +async function allowedStatusIds (ctx: ToolContext, name: string | undefined, excludeDone: boolean): Promise { + const query: Record = { ofAttribute: tracker.attribute.IssueStatus } + const statuses = (await ctx.client.findAll(tracker.class.IssueStatus, query as never, { + limit: 200 + })) as unknown as Array<{ _id: string }> + const resolved = await statusNames( + ctx.client, + statuses.map((status) => status._id) + ) + + const wanted = name?.toLowerCase() + return statuses + .filter((status) => { + const info = resolved.get(status._id) + if (wanted !== undefined && (info?.name ?? '').toLowerCase() !== wanted) return false + if (excludeDone && DONE_CATEGORIES.has((info?.category ?? '').toLowerCase())) return false + return true + }) + .map((status) => status._id) +} + +export const listIssuesTool: HulyTool = { + name: 'huly_list_issues', + title: 'List issues', + description: + 'List issues, most recently modified first. Filter by project, status name, assignee or free text. ' + + 'Issue statuses are matched by name; call huly_list_issue_statuses to see the valid values. ' + + 'Returns at most 200 issues per call; narrow the filters for large projects.', + readOnly: true, + inputSchema: objectSchema({ + projectId: stringProp('Restrict to one project id.'), + status: stringProp('Restrict to one status, matched case-insensitively by name (e.g. "In Progress").'), + assignee: stringProp('Restrict to issues assigned to this person id. Use huly_find_people to look one up.'), + search: stringProp('Case-insensitive substring match against the issue title.'), + includeDone: booleanProp('Include issues in a "done" category. Defaults to true.'), + limit: { type: 'integer', description: 'Maximum issues to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const limit = clampLimit(args.limit) + const query: Record = {} + + if (args.projectId !== undefined) query.space = args.projectId + if (args.assignee !== undefined) query.assignee = args.assignee + if (args.search !== undefined) { + query.title = { $like: likePattern(String(args.search)) } + } + + // Status is a reference to a status document, so a name or a "done" filter + // is turned into a set of status ids first. Filtering before the limit + // means a page is never emptied by a filter applied afterwards. + if (args.status !== undefined || args.includeDone === false) { + const allowed = await allowedStatusIds(ctx, args.status as string | undefined, args.includeDone === false) + if (allowed.length === 0) { + return textResult( + `No issue status matches "${String(args.status)}". Call huly_list_issue_statuses for the valid names.`, + { issues: [] } + ) + } + query.status = { $in: allowed } + } + + const issues = (await ctx.client.findAll(tracker.class.Issue, query as never, { + limit, + sort: { modifiedOn: SortingOrder.Descending } + })) as unknown as IssueRow[] + + if (issues.length === 0) { + return textResult('No issues matched. Try widening the filters or call huly_list_projects.', { + issues: [] + }) + } + + const formatted = await formatIssues(ctx, issues) + return textResult(JSON.stringify({ issues: formatted }, null, 2), { issues: formatted }) + } +} + +export const getIssueTool: HulyTool = { + name: 'huly_get_issue', + title: 'Get issue', + description: + 'Get one issue in full: description text, subtasks and comments. ' + + 'Pass the issue id returned by huly_list_issues or huly_search.', + readOnly: true, + inputSchema: objectSchema({ issueId: stringProp('Issue id.') }, ['issueId']), + handler: async (ctx, args) => { + const issueId = args.issueId as string + + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow + | undefined + + if (issue === undefined) { + return textResult( + `No issue with id ${issueId} is visible to you. It may have been deleted or belong to another project.`, + { found: false } + ) + } + + // Sub-issues are attached documents of their parent; an issue has no `parent` field. + const subtaskQuery: Record = { space: issue.space, attachedTo: issue._id } + const commentQuery: Record = { attachedTo: issueId, collection: 'comments' } + + const [subtasks, comments, description] = await Promise.all([ + ctx.client.findAll(tracker.class.Issue, subtaskQuery as never, { + limit: 100, + sort: { rank: SortingOrder.Ascending } + }) as Promise, + ctx.client.findAll(chunter.class.ChatMessage, commentQuery as never, { + limit: 200, + sort: { createdOn: SortingOrder.Ascending } + }) as Promise, + ctx.markup.read(issue.description as string) + ]) + + const subtaskRows = await formatIssues(ctx, subtasks as IssueRow[]) + const commentRows = await formatComments(ctx, comments as ChatMessage[]) + const [formatted] = await formatIssues(ctx, [issue]) + + return textResult( + JSON.stringify( + { + ...formatted, + description: (description ?? '').trim(), + subtasks: subtaskRows, + comments: commentRows + }, + null, + 2 + ), + { found: true, issueId: issue._id, commentCount: commentRows.length, subtaskCount: subtaskRows.length } + ) + } +} + +async function formatComments (ctx: ToolContext, comments: ChatMessage[]): Promise>> { + if (comments.length === 0) return [] + const people = await personNames( + ctx.client, + comments.map((comment) => comment.createdBy as never) + ) + return comments.map((comment) => ({ + id: comment._id, + author: people.get(comment.createdBy as never) ?? 'Unknown', + createdOn: toIso(comment.createdOn), + text: (comment.message ?? '').trim() + })) +} + +export const listIssueStatusesTool: HulyTool = { + name: 'huly_list_issue_statuses', + title: 'List issue statuses', + description: + 'List every issue status in this workspace with its id, display name and category. ' + + 'Status ids are what huly_update_issue expects; names are what huly_list_issues matches on.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const query: Record = { ofAttribute: tracker.attribute.IssueStatus } + const statuses = (await ctx.client.findAll(tracker.class.IssueStatus, query as never, { + limit: 200 + })) as unknown as Array<{ _id: string, name: string }> + + const resolved = await statusNames( + ctx.client, + statuses.map((status) => status._id) + ) + + const rows = statuses.map((status) => ({ + id: status._id, + name: resolved.get(status._id)?.name ?? status.name, + category: resolved.get(status._id)?.category ?? null + })) + + return textResult(JSON.stringify({ statuses: rows }, null, 2), { statuses: rows }) + } +} + +export const createIssueTool: HulyTool = { + name: 'huly_create_issue', + title: 'Create issue', + description: + 'Create a new issue in a project. The description is plain text or Markdown. ' + + 'The issue starts in the project default status unless statusId is given. ' + + 'Due dates are ISO-8601. Call huly_get_project first to learn the default status.', + readOnly: false, + inputSchema: objectSchema( + { + projectId: stringProp('Project id from huly_list_projects.'), + title: stringProp('Short issue title.', { minLength: 1, maxLength: 500 }), + description: stringProp('Longer description. Plain text or Markdown.', { maxLength: 200_000 }), + priority: { + type: 'string', + description: 'Issue priority.', + enum: ['NoPriority', 'Urgent', 'High', 'Medium', 'Low'] + }, + statusId: stringProp('Status id from huly_list_issue_statuses. Defaults to the project default.'), + assignee: stringProp('Person id to assign. Use huly_find_people to look one up.'), + dueDate: stringProp('ISO-8601 due date, e.g. 2026-12-31 or 2026-12-31T17:00:00Z.'), + startDate: stringProp('ISO-8601 start date.'), + milestone: stringProp('Milestone id to attach the issue to. Must belong to the same project.'), + componentId: stringProp('Component id from huly_list_components. Must belong to the same project.'), + parentIssueId: stringProp('Create this as a sub-issue of the issue with this id. Must be in the same project.') + }, + ['projectId', 'title'] + ), + handler: async (ctx, args) => { + const projectId = args.projectId as string + + const projectIdQuery: Record = { _id: projectId } + const project = (await ctx.client.findOne(tracker.class.Project, projectIdQuery as never)) as unknown as + | ProjectDefaults + | undefined + + if (project === undefined) { + return textResult(`No project with id ${projectId} is visible to you, so the issue was not created.`, { + created: false + }) + } + + // The task type decides both the issue `kind` and, when the project itself + // names no default, the first status of the workflow. + const taskTypeQuery: Record = { parent: project.type, ofClass: tracker.class.Issue } + const taskType = (await ctx.client.findOne(task.class.TaskType, taskTypeQuery as never)) as unknown as + | TaskTypeDefaults + | undefined + + const statusId = (args.statusId as string | undefined) ?? project.defaultIssueStatus ?? taskType?.statuses?.[0] + if (statusId === undefined || taskType === undefined) { + return textResult( + 'The project has no default issue status or issue task type configured, so the issue was not created. ' + + 'Pass an explicit statusId, or ask an administrator to configure the project.', + { created: false } + ) + } + + // Every check that can refuse the request runs before the sequence counter + // is incremented, so a rejected call never burns an issue number. + let parent: ParentRow | undefined + if (args.parentIssueId !== undefined) { + const parentQuery: Record = { _id: args.parentIssueId } + parent = (await ctx.client.findOne(tracker.class.Issue, parentQuery as never)) as unknown as ParentRow | undefined + if (parent === undefined || parent.space !== projectId) { + return textResult( + `No issue with id ${String(args.parentIssueId)} exists in project ${projectId}, so the sub-issue was not created.`, + { created: false } + ) + } + } + + const componentId = args.componentId as string | undefined + if (componentId !== undefined && !(await docInProject(ctx, tracker.class.Component, componentId, projectId))) { + return textResult( + `No component with id ${componentId} exists in project ${projectId}, so the issue was not ` + + 'created. Call huly_list_components for the valid ids.', + { created: false } + ) + } + + // Milestones live in the project's space just like components do, so the + // same "does it belong here" check applies. + const milestoneId = args.milestone as string | undefined + if (milestoneId !== undefined && !(await docInProject(ctx, tracker.class.Milestone, milestoneId, projectId))) { + return textResult( + `No milestone with id ${milestoneId} exists in project ${projectId}, so the issue was not ` + + 'created. Call huly_list_milestones for the valid ids.', + { created: false } + ) + } + + // The assignee must be a person this workspace can see. personNames is the + // same lookup the list tools use to turn assignee ids into names, so an id + // it does not know would otherwise persist and render as "Unknown" forever. + const assignee = args.assignee as string | undefined + if (assignee !== undefined && !(await personNames(ctx.client, [assignee])).has(assignee)) { + return textResult( + `No person with id ${assignee} is visible in this workspace, so the issue was not created. ` + + 'Call huly_find_people for the valid ids.', + { created: false } + ) + } + + // The body is stored as a blob owned by the collaborator service. Refuse up + // front, before a number is consumed, when that service is not configured: + // a missing COLLABORATOR_URL is fixable by the caller, a burned number is not. + const issueId = generateId() + const description = ((args.description as string | undefined) ?? '').trim() + if (description !== '' && ctx.markupWriter === undefined) { + return textResult( + 'A description was given but this server has no COLLABORATOR_URL configured, so the issue was not ' + + 'created. Retry without a description, or ask the administrator to set COLLABORATOR_URL.', + { created: false } + ) + } + + // A new issue belongs at the END of the project's rank order, so the rank + // is derived from the current maximum. This mirrors what the importer and + // the server's RankMiddleware do for issues: with no issues yet the query + // returns nothing and makeRank falls back to the very first rank, so no + // rank string is ever hardcoded here. + const rankQuery: Record = { space: projectId } + const last = await ctx.client.findOne(tracker.class.Issue, rankQuery as never, { + sort: { rank: SortingOrder.Descending }, + projection: { rank: 1 } + }) + const rank = makeRank(last?.rank, undefined) + + // Numbers are assigned by incrementing the project's sequence counter, which + // the transactor applies atomically. This is what the web client does, so + // concurrent creates never receive the same number. + // + // The reservation deliberately runs before the description blob is written. + // If the reservation fails, nothing has been persisted yet. Had the blob + // been written first, a failed reservation would leave it orphaned with no + // issue ever able to reference it again, and no way to reclaim it. A later + // failure costs at most a gap in the identifier sequence, which is purely + // cosmetic. + const increment: Record = { $inc: { sequence: 1 } } + const incremented = (await ctx.client.updateDoc( + tracker.class.Project, + core.space.Space, + projectId as never, + increment as never, + true + )) as unknown as { object?: { sequence?: number } } + const number = incremented.object?.sequence + if (typeof number !== 'number') { + throw new Error('The workspace did not return the next issue number') + } + const identifier = `${project.identifier ?? '?'}-${number}` + + const descriptionRef = + description === '' || ctx.markupWriter === undefined + ? null + : await ctx.markupWriter.write(tracker.class.Issue, issueId, 'description', description) + + const attributes: Record = { + title: args.title, + description: descriptionRef, + assignee: assignee ?? null, + component: componentId ?? null, + milestone: milestoneId ?? null, + number, + identifier, + kind: taskType._id, + status: statusId, + priority: priorityIndex(args.priority as string | undefined), + rank, + comments: 0, + subIssues: 0, + // Ancestors nearest first, as the web client stores them. + parents: + parent === undefined + ? [] + : [ + { + parentId: parent._id, + parentTitle: parent.title, + space: parent.space, + identifier: parent.identifier + }, + ...(parent.parents ?? []) + ], + childInfo: [], + relations: [], + startDate: toTimestamp(args.startDate as string | undefined), + dueDate: toTimestamp(args.dueDate as string | undefined), + estimation: 0, + remainingTime: 0, + reportedTime: 0, + reports: 0 + } + + await ctx.client.addCollection( + tracker.class.Issue, + projectId as never, + (parent?._id ?? tracker.ids.NoParent) as never, + tracker.class.Issue, + 'subIssues', + attributes as never, + issueId + ) + + return textResult( + JSON.stringify({ id: issueId, key: identifier, title: args.title, parentIssueId: parent?._id ?? null }, null, 2), + { created: true, issueId } + ) + } +} + +interface ParentRow { + _id: string + title: string + identifier: string + space: string + parents?: Array<{ parentId: string, parentTitle: string, space: string, identifier: string }> +} + +/** + * True when the document exists and lives in the given project's space. + * + * Components and milestones are both stored with `space` set to the project + * they belong to, so one check covers either kind of id. + */ +async function docInProject ( + ctx: ToolContext, + _class: Ref>, + docId: string, + projectId: string +): Promise { + const query: Record = { _id: docId, space: projectId } + return (await ctx.client.findOne(_class, query as never)) !== undefined +} + +interface ProjectDefaults { + identifier?: string + type?: string + defaultIssueStatus?: string | null +} + +interface TaskTypeDefaults { + _id: string + statuses?: string[] +} + +export const updateIssueTool: HulyTool = { + name: 'huly_update_issue', + title: 'Update issue', + description: + 'Change fields on an existing issue. Only the fields you pass are changed; everything else is left alone. ' + + 'Use statusId from huly_list_issue_statuses. Passing null for dueDate, startDate, assignee, ' + + 'milestone or componentId clears that field.', + readOnly: false, + inputSchema: objectSchema( + { + issueId: stringProp('Issue id from huly_list_issues.'), + title: stringProp('New title.', { maxLength: 500 }), + statusId: stringProp('New status id from huly_list_issue_statuses.'), + priority: { + type: 'string', + description: 'New priority.', + enum: ['NoPriority', 'Urgent', 'High', 'Medium', 'Low'] + }, + assignee: nullableStringProp('New assignee person id, or null to unassign.'), + dueDate: nullableStringProp('New ISO-8601 due date, or null to clear it.'), + startDate: nullableStringProp('New ISO-8601 start date, or null to clear it.'), + milestone: nullableStringProp('New milestone id, or null to detach.'), + componentId: nullableStringProp('New component id from huly_list_components, or null to clear it.') + }, + ['issueId'] + ), + handler: async (ctx, args) => { + const issueId = args.issueId as string + + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow + | undefined + + if (issue === undefined) { + return textResult(`No issue with id ${issueId} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + const operations: Record = {} + + if (args.title !== undefined) operations.title = args.title + if (args.statusId !== undefined) operations.status = args.statusId + if (args.priority !== undefined) operations.priority = priorityIndex(args.priority as string) + if (args.assignee !== undefined) operations.assignee = args.assignee + if (args.dueDate !== undefined) operations.dueDate = toTimestamp(args.dueDate as string) + if (args.startDate !== undefined) operations.startDate = toTimestamp(args.startDate as string) + if (args.milestone !== undefined) operations.milestone = args.milestone + if (args.componentId !== undefined) { + const componentId = args.componentId as string | null + if (componentId !== null && !(await docInProject(ctx, tracker.class.Component, componentId, issue.space))) { + return textResult( + `No component with id ${componentId} exists in the issue's project, so nothing was changed. ` + + 'Call huly_list_components for the valid ids.', + { updated: false } + ) + } + operations.component = args.componentId + } + + const changed = Object.keys(operations) + if (changed.length === 0) { + return textResult( + 'No fields to update. Pass at least one of title, statusId, priority, assignee, dueDate, startDate, milestone or componentId.', + { updated: false } + ) + } + + await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issueId as never, operations as never) + + return textResult(`Updated issue ${issueId}: ${changed.sort().join(', ')}.`, { updated: true, changed }) + } +} + +export const addCommentTool: HulyTool = { + name: 'huly_add_issue_comment', + title: 'Comment on an issue', + description: 'Append a comment to an issue. Comments are visible to everyone with access to the issue.', + readOnly: false, + inputSchema: objectSchema( + { + issueId: stringProp('Issue id from huly_list_issues.'), + text: stringProp('Comment body. Plain text or Markdown.', { minLength: 1, maxLength: 100_000 }) + }, + ['issueId', 'text'] + ), + handler: async (ctx, args) => { + const issueId = args.issueId as string + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow + | undefined + + if (issue === undefined) { + return textResult(`No issue with id ${issueId} is visible to you, so no comment was added.`, { + created: false + }) + } + + // Comments are attached documents living in the issue's own space, and the + // message body is stored as rich text, not as the raw string the agent sent. + const messageId = generateId() + const attributes: Record = { message: toMarkup(args.text as string) } + await ctx.client.addCollection( + chunter.class.ChatMessage, + issue.space as never, + issueId as never, + tracker.class.Issue, + 'comments', + attributes as never, + messageId + ) + + return textResult(`Commented on issue ${issueId}.`, { created: true, messageId }) + } +} + +export const createMilestoneTool: HulyTool = { + name: 'huly_create_milestone', + title: 'Create milestone', + description: + 'Create a milestone in a project and optionally attach existing issues to it. ' + + 'Use huly_list_issues to find issue ids first. Issues that could not be attached are ' + + 'reported back under skippedIssues rather than silently dropped.', + readOnly: false, + inputSchema: objectSchema( + { + projectId: stringProp('Project id from huly_list_projects.'), + name: stringProp('Milestone name.', { minLength: 1, maxLength: 200 }), + description: stringProp('Milestone description.', { maxLength: 20_000 }), + startDate: stringProp('ISO-8601 start date. Omit for an open-ended start.'), + targetDate: stringProp('ISO-8601 target date. Defaults to two weeks from now.'), + issueIds: { + type: 'array', + description: 'Issue ids to attach to the new milestone.', + items: { type: 'string' }, + maxItems: 200 + } + }, + ['projectId', 'name'] + ), + handler: async (ctx, args) => { + const projectId = args.projectId as string + const projectQuery: Record = { _id: projectId } + const project = await ctx.client.findOne(tracker.class.Project, projectQuery as never) + + if (project === undefined) { + return textResult(`No project with id ${projectId} is visible to you, so nothing was created.`, { + created: false + }) + } + + const milestoneId = generateId() + const attributes: Record = { + label: args.name, + description: toMarkup((args.description as string | undefined) ?? ''), + status: MilestoneStatus.Planned, + comments: 0, + attachments: 0, + startDate: toTimestamp(args.startDate as string | undefined), + targetDate: toTimestamp(args.targetDate as string | undefined) ?? Date.now() + DEFAULT_MILESTONE_SPAN_MS + } + await ctx.client.createDoc(tracker.class.Milestone, projectId as never, attributes as never, milestoneId) + + const issueIds = (args.issueIds as string[] | undefined) ?? [] + const attached: string[] = [] + + for (const issueId of issueIds) { + const issueIdQuery: Record = { _id: issueId } + const issue = (await ctx.client.findOne(tracker.class.Issue, issueIdQuery as never)) as unknown as + | IssueRow + | undefined + // Silently skipping unknown ids would make a partial failure look like a + // complete one, so unattached ids are reported back to the caller. + if (issue === undefined || issue.space !== projectId) continue + const attach: Record = { milestone: milestoneId } + await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issue._id as never, attach as never) + attached.push(issue._id) + } + + return textResult( + JSON.stringify( + { + milestoneId, + name: args.name, + attachedIssues: attached, + skippedIssues: issueIds.filter((id) => !attached.includes(id)) + }, + null, + 2 + ), + { created: true, milestoneId, attached: attached.length } + ) + } +} + +export const updateMilestoneTool: HulyTool = { + name: 'huly_update_milestone', + title: 'Update milestone', + description: + 'Change fields on an existing milestone. Only the fields you pass are changed. ' + + 'Passing null for startDate makes the start open-ended; targetDate cannot be cleared.', + readOnly: false, + inputSchema: objectSchema( + { + milestoneId: stringProp('Milestone id from huly_list_milestones.'), + name: stringProp('New name.', { minLength: 1, maxLength: 200 }), + description: stringProp('New description. Plain text or Markdown.', { maxLength: 20_000 }), + status: { + type: 'string', + description: 'New status.', + enum: MILESTONE_STATUS_NAMES + }, + startDate: nullableStringProp('New ISO-8601 start date, or null for an open-ended start.'), + targetDate: stringProp('New ISO-8601 target date.') + }, + ['milestoneId'] + ), + handler: async (ctx, args) => { + const milestoneId = args.milestoneId as string + const query: Record = { _id: milestoneId } + const milestone = (await ctx.client.findOne(tracker.class.Milestone, query as never)) as unknown as + | { _id: string, space: string } + | undefined + + if (milestone === undefined) { + return textResult(`No milestone with id ${milestoneId} is visible to you, so nothing was changed.`, { + updated: false + }) + } + + const operations: Record = {} + if (args.name !== undefined) operations.label = args.name + if (args.description !== undefined) operations.description = toMarkup(args.description as string) + if (args.status !== undefined) { + const index = MILESTONE_STATUS_NAMES.indexOf(args.status as string) + if (index < 0) { + return textResult(`Unknown milestone status "${String(args.status)}". Use ${MILESTONE_STATUS_NAMES.join(', ')}.`, { + updated: false + }) + } + operations.status = index + } + if (args.startDate !== undefined) operations.startDate = toTimestamp(args.startDate as string | null) + if (args.targetDate !== undefined) { + const target = toTimestamp(args.targetDate as string | null) + if (target === null) { + return textResult('targetDate cannot be cleared; pass an ISO-8601 date.', { updated: false }) + } + operations.targetDate = target + } + + const changed = Object.keys(operations) + if (changed.length === 0) { + return textResult('No fields to update. Pass at least one of name, description, status, startDate or targetDate.', { + updated: false + }) + } + + await ctx.client.updateDoc(tracker.class.Milestone, milestone.space as never, milestone._id as never, operations as never) + + return textResult(`Updated milestone ${milestoneId}: ${changed.sort().join(', ')}.`, { updated: true, changed }) + } +} + +function toTimestamp (value: string | null | undefined): number | null { + if (value === undefined || value === null || value === '') return null + const parsed = Date.parse(value) + if (Number.isNaN(parsed)) { + throw Error(`"${value}" is not a valid ISO-8601 date`) + } + return parsed +} + +export const issueTools: HulyTool[] = [ + listIssuesTool, + getIssueTool, + listIssueStatusesTool, + createIssueTool, + updateIssueTool, + addCommentTool, + createMilestoneTool, + updateMilestoneTool +] diff --git a/pods/mcp/src/tools/model-tools.ts b/pods/mcp/src/tools/model-tools.ts new file mode 100644 index 0000000000..d90504af98 --- /dev/null +++ b/pods/mcp/src/tools/model-tools.ts @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: EPL-2.0 + +import core, { type AnyAttribute, type Class, type Doc, type Hierarchy, type Ref, SortingOrder } from '@hcengineering/core' + +import { textResult } from '../mcp/protocol' +import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { fromMarkup } from '../platform/markup' +import { truncate } from '../platform/markup-reader' +import { clampLimit } from './shared' + +/** + * Generic, read-only access to any class in the workspace model. + * + * The curated tools cover the common screens. These four let an agent reach + * everything else (templates, space types, custom fields, other apps) by first + * discovering what exists. They run as the caller through the workspace + * client, so the transactor decides which documents come back. + */ + +/** Ceiling on the serialised size of one tool result, to protect the model's context window. */ +const MAX_RESULT_CHARS = 60_000 +/** Ceiling on one resolved rich-text field. */ +const MAX_MARKUP_CHARS = 20_000 + +/** Attributes every document carries; they are not listed per class. */ +const BASE_FIELDS = ['_id', '_class', 'space', 'modifiedOn', 'modifiedBy', 'createdOn', 'createdBy'] + +const RICH_TEXT_TYPES: string[] = [core.class.TypeCollaborativeDoc, core.class.TypeMarkup] + +const classKind = (hierarchy: Hierarchy, id: Ref>): 'mixin' | 'class' => + hierarchy.isMixin(id) ? 'mixin' : 'class' + +/** Domain of a class, or undefined when it is abstract and so cannot be queried. */ +function domainOf (hierarchy: Hierarchy, id: string): string | undefined { + return hierarchy.findDomain(id as Ref>) +} + +/** Refuses ids that are not queryable classes, with a pointer to the discovery tool. */ +function queryableError (hierarchy: Hierarchy, id: string): string | undefined { + if (!hierarchy.hasClass(id as Ref>)) { + return `"${id}" is not a class in this workspace. Call huly_list_classes to find the right id.` + } + if (domainOf(hierarchy, id) === undefined) { + return `"${id}" is an abstract class and cannot be queried directly. Call huly_list_classes with extends="${id}" to see the classes derived from it.` + } + return undefined +} + +/** Model type classes whose own names read poorly to an agent. */ +const TYPE_NAMES: Record = { RefTo: 'Ref', ArrOf: 'Array', EnumOf: 'Enum' } + +export interface TypeDescription { + type: string + to?: string + of?: string | TypeDescription +} + +/** Reduces a model `Type` to a short, readable description. */ +export function describeType (type: { _class: string, to?: string, of?: unknown }): TypeDescription { + const name = type._class.split(':').pop() ?? type._class + const kind = TYPE_NAMES[name] ?? (name.startsWith('Type') ? name.slice(4) : name) + const result: TypeDescription = { type: kind } + if (typeof type.to === 'string') result.to = type.to + if (typeof type.of === 'string') result.of = type.of + else if (typeof type.of === 'object' && type.of !== null) result.of = describeType(type.of as never) + return result +} + +export const listClassesTool: HulyTool = { + name: 'huly_list_classes', + title: 'List model classes', + description: + 'Discover what kinds of objects exist in this workspace. Returns class ids you can pass to ' + + 'huly_describe_class, huly_find and huly_get_doc. Narrow with search (substring of the id) or ' + + 'extends (a parent class id, e.g. "core:class:Space" for every kind of space). ' + + 'By default only classes that can be queried are listed.', + readOnly: true, + inputSchema: objectSchema({ + search: stringProp('Case-insensitive substring of the class id, e.g. "template" or "tracker".'), + extends: stringProp('Only classes derived from this class id. Defaults to core:class:Doc (all documents).'), + kind: { type: 'string', description: 'Restrict to classes or to mixins.', enum: ['class', 'mixin'] }, + includeAbstract: booleanProp('Also list abstract classes that cannot be queried. Defaults to false.'), + limit: { type: 'integer', description: 'Maximum classes to return (1-200, default 100).', default: 100 } + }), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const root = (args.extends as string | undefined) ?? core.class.Doc + + if (!hierarchy.hasClass(root as Ref>)) { + return textResult(`"${root}" is not a class in this workspace.`, { classes: [] }) + } + + const needle = (args.search as string | undefined)?.toLowerCase() + const limit = clampLimit(args.limit, 100) + + const candidates = new Set([root, ...hierarchy.getDescendants(root as Ref>)]) + const rows: Array> = [] + let matched = 0 + + for (const id of [...candidates].sort()) { + const klass = hierarchy.findClass(id as Ref>) + if (klass === undefined) continue + if (needle !== undefined && !id.toLowerCase().includes(needle)) continue + const kind = classKind(hierarchy, id as Ref>) + if (args.kind !== undefined && args.kind !== kind) continue + const domain = domainOf(hierarchy, id) + if (domain === undefined && args.includeAbstract !== true) continue + + matched++ + if (rows.length < limit) { + rows.push({ id, kind, extends: klass.extends ?? null, queryable: domain !== undefined, domain: domain ?? null }) + } + } + + if (rows.length === 0) { + return textResult('No classes matched. Try a shorter search or a different extends.', { classes: [] }) + } + + const payload = { total: matched, returned: rows.length, classes: rows } + return textResult(JSON.stringify(payload, null, 2), payload) + } +} + +export const describeClassTool: HulyTool = { + name: 'huly_describe_class', + title: 'Describe a class', + description: + 'Show the fields of a class: each field\'s name and type (a Ref field names the class it points to), ' + + 'whether it is required, and whether it is a custom field added by a user. Also lists the parent ' + + 'classes and collections of attached documents. Every document also has ' + + BASE_FIELDS.join(', ') + + '.', + readOnly: true, + inputSchema: objectSchema({ classId: stringProp('Class id from huly_list_classes.') }, ['classId']), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const id = args.classId as string + const ref = id as Ref> + + if (!hierarchy.hasClass(ref)) { + return textResult(`"${id}" is not a class in this workspace. Call huly_list_classes to find the right id.`, { + found: false + }) + } + + let attributes: Map + try { + attributes = hierarchy.getAllAttributes(ref, core.class.Doc) + } catch { + attributes = hierarchy.getAllAttributes(ref) + } + + const fields: Array> = [] + const collections: Array> = [] + + for (const [name, attribute] of attributes) { + if (attribute.hidden === true) continue + const description = describeType(attribute.type as never) + if (description.type === 'Collection') { + collections.push({ name, of: description.of }) + continue + } + fields.push({ + name, + ...description, + required: attribute.required === true, + custom: attribute.isCustom === true + }) + } + + const klass = hierarchy.findClass(ref) + const payload = { + id, + kind: classKind(hierarchy, ref), + extends: klass?.extends ?? null, + domain: domainOf(hierarchy, id) ?? null, + queryable: domainOf(hierarchy, id) !== undefined, + fields, + collections + } + return textResult(JSON.stringify(payload, null, 2), { found: true, ...payload }) + } +} + +/** Serialises rows, dropping from the end until the result fits the size ceiling. */ +function fitRows (rows: Array>): { rows: Array>, truncated: boolean } { + let kept = rows + while (kept.length > 1 && JSON.stringify(kept).length > MAX_RESULT_CHARS) { + kept = kept.slice(0, Math.ceil(kept.length / 2)) + } + return { rows: kept, truncated: kept.length < rows.length } +} + +function project (doc: Record, fields: string[] | undefined): Record { + if (fields === undefined) return doc + const picked: Record = {} + for (const name of ['_id', '_class', ...fields]) { + if (name in doc) picked[name] = doc[name] + } + return picked +} + +export const findTool: HulyTool = { + name: 'huly_find', + title: 'Find documents of any class', + description: + 'Query documents of any class. query is a filter object of field: value pairs; a value can be an ' + + 'operator object such as {"$in": [...]}, {"$ne": x}, {"$gt": n}, {"$like": "%text%"}. ' + + 'Get class ids from huly_list_classes and field names from huly_describe_class. ' + + 'Use fields to return only some columns. Returns at most 200 documents and reports the total match count. ' + + 'Rich-text fields come back as stored references; use huly_get_doc to read them as text.', + readOnly: true, + inputSchema: objectSchema( + { + classId: stringProp('Class id from huly_list_classes.'), + query: { type: 'object', description: 'Filter, e.g. {"space": "", "title": {"$like": "%bug%"}}. Omit for all.' }, + fields: { + type: 'array', + description: 'Only return these fields (plus _id and _class).', + items: { type: 'string' }, + maxItems: 50 + }, + sortBy: stringProp('Field to sort by. Defaults to most recently modified first.'), + ascending: booleanProp('Sort ascending instead of descending.'), + limit: { type: 'integer', description: 'Maximum documents to return (1-200, default 50).', default: 50 } + }, + ['classId'] + ), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const id = args.classId as string + + const problem = queryableError(hierarchy, id) + if (problem !== undefined) return textResult(problem, { found: false }) + + const query = (args.query as Record | undefined) ?? {} + const sortField = (args.sortBy as string | undefined) ?? 'modifiedOn' + const descending = args.sortBy === undefined ? true : args.ascending !== true + + const found = await ctx.client.findAll(id as Ref>, query as never, { + limit: clampLimit(args.limit), + sort: { [sortField]: descending ? SortingOrder.Descending : SortingOrder.Ascending }, + total: true + }) + + if (found.length === 0) { + return textResult('No documents matched. Check the field names with huly_describe_class.', { + total: 0, + documents: [] + }) + } + + const fields = args.fields as string[] | undefined + const projected = (found as unknown as Array>).map((doc) => project(doc, fields)) + const { rows, truncated } = fitRows(projected) + + const payload = { + total: found.total >= 0 ? found.total : found.length, + returned: rows.length, + ...(truncated ? { truncated: true, note: 'Output was cut to fit; narrow the query or pass fields.' } : {}), + documents: rows + } + return textResult(JSON.stringify(payload, null, 2), { + total: payload.total, + returned: rows.length, + truncated + }) + } +} + +/** Resolves one rich-text value (inline markup or a blob reference) to Markdown. */ +async function readRichText (ctx: ToolContext, value: string): Promise { + const text = value.trimStart().startsWith('{') ? fromMarkup(value) : await ctx.markup.read(value) + return truncate(text.trim(), MAX_MARKUP_CHARS) +} + +export const getDocTool: HulyTool = { + name: 'huly_get_doc', + title: 'Get one document of any class', + description: + 'Fetch one document by id with every field, and read its rich-text fields (descriptions, page bodies) ' + + 'as Markdown instead of stored references. Get the class id and document id from huly_find.', + readOnly: true, + inputSchema: objectSchema( + { classId: stringProp('Class id of the document.'), id: stringProp('Document id.') }, + ['classId', 'id'] + ), + handler: async (ctx, args) => { + const hierarchy = ctx.client.getHierarchy() + const classId = args.classId as string + + const problem = queryableError(hierarchy, classId) + if (problem !== undefined) return textResult(problem, { found: false }) + + const query: Record = { _id: args.id } + const doc = (await ctx.client.findOne(classId as Ref>, query as never)) as unknown as + | Record + | undefined + + if (doc === undefined) { + return textResult( + `No ${classId} with id ${String(args.id)} is visible to you. It may not exist or be in a space you cannot access.`, + { found: false } + ) + } + + const attributes = hierarchy.getAllAttributes((doc._class ?? classId) as Ref>, core.class.Doc) + const resolved: string[] = [] + const result: Record = { ...doc } + + for (const [name, attribute] of attributes) { + const value = doc[name] + if (!RICH_TEXT_TYPES.includes(attribute.type._class) || typeof value !== 'string' || value === '') continue + result[name] = await readRichText(ctx, value) + resolved.push(name) + } + + return textResult(JSON.stringify(result, null, 2), { found: true, id: doc._id, richTextFields: resolved }) + } +} + +export const modelTools: HulyTool[] = [listClassesTool, describeClassTool, findTool, getDocTool] diff --git a/pods/mcp/src/tools/people-tools.ts b/pods/mcp/src/tools/people-tools.ts new file mode 100644 index 0000000000..f78d96e82b --- /dev/null +++ b/pods/mcp/src/tools/people-tools.ts @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: EPL-2.0 + +import contact, { type Person } from '@hcengineering/contact' +import core, { generateId, SortingOrder } from '@hcengineering/core' +import drive, { type Drive } from '@hcengineering/drive' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool, type ToolContext } from '../mcp/tool' +import { clampLimit, likePattern, MILESTONE_STATUS_NAMES, personNames, statusNames, taskProjectNames, toIso } from './shared' + +interface TaskRow { + _id: string + number: number + title: string + status: string + assignee: string | null + kind: string + dueDate: number | null + modifiedOn: number + labels: number + isDone?: boolean + space: string +} + +interface MilestoneRow { + _id: string + label: string + status: number + startDate: number | null + targetDate: number +} + +export const listTasksTool: HulyTool = { + name: 'huly_list_tasks', + title: 'List tasks', + description: + 'List to-do tasks and subtasks, optionally scoped to one task project (board). ' + 'Returns at most 200 per call.', + readOnly: true, + inputSchema: objectSchema({ + projectId: stringProp('Task project (board) id from huly_list_projects.'), + assignee: stringProp('Person id to filter by. Use huly_find_people.'), + includeDone: booleanProp('Include completed tasks. Defaults to true.'), + limit: { type: 'integer', description: 'Maximum tasks to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const query: Record = { space: core.space.Space } + if (args.projectId !== undefined) query.space = args.projectId + if (args.assignee !== undefined) query.assignee = args.assignee + + let rows = (await ctx.client.findAll(task.class.Task, query as never, { + limit: clampLimit(args.limit), + sort: { modifiedOn: SortingOrder.Descending } + })) as unknown as TaskRow[] + + if (args.includeDone === false) { + rows = rows.filter((row) => row.isDone !== true) + } + + if (rows.length === 0) { + return textResult('No tasks matched.', { tasks: [] }) + } + + const [people, statuses, projects] = await Promise.all([ + personNames( + ctx.client, + rows.map((row) => row.assignee) + ), + statusNames( + ctx.client, + rows.map((row) => row.status) + ), + taskProjectNames( + ctx.client, + rows.map((row) => row.space) + ) + ]) + + const tasks = rows.map((row) => ({ + id: row._id, + number: row.number, + title: row.title, + done: row.isDone === true, + status: statuses.get(row.status)?.name ?? 'Unknown', + assignee: row.assignee === null ? null : (people.get(row.assignee) ?? 'Unknown'), + projectId: row.space, + project: projects.get(row.space)?.name ?? null, + kind: row.kind, + dueDate: toIso(row.dueDate), + modifiedOn: toIso(row.modifiedOn) + })) + + return textResult(JSON.stringify({ tasks }, null, 2), { tasks }) + } +} + +async function allPeople (ctx: ToolContext, limit: number): Promise { + return (await ctx.client.findAll( + contact.class.Person, + {}, + { limit, sort: { name: SortingOrder.Ascending } } + )) as unknown as Person[] +} + +/** + * Matches on name or email in the database rather than after a `limit`, so a + * match is never missed just because it sits beyond the first page of people. + */ +async function matchingPeople (ctx: ToolContext, needle: string, limit: number): Promise { + const like = likePattern(needle) + const nameQuery: Record = { name: { $like: like } } + const valueQuery: Record = { value: { $like: like } } + + const [byName, identityHits, channelHits] = await Promise.all([ + ctx.client.findAll(contact.class.Person, nameQuery as never, { + limit, + sort: { name: SortingOrder.Ascending } + }) as unknown as Promise, + ctx.client.findAll(contact.class.SocialIdentity, valueQuery as never, { limit }) as unknown as Promise< + Array<{ attachedTo: string }> + >, + ctx.client.findAll(contact.class.Channel, valueQuery as never, { limit }) as unknown as Promise< + Array<{ attachedTo: string }> + > + ]) + + const known = new Set(byName.map((person) => person._id as string)) + const extraIds = [...identityHits, ...channelHits].map((hit) => hit.attachedTo).filter((id) => !known.has(id)) + if (extraIds.length === 0) return byName + + const idQuery: Record = { _id: { $in: [...new Set(extraIds)] } } + const byEmail = (await ctx.client.findAll(contact.class.Person, idQuery as never, { + limit + })) as unknown as Person[] + return [...byName, ...byEmail].slice(0, limit) +} + +/** Email per person, from social identities first and legacy channels second. */ +async function emailsOf (ctx: ToolContext, personIds: string[]): Promise> { + const result = new Map() + if (personIds.length === 0) return result + + const attachedQuery: Record = { attachedTo: { $in: personIds } } + const channelQuery: Record = { ...attachedQuery, provider: contact.channelProvider.Email } + const [identities, channels] = await Promise.all([ + ctx.client.findAll(contact.class.SocialIdentity, attachedQuery as never, { + limit: personIds.length * 10 + }) as unknown as Promise>, + ctx.client.findAll( + contact.class.Channel, + channelQuery as never, + { limit: personIds.length * 10 } + ) as unknown as Promise> + ]) + + for (const identity of identities) { + if (identity.type === 'email' && !result.has(identity.attachedTo)) result.set(identity.attachedTo, identity.value) + } + for (const channel of channels) { + if (!result.has(channel.attachedTo)) result.set(channel.attachedTo, channel.value) + } + return result +} + +export const findPeopleTool: HulyTool = { + name: 'huly_find_people', + title: 'Find people', + description: + 'Find people in this workspace by name or email. Use the returned id with the assignee fields ' + + 'of the issue and task tools.', + readOnly: true, + inputSchema: objectSchema({ + query: stringProp('Name or email to match, case-insensitively. Omit to list everyone.'), + limit: { type: 'integer', description: 'Maximum people to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const limit = clampLimit(args.limit) + const needle = (args.query as string | undefined)?.trim() ?? '' + + const persons = needle === '' ? await allPeople(ctx, limit) : await matchingPeople(ctx, needle, limit) + const emails = await emailsOf( + ctx, + persons.map((person) => person._id) + ) + + const rows = persons.map((person) => ({ + id: person._id, + name: person.name, + email: emails.get(person._id) ?? null + })) + + if (rows.length === 0) { + return textResult('No people matched.', { people: [] }) + } + + return textResult(JSON.stringify({ people: rows }, null, 2), { people: rows }) + } +} + +export const createPersonTool: HulyTool = { + name: 'huly_create_person', + title: 'Create person', + description: + 'Create a new person record in this workspace, optionally with an email address. ' + + 'Use this to add an external collaborator who does not have a Huly account.', + readOnly: false, + inputSchema: objectSchema( + { + name: stringProp('Full name.', { minLength: 1, maxLength: 200 }), + email: stringProp('Email address, used as the primary communication channel.') + }, + ['name'] + ), + handler: async (ctx, args) => { + const name = args.name as string + const personId = generateId() + + const attributes: Record = { + name, + avatar: null, + avatarProps: { color: 'blue' }, + personUuid: generateId(), + city: '', + comments: 0, + channels: args.email === undefined ? 0 : 1, + attachments: 0, + links: 0, + socialIds: 0 + } + await ctx.client.createDoc(contact.class.Person, contact.space.Contacts, attributes as never, personId) + + if (args.email !== undefined) { + const channel: Record = { + provider: contact.channelProvider.Email, + value: args.email + } + await ctx.client.addCollection( + contact.class.Channel, + contact.space.Contacts, + personId as never, + contact.class.Person, + 'channels', + channel as never + ) + } + + return textResult(JSON.stringify({ id: personId, name }, null, 2), { created: true, personId }) + } +} + +export const listSpacesTool: HulyTool = { + name: 'huly_list_spaces', + title: 'List spaces', + description: + 'List every typed space the user belongs to: projects, drives, document teamspaces and ' + + 'anything else that holds documents. Use the id to scope huly_list_documents.', + readOnly: true, + inputSchema: objectSchema({ + limit: { type: 'integer', description: 'Maximum spaces to return (1-200, default 100).', default: 100 } + }), + handler: async (ctx, args) => { + const query: Record = { members: ctx.account } + const spaces = (await ctx.client.findAll(core.class.TypedSpace, query as never, { + limit: clampLimit(args.limit), + sort: { name: SortingOrder.Ascending } + })) as unknown as Array<{ _id: string, name: string, type: string, archived?: boolean }> + + const rows = spaces + .filter((space) => space.archived !== true) + .map((space) => ({ id: space._id, name: space.name, type: space.type })) + + if (rows.length === 0) { + return textResult('No spaces found for this user.', { spaces: [] }) + } + + return textResult(JSON.stringify({ spaces: rows }, null, 2), { spaces: rows }) + } +} + +export const listDrivesTool: HulyTool = { + name: 'huly_list_drives', + title: 'List drives', + description: 'List the drives (file collections) in this workspace that the user belongs to.', + readOnly: true, + inputSchema: objectSchema({}), + handler: async (ctx) => { + const query: Record = { members: ctx.account } + const drives = (await ctx.client.findAll(drive.class.Drive, query as never, { + limit: 200, + sort: { name: SortingOrder.Ascending } + })) as unknown as Drive[] + + const rows = drives + .filter((item) => !item.archived) + .map((item) => ({ id: item._id, name: item.name, description: item.description ?? '' })) + + return textResult(rows.length === 0 ? 'No drives found.' : JSON.stringify({ drives: rows }, null, 2), { + drives: rows + }) + } +} + +export const listMilestonesTool: HulyTool = { + name: 'huly_list_milestones', + title: 'List milestones', + description: 'List the milestones of a project with their status, start date and target date.', + readOnly: true, + inputSchema: objectSchema({ projectId: stringProp('Project id.') }, ['projectId']), + handler: async (ctx, args) => { + const query: Record = { space: args.projectId } + const milestones = (await ctx.client.findAll(tracker.class.Milestone, query as never, { + limit: 200 + })) as unknown as MilestoneRow[] + + const rows = milestones.map((milestone) => ({ + id: milestone._id, + name: milestone.label, + status: MILESTONE_STATUS_NAMES[milestone.status] ?? `Unknown(${milestone.status})`, + startDate: toIso(milestone.startDate), + targetDate: toIso(milestone.targetDate) + })) + + return textResult( + rows.length === 0 + ? `No milestones in project ${args.projectId as string}.` + : JSON.stringify({ milestones: rows }, null, 2), + { milestones: rows } + ) + } +} + +/* -------------------------------------------------------------------------- */ + +export const personTools: HulyTool[] = [ + findPeopleTool, + createPersonTool, + listSpacesTool, + listDrivesTool, + listMilestonesTool, + listTasksTool +] diff --git a/pods/mcp/src/tools/project-tools.ts b/pods/mcp/src/tools/project-tools.ts new file mode 100644 index 0000000000..0d01401642 --- /dev/null +++ b/pods/mcp/src/tools/project-tools.ts @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { SortingOrder, type TxOperations } from '@hcengineering/core' +import task, { type Project as TaskProject } from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { booleanProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { clampLimit } from './shared' + +/** Huly's to-do/kanban project class, distinct from a tracker project. */ +const taskProjectClass = task.class.Project + +/** Fields the project tools read. Structurally typed to keep the casts minimal. */ +interface ProjectRow { + _id: string + name: string + description?: string + identifier?: string + archived?: boolean + private?: boolean + defaultIssueStatus?: string + defaultAssignee?: string +} + +const summarize = (project: ProjectRow, kind: string, issueCount?: number): Record => ({ + id: project._id, + kind, + name: project.name, + ...(project.identifier === undefined ? {} : { identifier: project.identifier }), + description: project.description ?? '', + archived: project.archived === true, + private: project.private === true, + ...(issueCount === undefined ? {} : { issueCount }) +}) + +export const listProjectsTool: HulyTool = { + name: 'huly_list_projects', + title: 'List projects', + description: + 'List the projects in this workspace that the authenticated user is a member of. ' + + 'Returns tracker projects (issue tracking) and task projects (to-do / kanban boards) together. ' + + 'Use the returned "id" with the issue, task and document tools. ' + + 'Archived projects are excluded unless includeArchived is true.', + readOnly: true, + inputSchema: objectSchema({ + includeArchived: booleanProp('Include archived projects. Defaults to false.'), + limit: { type: 'integer', description: 'Maximum number of projects to return (1-200, default 50).', default: 50 } + }), + handler: async (ctx, args) => { + const includeArchived = args.includeArchived === true + const limit = clampLimit(args.limit) + const visibility: Record = { + members: ctx.account, + ...(includeArchived ? {} : { archived: false }) + } + + const [projects, taskProjects] = await Promise.all([ + ctx.client.findAll(tracker.class.Project, visibility as never, { + limit, + sort: { name: SortingOrder.Ascending } + }) as Promise, + ctx.client.findAll(taskProjectClass, visibility as never, { + limit, + sort: { name: SortingOrder.Ascending } + }) as Promise + ]) + + const projectRows = projects as ProjectRow[] + // A tracker project is also a task project (its base class), so the second + // query returns every tracker project again. Keep only the task-only ones. + const trackerIds = new Set(projectRows.map((project) => project._id)) + const taskRows = (taskProjects as Array).filter((project) => !trackerIds.has(project._id)) + const issueCounts = await countIssuesPerProject( + ctx.client, + projectRows.map((project) => project._id) + ) + + const payload = [ + ...projectRows.map((project) => summarize(project, 'tracker', issueCounts.get(project._id) ?? 0)), + ...taskRows.map((project) => summarize(project, 'task')) + ] + + if (payload.length === 0) { + return textResult('No projects found. The user is not a member of any project, or every project is archived.', { + projects: [] + }) + } + + return textResult(JSON.stringify({ projects: payload }, null, 2), { projects: payload }) + } +} + +export const getProjectTool: HulyTool = { + name: 'huly_get_project', + title: 'Get project details', + description: + 'Get a single project by id, including its default issue status and assignee. ' + + 'Accepts both tracker project ids and task project ids. ' + + 'Call huly_list_issue_statuses for the set of statuses an issue may be moved to.', + readOnly: true, + inputSchema: objectSchema({ projectId: stringProp('Project id, as returned by huly_list_projects.') }, ['projectId']), + handler: async (ctx, args) => { + const projectId = args.projectId as string + + const projectIdQuery: Record = { _id: projectId } + const project = (await ctx.client.findOne(tracker.class.Project, projectIdQuery as never)) as unknown as + | ProjectRow + | undefined + + if (project !== undefined) { + return textResult( + JSON.stringify( + { + ...summarize(project, 'tracker'), + defaultIssueStatus: project.defaultIssueStatus ?? null, + defaultAssignee: project.defaultAssignee ?? null + }, + null, + 2 + ), + { found: true, kind: 'tracker' } + ) + } + + const taskProject = (await ctx.client.findOne(taskProjectClass, projectIdQuery as never)) as unknown as + | ProjectRow + | undefined + + if (taskProject !== undefined) { + return textResult(JSON.stringify(summarize(taskProject, 'task'), null, 2), { + found: true, + kind: 'task' + }) + } + + return textResult( + `No project with id ${projectId} is visible to you. It may have been deleted, archived, ` + + 'or belong to a space you are not a member of. Use huly_list_projects to see what is available.', + { found: false } + ) + } +} + +/** + * Counts issues per project in a single pass. + * + * One projected `findAll` rather than N per-project counts: the whole point of + * listing projects is that a caller then asks about each one, and N+1 queries + * would dominate the latency of the tool. + */ +async function countIssuesPerProject (client: TxOperations, projectIds: string[]): Promise> { + const counts = new Map() + if (projectIds.length === 0) return counts + + const query: Record = { space: { $in: projectIds } } + const issues = (await client.findAll(tracker.class.Issue, query as never, { + limit: 5000, + projection: { space: 1 } + })) as unknown as Array<{ space: string }> + + for (const issue of issues) { + counts.set(issue.space, (counts.get(issue.space) ?? 0) + 1) + } + return counts +} + +export const projectTools: HulyTool[] = [listProjectsTool, getProjectTool] diff --git a/pods/mcp/src/tools/register.ts b/pods/mcp/src/tools/register.ts new file mode 100644 index 0000000000..905aa0d299 --- /dev/null +++ b/pods/mcp/src/tools/register.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: EPL-2.0 + +import { ToolRegistry } from '../mcp/tool' +import { accountTools } from './account-tools' +import { componentTools } from './component-tools' +import { documentTools } from './document-tools' +import { documentWriteTools } from './document-write-tools' +import { genericWriteTools } from './generic-write-tools' +import { issueTools } from './issue-tools' +import { modelTools } from './model-tools' +import { personTools } from './people-tools' +import { projectTools } from './project-tools' +import { searchTools } from './search-tool' + +/** + * The complete tool surface of the server. + * + * Adding a capability means adding a file and appending it here; nothing else in + * the pod needs to change. Read tools come first so `tools/list` reads as a + * progression from "look" to "change" — the order is what a model sees. + */ +export function buildRegistry (): ToolRegistry { + return new ToolRegistry().registerAll([ + ...searchTools, + ...accountTools, + ...projectTools, + ...issueTools, + ...componentTools, + ...personTools, + ...documentTools, + ...documentWriteTools, + ...modelTools, + ...genericWriteTools + ]) +} + +/** Shown to the model after `initialize`; steers it to the right tool first. */ +export const MCP_INSTRUCTIONS = [ + 'This server exposes the Huly workspace of the authenticated user.', + '', + 'Getting started:', + '1. huly_search when you only know a topic; it returns ids for everything below.', + '2. huly_list_projects to see the projects the user belongs to.', + '3. huly_list_issues / huly_list_documents to read within a project or space.', + '', + 'Writing:', + '- Issue statuses are documents, not strings. Call huly_list_issue_statuses to get valid ids', + ' before huly_update_issue.', + '- Sub-issues are created with huly_create_issue and parentIssueId; components come from huly_list_components.', + '- People are referenced by id. Call huly_find_people to resolve a name to an id.', + '- For anything the named tools do not cover, discover it: huly_list_classes -> huly_describe_class ->', + ' huly_find / huly_get_doc. These can read any class the caller is allowed to see.', + '- Creating and editing pages: huly_create_document / huly_update_document. Other creates, updates and deletes:', + ' huly_create_doc / huly_update_doc / huly_delete_doc, which list the classes they support in their errors.', + ' Deletes are permanent; only a workspace owner or the creator of a document may delete it.', + '- Tools that modify data are refused when the server runs in read-only mode.', + '', + 'Ids are opaque strings. Always pass an id obtained from a list or search tool rather than', + 'guessing one, and read a project before creating issues in it.' +].join('\n') diff --git a/pods/mcp/src/tools/search-tool.ts b/pods/mcp/src/tools/search-tool.ts new file mode 100644 index 0000000000..6caa96738d --- /dev/null +++ b/pods/mcp/src/tools/search-tool.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: EPL-2.0 + +import contact from '@hcengineering/contact' +import doc from '@hcengineering/document' +import task from '@hcengineering/task' +import tracker from '@hcengineering/tracker' + +import { textResult } from '../mcp/protocol' +import { numberProp, objectSchema, stringProp } from '../mcp/schema' +import { type HulyTool } from '../mcp/tool' +import { clampLimit } from './shared' + +/** Classes worth searching. Anything outside this list is noise for an agent. */ +const SEARCHABLE_CLASSES = [ + tracker.class.Issue, + tracker.class.Project, + tracker.class.Milestone, + doc.class.Document, + task.class.Task, + contact.class.Person +] as const + +/** Maps a class ref to a stable, human-meaningful type name for the response. */ +const TYPE_NAMES: Record = { + [tracker.class.Issue]: 'issue', + [tracker.class.Project]: 'project', + [tracker.class.Milestone]: 'milestone', + [doc.class.Document]: 'document', + [task.class.Task]: 'task', + [contact.class.Person]: 'person' +} + +export const searchTool: HulyTool = { + name: 'huly_search', + title: 'Full-text search', + description: + 'Full-text search across issues, projects, documents, tasks, milestones and people. ' + + 'This is the best starting point when you do not know the id of what you are looking for; ' + + 'returns ranked results with the matching snippet.', + readOnly: true, + inputSchema: objectSchema({ + query: stringProp('Search terms.', { minLength: 1, maxLength: 500 }), + types: { + type: 'array', + description: 'Restrict to certain object types.', + items: { type: 'string', enum: ['issue', 'project', 'milestone', 'document', 'task', 'person'] } + }, + limit: numberProp('Maximum results to return (1-50, default 20).', { + minimum: 1, + maximum: 50, + default: 20 + }) + }), + handler: async (ctx, args) => { + const query = String(args.query) + const limit = clampLimit(args.limit, 20) + + const types = (args.types as string[] | undefined) ?? [] + const classes = + types.length === 0 ? [...SEARCHABLE_CLASSES] : SEARCHABLE_CLASSES.filter((cls) => types.includes(TYPE_NAMES[cls])) + + if (classes.length === 0) { + return textResult(`No searchable type matches: ${types.join(', ')}.`, { results: [] }) + } + + const result = await ctx.client.searchFulltext({ query, classes: [...classes] }, { limit }) + + const results = result.docs.map((entry) => ({ + id: entry.id, + // `_class` lives on the nested `doc`, not on the search hit itself. + type: TYPE_NAMES[entry.doc?._class] ?? entry.doc?._class ?? 'unknown', + title: entry.title ?? entry.shortTitle ?? entry.id, + snippet: entry.description ?? null, + score: entry.score ?? null + })) + + if (results.length === 0) { + return textResult( + `No results for "${query}". The index may lag behind recent writes, or the terms may not appear ` + + 'verbatim — try fewer or more general words.', + { results: [] } + ) + } + + return textResult(JSON.stringify({ query, total: result.total ?? results.length, results }, null, 2), { results }) + } +} + +export const searchTools: HulyTool[] = [searchTool] diff --git a/pods/mcp/src/tools/shared.ts b/pods/mcp/src/tools/shared.ts new file mode 100644 index 0000000000..1067480530 --- /dev/null +++ b/pods/mcp/src/tools/shared.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: EPL-2.0 + +import contact from '@hcengineering/contact' +import core, { type Class, type Doc, type Ref, type TxOperations } from '@hcengineering/core' +import task, { type Project as TaskProject } from '@hcengineering/task' +import tracker, { MilestoneStatus } from '@hcengineering/tracker' + +/** Names of the milestone statuses, indexed by their enum value. */ +export const MILESTONE_STATUS_NAMES = Object.keys(MilestoneStatus).filter((key) => Number.isNaN(Number(key))) + +/** Hard ceiling on rows any list tool may return, regardless of what is asked. */ +export const MAX_PAGE_SIZE = 200 +export const DEFAULT_PAGE_SIZE = 50 + +/** + * Clamps a caller-supplied page size. + * + * The bound is a correctness guard, not a preference: an unbounded `findAll` + * against a large project would try to serialise thousands of documents into a + * model response and blow up both latency and the model's context window. + */ +export function clampLimit (value: unknown, fallback: number = DEFAULT_PAGE_SIZE): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback + return Math.min(Math.max(1, Math.floor(value)), MAX_PAGE_SIZE) +} + +export function toIso (timestamp: number | null | undefined): string | null { + if (timestamp === null || timestamp === undefined || timestamp === 0) return null + return new Date(timestamp).toISOString() +} + +/** + * The id types crossing the tool boundary are plain strings. + * + * Tool arguments arrive as JSON, so a `Ref` in a tool signature is fiction + * that only the compiler believes. Accepting `string` here and casting once + * inside the query keeps the branded types where they are useful and stops + * every call site from needing a cast. + */ +export type MaybeId = string | null | undefined + +export function uniqueIds (values: MaybeId[]): string[] { + return [...new Set(values.filter((value): value is string => typeof value === 'string' && value.length > 0))] +} + +export async function findByIds ( + client: TxOperations, + _class: Ref>, + ids: MaybeId[] +): Promise { + const wanted = uniqueIds(ids) + if (wanted.length === 0) return [] + const query: Record = { _id: { $in: wanted } } + return await client.findAll(_class, query as never, { limit: wanted.length }) +} + +/** + * Resolves social ids to display names in one round trip. + * + * Person docs are workspace-local; the `AccountUuid` behind them is global. + * Tools only ever need the name, so the global id is never surfaced. + */ +export async function personNames (client: TxOperations, socialIds: MaybeId[]): Promise> { + const persons = await findByIds(client, contact.class.Person, socialIds) + return new Map(persons.map((person) => [person._id, person.name])) +} + +export interface StatusInfo { + name: string + category: string | null +} + +/** + * Resolves status ids to their human labels. + * + * `Status.name` is a plain string (it is what case-insensitive status matching + * keys off), so no i18n loader is needed on the server. The category *label* is + * an IntlString, so `defaultStatusName` is used as the readable stand-in. + */ +export async function statusNames (client: TxOperations, refs: MaybeId[]): Promise> { + const statuses = await findByIds(client, tracker.class.IssueStatus, refs) + const categories = await findByIds(client, core.class.StatusCategory, uniqueIds(statuses.map((s) => s.category))) + + const categoryByRef = new Map(categories.map((category) => [category._id, category.defaultStatusName])) + + return new Map( + statuses.map((status) => [ + status._id, + { + name: status.name, + category: status.category === undefined ? null : (categoryByRef.get(status.category) ?? null) + } + ]) + ) +} + +export interface ProjectInfo { + name: string + identifier: string | null +} + +interface ProjectRow { + _id: string + name: string + identifier?: string +} + +/** + * Resolves space ids to names for both tracker projects and task projects. + * + * They are separate classes in Huly but behave identically from a caller's + * point of view, so tools accept either kind of id. + */ +export async function projectNames (client: TxOperations, refs: MaybeId[]): Promise> { + const wanted = uniqueIds(refs) + if (wanted.length === 0) return new Map() + + // `findAll` widens these classes to their `Space` base, so the row shape is + // restated locally instead of borrowed from the plugin package. + const query: Record = { _id: { $in: wanted } } + const [projects, taskProjects] = await Promise.all([ + client.findAll(tracker.class.Project, query as never, { limit: wanted.length }), + client.findAll(task.class.Project, query as never, { limit: wanted.length }) + ]) + + const result = new Map() + for (const project of projects as unknown as ProjectRow[]) { + result.set(project._id, { name: project.name, identifier: project.identifier ?? null }) + } + // `task.class.Project` is the base class of `tracker.class.Project`, so this + // query returns the tracker projects again. Only fill in ids not seen yet, or + // the identifier gathered above would be overwritten with null. + for (const project of taskProjects as unknown as Array<{ _id: string, name: string }>) { + if (!result.has(project._id)) result.set(project._id, { name: project.name, identifier: null }) + } + return result +} + +/** Names a task project without the tracker lookup, for task-only listings. */ +export async function taskProjectNames (client: TxOperations, refs: MaybeId[]): Promise> { + const wanted = uniqueIds(refs) + if (wanted.length === 0) return new Map() + + const query: Record = { _id: { $in: wanted } } + const projects = await client.findAll(task.class.Project, query as never, { limit: wanted.length }) + return new Map( + (projects as unknown as Array<{ _id: string, name: string }>).map((project) => [ + project._id, + { name: project.name, identifier: null } + ]) + ) +} + +/** Casts a JSON-supplied id to a `Ref` for use in a typed query. */ +export const asRef = (id: string): Ref => id as Ref +export const asTaskProjectRef = asRef + +/** Builds a case-insensitive "contains" pattern with LIKE wildcards in the input escaped. */ +export function likePattern (needle: string): string { + return `%${needle.replace(/[\\%_]/g, (char) => `\\${char}`)}%` +} diff --git a/pods/mcp/src/tools/write-profiles.ts b/pods/mcp/src/tools/write-profiles.ts new file mode 100644 index 0000000000..19506a9bc6 --- /dev/null +++ b/pods/mcp/src/tools/write-profiles.ts @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: EPL-2.0 + +import core, { type AnyAttribute, type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core' +import document from '@hcengineering/document' +import tags from '@hcengineering/tags' +import task from '@hcengineering/task' +import tracker, { IssuePriority, MilestoneStatus } from '@hcengineering/tracker' + +import { type ToolContext } from '../mcp/tool' +import { toMarkup } from '../platform/markup' +import { describeType } from './model-tools' +import { MILESTONE_STATUS_NAMES } from './shared' + +/** + * The classes the generic write tools may touch, and exactly how. + * + * A write is only as good as the bookkeeping around it: the web client + * initialises counters, derives kinds and ranks, and detaches references before + * a delete. Letting an agent write an arbitrary class would skip all of that and + * leave documents the UI misrenders. So a class is writable only when it has a + * profile here, and each profile was exercised against a live workspace. + * Fields outside `writable` are refused. + */ + +export type WriteData = Record + +export interface WriteProfile { + classId: string + label: string + /** Fields accepted on create and update. */ + writable: string[] + required?: string[] + /** Array fields huly_update_doc may push to or pull from. */ + pushable?: string[] + /** Numeric enums exposed to the agent by name. */ + enums?: Record + create?: { + /** `given` when the caller names the space, otherwise the fixed space id. */ + space: 'given' | string + /** Class the named space must belong to. */ + spaceClass?: string + defaults: (ctx: ToolContext, data: WriteData) => WriteData + /** + * Array fields whose default items must survive caller input: the caller's + * list is unioned with the default instead of replacing it. Without this a + * caller could pass `members: []` and create a teamspace they cannot manage. + */ + unionDefaults?: string[] + /** Derives fields that need a lookup. Returns a message to refuse the create. */ + prepare?: (ctx: ToolContext, spaceId: string, data: WriteData) => Promise + } + remove?: { + /** Attached documents are removed through their parent's collection. */ + attached?: boolean + /** + * Clears references that would otherwise dangle. Resolves to a message that + * refuses the delete, or undefined to let it proceed — the same convention + * `prepare` uses to refuse a create. + */ + beforeRemove?: (ctx: ToolContext, doc: Doc) => Promise + } +} + +const PRIORITY_NAMES = Object.keys(IssuePriority).filter((key) => Number.isNaN(Number(key))) +const DEFAULT_MILESTONE_SPAN_MS = 14 * 24 * 60 * 60 * 1000 + +/** Colour index for a new label, stable per title like the web client's text-derived colour. */ +const colorFor = (title: string): number => { + let sum = 0 + for (const char of title) sum = (sum * 31 + char.charCodeAt(0)) % 1000 + return sum % 20 +} + +const emptyMarkup = (): string => toMarkup('') + +/** + * Issues detached per round trip while clearing a reference. + * + * Every round re-runs the SAME `{ [field]: doc._id }` query: an issue whose + * field was cleared no longer matches, so the result set only ever shrinks and + * the sweep ends on an empty query instead of on a fixed cut-off. + */ +const DETACH_BATCH_SIZE = 1000 + +/** + * Rounds the sweep may run before the delete is refused. + * + * Clearing an issue removes it from the next round's query, so a healthy + * workspace finishes in `linked issues / batch size` rounds. The cap exists for + * the pathological case where the clears do not take effect: there it turns an + * endless loop into a refused delete, and refusing is the right outcome because + * the alternative is a milestone or component removed while issues still point + * at it — exactly the dangling reference the sweep is here to prevent. + */ +const DETACH_MAX_ROUNDS = 50 + +/** + * Clears the reference on every issue linked to a document that is about to be + * deleted. Returns undefined only after a query that found nothing left, so a + * partial sweep cannot happen unnoticed; when the sweep cannot finish it + * returns a message that refuses the delete and says what is still linked. + */ +async function detachFromIssues (ctx: ToolContext, field: 'milestone' | 'component', doc: Doc): Promise { + const query: Record = { [field]: doc._id } + for (let round = 0; ; round++) { + const issues = await ctx.client.findAll(tracker.class.Issue, query as never, { limit: DETACH_BATCH_SIZE }) + if (issues.length === 0) return undefined + if (round >= DETACH_MAX_ROUNDS) { + return ( + `Detaching issues from this ${field} did not finish: the query still matched ${issues.length} of them ` + + `after ${DETACH_MAX_ROUNDS} batches, so nothing was deleted. ` + + 'No issue will be left referencing a deleted document. Try again later.' + ) + } + for (const issue of issues) { + const clear: Record = { [field]: null } + await ctx.client.updateDoc(tracker.class.Issue, issue.space as never, issue._id as never, clear as never) + } + } +} + +async function issueKindFor (ctx: ToolContext, projectId: string): Promise { + const projectQuery: Record = { _id: projectId } + const project = (await ctx.client.findOne(tracker.class.Project, projectQuery as never)) as unknown as + | { type?: string } + | undefined + const taskTypeQuery: Record = { parent: project?.type, ofClass: tracker.class.Issue } + const taskType = (await ctx.client.findOne(task.class.TaskType, taskTypeQuery as never)) as unknown as + | { _id: string } + | undefined + return taskType === undefined + ? 'The project has no issue task type configured, so nothing was created.' + : { kind: taskType._id } +} + +export const WRITE_PROFILES: WriteProfile[] = [ + { + classId: tracker.class.Component, + label: 'Issue component', + writable: ['label', 'description', 'lead'], + required: ['label'], + create: { + space: 'given', + spaceClass: tracker.class.Project, + defaults: () => ({ description: emptyMarkup(), lead: null, comments: 0, attachments: 0 }) + }, + remove: { + beforeRemove: async (ctx, doc) => await detachFromIssues(ctx, 'component', doc) + } + }, + { + classId: tracker.class.Milestone, + label: 'Issue milestone', + writable: ['label', 'description', 'status', 'startDate', 'targetDate'], + required: ['label'], + enums: { status: MILESTONE_STATUS_NAMES }, + create: { + space: 'given', + spaceClass: tracker.class.Project, + defaults: () => ({ + description: emptyMarkup(), + status: MilestoneStatus.Planned, + comments: 0, + attachments: 0, + startDate: null, + targetDate: Date.now() + DEFAULT_MILESTONE_SPAN_MS + }) + }, + remove: { + beforeRemove: async (ctx, doc) => await detachFromIssues(ctx, 'milestone', doc) + } + }, + { + classId: tracker.class.IssueTemplate, + label: 'Issue template', + writable: ['title', 'description', 'priority', 'assignee', 'component', 'milestone', 'estimation', 'labels'], + required: ['title'], + enums: { priority: PRIORITY_NAMES }, + create: { + space: 'given', + spaceClass: tracker.class.Project, + defaults: () => ({ + description: emptyMarkup(), + priority: IssuePriority.NoPriority, + assignee: null, + component: null, + milestone: null, + estimation: 0, + children: [], + labels: [], + relations: [], + comments: 0, + attachments: 0 + }), + // The task type decides the `kind`, as it does for a new issue. + prepare: async (ctx, spaceId) => await issueKindFor(ctx, spaceId) + }, + remove: {} + }, + { + classId: tags.class.TagElement, + label: 'Label', + writable: ['title', 'description', 'color', 'targetClass', 'category'], + required: ['title'], + create: { + space: core.space.Workspace, + defaults: (_ctx, data) => ({ + description: '', + targetClass: tracker.class.Issue, + color: colorFor(String(data.title ?? '')), + category: tags.category.NoCategory + }) + }, + remove: {} + }, + { + classId: document.class.Teamspace, + label: 'Document teamspace', + writable: ['name', 'description', 'private', 'archived', 'members', 'owners', 'autoJoin'], + required: ['name'], + pushable: ['members', 'owners'], + create: { + space: core.space.Space, + // The creator becomes a member and owner, as in the web client, and stays + // one even when the caller passes their own members or owners lists. + unionDefaults: ['members', 'owners'], + defaults: (ctx) => ({ + description: '', + private: false, + archived: false, + autoJoin: false, + autoJoinForRoles: [], + members: [ctx.account], + owners: [ctx.account], + type: document.spaceType.DefaultTeamspaceType + }) + }, + remove: {} + }, + { + classId: tracker.class.Project, + label: 'Tracker project', + writable: ['name', 'description', 'private', 'archived', 'members', 'owners', 'autoJoin', 'defaultAssignee'], + pushable: ['members', 'owners'] + }, + { classId: tracker.class.Issue, label: 'Issue', writable: [], remove: { attached: true } }, + { classId: document.class.Document, label: 'Document', writable: [], remove: {} } +] + +export const profileFor = (classId: string): WriteProfile | undefined => + WRITE_PROFILES.find((profile) => profile.classId === classId) + +const classesWhere = (test: (profile: WriteProfile) => boolean): string[] => + WRITE_PROFILES.filter(test).map((profile) => profile.classId) + +export const creatableClasses = (): string[] => classesWhere((profile) => profile.create !== undefined) +export const updatableClasses = (): string[] => classesWhere((profile) => profile.writable.length > 0) +export const removableClasses = (): string[] => classesWhere((profile) => profile.remove !== undefined) + +export type CoerceResult = { ok: true, data: WriteData } | { ok: false, error: string } + +function attributesOf (hierarchy: Hierarchy, classId: string): Map { + try { + return hierarchy.getAllAttributes(classId as Ref>, core.class.Doc) + } catch { + return new Map() + } +} + +const toMillis = (value: unknown): number | null | undefined => { + if (value === null) return null + if (typeof value === 'number') return value + if (typeof value === 'string') { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined +} + +/** + * Checks caller-supplied fields against the profile and the model, and converts + * them to stored form: Markdown to rich text, ISO dates to epoch milliseconds, + * enum names to their numeric value. + */ +export function coerceFields (hierarchy: Hierarchy, profile: WriteProfile, input: WriteData): CoerceResult { + const attributes = attributesOf(hierarchy, profile.classId) + const data: WriteData = {} + + for (const [field, value] of Object.entries(input)) { + if (!profile.writable.includes(field)) { + const allowed = profile.writable.length === 0 ? 'none' : profile.writable.join(', ') + return { ok: false, error: `"${field}" cannot be set on ${profile.classId}. Writable fields: ${allowed}.` } + } + const attribute = attributes.get(field) + if (attribute === undefined) { + return { ok: false, error: `"${field}" is not a field of ${profile.classId} in this workspace.` } + } + + const enumNames = profile.enums?.[field] + if (enumNames !== undefined) { + const index = typeof value === 'string' ? enumNames.indexOf(value) : -1 + if (index < 0) return { ok: false, error: `"${field}" must be one of: ${enumNames.join(', ')}.` } + data[field] = index + continue + } + + const type = describeType(attribute.type as never).type + const fail = (expected: string): CoerceResult => ({ ok: false, error: `"${field}" must be ${expected}.` }) + + switch (type) { + case 'String': + if (typeof value !== 'string') return fail('a string') + data[field] = value + break + case 'Number': + case 'Estimation': + if (typeof value !== 'number') return fail('a number') + data[field] = value + break + case 'Boolean': + if (typeof value !== 'boolean') return fail('true or false') + data[field] = value + break + case 'Date': + case 'Timestamp': { + const millis = toMillis(value) + if (millis === undefined) return fail('an ISO-8601 date, epoch milliseconds, or null') + data[field] = millis + break + } + case 'Ref': + if (value !== null && typeof value !== 'string') return fail('an id string or null') + data[field] = value + break + case 'Array': + if (!Array.isArray(value)) return fail('an array') + data[field] = value + break + case 'Markup': + if (typeof value !== 'string') return fail('Markdown text') + data[field] = toMarkup(value) + break + default: + data[field] = value + } + } + + return { ok: true, data } +} + +/** + * What one array element may be, keyed by the type name `describeType` already + * produces for `coerceFields`: the same lookup for both checks, so they can + * never disagree about what a field holds. + */ +interface ElementCheck { + /** How the element reads in an error message. */ + label: string + ok: (value: unknown) => boolean +} + +const ELEMENT_CHECKS: Record = { + String: { label: 'string', ok: (value) => typeof value === 'string' }, + Markup: { label: 'string', ok: (value) => typeof value === 'string' }, + Ref: { label: 'id string', ok: (value) => typeof value === 'string' }, + // AccountUuid and PersonId are the ids members and owners hold. + AccountUuid: { label: 'account id', ok: (value) => typeof value === 'string' }, + PersonId: { label: 'account id', ok: (value) => typeof value === 'string' }, + Number: { label: 'number', ok: (value) => typeof value === 'number' }, + Estimation: { label: 'number', ok: (value) => typeof value === 'number' }, + Boolean: { label: 'boolean', ok: (value) => typeof value === 'boolean' }, + Date: { label: 'date', ok: (value) => toMillis(value) !== undefined }, + Timestamp: { label: 'date', ok: (value) => toMillis(value) !== undefined }, + Array: { label: 'array', ok: (value) => Array.isArray(value) } +} + +/** + * Checks one pushed or pulled item against the element type the model declares + * for the field, so a whole array or a stray object can never be written where + * a single id belongs. Returns undefined when the value is acceptable. + * + * This is a type check only — it does not verify that an id exists, which is + * the transactor's job and which no helper in these tools performs today. + */ +export function checkArrayItem ( + hierarchy: Hierarchy, + profile: WriteProfile, + field: string, + value: unknown +): string | undefined { + const attribute = attributesOf(hierarchy, profile.classId).get(field) + if (attribute === undefined) { + return `"${field}" is not a field of ${profile.classId} in this workspace.` + } + const described = describeType(attribute.type as never) + if (described.type !== 'Array') { + return `"${field}" is not an array field on ${profile.classId}, so nothing can be pushed to or pulled from it.` + } + // `of` is a nested Type normally, but describeType passes a bare id through as a string. + const element = typeof described.of === 'string' ? { type: 'Ref' } : described.of + // A field with no declared element type, or one of a custom type, gives this + // check nothing to judge; the profiles only push ids, which are covered above. + const check = element === undefined ? undefined : ELEMENT_CHECKS[element.type] + if (check === undefined || check.ok(value)) return undefined + const shown = JSON.stringify(value) ?? 'undefined' + return ( + `"${field}" expects a single ${check.label} — push and pull take ONE item at a time, ` + + `e.g. push {"${field}": "<${check.label}>"}. Received: ${shown}.` + ) +} diff --git a/pods/mcp/tsconfig.json b/pods/mcp/tsconfig.json new file mode 100644 index 0000000000..0679c9ef1d --- /dev/null +++ b/pods/mcp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} diff --git a/rush.json b/rush.json index b46ef7ebb6..7bd1506478 100644 --- a/rush.json +++ b/rush.json @@ -1616,6 +1616,11 @@ "projectFolder": "pods/link-preview", "shouldPublish": false }, + { + "packageName": "@hcengineering/pod-mcp", + "projectFolder": "pods/mcp", + "shouldPublish": false + }, { "packageName": "@hcengineering/hr", "projectFolder": "plugins/hr", diff --git a/server-plugins/tracker-resources/package.json b/server-plugins/tracker-resources/package.json index 62ea5b992a..8680b71130 100644 --- a/server-plugins/tracker-resources/package.json +++ b/server-plugins/tracker-resources/package.json @@ -34,7 +34,8 @@ "typescript": "^5.9.3", "jest": "^29.7.0", "ts-jest": "^29.1.1", - "@types/jest": "^29.5.5" + "@types/jest": "^29.5.5", + "@types/node": "^24.13.3" }, "dependencies": { "@hcengineering/chunter": "workspace:^0.7.0", @@ -51,6 +52,7 @@ "@hcengineering/text-core": "workspace:^0.7.19", "@hcengineering/tracker": "workspace:^0.7.0", "@hcengineering/view": "workspace:^0.7.0", + "@hcengineering/view-resources": "workspace:^0.7.0", "@hcengineering/workbench": "workspace:^0.7.0" } } diff --git a/server-plugins/tracker-resources/src/__tests__/mockControl.ts b/server-plugins/tracker-resources/src/__tests__/mockControl.ts new file mode 100644 index 0000000000..537441a39b --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/mockControl.ts @@ -0,0 +1,107 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { TxFactory } from '@hcengineering/core' + +// A small TriggerControl for the trigger tests. `findAll` understands the query operators the triggers use (equality, +// null, $in, $nin, $ne, comparisons), sorting, limits and projections, so a test can see a wrong query or a missing +// property in a projection. + +function valueMatches (value: any, selector: any): boolean { + if (selector !== null && typeof selector === 'object' && !Array.isArray(selector)) { + for (const [op, arg] of Object.entries(selector)) { + switch (op) { + case '$in': + if (!arg.includes(value ?? null) && !arg.includes(value)) return false + break + case '$nin': + if (arg.includes(value ?? null) || arg.includes(value)) return false + break + case '$ne': + if ((arg === null && (value === null || value === undefined)) || value === arg) return false + break + case '$gt': + if (!(typeof value === 'number' && value > arg)) return false + break + case '$gte': + if (!(typeof value === 'number' && value >= arg)) return false + break + case '$lt': + if (!(typeof value === 'number' && value < arg)) return false + break + case '$lte': + if (!(typeof value === 'number' && value <= arg)) return false + break + case '$like': + return true + default: + throw new Error(`mock findAll: unsupported operator ${op}`) + } + } + return true + } + if (selector === null) return value === null || value === undefined + return value === selector +} + +export function matchesQuery (doc: any, query: Record): boolean { + return Object.entries(query).every(([key, selector]) => { + if (key.startsWith('$')) return true + return valueMatches(doc[key], selector) + }) +} + +export interface MockOptions { + docs: Record + system?: boolean + cache?: Map + txes?: any[] + // Calls of findAll, for assertions + calls?: Array<{ _class: string, query: any, options: any }> +} + +export function makeControl (options: MockOptions): any { + const { docs } = options + return { + ctx: { + info: () => {}, + warn: () => {}, + error: () => {} + }, + workspace: { url: 'ws-url', uuid: 'ws-uuid' }, + branding: { front: 'https://huly.example.com' }, + cache: options.cache ?? new Map(), + txes: options.txes ?? [], + removedMap: new Map(), + hierarchy: { + isDerived: (a: string, b: string) => a === b, + getClass: () => ({}), + findClass: () => ({}) + }, + txFactory: new TxFactory(core.account.System, true), + findAll: async (_ctx: unknown, _class: string, query: any = {}, findOptions: any = {}) => { + options.calls?.push({ _class, query, options: findOptions }) + let found = (docs[_class] ?? []).filter((d) => matchesQuery(d, query)) + const sort = findOptions.sort as Record | undefined + if (sort !== undefined) { + const [[key, dir]] = Object.entries(sort) + found = [...found].sort((a, b) => ((a[key] ?? 0) - (b[key] ?? 0)) * (dir as number)) + } + const total = found.length + if (typeof findOptions.limit === 'number') found = found.slice(0, findOptions.limit) + const projection = findOptions.projection as Record | undefined + if (projection !== undefined) { + found = found.map((d) => { + const out: any = {} + for (const key of Object.keys(projection)) if (key in d) out[key] = d[key] + return out + }) + } + // Like the real findAll, `total` is the number of matches before the limit + if (findOptions.total === true) (found as any).total = total + return found + } + } +} diff --git a/server-plugins/tracker-resources/src/__tests__/project-field-trigger.test.ts b/server-plugins/tracker-resources/src/__tests__/project-field-trigger.test.ts new file mode 100644 index 0000000000..d2795b5ce4 --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/project-field-trigger.test.ts @@ -0,0 +1,205 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { type Ref, type Space, type Tx } from '@hcengineering/core' +import tracker, { MAX_PROJECT_FIELDS, ProjectFieldType } from '@hcengineering/tracker' + +import { OnIterationRemove, OnProjectFieldCreate, OnProjectFieldRemove, OnProjectRemove } from '../index' + +const SPACE = 'project-1' as Ref + +function makeControl (docs: Record, removed: Map = new Map()): any { + return { + ctx: {}, + removedMap: removed, + findAll: async (_ctx: unknown, cls: string) => docs[cls] ?? [], + txFactory: { + createTxRemoveDoc: (_class: string, space: string, id: string) => ({ kind: 'remove', _class, space, id }), + createTxUpdateDoc: (_class: string, space: string, id: string, update: any) => ({ + kind: 'update', + _class, + space, + id, + update + }) + } + } +} + +describe('OnProjectFieldCreate', () => { + const createTx = { _class: core.class.TxCreateDoc, objectSpace: SPACE } as unknown as Tx + + it('keeps everything at the limit', async () => { + const fields = Array.from({ length: MAX_PROJECT_FIELDS }, (_, i) => ({ _id: `f${i}`, createdOn: i, space: SPACE })) + const res = await OnProjectFieldCreate([createTx], makeControl({ [tracker.class.ProjectField]: fields })) + expect(res).toEqual([]) + }) + + it('removes the newest fields beyond the limit', async () => { + const fields = Array.from({ length: MAX_PROJECT_FIELDS + 2 }, (_, i) => ({ + _id: `f${i}`, + _class: tracker.class.ProjectField, + createdOn: i, + space: SPACE + })) + const res: any[] = await OnProjectFieldCreate([createTx], makeControl({ [tracker.class.ProjectField]: fields })) + expect(res.map((r) => r.id)).toEqual([`f${MAX_PROJECT_FIELDS}`, `f${MAX_PROJECT_FIELDS + 1}`]) + }) +}) + +describe('OnProjectFieldRemove', () => { + it('strips the value from issues that have it and skips the rest', async () => { + const issues = [ + { _id: 'i1', _class: tracker.class.Issue, space: SPACE, customFields: { size: 3, team: 'a' } }, + { _id: 'i2', _class: tracker.class.Issue, space: SPACE, customFields: { team: 'b' } }, + { _id: 'i3', _class: tracker.class.Issue, space: SPACE } + ] + const removed = new Map([['field-1', { _id: 'field-1', key: 'size', space: SPACE }]]) + const tx = { objectId: 'field-1' } as unknown as Tx + const res: any[] = await OnProjectFieldRemove([tx], makeControl({ [tracker.class.Issue]: issues }, removed)) + expect(res).toHaveLength(1) + expect(res[0].id).toBe('i1') + expect(res[0].update).toEqual({ customFields: { team: 'a' } }) + }) + + it('does nothing when the removed doc is unknown', async () => { + const res = await OnProjectFieldRemove([{ objectId: 'x' } as unknown as Tx], makeControl({})) + expect(res).toEqual([]) + }) +}) + +describe('OnProjectFieldRemove for an iteration field', () => { + it('removes the iterations of the field and strips the values', async () => { + const issues = [ + { _id: 'i1', _class: tracker.class.Issue, space: SPACE, customFields: { sprint: 'it1' } }, + { _id: 'i2', _class: tracker.class.Issue, space: SPACE, customFields: { other: 'x' } } + ] + const iterations = [ + { _id: 'it1', _class: tracker.class.Iteration, space: SPACE, field: 'field-1' }, + { _id: 'it2', _class: tracker.class.Iteration, space: SPACE, field: 'field-1' } + ] + const removed = new Map([['field-1', { _id: 'field-1', key: 'sprint', type: ProjectFieldType.Iteration, space: SPACE }]]) + const res: any[] = await OnProjectFieldRemove( + [{ objectId: 'field-1' } as unknown as Tx], + makeControl({ [tracker.class.Issue]: issues, [tracker.class.Iteration]: iterations }, removed) + ) + expect(res.filter((r) => r.kind === 'remove').map((r) => r.id)).toEqual(['it1', 'it2']) + const updates = res.filter((r) => r.kind === 'update') + expect(updates).toHaveLength(1) + expect(updates[0].update).toEqual({ customFields: {} }) + }) + + it('does not look for iterations of other field types', async () => { + const iterations = [{ _id: 'it1', _class: tracker.class.Iteration, space: SPACE, field: 'field-1' }] + const removed = new Map([['field-1', { _id: 'field-1', key: 'size', type: ProjectFieldType.Number, space: SPACE }]]) + const res = await OnProjectFieldRemove( + [{ objectId: 'field-1' } as unknown as Tx], + makeControl({ [tracker.class.Iteration]: iterations }, removed) + ) + expect(res).toEqual([]) + }) +}) + +describe('OnIterationRemove', () => { + const issues = [ + { _id: 'i1', _class: tracker.class.Issue, space: SPACE, customFields: { sprint: 'it1', team: 'a' } }, + { _id: 'i2', _class: tracker.class.Issue, space: SPACE, customFields: { sprint: 'it2' } }, + { _id: 'i3', _class: tracker.class.Issue, space: SPACE, customFields: { sprint: 'it3' } }, + { _id: 'i4', _class: tracker.class.Issue, space: SPACE } + ] + const sprintField = { _id: 'field-1', key: 'sprint', type: ProjectFieldType.Iteration, space: SPACE } + const removedIteration = (id: string, field = 'field-1'): [string, any] => [ + id, + { _id: id, _class: tracker.class.Iteration, space: SPACE, field } + ] + + it('strips the deleted iteration from the issues that were in it', async () => { + const removed = new Map([removedIteration('it1')]) + const res: any[] = await OnIterationRemove( + [{ objectId: 'it1' } as unknown as Tx], + makeControl({ [tracker.class.Issue]: issues, [tracker.class.ProjectField]: [sprintField] }, removed) + ) + expect(res).toHaveLength(1) + expect(res[0].id).toBe('i1') + expect(res[0].update).toEqual({ customFields: { team: 'a' } }) + }) + + it('handles several iterations removed together with one update per issue', async () => { + const removed = new Map([removedIteration('it1'), removedIteration('it2')]) + const res: any[] = await OnIterationRemove( + [{ objectId: 'it1' }, { objectId: 'it2' }] as unknown as Tx[], + makeControl({ [tracker.class.Issue]: issues, [tracker.class.ProjectField]: [sprintField] }, removed) + ) + expect(res.map((r) => r.id).sort()).toEqual(['i1', 'i2']) + }) + + it('leaves the issues alone when the owning field is gone', async () => { + const removed = new Map([removedIteration('it1')]) + const res = await OnIterationRemove( + [{ objectId: 'it1' } as unknown as Tx], + makeControl({ [tracker.class.Issue]: issues, [tracker.class.ProjectField]: [] }, removed) + ) + expect(res).toEqual([]) + }) + + it('does nothing for an unknown removed doc', async () => { + const res = await OnIterationRemove([{ objectId: 'zzz' } as unknown as Tx], makeControl({})) + expect(res).toEqual([]) + }) +}) + +describe('OnProjectRemove', () => { + it('removes the iterations together with the rest of the project', async () => { + const iterations = [{ _id: 'it1', _class: tracker.class.Iteration, space: SPACE }] + const control = makeControl({ [tracker.class.Iteration]: iterations }) + control.ctx = { contextData: { broadcast: { targets: {} } } } + const res: any[] = await OnProjectRemove([{ objectId: SPACE } as unknown as Tx], control) + expect(res.map((r) => r.id)).toEqual(['it1']) + }) + + it('removes the saved insight charts of the project', async () => { + const charts = [ + { _id: 'c1', _class: tracker.class.InsightChart, space: SPACE }, + { _id: 'c2', _class: tracker.class.InsightChart, space: SPACE } + ] + const control = makeControl({ [tracker.class.InsightChart]: charts }) + control.ctx = { contextData: { broadcast: { targets: {} } } } + const res: any[] = await OnProjectRemove([{ objectId: SPACE } as unknown as Tx], control) + expect(res.map((r) => [r._class, r.id])).toEqual([ + [tracker.class.InsightChart, 'c1'], + [tracker.class.InsightChart, 'c2'] + ]) + }) + + it('removes the workflows and webhooks of the project and the secrets of the webhooks', async () => { + const docs = { + [tracker.class.Workflow]: [{ _id: 'w1', _class: tracker.class.Workflow, space: SPACE }], + [tracker.class.ProjectWebhook]: [{ _id: 'h1', _class: tracker.class.ProjectWebhook, space: SPACE }], + [tracker.class.ProjectWebhookSecret]: [{ _id: 's1', _class: tracker.class.ProjectWebhookSecret, space: 'person-space', webhook: 'h1' }] + } + const control = makeControl(docs) + control.ctx = { contextData: { broadcast: { targets: {} } } } + const res: any[] = await OnProjectRemove([{ objectId: SPACE } as unknown as Tx], control) + expect(res.map((r) => [r._class, r.id, r.space])).toEqual([ + [tracker.class.Workflow, 'w1', SPACE], + [tracker.class.ProjectWebhook, 'h1', SPACE], + [tracker.class.ProjectWebhookSecret, 's1', 'person-space'] + ]) + }) + + it('removes the status updates of the project', async () => { + const updates = [ + { _id: 'u1', _class: tracker.class.ProjectStatusUpdate, space: SPACE }, + { _id: 'u2', _class: tracker.class.ProjectStatusUpdate, space: SPACE } + ] + const control = makeControl({ [tracker.class.ProjectStatusUpdate]: updates }) + control.ctx = { contextData: { broadcast: { targets: {} } } } + const res: any[] = await OnProjectRemove([{ objectId: SPACE } as unknown as Tx], control) + expect(res.map((r) => [r._class, r.id, r.space])).toEqual([ + [tracker.class.ProjectStatusUpdate, 'u1', SPACE], + [tracker.class.ProjectStatusUpdate, 'u2', SPACE] + ]) + }) +}) diff --git a/server-plugins/tracker-resources/src/__tests__/project-item-limit.test.ts b/server-plugins/tracker-resources/src/__tests__/project-item-limit.test.ts new file mode 100644 index 0000000000..6194e7de1e --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/project-item-limit.test.ts @@ -0,0 +1,83 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { type Ref, type Space, type Tx } from '@hcengineering/core' +import tracker, { MAX_PROJECT_ITEMS } from '@hcengineering/tracker' + +import { OnProjectItemLimit } from '../index' +import { makeControl } from './mockControl' + +const SPACE = 'project-1' as Ref +const OTHER = 'project-2' as Ref + +function createTx (id: string, space: Ref = SPACE, objectClass: string = tracker.class.Issue): Tx { + return { _class: core.class.TxCreateDoc, objectId: id, objectClass, objectSpace: space } as unknown as Tx +} + +// A project that holds `count` issues, without building 50,000 documents: the trigger only asks for the total +function projectWith (count: number, space: Ref = SPACE): any { + const docs: any[] = [] + const control = makeControl({ docs: { [tracker.class.Issue]: docs } }) + const findAll = control.findAll + control.findAll = async (ctx: unknown, cls: string, query: any, options: any) => { + const res = await findAll(ctx, cls, query, options) + if (cls === tracker.class.Issue && options?.total === true && query.space === space) (res as any).total = count + return res + } + return control +} + +describe('OnProjectItemLimit', () => { + it('lets a project below the limit grow', async () => { + expect(await OnProjectItemLimit([createTx('i1')], projectWith(MAX_PROJECT_ITEMS - 1))).toEqual([]) + }) + + it('lets a project grow up to the limit', async () => { + expect(await OnProjectItemLimit([createTx('i1')], projectWith(MAX_PROJECT_ITEMS))).toEqual([]) + }) + + it('removes the issue that goes over the limit', async () => { + const res: any[] = await OnProjectItemLimit([createTx('i1')], projectWith(MAX_PROJECT_ITEMS + 1)) + expect(res).toHaveLength(1) + expect(res[0]._class).toBe(core.class.TxRemoveDoc) + expect(res[0].objectId).toBe('i1') + expect(res[0].objectClass).toBe(tracker.class.Issue) + expect(res[0].objectSpace).toBe(SPACE) + }) + + it('removes only the newest issues of a batch, as many as exceed the limit', async () => { + const txes = [createTx('i1'), createTx('i2'), createTx('i3')] + const res: any[] = await OnProjectItemLimit(txes, projectWith(MAX_PROJECT_ITEMS + 2)) + expect(res.map((r) => r.objectId)).toEqual(['i2', 'i3']) + }) + + it('never removes issues that existed before the batch', async () => { + const res: any[] = await OnProjectItemLimit([createTx('i1')], projectWith(MAX_PROJECT_ITEMS + 500)) + expect(res.map((r) => r.objectId)).toEqual(['i1']) + }) + + it('counts every project on its own', async () => { + const control = projectWith(MAX_PROJECT_ITEMS + 1, OTHER) + const res: any[] = await OnProjectItemLimit([createTx('i1', SPACE), createTx('i2', OTHER)], control) + // the mock answers the real (empty) count for the first project and the full one for the second + expect(res.map((r) => r.objectId)).toEqual(['i2']) + expect(res[0].objectSpace).toBe(OTHER) + }) + + it('ignores other documents and other kinds of transactions', async () => { + const control = projectWith(MAX_PROJECT_ITEMS + 10) + const update = { _class: core.class.TxUpdateDoc, objectId: 'i9', objectClass: tracker.class.Issue, objectSpace: SPACE } + expect(await OnProjectItemLimit([createTx('c1', SPACE, tracker.class.Component), update as any], control)).toEqual([]) + }) + + it('asks only for the count of the issues, not for the issues', async () => { + const calls: any[] = [] + const control = makeControl({ docs: {}, calls }) + await OnProjectItemLimit([createTx('i1')], control) + expect(calls).toEqual([ + { _class: tracker.class.Issue, query: { space: SPACE }, options: { limit: 1, total: true, projection: { _id: 1 } } } + ]) + }) +}) diff --git a/server-plugins/tracker-resources/src/__tests__/webhook-delivery.test.ts b/server-plugins/tracker-resources/src/__tests__/webhook-delivery.test.ts new file mode 100644 index 0000000000..ad640dba4a --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/webhook-delivery.test.ts @@ -0,0 +1,170 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { deliver, runDeliveries, type DeliveryJob, type DeliveryOptions } from '../webhook/delivery' +import { signPayload } from '../webhook/sign' +import { createSafeLookup, WebhookBlockedError, type WebhookRequest } from '../webhook/transport' + +const job = (props: Partial = {}): DeliveryJob => ({ + webhookId: 'w1', + url: 'https://hooks.example.com/huly', + deliveryId: 'd1', + body: '{"action":"edited"}', + ...props +}) + +function options (transport: DeliveryOptions['transport'], props: Partial = {}): DeliveryOptions { + return { transport, allowInsecure: false, timeoutMs: 1000, retryDelayMs: 10, sleep: async () => {}, ...props } +} + +describe('deliver', () => { + it('posts the body with the event headers and the signature', async () => { + const seen: WebhookRequest[] = [] + const res = await deliver(job({ secret: 's3cret' }), options(async (r) => { seen.push(r); return { status: 204 } })) + expect(res).toEqual({ ok: true, attempts: 1, status: 204 }) + expect(seen).toHaveLength(1) + expect(seen[0].url.href).toBe('https://hooks.example.com/huly') + expect(seen[0].body).toBe('{"action":"edited"}') + expect(seen[0].headers['X-Huly-Signature-256']).toBe(signPayload('s3cret', '{"action":"edited"}')) + expect(seen[0].headers['X-Huly-Event']).toBe('project_item') + expect(seen[0].headers['X-Huly-Delivery']).toBe('d1') + expect(seen[0].headers['Content-Type']).toBe('application/json') + expect(seen[0].timeoutMs).toBe(1000) + }) + + it('does not sign without a secret', async () => { + const seen: WebhookRequest[] = [] + await deliver(job(), options(async (r) => { seen.push(r); return { status: 200 } })) + await deliver(job({ secret: '' }), options(async (r) => { seen.push(r); return { status: 200 } })) + expect(seen.every((r) => !('X-Huly-Signature-256' in r.headers))).toBe(true) + }) + + it('retries once after a network error and then succeeds', async () => { + let calls = 0 + const sleeps: number[] = [] + const res = await deliver( + job(), + options( + async () => { + calls++ + if (calls === 1) throw new Error('ECONNRESET') + return { status: 200 } + }, + { sleep: async (ms) => { sleeps.push(ms) }, retryDelayMs: 250 } + ) + ) + expect(res).toEqual({ ok: true, attempts: 2, status: 200 }) + expect(sleeps).toEqual([250]) + }) + + it('retries a server error or 429 once, and gives up after the second failure', async () => { + for (const status of [500, 502, 503, 429]) { + let calls = 0 + const res = await deliver(job(), options(async () => { calls++; return { status } })) + expect(calls).toBe(2) + expect(res).toEqual({ ok: false, attempts: 2, status, failure: 'status' }) + } + let calls = 0 + const res = await deliver(job(), options(async () => { calls++; throw new Error('timeout') })) + expect(calls).toBe(2) + expect(res).toEqual({ ok: false, attempts: 2, failure: 'network' }) + }) + + it('does not retry another refusal', async () => { + for (const status of [400, 401, 403, 404, 410, 301, 302]) { + let calls = 0 + const res = await deliver(job(), options(async () => { calls++; return { status } })) + expect(calls).toBe(1) + expect(res).toEqual({ ok: false, attempts: 1, status, failure: 'status' }) + } + }) + + it('does not try a URL that is not allowed', async () => { + let calls = 0 + const transport = async (): Promise<{ status: number }> => { calls++; return { status: 200 } } + for (const url of ['http://hooks.example.com/x', 'https://127.0.0.1/x', 'https://169.254.169.254/x', 'https://localhost/x', 'https://u:p@hooks.example.com/x', 'not a url', '']) { + expect(await deliver(job({ url }), options(transport))).toEqual({ ok: false, attempts: 0, failure: 'blocked' }) + } + expect(calls).toBe(0) + }) + + it('allows http and local targets only in development mode', async () => { + let calls = 0 + const transport = async (): Promise<{ status: number }> => { calls++; return { status: 200 } } + const res = await deliver(job({ url: 'http://localhost:3000/hook' }), options(transport, { allowInsecure: true })) + expect(res.ok).toBe(true) + expect(calls).toBe(1) + }) + + it('never retries a blocked target and never throws', async () => { + let calls = 0 + const res = await deliver(job(), options(async () => { calls++; throw new WebhookBlockedError('private') })) + expect(calls).toBe(1) + expect(res).toEqual({ ok: false, attempts: 1, failure: 'blocked' }) + }) +}) + +describe('runDeliveries', () => { + it('limits how many run at once and keeps the order of the results', async () => { + let active = 0 + let peak = 0 + const transport = async (r: WebhookRequest): Promise<{ status: number }> => { + active++ + peak = Math.max(peak, active) + await new Promise((resolve) => setTimeout(resolve, 5)) + active-- + return { status: r.body === 'bad' ? 400 : 200 } + } + const jobs = Array.from({ length: 12 }, (_, i) => job({ deliveryId: `d${i}`, body: i === 3 ? 'bad' : 'ok' })) + const results = await runDeliveries(jobs, options(transport), 3) + expect(peak).toBeLessThanOrEqual(3) + expect(results).toHaveLength(12) + expect(results[3].ok).toBe(false) + expect(results.filter((r) => r.ok)).toHaveLength(11) + }) + + it('has nothing to do for no jobs', async () => { + expect(await runDeliveries([], options(async () => ({ status: 200 })))).toEqual([]) + }) +}) + +describe('createSafeLookup (SSRF guard)', () => { + type Answer = Array<{ address: string, family: number }> + const resolver = (answer: Answer | Error) => (_host: string, _o: { all: true }, cb: (err: Error | null, a: Answer) => void): void => { + if (answer instanceof Error) cb(answer, []) + else cb(null, answer) + } + const run = async (lookup: ReturnType, options: any = { all: true }): Promise<{ err: any, res: any[] }> => + await new Promise((resolve) => { + lookup('hooks.example.com', options, (err: any, ...res: any[]) => { resolve({ err, res }) }) + }) + + it('answers with public addresses, in the shape the socket asked for', async () => { + const lookup = createSafeLookup(false, resolver([{ address: '93.184.216.34', family: 4 }]) as any) + expect((await run(lookup, { all: true })).res).toEqual([[{ address: '93.184.216.34', family: 4 }]]) + expect((await run(lookup, {})).res).toEqual(['93.184.216.34', 4]) + }) + + it('refuses a name that resolves to a private address, also among public ones', async () => { + for (const address of ['127.0.0.1', '10.0.0.5', '169.254.169.254', '192.168.1.1', '::1', 'fd00::1', '::ffff:10.0.0.1']) { + const lookup = createSafeLookup(false, resolver([{ address, family: address.includes(':') ? 6 : 4 }]) as any) + const { err } = await run(lookup) + expect(err).toBeInstanceOf(WebhookBlockedError) + } + const mixed = createSafeLookup(false, resolver([{ address: '93.184.216.34', family: 4 }, { address: '10.0.0.5', family: 4 }]) as any) + expect((await run(mixed)).err).toBeInstanceOf(WebhookBlockedError) + }) + + it('refuses a name that does not resolve and passes DNS errors on', async () => { + expect((await run(createSafeLookup(false, resolver([]) as any))).err).toBeInstanceOf(WebhookBlockedError) + const dnsError = Object.assign(new Error('ENOTFOUND'), { code: 'ENOTFOUND' }) + expect((await run(createSafeLookup(false, resolver(dnsError) as any))).err).toBe(dnsError) + }) + + it('allows private addresses only in development mode', async () => { + const lookup = createSafeLookup(true, resolver([{ address: '127.0.0.1', family: 4 }]) as any) + expect((await run(lookup)).err).toBeNull() + }) +}) diff --git a/server-plugins/tracker-resources/src/__tests__/webhook-payload.test.ts b/server-plugins/tracker-resources/src/__tests__/webhook-payload.test.ts new file mode 100644 index 0000000000..5ca1181000 --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/webhook-payload.test.ts @@ -0,0 +1,282 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createHmac } from 'crypto' +import { ProjectFieldType } from '@hcengineering/tracker' + +import { + boundValue, + buildPayload, + describeChange, + diffIssue, + MAX_PAYLOAD_BYTES, + serializePayload, + type ChangeLookups +} from '../webhook/payload' +import { SIGNATURE_HEADER, signPayload, verifySignature } from '../webhook/sign' + +const lookups: ChangeLookups = { + statuses: new Map([ + ['st-todo', 'Todo'], + ['st-done', 'Done'] + ]), + people: new Map([['p1', 'Alice']]), + components: new Map([['c1', 'Backend']]), + milestones: new Map([['m1', 'v1']]), + iterations: new Map([['it1', 'Sprint 1']]), + fields: new Map([ + [ + 'size', + { key: 'size', label: 'Size', type: ProjectFieldType.SingleSelect, options: [{ value: 'o-s', label: 'S' }, { value: 'o-l', label: 'L' }] } + ], + [ + 'areas', + { key: 'areas', label: 'Areas', type: ProjectFieldType.MultiSelect, options: [{ value: 'a1', label: 'Frontend' }, { value: 'a2', label: 'Backend' }] } + ], + ['points', { key: 'points', label: 'Story points', type: ProjectFieldType.Number }], + ['target', { key: 'target', label: 'Target', type: ProjectFieldType.Date }], + ['sprint', { key: 'sprint', label: 'Sprint', type: ProjectFieldType.Iteration }], + ['notes', { key: 'notes', label: 'Notes', type: ProjectFieldType.Text }] + ]) +} + +describe('signature', () => { + it('is sha256= and the hex HMAC SHA-256 of the body', () => { + const body = '{"action":"edited"}' + const expected = createHmac('sha256', 'topsecret').update(body).digest('hex') + expect(signPayload('topsecret', body)).toBe(`sha256=${expected}`) + expect(SIGNATURE_HEADER).toBe('X-Huly-Signature-256') + }) + + it('matches the reference vector of the GitHub scheme', () => { + // From GitHub's documentation of X-Hub-Signature-256 + expect(signPayload("It's a Secret to Everybody", 'Hello, World!')).toBe( + 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17' + ) + }) + + it('signs the exact bytes, so a changed body or secret changes it', () => { + const base = signPayload('s', '{"a":1}') + expect(signPayload('s', '{"a": 1}')).not.toBe(base) + expect(signPayload('t', '{"a":1}')).not.toBe(base) + expect(signPayload('s', '{"a":1}')).toBe(base) + }) + + it('handles non ASCII bodies as UTF-8', () => { + const body = '{"title":"Привет ✓"}' + expect(signPayload('s', body)).toBe(`sha256=${createHmac('sha256', 's').update(Buffer.from(body, 'utf8')).digest('hex')}`) + }) + + it('verifies in constant time and rejects malformed headers', () => { + const header = signPayload('s', 'body') + expect(verifySignature('s', 'body', header)).toBe(true) + expect(verifySignature('s', 'body2', header)).toBe(false) + expect(verifySignature('x', 'body', header)).toBe(false) + expect(verifySignature('s', 'body', undefined)).toBe(false) + expect(verifySignature('s', 'body', header.replace('sha256=', 'sha1='))).toBe(false) + expect(verifySignature('s', 'body', header.slice(0, -2))).toBe(false) + }) +}) + +describe('diffIssue', () => { + it('lists the watched attributes that changed, one change each', () => { + const changes = diffIssue( + { status: 'st-todo' as any, priority: 1, title: 'A', estimation: 2, description: 'x' as any }, + { status: 'st-done' as any, priority: 1, title: 'B', estimation: 2, description: 'y' as any } + ) + expect(changes).toEqual([ + { fieldId: 'title', from: 'A', to: 'B' }, + { fieldId: 'status', from: 'st-todo', to: 'st-done' } + ]) + }) + + it('reports each custom field on its own and tells set and cleared values', () => { + const changes = diffIssue( + { customFields: { size: 'o-s', points: 3, keep: 1 } }, + { customFields: { size: 'o-l', keep: 1, notes: 'hi' } } + ) + expect(changes).toEqual([ + { fieldId: 'customFields.notes', from: null, to: 'hi' }, + { fieldId: 'customFields.points', from: 3, to: null }, + { fieldId: 'customFields.size', from: 'o-s', to: 'o-l' } + ]) + }) + + it('treats null, undefined and equal arrays as unchanged', () => { + expect(diffIssue({ component: null }, { component: undefined })).toEqual([]) + expect(diffIssue({ customFields: { areas: ['a1'] } }, { customFields: { areas: ['a1'] } })).toEqual([]) + expect(diffIssue({ customFields: { areas: ['a1'] } }, { customFields: { areas: ['a1', 'a2'] } })).toHaveLength(1) + }) + + it('has nothing to report for a state that is not known or a creation', () => { + expect(diffIssue(undefined, undefined)).toEqual([]) + expect(diffIssue(undefined, { status: 'st-todo' as any })).toEqual([{ fieldId: 'status', from: null, to: 'st-todo' }]) + }) +}) + +describe('describeChange', () => { + it('names the value of select-like attributes', () => { + expect(describeChange({ fieldId: 'status', from: 'st-todo', to: 'st-done' }, lookups)).toEqual({ + field_node_id: 'status', + field_name: 'Status', + field_type: 'status', + from: { id: 'st-todo', name: 'Todo' }, + to: { id: 'st-done', name: 'Done' } + }) + expect(describeChange({ fieldId: 'priority', from: 0, to: 1 }, lookups)).toMatchObject({ + from: { id: '0', name: 'No priority' }, + to: { id: '1', name: 'Urgent' } + }) + expect(describeChange({ fieldId: 'assignee', from: null, to: 'p1' }, lookups)).toMatchObject({ + from: null, + to: { id: 'p1', name: 'Alice' } + }) + expect(describeChange({ fieldId: 'component', from: 'c1', to: null }, lookups)).toMatchObject({ from: { name: 'Backend' }, to: null }) + expect(describeChange({ fieldId: 'milestone', from: 'm1', to: 'unknown' }, lookups)).toMatchObject({ to: { id: 'unknown', name: null } }) + }) + + it('writes dates as ISO strings and keeps numbers and text', () => { + const day = Date.UTC(2026, 5, 17) + expect(describeChange({ fieldId: 'dueDate', from: null, to: day }, lookups)).toMatchObject({ + field_name: 'Due date', + field_type: 'date', + from: null, + to: '2026-06-17T00:00:00.000Z' + }) + expect(describeChange({ fieldId: 'estimation', from: 1, to: 2 }, lookups)).toMatchObject({ field_type: 'number', from: 1, to: 2 }) + expect(describeChange({ fieldId: 'title', from: 'a', to: 'b' }, lookups)).toMatchObject({ field_type: 'text', to: 'b' }) + }) + + it('describes custom fields by their type', () => { + expect(describeChange({ fieldId: 'customFields.size', from: 'o-s', to: 'o-l' }, lookups)).toEqual({ + field_node_id: 'customFields.size', + field_name: 'Size', + field_type: 'single_select', + from: { id: 'o-s', name: 'S' }, + to: { id: 'o-l', name: 'L' } + }) + expect(describeChange({ fieldId: 'customFields.areas', from: ['a1'], to: ['a1', 'a2'] }, lookups)).toMatchObject({ + field_type: 'multi_select', + to: [{ id: 'a1', name: 'Frontend' }, { id: 'a2', name: 'Backend' }] + }) + expect(describeChange({ fieldId: 'customFields.sprint', from: null, to: 'it1' }, lookups)).toMatchObject({ + field_type: 'iteration', + to: { id: 'it1', name: 'Sprint 1' } + }) + expect(describeChange({ fieldId: 'customFields.target', from: null, to: Date.UTC(2026, 0, 1) }, lookups)).toMatchObject({ + field_type: 'date', + to: '2026-01-01T00:00:00.000Z' + }) + expect(describeChange({ fieldId: 'customFields.points', from: 1, to: 5 }, lookups)).toMatchObject({ field_type: 'number', to: 5 }) + }) + + it('reports a removed custom field under its key', () => { + expect(describeChange({ fieldId: 'customFields.gone', from: 'x', to: null }, lookups)).toEqual({ + field_node_id: 'customFields.gone', + field_name: 'gone', + field_type: 'unknown', + from: 'x', + to: null + }) + }) +}) + +describe('boundValue', () => { + it('shortens long text, caps lists and drops what is not JSON', () => { + expect(boundValue('a'.repeat(5000))).toHaveLength(1001) + expect(boundValue(Array.from({ length: 500 }, (_, i) => i))).toHaveLength(100) + expect(boundValue(NaN)).toBeNull() + expect(boundValue(undefined)).toBeNull() + expect(boundValue(() => 1)).toBeNull() + expect(boundValue({ a: { b: { c: { d: 1 } } } })).toEqual({ a: { b: { c: null } } }) + expect(boundValue('ok')).toBe('ok') + expect(boundValue(true)).toBe(true) + }) +}) + +describe('buildPayload', () => { + const input = { + deliveryId: 'd1', + timestamp: Date.UTC(2026, 5, 17, 12), + workspace: 'acme', + project: { id: 'p', name: 'Platform', identifier: 'PLT' }, + issue: { id: 'i1', identifier: 'PLT-1', title: 'Fix', url: 'https://x/PLT-1', createdOn: 1000, modifiedOn: 2000, archivedAt: null }, + sender: { type: 'User' as const, id: 'u1', name: 'Alice' } + } + + it('is shaped after the projects_v2_item event', () => { + const change = describeChange({ fieldId: 'status', from: 'st-todo', to: 'st-done' }, lookups) + const payload: any = buildPayload({ ...input, action: 'edited', change }) + expect(payload.action).toBe('edited') + expect(payload.project_item).toMatchObject({ + id: 'i1', + project_id: 'p', + content_type: 'Issue', + identifier: 'PLT-1', + title: 'Fix', + archived_at: null, + created_at: '1970-01-01T00:00:01.000Z' + }) + expect(payload.changes.field_value).toMatchObject({ field_node_id: 'status', field_type: 'status', from: { name: 'Todo' }, to: { name: 'Done' } }) + expect(payload.project).toEqual({ id: 'p', name: 'Platform', identifier: 'PLT' }) + expect(payload.sender).toEqual({ type: 'User', id: 'u1', name: 'Alice' }) + expect(payload.delivery).toEqual({ id: 'd1', sent_at: '2026-06-17T12:00:00.000Z' }) + expect(payload.workspace).toEqual({ url: 'acme' }) + }) + + it('says a draft item is a DraftIssue, like GitHub', () => { + const draft: any = buildPayload({ ...input, action: 'created', issue: { ...input.issue, isDraft: true } }) + expect(draft.project_item.content_type).toBe('DraftIssue') + const issue: any = buildPayload({ ...input, action: 'created', issue: { ...input.issue, isDraft: false } }) + expect(issue.project_item.content_type).toBe('Issue') + }) + + it('has no changes unless the item was edited', () => { + for (const action of ['created', 'archived', 'restored', 'deleted'] as const) { + expect((buildPayload({ ...input, action }) as any).changes).toBeUndefined() + } + expect((buildPayload({ ...input, action: 'edited' }) as any).changes).toBeUndefined() + }) + + it('reports the archive time', () => { + const payload: any = buildPayload({ ...input, action: 'archived', issue: { ...input.issue, archivedAt: Date.UTC(2026, 0, 1) } }) + expect(payload.project_item.archived_at).toBe('2026-01-01T00:00:00.000Z') + }) + + it('bounds free text and values', () => { + const change = { field_node_id: 'title', field_name: 'Title', field_type: 'text', from: 'a'.repeat(5000), to: 'b' } + const payload: any = buildPayload({ ...input, action: 'edited', change, issue: { ...input.issue, title: 't'.repeat(5000) } }) + expect(payload.project_item.title).toHaveLength(1001) + expect(payload.changes.field_value.from).toHaveLength(1001) + }) +}) + +describe('serializePayload', () => { + it('is plain JSON of the payload when it is small', () => { + const payload = { a: 1, b: 'x' } + expect(serializePayload(payload)).toBe('{"a":1,"b":"x"}') + }) + + it('never exceeds the limit: big values are dropped, the field stays named', () => { + const big = { field_node_id: 'customFields.x', field_name: 'X', field_type: 'multi_select', from: 'a', to: 'b' } + const payload = { + action: 'edited', + project_item: { id: 'i', title: 't' }, + changes: { field_value: { ...big, from: 'x'.repeat(MAX_PAYLOAD_BYTES), to: 'y'.repeat(MAX_PAYLOAD_BYTES) } } + } + const body = serializePayload(payload) + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES) + const parsed = JSON.parse(body) + expect(parsed.truncated).toBe(true) + expect(parsed.changes.field_value).toMatchObject({ field_node_id: 'customFields.x', from: null, to: null }) + }) + + it('drops the free text as a last resort', () => { + const payload = { action: 'created', project_item: { id: 'i', title: 'x'.repeat(MAX_PAYLOAD_BYTES + 10), url: 'u' } } + const body = serializePayload(payload) + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES) + expect(JSON.parse(body).project_item.title).toBeNull() + }) +}) diff --git a/server-plugins/tracker-resources/src/__tests__/webhook-trigger.test.ts b/server-plugins/tracker-resources/src/__tests__/webhook-trigger.test.ts new file mode 100644 index 0000000000..d36f658d52 --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/webhook-trigger.test.ts @@ -0,0 +1,338 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { TxFactory, type Ref, type Tx } from '@hcengineering/core' +import tracker, { MAX_PROJECT_WEBHOOKS, ProjectFieldType, type Project } from '@hcengineering/tracker' + +import { verifySignature } from '../webhook/sign' +import type { WebhookRequest } from '../webhook/transport' +import { + flushWebhookDeliveries, + MAX_EVENTS_PER_RUN, + OnProjectItemWebhook, + OnProjectWebhookRemove, + setWebhookTransport, + webhookTuning +} from '../webhook/trigger' +import { makeControl } from './mockControl' + +jest.mock('@hcengineering/server-contact', () => ({ + getPerson: async (_control: unknown, id: string) => (id === 'person-1' ? { _id: 'person-doc-1', _class: 'contact:class:Person', name: 'Doe,Alice' } : undefined) +})) + +const PROJECT = 'project-1' as Ref +const user = new TxFactory('person-1' as any) +const system = new TxFactory(core.account.System, true) + +const project = { _id: PROJECT, _class: tracker.class.Project, name: 'Platform', identifier: 'PLT' } +const statuses = [ + { _id: 'st-todo', _class: tracker.class.IssueStatus, name: 'Todo' }, + { _id: 'st-done', _class: tracker.class.IssueStatus, name: 'Done' } +] +const statusField = { + _id: 'f-status', + _class: tracker.class.ProjectField, + space: PROJECT, + key: 'stage', + label: 'Stage', + type: ProjectFieldType.SingleSelect, + position: 0, + options: [ + { value: 'o-a', label: 'Plan' }, + { value: 'o-b', label: 'Build' } + ] +} + +function hook (props: Record = {}): any { + return { + _id: 'hook-1', + _class: tracker.class.ProjectWebhook, + space: PROJECT, + url: 'https://hooks.example.com/huly', + enabled: true, + events: ['created', 'edited', 'archived', 'restored', 'deleted'], + hasSecret: false, + ...props + } +} + +function createTx (): Tx { + const tx = user.createTxCreateDoc(tracker.class.Issue, PROJECT, { title: 'Fix', status: 'st-todo', identifier: 'PLT-1', customFields: {} } as any, 'i1' as any) + tx.modifiedOn = 1000 + return tx +} +function updateTx (ops: Record, at: number, factory: TxFactory = user): Tx { + const tx = factory.createTxUpdateDoc(tracker.class.Issue, PROJECT, 'i1' as any, ops as any) + tx.modifiedOn = at + return tx +} + +interface Setup { + hooks?: any[] + secrets?: any[] + history: Tx[] + extra?: Record +} + +function control (setup: Setup, txes: Tx[] = []): any { + return makeControl({ + txes, + docs: { + [tracker.class.ProjectWebhook]: setup.hooks ?? [hook()], + [tracker.class.ProjectWebhookSecret]: setup.secrets ?? [], + [tracker.class.Project]: [project], + [tracker.class.IssueStatus]: statuses, + [tracker.class.ProjectField]: [statusField], + [core.class.TxCUD]: setup.history, + ...(setup.extra ?? {}) + } + }) +} + +let sent: WebhookRequest[] = [] + +beforeEach(() => { + sent = [] + webhookTuning.retryDelayMs = 1 + setWebhookTransport(async (request) => { + sent.push(request) + return { status: 200 } + }) +}) +afterEach(async () => { + await flushWebhookDeliveries() + setWebhookTransport(undefined) +}) + +const bodies = (): any[] => sent.map((r) => JSON.parse(r.body)) + +describe('OnProjectItemWebhook', () => { + it('delivers a created event', async () => { + const create = createTx() + expect(await OnProjectItemWebhook([create], control({ history: [create] }))).toEqual([]) + await flushWebhookDeliveries() + expect(sent).toHaveLength(1) + const body = bodies()[0] + expect(body.action).toBe('created') + expect(body.project_item).toMatchObject({ id: 'i1', project_id: PROJECT, identifier: 'PLT-1', title: 'Fix' }) + expect(body.project).toMatchObject({ name: 'Platform', identifier: 'PLT' }) + expect(body.sender).toEqual({ type: 'User', id: 'person-doc-1', name: 'Alice Doe' }) + expect(sent[0].headers['X-Huly-Event']).toBe('project_item') + }) + + it('delivers an edited event for a status change with the old and new value', async () => { + const create = createTx() + const edit = updateTx({ status: 'st-done' }, 2000) + await OnProjectItemWebhook([edit], control({ history: [create, edit] })) + await flushWebhookDeliveries() + expect(sent).toHaveLength(1) + const body = bodies()[0] + expect(body.action).toBe('edited') + expect(body.changes.field_value).toEqual({ + field_node_id: 'status', + field_name: 'Status', + field_type: 'status', + from: { id: 'st-todo', name: 'Todo' }, + to: { id: 'st-done', name: 'Done' } + }) + }) + + it('delivers one event per changed field', async () => { + const create = createTx() + const edit = updateTx({ status: 'st-done', title: 'Fixed', customFields: { stage: 'o-b' } }, 2000) + await OnProjectItemWebhook([edit], control({ history: [create, edit] })) + await flushWebhookDeliveries() + const fields = bodies().map((b) => b.changes.field_value.field_node_id).sort() + expect(fields).toEqual(['customFields.stage', 'status', 'title']) + const stage = bodies().find((b) => b.changes.field_value.field_node_id === 'customFields.stage') + expect(stage.changes.field_value).toMatchObject({ field_name: 'Stage', field_type: 'single_select', from: null, to: { id: 'o-b', name: 'Build' } }) + }) + + it('does not deliver an update that changes nothing it reports', async () => { + const create = createTx() + const same = updateTx({ title: 'Fix', status: 'st-todo' }, 2000) + const other = updateTx({ description: 'x' }, 3000) + await OnProjectItemWebhook([same, other], control({ history: [create, same, other] })) + await flushWebhookDeliveries() + expect(sent).toEqual([]) + }) + + it('delivers archived and restored events, not edited ones', async () => { + const create = createTx() + const archive = updateTx({ archivedAt: 5000 }, 2000) + const restore = updateTx({ archivedAt: null }, 3000) + await OnProjectItemWebhook([archive, restore], control({ history: [create, archive, restore] })) + await flushWebhookDeliveries() + expect(bodies().map((b) => b.action).sort()).toEqual(['archived', 'restored']) + const archived = bodies().find((b) => b.action === 'archived') + expect(archived.project_item.archived_at).toBe('1970-01-01T00:00:05.000Z') + expect(archived.changes).toBeUndefined() + }) + + it('delivers a deleted event with what the issue was', async () => { + const create = createTx() + const remove = user.createTxRemoveDoc(tracker.class.Issue, PROJECT, 'i1' as any) + remove.modifiedOn = 4000 + await OnProjectItemWebhook([remove], control({ history: [create, remove] })) + await flushWebhookDeliveries() + expect(bodies()).toHaveLength(1) + expect(bodies()[0].action).toBe('deleted') + expect(bodies()[0].project_item).toMatchObject({ id: 'i1', identifier: 'PLT-1', title: 'Fix' }) + }) + + it('shows the project automation as the sender of its changes, and delivers them', async () => { + const create = createTx() + // A write of a workflow is in the operation as a result of a synchronous trigger + const own = updateTx({ customFields: { stage: 'o-a' } }, 2000, system) + await OnProjectItemWebhook([create], control({ history: [create, own] }, [create, own])) + await flushWebhookDeliveries() + const edited = bodies().find((b) => b.action === 'edited') + expect(edited.sender).toEqual({ type: 'Automation', id: null, name: 'Project automation' }) + expect(edited.changes.field_value.field_node_id).toBe('customFields.stage') + }) + + it('delivers a transaction once even when it is in the operation twice', async () => { + const create = createTx() + await OnProjectItemWebhook([create], control({ history: [create] }, [create])) + await flushWebhookDeliveries() + expect(sent).toHaveLength(1) + }) + + it('delivers only the subscribed events to enabled webhooks', async () => { + const create = createTx() + const edit = updateTx({ status: 'st-done' }, 2000) + const hooks = [ + hook({ _id: 'h-edit', url: 'https://a.example.com/', events: ['edited'] }), + hook({ _id: 'h-create', url: 'https://b.example.com/', events: ['created'] }), + hook({ _id: 'h-none', url: 'https://c.example.com/', events: [] }), + hook({ _id: 'h-off', url: 'https://d.example.com/', enabled: false }) + ] + await OnProjectItemWebhook([create, edit], control({ hooks: hooks.filter((h) => h.enabled), history: [create, edit] })) + await flushWebhookDeliveries() + expect(sent.map((r) => `${r.url.hostname}:${JSON.parse(r.body).action}`).sort()).toEqual(['a.example.com:edited', 'b.example.com:created']) + }) + + it('does nothing for a project without webhooks or for other documents', async () => { + const create = createTx() + await OnProjectItemWebhook([create], control({ hooks: [], history: [create] })) + const milestone = user.createTxCreateDoc(tracker.class.Milestone, PROJECT, { label: 'v1' } as any) + await OnProjectItemWebhook([milestone], control({ history: [] })) + await flushWebhookDeliveries() + expect(sent).toEqual([]) + }) + + it('signs with the newest secret and never puts the secret in the payload', async () => { + const create = createTx() + const secrets = [ + { _id: 's-old', webhook: 'hook-1', secret: 'old-secret', createdOn: 1 }, + { _id: 's-new', webhook: 'hook-1', secret: 'new-secret', createdOn: 2 }, + { _id: 's-foreign', webhook: 'hook-2', secret: 'foreign', createdOn: 3 } + ] + await OnProjectItemWebhook([create], control({ hooks: [hook({ hasSecret: true })], secrets, history: [create] })) + await flushWebhookDeliveries() + expect(sent).toHaveLength(1) + const header = sent[0].headers['X-Huly-Signature-256'] + expect(verifySignature('new-secret', sent[0].body, header)).toBe(true) + expect(verifySignature('old-secret', sent[0].body, header)).toBe(false) + expect(sent[0].body).not.toContain('new-secret') + expect(JSON.stringify(sent[0].headers)).not.toContain('new-secret') + }) + + it('does not sign a webhook without a secret', async () => { + const create = createTx() + await OnProjectItemWebhook([create], control({ hooks: [hook({ hasSecret: false })], secrets: [{ _id: 's', webhook: 'hook-1', secret: 'x', createdOn: 1 }], history: [create] })) + await flushWebhookDeliveries() + expect('X-Huly-Signature-256' in sent[0].headers).toBe(false) + }) + + it('does not block: it returns before the receiver answers', async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { release = resolve }) + let answered = false + setWebhookTransport(async (request) => { + sent.push(request) + await gate + answered = true + return { status: 200 } + }) + const create = createTx() + const res = await OnProjectItemWebhook([create], control({ history: [create] })) + expect(res).toEqual([]) + expect(answered).toBe(false) + release() + await flushWebhookDeliveries() + expect(answered).toBe(true) + }) + + it('survives a receiver that fails and retries once', async () => { + let calls = 0 + setWebhookTransport(async () => { + calls++ + throw new Error('ECONNREFUSED') + }) + const create = createTx() + await expect(OnProjectItemWebhook([create], control({ history: [create] }))).resolves.toEqual([]) + await flushWebhookDeliveries() + expect(calls).toBe(2) + }) + + it('never calls a private target', async () => { + const create = createTx() + const hooks = [ + hook({ _id: 'h1', url: 'https://127.0.0.1/x' }), + hook({ _id: 'h2', url: 'http://hooks.example.com/x' }), + hook({ _id: 'h3', url: 'https://169.254.169.254/latest' }) + ] + await OnProjectItemWebhook([create], control({ hooks: hooks, history: [create] })) + await flushWebhookDeliveries() + expect(sent).toEqual([]) + }) + + it('caps the events of one run', async () => { + const create = createTx() + const txes = Array.from({ length: MAX_EVENTS_PER_RUN + 30 }, (_, i) => { + const tx = user.createTxCreateDoc(tracker.class.Issue, PROJECT, { title: `T${i}`, status: 'st-todo' } as any, `n${i}` as any) + tx.modifiedOn = 1000 + i + return tx + }) + await OnProjectItemWebhook(txes, control({ history: [create] })) + await flushWebhookDeliveries() + expect(sent).toHaveLength(MAX_EVENTS_PER_RUN) + }) + + it('uses at most the allowed number of webhooks per project, the oldest first', async () => { + const create = createTx() + const hooks = Array.from({ length: MAX_PROJECT_WEBHOOKS + 5 }, (_, i) => + hook({ _id: `h${i}`, url: `https://h${i}.example.com/`, createdOn: i }) + ) + await OnProjectItemWebhook([create], control({ hooks: [...hooks].reverse(), history: [create] })) + await flushWebhookDeliveries() + expect(sent).toHaveLength(MAX_PROJECT_WEBHOOKS) + expect(sent.some((r) => r.url.hostname === `h${MAX_PROJECT_WEBHOOKS}.example.com`)).toBe(false) + }) + + it('bounds the payload', async () => { + const create = createTx() + const huge = updateTx({ title: 'x'.repeat(200_000) }, 2000) + await OnProjectItemWebhook([huge], control({ history: [create, huge] })) + await flushWebhookDeliveries() + expect(sent).toHaveLength(1) + expect(Buffer.byteLength(sent[0].body, 'utf8')).toBeLessThanOrEqual(64 * 1024) + }) +}) + +describe('OnProjectWebhookRemove', () => { + it('removes the secrets of the removed webhook, only theirs', async () => { + const secrets = [ + { _id: 's1', _class: tracker.class.ProjectWebhookSecret, space: 'person-space-1', webhook: 'hook-1' }, + { _id: 's2', _class: tracker.class.ProjectWebhookSecret, space: 'person-space-2', webhook: 'hook-1' }, + { _id: 's3', _class: tracker.class.ProjectWebhookSecret, space: 'person-space-1', webhook: 'hook-2' } + ] + const remove = user.createTxRemoveDoc(tracker.class.ProjectWebhook, PROJECT, 'hook-1' as any) + const res: any[] = await OnProjectWebhookRemove([remove], control({ history: [], secrets })) + expect(res.map((r) => r.objectId).sort()).toEqual(['s1', 's2']) + expect(res.map((r) => r.objectSpace).sort()).toEqual(['person-space-1', 'person-space-2']) + }) +}) diff --git a/server-plugins/tracker-resources/src/__tests__/workflow-trigger.test.ts b/server-plugins/tracker-resources/src/__tests__/workflow-trigger.test.ts new file mode 100644 index 0000000000..576800b285 --- /dev/null +++ b/server-plugins/tracker-resources/src/__tests__/workflow-trigger.test.ts @@ -0,0 +1,371 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { TxFactory, type Ref, type Tx } from '@hcengineering/core' +import task from '@hcengineering/task' +import tracker, { + MAX_WORKFLOW_ITEMS_PER_RUN, + ProjectFieldType, + WorkflowKind, + type Project +} from '@hcengineering/tracker' + +import { OnIssueWorkflow, OnWorkflowEvaluate, WORKFLOW_RUN_INTERVAL_MS } from '../workflow/triggers' +import { makeControl } from './mockControl' + +const PROJECT = 'project-1' as Ref +const OTHER = 'project-2' as Ref +const DAY = 24 * 60 * 60 * 1000 +const user = 'person-1' as any +const userTx = new TxFactory(user) + +const statuses = [ + { _id: 'st-todo', _class: tracker.class.IssueStatus, name: 'Todo', category: task.statusCategory.UnStarted }, + { _id: 'st-prog', _class: tracker.class.IssueStatus, name: 'In Progress', category: task.statusCategory.Active }, + { _id: 'st-done', _class: tracker.class.IssueStatus, name: 'Done', category: task.statusCategory.Won }, + { _id: 'st-canceled', _class: tracker.class.IssueStatus, name: 'Canceled', category: task.statusCategory.Lost } +] + +const statusField = { + _id: 'f-status', + _class: tracker.class.ProjectField, + space: PROJECT, + key: 'status', + label: 'Status', + type: ProjectFieldType.SingleSelect, + position: 0, + options: [ + { value: 'o-todo', label: 'Todo' }, + { value: 'o-prog', label: 'In progress' }, + { value: 'o-done', label: 'Done' } + ] +} + +function workflow (kind: WorkflowKind, props: Record = {}, space = PROJECT): any { + return { + _id: `wf-${kind}-${space}`, + _class: tracker.class.Workflow, + space, + kind, + name: kind, + enabled: true, + createdOn: 1, + ...props + } +} + +function issue (id: string, props: Record = {}): any { + return { + _id: id, + _class: tracker.class.Issue, + space: PROJECT, + status: 'st-todo', + attachedTo: tracker.ids.NoParent, + modifiedOn: Date.now(), + ...props + } +} + +describe('OnIssueWorkflow', () => { + function createIssueTx (props: Record = {}): Tx { + const tx = userTx.createTxCreateDoc(tracker.class.Issue, PROJECT, { status: 'st-todo', title: 'New', ...props } as any, 'i1' as any) + tx.modifiedOn = 1000 + return tx + } + function statusTx (next: string, at = 3000): Tx { + const tx = userTx.createTxUpdateDoc(tracker.class.Issue, PROJECT, 'i1' as any, { status: next } as any) + tx.modifiedOn = at + return tx + } + + it('sets the default open value on a new issue', async () => { + const control = makeControl({ docs: { [tracker.class.ProjectField]: [statusField], [tracker.class.IssueStatus]: statuses } }) + const res: any[] = await OnIssueWorkflow([createIssueTx()], control) + expect(res).toHaveLength(1) + expect(res[0].operations).toEqual({ customFields: { status: 'o-todo' } }) + // Authored by the system account, which is what marks it as automation + expect(res[0].modifiedBy).toBe(core.account.System) + }) + + it('keeps a value the issue was created with', async () => { + const control = makeControl({ docs: { [tracker.class.ProjectField]: [statusField], [tracker.class.IssueStatus]: statuses } }) + expect(await OnIssueWorkflow([createIssueTx({ customFields: { status: 'o-prog' } })], control)).toEqual([]) + }) + + it('sets Done when an issue is closed and does nothing when it was closed already', async () => { + const history = [createIssueTx(), statusTx('st-done')] + const docs: Record = { + [tracker.class.ProjectField]: [statusField], + [tracker.class.IssueStatus]: statuses, + [tracker.class.Issue]: [issue('i1', { status: 'st-done', customFields: { status: 'o-prog' } })], + [core.class.TxCUD]: history + } + const res: any[] = await OnIssueWorkflow([history[1]], makeControl({ docs })) + expect(res).toHaveLength(1) + expect(res[0].operations).toEqual({ customFields: { status: 'o-done' } }) + + // Repeated: the value is there, nothing is written + docs[tracker.class.Issue] = [issue('i1', { status: 'st-done', customFields: { status: 'o-done' } })] + expect(await OnIssueWorkflow([history[1]], makeControl({ docs }))).toEqual([]) + }) + + it('does not act on a move between closed statuses', async () => { + const history = [createIssueTx({ status: 'st-done' }), statusTx('st-canceled')] + const docs = { + [tracker.class.ProjectField]: [statusField], + [tracker.class.IssueStatus]: statuses, + [tracker.class.Issue]: [issue('i1', { status: 'st-canceled', customFields: { status: 'o-prog' } })], + [core.class.TxCUD]: history + } + expect(await OnIssueWorkflow([history[1]], makeControl({ docs }))).toEqual([]) + }) + + it('sets the open value when a closed issue is reopened', async () => { + const history = [createIssueTx({ status: 'st-done' }), statusTx('st-prog')] + const docs = { + [tracker.class.ProjectField]: [statusField], + [tracker.class.IssueStatus]: statuses, + [tracker.class.Issue]: [issue('i1', { status: 'st-prog', customFields: { status: 'o-done' } })], + [core.class.TxCUD]: history + } + const res: any[] = await OnIssueWorkflow([history[1]], makeControl({ docs })) + expect(res[0].operations).toEqual({ customFields: { status: 'o-todo' } }) + }) + + it('does not act on a move between open statuses', async () => { + const history = [createIssueTx(), statusTx('st-prog')] + const docs = { + [tracker.class.ProjectField]: [statusField], + [tracker.class.IssueStatus]: statuses, + [tracker.class.Issue]: [issue('i1', { status: 'st-prog', customFields: { status: 'o-done' } })], + [core.class.TxCUD]: history + } + expect(await OnIssueWorkflow([history[1]], makeControl({ docs }))).toEqual([]) + }) + + it('never reacts to a change made by the automation (loop protection)', async () => { + const sys = new TxFactory(core.account.System, true) + const tx = sys.createTxUpdateDoc(tracker.class.Issue, PROJECT, 'i1' as any, { status: 'st-done' } as any) + const docs = { + [tracker.class.ProjectField]: [statusField], + [tracker.class.IssueStatus]: statuses, + [tracker.class.Issue]: [issue('i1', { status: 'st-done' })], + [core.class.TxCUD]: [tx] + } + expect(await OnIssueWorkflow([tx], makeControl({ docs }))).toEqual([]) + const created = sys.createTxCreateDoc(tracker.class.Issue, PROJECT, { status: 'st-todo' } as any) + expect(await OnIssueWorkflow([created], makeControl({ docs }))).toEqual([]) + }) + + it('respects a disabled workflow, a missing field and a configured target', async () => { + const base = { [tracker.class.IssueStatus]: statuses } + const off = workflow(WorkflowKind.ItemAdded, { enabled: false }) + expect( + await OnIssueWorkflow( + [createIssueTx()], + makeControl({ docs: { ...base, [tracker.class.ProjectField]: [statusField], [tracker.class.Workflow]: [off] } }) + ) + ).toEqual([]) + expect(await OnIssueWorkflow([createIssueTx()], makeControl({ docs: { ...base, [tracker.class.ProjectField]: [] } }))).toEqual([]) + + const stage = { ...statusField, _id: 'f-stage', key: 'stage', label: 'Stage' } + const target = workflow(WorkflowKind.ItemAdded, { config: { target: { field: 'stage', option: 'o-prog' } } }) + const res: any[] = await OnIssueWorkflow( + [createIssueTx()], + makeControl({ docs: { ...base, [tracker.class.ProjectField]: [statusField, stage], [tracker.class.Workflow]: [target] } }) + ) + expect(res[0].operations).toEqual({ customFields: { stage: 'o-prog' } }) + }) + + it('ignores updates that do not change the status and other classes', async () => { + const control = makeControl({ docs: { [tracker.class.ProjectField]: [statusField], [tracker.class.IssueStatus]: statuses } }) + const title = userTx.createTxUpdateDoc(tracker.class.Issue, PROJECT, 'i1' as any, { title: 'x' } as any) + expect(await OnIssueWorkflow([title], control)).toEqual([]) + const other = userTx.createTxUpdateDoc(tracker.class.Milestone, PROJECT, 'm1' as any, { status: 'x' } as any) + expect(await OnIssueWorkflow([other], control)).toEqual([]) + }) +}) + +describe('OnWorkflowEvaluate', () => { + const NOW = Date.now() + const workflowTx = (space = PROJECT): Tx => userTx.createTxUpdateDoc(tracker.class.Workflow, space, 'wf' as any, { enabled: true } as any) + const issueTx = (space = PROJECT): Tx => userTx.createTxUpdateDoc(tracker.class.Issue, space, 'i1' as any, { title: 'x' } as any) + const archiveWorkflow = (props: Record = {}): any => workflow(WorkflowKind.AutoArchive, props) + + function docsOf (issues: any[], workflows: any[], extra: Record = {}): Record { + return { + [tracker.class.IssueStatus]: statuses, + [tracker.class.Issue]: issues, + [tracker.class.Workflow]: workflows, + ...extra + } + } + + const old = (id: string, props: Record = {}): any => issue(id, { modifiedOn: NOW - 30 * DAY, status: 'st-done', ...props }) + + it('archives the closed issues that were not touched for two weeks, nothing else', async () => { + const issues = [ + old('closed-old'), + old('canceled-old', { status: 'st-canceled' }), + issue('closed-new', { status: 'st-done', modifiedOn: NOW - DAY }), + old('open-old', { status: 'st-prog' }), + old('archived-already', { archivedAt: NOW - DAY }), + old('other-project', { space: OTHER }) + ] + const res: any[] = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs: docsOf(issues, [archiveWorkflow({ filter: 'is:closed updated:<@today-2w' })]) })) + expect(res.map((r) => r.objectId).sort()).toEqual(['canceled-old', 'closed-old']) + for (const tx of res) { + expect(tx.operations.archivedAt).toBeGreaterThan(0) + expect(tx.modifiedBy).toBe(core.account.System) + expect(tx.objectSpace).toBe(PROJECT) + } + }) + + it('uses the default filter of a project that never changed the workflow only once it is switched on', async () => { + // A stored, enabled workflow without a filter text of its own has the empty filter: it does nothing + const res = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs: docsOf([old('a')], [archiveWorkflow({ filter: '' })]) })) + expect(res).toEqual([]) + // Not stored at all: disabled by default + expect(await OnWorkflowEvaluate([workflowTx()], makeControl({ docs: docsOf([old('a')], []) }))).toEqual([]) + }) + + it('is idempotent: a second run over the result archives nothing', async () => { + const issues = [old('a'), old('b')] + const docs = docsOf(issues, [archiveWorkflow({ filter: 'is:closed' })]) + const first: any[] = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs })) + expect(first).toHaveLength(2) + for (const tx of first) Object.assign(issues.find((i) => i._id === tx.objectId), tx.operations) + expect(await OnWorkflowEvaluate([workflowTx()], makeControl({ docs }))).toEqual([]) + }) + + it('archives at most the cap per run, oldest first, and finishes over several runs', async () => { + const issues = Array.from({ length: 250 }, (_, i) => old(`i${String(i).padStart(3, '0')}`, { modifiedOn: NOW - (400 - i) * DAY })) + const docs = docsOf(issues, [archiveWorkflow({ filter: 'is:closed' })]) + let runs = 0 + let total = 0 + for (;;) { + const res: any[] = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs })) + if (res.length === 0 || runs > 5) break + runs++ + expect(res.length).toBeLessThanOrEqual(MAX_WORKFLOW_ITEMS_PER_RUN) + if (runs === 1) expect(res.map((r) => r.objectId)).toEqual(issues.slice(0, MAX_WORKFLOW_ITEMS_PER_RUN).map((i) => i._id)) + total += res.length + for (const tx of res) Object.assign(issues.find((i) => i._id === tx.objectId), tx.operations) + } + expect(runs).toBe(3) + expect(total).toBe(250) + }) + + it('skips a filter that does not parse, asks for archived items or is empty', async () => { + for (const filter of ['bogus-field:x', 'is:archived', ' ', '(is:closed']) { + const res = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs: docsOf([old('a')], [archiveWorkflow({ filter })]) })) + expect(res).toEqual([]) + } + }) + + it('evaluates what the database could not take on the client', async () => { + const issues = [old('a', { customFields: { size: 'o-l' } }), old('b', { customFields: { size: 'o-s' } })] + const size = { + _id: 'f-size', + _class: tracker.class.ProjectField, + space: PROJECT, + key: 'size', + label: 'Size', + type: ProjectFieldType.SingleSelect, + position: 1, + options: [ + { value: 'o-s', label: 'S' }, + { value: 'o-l', label: 'L' } + ] + } + const docs = docsOf(issues, [archiveWorkflow({ filter: 'is:closed size:L' })], { [tracker.class.ProjectField]: [size] }) + const res: any[] = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs })) + expect(res.map((r) => r.objectId)).toEqual(['a']) + }) + + it('names priorities in English', async () => { + const issues = [old('a', { priority: 1 }), old('b', { priority: 4 })] + const res: any[] = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs: docsOf(issues, [archiveWorkflow({ filter: 'priority:urgent' })]) })) + expect(res.map((r) => r.objectId)).toEqual(['a']) + }) + + it('restores archived issues that match the auto-add filter, but not those the archive filter would take again', async () => { + const issues = [ + old('keep-archived', { archivedAt: NOW - DAY }), + old('back', { archivedAt: NOW - DAY, status: 'st-prog', priority: 1 }), + old('back-too', { archivedAt: NOW - DAY, status: 'st-done', priority: 1, modifiedOn: NOW - DAY }), + old('not-matching', { archivedAt: NOW - DAY, priority: 3 }), + old('active', { priority: 1 }) + ] + issues[0].priority = 1 // matches the add filter, and is closed and old: archive wins + const workflows = [ + archiveWorkflow({ filter: 'is:closed updated:<@today-2w' }), + workflow(WorkflowKind.AutoAddFromQuery, { filter: 'priority:urgent' }) + ] + const res: any[] = await OnWorkflowEvaluate([workflowTx()], makeControl({ docs: docsOf(issues, workflows) })) + const restored = res.filter((r) => r.operations.archivedAt === null).map((r) => r.objectId).sort() + const archived = res.filter((r) => r.operations.archivedAt !== null).map((r) => r.objectId) + expect(restored).toEqual(['back', 'back-too']) + // The active closed old issues of the project are archived; 'active' matches the archive filter + expect(archived).toEqual(['active']) + // Nothing is both archived and restored + expect(new Set([...restored, ...archived]).size).toBe(restored.length + archived.length) + }) + + it('does nothing for a project without enabled filter workflows', async () => { + const calls: any[] = [] + const res = await OnWorkflowEvaluate( + [workflowTx()], + makeControl({ + docs: docsOf([old('a')], [archiveWorkflow({ filter: 'is:closed', enabled: false }), workflow(WorkflowKind.ItemAdded)]), + calls + }) + ) + expect(res).toEqual([]) + expect(calls.filter((c) => c._class === tracker.class.Issue)).toEqual([]) + }) + + it('never reacts to a change made by the automation (loop protection)', async () => { + const sys = new TxFactory(core.account.System, true) + const own = sys.createTxUpdateDoc(tracker.class.Issue, PROJECT, 'i1' as any, { archivedAt: NOW } as any) + const own2 = sys.createTxUpdateDoc(tracker.class.Workflow, PROJECT, 'wf' as any, { enabled: true } as any) + const docs = docsOf([old('a')], [archiveWorkflow({ filter: 'is:closed' })]) + expect(await OnWorkflowEvaluate([own, own2], makeControl({ docs }))).toEqual([]) + }) + + it('evaluates after issue changes at most once per interval, a workflow change always', async () => { + const cache = new Map() + const docs = docsOf([old('a')], [archiveWorkflow({ filter: 'is:closed' })]) + const first = await OnWorkflowEvaluate([issueTx()], makeControl({ docs, cache })) + expect(first).toHaveLength(1) + // Within the interval: skipped + expect(await OnWorkflowEvaluate([issueTx()], makeControl({ docs, cache }))).toEqual([]) + // A workflow change forces it + expect(await OnWorkflowEvaluate([workflowTx()], makeControl({ docs, cache }))).toHaveLength(1) + // After the interval it runs again + cache.set(`tracker:workflow:last-run:${PROJECT}`, Date.now() - WORKFLOW_RUN_INTERVAL_MS - 1) + expect(await OnWorkflowEvaluate([issueTx()], makeControl({ docs, cache }))).toHaveLength(1) + }) + + it('keeps one failing project from stopping the others', async () => { + const issues = [old('a'), old('b', { space: OTHER })] + const docs = docsOf(issues, [archiveWorkflow({ filter: 'is:closed' }), workflow(WorkflowKind.AutoArchive, { filter: 'is:closed' }, OTHER)]) + const control = makeControl({ docs }) + const findAll = control.findAll + control.findAll = async (ctx: unknown, cls: string, query: any, options: any) => { + if (cls === tracker.class.Workflow && query.space === PROJECT) throw new Error('boom') + return await findAll(ctx, cls, query, options) + } + const res: any[] = await OnWorkflowEvaluate([workflowTx(PROJECT), workflowTx(OTHER)], control) + expect(res.map((r) => r.objectId)).toEqual(['b']) + }) + + it('ignores transactions of other documents', async () => { + const other = userTx.createTxUpdateDoc(tracker.class.Milestone, PROJECT, 'm' as any, { label: 'x' } as any) + const calls: any[] = [] + expect(await OnWorkflowEvaluate([other], makeControl({ docs: docsOf([old('a')], [archiveWorkflow({ filter: 'is:closed' })]), calls }))).toEqual([]) + expect(calls).toEqual([]) + }) +}) diff --git a/server-plugins/tracker-resources/src/history.ts b/server-plugins/tracker-resources/src/history.ts new file mode 100644 index 0000000000..c2ac2cc188 --- /dev/null +++ b/server-plugins/tracker-resources/src/history.ts @@ -0,0 +1,33 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { TxProcessor, type Doc, type Ref, type Tx, type TxCUD } from '@hcengineering/core' +import type { TriggerControl } from '@hcengineering/server-core' + +// Triggers see the transaction, not the state before it. The previous values of a document (the status an issue had, +// the value a field had) are rebuilt from the transactions that came before. + +function before (a: Pick, b: Pick): boolean { + return a.modifiedOn < b.modifiedOn || (a.modifiedOn === b.modifiedOn && a._id < b._id) +} + +/** + * The document as it was just before `tx` and just after it, from the history of the object. `before` is undefined + * for a document `tx` creates (or whose creation is not in the history). A removal in the history is ignored, so the + * state of a document that was removed can still be read. + */ +export async function replayAround ( + control: TriggerControl, + tx: TxCUD +): Promise<{ before: T | undefined, after: T | undefined }> { + const history = (await control.findAll(control.ctx, core.class.TxCUD, { objectId: tx.objectId as Ref })) as Tx[] + const earlier = history + .filter((h) => h._id !== tx._id && h._class !== core.class.TxRemoveDoc && before(h, tx)) + .sort((a, b) => a.modifiedOn - b.modifiedOn || (a._id < b._id ? -1 : 1)) + const prev = TxProcessor.buildDoc2Doc(earlier) ?? undefined + const next = + tx._class === core.class.TxRemoveDoc ? prev : (TxProcessor.buildDoc2Doc([...earlier, tx]) ?? undefined) + return { before: prev, after: next } +} diff --git a/server-plugins/tracker-resources/src/index.ts b/server-plugins/tracker-resources/src/index.ts index ab3e82cf52..f89514357d 100644 --- a/server-plugins/tracker-resources/src/index.ts +++ b/server-plugins/tracker-resources/src/index.ts @@ -45,12 +45,24 @@ import tracker, { groupShiftsByRecipient, Issue, IssueParentInfo, + issueLinkSegment, + itemsOverProjectLimit, + type Iteration, + overLimitFields, + type ProjectField, + ProjectFieldType, + type ProjectWebhook, + stripIterationValues, type ShiftedIssuePayload, TimeSpendReport, trackerId, type Project } from '@hcengineering/tracker' import { workbenchId } from '@hcengineering/workbench' +import { OnIssueWorkflow, OnWorkflowEvaluate } from './workflow/triggers' +import { OnProjectItemWebhook, OnProjectWebhookRemove } from './webhook/trigger' + +export { OnIssueWorkflow, OnWorkflowEvaluate, OnProjectItemWebhook, OnProjectWebhookRemove } async function updateSubIssues ( updateTx: TxUpdateDoc, @@ -71,7 +83,7 @@ async function updateSubIssues ( export async function issueHTMLPresenter (doc: Doc, control: TriggerControl): Promise { const issue = doc as Issue const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? '' - const path = `${workbenchId}/${control.workspace.url}/${trackerId}/${issue.identifier}` + const path = `${workbenchId}/${control.workspace.url}/${trackerId}/${issueLinkSegment(issue)}` const link = concatLink(front, path) return `${issue.identifier} ${issue.title}` } @@ -81,6 +93,8 @@ export async function issueHTMLPresenter (doc: Doc, control: TriggerControl): Pr */ export async function getIssueId (doc: Issue, control: TriggerControl): Promise { const issue = doc + // A draft item has no number in the project sequence, only the placeholder identifier + if (issue.isDraft === true) return issue.identifier const project = (await control.findAll(control.ctx, tracker.class.Project, { _id: issue.space }))[0] return `${project?.identifier ?? '?'}-${issue.number}` } @@ -166,13 +180,33 @@ export async function OnProjectRemove (txes: Tx[], control: TriggerControl): Pro const result: Tx[] = [] for (const tx of txes) { const ctx = tx as TxRemoveDoc - const classes = [tracker.class.Issue, tracker.class.Component, tracker.class.Milestone, tracker.class.IssueTemplate] + const classes = [ + tracker.class.Issue, + tracker.class.Component, + tracker.class.Milestone, + tracker.class.IssueTemplate, + tracker.class.ProjectField, + tracker.class.Iteration, + tracker.class.InsightChart, + tracker.class.Workflow, + tracker.class.ProjectWebhook, + tracker.class.ProjectStatusUpdate + ] for (const cls of classes) { const docs = await control.findAll(control.ctx, cls, { space: ctx.objectId }) for (const doc of docs) { const tx = control.txFactory.createTxRemoveDoc(cls, doc.space, doc._id) result.push(tx) } + // The secrets of the webhooks are in the personal spaces of their authors, not in the project + if (cls === tracker.class.ProjectWebhook && docs.length > 0) { + const secrets = await control.findAll(control.ctx, tracker.class.ProjectWebhookSecret, { + webhook: { $in: docs.map((it) => it._id as unknown as Ref) } + }) + for (const secret of secrets) { + result.push(control.txFactory.createTxRemoveDoc(secret._class, secret.space, secret._id)) + } + } } } control.ctx.contextData.broadcast.targets.projectRemove = async (it) => { @@ -183,6 +217,124 @@ export async function OnProjectRemove (txes: Tx[], control: TriggerControl): Pro return result } +/** + * Server-side guard for the per-project custom field limit. A client that skips the UI check + * still cannot exceed GitHub's cap: the newest field beyond the limit is removed again. + * @public + */ +export async function OnProjectFieldCreate (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + const handled = new Set>() + for (const tx of txes) { + const createTx = tx as TxCreateDoc + if (handled.has(createTx.objectSpace)) continue + handled.add(createTx.objectSpace) + const fields = await control.findAll(control.ctx, tracker.class.ProjectField, { space: createTx.objectSpace as Ref }) + for (const field of overLimitFields(fields)) { + result.push(control.txFactory.createTxRemoveDoc(field._class, field.space, field._id)) + } + } + return result +} + +/** + * Server-side guard for the 50,000 items per project limit (GitHub parity). The UI refuses to create an issue in a + * full project; a client that skips that check still cannot exceed the limit: the newest issues beyond it are removed + * again, like the fields beyond the field limit. + * @public + */ +export async function OnProjectItemLimit (txes: Tx[], control: TriggerControl): Promise { + const created = new Map, Array>>() + for (const tx of txes) { + if (tx._class !== core.class.TxCreateDoc) continue + const createTx = tx as TxCreateDoc + if (!control.hierarchy.isDerived(createTx.objectClass, tracker.class.Issue)) continue + const list = created.get(createTx.objectSpace) ?? [] + list.push(createTx.objectId) + created.set(createTx.objectSpace, list) + } + const result: Tx[] = [] + for (const [space, ids] of created) { + const found = await control.findAll( + control.ctx, + tracker.class.Issue, + { space: space as Ref }, + { limit: 1, total: true, projection: { _id: 1 } } + ) + for (const id of itemsOverProjectLimit(ids, found.total)) { + result.push(control.txFactory.createTxRemoveDoc(tracker.class.Issue, space, id)) + } + } + return result +} + +/** + * Drop the values of a removed custom field from every issue of the project. + * @public + */ +export async function OnProjectFieldRemove (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + for (const tx of txes) { + const rmTx = tx as TxRemoveDoc + const field = control.removedMap.get(rmTx.objectId) as ProjectField | undefined + if (field === undefined) continue + const issues = await control.findAll(control.ctx, tracker.class.Issue, { space: field.space as Ref }) + for (const issue of issues) { + if (issue.customFields === undefined || !(field.key in issue.customFields)) continue + const { [field.key]: _removed, ...rest } = issue.customFields + result.push(control.txFactory.createTxUpdateDoc(issue._class, issue.space, issue._id, { customFields: rest })) + } + // The iterations of an Iteration field are meaningless without it + if (field.type === ProjectFieldType.Iteration) { + const iterations = await control.findAll(control.ctx, tracker.class.Iteration, { field: field._id }) + for (const iteration of iterations) { + result.push(control.txFactory.createTxRemoveDoc(iteration._class, iteration.space, iteration._id)) + } + } + } + return result +} + +/** + * Drop a deleted iteration from the issues that were in it. Several iterations removed together are + * handled in one pass, so an issue gets a single update. + * @public + */ +export async function OnIterationRemove (txes: Tx[], control: TriggerControl): Promise { + // Removed iteration ids by project and owning field + const removed = new Map, Map, Set>>() + for (const tx of txes) { + const iteration = control.removedMap.get((tx as TxRemoveDoc).objectId) as Iteration | undefined + if (iteration === undefined) continue + const byField = removed.get(iteration.space) ?? new Map, Set>() + const ids = byField.get(iteration.field) ?? new Set() + ids.add(iteration._id) + byField.set(iteration.field, ids) + removed.set(iteration.space, byField) + } + + const result: Tx[] = [] + for (const [space, byField] of removed) { + // The field may be gone already (it was removed together with its iterations): its values are dropped by + // OnProjectFieldRemove, an update from here would only race with that one + const fields = await control.findAll(control.ctx, tracker.class.ProjectField, { + _id: { $in: [...byField.keys()] }, + space + }) + const dropped = new Map>() + for (const field of fields) dropped.set(field.key, byField.get(field._id) ?? new Set()) + if (dropped.size === 0) continue + + const issues = await control.findAll(control.ctx, tracker.class.Issue, { space }) + for (const issue of issues) { + const customFields = stripIterationValues(issue.customFields, dropped) + if (customFields === undefined) continue + result.push(control.txFactory.createTxUpdateDoc(issue._class, issue.space, issue._id, { customFields })) + } + } + return result +} + /** * @public */ @@ -509,7 +661,8 @@ function updateIssueParentEstimations ( } async function issueLinkIdProvider (issue: Issue): Promise { - return issue.identifier + // A draft item has no identifier of an issue yet, its link carries the id of the document + return issueLinkSegment(issue) } /** @@ -873,6 +1026,14 @@ export default async () => ({ OnIssueUpdate, OnComponentRemove, OnProjectRemove, - OnDependencyShiftRequest + OnProjectFieldCreate, + OnProjectFieldRemove, + OnProjectItemLimit, + OnIterationRemove, + OnDependencyShiftRequest, + OnIssueWorkflow, + OnWorkflowEvaluate, + OnProjectItemWebhook, + OnProjectWebhookRemove } }) diff --git a/server-plugins/tracker-resources/src/log.ts b/server-plugins/tracker-resources/src/log.ts new file mode 100644 index 0000000000..225629ac46 --- /dev/null +++ b/server-plugins/tracker-resources/src/log.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import type { TriggerControl } from '@hcengineering/server-core' + +/** + * Logs through the measure context of a trigger. A missing context (or method) never breaks a trigger. + */ +export function logTrigger ( + control: Pick, + level: 'info' | 'warn' | 'error', + message: string, + data?: Record +): void { + try { + ;(control.ctx as any)?.[level]?.(message, data) + } catch { + // Logging must not fail the trigger + } +} diff --git a/server-plugins/tracker-resources/src/webhook/delivery.ts b/server-plugins/tracker-resources/src/webhook/delivery.ts new file mode 100644 index 0000000000..0003d2b1d4 --- /dev/null +++ b/server-plugins/tracker-resources/src/webhook/delivery.ts @@ -0,0 +1,106 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { validateWebhookUrl } from '@hcengineering/tracker' + +import { signPayload, SIGNATURE_HEADER } from './sign' +import { WebhookBlockedError, type WebhookTransport } from './transport' + +/** Name of the event in the `X-Huly-Event` header. */ +export const EVENT_NAME = 'project_item' + +/** One delivery to make. */ +export interface DeliveryJob { + webhookId: string + url: string + // Signs the delivery; absent: not signed + secret?: string + deliveryId: string + // The exact body to send + body: string +} + +export interface DeliveryOptions { + transport: WebhookTransport + // http and private targets (development) + allowInsecure: boolean + timeoutMs: number + // Wait before the second attempt + retryDelayMs: number + sleep?: (ms: number) => Promise +} + +export interface DeliveryResult { + ok: boolean + attempts: number + status?: number + // `blocked` (not allowed, not tried), `status` (a refused status) or `network` (no answer) + failure?: 'blocked' | 'status' | 'network' +} + +/** Defaults: a receiver that does not answer within 5 seconds has failed. */ +export const DEFAULT_TIMEOUT_MS = 5000 +export const DEFAULT_RETRY_DELAY_MS = 1000 + +const defaultSleep = async (ms: number): Promise => await new Promise((resolve) => setTimeout(resolve, ms)) + +// A server error or too many requests may pass; any other answer is final +function isRetryableStatus (status: number): boolean { + return status >= 500 || status === 429 +} + +/** + * Makes one delivery: the URL is validated, the body signed, and the request tried twice at most (a second time after a + * network error, a timeout, a 5xx or a 429; never after another answer and never after a blocked target). It never + * throws: the outcome is the result. + */ +export async function deliver (job: DeliveryJob, options: DeliveryOptions): Promise { + const checked = validateWebhookUrl(job.url, { allowInsecure: options.allowInsecure }) + if (checked.ok === false) return { ok: false, attempts: 0, failure: 'blocked' } + + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'Huly-Webhook/1.0', + 'X-Huly-Event': EVENT_NAME, + 'X-Huly-Delivery': job.deliveryId + } + if (job.secret !== undefined && job.secret !== '') headers[SIGNATURE_HEADER] = signPayload(job.secret, job.body) + + const sleep = options.sleep ?? defaultSleep + let last: DeliveryResult = { ok: false, attempts: 0, failure: 'network' } + for (let attempt = 1; attempt <= 2; attempt++) { + if (attempt === 2) await sleep(options.retryDelayMs) + try { + const res = await options.transport({ url: checked.url, headers, body: job.body, timeoutMs: options.timeoutMs }) + if (res.status >= 200 && res.status < 300) return { ok: true, attempts: attempt, status: res.status } + last = { ok: false, attempts: attempt, status: res.status, failure: 'status' } + if (!isRetryableStatus(res.status)) return last + } catch (err: any) { + if (err instanceof WebhookBlockedError) return { ok: false, attempts: attempt, failure: 'blocked' } + last = { ok: false, attempts: attempt, failure: 'network' } + } + } + return last +} + +/** + * Runs deliveries with a bounded number at a time. A failing delivery does not stop the others. + */ +export async function runDeliveries ( + jobs: readonly DeliveryJob[], + options: DeliveryOptions, + concurrency = 5 +): Promise { + const results: DeliveryResult[] = new Array(jobs.length) + let next = 0 + const worker = async (): Promise => { + while (next < jobs.length) { + const index = next++ + results[index] = await deliver(jobs[index], options) + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, async () => await worker())) + return results +} diff --git a/server-plugins/tracker-resources/src/webhook/payload.ts b/server-plugins/tracker-resources/src/webhook/payload.ts new file mode 100644 index 0000000000..dede3d1a86 --- /dev/null +++ b/server-plugins/tracker-resources/src/webhook/payload.ts @@ -0,0 +1,301 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { ProjectFieldType, WORKFLOW_PRIORITY_NAMES, type Issue, type ProjectField, type WebhookEvent } from '@hcengineering/tracker' + +// The JSON a project webhook receives, modelled after GitHub's `projects_v2_item` event: an `action`, the item, and for +// `edited` the `changes.field_value` with the id, name and type of the field and its value `from` and `to`. + +/** Largest payload that is sent, in bytes. */ +export const MAX_PAYLOAD_BYTES = 64 * 1024 + +const MAX_STRING = 1000 +const MAX_LIST = 100 + +/** + * A change of one field, as the server reads it from the documents. + */ +export interface RawFieldChange { + // `status`, `priority`, ..., or `customFields.` + fieldId: string + from: unknown + to: unknown +} + +/** + * What the ids in a change resolve to. + */ +export interface ChangeLookups { + statuses: ReadonlyMap + people: ReadonlyMap + components: ReadonlyMap + milestones: ReadonlyMap + iterations: ReadonlyMap + // Custom fields by key + fields: ReadonlyMap> +} + +/** + * The attributes of an issue a webhook reports a change of, and how each is typed in the payload. + */ +export const WATCHED_ATTRIBUTES: Readonly> = { + title: { name: 'Title', type: 'text' }, + status: { name: 'Status', type: 'status' }, + priority: { name: 'Priority', type: 'priority' }, + assignee: { name: 'Assignee', type: 'assignee' }, + component: { name: 'Component', type: 'component' }, + milestone: { name: 'Milestone', type: 'milestone' }, + estimation: { name: 'Estimation', type: 'number' }, + startDate: { name: 'Start date', type: 'date' }, + dueDate: { name: 'Due date', type: 'date' }, + deadline: { name: 'Deadline', type: 'date' } +} + +/** + * Cuts a value down to what a payload may carry: strings are shortened, lists capped, anything that is not a plain + * JSON value becomes null. + */ +export function boundValue (value: unknown, depth = 0): unknown { + if (value === null || value === undefined) return null + if (typeof value === 'string') return value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value + if (typeof value === 'number') return Number.isFinite(value) ? value : null + if (typeof value === 'boolean') return value + if (Array.isArray(value)) return depth > 2 ? null : value.slice(0, MAX_LIST).map((v) => boundValue(v, depth + 1)) + if (typeof value === 'object') { + if (depth > 2) return null + const out: Record = {} + for (const [k, v] of Object.entries(value as Record).slice(0, MAX_LIST)) out[k] = boundValue(v, depth + 1) + return out + } + return null +} + +function isoDate (value: unknown): string | null { + return typeof value === 'number' && Number.isFinite(value) ? new Date(value).toISOString() : null +} + +function named (id: unknown, names: ReadonlyMap): { id: string, name: string | null } | null { + if (id === null || id === undefined || id === '') return null + return { id: String(id), name: names.get(String(id)) ?? null } +} + +const PRIORITY_NAMES = new Map(WORKFLOW_PRIORITY_NAMES.map((p) => [String(p.id), p.name])) + +/** + * The change of one field in the form of GitHub's `changes.field_value`. + */ +export interface FieldValueChange { + field_node_id: string + field_name: string + field_type: string + from: unknown + to: unknown +} + +function describeValue (type: string, value: unknown, names: ReadonlyMap): unknown { + switch (type) { + case 'date': + return isoDate(value) + case 'status': + case 'assignee': + case 'component': + case 'milestone': + case 'priority': + case 'single_select': + case 'iteration': + return named(value, names) + case 'multi_select': + return Array.isArray(value) ? value.slice(0, MAX_LIST).map((v) => named(v, names)) : [] + default: + return boundValue(value) + } +} + +/** + * Turns a raw change into the payload form, resolving ids to names. A custom field that no longer exists is reported + * under its key, with its values as they are stored. + */ +export function describeChange (change: RawFieldChange, lookups: ChangeLookups): FieldValueChange { + const custom = change.fieldId.startsWith('customFields.') + if (custom) { + const key = change.fieldId.slice('customFields.'.length) + const field = lookups.fields.get(key) + if (field === undefined) { + return { + field_node_id: change.fieldId, + field_name: key, + field_type: 'unknown', + from: boundValue(change.from), + to: boundValue(change.to) + } + } + const options = new Map( + field.type === ProjectFieldType.Iteration + ? [...lookups.iterations] + : (field.options ?? []).map((o): [string, string] => [o.value, o.label]) + ) + const type = + field.type === ProjectFieldType.SingleSelect + ? 'single_select' + : field.type === ProjectFieldType.MultiSelect + ? 'multi_select' + : field.type + return { + field_node_id: change.fieldId, + field_name: field.label, + field_type: type, + from: describeValue(type, change.from, options), + to: describeValue(type, change.to, options) + } + } + const attribute = WATCHED_ATTRIBUTES[change.fieldId] ?? { name: change.fieldId, type: 'text' } + const names = + attribute.type === 'status' + ? lookups.statuses + : attribute.type === 'assignee' + ? lookups.people + : attribute.type === 'component' + ? lookups.components + : attribute.type === 'milestone' + ? lookups.milestones + : attribute.type === 'priority' + ? PRIORITY_NAMES + : new Map() + return { + field_node_id: change.fieldId, + field_name: attribute.name, + field_type: attribute.type, + from: describeValue(attribute.type, change.from, names), + to: describeValue(attribute.type, change.to, names) + } +} + +function sameValue (a: unknown, b: unknown): boolean { + if (a === b) return true + if ((a === null || a === undefined) && (b === null || b === undefined)) return true + if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { + return JSON.stringify(a) === JSON.stringify(b) + } + return false +} + +/** + * The fields that differ between two states of an issue: the watched attributes and each custom field, one change + * per field. A field a webhook does not report (the description, ranks, counters) never shows up. + */ +export function diffIssue (before: Partial | undefined, after: Partial | undefined): RawFieldChange[] { + if (after === undefined) return [] + const changes: RawFieldChange[] = [] + for (const key of Object.keys(WATCHED_ATTRIBUTES)) { + const from = (before as any)?.[key] + const to = (after as any)[key] + if (!sameValue(from, to)) changes.push({ fieldId: key, from: from ?? null, to: to ?? null }) + } + const oldFields = before?.customFields ?? {} + const newFields = after.customFields ?? {} + for (const key of [...new Set([...Object.keys(oldFields), ...Object.keys(newFields)])].sort()) { + if (!sameValue(oldFields[key], newFields[key])) { + changes.push({ fieldId: `customFields.${key}`, from: oldFields[key] ?? null, to: newFields[key] ?? null }) + } + } + return changes +} + +/** + * Who made the change. + */ +export interface PayloadSender { + // `Automation` for a change made by a project workflow + type: 'User' | 'Automation' + id?: string + name?: string +} + +/** + * Everything a payload is built from. + */ +export interface PayloadInput { + action: WebhookEvent + deliveryId: string + // When the event happened + timestamp: number + workspace: string + project: { id: string, name?: string, identifier?: string } + issue: { + id: string + identifier?: string + title?: string + url?: string + createdOn?: number + modifiedOn?: number + archivedAt?: number | null + // A draft item (GitHub `DraftIssue`), not an issue yet + isDraft?: boolean + } + // Only for `edited` + change?: FieldValueChange + sender: PayloadSender +} + +/** + * The payload of an event. Free text and values are bounded; the result is plain JSON. + */ +export function buildPayload (input: PayloadInput): Record { + const payload: Record = { + action: input.action, + project_item: { + id: input.issue.id, + project_id: input.project.id, + content_type: input.issue.isDraft === true ? 'DraftIssue' : 'Issue', + content_id: input.issue.id, + identifier: boundValue(input.issue.identifier), + title: boundValue(input.issue.title), + url: boundValue(input.issue.url), + created_at: isoDate(input.issue.createdOn), + updated_at: isoDate(input.issue.modifiedOn), + archived_at: isoDate(input.issue.archivedAt) + }, + project: { + id: input.project.id, + name: boundValue(input.project.name), + identifier: boundValue(input.project.identifier) + }, + workspace: { url: boundValue(input.workspace) }, + sender: { type: input.sender.type, id: boundValue(input.sender.id), name: boundValue(input.sender.name) }, + delivery: { id: input.deliveryId, sent_at: new Date(input.timestamp).toISOString() } + } + if (input.action === 'edited' && input.change !== undefined) { + payload.changes = { + field_value: { + ...input.change, + field_name: boundValue(input.change.field_name), + from: boundValue(input.change.from), + to: boundValue(input.change.to) + } + } + } + return payload +} + +/** + * The exact bytes that are sent and signed. A payload over the limit is sent without the old and new value of the change + * (the field is still named), so a receiver can always tell what happened. + */ +export function serializePayload (payload: Record): string { + let body = JSON.stringify(payload) + if (Buffer.byteLength(body, 'utf8') <= MAX_PAYLOAD_BYTES) return body + const changes = payload.changes as { field_value: Record } | undefined + const trimmed: Record = { + ...payload, + truncated: true, + changes: + changes !== undefined ? { field_value: { ...changes.field_value, from: null, to: null } } : undefined + } + body = JSON.stringify(trimmed) + if (Buffer.byteLength(body, 'utf8') <= MAX_PAYLOAD_BYTES) return body + // Nothing left to drop but the free text + const item = { ...(payload.project_item as Record), title: null, url: null } + return JSON.stringify({ ...trimmed, project_item: item }) +} diff --git a/server-plugins/tracker-resources/src/webhook/sign.ts b/server-plugins/tracker-resources/src/webhook/sign.ts new file mode 100644 index 0000000000..55cb9a1815 --- /dev/null +++ b/server-plugins/tracker-resources/src/webhook/sign.ts @@ -0,0 +1,30 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { createHmac, timingSafeEqual } from 'crypto' + +/** Header that carries the signature of a delivery. */ +export const SIGNATURE_HEADER = 'X-Huly-Signature-256' + +const PREFIX = 'sha256=' + +/** + * The signature of a payload: `sha256=` and the hex HMAC SHA-256 of the exact bytes of the body, keyed by the secret + * (the scheme of GitHub's `X-Hub-Signature-256`). + */ +export function signPayload (secret: string, body: string): string { + return PREFIX + createHmac('sha256', secret).update(body, 'utf8').digest('hex') +} + +/** + * Checks a signature in constant time. A receiver does the same with the body it got; this is here for the tests and + * as the reference of the scheme. + */ +export function verifySignature (secret: string, body: string, header: string | undefined): boolean { + if (header === undefined || !header.startsWith(PREFIX)) return false + const expected = Buffer.from(signPayload(secret, body)) + const given = Buffer.from(header) + return expected.length === given.length && timingSafeEqual(expected, given) +} diff --git a/server-plugins/tracker-resources/src/webhook/transport.ts b/server-plugins/tracker-resources/src/webhook/transport.ts new file mode 100644 index 0000000000..993a99e645 --- /dev/null +++ b/server-plugins/tracker-resources/src/webhook/transport.ts @@ -0,0 +1,129 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { lookup as dnsLookup, type LookupAddress } from 'dns' +import http from 'http' +import https from 'https' +import { isPublicAddress, parseIp } from '@hcengineering/tracker' + +// How a delivery reaches the receiver, and the guard against server side request forgery (SSRF): a webhook URL is +// typed by a project member, so the server must never be made to call its own network. +// +// - The host is resolved by this module, every address must be public, and the connection is made to the address +// that was checked (the `lookup` of the socket is replaced), so a name that answers differently the second time +// (DNS rebinding) gets no chance. +// - An IP address in the URL is checked directly, since a socket does not resolve it. +// - A redirect is not followed: the receiver answers or the delivery fails. +// - The answer body is never read, only the status. + +/** What is sent. */ +export interface WebhookRequest { + url: URL + headers: Record + body: string + timeoutMs: number +} + +/** What comes back. */ +export interface WebhookResponse { + status: number +} + +/** + * Sends one request. It rejects when the request could not be made or timed out; a response with any status resolves. + */ +export type WebhookTransport = (request: WebhookRequest) => Promise + +/** + * The target is not allowed; such a delivery is never retried. + */ +export class WebhookBlockedError extends Error { + constructor (message: string) { + super(message) + this.name = 'WebhookBlockedError' + } +} + +/** + * Development switch (`TRACKER_WEBHOOK_ALLOW_PRIVATE=true`): http, and targets on the local network, are allowed so a + * receiver can run next to the server. Never set it in production. + */ +export function allowPrivateTargets (env: Record = process.env): boolean { + return env.TRACKER_WEBHOOK_ALLOW_PRIVATE === 'true' +} + +type LookupAll = (hostname: string, options: { all: true }, cb: (err: Error | null, addresses: LookupAddress[]) => void) => void + +/** + * A `lookup` for a socket that only ever answers with public addresses. When any address of the name is not public the + * lookup fails (a name with one private address among public ones is not trusted). + */ +export function createSafeLookup ( + allowPrivate: boolean, + resolve: LookupAll = dnsLookup as unknown as LookupAll +): (hostname: string, options: any, callback: (...args: any[]) => void) => void { + return (hostname, options, callback) => { + resolve(hostname, { all: true }, (err, addresses) => { + if (err !== null && err !== undefined) { + callback(err) + return + } + if (addresses.length === 0) { + callback(new WebhookBlockedError(`Host ${hostname} did not resolve`)) + return + } + if (!allowPrivate) { + const bad = addresses.find((a) => !isPublicAddress(a.address)) + if (bad !== undefined) { + callback(new WebhookBlockedError(`Host ${hostname} resolves to a non-public address`)) + return + } + } + if (options?.all === true) callback(null, addresses) + else callback(null, addresses[0].address, addresses[0].family) + }) + } +} + +/** + * The transport of the server: Node's http(s) client with the guarded lookup. + */ +export function createNodeTransport (allowPrivate: boolean = allowPrivateTargets()): WebhookTransport { + const lookup = createSafeLookup(allowPrivate) + return async (request) => + await new Promise((resolve, reject) => { + const { url } = request + const secure = url.protocol === 'https:' + if (!secure && !(allowPrivate && url.protocol === 'http:')) { + reject(new WebhookBlockedError('Only https targets are allowed')) + return + } + const host = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname + if (!allowPrivate && parseIp(host) !== undefined && !isPublicAddress(host)) { + reject(new WebhookBlockedError('The target is not a public address')) + return + } + const module = secure ? https : http + const req = module.request( + { + protocol: url.protocol, + hostname: host, + port: url.port !== '' ? Number(url.port) : undefined, + path: `${url.pathname}${url.search}`, + method: 'POST', + headers: { ...request.headers, 'Content-Length': String(Buffer.byteLength(request.body, 'utf8')) }, + lookup: lookup as any, + signal: AbortSignal.timeout(request.timeoutMs) + }, + (res) => { + // Only the status counts, the body is dropped + res.resume() + resolve({ status: res.statusCode ?? 0 }) + } + ) + req.on('error', reject) + req.end(request.body) + }) +} diff --git a/server-plugins/tracker-resources/src/webhook/trigger.ts b/server-plugins/tracker-resources/src/webhook/trigger.ts new file mode 100644 index 0000000000..3daf4e4177 --- /dev/null +++ b/server-plugins/tracker-resources/src/webhook/trigger.ts @@ -0,0 +1,323 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import contact, { getName } from '@hcengineering/contact' +import core, { + concatLink, + generateId, + TxProcessor, + type Doc, + type Ref, + type Tx, + type TxCUD, + type TxUpdateDoc +} from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import { getPerson } from '@hcengineering/server-contact' +import serverCore, { type TriggerControl } from '@hcengineering/server-core' +import tracker, { + isAutomationAuthor, + issueLinkSegment, + MAX_PROJECT_WEBHOOKS, + trackerId, + type Issue, + type Project, + type ProjectWebhook, + type WebhookEvent +} from '@hcengineering/tracker' +import { workbenchId } from '@hcengineering/workbench' + +import { replayAround } from '../history' +import { logTrigger } from '../log' +import { + DEFAULT_RETRY_DELAY_MS, + DEFAULT_TIMEOUT_MS, + runDeliveries, + type DeliveryJob, + type DeliveryOptions +} from './delivery' +import { + buildPayload, + describeChange, + diffIssue, + serializePayload, + WATCHED_ATTRIBUTES, + type ChangeLookups, + type FieldValueChange, + type PayloadSender, + type RawFieldChange +} from './payload' +import { allowPrivateTargets, createNodeTransport } from './transport' + +// Delivery of project webhooks. The trigger is asynchronous (it runs after the transaction is done and never delays it), +// and the deliveries themselves are not even awaited by it: they run in the background with a timeout and one retry. +// There is no persistent queue, so a delivery that is in flight when the server stops is lost (at most once, with a +// single retry). Security: see the notes of Phase 10 in the plan (SSRF guard in `transport.ts`, signature in `sign.ts`, +// secrets in the personal space of their author). + +/** Events one run of the trigger delivers at most; the rest is dropped. */ +export const MAX_EVENTS_PER_RUN = 100 + +/** Timeouts of a delivery; a test shortens them. */ +export const webhookTuning = { timeoutMs: DEFAULT_TIMEOUT_MS, retryDelayMs: DEFAULT_RETRY_DELAY_MS } + +let transportOverride: DeliveryOptions['transport'] | undefined +const inFlight = new Set>() + +/** + * Replaces the network transport; for tests. + */ +export function setWebhookTransport (transport: DeliveryOptions['transport'] | undefined): void { + transportOverride = transport +} + +/** + * Waits for the deliveries that are in flight; for tests and for a clean shutdown. + */ +export async function flushWebhookDeliveries (): Promise { + while (inFlight.size > 0) await Promise.allSettled([...inFlight]) +} + +interface ItemEvent { + tx: TxCUD + action: WebhookEvent + change?: FieldValueChange + issue: Partial & { _id: Ref } +} + +function issueUrl (control: TriggerControl, identifier: string | undefined): string | undefined { + if (identifier === undefined) return undefined + const front = control.branding?.front ?? getMetadata(serverCore.metadata.FrontUrl) ?? '' + if (front === '') return undefined + return concatLink(front, `${workbenchId}/${control.workspace.url}/${trackerId}/${identifier}`) +} + +// The part of the link of an item, `undefined` when the item has no identifier (a removed one that could not be rebuilt) +function linkSegmentOf (issue: Partial & { _id: Ref }): string | undefined { + if (issue.identifier === undefined) return undefined + return issueLinkSegment({ _id: issue._id, identifier: issue.identifier, isDraft: issue.isDraft }) +} + +const WATCHED = new Set(Object.keys(WATCHED_ATTRIBUTES)) + +function touchesWatched (tx: TxUpdateDoc): boolean { + return Object.keys(tx.operations).some((k) => WATCHED.has(k) || k === 'customFields') +} + +async function loadLookups (control: TriggerControl, project: Ref, changes: RawFieldChange[]): Promise { + const ids = (key: string): string[] => [ + ...new Set(changes.filter((c) => c.fieldId === key).flatMap((c) => [c.from, c.to]).filter((v): v is string => typeof v === 'string')) + ] + const statuses = new Map() + const people = new Map() + const components = new Map() + const milestones = new Map() + const iterations = new Map() + const fieldMap = new Map() + + const statusIds = ids('status') + if (statusIds.length > 0) { + for (const s of await control.findAll(control.ctx, tracker.class.IssueStatus, { _id: { $in: statusIds as any } })) statuses.set(s._id, s.name) + } + const assigneeIds = ids('assignee') + if (assigneeIds.length > 0) { + for (const p of await control.findAll(control.ctx, contact.class.Person, { _id: { $in: assigneeIds as any } })) { + people.set(p._id, getName(control.hierarchy, p)) + } + } + const componentIds = ids('component') + if (componentIds.length > 0) { + for (const c of await control.findAll(control.ctx, tracker.class.Component, { _id: { $in: componentIds as any } })) components.set(c._id, c.label) + } + const milestoneIds = ids('milestone') + if (milestoneIds.length > 0) { + for (const m of await control.findAll(control.ctx, tracker.class.Milestone, { _id: { $in: milestoneIds as any } })) milestones.set(m._id, m.label) + } + if (changes.some((c) => c.fieldId.startsWith('customFields.'))) { + for (const f of await control.findAll(control.ctx, tracker.class.ProjectField, { space: project })) fieldMap.set(f.key, f) + for (const it of await control.findAll(control.ctx, tracker.class.Iteration, { space: project })) iterations.set(it._id, it.label) + } + return { statuses, people, components, milestones, iterations, fields: fieldMap } +} + +async function resolveSender (control: TriggerControl, tx: Tx): Promise { + if (isAutomationAuthor(tx.modifiedBy, core.account.System)) return { type: 'Automation', name: 'Project automation' } + try { + const person = await getPerson(control, tx.modifiedBy) + if (person !== undefined) return { type: 'User', id: person._id, name: getName(control.hierarchy, person) } + } catch (err: any) { + logTrigger(control, 'warn', 'tracker webhook could not resolve the sender', { err }) + } + return { type: 'User' } +} + +// The events one transaction stands for +async function eventsOf (control: TriggerControl, tx: TxCUD, wanted: ReadonlySet): Promise { + if (tx._class === core.class.TxCreateDoc) { + if (!wanted.has('created')) return [] + return [{ tx, action: 'created', issue: TxProcessor.createDoc2Doc(tx as any) as Issue }] + } + if (tx._class === core.class.TxRemoveDoc) { + if (!wanted.has('deleted')) return [] + const { after } = await replayAround(control, tx) + return [{ tx, action: 'deleted', issue: { ...(after ?? {}), _id: tx.objectId } }] + } + if (tx._class !== core.class.TxUpdateDoc) return [] + const update = tx as TxUpdateDoc + const ops = update.operations as Record + const archives = 'archivedAt' in ops + const edits = touchesWatched(update) + if (!archives && !edits) return [] + const { before, after } = await replayAround(control, update) + if (after === undefined) return [] + const events: ItemEvent[] = [] + if (archives) { + const was = before?.archivedAt !== undefined && before?.archivedAt !== null + const is = after.archivedAt !== undefined && after.archivedAt !== null + if (is && !was && wanted.has('archived')) events.push({ tx, action: 'archived', issue: after }) + if (!is && was && wanted.has('restored')) events.push({ tx, action: 'restored', issue: after }) + } + if (edits && wanted.has('edited')) { + const changes = diffIssue(before, after) + const lookups = changes.length > 0 ? await loadLookups(control, update.objectSpace as Ref, changes) : undefined + for (const change of changes) { + if (lookups === undefined) break + events.push({ tx, action: 'edited', issue: after, change: describeChange(change, lookups) }) + } + } + return events +} + +async function newestSecret (control: TriggerControl, webhook: ProjectWebhook): Promise { + if (!webhook.hasSecret) return undefined + const secrets = await control.findAll(control.ctx, tracker.class.ProjectWebhookSecret, { webhook: webhook._id }) + const newest = [...secrets].sort((a, b) => (b.createdOn ?? 0) - (a.createdOn ?? 0))[0] + return newest?.secret +} + +/** + * Calls the webhooks of a project when one of its items is created, edited (one delivery per changed field, with the + * old and new value), archived, restored or deleted. Changes made by the project automation are delivered as well, + * with the sender `Automation`. + * @public + */ +export async function OnProjectItemWebhook (txes: Tx[], control: TriggerControl): Promise { + // The transactions of the operation: the original ones and what the synchronous triggers added to them + const seen = new Set() + const issueTxes: Array> = [] + for (const tx of [...txes, ...(control.txes ?? [])]) { + if (seen.has(tx._id)) continue + seen.add(tx._id) + if (!TxProcessor.isExtendsCUD(tx._class) || tx._class === core.class.TxMixin) continue + if (!control.hierarchy.isDerived((tx as TxCUD).objectClass, tracker.class.Issue)) continue + issueTxes.push(tx as TxCUD) + } + if (issueTxes.length === 0) return [] + + const webhooksOf = new Map, ProjectWebhook[]>() + const jobs: DeliveryJob[] = [] + const allowInsecure = allowPrivateTargets() + const secretCache = new Map() + let dropped = 0 + + for (const tx of issueTxes) { + const project = tx.objectSpace as Ref + let hooks = webhooksOf.get(project) + if (hooks === undefined) { + // At most the number a project may have (the oldest), whatever clients wrote + hooks = [...(await control.findAll(control.ctx, tracker.class.ProjectWebhook, { space: project, enabled: true }))] + .sort((a, b) => (a.createdOn ?? 0) - (b.createdOn ?? 0)) + .slice(0, MAX_PROJECT_WEBHOOKS) + webhooksOf.set(project, hooks) + } + if (hooks.length === 0) continue + + const wanted = new Set(hooks.flatMap((h) => h.events ?? [])) + if (wanted.size === 0) continue + const events = await eventsOf(control, tx, wanted) + if (events.length === 0) continue + + const sender = await resolveSender(control, tx) + const projectDoc = (await control.findAll(control.ctx, tracker.class.Project, { _id: project }, { limit: 1 }))[0] + for (const event of events) { + for (const hook of hooks) { + if (!(hook.events ?? []).includes(event.action)) continue + if (jobs.length >= MAX_EVENTS_PER_RUN) { + dropped++ + continue + } + if (!secretCache.has(hook._id)) secretCache.set(hook._id, await newestSecret(control, hook)) + const deliveryId = generateId() + const payload = buildPayload({ + action: event.action, + deliveryId, + timestamp: Date.now(), + workspace: control.workspace.url, + project: { id: project, name: projectDoc?.name, identifier: projectDoc?.identifier }, + issue: { + id: event.issue._id, + identifier: event.issue.identifier, + title: event.issue.title, + // A draft item has no identifier of an issue, its link carries the id of the document + url: issueUrl(control, linkSegmentOf(event.issue)), + createdOn: event.issue.createdOn, + modifiedOn: event.tx.modifiedOn, + archivedAt: event.issue.archivedAt, + isDraft: event.issue.isDraft === true + }, + change: event.change, + sender + }) + jobs.push({ + webhookId: hook._id, + url: hook.url, + secret: secretCache.get(hook._id), + deliveryId, + body: serializePayload(payload) + }) + } + } + } + if (dropped > 0) logTrigger(control, 'warn', 'tracker webhook events dropped (per run limit)', { dropped }) + if (jobs.length === 0) return [] + + // Not awaited: the trigger returns at once, the deliveries run on their own + const options: DeliveryOptions = { + transport: transportOverride ?? createNodeTransport(allowInsecure), + allowInsecure, + timeoutMs: webhookTuning.timeoutMs, + retryDelayMs: webhookTuning.retryDelayMs + } + const running = runDeliveries(jobs, options) + .then((results) => { + const failed = results.filter((r) => !r.ok).length + if (failed > 0) logTrigger(control, 'warn', 'tracker webhook deliveries failed', { failed, total: results.length }) + }) + .catch((err) => { + logTrigger(control, 'error', 'tracker webhook deliveries crashed', { err }) + }) + .finally(() => { + inFlight.delete(running) + }) + inFlight.add(running) + return [] +} + +/** + * The secrets of a removed webhook (they live in the personal spaces of their authors) are removed with it. + * @public + */ +export async function OnProjectWebhookRemove (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + for (const tx of txes) { + const removed = (tx as TxCUD).objectId + if (removed === undefined) continue + const secrets = await control.findAll(control.ctx, tracker.class.ProjectWebhookSecret, { webhook: removed }) + for (const secret of secrets) { + result.push(control.txFactory.createTxRemoveDoc(secret._class, secret.space, secret._id)) + } + } + return result +} diff --git a/server-plugins/tracker-resources/src/workflow/engine.ts b/server-plugins/tracker-resources/src/workflow/engine.ts new file mode 100644 index 0000000000..39e8a4c149 --- /dev/null +++ b/server-plugins/tracker-resources/src/workflow/engine.ts @@ -0,0 +1,171 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import contact, { getName, type Employee } from '@hcengineering/contact' +import { SortingOrder, type Ref } from '@hcengineering/core' +import type { TriggerControl } from '@hcengineering/server-core' +import task from '@hcengineering/task' +import tracker, { + archivedQuery, + isFilterWorkflowKind, + isWorkflowFilterUsable, + restoreCandidates, + selectWorkflowItems, + WORKFLOW_SCAN_LIMIT, + WorkflowKind, + type EffectiveWorkflow, + type Issue, + type NamedOption, + type Project +} from '@hcengineering/tracker' + +import { logTrigger } from '../log' +import { compileWorkflowFilter, type CompiledWorkflowFilter, type WorkflowFilterData } from './filter' + +/** + * What a project's filter workflows do in one run: the issues to archive and to restore. Each list is capped at + * `MAX_WORKFLOW_ITEMS_PER_RUN`; what is left is found by the next run. + */ +export interface FilterRunPlan { + archive: Array> + restore: Array> +} + +/** + * Loads what the names of a filter resolve against. + */ +export async function loadFilterData ( + control: TriggerControl, + project: Ref, + needAssignees: boolean +): Promise { + const named = (list: Array<{ _id: string, name?: string, label?: string }>): NamedOption[] => + list.map((it) => ({ id: it._id, name: it.name ?? it.label ?? '' })) + + const [statuses, components, milestones, fields, iterations] = await Promise.all([ + control.findAll(control.ctx, tracker.class.IssueStatus, {}), + control.findAll(control.ctx, tracker.class.Component, { space: project }), + control.findAll(control.ctx, tracker.class.Milestone, { space: project }), + control.findAll(control.ctx, tracker.class.ProjectField, { space: project }), + control.findAll(control.ctx, tracker.class.Iteration, { space: project }) + ]) + const assignees: NamedOption[] = needAssignees + ? (await control.findAll(control.ctx, contact.mixin.Employee, { active: true })).map((e: Employee) => ({ + id: e._id, + name: getName(control.hierarchy, e) + })) + : [] + const closedStatuses = new Set( + statuses + .filter((s) => s.category === task.statusCategory.Won || s.category === task.statusCategory.Lost) + .map((s) => s._id as string) + ) + return { + statuses: named(statuses), + components: named(components), + milestones: named(milestones), + assignees, + fields: [...fields], + iterations: [...iterations], + closedStatuses, + noParentId: tracker.ids.NoParent as string + } +} + +type Scanned = Pick & Record + +async function scan ( + control: TriggerControl, + project: Ref, + query: Record, + properties: readonly string[] +): Promise { + const projection: Record = { _id: 1, _class: 1, space: 1, archivedAt: 1, modifiedOn: 1 } + for (const key of properties) projection[key] = 1 + const found = await control.findAll( + control.ctx, + tracker.class.Issue, + { ...query, space: project } as any, + // Oldest first, so that a backlog larger than a run is worked off in order + { limit: WORKFLOW_SCAN_LIMIT, sort: { modifiedOn: SortingOrder.Ascending }, projection } as any + ) + return [...found] as unknown as Scanned[] +} + +function compileUsable ( + control: TriggerControl, + workflow: EffectiveWorkflow | undefined, + data: WorkflowFilterData, + now: number +): Extract | undefined { + if (workflow === undefined || !workflow.enabled || !isWorkflowFilterUsable(workflow.filter)) return undefined + const compiled = compileWorkflowFilter(workflow.filter, data, now) + if (compiled.ok === false) { + logTrigger(control, 'warn', 'tracker workflow filter is not valid, skipped', { + kind: workflow.kind, + error: compiled.error + }) + return undefined + } + return compiled +} + +/** + * Works out what the enabled filter workflows of a project change now. + * + * - Auto-archive: the not archived issues that match its filter are archived. + * - Auto-add (restore): the archived issues that match its filter come back, except those Auto-archive would archive + * again at once (archive wins, so the two never undo each other). + * + * A filter that is not valid, or that asks for archived items in Auto-archive, is skipped, not guessed at. Nothing is + * written here. + */ +export async function planFilterWorkflows ( + control: TriggerControl, + project: Ref, + workflows: readonly EffectiveWorkflow[], + now: number +): Promise { + const plan: FilterRunPlan = { archive: [], restore: [] } + const archiveWf = workflows.find((w) => w.kind === WorkflowKind.AutoArchive) + const addWf = workflows.find((w) => w.kind === WorkflowKind.AutoAddFromQuery) + const active = [archiveWf, addWf].filter( + (w): w is EffectiveWorkflow => w !== undefined && isFilterWorkflowKind(w.kind) && w.enabled && isWorkflowFilterUsable(w.filter) + ) + if (active.length === 0) return plan + + const needAssignees = active.some((w) => w.filter.toLowerCase().includes('assignee')) + const data = await loadFilterData(control, project, needAssignees) + + let archiveFilter = compileUsable(control, archiveWf, data, now) + if (archiveFilter?.mentionsArchived === true) { + // Archived items are not scanned by Auto-archive, so such a filter can only be a mistake + logTrigger(control, 'warn', 'tracker auto-archive filter mentions archived items, skipped', { project }) + archiveFilter = undefined + } + const addFilter = compileUsable(control, addWf, data, now) + + if (archiveFilter !== undefined) { + const candidates = await scan(control, project, { ...archiveFilter.query, ...archivedQuery(false) }, archiveFilter.projection) + // The candidates are not archived (the query says so), and the filter is evaluated on every one again + plan.archive = selectWorkflowItems( + candidates, + (issue) => (issue.archivedAt === undefined || issue.archivedAt === null) && archiveFilter?.matches(issue) === true + ).map(({ _id, _class, space }) => ({ _id, _class, space })) + } + + if (addFilter !== undefined) { + // The archive filter is evaluated on the archived issues too (archive wins), so their properties are read as well + const properties = [...addFilter.projection, ...(archiveFilter?.projection ?? [])] + const archived = await scan(control, project, { ...addFilter.query, ...archivedQuery(true) }, properties) + const archiveMatches = archiveFilter?.matches + plan.restore = restoreCandidates( + archived, + (issue) => issue.archivedAt !== undefined && issue.archivedAt !== null && addFilter.matches(issue), + archiveMatches !== undefined ? (issue) => archiveMatches({ ...issue, archivedAt: null }) : undefined + ).map(({ _id, _class, space }) => ({ _id, _class, space })) + } + return plan +} diff --git a/server-plugins/tracker-resources/src/workflow/filter.ts b/server-plugins/tracker-resources/src/workflow/filter.ts new file mode 100644 index 0000000000..f609a72253 --- /dev/null +++ b/server-plugins/tracker-resources/src/workflow/filter.ts @@ -0,0 +1,91 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import { + buildWorkflowFilterSchema, + toIterationRanges, + type Iteration, + type NamedOption, + type ProjectField +} from '@hcengineering/tracker' +import { + compileFilter, + mentionsArchived, + referencedProperties, + type FilterContext, + type Node +} from '@hcengineering/view-resources/src/filter/grammar' + +// Evaluation of the filter string of a workflow on the server, with the grammar of the project views. The grammar is +// pure (it has no UI imports), so the server loads it from its source. + +/** + * What the names in a filter resolve against. + */ +export interface WorkflowFilterData { + statuses: NamedOption[] + components: NamedOption[] + milestones: NamedOption[] + assignees: NamedOption[] + fields: ProjectField[] + iterations: Iteration[] + // Ids of the done and canceled statuses, for `is:open` / `is:closed` + closedStatuses: ReadonlySet + // `Issue.attachedTo` of an issue without a parent + noParentId: string +} + +/** + * A filter ready to run on the server. + */ +export type CompiledWorkflowFilter = + | { + ok: true + // The part the database evaluates, a safe narrowing of the candidates + query: Record + // The whole filter on one document. The scan always applies it, so the result does not depend on how much of the + // filter the database could take + matches: (doc: any) => boolean + // Properties of an issue the evaluation reads + projection: string[] + // Whether the filter talks about archived items (a filter workflow decides on its own which ones it scans) + mentionsArchived: boolean + } + | { ok: false, error: string } + +/** + * Parses a workflow filter. Invalid input is reported, never thrown. + */ +export function compileWorkflowFilter (filter: string, data: WorkflowFilterData, now: number): CompiledWorkflowFilter { + const schema = buildWorkflowFilterSchema({ + statuses: data.statuses, + assignees: data.assignees, + components: data.components, + milestones: data.milestones, + customFields: data.fields, + iterations: data.iterations, + noParentId: data.noParentId + }) + const fieldIdByKey = new Map(data.fields.map((f) => [f.key, f._id])) + const ctx: FilterContext = { + now, + closedStatuses: data.closedStatuses, + noParentId: data.noParentId, + iterations: (fieldKey: string) => { + const fieldId = fieldIdByKey.get(fieldKey) + return toIterationRanges(data.iterations.filter((it) => it.field === fieldId)) + } + } + const res = compileFilter(filter, schema, ctx) + if (res.ok === false) return { ok: false, error: res.error.message } + const ast: Node = res.value.ast + return { + ok: true, + query: res.value.query, + matches: res.value.matches, + projection: referencedProperties(ast), + mentionsArchived: mentionsArchived(ast) + } +} diff --git a/server-plugins/tracker-resources/src/workflow/triggers.ts b/server-plugins/tracker-resources/src/workflow/triggers.ts new file mode 100644 index 0000000000..68b36bb767 --- /dev/null +++ b/server-plugins/tracker-resources/src/workflow/triggers.ts @@ -0,0 +1,220 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// SPDX-License-Identifier: EPL-2.0 +// + +import core, { + TxProcessor, + type Doc, + type Ref, + type Space, + type Tx, + type TxCreateDoc, + type TxCUD, + type TxUpdateDoc +} from '@hcengineering/core' +import type { TriggerControl } from '@hcengineering/server-core' +import task from '@hcengineering/task' +import tracker, { + archiveUpdate, + isAutomationAuthor, + isFieldWorkflowKind, + planIssueWorkflows, + resolveWorkflows, + restoreUpdate, + WorkflowKind, + type EffectiveWorkflow, + type Issue, + type IssueChange, + type Project, + type Workflow +} from '@hcengineering/tracker' + +import { replayAround } from '../history' +import { logTrigger } from '../log' +import { planFilterWorkflows } from './engine' + +// Server side of the built-in workflows. +// +// - Loop protection: every write of a workflow is authored by the system account, and a workflow never reacts to a +// change authored by it (`isAutomationAuthor`). All writes are idempotent too: they are skipped when the value is +// already there, so nothing can ping-pong. The system account is also what the activity timeline shows for such a +// change ("System"), which tells the viewer it was not a person. +// - Caps: a filter workflow changes at most `MAX_WORKFLOW_ITEMS_PER_RUN` issues per run and reads at most +// `WORKFLOW_SCAN_LIMIT`, oldest first; the rest is picked up by the next run. + +/** Evaluations of a project triggered by issue changes are at most this often apart. */ +export const WORKFLOW_RUN_INTERVAL_MS = 60 * 1000 + +const lastRunKey = (project: Ref): string => `tracker:workflow:last-run:${project}` + +async function projectWorkflows (control: TriggerControl, project: Ref): Promise { + const docs = await control.findAll(control.ctx, tracker.class.Workflow, { space: project }) + return resolveWorkflows(docs as Workflow[]) +} + +function isIssueTx (tx: Tx, control: TriggerControl): tx is TxCUD { + if (!TxProcessor.isExtendsCUD(tx._class)) return false + return control.hierarchy.isDerived((tx as TxCUD).objectClass, tracker.class.Issue) +} + +/** + * The "set a field" workflows (item closed, item reopened, item added to project), run with the transaction that + * causes them. A new issue or a status change sets the value of the project's Status field when the matching + * workflow is enabled. The write is a second update of the issue in the same transaction batch. + * @public + */ +export async function OnIssueWorkflow (txes: Tx[], control: TriggerControl): Promise { + const result: Tx[] = [] + const workflowCache = new Map, EffectiveWorkflow[]>() + let closedStatuses: Set | undefined + + for (const tx of txes) { + if (isAutomationAuthor(tx.modifiedBy, core.account.System)) continue + if (!isIssueTx(tx, control)) continue + + let change: IssueChange | undefined + let issue: Pick | undefined + let prevOf: (() => Promise) | undefined + + if (tx._class === core.class.TxCreateDoc) { + const created = TxProcessor.createDoc2Doc(tx as TxCreateDoc) + change = { type: 'created' } + issue = created + } else if (tx._class === core.class.TxUpdateDoc) { + const next = (tx as TxUpdateDoc).operations.status + if (typeof next !== 'string') continue + change = { type: 'status', next } + prevOf = async () => (await replayAround(control, tx)).before?.status as string | undefined + } else { + continue + } + + const project = tx.objectSpace as Ref + let workflows = workflowCache.get(project) + if (workflows === undefined) { + workflows = await projectWorkflows(control, project) + workflowCache.set(project, workflows) + } + const fieldWorkflows = workflows.filter((w) => w.enabled && isFieldWorkflowKind(w.kind)) + if (fieldWorkflows.length === 0) continue + + const fields = await control.findAll(control.ctx, tracker.class.ProjectField, { space: project }) + if (fields.length === 0) continue + + if (closedStatuses === undefined) { + const statuses = await control.findAll(control.ctx, tracker.class.IssueStatus, {}) + closedStatuses = new Set( + statuses + .filter((s) => s.category === task.statusCategory.Won || s.category === task.statusCategory.Lost) + .map((s) => s._id as string) + ) + } + + // The previous status is only needed to tell a close from a move between closed statuses (and a reopen) + if (change.type === 'status' && prevOf !== undefined) { + change = { ...change, prev: await prevOf() } + } + if (issue === undefined) { + // The issue as it is now (the status update is already applied) + issue = (await control.findAll(control.ctx, tracker.class.Issue, { _id: tx.objectId as Ref }, { limit: 1 }))[0] + } + if (issue === undefined) continue + + const customFields = planIssueWorkflows(fieldWorkflows, change, issue, closedStatuses, fields) + if (customFields === undefined) continue + result.push( + control.txFactory.createTxUpdateDoc( + issue._class, + issue.space, + issue._id, + { customFields }, + false, + undefined, + core.account.System + ) + ) + } + return result +} + +/** + * Auto-archive and Auto-add (restore) of a project, evaluated after the fact (asynchronously). It runs + * + * - when a workflow of the project is created or changed (GitHub also archives what already matches when the + * workflow is switched on), + * - when an issue of the project changes, at most once a minute per project, which is how items that age into a + * filter such as `updated:<@today-2w` are found without a timer, + * - when a client that opens the project asks for it by touching a workflow (`runRequestedAt`). + * There is no periodic runner in the server: a project in which nothing happens and nobody looks at is not evaluated. + * @public + */ +export async function OnWorkflowEvaluate (txes: Tx[], control: TriggerControl): Promise { + const forced = new Set>() + const soft = new Set>() + for (const tx of txes) { + // Loop protection: a change made by the automation never starts another evaluation + if (isAutomationAuthor(tx.modifiedBy, core.account.System)) continue + if (!TxProcessor.isExtendsCUD(tx._class)) continue + const cud = tx as TxCUD + if (cud._class === core.class.TxRemoveDoc) continue + const project = cud.objectSpace as Ref as Ref + if (control.hierarchy.isDerived(cud.objectClass, tracker.class.Workflow)) forced.add(project) + else if (control.hierarchy.isDerived(cud.objectClass, tracker.class.Issue)) soft.add(project) + } + + const now = Date.now() + const result: Tx[] = [] + for (const project of new Set([...forced, ...soft])) { + if (!forced.has(project)) { + const last = control.cache.get(lastRunKey(project)) as number | undefined + if (last !== undefined && now - last < WORKFLOW_RUN_INTERVAL_MS) continue + } + control.cache.set(lastRunKey(project), now) + + try { + const workflows = await projectWorkflows(control, project) + if (!workflows.some((w) => w.enabled && (w.kind === WorkflowKind.AutoArchive || w.kind === WorkflowKind.AutoAddFromQuery))) { + continue + } + const plan = await planFilterWorkflows(control, project, workflows, now) + for (const issue of plan.archive) { + result.push( + control.txFactory.createTxUpdateDoc( + issue._class, + issue.space, + issue._id, + archiveUpdate(now), + false, + undefined, + core.account.System + ) + ) + } + for (const issue of plan.restore) { + result.push( + control.txFactory.createTxUpdateDoc( + issue._class, + issue.space, + issue._id, + restoreUpdate(), + false, + undefined, + core.account.System + ) + ) + } + if (plan.archive.length + plan.restore.length > 0) { + logTrigger(control, 'info', 'tracker workflows changed issues', { + project, + archived: plan.archive.length, + restored: plan.restore.length + }) + } + } catch (err: any) { + // One project must not stop the others, and a failing evaluation is retried by the next one + logTrigger(control, 'error', 'tracker workflow evaluation failed', { project, err }) + } + } + return result +} diff --git a/server-plugins/tracker-resources/tsconfig.json b/server-plugins/tracker-resources/tsconfig.json index b5ae22f6e4..c6a877cf6c 100644 --- a/server-plugins/tracker-resources/tsconfig.json +++ b/server-plugins/tracker-resources/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + "extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json", "compilerOptions": { "rootDir": "./src", diff --git a/server-plugins/tracker/src/index.ts b/server-plugins/tracker/src/index.ts index 4bb2ce8a14..f2d4575448 100644 --- a/server-plugins/tracker/src/index.ts +++ b/server-plugins/tracker/src/index.ts @@ -38,6 +38,14 @@ export default plugin(serverTrackerId, { OnIssueUpdate: '' as Resource, OnComponentRemove: '' as Resource, OnProjectRemove: '' as Resource, - OnDependencyShiftRequest: '' as Resource + OnProjectFieldCreate: '' as Resource, + OnProjectFieldRemove: '' as Resource, + OnProjectItemLimit: '' as Resource, + OnIterationRemove: '' as Resource, + OnDependencyShiftRequest: '' as Resource, + OnIssueWorkflow: '' as Resource, + OnWorkflowEvaluate: '' as Resource, + OnProjectItemWebhook: '' as Resource, + OnProjectWebhookRemove: '' as Resource } })