From 19d496b7c1abee883f275d1c747a79e40976d5bd Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Tue, 22 Sep 2026 16:45:53 +0530 Subject: [PATCH 1/2] chore: bump version to 0.3.1 and add Initiative Work Items API resource --- package.json | 2 +- src/api/Initiatives/Epics.ts | 7 +- src/api/Initiatives/WorkItems.ts | 67 ++++++++ src/api/Initiatives/index.ts | 4 + src/api/WorkItemProperties/Values.ts | 26 +-- src/index.ts | 4 + src/models/Customer.ts | 14 +- src/models/Initiative.ts | 17 ++ src/models/WorkItem.ts | 13 +- src/models/WorkItemProperty.ts | 50 +++++- src/models/common.ts | 35 +++- tests/unit/initiative-work-items.test.ts | 59 +++++++ tests/unit/initiative.test.ts | 52 ++++++ tests/unit/property-types.test.ts | 150 ++++++++++++++++++ .../work-item-types/rich-text-values.test.ts | 77 +++++++++ tests/unit/work-items/update-payload.test.ts | 60 +++++++ tests/unit/work-items/work-items.test.ts | 14 ++ 17 files changed, 626 insertions(+), 25 deletions(-) create mode 100644 src/api/Initiatives/WorkItems.ts create mode 100644 tests/unit/initiative-work-items.test.ts create mode 100644 tests/unit/property-types.test.ts create mode 100644 tests/unit/work-item-types/rich-text-values.test.ts create mode 100644 tests/unit/work-items/update-payload.test.ts diff --git a/package.json b/package.json index 8c4dda97..d40ee612 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makeplane/plane-node-sdk", - "version": "0.3.0", + "version": "0.3.1", "description": "Node SDK for Plane", "author": "Plane ", "repository": { diff --git a/src/api/Initiatives/Epics.ts b/src/api/Initiatives/Epics.ts index d34a6bf3..65598bfb 100644 --- a/src/api/Initiatives/Epics.ts +++ b/src/api/Initiatives/Epics.ts @@ -6,7 +6,12 @@ import { AddInitiativeEpicsRequest, RemoveInitiativeEpicsRequest } from "../../m /** * Initiative Epics API resource - * Handles initiative epic relationships + * + * @deprecated Use `client.initiatives.workItems`. The `/epics/` endpoints are preserved + * for backward compatibility. Server-side they share one implementation and one + * association model with `/work-items/`, so any work item type is accepted and returned + * here despite the name; the request field is spelled `epic_ids` only because it + * predates the unified model. */ export class Epics extends BaseResource { constructor(config: Configuration) { diff --git a/src/api/Initiatives/WorkItems.ts b/src/api/Initiatives/WorkItems.ts new file mode 100644 index 00000000..8630c402 --- /dev/null +++ b/src/api/Initiatives/WorkItems.ts @@ -0,0 +1,67 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { WorkItem } from "../../models/WorkItem"; +import { PaginatedResponse } from "../../models/common"; +import { + AddInitiativeWorkItemsRequest, + ListInitiativeWorkItemsParams, + RemoveInitiativeWorkItemsRequest, +} from "../../models/Initiative"; + +/** + * Initiative Work Items API resource + * Handles the work items associated with an initiative. + * + * This is the successor to `client.initiatives.epics`. The two surfaces share one implementation + * server-side and one association model, so they behave identically; they differ only + * in the URL and in the request field name (`work_item_ids` here, `epic_ids` there). + * Any work item type is accepted -- the `/epics/` spelling reflects the old Epic-only + * model and is deprecated. + */ +export class WorkItems extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List the work items associated with an initiative (paginated). + * + * Returns one page (20 by default). Pass `per_page`/`cursor` and follow + * `next_cursor` to page through the rest. + */ + async list( + workspaceSlug: string, + initiativeId: string, + params?: ListInitiativeWorkItemsParams + ): Promise> { + return this.get>( + `/workspaces/${workspaceSlug}/initiatives/${initiativeId}/work-items/`, + params + ); + } + + /** + * Associate work items with an initiative. + * + * Work items already associated are skipped. The response covers every id + * requested, not only the newly added ones. + */ + async add( + workspaceSlug: string, + initiativeId: string, + addWorkItems: AddInitiativeWorkItemsRequest + ): Promise { + return this.post(`/workspaces/${workspaceSlug}/initiatives/${initiativeId}/work-items/`, addWorkItems); + } + + /** + * Remove work items from an initiative + */ + async remove( + workspaceSlug: string, + initiativeId: string, + removeWorkItems: RemoveInitiativeWorkItemsRequest + ): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/initiatives/${initiativeId}/work-items/`, removeWorkItems); + } +} diff --git a/src/api/Initiatives/index.ts b/src/api/Initiatives/index.ts index f490ff4f..1d695b7d 100644 --- a/src/api/Initiatives/index.ts +++ b/src/api/Initiatives/index.ts @@ -5,6 +5,7 @@ import { PaginatedResponse } from "../../models/common"; import { Labels } from "./Labels"; import { Projects } from "./Projects"; import { Epics } from "./Epics"; +import { WorkItems } from "./WorkItems"; /** * Initiatives API resource @@ -13,12 +14,15 @@ import { Epics } from "./Epics"; export class Initiatives extends BaseResource { public labels: Labels; public projects: Projects; + public workItems: WorkItems; + /** @deprecated Use {@link Initiatives.workItems}; it accepts every work item type. */ public epics: Epics; constructor(config: Configuration) { super(config); this.labels = new Labels(config); this.projects = new Projects(config); + this.workItems = new WorkItems(config); this.epics = new Epics(config); } diff --git a/src/api/WorkItemProperties/Values.ts b/src/api/WorkItemProperties/Values.ts index c70305aa..ba619172 100644 --- a/src/api/WorkItemProperties/Values.ts +++ b/src/api/WorkItemProperties/Values.ts @@ -3,6 +3,7 @@ import { Configuration } from "../../Configuration"; import { UpdateWorkItemPropertyValue, ListWorkItemPropertyValuesParams, + WorkItemPropertyValueDetail, WorkItemPropertyValues, } from "../../models/WorkItemProperty"; @@ -16,15 +17,19 @@ export class Values extends BaseResource { } /** - * Retrieve a property value by ID + * Retrieve a work item's value(s) for one property + * + * Answers a single value, or a list when the property holds more than one + * (multi-select, or a cascading property's levels). A rich text value carries its + * HTML in `value_detail`; its `value` is the ID of the stored content. */ async retrieve( workspaceSlug: string, projectId: string, workItemId: string, propertyId: string - ): Promise { - const propertyValues = await this.get( + ): Promise { + const propertyValues = await this.get( `/workspaces/${workspaceSlug}/projects/${projectId}/work-items/${workItemId}/work-item-properties/${propertyId}/values/` ); return propertyValues; @@ -48,9 +53,12 @@ export class Values extends BaseResource { /** * Create/update a property value * + * A rich text property (`relation_type: "RICH_TEXT"`) takes a `RichTextValue` + * object, not a bare HTML string. + * * For single-value properties: * - Acts as an upsert operation (create or update) - * - Returns a single WorkItemPropertyValues + * - Returns a single WorkItemPropertyValueDetail * * For multi-value properties (is_multi=True): * - Replaces all existing values with the new ones (sync operation) @@ -62,8 +70,8 @@ export class Values extends BaseResource { workItemId: string, propertyId: string, updateData: UpdateWorkItemPropertyValue - ): Promise { - return this.post( + ): Promise { + return this.post( `/workspaces/${workspaceSlug}/projects/${projectId}/work-items/${workItemId}/work-item-properties/${propertyId}/values/`, updateData ); @@ -74,7 +82,7 @@ export class Values extends BaseResource { * * For single-value properties: * - Updates the existing value - * - Returns a single WorkItemPropertyValues + * - Returns a single WorkItemPropertyValueDetail * * For multi-value properties (is_multi=True): * - Replaces all existing values with the new ones (sync operation) @@ -88,8 +96,8 @@ export class Values extends BaseResource { workItemId: string, propertyId: string, updateData: UpdateWorkItemPropertyValue - ): Promise { - return this.patch( + ): Promise { + return this.patch( `/workspaces/${workspaceSlug}/projects/${projectId}/work-items/${workItemId}/work-item-properties/${propertyId}/values/`, updateData ); diff --git a/src/index.ts b/src/index.ts index d568c0f1..f0fc249f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ export { Projects as TeamspaceProjects } from "./api/Teamspaces/Projects"; export { Members as TeamspaceMembers } from "./api/Teamspaces/Members"; export { Labels as InitiativeLabels } from "./api/Initiatives/Labels"; export { Projects as InitiativeProjects } from "./api/Initiatives/Projects"; +export { WorkItems as InitiativeWorkItems } from "./api/Initiatives/WorkItems"; export { Epics as InitiativeEpics } from "./api/Initiatives/Epics"; export { Activities as AgentRunActivities } from "./api/AgentRuns/Activities"; export { WorkItems as WorkspaceWorkItemTemplates } from "./api/WorkspaceTemplates/WorkItems"; @@ -236,6 +237,9 @@ export type { CollectionMember as V2CollectionMember } from "./models/v2/Collect export type { CollectionMemberAccess as V2CollectionMemberAccess } from "./models/v2/Collection"; export type { Customer as V2Customer } from "./models/v2/Customer"; export type { CustomerProperty as V2CustomerProperty } from "./models/v2/CustomerProperty"; +// v1 declares `CustomerPropertyType` too (`src/models/common.ts`). The two unions hold +// the same nine values, so the bare name moving to v1 changes nothing a consumer sees. +export type { CustomerPropertyType as V2CustomerPropertyType } from "./models/v2/CustomerProperty"; export type { CustomerRequest as V2CustomerRequest } from "./models/v2/CustomerRequest"; export type { Estimate as V2Estimate } from "./models/v2/Estimate"; export type { EstimatePoint as V2EstimatePoint } from "./models/v2/EstimatePoint"; diff --git a/src/models/Customer.ts b/src/models/Customer.ts index 6a923e8c..0bd098c3 100644 --- a/src/models/Customer.ts +++ b/src/models/Customer.ts @@ -1,4 +1,4 @@ -import { BaseModel, PropertyRelationType, PropertyType } from "./common"; +import { BaseModel, CustomerPropertyType, CustomerRelationType } from "./common"; /** * Customer model interfaces @@ -94,8 +94,8 @@ export interface CustomerProperty extends BaseModel { description?: string; logo_props?: any; sort_order?: number; - property_type: PropertyType; - relation_type?: PropertyRelationType; + property_type: CustomerPropertyType; + relation_type?: CustomerRelationType; is_required?: boolean; default_value?: string[]; settings?: any; @@ -111,8 +111,8 @@ export interface CreateCustomerPropertyRequest { description?: string; logo_props?: any; sort_order?: number; - property_type?: PropertyType; - relation_type?: PropertyRelationType; + property_type?: CustomerPropertyType; + relation_type?: CustomerRelationType; is_required?: boolean; default_value?: string[]; settings?: any; @@ -131,8 +131,8 @@ export interface UpdateCustomerPropertyRequest { description?: string; logo_props?: any; sort_order?: number; - property_type?: PropertyType; - relation_type?: PropertyRelationType; + property_type?: CustomerPropertyType; + relation_type?: CustomerRelationType; is_required?: boolean; default_value?: string[]; settings?: any; diff --git a/src/models/Initiative.ts b/src/models/Initiative.ts index 44b74a89..a8edf4fa 100644 --- a/src/models/Initiative.ts +++ b/src/models/Initiative.ts @@ -55,10 +55,27 @@ export interface RemoveInitiativeProjectsRequest { project_ids: string[]; } +/** @deprecated Use {@link AddInitiativeWorkItemsRequest} with `client.initiatives.workItems`. */ export interface AddInitiativeEpicsRequest { epic_ids: string[]; } +/** @deprecated Use {@link RemoveInitiativeWorkItemsRequest} with `client.initiatives.workItems`. */ export interface RemoveInitiativeEpicsRequest { epic_ids: string[]; } + +export interface AddInitiativeWorkItemsRequest { + work_item_ids: string[]; +} + +export interface RemoveInitiativeWorkItemsRequest { + work_item_ids: string[]; +} + +export interface ListInitiativeWorkItemsParams { + /** Page size; the server defaults to 20. */ + per_page?: number; + /** Cursor from a previous page's `next_cursor`. */ + cursor?: string; +} diff --git a/src/models/WorkItem.ts b/src/models/WorkItem.ts index 96bb6de5..a0559dad 100644 --- a/src/models/WorkItem.ts +++ b/src/models/WorkItem.ts @@ -66,18 +66,23 @@ export interface CreateWorkItem { priority?: PriorityEnum; } +/** + * Plane patches partially: a field that is present is written, and a field left out is + * left alone. On the nullable fields, `null` clears the value -- `undefined` or an + * omitted key does not, since it never reaches the wire. + */ export interface UpdateWorkItem { name?: string; description_html?: string; state?: string; assignees?: string[]; labels?: string[]; - parent?: string; - estimate_point?: string; + parent?: string | null; + estimate_point?: string | null; type?: string; module?: string; - target_date?: string; - start_date?: string; + target_date?: string | null; + start_date?: string | null; priority?: PriorityEnum; } diff --git a/src/models/WorkItemProperty.ts b/src/models/WorkItemProperty.ts index de50f0a2..d9433ecd 100644 --- a/src/models/WorkItemProperty.ts +++ b/src/models/WorkItemProperty.ts @@ -138,6 +138,53 @@ export type WorkItemPropertyValues = { values: any[]; }[]; +/** + * The value of a rich text property: `property_type: "RELATION"`, `relation_type: "RICH_TEXT"`. + * + * Plane requires an object here, not a bare HTML string (a string answers 400 "Rich text + * value must be an object"), and sanitises the HTML before storing it. Reads return the + * stored content in {@link WorkItemPropertyValueDetail.value_detail}. + */ +export interface RichTextValue { + /** The content, as HTML, e.g. `"

Notes

"` */ + description_html: string; +} + +/** + * The stored content of a rich text property value, as Plane returns it. + */ +export interface RichTextValueDetail { + /** ID of the stored description; `null` when never set */ + id: string | null; + /** The content as sanitised HTML */ + description_html: string; + /** The content as plain text */ + description_stripped: string; +} + +/** + * A single property value, as returned by the per-property `values/` endpoint + * (`retrieve`, `create`, `update`). A property holding more than one value (multi-select, + * or a cascading property's levels) answers a list of these instead. + */ +export interface WorkItemPropertyValueDetail { + id: string; + property_id: string; + issue_id: string; + /** + * The value, formatted according to the property type. For rich text this is the ID of + * the stored content; the HTML is in `value_detail`. + */ + value: string | boolean | number | null; + value_type?: string | null; + /** The stored content of a rich text property; absent for every other type. */ + value_detail?: RichTextValueDetail; + external_id?: string | null; + external_source?: string | null; + created_at?: string; + updated_at?: string; +} + /** * UpdateWorkItemPropertyValue model interface * Request model for creating/updating a work item property value. @@ -149,6 +196,7 @@ export type WorkItemPropertyValues = { * - BOOLEAN: boolean (true/false) * - OPTION/RELATION (single): string (UUID) * - OPTION/RELATION (multi, when is_multi=True): list of strings (UUIDs) or single string + * - RELATION with relation_type=RICH_TEXT: {@link RichTextValue} * * For multi-value properties (is_multi=True): * - Accept either a single UUID string or a list of UUID strings @@ -159,7 +207,7 @@ export type WorkItemPropertyValues = { * - Only one value is allowed per work item/property combination */ export type UpdateWorkItemPropertyValue = { - value: string | boolean | number | string[]; + value: string | boolean | number | string[] | RichTextValue; external_id?: string; external_source?: string; }; diff --git a/src/models/common.ts b/src/models/common.ts index 6351dd96..a9024f68 100644 --- a/src/models/common.ts +++ b/src/models/common.ts @@ -75,9 +75,40 @@ export type ResponseEntity = T; export type PriorityEnum = "urgent" | "high" | "medium" | "low" | "none"; -export type PropertyType = "TEXT" | "DECIMAL" | "OPTION" | "BOOLEAN" | "DATETIME" | "RELATION"; +/** Work item property types. */ +export type PropertyType = + | "TEXT" + | "DATETIME" + | "DECIMAL" + | "BOOLEAN" + | "OPTION" + | "RELATION" + | "URL" + | "EMAIL" + | "FILE" + | "FORMULA" + | "CASCADING"; -export type PropertyRelationType = "USER" | "ISSUE"; +/** + * Work item relation types. A rich text property is `property_type: "RELATION"` with + * `relation_type: "RICH_TEXT"`, not a property type of its own. + */ +export type PropertyRelationType = "ISSUE" | "USER" | "RELEASE" | "RICH_TEXT"; + +/** Customer property types -- the work item set without FORMULA and CASCADING. */ +export type CustomerPropertyType = + | "TEXT" + | "DATETIME" + | "DECIMAL" + | "BOOLEAN" + | "OPTION" + | "RELATION" + | "URL" + | "EMAIL" + | "FILE"; + +/** Customer relation types -- a customer property can relate to a work item or a user. */ +export type CustomerRelationType = "ISSUE" | "USER"; export type AccessEnum = "INTERNAL" | "EXTERNAL"; diff --git a/tests/unit/initiative-work-items.test.ts b/tests/unit/initiative-work-items.test.ts new file mode 100644 index 00000000..06970859 --- /dev/null +++ b/tests/unit/initiative-work-items.test.ts @@ -0,0 +1,59 @@ +import nock from "nock"; +import { PlaneClient } from "../../src/client/plane-client"; + +/** + * What `initiatives.workItems` sends. Offline, so it runs without a Plane instance. + * The live round trip is in initiative.test.ts ("Initiative Work Items"). + */ +const BASE = "https://plane.example.com"; +const URL = "/api/v1/workspaces/ws/initiatives/i/work-items/"; + +const client = new PlaneClient({ apiKey: "k", baseUrl: BASE }); + +afterEach(() => nock.cleanAll()); + +describe("Initiative work items", () => { + it("lists the initiative's work items, passing paging params through", async () => { + const scope = nock(BASE) + .get(URL) + .query({ per_page: "5", cursor: "5:1:0" }) + .reply(200, { results: [{ id: "w1" }], next_cursor: "5:2:0", count: 1 }); + + const page = await client.initiatives.workItems.list("ws", "i", { per_page: 5, cursor: "5:1:0" }); + + expect(scope.isDone()).toBe(true); + expect(page.results.map((item) => item.id)).toEqual(["w1"]); + expect(page.next_cursor).toBe("5:2:0"); + }); + + it("adds work items with work_item_ids and answers the work items", async () => { + const scope = nock(BASE) + .post(URL, { work_item_ids: ["w1", "w2"] }) + .reply(200, [{ id: "w1" }, { id: "w2" }]); + + const added = await client.initiatives.workItems.add("ws", "i", { work_item_ids: ["w1", "w2"] }); + + expect(scope.isDone()).toBe(true); + expect(added.map((item) => item.id)).toEqual(["w1", "w2"]); + }); + + it("removes work items with a DELETE carrying work_item_ids", async () => { + const scope = nock(BASE) + .delete(URL, { work_item_ids: ["w1"] }) + .reply(204); + + await expect(client.initiatives.workItems.remove("ws", "i", { work_item_ids: ["w1"] })).resolves.toBeUndefined(); + + expect(scope.isDone()).toBe(true); + }); + + it("keeps the deprecated epics surface on /epics/ with epic_ids", async () => { + const scope = nock(BASE) + .post("/api/v1/workspaces/ws/initiatives/i/epics/", { epic_ids: ["e1"] }) + .reply(200, [{ id: "e1" }]); + + await client.initiatives.epics.add("ws", "i", { epic_ids: ["e1"] }); + + expect(scope.isDone()).toBe(true); + }); +}); diff --git a/tests/unit/initiative.test.ts b/tests/unit/initiative.test.ts index 1791fb84..8faa66d1 100644 --- a/tests/unit/initiative.test.ts +++ b/tests/unit/initiative.test.ts @@ -3,6 +3,7 @@ import { Initiative, InitiativeState, UpdateInitiative } from "../../src/models/ import { InitiativeLabel } from "../../src/models/InitiativeLabel"; import { Project } from "../../src/models/Project"; import { Epic } from "../../src/models/Epic"; +import { WorkItem } from "../../src/models/WorkItem"; import { config } from "./constants"; import { createTestClient, randomizeName } from "../helpers/test-utils"; import { describeIf } from "../helpers/conditional-tests"; @@ -242,6 +243,57 @@ describeIf(!!(config.workspaceSlug && config.projectId), "Initiative API Tests", }); }); + describe("Initiative Work Items", () => { + let testWorkItem: WorkItem; + + beforeAll(async () => { + testWorkItem = await client.workItems.create(workspaceSlug, projectId, { + name: randomizeName("Test Initiative Work Item"), + }); + }); + + afterAll(async () => { + if (testWorkItem?.id) { + try { + await client.workItems.delete(workspaceSlug, projectId, testWorkItem.id); + } catch (error) { + console.warn("Failed to delete test work item:", error); + } + } + }); + + it("should add work items to initiative", async () => { + const added = await client.initiatives.workItems.add(workspaceSlug, initiative.id, { + work_item_ids: [testWorkItem.id!], + }); + + expect(Array.isArray(added)).toBe(true); + expect(added.map((item) => item.id)).toContain(testWorkItem.id); + }); + + it("should list work items in initiative", async () => { + const workItems = await client.initiatives.workItems.list(workspaceSlug, initiative.id); + + expect(Array.isArray(workItems.results)).toBe(true); + expect(workItems.results.map((item) => item.id)).toContain(testWorkItem.id); + }); + + it("should list work items in initiative with a page size", async () => { + const workItems = await client.initiatives.workItems.list(workspaceSlug, initiative.id, { per_page: 1 }); + + expect(workItems.results.length).toBeLessThanOrEqual(1); + }); + + it("should remove work items from initiative", async () => { + await client.initiatives.workItems.remove(workspaceSlug, initiative.id, { + work_item_ids: [testWorkItem.id!], + }); + + const workItems = await client.initiatives.workItems.list(workspaceSlug, initiative.id); + expect(workItems.results.map((item) => item.id)).not.toContain(testWorkItem.id); + }); + }); + describe("Initiative Epics", () => { it("should add epics to initiative", async () => { if (!testEpic?.id) { diff --git a/tests/unit/property-types.test.ts b/tests/unit/property-types.test.ts new file mode 100644 index 00000000..c098a5a1 --- /dev/null +++ b/tests/unit/property-types.test.ts @@ -0,0 +1,150 @@ +import { + CreateCustomerPropertyRequest, + CustomerProperty, + UpdateCustomerPropertyRequest, +} from "../../src/models/Customer"; +import { + CustomerPropertyType, + CustomerRelationType, + PropertyRelationType, + PropertyType, +} from "../../src/models/common"; +import { WorkItemProperty } from "../../src/models/WorkItemProperty"; + +/** + * Property and relation type unions. Customer properties take Plane's customer set, not + * the work item one: Plane answers FORMULA/CASCADING on a customer property, or a + * RELEASE/RICH_TEXT customer relation, with a 400. + * + * These are compile-time checks -- ts-jest type-checks this file, so each + * `@ts-expect-error` fails the suite if the line it guards stops being an error. + */ +describe("Customer property types", () => { + // One literal per `@ts-expect-error`: a directive covering two values would stay + // satisfied if only one of them were wrongly admitted. + it("refuses FORMULA", () => { + const create: CreateCustomerPropertyRequest = { + display_name: "Score", + // @ts-expect-error FORMULA is a work item property type only + property_type: "FORMULA", + }; + const update: UpdateCustomerPropertyRequest = { + // @ts-expect-error FORMULA is a work item property type only + property_type: "FORMULA", + }; + + expect([create.property_type, update.property_type]).toEqual(["FORMULA", "FORMULA"]); + }); + + it("refuses CASCADING", () => { + const create: CreateCustomerPropertyRequest = { + display_name: "Region", + // @ts-expect-error CASCADING is a work item property type only + property_type: "CASCADING", + }; + const update: UpdateCustomerPropertyRequest = { + // @ts-expect-error CASCADING is a work item property type only + property_type: "CASCADING", + }; + + expect([create.property_type, update.property_type]).toEqual(["CASCADING", "CASCADING"]); + }); + + it("refuses a RELEASE relation", () => { + const create: CreateCustomerPropertyRequest = { + display_name: "Release", + property_type: "RELATION", + // @ts-expect-error a customer property relates only to a work item or a user + relation_type: "RELEASE", + }; + + expect(create.relation_type).toBe("RELEASE"); + }); + + it("refuses a RICH_TEXT relation", () => { + const create: CreateCustomerPropertyRequest = { + display_name: "Notes", + property_type: "RELATION", + // @ts-expect-error a customer property relates only to a work item or a user + relation_type: "RICH_TEXT", + }; + + expect(create.relation_type).toBe("RICH_TEXT"); + }); + + it("accepts every customer type, including URL, EMAIL and FILE", () => { + const types: CustomerPropertyType[] = ["TEXT", "DATETIME", "DECIMAL", "BOOLEAN", "OPTION", "RELATION"]; + const added: CustomerPropertyType[] = ["URL", "EMAIL", "FILE"]; + const relations: CustomerRelationType[] = ["ISSUE", "USER"]; + + const read: Pick = { + property_type: "EMAIL", + relation_type: "USER", + }; + + expect([...types, ...added]).toHaveLength(9); + expect(relations).toHaveLength(2); + expect(read.property_type).toBe("EMAIL"); + }); + + it("still takes a shared work item type the customer set includes", () => { + const shared = "RELATION" satisfies PropertyType; + const sharedRelation = "USER" satisfies PropertyRelationType; + + const create: CreateCustomerPropertyRequest = { property_type: shared, relation_type: sharedRelation }; + + expect(create).toStrictEqual({ property_type: "RELATION", relation_type: "USER" }); + }); + + it("refuses an unnarrowed work item type, which may be one the customer set lacks", () => { + const wide = "FORMULA" as PropertyType; + + // @ts-expect-error PropertyType includes FORMULA and CASCADING + const create: CreateCustomerPropertyRequest = { property_type: wide }; + + expect(create.property_type).toBe("FORMULA"); + }); + + it("is a subset of the work item types", () => { + const customerType: CustomerPropertyType = "FILE"; + const customerRelation: CustomerRelationType = "ISSUE"; + + const asWorkItemType: PropertyType = customerType; + const asWorkItemRelation: PropertyRelationType = customerRelation; + + expect([asWorkItemType, asWorkItemRelation]).toEqual(["FILE", "ISSUE"]); + }); +}); + +describe("Work item property types", () => { + it("types a CASCADING property", () => { + const property: Pick = { property_type: "CASCADING" }; + + expect(property.property_type).toBe("CASCADING"); + }); + + it("types FORMULA, URL, EMAIL and FILE properties", () => { + const types: PropertyType[] = ["FORMULA", "URL", "EMAIL", "FILE"]; + + expect(types).toHaveLength(4); + }); + + it("treats RICH_TEXT as a relation type, not a property type", () => { + const relation: PropertyRelationType = "RICH_TEXT"; + // @ts-expect-error RICH_TEXT is a relation type; the property is RELATION + const propertyType: PropertyType = "RICH_TEXT"; + + const property: Pick = { + property_type: "RELATION", + relation_type: "RICH_TEXT", + }; + + expect([relation, propertyType, property.relation_type]).toEqual(["RICH_TEXT", "RICH_TEXT", "RICH_TEXT"]); + }); + + it("types a RELEASE relation", () => { + const relation: PropertyRelationType = "RELEASE"; + + expect(relation).toBe("RELEASE"); + }); +}); diff --git a/tests/unit/work-item-types/rich-text-values.test.ts b/tests/unit/work-item-types/rich-text-values.test.ts new file mode 100644 index 00000000..2e0ca602 --- /dev/null +++ b/tests/unit/work-item-types/rich-text-values.test.ts @@ -0,0 +1,77 @@ +import nock from "nock"; +import { PlaneClient } from "../../../src/client/plane-client"; +import { RichTextValue, UpdateWorkItemPropertyValue } from "../../../src/models/WorkItemProperty"; + +/** + * A rich text value is an object with `description_html` -- Plane answers a bare string + * with 400 "Rich text value must be an object". Offline, so it runs without a Plane instance. + */ +const BASE = "https://plane.example.com"; +const URL = "/api/v1/workspaces/ws/projects/p/work-items/w/work-item-properties/prop/values/"; + +const client = new PlaneClient({ apiKey: "k", baseUrl: BASE }); + +afterEach(() => nock.cleanAll()); + +async function sent(value: UpdateWorkItemPropertyValue["value"]): Promise { + let body: unknown; + const scope = nock(BASE) + .post(URL, (requestBody) => { + body = requestBody; + return true; + }) + .reply(201, { id: "v", property_id: "prop", issue_id: "w", value: "desc-1" }); + + await client.workItemProperties.values.create("ws", "p", "w", "prop", { value }); + + expect(scope.isDone()).toBe(true); + return body; +} + +describe("Rich text property values", () => { + it("sends a rich text value as an object", async () => { + const richText: RichTextValue = { description_html: "

Notes

" }; + + expect(await sent(richText)).toStrictEqual({ value: { description_html: "

Notes

" } }); + }); + + it("leaves the other value types unchanged", async () => { + expect(await sent("plain")).toStrictEqual({ value: "plain" }); + expect(await sent(true)).toStrictEqual({ value: true }); + expect(await sent(4.5)).toStrictEqual({ value: 4.5 }); + expect(await sent(["a", "b"])).toStrictEqual({ value: ["a", "b"] }); + }); + + it("reads a rich text value's HTML from value_detail; value is the stored content's ID", async () => { + nock(BASE) + .get(URL) + .reply(200, { + id: "v", + property_id: "prop", + issue_id: "w", + value: "desc-1", + value_type: "uuid", + value_detail: { id: "desc-1", description_html: "

Notes

", description_stripped: "Notes" }, + }); + + const detail = await client.workItemProperties.values.retrieve("ws", "p", "w", "prop"); + + if (Array.isArray(detail)) throw new Error("expected a single value"); + expect(detail.value).toBe("desc-1"); + expect(detail.value_detail?.description_html).toBe("

Notes

"); + expect(detail.value_detail?.description_stripped).toBe("Notes"); + }); + + it("answers a list when the property holds more than one value", async () => { + nock(BASE) + .get(URL) + .reply(200, [ + { id: "v1", property_id: "prop", issue_id: "w", value: "o1" }, + { id: "v2", property_id: "prop", issue_id: "w", value: "o2" }, + ]); + + const values = await client.workItemProperties.values.retrieve("ws", "p", "w", "prop"); + + expect(Array.isArray(values) && values.map((v) => v.value)).toEqual(["o1", "o2"]); + }); +}); diff --git a/tests/unit/work-items/update-payload.test.ts b/tests/unit/work-items/update-payload.test.ts new file mode 100644 index 00000000..3fedb2e7 --- /dev/null +++ b/tests/unit/work-items/update-payload.test.ts @@ -0,0 +1,60 @@ +import nock from "nock"; +import { PlaneClient } from "../../../src/client/plane-client"; +import { UpdateWorkItem } from "../../../src/models/WorkItem"; + +/** + * What `workItems.update` puts on the wire. Offline, so it runs without a Plane instance. + * + * Plane patches partially, so the payload is the whole contract: a field that is present + * is written, and a field that is absent is left alone. `null` is how a nullable field is + * cleared; `undefined` never reaches the wire. + */ +const BASE = "https://plane.example.com"; +const URL = "/api/v1/workspaces/ws/projects/p/work-items/w/"; + +const client = new PlaneClient({ apiKey: "k", baseUrl: BASE }); + +afterEach(() => nock.cleanAll()); + +async function sent(data: UpdateWorkItem): Promise { + let body: unknown; + const scope = nock(BASE) + .patch(URL, (requestBody) => { + body = requestBody; + return true; + }) + .reply(200, { id: "w" }); + + await client.workItems.update("ws", "p", "w", data); + + expect(scope.isDone()).toBe(true); + return body; +} + +describe("Work item update payload", () => { + it("sends a field set to null as null", async () => { + expect(await sent({ target_date: null })).toStrictEqual({ target_date: null }); + }); + + it("does not send a field left out", async () => { + expect(await sent({ name: "Renamed" })).toStrictEqual({ name: "Renamed" }); + }); + + it("does not send a field set to undefined", async () => { + expect(await sent({ name: "Renamed", target_date: undefined })).toStrictEqual({ name: "Renamed" }); + }); + + it("clears one date and leaves the other alone", async () => { + expect(await sent({ start_date: "2026-01-01", target_date: null })).toStrictEqual({ + start_date: "2026-01-01", + target_date: null, + }); + }); + + it("clears parent and estimate_point with null", async () => { + expect(await sent({ parent: null, estimate_point: null })).toStrictEqual({ + parent: null, + estimate_point: null, + }); + }); +}); diff --git a/tests/unit/work-items/work-items.test.ts b/tests/unit/work-items/work-items.test.ts index 123ff0da..321e5bd1 100644 --- a/tests/unit/work-items/work-items.test.ts +++ b/tests/unit/work-items/work-items.test.ts @@ -71,6 +71,20 @@ describe(!!(config.workspaceSlug && config.projectId && config.userId), "Work It expect(updatedWorkItem.description_html).toBe("

Updated Test Work Item Description

"); }); + it("should clear a date set to null and leave the other alone", async () => { + await client.workItems.update(workspaceSlug, projectId, workItem.id!, { + start_date: "2026-01-01", + target_date: "2026-01-31", + }); + + const cleared = await client.workItems.update(workspaceSlug, projectId, workItem.id!, { + target_date: null, + }); + + expect(cleared.target_date ?? null).toBeNull(); + expect(cleared.start_date).toBe("2026-01-01"); + }); + it("should list work items", async () => { const workItems = await client.workItems.list(workspaceSlug, projectId); From 95229e36489edca3f396544039173668153a8467 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Tue, 22 Sep 2026 16:50:20 +0530 Subject: [PATCH 2/2] chore: update types-bundle export snapshot Records the new v1 exports (initiative work items, rich text value types, CustomerRelationType), the reshaped property/customer/update types, and v2's CustomerPropertyType moving to the V2CustomerPropertyType alias. Co-Authored-By: WOZCODE --- .../types-bundle-exports.snapshot.txt | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/scripts/__fixtures__/types-bundle-exports.snapshot.txt b/scripts/__fixtures__/types-bundle-exports.snapshot.txt index c02f9d7d..855d9c41 100644 --- a/scripts/__fixtures__/types-bundle-exports.snapshot.txt +++ b/scripts/__fixtures__/types-bundle-exports.snapshot.txt @@ -4,6 +4,7 @@ root AddEpicWorkItems AddEpicWorkItems 1a7fcb98960f392a root AddInitiativeEpicsRequest AddInitiativeEpicsRequest 1f9bd4f3adcdb4ba root AddInitiativeLabelsRequest AddInitiativeLabelsRequest 4a3a7e57dd2157c0 root AddInitiativeProjectsRequest AddInitiativeProjectsRequest 53a0593ceff8cbd3 +root AddInitiativeWorkItemsRequest AddInitiativeWorkItemsRequest f86b1aad08ab61ea root AddReleaseItemLabel AddReleaseItemLabel 572875acd1554656 root AddReleaseWorkItems AddReleaseWorkItems 1d52f5148f2b617e root AddTeamspaceMembersRequest AddTeamspaceMembersRequest e940c26eb5341c32 @@ -96,7 +97,7 @@ root CreateCollectionMember CreateCollectionMember d688f78bf4d913a2 root CreateCustomer CreateCustomer$1 fd9de350af2b072c root CreateCustomerProperty CreateCustomerProperty 420db71864a79391 root CreateCustomerPropertyOption CreateCustomerPropertyOption e818442a4ce6c3a0 -root CreateCustomerPropertyRequest CreateCustomerPropertyRequest 52ea388107f77ac4 +root CreateCustomerPropertyRequest CreateCustomerPropertyRequest c5f32e591ff1043c root CreateCustomerPropertyValues CreateCustomerPropertyValues da41ca7f5977895d root CreateCustomerRequest CreateCustomerRequest$1 565f10415fc05cb5 root CreateCycle CreateCycle 23f0ebe512ec0384 @@ -171,14 +172,15 @@ root CustomerIds CustomerIds da099612e4d1ca99 root CustomerNavigation CustomerNavigation 3fcc0ea61d9a2865 root CustomerOrderBy CustomerOrderBy f7a9e1ab0c718d10 root CustomerProperties Properties 1d1c9c2934eb5b6d -root CustomerProperty CustomerProperty$1 b0f376acacc1899a +root CustomerProperty CustomerProperty$1 161e0d613d54fe4d root CustomerPropertyField CustomerPropertyField 2c47c1217ab07847 root CustomerPropertyFieldsParams CustomerPropertyFieldsParams 10d3ef57db12a3bf root CustomerPropertyOrderBy CustomerPropertyOrderBy dfdb2d89b5562988 root CustomerPropertyRelationType CustomerPropertyRelationType 2bfd7d84e1fefe5e -root CustomerPropertyType CustomerPropertyType bc8c90c3a66852ae +root CustomerPropertyType CustomerPropertyType$1 a6b1556bab3db81f root CustomerPropertyValue CustomerPropertyValue fdc1e31a279eab61 root CustomerPropertyValuesMap CustomerPropertyValuesMap 2938d2ca1c056e61 +root CustomerRelationType CustomerRelationType 8e3a8dcbf3f71a7e root CustomerRequest CustomerRequest$1 874a4ddd78db455e root CustomerRequestField CustomerRequestField b9db5f28b94dda8b root CustomerRequestOrderBy CustomerRequestOrderBy c7f657f358bd2825 @@ -246,9 +248,10 @@ root InitiativeLabelShapeParams InitiativeLabelShapeParams 8216078f53b0a3c4 root InitiativeNavigation InitiativeNavigation b6d25992d3ffa107 root InitiativeOrderBy InitiativeOrderBy 14d95f84f079cbb4 root InitiativeProjects Projects$3 d4f6849dc8399f2c -root Initiatives Initiatives$1 61b33657c48439c5 +root Initiatives Initiatives$1 6fc71b7e6d37d5a4 root InitiativeShapeParams InitiativeShapeParams e7f44418db729904 root InitiativeState InitiativeState$1 d5be5f442a985ecc +root InitiativeWorkItems WorkItems$2 8439a00256575c60 root Intake Intake c78459a12fde46c4 root IntakeWorkItem IntakeWorkItem$1 a54033b5c1d929fb root IntakeWorkItemCreateRequest IntakeWorkItemCreateRequest b1f2db6caa073ad0 @@ -286,6 +289,7 @@ root ListGroupSyncProjectMappingsParams ListGroupSyncProjectMappingsParams 00a09 root ListGroupSyncWorkspaceMappingsParams ListGroupSyncWorkspaceMappingsParams ad5f3613b6d163ae root ListInitiativeLabelsParams ListInitiativeLabelsParams$1 1f240d14b7dd66e4 root ListInitiativesParams ListInitiativesParams$1 a8adc5407126753e +root ListInitiativeWorkItemsParams ListInitiativeWorkItemsParams da1f25c4a3013844 root ListIntakeWorkItemsParams ListIntakeWorkItemsParams 6a0e589c4f8e87d4 root ListInvitationsParams ListInvitationsParams fcb36743411aade9 root ListLabelsParams ListLabelsParams$1 e67ed5e903ccc1b0 @@ -475,15 +479,15 @@ root Projects Projects$1 088f47c4abed128d root ProjectShapeParams ProjectShapeParams f165757a25bd5926 root ProjectSummary ProjectSummary d65cc65ce9e01fe5 root ProjectSummaryCount ProjectSummaryCount a56d0cafb6046549 -root ProjectTemplates ProjectTemplates b1e0bab17e0c95dc +root ProjectTemplates ProjectTemplates 5f9538bf1fe3fe4b root ProjectTimezone ProjectTimezone 2af873c204b08948 root ProjectTypeWorkflow ProjectTypeWorkflow dcadb1ebcca53d7c root ProjectTypeWorkflows ProjectWorkflows b7c56692cb749a18 root ProjectViewOrderBy ProjectViewOrderBy ad8cf1c8aef1ae5a root ProjectWorkflowPickResult ProjectWorkflowPickResult 30eae8d7c955a00c -root ProjectWorkItemTemplates WorkItems$4 5ce2a347cfa3551a -root PropertyRelationType PropertyRelationType 5f402ef77ff8b22e -root PropertyType PropertyType 6cd58d6f37f54af9 +root ProjectWorkItemTemplates WorkItems$5 e2b728d58c51ad5c +root PropertyRelationType PropertyRelationType 37a110acff15960e +root PropertyType PropertyType 51aae2d5a6908d1a root Release Release$1 cf6bbf767217dc91 root ReleaseChangelog ReleaseChangelog$1 66538285fea72acc root ReleaseChangelogResource Changelog$1 9ba6e3cb0b90f118 @@ -507,7 +511,7 @@ root ReleaseLinks Links$3 55229b3f4762953e root ReleaseLinkShapeParams ReleaseLinkShapeParams ceb34e2325d893bd root ReleaseNavigation ReleaseNavigation 58886251bbfe75d3 root ReleaseOrderBy ReleaseOrderBy a7f347071a76ae95 -root Releases Releases$1 ba35112122cbd4b9 +root Releases Releases$1 c848279647822021 root ReleaseStatus ReleaseStatus$1 6ff81d7c7dbd4413 root ReleaseStatusFilter ReleaseStatusFilter d7d8095303a2d206 root ReleaseTag ReleaseTag$1 0dd988c4efa89124 @@ -515,17 +519,20 @@ root ReleaseTagField ReleaseTagField 04b2d3c20f2d15cc root ReleaseTagOrderBy ReleaseTagOrderBy 235fab9b00a78cd7 root ReleaseTags Tags 0e7155950d78390d root ReleaseWorkItem ReleaseWorkItem 197e573f1a5b1c83 -root ReleaseWorkItems WorkItems$3 5be6361eba6d2074 +root ReleaseWorkItems WorkItems$4 281bfaf3c9f3401c root RemoveCycleIssuesRequestRequest RemoveCycleIssuesRequestRequest 4f49b7fff51d6a19 root RemoveInitiativeEpicsRequest RemoveInitiativeEpicsRequest baad4f6ec5b883d3 root RemoveInitiativeLabelsRequest RemoveInitiativeLabelsRequest b5735e3f255e412c root RemoveInitiativeProjectsRequest RemoveInitiativeProjectsRequest 57db26014fd0b23e +root RemoveInitiativeWorkItemsRequest RemoveInitiativeWorkItemsRequest 74a230e6752c6b73 root RemoveReleaseItemLabel RemoveReleaseItemLabel e773b24b75b94c9d root RemoveReleaseWorkItems RemoveReleaseWorkItems bfbddac452d9649d root RemoveTeamspaceMembersRequest RemoveTeamspaceMembersRequest 3fdb94634e5a0385 root RemoveTeamspaceProjectsRequest RemoveTeamspaceProjectsRequest 86bd33d72e7a34f6 root RemoveWorkspaceWorkflowState RemoveWorkspaceWorkflowState 0e9bf5130afa56f1 root ResponseEntity ResponseEntity f4502bde26dca136 +root RichTextValue RichTextValue 5aa030d4c4208d7f +root RichTextValueDetail RichTextValueDetail 2ed03e51a72f7d0c root Role Role$1 5dc96afcd19d6bb0 root RoleField RoleField 685a79b673e0226d root RoleFieldsParams RoleFieldsParams 7b5e84b4cf0bece1 @@ -573,7 +580,7 @@ root UpdateCollectionMember UpdateCollectionMember 666db61788c83904 root UpdateCollectionPage UpdateCollectionPage f1ba419b598788d8 root UpdateCustomer UpdateCustomer$1 07514df5c6112dc4 root UpdateCustomerProperty UpdateCustomerProperty 58cdd9a89580f0e0 -root UpdateCustomerPropertyRequest UpdateCustomerPropertyRequest e907cf34064f40a7 +root UpdateCustomerPropertyRequest UpdateCustomerPropertyRequest 91caac25caa63141 root UpdateCustomerPropertyValue UpdateCustomerPropertyValue 60f25edbb6c8a557 root UpdateCustomerRequest UpdateCustomerRequest$1 9492d2887887f4bf root UpdateCycle UpdateCycle d7a6321443f778fc @@ -619,14 +626,14 @@ root UpdateWorkflow UpdateWorkflow$1 0929b3752ee0fe44 root UpdateWorkflowState UpdateWorkflowState$1 ecb713c892cf7421 root UpdateWorkflowTransition UpdateWorkflowTransition$1 9190e212b49f3955 root UpdateWorkflowTransitionHook UpdateWorkflowTransitionHook 5b56015072b853f6 -root UpdateWorkItem UpdateWorkItem$1 77833463edc21c90 +root UpdateWorkItem UpdateWorkItem$1 6a37c05e0699ba0b root UpdateWorkItemAttachmentRequest UpdateWorkItemAttachmentRequest 22b4f17db1cfe987 root UpdateWorkItemComment UpdateWorkItemComment 4f6aaf6666082c30 root UpdateWorkItemLink UpdateWorkItemLink 67f7ea8ef968edfb root UpdateWorkItemProperty UpdateWorkItemProperty$1 c062887b65f5eaa7 root UpdateWorkItemPropertyContext UpdateWorkItemPropertyContext b19d5a82a8ce76ef root UpdateWorkItemPropertyOption UpdateWorkItemPropertyOption$1 6d23dbf98435bcec -root UpdateWorkItemPropertyValue UpdateWorkItemPropertyValue 223f7ca36e4bcee8 +root UpdateWorkItemPropertyValue UpdateWorkItemPropertyValue 23b58c453ef01ec9 root UpdateWorkItemRelationDefinition UpdateWorkItemRelationDefinition$1 dad02a1ef882b7c4 root UpdateWorkItemTemplate UpdateWorkItemTemplate$1 856cdfea975105b7 root UpdateWorkItemType UpdateWorkItemType$1 7db0326070f3a59e @@ -684,6 +691,7 @@ root V2CreateWorkItemTemplate CreateWorkItemTemplate 82d42c0b427052e8 root V2CreateWorkItemType CreateWorkItemType 6455106b39dd4b1d root V2Customer Customer 89d52a530f3465fc root V2CustomerProperty CustomerProperty 36edbe2c51ea4a4e +root V2CustomerPropertyType CustomerPropertyType 526e078c93f38f76 root V2CustomerRequest CustomerRequest 918f9ce3c12e4dab root V2Cycle Cycle 0fbe8469e892070f root V2Estimate Estimate 66f37d2b160c0e65 @@ -893,8 +901,9 @@ root WorkItemPropertySettings WorkItemPropertySettings 655ea56b1c16fdb3 root WorkItemPropertyShapeParams WorkItemPropertyShapeParams f6cfd6133817f659 root WorkItemPropertyType WorkItemPropertyType e60994cc895bf11c root WorkItemPropertyValue WorkItemPropertyValue 91e586665aacbe28 +root WorkItemPropertyValueDetail WorkItemPropertyValueDetail 2e9b50d3e0720448 root WorkItemPropertyValueMap WorkItemPropertyValues 685636134b0e4719 -root WorkItemPropertyValues Values cb2abfa681e961da +root WorkItemPropertyValues Values 6df7f2214d886508 root WorkItemRelation WorkItemRelation 1b48e223ba35b073 root WorkItemRelationCreateRequest WorkItemRelationCreateRequest$1 18172f4acc699cd0 root WorkItemRelationDefinition WorkItemRelationDefinition$1 d217e346ff9547b4 @@ -983,7 +992,7 @@ root WorkspaceProjectStates WorkspaceProjectStates b62090f3daa94215 root WorkspaceProjectTemplate WorkspaceProjectTemplate 44e9b4a4f2108f7a root WorkspaceProjectTemplates Projects$4 cd08047c8ba2bd05 root WorkspaceStates WorkspaceStates aaa1c34fc4c585b2 -root WorkspaceTemplates WorkspaceTemplates e5c624f470f55546 +root WorkspaceTemplates WorkspaceTemplates 38a3350046a5b258 root WorkspaceViewOrderBy WorkspaceViewOrderBy ea48a52e1e6e629d root WorkspaceWorkflow WorkspaceWorkflow c6f13f923af229f1 root WorkspaceWorkflows WorkspaceWorkflows 68b5cca36b2e6514 @@ -1007,7 +1016,7 @@ root WorkspaceWorkItemTemplate WorkspaceWorkItemTemplate 353773bbf7425bbf root WorkspaceWorkItemTemplateField WorkspaceWorkItemTemplateField ebccf9986a66dcd6 root WorkspaceWorkItemTemplateFieldsParams WorkspaceWorkItemTemplateFieldsParams ee15d91efcc41baf root WorkspaceWorkItemTemplateOrderBy WorkspaceWorkItemTemplateOrderBy 96cf8171f05f86e8 -root WorkspaceWorkItemTemplates WorkItems$2 e41f16d6ba508918 +root WorkspaceWorkItemTemplates WorkItems$3 0dbd4bbf71e4299c root WorkspaceWorkItemTypeIds WorkspaceWorkItemTypeIds 3dc8ddaa2f4b2030 root WorkspaceWorkItemTypeNavigation WorkspaceWorkItemTypeNavigation a8daaff83f39f30a root WorkspaceWorkItemTypeProperties Properties$1 b6f1cc8592d0b98a @@ -1542,7 +1551,7 @@ v2models CursorPage CursorPage fcc2a5033696f854 v2models Customer Customer 89d52a530f3465fc v2models CustomerProperty CustomerProperty 36edbe2c51ea4a4e v2models CustomerPropertyRelationType CustomerPropertyRelationType 2bfd7d84e1fefe5e -v2models CustomerPropertyType CustomerPropertyType bc8c90c3a66852ae +v2models CustomerPropertyType CustomerPropertyType 526e078c93f38f76 v2models CustomerPropertyValuesMap CustomerPropertyValuesMap 2938d2ca1c056e61 v2models CustomerRequest CustomerRequest 918f9ce3c12e4dab v2models Cycle Cycle 0fbe8469e892070f