diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 0a2d0b401..da600e6f4 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -66,6 +66,7 @@ jobs: echo "data_fabric_test_entity_id=${{ secrets.UIPATH_DATA_FABRIC_TEST_ENTITY_ID_DEV || secrets.UIPATH_DATA_FABRIC_TEST_ENTITY_ID }}" >> $GITHUB_OUTPUT echo "data_fabric_test_choiceset_id=${{ secrets.UIPATH_DATA_FABRIC_TEST_CHOICESET_ID_DEV || secrets.UIPATH_DATA_FABRIC_TEST_CHOICESET_ID }}" >> $GITHUB_OUTPUT echo "data_fabric_test_attachment_field=${{ secrets.UIPATH_DATA_FABRIC_TEST_ATTACHMENT_FIELD_DEV || secrets.UIPATH_DATA_FABRIC_TEST_ATTACHMENT_FIELD }}" >> $GITHUB_OUTPUT + echo "data_fabric_test_folder_entity_id=${{ secrets.UIPATH_DATA_FABRIC_TEST_FOLDER_ENTITY_ID_DEV || secrets.UIPATH_DATA_FABRIC_TEST_FOLDER_ENTITY_ID }}" >> $GITHUB_OUTPUT echo "orchestrator_attachment_id=${{ secrets.UIPATH_ORCHESTRATOR_ATTACHMENT_ID_DEV || secrets.UIPATH_ORCHESTRATOR_ATTACHMENT_ID }}" >> $GITHUB_OUTPUT echo "jobs_test_folder_id=${{ secrets.UIPATH_JOBS_TEST_FOLDER_ID_DEV || secrets.UIPATH_JOBS_TEST_FOLDER_ID }}" >> $GITHUB_OUTPUT echo "traces_test_trace_id=${{ secrets.UIPATH_TRACES_TEST_TRACE_ID_DEV || secrets.UIPATH_TRACES_TEST_TRACE_ID }}" >> $GITHUB_OUTPUT @@ -88,6 +89,7 @@ jobs: DATA_FABRIC_TEST_ENTITY_ID=${{ steps.config.outputs.data_fabric_test_entity_id }} DATA_FABRIC_TEST_CHOICESET_ID=${{ steps.config.outputs.data_fabric_test_choiceset_id }} DATA_FABRIC_TEST_ATTACHMENT_FIELD=${{ steps.config.outputs.data_fabric_test_attachment_field }} + DATA_FABRIC_TEST_FOLDER_ENTITY_ID=${{ steps.config.outputs.data_fabric_test_folder_entity_id }} ORCHESTRATOR_TEST_PROCESS_KEY=${{ steps.config.outputs.orchestrator_test_process_key }} ORCHESTRATOR_ATTACHMENT_ID=${{ steps.config.outputs.orchestrator_attachment_id }} JOBS_TEST_FOLDER_ID=${{ steps.config.outputs.jobs_test_folder_id }} diff --git a/src/models/data-fabric/choicesets.models.ts b/src/models/data-fabric/choicesets.models.ts index e9cef5627..5d00a9ca8 100644 --- a/src/models/data-fabric/choicesets.models.ts +++ b/src/models/data-fabric/choicesets.models.ts @@ -1,12 +1,16 @@ import { + ChoiceSetGetAllOptions, ChoiceSetGetAllResponse, ChoiceSetGetResponse, ChoiceSetGetByIdOptions, ChoiceSetCreateOptions, ChoiceSetUpdateOptions, + ChoiceSetDeleteByIdOptions, ChoiceSetValueInsertOptions, ChoiceSetValueInsertResponse, - ChoiceSetValueUpdateResponse + ChoiceSetValueUpdateOptions, + ChoiceSetValueUpdateResponse, + ChoiceSetValueDeleteOptions, } from './choicesets.types'; import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination/types'; @@ -28,33 +32,32 @@ import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '. */ export interface ChoiceSetServiceModel { /** - * Gets all choice sets in the org + * Gets choice sets in the tenant. * + * Three call modes: + * - `getAll()` — default. Returns only tenant-level choice sets. + * - `getAll({ folderKey: "" })` — preferred for folder-scoped data. Returns only choice sets in that folder. + * - `getAll({ includeFolderChoiceSets: true })` — returns tenant-level **and** folder-level choice sets together. `folderKey` is preferred over `includeFolderChoiceSets` when both are set. + * + * @param options - Optional {@link ChoiceSetGetAllOptions} (`folderKey` to list a single folder's choice sets — preferred when scoping to a folder; `includeFolderChoiceSets: true` to list tenant + folder choice sets together) The `folderKey` property is **experimental**. * @returns Promise resolving to an array of choice set metadata * {@link ChoiceSetGetAllResponse} * @example * ```typescript - * // Get all choice sets - * const allChoiceSets = await choicesets.getAll(); + * // Tenant-only (default) + * const tenantChoiceSets = await choicesets.getAll(); * - * // Iterate through choice sets - * allChoiceSets.forEach(choiceSet => { - * console.log(`ChoiceSet: ${choiceSet.displayName} (${choiceSet.name})`); - * console.log(`Description: ${choiceSet.description}`); - * console.log(`Created by: ${choiceSet.createdBy}`); - * }); + * // A single folder's choice sets (preferred when targeting a specific folder) + * const folderChoiceSets = await choicesets.getAll({ folderKey: "" }); * - * // Find a specific choice set by name - * const expenseTypes = allChoiceSets.find(cs => cs.name === 'ExpenseTypes'); + * // Tenant + folder choice sets together + * const allChoiceSets = await choicesets.getAll({ includeFolderChoiceSets: true }); * - * // Check choice set details - * if (expenseTypes) { - * console.log(`Last updated: ${expenseTypes.updatedTime}`); - * console.log(`Updated by: ${expenseTypes.updatedBy}`); - * } + * // Find a specific choice set by name + * const expenseTypes = tenantChoiceSets.find(cs => cs.name === 'ExpenseTypes'); * ``` */ - getAll(): Promise; + getAll(options?: ChoiceSetGetAllOptions): Promise; /** * Gets choice set values by choice set ID with optional pagination @@ -64,7 +67,7 @@ export interface ChoiceSetServiceModel { * - A PaginatedResponse with navigation cursors (when any pagination parameter is provided) * * @param choiceSetId - UUID of the choice set - * @param options - Pagination options + * @param options - Pagination options and optional `folderKey` (omit for tenant-level choice sets) The `folderKey` property is **experimental**. * @returns Promise resolving to choice set values or paginated result * {@link ChoiceSetGetResponse} * @example @@ -89,6 +92,9 @@ export interface ChoiceSetServiceModel { * if (page1.hasNextPage) { * const page2 = await choicesets.getById(choiceSetId, { cursor: page1.nextCursor }); * } + * + * // Folder-scoped choice set + * const folderValues = await choicesets.getById(choiceSetId, { folderKey: "" }); * ``` */ getById( @@ -106,7 +112,7 @@ export interface ChoiceSetServiceModel { * @param name - Choice set name. Must start with a * letter, may contain only letters, numbers, and underscores, length * 3–100 characters (e.g., `"expenseTypes"`). - * @param options - Optional choice-set-level settings ({@link ChoiceSetCreateOptions}) + * @param options - Optional choice-set-level settings ({@link ChoiceSetCreateOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving to the UUID of the created choice set * * @example @@ -131,7 +137,7 @@ export interface ChoiceSetServiceModel { * the call throws `ValidationError` if both are omitted. * * @param choiceSetId - UUID of the choice set to update - * @param options - Metadata fields to change ({@link ChoiceSetUpdateOptions}) + * @param options - Metadata fields to change ({@link ChoiceSetUpdateOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving when the update is complete * * @example @@ -153,6 +159,7 @@ export interface ChoiceSetServiceModel { * Deletes a Data Fabric choice set and all its values. * * @param choiceSetId - UUID of the choice set to delete + * @param options - Optional {@link ChoiceSetDeleteByIdOptions} — pass `folderKey` for folder-scoped choice sets; omit for tenant-level The `folderKey` property is **experimental**. * @returns Promise resolving when the choice set is deleted * * @example @@ -162,17 +169,20 @@ export interface ChoiceSetServiceModel { * const expenseTypes = allChoiceSets.find(cs => cs.name === 'expense_types'); * * await choicesets.deleteById(expenseTypes.id); + * + * // Folder-scoped choice set + * await choicesets.deleteById(expenseTypes.id, { folderKey: "" }); * ``` * @internal */ - deleteById(choiceSetId: string): Promise; + deleteById(choiceSetId: string, options?: ChoiceSetDeleteByIdOptions): Promise; /** * Inserts a single value into a choice set. * * @param choiceSetId - UUID of the parent choice set * @param name - Identifier name of the new value (e.g., `"TRAVEL"`) - * @param options - Optional fields ({@link ChoiceSetValueInsertOptions}) + * @param options - Optional fields ({@link ChoiceSetValueInsertOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving to the inserted value ({@link ChoiceSetValueInsertResponse}) * * @example @@ -185,6 +195,12 @@ export interface ChoiceSetServiceModel { * displayName: 'Travel', * }); * console.log(inserted.id); + * + * // Folder-scoped choice set: folderKey is required on the wire + * await choicesets.insertValueById(expenseTypes.id, 'TRAVEL', { + * displayName: 'Travel', + * folderKey: "", + * }); * ``` * @internal */ @@ -203,6 +219,7 @@ export interface ChoiceSetServiceModel { * @param choiceSetId - UUID of the parent choice set * @param valueId - UUID of the value to update * @param displayName - New human-readable display name for the value + * @param options - Optional {@link ChoiceSetValueUpdateOptions} — pass `folderKey` for folder-scoped choice sets; omit for tenant-level. The `folderKey` property is **experimental**. * @returns Promise resolving to the updated value ({@link ChoiceSetValueUpdateResponse}) * * @example @@ -214,6 +231,11 @@ export interface ChoiceSetServiceModel { * const travel = values.items.find(v => v.name === 'TRAVEL'); * * await choicesets.updateValueById(expenseTypes.id, travel.id, 'Business Travel'); + * + * // Folder-scoped choice set: folderKey is required on the wire + * await choicesets.updateValueById(expenseTypes.id, travel.id, 'Business Travel', { + * folderKey: "", + * }); * ``` * @internal */ @@ -221,6 +243,7 @@ export interface ChoiceSetServiceModel { choiceSetId: string, valueId: string, displayName: string, + options?: ChoiceSetValueUpdateOptions, ): Promise; /** @@ -228,6 +251,7 @@ export interface ChoiceSetServiceModel { * * @param choiceSetId - UUID of the parent choice set * @param valueIds - Array of value UUIDs to delete + * @param options - Optional {@link ChoiceSetValueDeleteOptions} — pass `folderKey` for folder-scoped choice sets; omit for tenant-level The `folderKey` property is **experimental**. * @returns Promise resolving when the values are deleted * * @example @@ -237,9 +261,12 @@ export interface ChoiceSetServiceModel { * const idsToDelete = values.items.slice(0, 2).map(v => v.id); * * await choicesets.deleteValuesById('', idsToDelete); + * + * // Folder-scoped choice set + * await choicesets.deleteValuesById('', idsToDelete, { folderKey: "" }); * ``` * @internal */ - deleteValuesById(choiceSetId: string, valueIds: string[]): Promise; + deleteValuesById(choiceSetId: string, valueIds: string[], options?: ChoiceSetValueDeleteOptions): Promise; } diff --git a/src/models/data-fabric/choicesets.types.ts b/src/models/data-fabric/choicesets.types.ts index 5fd4cb585..b6a33e62a 100644 --- a/src/models/data-fabric/choicesets.types.ts +++ b/src/models/data-fabric/choicesets.types.ts @@ -1,4 +1,5 @@ import { PaginationOptions } from '../../utils/pagination/types'; +import { EntityFolderScopedOptions } from './data-fabric.types'; /** * ChoiceSet Get All Response @@ -49,44 +50,59 @@ export interface ChoiceSetGetResponse { recordOwner?: string; } +export interface ChoiceSetGetAllOptions extends EntityFolderScopedOptions { + /** + * When `true`, also returns folder-level choice sets alongside tenant ones. + * Omit (or `false`, the default) to return only tenant-level choice sets. + * Ignored when `folderKey` is provided — `folderKey` is preferred over `includeFolderChoiceSets` when both are set. + * + * @experimental Folder-scoped Data Fabric is in preview — the contract may change. + */ + includeFolderChoiceSets?: boolean; +} + /** * Options for getting choice set values by choice set ID */ -export type ChoiceSetGetByIdOptions = PaginationOptions; +export type ChoiceSetGetByIdOptions = PaginationOptions & EntityFolderScopedOptions; /** * Options for creating a new choice set */ -export interface ChoiceSetCreateOptions { +export interface ChoiceSetCreateOptions extends EntityFolderScopedOptions { /** Human-readable display name */ displayName?: string; /** Optional choice set description */ description?: string; - /** UUID of the folder to place the choice set in (defaults to the tenant-level folder) */ - folderKey?: string; } /** * Options for updating an existing choice set's metadata */ -export interface ChoiceSetUpdateOptions { +export interface ChoiceSetUpdateOptions extends EntityFolderScopedOptions { /** New display name for the choice set */ displayName?: string; /** New description for the choice set */ description?: string; } +export interface ChoiceSetDeleteByIdOptions extends EntityFolderScopedOptions {} + /** * Optional fields when inserting a single value into a choice set. * * The required `name` identifier is passed as a positional argument to * `insertValueById`. */ -export interface ChoiceSetValueInsertOptions { +export interface ChoiceSetValueInsertOptions extends EntityFolderScopedOptions { /** Human-readable display name */ displayName?: string; } +export interface ChoiceSetValueUpdateOptions extends EntityFolderScopedOptions {} + +export interface ChoiceSetValueDeleteOptions extends EntityFolderScopedOptions {} + /** * Response returned after inserting a choice-set value — the full value object. */ diff --git a/src/models/data-fabric/data-fabric.types.ts b/src/models/data-fabric/data-fabric.types.ts new file mode 100644 index 000000000..39a910bf3 --- /dev/null +++ b/src/models/data-fabric/data-fabric.types.ts @@ -0,0 +1,18 @@ +/** + * Shared types for the Data Fabric domain — used by both Entities and ChoiceSets. + * Lives here (not in either service's `*.types.ts`) to avoid cross-domain coupling + * between sibling services. + */ + +/** + * Common shape for every folder-scoped Data Fabric operation. + * Forwarded on the wire as the `X-UIPATH-FolderKey` header. + */ +export interface EntityFolderScopedOptions { + /** + * Key identifying the folder the entity belongs to. Omit for tenant-level entities. + * + * @experimental Folder-scoped Data Fabric is in preview — the contract may change. + */ + folderKey?: string; +} diff --git a/src/models/data-fabric/entities.constants.ts b/src/models/data-fabric/entities.constants.ts index aa61042bb..27f364056 100644 --- a/src/models/data-fabric/entities.constants.ts +++ b/src/models/data-fabric/entities.constants.ts @@ -1,4 +1,4 @@ -import { EntityFieldDataType, FieldDisplayType } from "./entities.types"; +import { EntityFieldDataType, EntityType, FieldDisplayType } from "./entities.types"; import { EntitySchemaFieldMapping, SqlFieldType, @@ -7,6 +7,15 @@ import { } from "./entities.internal-types"; export { SqlFieldType } from "./entities.internal-types"; +/** + * Numeric type IDs that pair with {@link EntityType} on reference payloads + * (entityType + entityTypeId travel together in `referenceEntity` / + * `referenceChoiceSet` bodies on cross-folder schema upserts). + */ +export const ENTITY_TYPE_IDS = { + [EntityType.ChoiceSet]: 1, +} as const; + /** * Maps fields for Entities */ diff --git a/src/models/data-fabric/entities.internal-types.ts b/src/models/data-fabric/entities.internal-types.ts index 20814b6a1..eb6248d61 100644 --- a/src/models/data-fabric/entities.internal-types.ts +++ b/src/models/data-fabric/entities.internal-types.ts @@ -1,4 +1,4 @@ -import { FieldDisplayType, EntityRecord, ReferenceType, SqlType } from './entities.types'; +import { EntityType, FieldDisplayType, EntityRecord, ReferenceType, SqlType } from './entities.types'; /** * Write-side payload shape for creating a new field in a schema upsert call. @@ -20,10 +20,25 @@ export interface FieldSchemaPayload { defaultValue?: string; choiceSetId?: string; referenceType?: ReferenceType; - referenceEntity?: { id: string }; + referenceEntity?: ReferenceEntityPayload; + referenceChoiceSet?: ReferenceEntityPayload; referenceField?: { id: string }; } +/** Body embedded in `referenceEntity` / `referenceChoiceSet` on cross-folder field payloads. */ +export interface ReferenceEntityPayload { + id: string; + name?: string; + folderId?: string; + entityType?: EntityType; + entityTypeId?: number; +} + +export interface ResolvedReferenceMeta { + referenceEntity?: ReferenceEntityPayload; + referenceChoiceSet?: ReferenceEntityPayload; +} + /** * Shape of each entry in EntitySchemaFieldTypeMap — internal only. */ diff --git a/src/models/data-fabric/entities.models.ts b/src/models/data-fabric/entities.models.ts index 9a6f1389d..0bb3c16bc 100644 --- a/src/models/data-fabric/entities.models.ts +++ b/src/models/data-fabric/entities.models.ts @@ -27,6 +27,7 @@ import { EntityImportRecordsByIdOptions, EntityCreateOptions, EntityCreateFieldOptions, + EntityGetAllOptions, EntityGetByIdOptions, EntityDeleteByIdOptions, EntityDeleteRecordByIdOptions, @@ -52,23 +53,35 @@ import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '. */ export interface EntityServiceModel { /** - * Gets all entities in the system - * - * @returns Promise resolving to either an array of entities NonPaginatedResponse or a PaginatedResponse when pagination options are used. + * Gets entities in the tenant. + * + * Three call modes: + * - `getAll()` — default. Returns only tenant-level entities. + * - `getAll({ folderKey: "" })` — preferred for folder-scoped data. Returns only entities in that folder. + * - `getAll({ includeFolderEntities: true })` — returns tenant-level **and** folder-level entities together. `folderKey` is preferred over `includeFolderEntities` when both are set. + * + * @param options - Optional {@link EntityGetAllOptions} (`folderKey` to list a single folder's entities — preferred when scoping to a folder; `includeFolderEntities: true` to list tenant + folder entities together) The `folderKey` property is **experimental**. + * @returns Promise resolving to an array of entity metadata * {@link EntityGetResponse} * @example * ```typescript - * // Get all entities - * const allEntities = await entities.getAll(); + * // Tenant-only (default) + * const tenantEntities = await entities.getAll(); + * + * // A single folder's entities (preferred when targeting a specific folder) + * const folderEntities = await entities.getAll({ folderKey: "" }); + * + * // Tenant + folder entities together + * const allEntities = await entities.getAll({ includeFolderEntities: true }); * * // Iterate through entities - * allEntities.forEach(entity => { + * tenantEntities.forEach(entity => { * console.log(`Entity: ${entity.displayName} (${entity.name})`); * console.log(`Type: ${entity.entityType}`); * }); * * // Find a specific entity by name - * const customerEntity = allEntities.find(e => e.name === 'Customer'); + * const customerEntity = tenantEntities.find(e => e.name === 'Customer'); * * // Use entity methods directly * if (customerEntity) { @@ -86,13 +99,13 @@ export interface EntityServiceModel { * } * ``` */ - getAll(): Promise; + getAll(options?: EntityGetAllOptions): Promise; /** * Gets entity metadata by entity ID with attached operation methods * * @param id - UUID of the entity - * @param options - Optional {@link EntityGetByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityGetByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to entity metadata with operation methods * {@link EntityGetResponse} * @example @@ -133,7 +146,7 @@ export interface EntityServiceModel { * Gets entity records by entity ID * * @param entityId - UUID of the entity - * @param options - Query options + * @param options - Query options The `folderKey` property is **experimental**. * @returns Promise resolving to either an array of entity records NonPaginatedResponse or a PaginatedResponse when pagination options are used. * {@link EntityRecord} * @example @@ -157,6 +170,9 @@ export interface EntityServiceModel { * cursor: paginatedResponse.nextCursor, * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * const records = await entities.getAllRecords("", { folderKey: "" }); * ``` */ getAllRecords(entityId: string, options?: T): Promise< @@ -180,7 +196,7 @@ export interface EntityServiceModel { * * @param entityId - UUID of the entity * @param recordId - UUID of the record - * @param options - Query options + * @param options - Query options The `folderKey` property is **experimental**. * @returns Promise resolving to a single entity record * {@link EntityRecord} * @example @@ -196,6 +212,11 @@ export interface EntityServiceModel { * const record = await entities.getRecordById(, recordId, { * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * const record = await entities.getRecordById(, recordId, { + * folderKey: "" + * }); * ``` */ getRecordById(entityId: string, recordId: string, options?: EntityGetRecordByIdOptions): Promise; @@ -208,7 +229,7 @@ export interface EntityServiceModel { * * @param id - UUID of the entity * @param data - Record to insert - * @param options - Insert options + * @param options - Insert options The `folderKey` property is **experimental**. * @returns Promise resolving to the inserted record with generated record ID * {@link EntityInsertResponse} * @example @@ -220,6 +241,11 @@ export interface EntityServiceModel { * const result = await entities.insertRecordById(, { name: "John", age: 30 }, { * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.insertRecordById(, { name: "John", age: 30 }, { + * folderKey: "" + * }); * ``` */ insertRecordById(id: string, data: Record, options?: EntityInsertRecordOptions): Promise; @@ -238,7 +264,7 @@ export interface EntityServiceModel { * * @param id - UUID of the entity * @param data - Array of records to insert - * @param options - Insert options + * @param options - Insert options The `folderKey` property is **experimental**. * @returns Promise resolving to insert response * {@link EntityBatchInsertResponse} * @example @@ -257,6 +283,12 @@ export interface EntityServiceModel { * expansionLevel: 1, * failOnFirst: true * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.insertRecordsById(, [ + * { name: "John", age: 30 }, + * { name: "Jane", age: 25 } + * ], { folderKey: "" }); * ``` */ insertRecordsById(id: string, data: Record[], options?: EntityInsertRecordsOptions): Promise; @@ -276,7 +308,7 @@ export interface EntityServiceModel { * @param entityId - UUID of the entity * @param recordId - UUID of the record to update * @param data - Key-value pairs of fields to update - * @param options - Update options + * @param options - Update options The `folderKey` property is **experimental**. * @returns Promise resolving to the updated record * {@link EntityUpdateRecordResponse} * @example @@ -288,6 +320,11 @@ export interface EntityServiceModel { * const result = await entities.updateRecordById(, , { name: "John Updated", age: 31 }, { * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.updateRecordById(, , { name: "John Updated" }, { + * folderKey: "" + * }); * ``` */ updateRecordById(entityId: string, recordId: string, data: Record, options?: EntityUpdateRecordOptions): Promise; @@ -299,7 +336,7 @@ export interface EntityServiceModel { * * @param id - UUID of the entity * @param data - Array of records to update. Each record MUST contain the record id. - * @param options - Update options + * @param options - Update options The `folderKey` property is **experimental**. * @returns Promise resolving to update response * {@link EntityUpdateResponse} * @example @@ -318,6 +355,11 @@ export interface EntityServiceModel { * expansionLevel: 1, * failOnFirst: true * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.updateRecordsById(, [ + * { Id: "123", name: "John Updated" } + * ], { folderKey: "" }); * ``` */ updateRecordsById(id: string, data: EntityRecord[], options?: EntityUpdateRecordsOptions): Promise; @@ -330,7 +372,7 @@ export interface EntityServiceModel { * * @param id - UUID of the entity * @param recordIds - Array of record UUIDs to delete - * @param options - Delete options + * @param options - Delete options The `folderKey` property is **experimental**. * @returns Promise resolving to delete response * {@link EntityDeleteResponse} * @example @@ -339,6 +381,11 @@ export interface EntityServiceModel { * const result = await entities.deleteRecordsById(, [ * , * ]); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.deleteRecordsById(, [ + * , + * ], { folderKey: "" }); * ``` */ deleteRecordsById(id: string, recordIds: string[], options?: EntityDeleteRecordsOptions): Promise; @@ -351,7 +398,7 @@ export interface EntityServiceModel { * * @param entityId - UUID of the entity * @param recordId - UUID of the record to delete - * @param options - Optional {@link EntityDeleteRecordByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDeleteRecordByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to void on success * @example * ```typescript @@ -371,7 +418,7 @@ export interface EntityServiceModel { * Queries entity records with filters, sorting, aggregates, and SDK-managed pagination * * @param id - UUID of the entity - * @param options - Query options including filterGroup, selectedFields, sortOptions, aggregates, groupBy, and pagination + * @param options - Query options including filterGroup, selectedFields, sortOptions, aggregates, groupBy, and pagination The `folderKey` property is **experimental**. * @returns Promise resolving to {@link NonPaginatedResponse} without pagination options, * or {@link PaginatedResponse} when `pageSize`, `cursor`, or `jumpToPage` are provided * @example @@ -405,6 +452,12 @@ export interface EntityServiceModel { * ], * }); * + * // Folder-scoped entity: pass the entity's folder key + * await entities.queryRecordsById(, { + * filterGroup: { queryFilters: [{ fieldName: "status", operator: QueryFilterOperator.Equals, value: "active" }] }, + * folderKey: "", + * }); + * * // Aggregate: total sum and average across all records (no grouping) * await entities.queryRecordsById(, { * aggregates: [ @@ -425,7 +478,7 @@ export interface EntityServiceModel { * * @param id - UUID of the entity * @param file - CSV file to import as a Blob or File or Uint8Array - * @param options - Optional {@link EntityImportRecordsByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityImportRecordsByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to {@link EntityImportRecordsResponse} with record counts * @example * ```typescript @@ -447,7 +500,7 @@ export interface EntityServiceModel { * @param entityId - UUID of the entity * @param recordId - UUID of the record containing the attachment * @param fieldName - Name of the File-type field containing the attachment - * @param options - Optional {@link EntityDownloadAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDownloadAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to Blob containing the file content * @example * ```typescript @@ -505,7 +558,7 @@ export interface EntityServiceModel { * @param recordId - UUID of the record to upload the attachment to * @param fieldName - Name of the File-type field * @param file - File to upload (Blob, File, or Uint8Array) - * @param options - Optional {@link EntityUploadAttachmentOptions} (e.g. `expansionLevel`, `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityUploadAttachmentOptions} (e.g. `expansionLevel`, `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to {@link EntityUploadAttachmentResponse} * @example * ```typescript @@ -543,7 +596,7 @@ export interface EntityServiceModel { * @param entityId - UUID of the entity * @param recordId - UUID of the record containing the attachment * @param fieldName - Name of the File-type field containing the attachment - * @param options - Optional {@link EntityDeleteAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDeleteAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to {@link EntityDeleteAttachmentResponse} * @example * ```typescript @@ -575,7 +628,7 @@ export interface EntityServiceModel { * @param name - Entity name — must start with a letter, letters/numbers/underscores only * (e.g., `"productCatalog"`). * @param fields - Array of field definitions - * @param options - Optional entity-level settings ({@link EntityCreateOptions}) + * @param options - Optional entity-level settings ({@link EntityCreateOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving to the ID of the created entity * @example * ```typescript @@ -594,6 +647,24 @@ export interface EntityServiceModel { * { fieldName: "price", type: EntityFieldDataType.DECIMAL, decimalPrecision: 4, maxValue: 999999, minValue: 0 }, * { fieldName: "quantity", type: EntityFieldDataType.INTEGER, maxValue: 10000, minValue: 1, defaultValue: "0" }, * ]); + * + * // Cross-folder references — link a folder-scoped entity to entities and + * // system choice sets that live in another folder or at the tenant level. + * await entities.create("orderLine", [ + * { + * fieldName: "order", + * type: EntityFieldDataType.RELATIONSHIP, + * referenceEntityId: "", + * referenceFieldId: "", + * referenceFolderKey: "", // target lives in a different folder + * }, + * { + * fieldName: "userType", + * type: EntityFieldDataType.CHOICE_SET_SINGLE, + * choiceSetId: "", // tenant-level system choice set + * // referenceFolderKey omitted → SDK looks up the target at tenant scope + * }, + * ], { folderKey: "" }); * ``` * @internal */ @@ -603,7 +674,7 @@ export interface EntityServiceModel { * Deletes a Data Fabric entity and all its records * * @param id - UUID of the entity to delete - * @param options - Optional {@link EntityDeleteByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDeleteByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving when the entity is deleted * @example * ```typescript @@ -624,7 +695,7 @@ export interface EntityServiceModel { * only when the corresponding fields are provided. * * @param id - UUID of the entity to update - * @param options - Changes to apply ({@link EntityUpdateByIdOptions}) + * @param options - Changes to apply ({@link EntityUpdateByIdOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving when the update is complete * * @example @@ -750,7 +821,7 @@ export interface EntityMethods { /** * Get all records from this entity * - * @param options - Query options + * @param options - Query options The `folderKey` property is **experimental**. * @returns Promise resolving to query response */ getAllRecords(options?: T): Promise< @@ -773,7 +844,7 @@ export interface EntityMethods { * * @param recordId - UUID of the record containing the attachment * @param fieldName - Name of the File-type field containing the attachment - * @param options - Optional {@link EntityDownloadAttachmentOptions} (e.g. folderKey) + * @param options - Optional {@link EntityDownloadAttachmentOptions} (e.g. folderKey) The `folderKey` property is **experimental**. * @returns Promise resolving to Blob containing the file content */ downloadAttachment(recordId: string, fieldName: string, options?: EntityDownloadAttachmentOptions): Promise; @@ -784,7 +855,7 @@ export interface EntityMethods { * @param recordId - UUID of the record to upload the attachment to * @param fieldName - Name of the File-type field * @param file - File to upload (Blob, File, or Uint8Array) - * @param options - Optional {@link EntityUploadAttachmentOptions} (e.g. expansionLevel, folderKey) + * @param options - Optional {@link EntityUploadAttachmentOptions} (e.g. expansionLevel, folderKey) The `folderKey` property is **experimental**. * @returns Promise resolving to {@link EntityUploadAttachmentResponse} */ uploadAttachment(recordId: string, fieldName: string, file: EntityFileType, options?: EntityUploadAttachmentOptions): Promise; @@ -794,7 +865,7 @@ export interface EntityMethods { * * @param recordId - UUID of the record containing the attachment * @param fieldName - Name of the File-type field containing the attachment - * @param options - Optional {@link EntityDeleteAttachmentOptions} (e.g. folderKey) + * @param options - Optional {@link EntityDeleteAttachmentOptions} (e.g. folderKey) The `folderKey` property is **experimental**. * @returns Promise resolving to {@link EntityDeleteAttachmentResponse} */ deleteAttachment(recordId: string, fieldName: string, options?: EntityDeleteAttachmentOptions): Promise; diff --git a/src/models/data-fabric/entities.types.ts b/src/models/data-fabric/entities.types.ts index 94719f18a..f12d09fcc 100644 --- a/src/models/data-fabric/entities.types.ts +++ b/src/models/data-fabric/entities.types.ts @@ -1,4 +1,8 @@ import { PaginationOptions } from "../../utils/pagination/types"; +import { EntityFolderScopedOptions } from "./data-fabric.types"; + +// Re-export — canonical definition is in `./data-fabric.types`. +export type { EntityFolderScopedOptions }; /** * Entity field data type names (SQL-level types returned by the API) @@ -125,14 +129,8 @@ export interface EntityDeleteOptions extends EntityFolderScopedOptions { */ export interface EntityDeleteRecordsOptions extends EntityDeleteOptions {} -/** - * Options for {@link EntityServiceModel.deleteRecordById} - */ export interface EntityDeleteRecordByIdOptions extends EntityFolderScopedOptions {} -/** - * Options for {@link EntityServiceModel.importRecordsById} - */ export interface EntityImportRecordsByIdOptions extends EntityFolderScopedOptions {} @@ -310,26 +308,29 @@ export interface EntityCreateFieldOptions extends EntityFieldBase { referenceEntityId?: string; /** UUID of the referenced field on the target entity (required when `type` is `RELATIONSHIP` or `FILE`) */ referenceFieldId?: string; + /** + * Folder key of the reference target when it lives outside the source's folder. Pass `'00000000-0000-0000-0000-000000000000'` for tenant-level system targets. + * + * @experimental Folder-scoped Data Fabric is in preview — the contract may change. + */ + referenceFolderKey?: string; } -/** - * Common shape for every folder-scoped Data Fabric entity operation. - * Forwarded on the wire as the `X-UIPATH-FolderKey` header. - */ -export interface EntityFolderScopedOptions { - /** Key identifying the folder the entity belongs to. Omit for tenant-level entities. */ - folderKey?: string; + +export interface EntityGetAllOptions extends EntityFolderScopedOptions { + /** + * When `true`, returns tenant-level and folder-level entities together. + * Omit (or `false`, the default) to return only tenant-level entities. + * Ignored when `folderKey` is provided — `folderKey` is preferred over `includeFolderEntities` when both are set. + * + * @experimental Folder-scoped Data Fabric is in preview — the contract may change. + */ + includeFolderEntities?: boolean; } -/** - * Options for {@link EntityServiceModel.getById} - */ export interface EntityGetByIdOptions extends EntityFolderScopedOptions {} -/** - * Options for {@link EntityServiceModel.deleteById} - */ export interface EntityDeleteByIdOptions extends EntityFolderScopedOptions {} /** diff --git a/src/services/data-fabric/choicesets.ts b/src/services/data-fabric/choicesets.ts index ed503b2f3..85d5d8292 100644 --- a/src/services/data-fabric/choicesets.ts +++ b/src/services/data-fabric/choicesets.ts @@ -1,8 +1,23 @@ import { BaseService } from '../base'; import { ChoiceSetServiceModel } from '../../models/data-fabric/choicesets.models'; -import { ChoiceSetGetAllResponse, ChoiceSetGetResponse, ChoiceSetGetByIdOptions, ChoiceSetCreateOptions, ChoiceSetUpdateOptions, ChoiceSetValueInsertOptions, ChoiceSetValueInsertResponse, ChoiceSetValueUpdateResponse } from '../../models/data-fabric/choicesets.types'; +import { + ChoiceSetGetAllOptions, + ChoiceSetGetAllResponse, + ChoiceSetGetResponse, + ChoiceSetGetByIdOptions, + ChoiceSetCreateOptions, + ChoiceSetUpdateOptions, + ChoiceSetDeleteByIdOptions, + ChoiceSetValueInsertOptions, + ChoiceSetValueInsertResponse, + ChoiceSetValueUpdateOptions, + ChoiceSetValueUpdateResponse, + ChoiceSetValueDeleteOptions, +} from '../../models/data-fabric/choicesets.types'; import { RawChoiceSetGetAllResponse, RawChoiceSetGetResponse } from '../../models/data-fabric/choicesets.internal-types'; import { DATA_FABRIC_ENDPOINTS, DATA_FABRIC_TENANT_FOLDER_ID } from '../../utils/constants/endpoints/data-fabric'; +import { FOLDER_KEY } from '../../utils/constants/headers'; +import { createHeaders } from '../../utils/http/headers'; import { transformData, pascalToCamelCaseKeys } from '../../utils/transform'; import { EntityMap } from '../../models/data-fabric/entities.constants'; import { track } from '../../core/telemetry'; @@ -14,8 +29,14 @@ import { ValidationError, NotFoundError } from '../../core/errors'; export class ChoiceSetService extends BaseService implements ChoiceSetServiceModel { /** - * Gets all choice sets in the system + * Gets choice sets in the tenant. * + * Three call modes: + * - `getAll()` — default. Returns only tenant-level choice sets. + * - `getAll({ folderKey: "" })` — preferred for folder-scoped data. Returns only choice sets in that folder. + * - `getAll({ includeFolderChoiceSets: true })` — returns tenant-level **and** folder-level choice sets together. `folderKey` is preferred over `includeFolderChoiceSets` when both are set. + * + * @param options - Optional {@link ChoiceSetGetAllOptions} (`folderKey` to list a single folder's choice sets — preferred when scoping to a folder; `includeFolderChoiceSets: true` to list tenant + folder choice sets together) The `folderKey` property is **experimental**. * @returns Promise resolving to an array of choice set metadata * * @example @@ -24,20 +45,38 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * * const choiceSets = new ChoiceSets(sdk); * - * // Get all choice sets - * const allChoiceSets = await choiceSets.getAll(); + * // Tenant-only (default) + * const tenantChoiceSets = await choiceSets.getAll(); * - * // Iterate through choice sets - * allChoiceSets.forEach(choiceSet => { - * console.log(`ChoiceSet: ${choiceSet.displayName} (${choiceSet.name})`); - * console.log(`Description: ${choiceSet.description}`); - * }); + * // A single folder's choice sets (preferred when targeting a specific folder) + * const folderChoiceSets = await choiceSets.getAll({ folderKey: "" }); + * + * // Tenant + folder choice sets together + * const allChoiceSets = await choiceSets.getAll({ includeFolderChoiceSets: true }); * ``` */ @track('Choicesets.GetAll') - async getAll(): Promise { + async getAll(options?: ChoiceSetGetAllOptions): Promise { + return this.fetchAllChoiceSets(options); + } + + /** + * Internal helper that performs the choice-set fetch. Kept separate from the + * public `getAll()` so that internal callers (e.g. `resolveChoiceSetName`) + * can reuse it without triggering double `@track` telemetry. + */ + private async fetchAllChoiceSets(options?: ChoiceSetGetAllOptions): Promise { + // The choice-set endpoint returns cross-scope results when called without + // a folder header. To stay tenant-only by default, send the tenant-marker + // UUID as the folder key unless the caller explicitly opts into cross-scope + // via includeFolderChoiceSets: true. folderKey is preferred over + // includeFolderChoiceSets when both are set. + const folderKey = options?.folderKey + ?? (options?.includeFolderChoiceSets ? undefined : DATA_FABRIC_TENANT_FOLDER_ID); + const rawResponse = await this.get( - DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: createHeaders({ [FOLDER_KEY]: folderKey }) } ); // Transform field names @@ -55,7 +94,7 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * - A PaginatedResponse with navigation cursors (when any pagination parameter is provided) * * @param choiceSetId - UUID of the choice set - * @param options - Pagination options + * @param options - Pagination options and optional `folderKey` for folder-scoped choice sets The `folderKey` property is **experimental**. * @returns Promise resolving to choice set values or paginated result * * @example @@ -84,6 +123,9 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * if (page1.hasNextPage) { * const page2 = await choiceSets.getById(choiceSetId, { cursor: page1.nextCursor }); * } + * + * // Folder-scoped choice set + * const folderValues = await choiceSets.getById(choiceSetId, { folderKey: "" }); * ``` */ @track('Choicesets.GetById') @@ -98,14 +140,19 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod // Transform a single item from PascalCase to camelCase const transformFn = (item: RawChoiceSetGetResponse): ChoiceSetGetResponse => { const camelCased = pascalToCamelCaseKeys(item); - return transformData(camelCased, EntityMap) as unknown as ChoiceSetGetResponse; + return transformData(camelCased, EntityMap) as ChoiceSetGetResponse; }; + // folderKey is header-only — destructure it out so PaginationHelpers doesn't + // include it in the POST body alongside pagination params. + const { folderKey, ...rest } = options ?? {}; + const downstreamOptions = options === undefined ? undefined : (rest as T); return PaginationHelpers.getAll({ serviceAccess: this.createPaginationServiceAccess(), getEndpoint: () => DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_BY_ID(choiceSetId), transformFn, method: HTTP_METHODS.POST, + headers: createHeaders({ [FOLDER_KEY]: folderKey }), pagination: { paginationType: PaginationType.OFFSET, itemsField: CHOICESET_VALUES_PAGINATION.ITEMS_FIELD, @@ -116,7 +163,7 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod countParam: ENTITY_OFFSET_PARAMS.COUNT_PARAM } } - }, options) as any; + }, downstreamOptions); } /** @@ -125,7 +172,7 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * @param name - Choice set name. Must start with a * letter, may contain only letters, numbers, and underscores, length * 3–100 characters (e.g., `"expenseTypes"`). - * @param options - Optional choice-set-level settings ({@link ChoiceSetCreateOptions}) + * @param options - Optional choice-set-level settings ({@link ChoiceSetCreateOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving to the UUID of the created choice set * * @example @@ -157,7 +204,11 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod folderId: opts.folderKey ?? DATA_FABRIC_TENANT_FOLDER_ID, }, }; - const response = await this.post(DATA_FABRIC_ENDPOINTS.CHOICESETS.CREATE, payload); + const response = await this.post( + DATA_FABRIC_ENDPOINTS.CHOICESETS.CREATE, + payload, + { headers: createHeaders({ [FOLDER_KEY]: opts.folderKey }) }, + ); return response.data; } @@ -168,7 +219,7 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * the call throws `ValidationError` if both are omitted. * * @param choiceSetId - UUID of the choice set to update - * @param options - Metadata fields to change ({@link ChoiceSetUpdateOptions}) + * @param options - Metadata fields to change ({@link ChoiceSetUpdateOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving when the update is complete * * @example @@ -191,16 +242,21 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod message: 'updateById requires at least one of displayName or description.', }); } - await this.patch(DATA_FABRIC_ENDPOINTS.CHOICESETS.UPDATE(choiceSetId), { - ...(options.displayName !== undefined && { displayName: options.displayName }), - ...(options.description !== undefined && { description: options.description }), - }); + await this.patch( + DATA_FABRIC_ENDPOINTS.CHOICESETS.UPDATE(choiceSetId), + { + ...(options.displayName !== undefined && { displayName: options.displayName }), + ...(options.description !== undefined && { description: options.description }), + }, + { headers: createHeaders({ [FOLDER_KEY]: options.folderKey }) }, + ); } /** * Deletes a Data Fabric choice set and all its values. * * @param choiceSetId - UUID of the choice set to delete + * @param options - Optional {@link ChoiceSetDeleteByIdOptions} (e.g. `folderKey` for folder-scoped choice sets) The `folderKey` property is **experimental**. * @returns Promise resolving when the choice set is deleted * * @example @@ -210,12 +266,19 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * const expenseTypes = allChoiceSets.find(cs => cs.name === 'expense_types'); * * await choicesets.deleteById(expenseTypes.id); + * + * // Folder-scoped choice set + * await choicesets.deleteById(expenseTypes.id, { folderKey: "" }); * ``` * @internal */ @track('Choicesets.DeleteById') - async deleteById(choiceSetId: string): Promise { - await this.post(DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE(choiceSetId), {}); + async deleteById(choiceSetId: string, options?: ChoiceSetDeleteByIdOptions): Promise { + await this.post( + DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE(choiceSetId), + {}, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) }, + ); } /** @@ -223,7 +286,7 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * * @param choiceSetId - UUID of the parent choice set * @param name - Identifier name of the new value (e.g., `"TRAVEL"`) - * @param options - Optional fields ({@link ChoiceSetValueInsertOptions}) + * @param options - Optional fields ({@link ChoiceSetValueInsertOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving to the inserted value ({@link ChoiceSetValueInsertResponse}) * * @example @@ -236,6 +299,12 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * displayName: 'Travel', * }); * console.log(inserted.id); + * + * // Folder-scoped choice set: folderKey is required on the wire + * await choicesets.insertValueById(expenseTypes.id, 'TRAVEL', { + * displayName: 'Travel', + * folderKey: "", + * }); * ``` * @internal */ @@ -245,14 +314,15 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod name: string, options?: ChoiceSetValueInsertOptions, ): Promise { - const choiceSetName = await this.resolveChoiceSetName(choiceSetId); + const choiceSetName = await this.resolveChoiceSetName(choiceSetId, options?.folderKey); const payload = { Name: name, ...(options?.displayName !== undefined && { DisplayName: options.displayName }), }; const response = await this.post( DATA_FABRIC_ENDPOINTS.CHOICESETS.INSERT_BY_NAME(choiceSetName), - payload + payload, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) }, ); const camelCased = pascalToCamelCaseKeys(response.data); return transformData(camelCased, EntityMap) as ChoiceSetValueInsertResponse; @@ -267,6 +337,7 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * @param choiceSetId - UUID of the parent choice set * @param valueId - UUID of the value to update * @param displayName - New human-readable display name for the value + * @param options - Optional {@link ChoiceSetValueUpdateOptions} — pass `folderKey` for folder-scoped choice sets; omit for tenant-level. The `folderKey` property is **experimental**. * @returns Promise resolving to the updated value ({@link ChoiceSetValueUpdateResponse}) * * @example @@ -278,6 +349,11 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * const travel = values.items.find(v => v.name === 'TRAVEL'); * * await choicesets.updateValueById(expenseTypes.id, travel.id, 'Business Travel'); + * + * // Folder-scoped choice set: folderKey is required on the wire + * await choicesets.updateValueById(expenseTypes.id, travel.id, 'Business Travel', { + * folderKey: "", + * }); * ``` * @internal */ @@ -286,12 +362,14 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod choiceSetId: string, valueId: string, displayName: string, + options?: ChoiceSetValueUpdateOptions, ): Promise { - const choiceSetName = await this.resolveChoiceSetName(choiceSetId); + const choiceSetName = await this.resolveChoiceSetName(choiceSetId, options?.folderKey); const payload = { DisplayName: displayName }; const response = await this.post( DATA_FABRIC_ENDPOINTS.CHOICESETS.UPDATE_BY_NAME(choiceSetName, valueId), - payload + payload, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) }, ); const camelCased = pascalToCamelCaseKeys(response.data); return transformData(camelCased, EntityMap) as ChoiceSetValueUpdateResponse; @@ -302,6 +380,7 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * * @param choiceSetId - UUID of the parent choice set * @param valueIds - Array of value UUIDs to delete + * @param options - Optional {@link ChoiceSetValueDeleteOptions} (e.g. `folderKey` for folder-scoped choice sets) The `folderKey` property is **experimental**. * @returns Promise resolving when the values are deleted * * @example @@ -311,16 +390,26 @@ export class ChoiceSetService extends BaseService implements ChoiceSetServiceMod * const idsToDelete = values.items.slice(0, 2).map(v => v.id); * * await choicesets.deleteValuesById('', idsToDelete); + * + * // Folder-scoped choice set + * await choicesets.deleteValuesById('', idsToDelete, { folderKey: "" }); * ``` * @internal */ @track('Choicesets.DeleteValuesById') - async deleteValuesById(choiceSetId: string, valueIds: string[]): Promise { - await this.post(DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE_BY_ID(choiceSetId), valueIds); + async deleteValuesById(choiceSetId: string, valueIds: string[], options?: ChoiceSetValueDeleteOptions): Promise { + await this.post( + DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE_BY_ID(choiceSetId), + valueIds, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) }, + ); } - private async resolveChoiceSetName(choiceSetId: string): Promise { - const all = await this.getAll(); + private async resolveChoiceSetName(choiceSetId: string, folderKey?: string): Promise { + // Use the un-tracked helper directly so we don't fire a duplicate + // `Choicesets.GetAll` telemetry event for every insertValueById / + // updateValueById call. + const all = await this.fetchAllChoiceSets(folderKey === undefined ? undefined : { folderKey }); const match = all.find(cs => cs.id === choiceSetId); if (!match) { throw new NotFoundError({ message: `Choice set with id '${choiceSetId}' not found.` }); diff --git a/src/services/data-fabric/entities.ts b/src/services/data-fabric/entities.ts index 89e0dd9cf..3b3ae3f1a 100644 --- a/src/services/data-fabric/entities.ts +++ b/src/services/data-fabric/entities.ts @@ -32,6 +32,8 @@ import { EntityCreateOptions, EntityCreateFieldOptions, EntityFieldDataType, + EntityType, + EntityGetAllOptions, EntityGetByIdOptions, EntityDeleteByIdOptions, EntityDeleteRecordByIdOptions, @@ -56,8 +58,9 @@ import { FieldDisplayTypeToDataType, ENTITY_FIELD_CONSTRAINT_DEFAULTS, ENTITY_FIELD_CONSTRAINT_SPEC, + ENTITY_TYPE_IDS, } from '../../models/data-fabric/entities.constants'; -import { FieldSchemaPayload, SqlFieldType, EntityFieldConstraint } from '../../models/data-fabric/entities.internal-types'; +import { FieldSchemaPayload, SqlFieldType, EntityFieldConstraint, ResolvedReferenceMeta } from '../../models/data-fabric/entities.internal-types'; import { track } from '../../core/telemetry'; /** @@ -68,7 +71,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * Gets entity metadata by entity ID with attached operation methods * * @param id - UUID of the entity - * @param options - Optional {@link EntityGetByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityGetByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to entity metadata with schema information and operation methods * * @example @@ -116,7 +119,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * Gets entity records by entity ID * * @param entityId - UUID of the entity - * @param options - Query options including expansionLevel and pagination options + * @param options - Query options including expansionLevel and pagination options The `folderKey` property is **experimental**. * @returns Promise resolving to an array of entity records or paginated response * * @example @@ -144,6 +147,9 @@ export class EntityService extends BaseService implements EntityServiceModel { * cursor: paginatedResponse.nextCursor, * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * const records = await entities.getAllRecords("", { folderKey: "" }); * ``` */ @track('Entities.GetAllRecords') @@ -157,7 +163,7 @@ export class EntityService extends BaseService implements EntityServiceModel { > { // folderKey is header-only — destructure it out so PaginationHelpers doesn't serialise it // into the query string as $folderKey. - const { folderKey, ...rest } = options ?? {} as T; + const { folderKey, ...rest } = options ?? {}; const downstreamOptions = options === undefined ? undefined : (rest as T); return PaginationHelpers.getAll({ serviceAccess: this.createPaginationServiceAccess(), @@ -182,7 +188,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * * @param entityId - UUID of the entity * @param recordId - UUID of the record - * @param options - Query options including expansionLevel + * @param options - Query options including `expansionLevel` and `folderKey` The `folderKey` property is **experimental**. * @returns Promise resolving to the entity record * * @example @@ -194,6 +200,11 @@ export class EntityService extends BaseService implements EntityServiceModel { * const record = await sdk.entities.getRecordById(, , { * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * const record = await sdk.entities.getRecordById(, , { + * folderKey: "" + * }); * ``` */ @track('Entities.GetRecordById') @@ -219,7 +230,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * * @param entityId - UUID of the entity * @param data - Record to insert - * @param options - Insert options + * @param options - Insert options The `folderKey` property is **experimental**. * @returns Promise resolving to the inserted record with generated record ID * * @example @@ -235,6 +246,11 @@ export class EntityService extends BaseService implements EntityServiceModel { * const result = await entities.insertRecordById("", { name: "John", age: 30 }, { * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.insertRecordById("", { name: "John", age: 30 }, { + * folderKey: "" + * }); * ``` */ @track('Entities.InsertRecordById') @@ -260,7 +276,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * * @param entityId - UUID of the entity * @param data - Array of records to insert - * @param options - Insert options + * @param options - Insert options The `folderKey` property is **experimental**. * @returns Promise resolving to insert response * * @example @@ -283,6 +299,12 @@ export class EntityService extends BaseService implements EntityServiceModel { * expansionLevel: 1, * failOnFirst: true * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.insertRecordsById("", [ + * { name: "John", age: 30 }, + * { name: "Jane", age: 25 } + * ], { folderKey: "" }); * ``` */ @track('Entities.InsertRecordsById') @@ -310,7 +332,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * @param entityId - UUID of the entity * @param recordId - UUID of the record to update * @param data - Key-value pairs of fields to update - * @param options - Update options + * @param options - Update options The `folderKey` property is **experimental**. * @returns Promise resolving to the updated record * * @example @@ -326,6 +348,11 @@ export class EntityService extends BaseService implements EntityServiceModel { * const result = await entities.updateRecordById("", "", { name: "John Updated", age: 31 }, { * expansionLevel: 1 * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.updateRecordById("", "", { name: "John Updated" }, { + * folderKey: "" + * }); * ``` */ @track('Entities.UpdateRecordById') @@ -352,7 +379,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * @param entityId - UUID of the entity * @param data - Array of records to update. Each record MUST contain the record Id, * otherwise the update will fail. - * @param options - Update options + * @param options - Update options The `folderKey` property is **experimental**. * @returns Promise resolving to update response * * @example @@ -375,6 +402,11 @@ export class EntityService extends BaseService implements EntityServiceModel { * expansionLevel: 1, * failOnFirst: true * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.updateRecordsById("", [ + * { Id: "123", name: "John Updated" } + * ], { folderKey: "" }); * ``` */ @track('Entities.UpdateRecordsById') @@ -403,7 +435,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * * @param entityId - UUID of the entity * @param recordIds - Array of record UUIDs to delete - * @param options - Delete options + * @param options - Delete options The `folderKey` property is **experimental**. * @returns Promise resolving to delete response * * @example @@ -416,6 +448,11 @@ export class EntityService extends BaseService implements EntityServiceModel { * const result = await entities.deleteRecordsById("", [ * "", "" * ]); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.deleteRecordsById("", [ + * "", "" + * ], { folderKey: "" }); * ``` */ @track('Entities.DeleteRecordsById') @@ -444,7 +481,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * * @param entityId - UUID of the entity * @param recordId - UUID of the record to delete - * @param options - Optional {@link EntityDeleteRecordByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDeleteRecordByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to void on success * @example * ```typescript @@ -467,8 +504,14 @@ export class EntityService extends BaseService implements EntityServiceModel { } /** - * Gets all entities in the system + * Gets entities in the tenant. * + * Three call modes: + * - `getAll()` — default. Returns only tenant-level entities. + * - `getAll({ folderKey: "" })` — preferred for folder-scoped data. Returns only entities in that folder. + * - `getAll({ includeFolderEntities: true })` — returns tenant-level **and** folder-level entities together. `folderKey` is preferred over `includeFolderEntities` when both are set. + * + * @param options - Optional {@link EntityGetAllOptions} (`folderKey` to list a single folder's entities — preferred when scoping to a folder; `includeFolderEntities: true` to list tenant + folder entities together) The `folderKey` property is **experimental**. * @returns Promise resolving to an array of entity metadata * * @example @@ -477,17 +520,33 @@ export class EntityService extends BaseService implements EntityServiceModel { * * const entities = new Entities(sdk); * - * // Get all entities - * const allEntities = await entities.getAll(); + * // Tenant-only (default) + * const tenantEntities = await entities.getAll(); + * + * // A single folder's entities (preferred when targeting a specific folder) + * const folderEntities = await entities.getAll({ folderKey: "" }); + * + * // Tenant + folder entities together + * const allEntities = await entities.getAll({ includeFolderEntities: true }); * * // Call operations on an entity - * const records = await allEntities[0].getAllRecords(); + * const records = await tenantEntities[0].getAllRecords(); * ``` */ @track('Entities.GetAll') - async getAll(): Promise { + async getAll(options?: EntityGetAllOptions): Promise { + // folderKey is preferred over includeFolderEntities: when present, scope to that folder + // via the v1 endpoint + header. Only when no folderKey is given AND includeFolderEntities + // is explicitly true does the SDK switch to the v2 endpoint (returns tenant + folder + // entities together). Default (no options or includeFolderEntities omitted) stays on + // the v1 endpoint = tenant only. + const endpoint = !options?.folderKey && options?.includeFolderEntities + ? DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V2 + : DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL; + const response = await this.get( - DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL + endpoint, + { headers: createHeaders({ [FOLDER_KEY]: options?.folderKey }) } ); // Apply transformations @@ -506,7 +565,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * Queries entity records with filters, sorting, aggregates, and pagination * * @param id - UUID of the entity - * @param options - Query options including filterGroup, selectedFields, sortOptions, aggregates, groupBy, and pagination + * @param options - Query options including filterGroup, selectedFields, sortOptions, aggregates, groupBy, and pagination The `folderKey` property is **experimental**. * @returns Promise resolving to {@link NonPaginatedResponse} without pagination options, * or {@link PaginatedResponse} when `pageSize`, `cursor`, or `jumpToPage` are provided * @@ -553,6 +612,12 @@ export class EntityService extends BaseService implements EntityServiceModel { * { function: EntityAggregateFunction.Avg, field: "amount", alias: "avgAmount" }, * ], * }); + * + * // Folder-scoped entity: pass the entity's folder key + * await entities.queryRecordsById("", { + * filterGroup: { queryFilters: [{ fieldName: "status", operator: QueryFilterOperator.Equals, value: "active" }] }, + * folderKey: "", + * }); * ``` */ @track('Entities.QueryRecordsById') @@ -562,7 +627,7 @@ export class EntityService extends BaseService implements EntityServiceModel { ): Promise ? PaginatedResponse : NonPaginatedResponse> { // folderKey is header-only — destructure it out so PaginationHelpers doesn't include // it in the POST body alongside the query filters. - const { folderKey, ...rest } = options ?? {} as T; + const { folderKey, ...rest } = options ?? {}; const downstreamOptions = options === undefined ? undefined : (rest as T); return PaginationHelpers.getAll({ serviceAccess: this.createPaginationServiceAccess(), @@ -588,7 +653,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * * @param id - UUID of the entity * @param file - CSV file to import (Blob, File, or Uint8Array) - * @param options - Optional {@link EntityImportRecordsByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityImportRecordsByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to import result with record counts * * @example @@ -639,7 +704,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * @param entityId - UUID of the entity * @param recordId - UUID of the record containing the attachment * @param fieldName - Name of the File-type field containing the attachment - * @param options - Optional {@link EntityDownloadAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDownloadAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to Blob containing the file content * * @example @@ -683,7 +748,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * @param recordId - UUID of the record to upload the attachment to * @param fieldName - Name of the File-type field * @param file - File to upload (Blob, File, or Uint8Array) - * @param options - Optional {@link EntityUploadAttachmentOptions} (e.g. `expansionLevel`, `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityUploadAttachmentOptions} (e.g. `expansionLevel`, `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to {@link EntityUploadAttachmentResponse} * * @example @@ -736,7 +801,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * @param entityId - UUID of the entity * @param recordId - UUID of the record containing the attachment * @param fieldName - Name of the File-type field containing the attachment - * @param options - Optional {@link EntityDeleteAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDeleteAttachmentOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving to {@link EntityDeleteAttachmentResponse} * * @example @@ -807,7 +872,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * @param name - Entity name — must start with a letter and contain * only letters, numbers, and underscores (e.g., `"productCatalog"`). * @param fields - Array of field definitions - * @param options - Optional entity-level settings ({@link EntityCreateOptions}) + * @param options - Optional entity-level settings ({@link EntityCreateOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving to the ID of the created entity * * @example @@ -823,18 +888,37 @@ export class EntityService extends BaseService implements EntityServiceModel { * { fieldName: "price", type: EntityFieldDataType.DECIMAL, decimalPrecision: 4, maxValue: 999999, minValue: 0 }, * { fieldName: "quantity", type: EntityFieldDataType.INTEGER, maxValue: 10000, minValue: 1, defaultValue: "0" }, * ]); + * + * // Cross-folder references — link a folder-scoped entity to entities and + * // system choice sets that live in another folder or at the tenant level. + * await entities.create("orderLine", [ + * { + * fieldName: "order", + * type: EntityFieldDataType.RELATIONSHIP, + * referenceEntityId: "", + * referenceFieldId: "", + * referenceFolderKey: "", // target lives in a different folder + * }, + * { + * fieldName: "userType", + * type: EntityFieldDataType.CHOICE_SET_SINGLE, + * choiceSetId: "", // tenant-level system choice set + * // referenceFolderKey omitted → SDK looks up the target at tenant scope + * }, + * ], { folderKey: "" }); * ``` * @internal */ @track('Entities.Create') async create(name: string, fields: EntityCreateFieldOptions[], options?: EntityCreateOptions): Promise { const opts = options ?? {}; + const fieldPayloads = await this.buildFieldsWithReferenceMeta(fields); const payload = { ...(opts.description !== undefined && { description: opts.description }), displayName: opts.displayName ?? name, entityDefinition: { name, - fields: fields.map(f => this.buildSchemaFieldPayload(f)), + fields: fieldPayloads, folderId: opts.folderKey ?? DATA_FABRIC_TENANT_FOLDER_ID, isRbacEnabled: opts.isRbacEnabled ?? false, isInsightsEnabled: opts.isAnalyticsEnabled ?? false, @@ -853,7 +937,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * Deletes a Data Fabric entity and all its records * * @param id - UUID of the entity to delete - * @param options - Optional {@link EntityDeleteByIdOptions} (e.g. `folderKey` for folder-scoped entities) + * @param options - Optional {@link EntityDeleteByIdOptions} (e.g. `folderKey` for folder-scoped entities) The `folderKey` property is **experimental**. * @returns Promise resolving when the entity is deleted * * @example @@ -885,7 +969,7 @@ export class EntityService extends BaseService implements EntityServiceModel { * overwrite each other's changes. * * @param id - UUID of the entity to update - * @param options - Changes to apply ({@link EntityUpdateByIdOptions}) + * @param options - Changes to apply ({@link EntityUpdateByIdOptions}) The `folderKey` property is **experimental**. * @returns Promise resolving when the update is complete * * @example @@ -1010,10 +1094,9 @@ export class EntityService extends BaseService implements EntityServiceModel { }); } - // Build and append new fields const newFields: FieldSchemaPayload[] = []; if (options.addFields?.length) { - newFields.push(...options.addFields.map(f => this.buildSchemaFieldPayload(f))); + newFields.push(...await this.buildFieldsWithReferenceMeta(options.addFields)); } await this.post( @@ -1127,8 +1210,47 @@ export class EntityService extends BaseService implements EntityServiceModel { }); } + private async buildFieldsWithReferenceMeta(fields: EntityCreateFieldOptions[]): Promise { + const metas = await Promise.all(fields.map(f => this.buildReferenceMeta(f))); + return fields.map((f, i) => this.buildSchemaFieldPayload(f, metas[i])); + } + + // Choice-set targets resolve server-side by NAME (the API rejects cross-folder + // refs with empty target name even when folderId is supplied), so the SDK + // fetches the name once for each cross-folder choice-set field. Relationship + // targets resolve by folderId — no lookup needed. + private async buildReferenceMeta(field: EntityCreateFieldOptions): Promise { + if (field.referenceFolderKey === undefined) return undefined; + if (field.referenceEntityId === undefined && field.choiceSetId === undefined) return undefined; + + const folderId = field.referenceFolderKey; + const meta: ResolvedReferenceMeta = {}; + + if (field.referenceEntityId !== undefined) { + meta.referenceEntity = { id: field.referenceEntityId, folderId }; + } + if (field.choiceSetId !== undefined) { + const lookupFolderKey = folderId === DATA_FABRIC_TENANT_FOLDER_ID ? undefined : folderId; + const target = await this.get( + DATA_FABRIC_ENDPOINTS.ENTITY.GET_BY_ID(field.choiceSetId), + { headers: createHeaders({ [FOLDER_KEY]: lookupFolderKey }) }, + ); + meta.referenceChoiceSet = { + id: field.choiceSetId, + name: target.data.name, + folderId, + entityType: EntityType.ChoiceSet, + entityTypeId: ENTITY_TYPE_IDS[EntityType.ChoiceSet], + }; + } + return meta; + } + /** Converts a user-facing EntityCreateFieldOptions to the raw API field payload */ - private buildSchemaFieldPayload(field: EntityCreateFieldOptions): FieldSchemaPayload { + private buildSchemaFieldPayload( + field: EntityCreateFieldOptions, + refMeta?: ResolvedReferenceMeta, + ): FieldSchemaPayload { const fieldType = field.type ?? EntityFieldDataType.STRING; this.validateFieldConstraints(fieldType, field, field.fieldName); const isRelationship = fieldType === EntityFieldDataType.RELATIONSHIP; @@ -1139,6 +1261,10 @@ export class EntityService extends BaseService implements EntityServiceModel { }); } const mapping = EntitySchemaFieldTypeMap[fieldType]; + // Prefer the resolved {id, name, folderId} body so cross-folder targets resolve + // server-side; fall back to a bare {id} when no meta was fetched. + const referenceEntityBody = refMeta?.referenceEntity ?? (field.referenceEntityId === undefined ? undefined : { id: field.referenceEntityId }); + const referenceChoiceSetBody = refMeta?.referenceChoiceSet; return { name: field.fieldName, displayName: field.displayName ?? field.fieldName, @@ -1157,7 +1283,8 @@ export class EntityService extends BaseService implements EntityServiceModel { ...(field.choiceSetId !== undefined && { choiceSetId: field.choiceSetId }), ...((isRelationship || isFile) && { isForeignKey: true }), ...(isRelationship && { referenceType: ReferenceType.ManyToOne }), - ...(field.referenceEntityId !== undefined && { referenceEntity: { id: field.referenceEntityId } }), + ...(referenceEntityBody !== undefined && { referenceEntity: referenceEntityBody }), + ...(referenceChoiceSetBody !== undefined && { referenceChoiceSet: referenceChoiceSetBody }), ...(field.referenceFieldId !== undefined && { referenceField: { id: field.referenceFieldId } }), }; } diff --git a/src/utils/constants/endpoints/data-fabric.ts b/src/utils/constants/endpoints/data-fabric.ts index 8cd508b53..727220619 100644 --- a/src/utils/constants/endpoints/data-fabric.ts +++ b/src/utils/constants/endpoints/data-fabric.ts @@ -17,6 +17,9 @@ export const DATA_FABRIC_TENANT_FOLDER_ID = '00000000-0000-0000-0000-00000000000 export const DATA_FABRIC_ENDPOINTS = { ENTITY: { GET_ALL: `${DATAFABRIC_BASE}/api/Entity`, + // Lists tenant-level and folder-level entities together. + // Used by getAll when includeFolderEntities is true. + GET_ALL_V2: `${DATAFABRIC_BASE}/api/v2/Entity`, GET_ENTITY_RECORDS: (entityId: string) => `${DATAFABRIC_BASE}/api/EntityService/entity/${entityId}/read`, GET_BY_ID: (entityId: string) => `${DATAFABRIC_BASE}/api/Entity/${entityId}`, GET_RECORD_BY_ID: (entityId: string, recordId: string) => diff --git a/tests/integration/shared/data-fabric/choicesets.integration.test.ts b/tests/integration/shared/data-fabric/choicesets.integration.test.ts index 5ff2d082d..054d6bb4b 100644 --- a/tests/integration/shared/data-fabric/choicesets.integration.test.ts +++ b/tests/integration/shared/data-fabric/choicesets.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; import { generateRandomString } from '../../utils/helpers'; @@ -11,10 +11,15 @@ describe.each(modes)('Data Fabric ChoiceSets - Integration Tests [%s]', (mode) = const createdChoiceSetIds: string[] = []; const insertedValueIds: string[] = []; + // Folder-scoped CS created in the Folder-scoped operations describe block. + // Tracked here so the file-level afterAll can clean it up if a test failed + // before its own delete step ran. + let folderScopedChoiceSetId: string | null = null; + let folderScopedFolderKey: string | null = null; + afterAll(async () => { const { choiceSets } = getServices(); - // Clean up any leftover choice-set values from the value-level CRUD block. if (testChoiceSetId && insertedValueIds.length > 0) { try { await choiceSets.deleteValuesById(testChoiceSetId, insertedValueIds); @@ -23,6 +28,14 @@ describe.each(modes)('Data Fabric ChoiceSets - Integration Tests [%s]', (mode) = } } + if (folderScopedChoiceSetId && folderScopedFolderKey) { + try { + await choiceSets.deleteById(folderScopedChoiceSetId, { folderKey: folderScopedFolderKey }); + } catch { + // Ignore cleanup failures — test resources are sandboxed. + } + } + for (const id of createdChoiceSetIds) { try { await choiceSets.deleteById(id); @@ -252,6 +265,13 @@ describe.each(modes)('Data Fabric ChoiceSets - Integration Tests [%s]', (mode) = await choiceSets.deleteValuesById(choiceSetId, serviceLevelValueIds); + // Verify the deleted values are no longer present on the choice set + const remaining = await choiceSets.getById(choiceSetId); + const remainingIds = new Set(remaining.items.map((v) => v.id)); + for (const deletedId of serviceLevelValueIds) { + expect(remainingIds.has(deletedId)).toBe(false); + } + // Remove deleted IDs from the file-level tracking list for (const id of serviceLevelValueIds) { const idx = insertedValueIds.indexOf(id); @@ -262,4 +282,211 @@ describe.each(modes)('Data Fabric ChoiceSets - Integration Tests [%s]', (mode) = serviceLevelValueIds.length = 0; }); }); + + // Skipped: folder-scoped choice-set value CRUD requires the DataFabric.Schema.Write + // OAuth scope (same restriction as the tenant-scope value CRUD block above), which + // PAT-authenticated CI runs do not have. Re-enable locally with describe.only when + // INTEGRATION_TEST_FOLDER_KEY is set and OAuth is configured. + describe.skip('Folder-scoped operations', () => { + beforeAll(() => { + const config = getTestConfig(); + if (!config.folderKey) { + throw new Error('INTEGRATION_TEST_FOLDER_KEY is required for folder-scoped choice-set tests'); + } + folderScopedFolderKey = config.folderKey; + }); + + it('should return only folder-scoped choice sets when folderKey is provided', async () => { + const { choiceSets } = getServices(); + const folderKey = folderScopedFolderKey!; + + const [tenantSets, folderSets] = await Promise.all([ + choiceSets.getAll(), + choiceSets.getAll({ folderKey }), + ]); + + expect(Array.isArray(folderSets)).toBe(true); + + // Every folder-scoped choice set carries the requested folder key + for (const cs of folderSets) { + expect(cs.folderId).toBe(folderKey); + } + + // Tenant scope and folder scope are disjoint + const folderIds = new Set(folderSets.map((cs) => cs.id)); + for (const tenantSet of tenantSets) { + expect(folderIds.has(tenantSet.id)).toBe(false); + } + }); + + it('should create a folder-scoped choice set, list its values, and delete it', async () => { + const { choiceSets } = getServices(); + const folderKey = folderScopedFolderKey!; + const name = `sdk_cs_fld_${generateRandomString(8)}`; + + folderScopedChoiceSetId = await choiceSets.create(name, { + displayName: `SDK Folder ${name}`, + folderKey, + }); + expect(typeof folderScopedChoiceSetId).toBe('string'); + + // The new choice set should appear in the folder-scoped listing + const folderSets = await choiceSets.getAll({ folderKey }); + const found = folderSets.find((cs) => cs.id === folderScopedChoiceSetId); + expect(found).toBeDefined(); + expect(found?.folderId).toBe(folderKey); + + // ...and NOT in the tenant listing + const tenantSets = await choiceSets.getAll(); + expect(tenantSets.find((cs) => cs.id === folderScopedChoiceSetId)).toBeUndefined(); + + // getById on the new (empty) choice set should succeed with folderKey + const values = await choiceSets.getById(folderScopedChoiceSetId, { folderKey }); + expect(Array.isArray(values.items)).toBe(true); + }); + + it('should delete a folder-scoped choice set with folderKey', async () => { + const { choiceSets } = getServices(); + const folderKey = folderScopedFolderKey!; + + if (!folderScopedChoiceSetId) { + throw new Error('Folder-scoped choice set was not created earlier in the suite'); + } + + await choiceSets.deleteById(folderScopedChoiceSetId, { folderKey }); + + const folderSets = await choiceSets.getAll({ folderKey }); + expect(folderSets.find((cs) => cs.id === folderScopedChoiceSetId)).toBeUndefined(); + + // Clear so the top-level afterAll doesn't try to delete it again + folderScopedChoiceSetId = null; + }); + }); + + // ─── Folder-scoped Choice value CRUD ────────────────────────────────────── + // Mirrors the tenant-scope `Choice value CRUD operations` block above, but + // against a pre-existing folder-scoped choice set named + // `aIntegrationTestFolderScoped` in the folder identified by + // INTEGRATION_TEST_FOLDER_KEY (looked up by name in beforeAll so the test + // env doesn't need a separate CS UUID). + // + // Skipped: same DataFabric.Schema.Write OAuth-scope restriction as the tenant + // value-CRUD block. Re-enable locally with describe.only when + // INTEGRATION_TEST_FOLDER_KEY is set, OAuth is configured, and the named + // choice set exists in that folder. + describe.skip('Folder-scoped Choice value CRUD operations', () => { + const FOLDER_CHOICE_SET_NAME = 'aIntegrationTestFolderScoped'; + const folderValueIds: string[] = []; + let folderKey!: string; + let folderChoiceSetId!: string; + + beforeAll(async () => { + const config = getTestConfig(); + if (!config.folderKey) { + throw new Error('INTEGRATION_TEST_FOLDER_KEY is required for folder-scoped value-CRUD tests'); + } + folderKey = config.folderKey; + + const { choiceSets } = getServices(); + const folderSets = await choiceSets.getAll({ folderKey }); + const match = folderSets.find((cs) => cs.name === FOLDER_CHOICE_SET_NAME); + if (!match) { + throw new Error( + `Folder-scoped choice set '${FOLDER_CHOICE_SET_NAME}' not found in folder '${folderKey}' — create it first or update FOLDER_CHOICE_SET_NAME.`, + ); + } + folderChoiceSetId = match.id; + }); + + afterAll(async () => { + if (folderValueIds.length === 0) return; + const { choiceSets } = getServices(); + try { + await choiceSets.deleteValuesById(folderChoiceSetId, folderValueIds, { folderKey }); + } catch { + // Ignore cleanup failures — test resources are sandboxed. + } + }); + + it('should insert a single value using insertValueById', async () => { + const { choiceSets } = getServices(); + + const valueName = `SDK_FLD_${generateRandomString(6)}`; + const result = await choiceSets.insertValueById(folderChoiceSetId, valueName, { + displayName: 'Travel', + folderKey, + }); + + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + folderValueIds.push(result.id); + }); + + it('should verify inserted value via getById', async () => { + const { choiceSets } = getServices(); + + if (folderValueIds.length === 0) { + throw new Error('No inserted value available to verify'); + } + + const valueId = folderValueIds[0]; + const result = await choiceSets.getById(folderChoiceSetId, { folderKey }); + const found = result.items.find((v) => v.id === valueId); + + expect(found).toBeDefined(); + expect(found?.id).toBe(valueId); + }); + + it('should insert another value with default displayName', async () => { + const { choiceSets } = getServices(); + + const valueName = `SDK_FLD_SOLO_${generateRandomString(6)}`; + const result = await choiceSets.insertValueById(folderChoiceSetId, valueName, { folderKey }); + + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.name).toBe(valueName); + expect(result.displayName).toBe(valueName); + folderValueIds.push(result.id); + }); + + it('should update value using updateValueById', async () => { + const { choiceSets } = getServices(); + + if (folderValueIds.length === 0) { + throw new Error('No values available to update'); + } + + const valueId = folderValueIds[0]; + const result = await choiceSets.updateValueById( + folderChoiceSetId, + valueId, + 'Business Travel', + { folderKey }, + ); + + expect(result).toBeDefined(); + expect(result.id).toBe(valueId); + expect(result.displayName).toBe('Business Travel'); + }); + + it('should delete values using deleteValuesById', async () => { + const { choiceSets } = getServices(); + + if (folderValueIds.length === 0) { + throw new Error('No values available to delete'); + } + + await choiceSets.deleteValuesById(folderChoiceSetId, folderValueIds, { folderKey }); + + // Verify the deleted values are no longer present on the choice set + const remaining = await choiceSets.getById(folderChoiceSetId, { folderKey }); + const remainingIds = new Set(remaining.items.map((v) => v.id)); + for (const deletedId of folderValueIds) { + expect(remainingIds.has(deletedId)).toBe(false); + } + + folderValueIds.length = 0; + }); + }); }); diff --git a/tests/integration/shared/data-fabric/entities.integration.test.ts b/tests/integration/shared/data-fabric/entities.integration.test.ts index 4e51dbca0..9e5f064bd 100644 --- a/tests/integration/shared/data-fabric/entities.integration.test.ts +++ b/tests/integration/shared/data-fabric/entities.integration.test.ts @@ -17,21 +17,26 @@ import { QueryFilterOperator, RawEntityGetResponse, } from '../../../../src/models/data-fabric/entities.types'; +import { DATA_FABRIC_TENANT_FOLDER_ID } from '../../../../src/utils/constants/endpoints/data-fabric'; // Cache for choice set values to avoid repeated API calls within a test run const choiceSetValueCache = new Map(); /** * Fetches and caches choice set values for a given choice set ID. + * When the target choice set lives in a folder (not tenant-level), pass that + * folder's key so the lookup carries the X-UIPATH-FolderKey header — otherwise + * the server returns empty values and required CS fields end up undefined. */ -async function getChoiceSetValues(choiceSetId: string): Promise { - if (choiceSetValueCache.has(choiceSetId)) { - return choiceSetValueCache.get(choiceSetId)!; +async function getChoiceSetValues(choiceSetId: string, folderKey?: string): Promise { + const cacheKey = `${folderKey ?? ''}::${choiceSetId}`; + if (choiceSetValueCache.has(cacheKey)) { + return choiceSetValueCache.get(cacheKey)!; } const { choiceSets } = getServices(); - const result = await choiceSets.getById(choiceSetId); + const result = await choiceSets.getById(choiceSetId, folderKey ? { folderKey } : undefined); const values = result.items || []; - choiceSetValueCache.set(choiceSetId, values); + choiceSetValueCache.set(cacheKey, values); return values; } @@ -120,7 +125,12 @@ async function buildDummyRecord(entityMetadata: RawEntityGetResponse): Promise expect(typeof entity.getRecord).toBe('function'); expect(typeof entity.downloadAttachment).toBe('function'); }); + + // The Data Fabric entity list is scoped exclusively: omitting folderKey returns + // only tenant entities; passing folderKey returns only entities in that folder. + // The two sets are disjoint. + it('should return only folder-scoped entities when folderKey is provided', async () => { + const { entities } = getServices(); + const folderKey = getTestConfig().folderKey; + + if (!folderKey) { + throw new Error('INTEGRATION_TEST_FOLDER_KEY is required to exercise folder-scoped getAll'); + } + + const [tenantEntities, folderEntities] = await Promise.all([ + entities.getAll(), + entities.getAll({ folderKey }), + ]); + + expect(Array.isArray(folderEntities)).toBe(true); + + // Every folder-scoped entity carries the requested folder key + for (const entity of folderEntities) { + expect(entity.folderId).toBe(folderKey); + } + + // Tenant scope and folder scope are disjoint — no entity appears in both + const folderIds = new Set(folderEntities.map((e) => e.id)); + for (const tenantEntity of tenantEntities) { + expect(folderIds.has(tenantEntity.id)).toBe(false); + } + }); + + // includeFolderEntities switches to the v2 endpoint, which returns tenant-level and + // folder-level entities together — a superset of the default tenant-only result. + // Requires the OR.Users OAuth scope on the integration token. + it('should return tenant and folder entities together when includeFolderEntities is true', async () => { + const { entities } = getServices(); + + const [tenantEntities, allEntities] = await Promise.all([ + entities.getAll(), + entities.getAll({ includeFolderEntities: true }), + ]); + + expect(Array.isArray(allEntities)).toBe(true); + // The combined list is a superset of the tenant-only list + expect(allEntities.length).toBeGreaterThanOrEqual(tenantEntities.length); + + const allIds = new Set(allEntities.map((e) => e.id)); + for (const tenantEntity of tenantEntities) { + expect(allIds.has(tenantEntity.id)).toBe(true); + } + + // Each entity still carries metadata with methods attached + for (const entity of allEntities) { + expect(entity.id).toBeDefined(); + expect(typeof entity.getAllRecords).toBe('function'); + } + }); }); describe('getById', () => { @@ -1333,18 +1400,11 @@ describe.each(modes)('Data Fabric Entities - Integration Tests [%s]', (mode) => }); // ─── Folder-scoped record CRUD ──────────────────────────────────────────── - // Verifies that every record-CRUD method correctly forwards the - // X-UIPATH-FolderKey header for an entity that lives in a non-tenant folder. - // - // Uses a pre-existing folder-scoped entity (DATA_FABRIC_TEST_FOLDER_ENTITY_ID) - // so the suite runs with the standard integration-test PAT — entity schema - // create/delete are NOT exercised here (they live in the skipped schema-write - // describes above). - // - // Skipped when DATA_FABRIC_TEST_FOLDER_ENTITY_ID is not configured — CI does - // not yet provision a persistent folder-scoped test entity. Run locally by - // setting both INTEGRATION_TEST_FOLDER_KEY and DATA_FABRIC_TEST_FOLDER_ENTITY_ID. - describe.skipIf(!process.env.DATA_FABRIC_TEST_FOLDER_ENTITY_ID || !process.env.INTEGRATION_TEST_FOLDER_KEY)('Folder-scoped record CRUD', () => { + // Mirrors the tenant-scope record CRUD block above, but against a folder-scoped + // entity (DATA_FABRIC_TEST_FOLDER_ENTITY_ID + INTEGRATION_TEST_FOLDER_KEY). + // Record CRUD works with PAT auth; schema create/delete on the folder entity + // is NOT exercised here (lives in the describe.skip schema-write blocks above). + describe('Folder-scoped record CRUD', () => { let folderEntityId!: string; let folderKey!: string; let folderEntityMetadata!: RawEntityGetResponse; @@ -1352,8 +1412,14 @@ describe.each(modes)('Data Fabric Entities - Integration Tests [%s]', (mode) => beforeAll(async () => { const config = getTestConfig(); - folderKey = config.folderKey!; - folderEntityId = config.dataFabricTestFolderEntityId!; + if (!config.folderKey) { + throw new Error('INTEGRATION_TEST_FOLDER_KEY is required for folder-scoped record CRUD tests'); + } + if (!config.dataFabricTestFolderEntityId) { + throw new Error('DATA_FABRIC_TEST_FOLDER_ENTITY_ID is required — set to the UUID of a folder-scoped entity in the same folder'); + } + folderKey = config.folderKey; + folderEntityId = config.dataFabricTestFolderEntityId; // Fetch schema once so per-test record bodies match the entity's shape. const { entities } = getServices(); diff --git a/tests/unit/services/data-fabric/choicesets.test.ts b/tests/unit/services/data-fabric/choicesets.test.ts index 74514d0e3..088c5fba4 100644 --- a/tests/unit/services/data-fabric/choicesets.test.ts +++ b/tests/unit/services/data-fabric/choicesets.test.ts @@ -78,10 +78,10 @@ describe('ChoiceSetService Unit Tests', () => { expect(result[0].createdTime).toBe(CHOICESET_TEST_CONSTANTS.CREATED_TIME); expect(result[0].updatedTime).toBe(CHOICESET_TEST_CONSTANTS.UPDATED_TIME); - // Verify the API call has correct endpoint + // Verify the API call — default tenant-only via tenant-marker header expect(mockApiClient.get).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, - {} + { headers: { 'X-UIPATH-FolderKey': DATA_FABRIC_TENANT_FOLDER_ID } } ); }); @@ -107,10 +107,10 @@ describe('ChoiceSetService Unit Tests', () => { expect(choiceSet).toHaveProperty('updatedTime'); }); - // Verify the API call + // Verify the API call — default tenant-only via tenant-marker header expect(mockApiClient.get).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, - {} + { headers: { 'X-UIPATH-FolderKey': DATA_FABRIC_TENANT_FOLDER_ID } } ); }); @@ -153,6 +153,64 @@ describe('ChoiceSetService Unit Tests', () => { await expect(choiceSetService.getAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + it('should pass folderKey via X-UIPATH-FolderKey header when provided', async () => { + mockApiClient.get.mockResolvedValue([createMockChoiceSetResponse()]); + + await choiceSetService.getAll({ folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } } + ); + }); + + it('should send tenant-marker folder header by default (tenant-only)', async () => { + mockApiClient.get.mockResolvedValue([createMockChoiceSetResponse()]); + + await choiceSetService.getAll(); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: { 'X-UIPATH-FolderKey': DATA_FABRIC_TENANT_FOLDER_ID } } + ); + }); + + it('should send tenant-marker folder header when includeFolderChoiceSets is false', async () => { + mockApiClient.get.mockResolvedValue([createMockChoiceSetResponse()]); + + await choiceSetService.getAll({ includeFolderChoiceSets: false }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: { 'X-UIPATH-FolderKey': DATA_FABRIC_TENANT_FOLDER_ID } } + ); + }); + + it('should omit folder header when includeFolderChoiceSets is true (cross-scope)', async () => { + mockApiClient.get.mockResolvedValue([createMockChoiceSetResponse()]); + + await choiceSetService.getAll({ includeFolderChoiceSets: true }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: {} } + ); + }); + + it('should let folderKey win when both folderKey and includeFolderChoiceSets are provided', async () => { + mockApiClient.get.mockResolvedValue([createMockChoiceSetResponse()]); + + await choiceSetService.getAll({ + folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY, + includeFolderChoiceSets: true, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } } + ); + }); + }); describe('getById', () => { @@ -301,6 +359,24 @@ describe('ChoiceSetService Unit Tests', () => { ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + it('should forward folderKey as X-UIPATH-FolderKey header but NOT include it in the POST body', async () => { + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); + + await choiceSetService.getById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID, { + folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY, + }); + + const config = vi.mocked(PaginationHelpers.getAll).mock.calls[0][0] as any; + const downstreamOptions = vi.mocked(PaginationHelpers.getAll).mock.calls[0][1] as any; + + // Header is set + expect(config.headers).toEqual({ 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY }); + + // folderKey must be stripped from downstream options so it never leaks into the POST body + expect(downstreamOptions).toBeDefined(); + expect(downstreamOptions.folderKey).toBeUndefined(); + }); + it('should handle empty results gracefully', async () => { const mockResponse = { items: [], @@ -338,7 +414,7 @@ describe('ChoiceSetService Unit Tests', () => { folderId: DATA_FABRIC_TENANT_FOLDER_ID, }, }, - {}, + { headers: {} }, ); }); @@ -372,6 +448,24 @@ describe('ChoiceSetService Unit Tests', () => { TEST_CONSTANTS.ERROR_MESSAGE, ); }); + + it('should pass folderKey via X-UIPATH-FolderKey header when provided', async () => { + mockApiClient.post.mockResolvedValue(CHOICESET_TEST_CONSTANTS.CHOICESET_ID); + + await choiceSetService.create('expense_types', { + folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY, + }); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.CREATE, + expect.objectContaining({ + entityDefinition: expect.objectContaining({ + folderId: CHOICESET_TEST_CONSTANTS.FOLDER_KEY, + }), + }), + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + }); }); describe('updateById', () => { @@ -389,7 +483,7 @@ describe('ChoiceSetService Unit Tests', () => { displayName: 'Renamed Choice Set', description: 'Updated description', }, - {}, + { headers: {} }, ); }); @@ -436,6 +530,21 @@ describe('ChoiceSetService Unit Tests', () => { choiceSetService.updateById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID, { displayName: 'x' }), ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + + it('should pass folderKey via X-UIPATH-FolderKey header when provided', async () => { + mockApiClient.patch.mockResolvedValue(true); + + await choiceSetService.updateById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID, { + displayName: 'Renamed', + folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY, + }); + + expect(mockApiClient.patch).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.UPDATE(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), + { displayName: 'Renamed' }, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + }); }); describe('deleteById', () => { @@ -447,7 +556,7 @@ describe('ChoiceSetService Unit Tests', () => { expect(mockApiClient.post).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), {}, - {}, + { headers: {} }, ); }); @@ -459,6 +568,20 @@ describe('ChoiceSetService Unit Tests', () => { choiceSetService.deleteById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + + it('should pass folderKey via X-UIPATH-FolderKey header when provided', async () => { + mockApiClient.post.mockResolvedValue(true); + + await choiceSetService.deleteById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID, { + folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY, + }); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), + {}, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + }); }); describe('insertValueById', () => { @@ -478,7 +601,7 @@ describe('ChoiceSetService Unit Tests', () => { expect(mockApiClient.post).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.CHOICESETS.INSERT_BY_NAME(CHOICESET_TEST_CONSTANTS.CHOICESET_NAME), { Name: 'NEW_VAL', DisplayName: 'New Value' }, - {}, + { headers: {} }, ); expect(result.id).toBe(CHOICESET_TEST_CONSTANTS.VALUE_ID); @@ -526,6 +649,30 @@ describe('ChoiceSetService Unit Tests', () => { choiceSetService.insertValueById(CHOICESET_TEST_CONSTANTS.CHOICESET_ID, 'NEW_VAL'), ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + + it('should pass folderKey via X-UIPATH-FolderKey header on both name resolution and insert', async () => { + mockApiClient.get.mockResolvedValue([createMockChoiceSetResponse()]); + mockApiClient.post.mockResolvedValue(createMockChoiceSetValueResponse()); + + await choiceSetService.insertValueById( + CHOICESET_TEST_CONSTANTS.CHOICESET_ID, + 'NEW_VAL', + { folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY }, + ); + + // Name resolution call (getAll) sends the folder header so folder-scoped sets are found + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + + // The insert call itself also forwards the header (server requires it on name-based endpoint) + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.INSERT_BY_NAME(CHOICESET_TEST_CONSTANTS.CHOICESET_NAME), + { Name: 'NEW_VAL' }, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + }); }); describe('updateValueById', () => { @@ -546,7 +693,7 @@ describe('ChoiceSetService Unit Tests', () => { CHOICESET_TEST_CONSTANTS.VALUE_ID, ), { DisplayName: 'Updated' }, - {}, + { headers: {} }, ); expect(result.id).toBe(CHOICESET_TEST_CONSTANTS.VALUE_ID); @@ -583,6 +730,32 @@ describe('ChoiceSetService Unit Tests', () => { ), ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + + it('should pass folderKey via X-UIPATH-FolderKey header on both name resolution and update', async () => { + mockApiClient.get.mockResolvedValue([createMockChoiceSetResponse()]); + mockApiClient.post.mockResolvedValue(createMockChoiceSetValueResponse({ DisplayName: 'Renamed' })); + + await choiceSetService.updateValueById( + CHOICESET_TEST_CONSTANTS.CHOICESET_ID, + CHOICESET_TEST_CONSTANTS.VALUE_ID, + 'Renamed', + { folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY }, + ); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.GET_ALL, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.UPDATE_BY_NAME( + CHOICESET_TEST_CONSTANTS.CHOICESET_NAME, + CHOICESET_TEST_CONSTANTS.VALUE_ID, + ), + { DisplayName: 'Renamed' }, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + }); }); describe('deleteValuesById', () => { @@ -598,7 +771,7 @@ describe('ChoiceSetService Unit Tests', () => { expect(mockApiClient.post).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE_BY_ID(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), valueIds, - {}, + { headers: {} }, ); }); @@ -612,5 +785,22 @@ describe('ChoiceSetService Unit Tests', () => { ]), ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); + + it('should pass folderKey via X-UIPATH-FolderKey header when provided', async () => { + mockApiClient.post.mockResolvedValue(true); + const valueIds = [CHOICESET_TEST_CONSTANTS.VALUE_ID]; + + await choiceSetService.deleteValuesById( + CHOICESET_TEST_CONSTANTS.CHOICESET_ID, + valueIds, + { folderKey: CHOICESET_TEST_CONSTANTS.FOLDER_KEY }, + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.CHOICESETS.DELETE_BY_ID(CHOICESET_TEST_CONSTANTS.CHOICESET_ID), + valueIds, + { headers: { 'X-UIPATH-FolderKey': CHOICESET_TEST_CONSTANTS.FOLDER_KEY } }, + ); + }); }); }); diff --git a/tests/unit/services/data-fabric/entities.test.ts b/tests/unit/services/data-fabric/entities.test.ts index 5fc766040..79efee2fb 100644 --- a/tests/unit/services/data-fabric/entities.test.ts +++ b/tests/unit/services/data-fabric/entities.test.ts @@ -36,6 +36,7 @@ import type { import { EntityFieldDataType, EntityAggregateFunction, + EntityType, ExternalField, FieldDisplayType, QueryFilterOperator, @@ -45,10 +46,12 @@ import { EntityFieldTypeMap, EntitySchemaFieldTypeMap, FieldDisplayTypeToDataType, + ENTITY_TYPE_IDS, } from "../../../../src/models/data-fabric/entities.constants"; import { ENTITY_TEST_CONSTANTS } from "../../../utils/constants/entities"; import { TEST_CONSTANTS } from "../../../utils/constants/common"; import { DATA_FABRIC_ENDPOINTS } from "../../../../src/utils/constants/endpoints"; +import { DATA_FABRIC_TENANT_FOLDER_ID } from "../../../../src/utils/constants/endpoints/data-fabric"; import { SqlFieldType, FieldSchemaPayload } from "@/models/data-fabric/entities.internal-types"; // ===== MOCKING ===== @@ -336,10 +339,10 @@ describe("EntityService Unit Tests", () => { expect(typeof entity.getAllRecords).toBe("function"); }); - // Verify the API call + // Verify the API call — default tenant-only via v1 endpoint expect(mockApiClient.get).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL, - {}, + { headers: {} }, ); expect(result[0].fields).toBeDefined(); @@ -395,10 +398,10 @@ describe("EntityService Unit Tests", () => { }); }); - // Verify the API call + // Verify the API call — default tenant-only via v1 endpoint expect(mockApiClient.get).toHaveBeenCalledWith( DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL, - {}, + { headers: {} }, ); }); @@ -410,6 +413,53 @@ describe("EntityService Unit Tests", () => { TEST_CONSTANTS.ERROR_MESSAGE, ); }); + + it("should pass folderKey via X-UIPATH-FolderKey header when provided", async () => { + mockApiClient.get.mockResolvedValue([createMockEntityResponse()]); + + await entityService.getAll({ folderKey: ENTITY_TEST_CONSTANTS.FIELD_ID }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL, + { headers: { "X-UIPATH-FolderKey": ENTITY_TEST_CONSTANTS.FIELD_ID } }, + ); + }); + + it("should call the v2 endpoint when includeFolderEntities is true (cross-scope)", async () => { + mockApiClient.get.mockResolvedValue([createMockEntityResponse()]); + + await entityService.getAll({ includeFolderEntities: true }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL_V2, + { headers: {} }, + ); + }); + + it("should call the v1 endpoint when includeFolderEntities is false (tenant-only)", async () => { + mockApiClient.get.mockResolvedValue([createMockEntityResponse()]); + + await entityService.getAll({ includeFolderEntities: false }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL, + { headers: {} }, + ); + }); + + it("should let folderKey win and call v1 with the header when both folderKey and includeFolderEntities are provided", async () => { + mockApiClient.get.mockResolvedValue([createMockEntityResponse()]); + + await entityService.getAll({ + folderKey: ENTITY_TEST_CONSTANTS.FIELD_ID, + includeFolderEntities: true, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.GET_ALL, + { headers: { "X-UIPATH-FolderKey": ENTITY_TEST_CONSTANTS.FIELD_ID } }, + ); + }); }); describe("getAllRecords", () => { @@ -2149,6 +2199,97 @@ describe("EntityService Unit Tests", () => { ).rejects.toThrow(/requires both referenceEntityId and referenceFieldId/); }); + it("should embed { id, folderId } in referenceEntity for cross-folder RELATIONSHIP when referenceFolderKey is set", async () => { + await entityService.create("my_entity", [{ + fieldName: "rel_field", + type: EntityFieldDataType.RELATIONSHIP, + referenceEntityId: ENTITY_TEST_CONSTANTS.REFERENCE_ENTITY_ID, + referenceFieldId: ENTITY_TEST_CONSTANTS.REFERENCE_FIELD_ID, + referenceFolderKey: ENTITY_TEST_CONSTANTS.REFERENCE_TARGET_FOLDER_KEY, + }]); + + // No GET — payload is built from caller inputs only + expect(mockApiClient.get).not.toHaveBeenCalled(); + + const f = getCreatedFields().find((x) => x.name === "rel_field"); + expect(f?.referenceEntity).toEqual({ + id: ENTITY_TEST_CONSTANTS.REFERENCE_ENTITY_ID, + folderId: ENTITY_TEST_CONSTANTS.REFERENCE_TARGET_FOLDER_KEY, + }); + expect(f?.referenceField).toEqual({ id: ENTITY_TEST_CONSTANTS.REFERENCE_FIELD_ID }); + expect(f?.isForeignKey).toBe(true); + expect(f?.referenceType).toBe("ManyToOne"); + }); + + it("should look up name for cross-folder CHOICE_SET target (API requires name, not folderId)", async () => { + // Choice-set endpoint resolves the target by name, so the SDK fetches it + mockApiClient.get.mockResolvedValue({ + id: ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID, + name: "UserType", + folderId: DATA_FABRIC_TENANT_FOLDER_ID, + }); + + await entityService.create("my_entity", [{ + fieldName: "userType", + type: EntityFieldDataType.CHOICE_SET_SINGLE, + choiceSetId: ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID, + referenceFolderKey: DATA_FABRIC_TENANT_FOLDER_ID, + }]); + + // Tenant marker → no folder header on the lookup + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.GET_BY_ID(ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID), + { headers: {} }, + ); + + const f = getCreatedFields().find((x) => x.name === "userType"); + expect(f?.referenceChoiceSet).toEqual({ + id: ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID, + name: "UserType", + folderId: DATA_FABRIC_TENANT_FOLDER_ID, + entityType: EntityType.ChoiceSet, + entityTypeId: ENTITY_TYPE_IDS[EntityType.ChoiceSet], + }); + expect(f?.choiceSetId).toBe(ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID); + }); + + it("should use folder header on choice-set lookup when referenceFolderKey is a folder UUID", async () => { + mockApiClient.get.mockResolvedValue({ + id: ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID, + name: "FolderCS", + folderId: ENTITY_TEST_CONSTANTS.REFERENCE_TARGET_FOLDER_KEY, + }); + + await entityService.create("my_entity", [{ + fieldName: "cat", + type: EntityFieldDataType.CHOICE_SET_SINGLE, + choiceSetId: ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID, + referenceFolderKey: ENTITY_TEST_CONSTANTS.REFERENCE_TARGET_FOLDER_KEY, + }]); + + expect(mockApiClient.get).toHaveBeenCalledWith( + DATA_FABRIC_ENDPOINTS.ENTITY.GET_BY_ID(ENTITY_TEST_CONSTANTS.CHOICE_SET_TARGET_ID), + { headers: { "X-UIPATH-FolderKey": ENTITY_TEST_CONSTANTS.REFERENCE_TARGET_FOLDER_KEY } }, + ); + + const f = getCreatedFields().find((x) => x.name === "cat"); + expect(f?.referenceChoiceSet?.name).toBe("FolderCS"); + }); + + it("should emit a bare { id } reference when referenceFolderKey is omitted (same-scope default)", async () => { + await entityService.create("my_entity", [{ + fieldName: "rel_field", + type: EntityFieldDataType.RELATIONSHIP, + referenceEntityId: ENTITY_TEST_CONSTANTS.REFERENCE_ENTITY_ID, + referenceFieldId: ENTITY_TEST_CONSTANTS.REFERENCE_FIELD_ID, + }]); + + expect(mockApiClient.get).not.toHaveBeenCalled(); + + const f = getCreatedFields().find((x) => x.name === "rel_field"); + expect(f?.referenceEntity).toEqual({ id: ENTITY_TEST_CONSTANTS.REFERENCE_ENTITY_ID }); + }); + it("should emit isForeignKey, referenceEntity, referenceField — but NOT referenceType — for FILE fields", async () => { await entityService.create("my_entity", [{ fieldName: "file_field", @@ -2965,7 +3106,7 @@ describe("EntityService Unit Tests", () => { isRbacEnabled: false, fields: [], id: ENTITY_TEST_CONSTANTS.ENTITY_ID, - entityType: 0, + entityType: EntityType.Entity, externalFields: [], createdBy: "u", createdTime: "t", diff --git a/tests/utils/constants/choicesets.ts b/tests/utils/constants/choicesets.ts index cc61e129d..908d45c5c 100644 --- a/tests/utils/constants/choicesets.ts +++ b/tests/utils/constants/choicesets.ts @@ -21,5 +21,8 @@ export const CHOICESET_TEST_CONSTANTS = { VALUE_NAME: 'Application', VALUE_DISPLAY_NAME: 'Application', VALUE_NUMBER_ID: 3, + + // Folder key used to verify X-UIPATH-FolderKey header forwarding + FOLDER_KEY: 'cd6e24d3-2941-4aac-9aba-2ebf38dcceec', } as const; diff --git a/tests/utils/constants/entities.ts b/tests/utils/constants/entities.ts index 744245518..d74b4b096 100644 --- a/tests/utils/constants/entities.ts +++ b/tests/utils/constants/entities.ts @@ -36,6 +36,8 @@ export const ENTITY_TEST_CONSTANTS = { // Reference IDs (for relationship-type fields) REFERENCE_ENTITY_ID: 'a1234567-e89b-12d3-a456-426614174000', REFERENCE_FIELD_ID: 'b1234567-e89b-12d3-a456-426614174000', + REFERENCE_TARGET_FOLDER_KEY: 'c1234567-e89b-12d3-a456-426614174000', + CHOICE_SET_TARGET_ID: 'd1234567-e89b-12d3-a456-426614174000', // Field Data Type Names (for assertions) FIELD_TYPE_UUID: 'UUID',