diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f210fb..8ae3018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased +- Added support for PATCH /2.0/reports/{reportId}/definition endpoint +- WireMock integration tests for contract testing for PATCH /2.0/reports/{reportId}/definition endpoint +- Support for POST /reports/{reportId}/scope endpoint +- Support for DELETE /reports/{reportId}/scope endpoint +- Support for DELETE /reports/{reportId} endpoint + ## [4.7.2] - 2026-03-17 ### Fixed - App crashing after updating to v4.7.1 with logLevel set to 'info' [#158](https://github.com/smartsheet/smartsheet-javascript-sdk/issues/158) @@ -16,8 +23,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - WireMock integration tests for contract testing for POST /2.0/users/{userId}/plans/{planId}/upgrade and POST /2.0/users/{userId}/plans/{planId}/downgrade - WireMock integration tests for contract testing for DELETE /2.0/users/{userId}/plans/{planId} endpoint - Remove trailing slashes from routes -- Support for POST /reports/{reportId}/scope endpoint -- Support for DELETE /reports/{reportId}/scope endpoint ### Updated - listAllUsers url generation - Folder structure for the Users related WireMock tests diff --git a/lib/reports/index.ts b/lib/reports/index.ts index b5cde81..e7c50a8 100644 --- a/lib/reports/index.ts +++ b/lib/reports/index.ts @@ -17,6 +17,7 @@ import type { ReportPublish, SetReportPublishStatusOptions, SetReportPublishStatusResponse, + UpdateReportDefinitionOptions, DeleteReportOptions, DeleteReportResponse, AddReportScopeOptions, @@ -92,6 +93,19 @@ export function create(options: CreateOptions): ReportsApi { return requestor.put({ ...optionsToSend, ...urlOptions, ...putOptions }, callback); }; + const updateReportDefinition = ( + patchOptions: UpdateReportDefinitionOptions, + callback?: RequestCallback + ): Promise => { + const urlOptions = { + url: options.apiUrls.reports + '/' + patchOptions.reportId + '/definition', + queryParameters: { + updateFilters: patchOptions.updateFilters, + }, + }; + return requestor.put({ ...optionsToSend, ...urlOptions, ...patchOptions }, callback); + }; + const deleteReport = (deleteOptions: DeleteReportOptions, callback?: RequestCallback) => { const urlOptions = { url: options.apiUrls.reports + '/' + deleteOptions.reportId }; return requestor.delete({ ...optionsToSend, ...urlOptions, ...deleteOptions }, callback); @@ -121,6 +135,7 @@ export function create(options: CreateOptions): ReportsApi { getReportAsCSV, getReportPublishStatus, setReportPublishStatus, + updateReportDefinition, deleteReport, addReportScope, removeReportScope, diff --git a/lib/reports/types.ts b/lib/reports/types.ts index 1eed594..57c7942 100644 --- a/lib/reports/types.ts +++ b/lib/reports/types.ts @@ -176,6 +176,62 @@ export interface ReportsApi { callback?: RequestCallback ) => Promise; + /** + * Update a Report's definition based on the specified ID + * + * **Note:** This endpoint supports partial updates **only on root level** properties of the report definition, + * such as `filters`, `groupingCriteria` and `summarizingCriteria`. For example, you can update the report's filters + * without affecting its grouping criteria. However, nested properties within these objects, + * such as a specific filter or grouping criterion, cannot be updated individually and + * require a full replacement of the respective section. + * + * @param options - {@link UpdateReportDefinitionOptions} - Configuration options for the request + * @param callback - {@link RequestCallback}\<{@link BaseResponseStatus}\> - Optional callback function + * @returns Promise\<{@link BaseResponseStatus}\> + * + * @remarks + * It mirrors to the following Smartsheet REST API method: `PATCH /reports/{reportId}/definition` + * + * @example + * ```typescript + * // Update report filters with different value types + * const result = await client.reports.updateReportDefinition({ + * reportId: 4583173393803140, + * body: { + * filters: { + * operator: 'AND', + * criteria: [ + * { + * column: { title: 'Status', type: 'PICKLIST' }, + * operator: 'EQUAL', + * values: ['Complete'] // String values + * }, + * { + * column: { title: 'Priority', type: 'TEXT_NUMBER' }, + * operator: 'GREATER_THAN', + * values: [5] // Numeric values + * }, + * { + * column: { title: 'Due Date', type: 'DATE' }, + * operator: 'GREATER_THAN', + * values: [{ objectType: 'DATE', value: '2024-01-01' }] // Date object + * }, + * { + * column: { title: 'Assigned To', type: 'CONTACT_LIST' }, + * operator: 'EQUAL', + * values: [{ objectType: 'CURRENT_USER', value: '' }] // Current user filter + * } + * ] + * } + * } + * }); + * ``` + */ + updateReportDefinition: ( + options: UpdateReportDefinitionOptions, + callback?: RequestCallback + ) => Promise; + /** * Deletes the specified Report. * @@ -842,6 +898,366 @@ export interface SetReportPublishStatusResponse extends BaseResponseStatus { result: ReportPublish; } +// ============================================================================ +// Update Report Definition +// ============================================================================ + +export interface UpdateReportDefinitionOptions extends RequestOptions { + /** + * reportID of the report being accessed. + */ + reportId: number; + /** + * Whether the `filters` property should be updated. + */ + updateFilters?: boolean; +} + +// ============================================================================ +// Report Definition Enums +// ============================================================================ + +/** + * Boolean operator for filter expressions. + */ +export enum ReportFilterOperator { + AND = 'AND', + OR = 'OR', +} + +/** + * Filter condition operators. + */ +export enum ReportFilterConditionOperator { + EQUAL = 'EQUAL', + NOT_EQUAL = 'NOT_EQUAL', + GREATER_THAN = 'GREATER_THAN', + LESS_THAN = 'LESS_THAN', + CONTAINS = 'CONTAINS', + BETWEEN = 'BETWEEN', + TODAY = 'TODAY', + PAST = 'PAST', + FUTURE = 'FUTURE', + LAST_N_DAYS = 'LAST_N_DAYS', + NEXT_N_DAYS = 'NEXT_N_DAYS', + IS_BLANK = 'IS_BLANK', + IS_NOT_BLANK = 'IS_NOT_BLANK', + IS_NUMBER = 'IS_NUMBER', + IS_NOT_NUMBER = 'IS_NOT_NUMBER', + IS_DATE = 'IS_DATE', + IS_NOT_DATE = 'IS_NOT_DATE', + IS_CHECKED = 'IS_CHECKED', + IS_UNCHECKED = 'IS_UNCHECKED', + IS_ONE_OF = 'IS_ONE_OF', + IS_NOT_ONE_OF = 'IS_NOT_ONE_OF', + LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', + GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', + DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN', + NOT_BETWEEN = 'NOT_BETWEEN', + NOT_TODAY = 'NOT_TODAY', + NOT_PAST = 'NOT_PAST', + NOT_FUTURE = 'NOT_FUTURE', + NOT_LAST_N_DAYS = 'NOT_LAST_N_DAYS', + NOT_NEXT_N_DAYS = 'NOT_NEXT_N_DAYS', + HAS_ANY_OF = 'HAS_ANY_OF', + HAS_NONE_OF = 'HAS_NONE_OF', + HAS_ALL_OF = 'HAS_ALL_OF', + NOT_ALL_OF = 'NOT_ALL_OF', + MULTI_IS_EQUAL = 'MULTI_IS_EQUAL', + MULTI_IS_NOT_EQUAL = 'MULTI_IS_NOT_EQUAL', +} + +/** + * Sorting direction for grouping and sorting criteria. + */ +export enum ReportSortingDirection { + ASCENDING = 'ASCENDING', + DESCENDING = 'DESCENDING', +} + +/** + * Aggregation types for report summarizing criteria. + */ +export enum ReportAggregationType { + SUM = 'SUM', + AVG = 'AVG', + MIN = 'MIN', + MAX = 'MAX', + COUNT = 'COUNT', + FIRST = 'FIRST', + LAST = 'LAST', +} + +/** + * Column types for report column identifiers. + */ +export enum ReportColumnType { + CHECKBOX = 'CHECKBOX', + DATE = 'DATE', + DATETIME = 'DATETIME', + DURATION = 'DURATION', + CONTACT_LIST = 'CONTACT_LIST', + MULTI_CONTACT_LIST = 'MULTI_CONTACT_LIST', + PICKLIST = 'PICKLIST', + MULTI_PICKLIST = 'MULTI_PICKLIST', + PREDECESSOR = 'PREDECESSOR', + TEXT_NUMBER = 'TEXT_NUMBER', +} + +/** + * System column types for report column identifiers. + */ +export enum ReportSystemColumnType { + CREATED_BY = 'CREATED_BY', + CREATED_DATE = 'CREATED_DATE', + MODIFIED_BY = 'MODIFIED_BY', + MODIFIED_DATE = 'MODIFIED_DATE', + AUTO_NUMBER = 'AUTO_NUMBER', +} + +// ============================================================================ +// Report Definition Types +// ============================================================================ + +/** + * The report definition contains filters, grouping and sorting properties of the report. + * + * Note: When groupingCriteria is defined the primary column of the report will move to the index 0 when it is first rendered by the app. + */ +export interface ReportDefinition { + /** + * Report filter expression. + */ + filters?: ReportFilterExpression; + /** + * List of report grouping criteria. + */ + groupingCriteria?: ReportGroupingCriterion[]; + /** + * List of report summarizing criteria. + */ + summarizingCriteria?: ReportSummarizingCriterion[]; + /** + * List of report sorting criteria. + */ + sortingCriteria?: ReportSortingCriterion[]; +} + +/** + * An expression to filter on report columns. It is a recursive object that allows at most three levels. + * + * It must include `operator` and at least one of the following: `criteria` or `nestedCriteria` + * + * Here is a two-level example with different value types: + * + * ```json + * { + * "operator": "OR", + * "nestedCriteria": [ + * { + * "operator": "AND", + * "criteria": [ + * { + * "column": { "title": "Price", "type": "TEXT_NUMBER" }, + * "operator": "GREATER_THAN", + * "values": [100] + * }, + * { + * "column": { "primary": true }, + * "operator": "CONTAINS", + * "values": ["PROJ-1"] + * } + * ] + * }, + * { + * "operator": "AND", + * "criteria": [ + * { + * "column": { "title": "Due Date", "type": "DATE" }, + * "operator": "GREATER_THAN", + * "values": [{ "objectType": "DATE", "value": "2024-01-01" }] + * }, + * { + * "column": { "title": "Assigned To", "type": "CONTACT_LIST" }, + * "operator": "EQUAL", + * "values": [{ "objectType": "CURRENT_USER", "value": "" }] + * } + * ] + * } + * ] + * } + * ``` + * + * It's equivalent to the following pseudo logic: + * + * ``` + * (Price > 100 AND Primary CONTAINS "PROJ-1") + * OR + * (Due Date > 2024-01-01 AND Assigned To = CURRENT_USER) + * ``` + */ +export interface ReportFilterExpression { + /** + * The boolean operator to apply to the list of `criteria` and `nestedCriteria`. + */ + operator: ReportFilterOperator | string; + /** + * A recursive list of report filter expressions. Each item is joined to the filter expression with the AND/OR operator defined on this level. + */ + nestedCriteria?: ReportFilterExpression[]; + /** + * Criteria objects specifying custom criteria against which to match cell values. Each item is joined to the filter expression with the AND/OR operator defined on this level. + */ + criteria?: ReportFilterCriterion[]; +} + +/** + * Represents a filter value object for special filter types. + * Used for date-based filters and current user filters. + */ +export interface ReportFilterValueObject { + /** + * Type of the object value. + * - `DATE`: For date-based filter values + * - `CURRENT_USER`: For filtering by the current user + */ + objectType: 'DATE' | 'CURRENT_USER'; + /** + * The value associated with the object type. + * For DATE objects, this would be a date string. + * For CURRENT_USER, this represents the user identifier. + */ + value: string; +} + +/** + * Union type for report filter values. + * Can be a string, number, null, or a filter value object. + */ +export type ReportFilterValue = string | number | null | ReportFilterValueObject; + +/** + * Represents a report filter criterion. + */ +export interface ReportFilterCriterion { + /** + * Object used to match a sheet column for a report. + */ + column: ReportColumnIdentifier; + /** + * Condition operator. + */ + operator: ReportFilterConditionOperator | string; + /** + * List of filter values. + * + * Values can be: + * - `string`: Regular text values (nullable) + * - `number`: Numeric values + * - `null`: Explicit null values + * - `ReportFilterValueObject`: Special object values for dates or current user + * + * @example + * ```typescript + * // String values + * values: ["Complete", "In Progress"] + * + * // Numeric values + * values: [100, 200] + * + * // Date object values + * values: [{ objectType: "DATE", value: "2024-01-01" }] + * + * // Current user filter + * values: [{ objectType: "CURRENT_USER", value: "" }] + * ``` + */ + values?: ReportFilterValue[]; +} + +/** + * Report grouping criterion. + */ +export interface ReportGroupingCriterion { + /** + * Object used to match a sheet column for a report. + */ + column: ReportColumnIdentifier; + /** + * Sorting direction within the group. + */ + sortingDirection: ReportSortingDirection | string; + /** + * Indicates whether the group is expanded in the UI. + */ + isExpanded?: boolean; +} + +/** + * Report summarizing criterion. + */ +export interface ReportSummarizingCriterion { + /** + * Object used to match a sheet column for a report. + */ + column: ReportColumnIdentifier; + /** + * Type of aggregation. + */ + aggregationType: ReportAggregationType | string; +} + +/** + * Report sorting criterion. + */ +export interface ReportSortingCriterion { + /** + * Object used to match a sheet column for a report. + */ + column: ReportColumnIdentifier; + /** + * Sorting direction. + */ + sortingDirection: ReportSortingDirection | string; +} + +/** + * An object for matching a source sheet column for a report. It requires one of: + * + * - [`type`, `title`] for **regular columns** + * - [`type`, `systemColumnType`] for **system columns** + * - [`type=TEXT_NUMBER`, `primary=true`] for the **primary column** + * - [`type=TEXT_NUMBER`, `sheetNameColumn=true`] for the special **sheet name report column** + * + * **Note:** You can combine multiple `CHECKBOX` columns or multiple `PICKLIST` columns from different sheets into a single report column, even if their underlying symbols differ. However, you can't combine a `CHECKBOX` column with a `PICKLIST` column, because they're different types. + * + * **Note:** The system column type `AUTO_NUMBER` is matched together with columns having the same `title` and `type=TEXT_NUMBER`. Therefore, `title` is a required property in this case. + */ +export interface ReportColumnIdentifier { + /** + * Title of a column to match. + * + * **Note:** If you specified `primary=true` to match primary columns, you can set the resulting report column title to this value. + */ + title?: string; + /** + * Type of column to match. See [Column Types](/api/smartsheet/openapi/columns). + */ + type?: ReportColumnType | string; + /** + * System column type to match. See [System Columns](/api/smartsheet/openapi/columns). + */ + systemColumnType?: ReportSystemColumnType | string; + /** + * Set this to `true` to match the primary column. + */ + primary?: boolean; + /** + * Set this to `true` to match the special "Sheet Name" report column. + */ + sheetNameColumn?: boolean; +} + // ============================================================================ // Delete Report // ============================================================================ @@ -853,6 +1269,8 @@ export interface DeleteReportOptions extends RequestOptions methodRequest(options, request.put, 'PUT', callback, options.body); + const patch = (options, callback) => methodRequest(options, request.patch, 'PATCH', callback, options.body); + const methodRequest = (options, method, methodName, callback, body) => { const baseRequestOptions = { url: buildUrl(options), @@ -146,7 +148,7 @@ export function create(requestorConfig) { }; const methodHandler = (url, method, methodName, requestOptions, body) => { - if (methodName === 'POST' || methodName === 'PUT') { + if (methodName === 'POST' || methodName === 'PUT' || methodName === 'PATCH') { return method(url, body, requestOptions); } @@ -193,6 +195,7 @@ export function create(requestorConfig) { put: put, post: post, postFile: postFile, + patch: patch, delete: deleteFunc, internal: { buildHeaders: buildHeaders, diff --git a/test/functional/client.spec.ts b/test/functional/client.spec.ts index e2e9fb6..91a03f1 100644 --- a/test/functional/client.spec.ts +++ b/test/functional/client.spec.ts @@ -180,7 +180,7 @@ describe('Client Unit Tests', () => { describe('#reports', () => { it('should have reports object', () => { expect(smartsheet).toHaveProperty('reports'); - expect(Object.keys(smartsheet.reports)).toHaveLength(15); + expect(Object.keys(smartsheet.reports)).toHaveLength(16); }); it('should have get methods', () => { diff --git a/test/mock-api/reports/update_report_definition.spec.ts b/test/mock-api/reports/update_report_definition.spec.ts new file mode 100644 index 0000000..de6ef6a --- /dev/null +++ b/test/mock-api/reports/update_report_definition.spec.ts @@ -0,0 +1,141 @@ +import crypto from 'crypto'; +import { createClient, findWireMockRequest } from '../utils/utils'; +import { expect } from '@jest/globals'; +import { + TEST_REPORT_ID, + TEST_SUCCESS_MESSAGE, + TEST_SUCCESS_RESULT_CODE, + ERROR_500_STATUS_CODE, + ERROR_500_MESSAGE, + ERROR_400_STATUS_CODE, + ERROR_400_MESSAGE +} from './common_test_constants'; + +describe('Reports - updateReportDefinition endpoint tests', () => { + const client = createClient(); + + const testFilterOnlyBody = { + filters: { + operator: 'AND', + criteria: [ + { + column: { title: 'Status', type: 'PICKLIST' }, + operator: 'EQUAL', + values: ['Complete'] + }, + { + column: { title: 'Priority', type: 'TEXT_NUMBER' }, + operator: 'IS_ONE_OF', + values: ['High', 'Critical'] + } + ] + } + }; + + it('updateReportDefinition generated url is correct', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: testFilterOnlyBody, + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/update-report-definition/all-response-body-properties' + } + }; + await client.reports.updateReportDefinition(options); + const matchedRequest = await findWireMockRequest(requestId); + const parsedUrl = new URL(matchedRequest.absoluteUrl); + expect(parsedUrl.pathname).toEqual(`/2.0/reports/${TEST_REPORT_ID}/definition`); + }); + + it('updateReportDefinition uses PUT method', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: testFilterOnlyBody, + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/update-report-definition/all-response-body-properties' + } + }; + await client.reports.updateReportDefinition(options); + const matchedRequest = await findWireMockRequest(requestId); + expect(matchedRequest.method).toEqual('PUT'); + }); + + it('updateReportDefinition sets query parameters correctly', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: testFilterOnlyBody, + updateFilters: true, + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/update-report-definition/all-response-body-properties' + } + }; + await client.reports.updateReportDefinition(options); + const matchedRequest = await findWireMockRequest(requestId); + expect(matchedRequest.queryParams.updateFilters.values).toContain('true'); + }); + + it('updateReportDefinition all response body properties', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: testFilterOnlyBody, + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/reports/update-report-definition/all-response-body-properties' + } + }; + const response = await client.reports.updateReportDefinition(options); + const matchedRequest = await findWireMockRequest(requestId); + + expect(response).toEqual({ + message: TEST_SUCCESS_MESSAGE, + resultCode: TEST_SUCCESS_RESULT_CODE, + }); + + const body = JSON.parse(matchedRequest.body); + expect(body).toEqual(testFilterOnlyBody); + }); + + it('updateReportDefinition error 500 response', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: testFilterOnlyBody, + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/errors/500-response' + } + }; + try { + await client.reports.updateReportDefinition(options); + expect(true).toBe(false); // Expected an error to be thrown + } catch (error) { + expect(error.statusCode).toBe(ERROR_500_STATUS_CODE); + expect(error.message).toBe(ERROR_500_MESSAGE); + } + }); + + it('updateReportDefinition error 400 response', async () => { + const requestId = crypto.randomUUID(); + const options = { + reportId: TEST_REPORT_ID, + body: testFilterOnlyBody, + customProperties: { + 'x-request-id': requestId, + 'x-test-name': '/errors/400-response' + } + }; + try { + await client.reports.updateReportDefinition(options); + expect(true).toBe(false); // Expected an error to be thrown + } catch (error) { + expect(error.statusCode).toBe(ERROR_400_STATUS_CODE); + expect(error.message).toBe(ERROR_400_MESSAGE); + } + }); +});