diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 11b8484d4..718afdb21 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -23,7 +23,7 @@ import AppDataOperator from '@database/operator/app_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator'; import {schema as appSchema} from '@database/schema/app'; import {serverSchema} from '@database/schema/server'; -import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel} from '@playbooks/database/models'; +import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunPropertyFieldModel, PlaybookRunPropertyValueModel} from '@playbooks/database/models'; import {deleteIOSDatabase} from '@utils/mattermost_managed'; import {urlSafeBase64Encode} from '@utils/security'; import {removeProtocol} from '@utils/url'; @@ -55,7 +55,7 @@ class DatabaseManagerSingleton { PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, - PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel, + PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunPropertyFieldModel, PlaybookRunPropertyValueModel, ]; this.databaseDirectory = ''; } diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 84d75d7ec..200e1bbd4 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -24,7 +24,7 @@ import ServerDataOperator from '@database/operator/server_data_operator'; import {schema as appSchema} from '@database/schema/app'; import {serverSchema} from '@database/schema/server'; import {beforeUpgrade} from '@helpers/database/upgrade'; -import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel} from '@playbooks/database/models'; +import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunPropertyFieldModel, PlaybookRunPropertyValueModel} from '@playbooks/database/models'; import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers'; import {logDebug, logError} from '@utils/log'; import {deleteIOSDatabase, getIOSAppGroupDetails, renameIOSDatabase} from '@utils/mattermost_managed'; @@ -51,7 +51,7 @@ class DatabaseManagerSingleton { PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel, - PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunAttributeModel, PlaybookRunAttributeValueModel, + PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel, PlaybookRunPropertyFieldModel, PlaybookRunPropertyValueModel, ]; this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${documentDirectory}/databases/`; diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index 1669518a5..a1d225b2b 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -25,6 +25,17 @@ const { const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; export default schemaMigrations({migrations: [ + { + toVersion: 17, + steps: [ + addColumns({ + table: PLAYBOOK_RUN_ATTRIBUTE_VALUE, + columns: [ + {name: 'update_at', type: 'number'}, + ], + }), + ], + }, { toVersion: 16, steps: [ diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 0a5f8e4db..c4f8d12eb 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -45,7 +45,7 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 16, + version: 17, tables: [ CategorySchema, CategoryChannelSchema, diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index a3a3b8a66..37e2bf191 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -52,7 +52,7 @@ const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_A describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 16, + version: 17, unsafeSql: undefined, tables: { [CATEGORY]: { @@ -590,11 +590,13 @@ describe('*** Test schema for SERVER database ***', () => { attribute_id: {name: 'attribute_id', type: 'string', isIndexed: true}, run_id: {name: 'run_id', type: 'string', isIndexed: true}, value: {name: 'value', type: 'string'}, + update_at: {name: 'update_at', type: 'number'}, }, columnArray: [ {name: 'attribute_id', type: 'string', isIndexed: true}, {name: 'run_id', type: 'string', isIndexed: true}, {name: 'value', type: 'string'}, + {name: 'update_at', type: 'number'}, ], }, [POSTS_IN_THREAD]: { diff --git a/app/products/playbooks/actions/local/property_fields.test.ts b/app/products/playbooks/actions/local/property_fields.test.ts new file mode 100644 index 000000000..49a7ce884 --- /dev/null +++ b/app/products/playbooks/actions/local/property_fields.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; +import TestHelper from '@test/test_helper'; + +import {handlePlaybookRunPropertyFields} from './property_fields'; + +import type {Database} from '@nozbe/watermelondb'; +import type PlaybookRunAttributeModel from '@playbooks/types/database/models/playbook_run_attribute'; +import type PlaybookRunAttributeValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; + +const serverUrl = 'baseHandler.test.com'; + +beforeEach(async () => { + await DatabaseManager.init([serverUrl]); +}); + +afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); +}); + +describe('handlePlaybookRunPropertyFields', () => { + let database: Database; + + beforeEach(() => { + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + }); + + it('should handle not found database', async () => { + const propertyFields = [TestHelper.fakePlaybookRunAttribute()]; + const propertyValues: PlaybookRunPropertyValue[] = []; + + const {error} = await handlePlaybookRunPropertyFields('foo', propertyFields, propertyValues); + expect(error).toBeDefined(); + expect((error as Error).message).toContain('foo database not found'); + }); + + it('should handle property fields successfully', async () => { + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Priority'}), + TestHelper.fakePlaybookRunAttribute({id: 'field-2', name: 'Status'}), + ]; + const propertyValues: PlaybookRunPropertyValue[] = []; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Verify property fields were stored + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + expect(storedFields.length).toBe(2); + expect(storedFields[0].id).toBe('field-1'); + expect(storedFields[0].name).toBe('Priority'); + expect(storedFields[1].id).toBe('field-2'); + expect(storedFields[1].name).toBe('Status'); + }); + + it('should handle property values successfully', async () => { + const propertyFields: PlaybookRunPropertyField[] = []; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', {id: 'value-1', value: 'high'}), + TestHelper.fakePlaybookRunAttributeValue('field-2', 'run-1', {id: 'value-2', value: 'in-progress'}), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Verify property values were stored + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedValues.length).toBe(2); + expect(storedValues[0].id).toBe('value-1'); + expect(storedValues[0].value).toBe('high'); + expect(storedValues[1].id).toBe('value-2'); + expect(storedValues[1].value).toBe('in-progress'); + }); + + it('should handle both property fields and values together', async () => { + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Priority'}), + ]; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', {id: 'value-1', value: 'high'}), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Verify both were stored + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields.length).toBe(1); + expect(storedValues.length).toBe(1); + expect(storedFields[0].id).toBe('field-1'); + expect(storedValues[0].id).toBe('value-1'); + }); + + it('should handle empty property fields array', async () => { + const propertyFields: PlaybookRunPropertyField[] = []; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', {id: 'value-1', value: 'test'}), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Only values should be stored + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields.length).toBe(0); + expect(storedValues.length).toBe(1); + }); + + it('should handle empty property values array', async () => { + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Priority'}), + ]; + const propertyValues: PlaybookRunPropertyValue[] = []; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Only fields should be stored + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields.length).toBe(1); + expect(storedValues.length).toBe(0); + }); + + it('should handle both empty arrays', async () => { + const propertyFields: PlaybookRunPropertyField[] = []; + const propertyValues: PlaybookRunPropertyValue[] = []; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Nothing should be stored + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields.length).toBe(0); + expect(storedValues.length).toBe(0); + }); + + it('should update existing property field', async () => { + // First insert + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Priority', update_at: 1000}), + ]; + await handlePlaybookRunPropertyFields(serverUrl, propertyFields, []); + + // Update with new name and timestamp + const updatedFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Updated Priority', update_at: 2000}), + ]; + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, updatedFields, []); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Verify update + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + expect(storedFields.length).toBe(1); + expect(storedFields[0].id).toBe('field-1'); + expect(storedFields[0].name).toBe('Updated Priority'); + expect(storedFields[0].updateAt).toBe(2000); + }); + + it('should update existing property value', async () => { + // First insert + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', {id: 'value-1', value: 'high'}), + ]; + await handlePlaybookRunPropertyFields(serverUrl, [], propertyValues); + + // Update with new value + const updatedValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', {id: 'value-1', value: 'low'}), + ]; + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, [], updatedValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + // Verify update + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedValues.length).toBe(1); + expect(storedValues[0].id).toBe('value-1'); + expect(storedValues[0].value).toBe('low'); + }); + + it('should handle text property field type', async () => { + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Description', type: 'text'}), + ]; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', { + id: 'value-1', + value: 'This is a text value', + }), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields[0].type).toBe('text'); + expect(storedValues[0].value).toBe('This is a text value'); + }); + + it('should handle select property field type', async () => { + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Priority', type: 'select'}), + ]; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', { + id: 'value-1', + value: 'option-id-123', + }), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields[0].type).toBe('select'); + expect(storedValues[0].value).toBe('option-id-123'); + }); + + it('should handle multiselect property field type with JSON array', async () => { + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Tags', type: 'multiselect'}), + ]; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', 'run-1', { + id: 'value-1', + value: '["option-id-1","option-id-2","option-id-3"]', + }), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields[0].type).toBe('multiselect'); + expect(storedValues[0].value).toBe('["option-id-1","option-id-2","option-id-3"]'); + }); + + it('should handle property field with attrs JSON', async () => { + const attrs = JSON.stringify({ + options: [ + {id: 'opt-1', name: 'High'}, + {id: 'opt-2', name: 'Medium'}, + {id: 'opt-3', name: 'Low'}, + ], + sort_order: 1, + visibility: 'always', + value_type: '', + }); + + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: 'field-1', + name: 'Priority', + type: 'select', + attrs, + }), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, []); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + expect(storedFields[0].attrs).toBe(attrs); + }); + + it('should handle multiple property fields for the same run', async () => { + const runId = 'run-123'; + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({id: 'field-1', name: 'Priority', target_id: runId}), + TestHelper.fakePlaybookRunAttribute({id: 'field-2', name: 'Status', target_id: runId}), + TestHelper.fakePlaybookRunAttribute({id: 'field-3', name: 'Tags', target_id: runId}), + ]; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue('field-1', runId, {id: 'value-1', value: 'high'}), + TestHelper.fakePlaybookRunAttributeValue('field-2', runId, {id: 'value-2', value: 'in-progress'}), + TestHelper.fakePlaybookRunAttributeValue('field-3', runId, {id: 'value-3', value: '["tag1","tag2"]'}), + ]; + + const {data, error} = await handlePlaybookRunPropertyFields(serverUrl, propertyFields, propertyValues); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const storedFields = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); + const storedValues = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); + expect(storedFields.length).toBe(3); + expect(storedValues.length).toBe(3); + }); +}); diff --git a/app/products/playbooks/actions/local/property_fields.ts b/app/products/playbooks/actions/local/property_fields.ts new file mode 100644 index 000000000..bdc90a94d --- /dev/null +++ b/app/products/playbooks/actions/local/property_fields.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {logError} from '@utils/log'; + +import type Model from '@nozbe/watermelondb/Model'; + +/** + * Store property fields and values in the local database + * @param serverUrl - The server URL + * @param propertyFields - Array of property field records to store + * @param propertyValues - Array of property value records to store + * @returns Promise with data or error + */ +export async function handlePlaybookRunPropertyFields( + serverUrl: string, + propertyFields: PlaybookRunPropertyField[], + propertyValues: PlaybookRunPropertyValue[], +) { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + // Prepare records for batching + const batch: Model[] = []; + + // Handle property fields + if (propertyFields.length) { + const propertyFieldRecords = await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: true, + }); + batch.push(...propertyFieldRecords); + } + + // Handle property values + if (propertyValues.length) { + const propertyValueRecords = await operator.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: true, + }); + batch.push(...propertyValueRecords); + } + + // Batch all records together in a single database operation + if (batch.length) { + await operator.batchRecords(batch, 'handlePlaybookRunPropertyFields'); + } + + return {data: true}; + } catch (error) { + logError('failed to handle playbook run property fields', error); + return {error}; + } +} diff --git a/app/products/playbooks/actions/remote/property_fields.test.ts b/app/products/playbooks/actions/remote/property_fields.test.ts new file mode 100644 index 000000000..4b977a2be --- /dev/null +++ b/app/products/playbooks/actions/remote/property_fields.test.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {handlePlaybookRunPropertyFields} from '@playbooks/actions/local/property_fields'; +import TestHelper from '@test/test_helper'; + +import {fetchPlaybookRunPropertyFields, updatePlaybookRunPropertyValue} from './property_fields'; + +const serverUrl = 'baseHandler.test.com'; +const runId = 'run-id-1'; + +const mockPropertyField = TestHelper.fakePlaybookRunAttribute({target_id: runId}); +const mockPropertyField2 = TestHelper.fakePlaybookRunAttribute({target_id: runId}); +const mockPropertyValue = TestHelper.fakePlaybookRunAttributeValue(mockPropertyField.id, runId); +const mockPropertyValue2 = TestHelper.fakePlaybookRunAttributeValue(mockPropertyField2.id, runId); + +const mockClient = { + fetchRunPropertyFields: jest.fn(), + fetchRunPropertyValues: jest.fn(), + setRunPropertyValue: jest.fn(), +}; + +jest.mock('@playbooks/actions/local/property_fields'); + +const throwFunc = () => { + throw Error('error'); +}; + +beforeAll(() => { + // @ts-ignore + NetworkManager.getClient = () => mockClient; +}); + +beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + jest.clearAllMocks(); +}); + +afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); +}); + +describe('fetchPlaybookRunPropertyFields', () => { + beforeEach(() => { + jest.mocked(handlePlaybookRunPropertyFields).mockResolvedValue({data: true}); + }); + + it('should fetch property fields and values successfully', async () => { + mockClient.fetchRunPropertyFields.mockResolvedValueOnce([mockPropertyField, mockPropertyField2]); + mockClient.fetchRunPropertyValues.mockResolvedValueOnce([mockPropertyValue, mockPropertyValue2]); + + const result = await fetchPlaybookRunPropertyFields(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(mockClient.fetchRunPropertyFields).toHaveBeenCalledWith(runId); + expect(mockClient.fetchRunPropertyValues).toHaveBeenCalledWith(runId); + expect(handlePlaybookRunPropertyFields).toHaveBeenCalledWith( + serverUrl, + [mockPropertyField, mockPropertyField2], + [mockPropertyValue, mockPropertyValue2], + ); + }); + + it('should fetch empty property fields and values', async () => { + mockClient.fetchRunPropertyFields.mockResolvedValueOnce([]); + mockClient.fetchRunPropertyValues.mockResolvedValueOnce([]); + + const result = await fetchPlaybookRunPropertyFields(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(mockClient.fetchRunPropertyFields).toHaveBeenCalledWith(runId); + expect(mockClient.fetchRunPropertyValues).toHaveBeenCalledWith(runId); + expect(handlePlaybookRunPropertyFields).toHaveBeenCalledWith( + serverUrl, + [], + [], + ); + }); + + it('should handle fetchOnly mode without storing in DB', async () => { + mockClient.fetchRunPropertyFields.mockResolvedValueOnce([mockPropertyField]); + mockClient.fetchRunPropertyValues.mockResolvedValueOnce([mockPropertyValue]); + + const result = await fetchPlaybookRunPropertyFields(serverUrl, runId, true); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(mockClient.fetchRunPropertyFields).toHaveBeenCalledWith(runId); + expect(mockClient.fetchRunPropertyValues).toHaveBeenCalledWith(runId); + expect(handlePlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle client error on fetchRunPropertyFields', async () => { + mockClient.fetchRunPropertyFields.mockRejectedValueOnce(new Error('Client error')); + + const result = await fetchPlaybookRunPropertyFields(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(handlePlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle client error on fetchRunPropertyValues', async () => { + mockClient.fetchRunPropertyFields.mockResolvedValueOnce([mockPropertyField]); + mockClient.fetchRunPropertyValues.mockRejectedValueOnce(new Error('Client error')); + + const result = await fetchPlaybookRunPropertyFields(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(handlePlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await fetchPlaybookRunPropertyFields(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(handlePlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle database storage error', async () => { + mockClient.fetchRunPropertyFields.mockResolvedValueOnce([mockPropertyField]); + mockClient.fetchRunPropertyValues.mockResolvedValueOnce([mockPropertyValue]); + jest.mocked(handlePlaybookRunPropertyFields).mockResolvedValueOnce({error: new Error('DB error')}); + + const result = await fetchPlaybookRunPropertyFields(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(mockClient.fetchRunPropertyFields).toHaveBeenCalledWith(runId); + expect(mockClient.fetchRunPropertyValues).toHaveBeenCalledWith(runId); + expect(handlePlaybookRunPropertyFields).toHaveBeenCalled(); + }); + + it('should fetch property fields and values in parallel', async () => { + let propertyFieldsResolve: any; + let propertyValuesResolve: any; + + const propertyFieldsPromise = new Promise((resolve) => { + propertyFieldsResolve = resolve; + }); + const propertyValuesPromise = new Promise((resolve) => { + propertyValuesResolve = resolve; + }); + + mockClient.fetchRunPropertyFields.mockReturnValueOnce(propertyFieldsPromise); + mockClient.fetchRunPropertyValues.mockReturnValueOnce(propertyValuesPromise); + + const resultPromise = fetchPlaybookRunPropertyFields(serverUrl, runId); + + // Resolve in reverse order to ensure parallel execution + propertyValuesResolve([mockPropertyValue]); + propertyFieldsResolve([mockPropertyField]); + + const result = await resultPromise; + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(handlePlaybookRunPropertyFields).toHaveBeenCalledWith( + serverUrl, + [mockPropertyField], + [mockPropertyValue], + ); + }); +}); + +describe('updatePlaybookRunPropertyValue', () => { + const fieldId = 'field-id-1'; + const value = 'test value'; + + beforeEach(() => { + jest.mocked(handlePlaybookRunPropertyFields).mockResolvedValue({data: true}); + }); + + it('should update property value successfully', async () => { + const updatedValue = {...mockPropertyValue, value}; + mockClient.setRunPropertyValue.mockResolvedValueOnce(updatedValue); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, value); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.propertyValue).toEqual(updatedValue); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, value, undefined); + expect(handlePlaybookRunPropertyFields).toHaveBeenCalledWith( + serverUrl, + [], + [updatedValue], + ); + }); + + it('should update property value for text field', async () => { + const textValue = 'Some text content'; + const fieldType = 'text'; + const updatedValue = {...mockPropertyValue, value: textValue}; + mockClient.setRunPropertyValue.mockResolvedValueOnce(updatedValue); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, textValue, fieldType); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.propertyValue).toEqual(updatedValue); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, textValue, fieldType); + }); + + it('should update property value for select field', async () => { + const selectValue = 'option-id-123'; + const fieldType = 'select'; + const updatedValue = {...mockPropertyValue, value: selectValue}; + mockClient.setRunPropertyValue.mockResolvedValueOnce(updatedValue); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, selectValue, fieldType); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.propertyValue).toEqual(updatedValue); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, selectValue, fieldType); + }); + + it('should update property value for multiselect field', async () => { + const multiselectValue = 'option-id-1,option-id-2'; + const fieldType = 'multiselect'; + const updatedValue = {...mockPropertyValue, value: '["option-id-1","option-id-2"]'}; + mockClient.setRunPropertyValue.mockResolvedValueOnce(updatedValue); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, multiselectValue, fieldType); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.propertyValue).toEqual(updatedValue); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, multiselectValue, fieldType); + }); + + it('should update property value for multiselect field with empty value', async () => { + const emptyMultiselectValue = ''; + const fieldType = 'multiselect'; + const updatedValue = {...mockPropertyValue, value: '[]'}; + mockClient.setRunPropertyValue.mockResolvedValueOnce(updatedValue); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, emptyMultiselectValue, fieldType); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.propertyValue).toEqual(updatedValue); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, emptyMultiselectValue, fieldType); + }); + + it('should update property value with empty string', async () => { + const emptyValue = ''; + const updatedValue = {...mockPropertyValue, value: emptyValue}; + mockClient.setRunPropertyValue.mockResolvedValueOnce(updatedValue); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, emptyValue); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.propertyValue).toEqual(updatedValue); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, emptyValue, undefined); + }); + + it('should handle client error', async () => { + mockClient.setRunPropertyValue.mockRejectedValueOnce(new Error('Client error')); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, value); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.propertyValue).toBeUndefined(); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, value, undefined); + expect(handlePlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, value); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.propertyValue).toBeUndefined(); + expect(handlePlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle database storage error', async () => { + const updatedValue = {...mockPropertyValue, value}; + mockClient.setRunPropertyValue.mockResolvedValueOnce(updatedValue); + jest.mocked(handlePlaybookRunPropertyFields).mockResolvedValueOnce({error: new Error('DB error')}); + + const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, value); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.propertyValue).toBeUndefined(); + expect(mockClient.setRunPropertyValue).toHaveBeenCalledWith(runId, fieldId, value, undefined); + expect(handlePlaybookRunPropertyFields).toHaveBeenCalled(); + }); +}); diff --git a/app/products/playbooks/actions/remote/property_fields.ts b/app/products/playbooks/actions/remote/property_fields.ts new file mode 100644 index 000000000..1ca58643c --- /dev/null +++ b/app/products/playbooks/actions/remote/property_fields.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import NetworkManager from '@managers/network_manager'; +import {handlePlaybookRunPropertyFields} from '@playbooks/actions/local/property_fields'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug} from '@utils/log'; + +/** + * Fetch property fields and values for a playbook run from the server and store them in the database + * @param serverUrl - The server URL + * @param runId - The playbook run ID + * @param fetchOnly - If true, only fetch from server without storing in DB + * @returns Promise with error if any + */ +export const fetchPlaybookRunPropertyFields = async ( + serverUrl: string, + runId: string, + fetchOnly = false, +): Promise<{error?: unknown}> => { + try { + const client = NetworkManager.getClient(serverUrl); + + // Fetch property fields and values from the server + const [propertyFields, propertyValues] = await Promise.all([ + client.fetchRunPropertyFields(runId), + client.fetchRunPropertyValues(runId), + ]); + + // Store in database if not fetchOnly + if (!fetchOnly) { + const result = await handlePlaybookRunPropertyFields( + serverUrl, + propertyFields, + propertyValues, + ); + if (result.error) { + logDebug('error on handlePlaybookRunPropertyFields', getFullErrorMessage(result.error)); + return {error: result.error}; + } + } + + return {}; + } catch (error) { + logDebug('error on fetchPlaybookRunPropertyFields', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +/** + * Update a property value for a playbook run on the server and locally + * @param serverUrl - The server URL + * @param runId - The playbook run ID + * @param fieldId - The property field ID + * @param value - The new value (string for text/select, comma-separated string for multiselect) + * @param fieldType - The type of the field ('text', 'select', 'multiselect') + * @returns Promise with updated property value or error + */ +export const updatePlaybookRunPropertyValue = async ( + serverUrl: string, + runId: string, + fieldId: string, + value: string, + fieldType?: string, +): Promise<{error?: unknown; propertyValue?: PlaybookRunPropertyValue}> => { + try { + const client = NetworkManager.getClient(serverUrl); + + // Update value on server + const propertyValue = await client.setRunPropertyValue(runId, fieldId, value, fieldType); + + // Update local database + const result = await handlePlaybookRunPropertyFields( + serverUrl, + [], + [propertyValue], + ); + + if (result.error) { + logDebug('error on handlePlaybookRunPropertyFields after update', getFullErrorMessage(result.error)); + return {error: result.error}; + } + + return {propertyValue}; + } catch (error) { + logDebug('error on updatePlaybookRunPropertyValue', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; diff --git a/app/products/playbooks/actions/remote/runs.test.ts b/app/products/playbooks/actions/remote/runs.test.ts index 311f7c925..5dbf2dec2 100644 --- a/app/products/playbooks/actions/remote/runs.test.ts +++ b/app/products/playbooks/actions/remote/runs.test.ts @@ -12,6 +12,7 @@ import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run'; import EphemeralStore from '@store/ephemeral_store'; import TestHelper from '@test/test_helper'; +import {fetchPlaybookRunPropertyFields} from './property_fields'; import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, fetchPlaybookRunsPageForParticipant, setOwner, finishRun, renamePlaybookRun, createPlaybookRun, fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from './runs'; const serverUrl = 'baseHandler.test.com'; @@ -34,6 +35,7 @@ const mockClient = { jest.mock('@playbooks/database/queries/run'); jest.mock('@playbooks/actions/local/run'); jest.mock('@playbooks/actions/local/channel'); +jest.mock('./property_fields'); const throwFunc = () => { throw Error('error'); @@ -239,6 +241,106 @@ describe('fetchFinishedRunsForChannel', () => { }); }); +describe('fetchPlaybookRun', () => { + const runId = 'run-id-123'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(handlePlaybookRuns).mockResolvedValue({data: []}); + jest.mocked(fetchPlaybookRunPropertyFields).mockResolvedValue({}); + }); + + it('should fetch playbook run successfully', async () => { + const mockRun = TestHelper.fakePlaybookRun({id: runId}); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.run).toEqual(mockRun); + expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(runId); + expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true); + expect(fetchPlaybookRunPropertyFields).toHaveBeenCalledWith(serverUrl, runId, false); + }); + + it('should handle fetchOnly mode without storing in DB', async () => { + const mockRun = TestHelper.fakePlaybookRun({id: runId}); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + + const result = await fetchPlaybookRun(serverUrl, runId, true); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.run).toEqual(mockRun); + expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(runId); + expect(handlePlaybookRuns).not.toHaveBeenCalled(); + expect(fetchPlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should fetch property fields after fetching run', async () => { + const mockRun = TestHelper.fakePlaybookRun({id: runId}); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + + await fetchPlaybookRun(serverUrl, runId); + + expect(fetchPlaybookRunPropertyFields).toHaveBeenCalledWith(serverUrl, runId, false); + expect(handlePlaybookRuns).toHaveBeenCalled(); + }); + + it('should continue even if property fields fetch fails', async () => { + const mockRun = TestHelper.fakePlaybookRun({id: runId}); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + jest.mocked(fetchPlaybookRunPropertyFields).mockResolvedValueOnce({error: new Error('Property fields error')}); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.run).toEqual(mockRun); + expect(fetchPlaybookRunPropertyFields).toHaveBeenCalledWith(serverUrl, runId, false); + }); + + it('should handle client error on fetchPlaybookRun', async () => { + mockClient.fetchPlaybookRun.mockRejectedValueOnce(new Error('Client error')); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.run).toBeUndefined(); + expect(handlePlaybookRuns).not.toHaveBeenCalled(); + expect(fetchPlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.run).toBeUndefined(); + expect(handlePlaybookRuns).not.toHaveBeenCalled(); + expect(fetchPlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); + + it('should handle handlePlaybookRuns error', async () => { + const mockRun = TestHelper.fakePlaybookRun({id: runId}); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + jest.mocked(handlePlaybookRuns).mockResolvedValueOnce({error: new Error('DB error')}); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.run).toBeUndefined(); + expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(runId); + expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true); + expect(fetchPlaybookRunPropertyFields).not.toHaveBeenCalled(); + }); +}); + describe('setOwner', () => { const playbookRunId = 'playbook-run-id-1'; const ownerId = 'owner-user-id-1'; diff --git a/app/products/playbooks/actions/remote/runs.ts b/app/products/playbooks/actions/remote/runs.ts index c4bd245ed..67fa9116f 100644 --- a/app/products/playbooks/actions/remote/runs.ts +++ b/app/products/playbooks/actions/remote/runs.ts @@ -13,6 +13,8 @@ import EphemeralStore from '@store/ephemeral_store'; import {getFullErrorMessage} from '@utils/errors'; import {logDebug} from '@utils/log'; +import {fetchPlaybookRunPropertyFields} from './property_fields'; + type PlaybookRunsRequest = { runs?: PlaybookRun[]; error?: unknown; @@ -100,6 +102,14 @@ export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOn logDebug('error on handlePlaybookRuns', getFullErrorMessage(result.error)); return {error: result.error}; } + + // Fetch property fields and values for the run + const propertyFieldsResult = await fetchPlaybookRunPropertyFields(serverUrl, runId, fetchOnly); + if (propertyFieldsResult.error) { + logDebug('error on fetchPlaybookRunPropertyFields', getFullErrorMessage(propertyFieldsResult.error)); + + // Don't return error - property fields are not critical, just log it + } } return {run}; diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index c254d349d..67e04306b 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -712,7 +712,6 @@ describe('fetchPlaybooks', () => { jest.mocked(client.doFetch).mockResolvedValue(mockResponse); const result = await client.fetchPlaybooks(params); - expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); expect(result).toEqual(mockResponse); }); @@ -814,7 +813,6 @@ describe('createPlaybookRun', () => { name, description, ); - expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); expect(result).toEqual(mockResponse); }); @@ -892,7 +890,6 @@ describe('createPlaybookRun', () => { name, playbook_id, }; - jest.mocked(client.doFetch).mockResolvedValue(mockResponse); const result = await client.createPlaybookRun( @@ -935,7 +932,6 @@ describe('createPlaybookRun', () => { }; jest.mocked(client.doFetch).mockResolvedValue(mockResponse); - const result = await client.createPlaybookRun( playbook_id, owner_user_id, @@ -945,7 +941,6 @@ describe('createPlaybookRun', () => { undefined, create_public_run, ); - expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); expect(result).toEqual(mockResponse); }); @@ -1083,3 +1078,271 @@ describe('addChecklistItem', () => { expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); }); }); + +describe('fetchRunPropertyFields', () => { + test('should fetch property fields without updatedSince parameter', async () => { + const runId = 'run123'; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_fields'; + const expectedOptions = {method: 'get'}; + const mockResponse: PlaybookRunPropertyField[] = [ + { + id: 'field1', + group_id: 'group1', + name: 'Priority', + type: 'select', + target_id: 'run123', + target_type: 'run', + create_at: 1234567890, + update_at: 1234567890, + delete_at: 0, + attrs: '{"options":[{"id":"opt1","name":"High"}]}', + }, + ]; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchRunPropertyFields(runId); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should fetch property fields with updatedSince parameter', async () => { + const runId = 'run123'; + const updatedSince = 1234567890; + const queryParams = buildQueryString({updated_since: updatedSince}); + const expectedUrl = `/plugins/playbooks/api/v0/runs/run123/property_fields${queryParams}`; + const expectedOptions = {method: 'get'}; + const mockResponse: PlaybookRunPropertyField[] = []; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchRunPropertyFields(runId, updatedSince); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should return empty array when doFetch returns null', async () => { + const runId = 'run123'; + + jest.mocked(client.doFetch).mockResolvedValue(null); + + const result = await client.fetchRunPropertyFields(runId); + + expect(result).toEqual([]); + }); + + test('should handle error when fetching property fields', async () => { + const runId = 'run123'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.fetchRunPropertyFields(runId)).rejects.toThrow('Network error'); + }); +}); + +describe('fetchRunPropertyValues', () => { + test('should fetch property values without updatedSince parameter', async () => { + const runId = 'run123'; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_values'; + const expectedOptions = {method: 'get'}; + const mockResponse: PlaybookRunPropertyValue[] = [ + { + id: 'value1', + field_id: 'field1', + target_id: 'run123', + update_at: 1234567890, + value: 'opt1', + }, + ]; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchRunPropertyValues(runId); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should fetch property values with updatedSince parameter', async () => { + const runId = 'run123'; + const updatedSince = 1234567890; + const queryParams = buildQueryString({updated_since: updatedSince}); + const expectedUrl = `/plugins/playbooks/api/v0/runs/run123/property_values${queryParams}`; + const expectedOptions = {method: 'get'}; + const mockResponse: PlaybookRunPropertyValue[] = []; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchRunPropertyValues(runId, updatedSince); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should return empty array when doFetch returns null', async () => { + const runId = 'run123'; + + jest.mocked(client.doFetch).mockResolvedValue(null); + + const result = await client.fetchRunPropertyValues(runId); + + expect(result).toEqual([]); + }); + + test('should handle error when fetching property values', async () => { + const runId = 'run123'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.fetchRunPropertyValues(runId)).rejects.toThrow('Network error'); + }); + +}); + +describe('setRunPropertyValue', () => { + test('should set property value for text field', async () => { + const runId = 'run123'; + const fieldId = 'field1'; + const value = 'New text value'; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_fields/field1/value'; + const expectedOptions = {method: 'put', body: {value}}; + const mockResponse: PlaybookRunPropertyValue = { + id: 'value1', + field_id: fieldId, + target_id: runId, + update_at: 1234567890, + value, + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.setRunPropertyValue(runId, fieldId, value); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should set property value for select field', async () => { + const runId = 'run123'; + const fieldId = 'field2'; + const value = 'opt1'; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_fields/field2/value'; + const expectedOptions = {method: 'put', body: {value}}; + const mockResponse: PlaybookRunPropertyValue = { + id: 'value2', + field_id: fieldId, + target_id: runId, + update_at: 1234567890, + value, + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.setRunPropertyValue(runId, fieldId, value); + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should set property value for multiselect field with multiple values', async () => { + const runId = 'run123'; + const fieldId = 'field3'; + const value = 'opt1,opt2'; + const fieldType = 'multiselect'; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_fields/field3/value'; + const expectedOptions = {method: 'put', body: {value: ['opt1', 'opt2']}}; + const mockResponse: PlaybookRunPropertyValue = { + id: 'value3', + field_id: fieldId, + target_id: runId, + update_at: 1234567890, + value: '["opt1","opt2"]', + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.setRunPropertyValue(runId, fieldId, value, fieldType); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should set property value for multiselect field with single value', async () => { + const runId = 'run123'; + const fieldId = 'field3'; + const value = 'opt1'; + const fieldType = 'multiselect'; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_fields/field3/value'; + const expectedOptions = {method: 'put', body: {value: ['opt1']}}; + const mockResponse: PlaybookRunPropertyValue = { + id: 'value3', + field_id: fieldId, + target_id: runId, + update_at: 1234567890, + value: '["opt1"]', + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.setRunPropertyValue(runId, fieldId, value, fieldType); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should set property value for multiselect field with empty value', async () => { + const runId = 'run123'; + const fieldId = 'field3'; + const value = ''; + const fieldType = 'multiselect'; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_fields/field3/value'; + const expectedOptions = {method: 'put', body: {value: []}}; + const mockResponse: PlaybookRunPropertyValue = { + id: 'value3', + field_id: fieldId, + target_id: runId, + update_at: 1234567890, + value: '[]', + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.setRunPropertyValue(runId, fieldId, value, fieldType); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should set property value with empty string', async () => { + const runId = 'run123'; + const fieldId = 'field1'; + const value = ''; + const expectedUrl = '/plugins/playbooks/api/v0/runs/run123/property_fields/field1/value'; + const expectedOptions = {method: 'put', body: {value}}; + const mockResponse: PlaybookRunPropertyValue = { + id: 'value1', + field_id: fieldId, + target_id: runId, + update_at: 1234567890, + value, + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.setRunPropertyValue(runId, fieldId, value); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should handle error when setting property value', async () => { + const runId = 'run123'; + const fieldId = 'field1'; + const value = 'test'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.setRunPropertyValue(runId, fieldId, value)).rejects.toThrow('Network error'); + }); +}); diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index 37dba785c..884dd93fe 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -35,6 +35,11 @@ export interface ClientPlaybooksMix { // Slash Commands runChecklistItemSlashCommand: (playbookRunId: string, checklistNumber: number, itemNumber: number) => Promise<{trigger_id: string}>; setChecklistItemCommand: (playbookRunID: string, checklistNum: number, itemNum: number, command: string) => Promise; + + // Property Fields + fetchRunPropertyFields: (runId: string, updatedSince?: number) => Promise; + fetchRunPropertyValues: (runId: string, updatedSince?: number) => Promise; + setRunPropertyValue: (runId: string, fieldId: string, value: string, fieldType?: string) => Promise; } const ClientPlaybooks = >(superclass: TBase) => class extends superclass { @@ -229,6 +234,45 @@ const ClientPlaybooks = >(superclass: TBas return data; }; + + // Property Fields + fetchRunPropertyFields = async (runId: string, updatedSince?: number) => { + const queryParams = updatedSince ? buildQueryString({updated_since: updatedSince}) : ''; + const data = await this.doFetch( + `${this.getPlaybookRunRoute(runId)}/property_fields${queryParams}`, + {method: 'get'}, + ); + return data || []; + }; + + fetchRunPropertyValues = async (runId: string, updatedSince?: number) => { + const queryParams = updatedSince ? buildQueryString({updated_since: updatedSince}) : ''; + const data = await this.doFetch( + `${this.getPlaybookRunRoute(runId)}/property_values${queryParams}`, + {method: 'get'}, + ); + return data || []; + }; + + setRunPropertyValue = async (runId: string, fieldId: string, value: string, fieldType?: string) => { + // Convert value to appropriate format based on field type + let bodyValue: string | string[] = value; + + if (fieldType === 'multiselect') { + // For multiselect fields, always convert to array (even if empty) + if (value) { + bodyValue = value.split(',').filter((id) => id.length > 0); + } else { + bodyValue = []; + } + } + + const data = await this.doFetch( + `${this.getPlaybookRunRoute(runId)}/property_fields/${fieldId}/value`, + {method: 'put', body: {value: bodyValue}}, + ); + return data; + }; }; export default ClientPlaybooks; diff --git a/app/products/playbooks/database/models/index.ts b/app/products/playbooks/database/models/index.ts index 358bfa810..08d10f5cf 100644 --- a/app/products/playbooks/database/models/index.ts +++ b/app/products/playbooks/database/models/index.ts @@ -4,5 +4,5 @@ export {default as PlaybookRunModel} from './playbook_run'; export {default as PlaybookChecklistModel} from './playbook_checklist'; export {default as PlaybookChecklistItemModel} from './playbook_checklist_item'; -export {default as PlaybookRunAttributeModel} from './playbook_run_attribute'; -export {default as PlaybookRunAttributeValueModel} from './playbook_run_attribute_value'; +export {default as PlaybookRunPropertyFieldModel} from './playbook_run_attribute'; +export {default as PlaybookRunPropertyValueModel} from './playbook_run_attribute_value'; diff --git a/app/products/playbooks/database/models/playbook_run_attribute.test.ts b/app/products/playbooks/database/models/playbook_run_attribute.test.ts index 3a446ff61..333b0f5ad 100644 --- a/app/products/playbooks/database/models/playbook_run_attribute.test.ts +++ b/app/products/playbooks/database/models/playbook_run_attribute.test.ts @@ -5,7 +5,7 @@ import DatabaseManager from '@database/manager'; import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import TestHelper from '@test/test_helper'; -import PlaybookRunAttributeModel from './playbook_run_attribute'; +import PlaybookRunPropertyFieldModel from './playbook_run_attribute'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -13,7 +13,7 @@ const {PLAYBOOK_RUN_ATTRIBUTE} = PLAYBOOK_TABLES; const SERVER_URL = `playbookRunAttributeModel.test.${Date.now()}.com`; -const applyMockData = (attribute: PlaybookRunAttributeModel, mockData: PlaybookRunAttribute, includeAttrs = true) => { +const applyMockData = (attribute: PlaybookRunPropertyFieldModel, mockData: PlaybookRunPropertyField, includeAttrs = true) => { attribute._raw.id = mockData.id; attribute.groupId = mockData.group_id; attribute.name = mockData.name; @@ -24,13 +24,20 @@ const applyMockData = (attribute: PlaybookRunAttributeModel, mockData: PlaybookR attribute.updateAt = mockData.update_at; attribute.deleteAt = mockData.delete_at; if (includeAttrs) { - attribute.attrs = mockData.attrs || '{"placeholder": "Enter value"}'; + // API can send attrs as string or object, DB expects string + if (!mockData.attrs) { + attribute.attrs = '{"placeholder": "Enter value"}'; + } else if (typeof mockData.attrs === 'string') { + attribute.attrs = mockData.attrs; + } else { + attribute.attrs = JSON.stringify(mockData.attrs); + } } }; -describe('PlaybookRunAttributeModel', () => { +describe('PlaybookRunPropertyFieldModel', () => { let operator: ServerDataOperator; - let playbook_run_attribute: PlaybookRunAttributeModel; + let playbook_run_attribute: PlaybookRunPropertyFieldModel; beforeEach(async () => { await DatabaseManager.init([SERVER_URL]); @@ -38,7 +45,7 @@ describe('PlaybookRunAttributeModel', () => { const {database} = operator; await database.write(async () => { - playbook_run_attribute = await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunAttributeModel) => { + playbook_run_attribute = await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunPropertyFieldModel) => { applyMockData(attribute, TestHelper.createPlaybookRunAttribute('test', 0)); }); }); @@ -63,15 +70,15 @@ describe('PlaybookRunAttributeModel', () => { }); it('=> should have the correct table name', () => { - expect(PlaybookRunAttributeModel.table).toBe(PLAYBOOK_RUN_ATTRIBUTE); + expect(PlaybookRunPropertyFieldModel.table).toBe(PLAYBOOK_RUN_ATTRIBUTE); }); it('=> should handle optional attrs field', async () => { - let attributeWithoutAttrs: PlaybookRunAttributeModel; + let attributeWithoutAttrs: PlaybookRunPropertyFieldModel; const {database} = operator; await database.write(async () => { - attributeWithoutAttrs = await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunAttributeModel) => { + attributeWithoutAttrs = await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunPropertyFieldModel) => { applyMockData(attribute, TestHelper.createPlaybookRunAttribute('test', 1), false); }); }); @@ -85,7 +92,7 @@ describe('PlaybookRunAttributeModel', () => { const {database} = operator; await database.write(async () => { - await playbook_run_attribute.update((attribute: PlaybookRunAttributeModel) => { + await playbook_run_attribute.update((attribute: PlaybookRunPropertyFieldModel) => { attribute.name = 'Updated Attribute Name'; attribute.type = 'number'; attribute.updateAt = 1620000005000; @@ -103,7 +110,7 @@ describe('PlaybookRunAttributeModel', () => { const {database} = operator; await database.write(async () => { - await playbook_run_attribute.update((attribute: PlaybookRunAttributeModel) => { + await playbook_run_attribute.update((attribute: PlaybookRunPropertyFieldModel) => { attribute.deleteAt = 1620000010000; }); }); diff --git a/app/products/playbooks/database/models/playbook_run_attribute.ts b/app/products/playbooks/database/models/playbook_run_attribute.ts index af131f1cf..4ba60e3d4 100644 --- a/app/products/playbooks/database/models/playbook_run_attribute.ts +++ b/app/products/playbooks/database/models/playbook_run_attribute.ts @@ -11,9 +11,9 @@ import type PlaybookRunAttributeModelInterface from '@playbooks/types/database/m const {PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; /** - * The model represents a playbook run attribute definition in the Mattermost app. + * The model represents a playbook run property field definition in the Mattermost app. */ -export default class PlaybookRunAttributeModel extends Model implements PlaybookRunAttributeModelInterface { +export default class PlaybookRunPropertyFieldModel extends Model implements PlaybookRunAttributeModelInterface { /** table (name) : PlaybookRunAttribute */ static table = PLAYBOOK_RUN_ATTRIBUTE; diff --git a/app/products/playbooks/database/models/playbook_run_attribute_value.test.ts b/app/products/playbooks/database/models/playbook_run_attribute_value.test.ts index 0009fa79e..a582e4181 100644 --- a/app/products/playbooks/database/models/playbook_run_attribute_value.test.ts +++ b/app/products/playbooks/database/models/playbook_run_attribute_value.test.ts @@ -6,8 +6,8 @@ import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import TestHelper from '@test/test_helper'; import PlaybookRunModel from './playbook_run'; -import PlaybookRunAttributeModel from './playbook_run_attribute'; -import PlaybookRunAttributeValueModel from './playbook_run_attribute_value'; +import PlaybookRunPropertyFieldModel from './playbook_run_attribute'; +import PlaybookRunPropertyValueModel from './playbook_run_attribute_value'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -40,7 +40,7 @@ const applyRunData = (run: PlaybookRunModel, mockData: PlaybookRun) => { run.updateAt = mockData.update_at; }; -const applyAttributeData = (attribute: PlaybookRunAttributeModel, mockData: PlaybookRunAttribute) => { +const applyAttributeData = (attribute: PlaybookRunPropertyFieldModel, mockData: PlaybookRunPropertyField) => { attribute._raw.id = mockData.id; attribute.groupId = mockData.group_id; attribute.name = mockData.name; @@ -50,19 +50,24 @@ const applyAttributeData = (attribute: PlaybookRunAttributeModel, mockData: Play attribute.createAt = mockData.create_at; attribute.updateAt = mockData.update_at; attribute.deleteAt = mockData.delete_at; - attribute.attrs = mockData.attrs; + + // API can send attrs as string or object, DB expects string + if (mockData.attrs) { + attribute.attrs = typeof mockData.attrs === 'string' ? mockData.attrs : JSON.stringify(mockData.attrs); + } }; -const applyAttributeValueData = (attributeValue: PlaybookRunAttributeValueModel, mockData: PlaybookRunAttributeValue) => { +const applyAttributeValueData = (attributeValue: PlaybookRunPropertyValueModel, mockData: PlaybookRunPropertyValue) => { attributeValue._raw.id = mockData.id; - attributeValue.attributeId = mockData.attribute_id; - attributeValue.runId = mockData.run_id; + attributeValue.attributeId = mockData.field_id; + attributeValue.runId = mockData.target_id; attributeValue.value = mockData.value; + attributeValue.updateAt = mockData.update_at; }; -describe('PlaybookRunAttributeValueModel', () => { +describe('PlaybookRunPropertyValueModel', () => { let operator: ServerDataOperator; - let playbook_run_attribute_value: PlaybookRunAttributeValueModel; + let playbook_run_attribute_value: PlaybookRunPropertyValueModel; beforeEach(async () => { await DatabaseManager.init([SERVER_URL]); @@ -75,8 +80,8 @@ describe('PlaybookRunAttributeValueModel', () => { await database.write(async () => { await database.get(PLAYBOOK_RUN).create((run) => applyRunData(run, mockRun)); - await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attr) => applyAttributeData(attr, mockAttribute)); - playbook_run_attribute_value = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attrVal) => applyAttributeValueData(attrVal, mockAttributeValue)); + await database.get(PLAYBOOK_RUN_ATTRIBUTE).create((attr) => applyAttributeData(attr, mockAttribute)); + playbook_run_attribute_value = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attrVal) => applyAttributeValueData(attrVal, mockAttributeValue)); }); }); @@ -90,19 +95,20 @@ describe('PlaybookRunAttributeValueModel', () => { expect(playbook_run_attribute_value.attributeId).toBe('test-attribute_0'); expect(playbook_run_attribute_value.runId).toBe('playbook_run_0'); expect(playbook_run_attribute_value.value).toBe('Value 1'); + expect(playbook_run_attribute_value.updateAt).toBeGreaterThan(0); }); it('=> should have the correct table name', () => { - expect(PlaybookRunAttributeValueModel.table).toBe(PLAYBOOK_RUN_ATTRIBUTE_VALUE); + expect(PlaybookRunPropertyValueModel.table).toBe(PLAYBOOK_RUN_ATTRIBUTE_VALUE); }); it('=> should have correct associations defined', () => { - expect(PlaybookRunAttributeValueModel.associations).toBeDefined(); - expect(PlaybookRunAttributeValueModel.associations[PLAYBOOK_RUN_ATTRIBUTE]).toEqual({ + expect(PlaybookRunPropertyValueModel.associations).toBeDefined(); + expect(PlaybookRunPropertyValueModel.associations[PLAYBOOK_RUN_ATTRIBUTE]).toEqual({ type: 'belongs_to', key: 'attribute_id', }); - expect(PlaybookRunAttributeValueModel.associations[PLAYBOOK_RUN]).toEqual({ + expect(PlaybookRunPropertyValueModel.associations[PLAYBOOK_RUN]).toEqual({ type: 'belongs_to', key: 'run_id', }); @@ -132,7 +138,7 @@ describe('PlaybookRunAttributeValueModel', () => { const {database} = operator; await database.write(async () => { - await playbook_run_attribute_value.update((attributeValue: PlaybookRunAttributeValueModel) => { + await playbook_run_attribute_value.update((attributeValue: PlaybookRunPropertyValueModel) => { attributeValue.value = 'Updated Test Value'; }); }); @@ -140,13 +146,26 @@ describe('PlaybookRunAttributeValueModel', () => { expect(playbook_run_attribute_value.value).toBe('Updated Test Value'); }); + it('=> should allow updating updateAt timestamp', async () => { + const {database} = operator; + const newTimestamp = Date.now() + 10000; + + await database.write(async () => { + await playbook_run_attribute_value.update((attributeValue: PlaybookRunPropertyValueModel) => { + attributeValue.updateAt = newTimestamp; + }); + }); + + expect(playbook_run_attribute_value.updateAt).toBe(newTimestamp); + }); + it('=> should handle empty value', async () => { - let attributeValueWithEmptyValue: PlaybookRunAttributeValueModel; + let attributeValueWithEmptyValue: PlaybookRunPropertyValueModel; const {database} = operator; await database.write(async () => { const mockData = TestHelper.createPlaybookRunAttributeValue('test-attribute_0', 'playbook_run_0', 1); - attributeValueWithEmptyValue = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunAttributeValueModel) => { + attributeValueWithEmptyValue = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunPropertyValueModel) => { applyAttributeValueData(attributeValue, mockData); attributeValue.value = ''; }); @@ -159,12 +178,12 @@ describe('PlaybookRunAttributeValueModel', () => { it('=> should handle long text values', async () => { const longValue = 'This is a very long text value that might be used to store detailed information about the playbook run attribute. It could contain multiple sentences and various types of content.'; - let attributeValueWithLongText: PlaybookRunAttributeValueModel; + let attributeValueWithLongText: PlaybookRunPropertyValueModel; const {database} = operator; await database.write(async () => { const mockData = TestHelper.createPlaybookRunAttributeValue('test-attribute_0', 'playbook_run_0', 2); - attributeValueWithLongText = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunAttributeValueModel) => { + attributeValueWithLongText = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunPropertyValueModel) => { applyAttributeValueData(attributeValue, mockData); attributeValue.value = longValue; }); diff --git a/app/products/playbooks/database/models/playbook_run_attribute_value.ts b/app/products/playbooks/database/models/playbook_run_attribute_value.ts index f10a87fbb..637d9d028 100644 --- a/app/products/playbooks/database/models/playbook_run_attribute_value.ts +++ b/app/products/playbooks/database/models/playbook_run_attribute_value.ts @@ -7,16 +7,16 @@ import Model, {type Associations} from '@nozbe/watermelondb/Model'; import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import type PlaybookRunModel from './playbook_run'; -import type PlaybookRunAttributeModel from './playbook_run_attribute'; +import type PlaybookRunPropertyFieldModel from './playbook_run_attribute'; import type {Relation} from '@nozbe/watermelondb'; import type PlaybookRunAttributeValueModelInterface from '@playbooks/types/database/models/playbook_run_attribute_value'; const {PLAYBOOK_RUN, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; /** - * The PlaybookRunAttributeValue model represents a playbook run attribute value in the Mattermost app. + * The PlaybookRunPropertyValue model represents a playbook run property value in the Mattermost app. */ -export default class PlaybookRunAttributeValueModel extends Model implements PlaybookRunAttributeValueModelInterface { +export default class PlaybookRunPropertyValueModel extends Model implements PlaybookRunAttributeValueModelInterface { /** table (name) : PlaybookRunAttributeValue */ static table = PLAYBOOK_RUN_ATTRIBUTE_VALUE; @@ -39,8 +39,11 @@ export default class PlaybookRunAttributeValueModel extends Model implements Pla /** value : The value of the attribute */ @field('value') value!: string; + /** updateAt : The timestamp when this attribute value was last updated */ + @field('update_at') updateAt!: number; + /** attribute : The attribute this attribute value belongs to */ - @immutableRelation(PLAYBOOK_RUN_ATTRIBUTE, 'attribute_id') attribute!: Relation; + @immutableRelation(PLAYBOOK_RUN_ATTRIBUTE, 'attribute_id') attribute!: Relation; /** run : The playbook run this attribute belongs to */ @immutableRelation(PLAYBOOK_RUN, 'run_id') run!: Relation; diff --git a/app/products/playbooks/database/operators/handlers/index.test.ts b/app/products/playbooks/database/operators/handlers/index.test.ts index 30fd42d1a..f4e2a67d3 100644 --- a/app/products/playbooks/database/operators/handlers/index.test.ts +++ b/app/products/playbooks/database/operators/handlers/index.test.ts @@ -554,20 +554,20 @@ describe('PlaybookHandler', () => { }); }); - describe('handlePlaybookRunAttribute', () => { - it('should return an empty array if attributes is undefined or empty', async () => { + describe('handlePlaybookRunPropertyField', () => { + it('should return an empty array if propertyFields is undefined or empty', async () => { const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); - let result = await operator.handlePlaybookRunAttribute({ - attributes: undefined, + let result = await operator.handlePlaybookRunPropertyField({ + propertyFields: undefined, prepareRecordsOnly: true, }); expect(result).toEqual([]); expect(spyOnPrepareRecords).not.toHaveBeenCalled(); - result = await operator.handlePlaybookRunAttribute({ - attributes: [], + result = await operator.handlePlaybookRunPropertyField({ + propertyFields: [], prepareRecordsOnly: true, }); @@ -575,54 +575,54 @@ describe('PlaybookHandler', () => { expect(spyOnPrepareRecords).not.toHaveBeenCalled(); }); - it('should process attributes correctly', async () => { - const mockAttributes = [ + it('should process property fields correctly', async () => { + const mockPropertyFields = [ TestHelper.createPlaybookRunAttribute('attribute_1', 0), TestHelper.createPlaybookRunAttribute('attribute_2', 1), - ].map((attribute, index) => ({ - ...attribute, - run_id: 'playbook_run_1', + ].map((propertyField, index) => ({ + ...propertyField, + target_id: 'playbook_run_1', order: index, })); const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); - const result = await operator.handlePlaybookRunAttribute({ - attributes: mockAttributes, + const result = await operator.handlePlaybookRunPropertyField({ + propertyFields: mockPropertyFields, prepareRecordsOnly: false, }); expect(result).toBeDefined(); - expect(result.length).toBe(mockAttributes.length); + expect(result.length).toBe(mockPropertyFields.length); expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); const {database} = operator; const attributeRecords = await database.get(PLAYBOOK_RUN_ATTRIBUTE).query().fetch(); - expect(attributeRecords.length).toBe(mockAttributes.length); + expect(attributeRecords.length).toBe(mockPropertyFields.length); }); it('should only prepare records when prepareRecordsOnly is true', async () => { - const mockAttributes = [ + const mockPropertyFields = [ TestHelper.createPlaybookRunAttribute('attribute_3', 2), - ].map((attribute, index) => ({ - ...attribute, - run_id: 'playbook_run_1', + ].map((propertyField, index) => ({ + ...propertyField, + target_id: 'playbook_run_1', order: index, })); const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); - const result = await operator.handlePlaybookRunAttribute({ - attributes: mockAttributes, + const result = await operator.handlePlaybookRunPropertyField({ + propertyFields: mockPropertyFields, prepareRecordsOnly: true, }); expect(result).toBeDefined(); - expect(result.length).toBe(mockAttributes.length); + expect(result.length).toBe(mockPropertyFields.length); expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); expect(spyOnBatchOperation).not.toHaveBeenCalled(); @@ -633,20 +633,20 @@ describe('PlaybookHandler', () => { }); }); - describe('handlePlaybookRunAttributeValue', () => { - it('should return an empty array if attributeValues is undefined or empty', async () => { + describe('handlePlaybookRunPropertyValue', () => { + it('should return an empty array if propertyValues is undefined or empty', async () => { const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); - let result = await operator.handlePlaybookRunAttributeValue({ - attributeValues: undefined, + let result = await operator.handlePlaybookRunPropertyValue({ + propertyValues: undefined, prepareRecordsOnly: true, }); expect(result).toEqual([]); expect(spyOnPrepareRecords).not.toHaveBeenCalled(); - result = await operator.handlePlaybookRunAttributeValue({ - attributeValues: [], + result = await operator.handlePlaybookRunPropertyValue({ + propertyValues: [], prepareRecordsOnly: true, }); @@ -654,8 +654,8 @@ describe('PlaybookHandler', () => { expect(spyOnPrepareRecords).not.toHaveBeenCalled(); }); - it('should process attribute values correctly', async () => { - const mockAttributeValues = [ + it('should process property values correctly', async () => { + const mockPropertyValues = [ TestHelper.createPlaybookRunAttributeValue('attribute_1', 'playbook_run_1', 0), TestHelper.createPlaybookRunAttributeValue('attribute_2', 'playbook_run_2', 1), ]; @@ -663,37 +663,37 @@ describe('PlaybookHandler', () => { const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); - const result = await operator.handlePlaybookRunAttributeValue({ - attributeValues: mockAttributeValues, + const result = await operator.handlePlaybookRunPropertyValue({ + propertyValues: mockPropertyValues, prepareRecordsOnly: false, }); expect(result).toBeDefined(); - expect(result.length).toBe(mockAttributeValues.length); + expect(result.length).toBe(mockPropertyValues.length); expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); const {database} = operator; const attributeValueRecords = await database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch(); - expect(attributeValueRecords.length).toBe(mockAttributeValues.length); + expect(attributeValueRecords.length).toBe(mockPropertyValues.length); }); it('should only prepare records when prepareRecordsOnly is true', async () => { - const mockAttributeValues = [ + const mockPropertyValues = [ TestHelper.createPlaybookRunAttributeValue('attribute_3', 'playbook_run_3', 2), ]; const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords'); const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords'); - const result = await operator.handlePlaybookRunAttributeValue({ - attributeValues: mockAttributeValues, + const result = await operator.handlePlaybookRunPropertyValue({ + propertyValues: mockPropertyValues, prepareRecordsOnly: true, }); expect(result).toBeDefined(); - expect(result.length).toBe(mockAttributeValues.length); + expect(result.length).toBe(mockPropertyValues.length); expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); expect(spyOnBatchOperation).not.toHaveBeenCalled(); diff --git a/app/products/playbooks/database/operators/handlers/index.ts b/app/products/playbooks/database/operators/handlers/index.ts index bde428c5c..68b67b8ca 100644 --- a/app/products/playbooks/database/operators/handlers/index.ts +++ b/app/products/playbooks/database/operators/handlers/index.ts @@ -8,7 +8,7 @@ import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {logWarning} from '@utils/log'; import {shouldHandlePlaybookChecklistItemRecord, shouldHandlePlaybookChecklistRecord, shouldUpdatePlaybookRunRecord} from '../comparators'; -import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord, transformPlaybookRunAttributeRecord, transformPlaybookRunAttributeValueRecord} from '../transformers'; +import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord, transformPlaybookRunPropertyFieldRecord, transformPlaybookRunPropertyValueRecord} from '../transformers'; import type ServerDataOperatorBase from '@database/operator/server_data_operator/handlers'; import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist'; @@ -34,14 +34,14 @@ type HandlePlaybookChecklistItemArgs = { items?: PartialChecklistItem[]; } -type HandlePlaybookRunAttributeArgs = { +type HandlePlaybookRunPropertyFieldArgs = { prepareRecordsOnly: boolean; - attributes?: PartialPlaybookRunAttribute[]; + propertyFields?: PartialPlaybookRunPropertyField[]; } -type HandlePlaybookRunAttributeValueArgs = { +type HandlePlaybookRunPropertyValueArgs = { prepareRecordsOnly: boolean; - attributeValues?: PartialPlaybookRunAttributeValue[]; + propertyValues?: PartialPlaybookRunPropertyValue[]; } const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; @@ -50,8 +50,8 @@ export interface PlaybookHandlerMix { handlePlaybookRun: (args: HandlePlaybookRunArgs) => Promise; handlePlaybookChecklist: (args: HandlePlaybookChecklistArgs) => Promise; handlePlaybookChecklistItem: (args: HandlePlaybookChecklistItemArgs) => Promise; - handlePlaybookRunAttribute: (args: HandlePlaybookRunAttributeArgs) => Promise; - handlePlaybookRunAttributeValue: (args: HandlePlaybookRunAttributeValueArgs) => Promise; + handlePlaybookRunPropertyField: (args: HandlePlaybookRunPropertyFieldArgs) => Promise; + handlePlaybookRunPropertyValue: (args: HandlePlaybookRunPropertyValueArgs) => Promise; } const PlaybookHandler = >(superclass: TBase) => class extends superclass { @@ -157,6 +157,37 @@ const PlaybookHandler = >(supe const childRecords = await this.handlePlaybookChecklist({checklists, prepareRecordsOnly: true, processChildren}); batchRecords.push(...childRecords); + + // Process property_fields and property_values + const propertyFields = uniqueRaws.reduce((res, raw) => { + if (raw.property_fields?.length) { + res.push(...raw.property_fields); + } + return res; + }, []); + + const propertyValues = uniqueRaws.reduce((res, raw) => { + if (raw.property_values?.length) { + res.push(...raw.property_values); + } + return res; + }, []); + + if (propertyFields.length) { + const propertyFieldRecords = await this.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: true, + }); + batchRecords.push(...propertyFieldRecords); + } + + if (propertyValues.length) { + const propertyValueRecords = await this.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: true, + }); + batchRecords.push(...propertyValueRecords); + } } if (batchRecords.length && !prepareRecordsOnly) { @@ -288,55 +319,55 @@ const PlaybookHandler = >(supe }; /** - * Handles the playbook run attribute records. - * @param {HandlePlaybookRunAttributeArgs} args - The arguments for handling playbook run attribute records. + * Handles the playbook run property field records. + * @param {HandlePlaybookRunPropertyFieldArgs} args - The arguments for handling playbook run property field records. * @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them. - * @param {PlaybookRunAttribute[]} [args.attributes] - The playbook run attribute records to handle. - * @returns {Promise} - A promise that resolves to an array of handled playbook run attribute records. + * @param {PlaybookRunPropertyField[]} [args.propertyFields] - The playbook run property field records to handle. + * @returns {Promise} - A promise that resolves to an array of handled playbook run property field records. */ - handlePlaybookRunAttribute = async ({attributes, prepareRecordsOnly = true}: HandlePlaybookRunAttributeArgs): Promise => { - if (!attributes?.length) { + handlePlaybookRunPropertyField = async ({propertyFields, prepareRecordsOnly = true}: HandlePlaybookRunPropertyFieldArgs): Promise => { + if (!propertyFields?.length) { logWarning( - 'An empty or undefined "attributes" array has been passed to the handlePlaybookRunAttribute method', + 'An empty or undefined "propertyFields" array has been passed to the handlePlaybookRunPropertyField method', ); return []; } - const createOrUpdateRawValues = getUniqueRawsBy({raws: attributes, key: 'id'}); + const createOrUpdateRawValues = getUniqueRawsBy({raws: propertyFields, key: 'id'}); return this.handleRecords({ fieldName: 'id', - transformer: transformPlaybookRunAttributeRecord, + transformer: transformPlaybookRunPropertyFieldRecord, createOrUpdateRawValues, tableName: PLAYBOOK_RUN_ATTRIBUTE, prepareRecordsOnly, - }, 'handlePlaybookRunAttribute'); + }, 'handlePlaybookRunPropertyField'); }; /** - * Handles the playbook run attribute value records. - * @param {HandlePlaybookRunAttributeValueArgs} args - The arguments for handling playbook run attribute value records. + * Handles the playbook run property value records. + * @param {HandlePlaybookRunPropertyValueArgs} args - The arguments for handling playbook run property value records. * @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them. - * @param {PlaybookRunAttributeValue[]} [args.attributeValues] - The playbook run attribute value records to handle. - * @returns {Promise} - A promise that resolves to an array of handled playbook run attribute value records. + * @param {PlaybookRunPropertyValue[]} [args.propertyValues] - The playbook run property value records to handle. + * @returns {Promise} - A promise that resolves to an array of handled playbook run property value records. */ - handlePlaybookRunAttributeValue = async ({attributeValues, prepareRecordsOnly = true}: HandlePlaybookRunAttributeValueArgs): Promise => { - if (!attributeValues?.length) { + handlePlaybookRunPropertyValue = async ({propertyValues, prepareRecordsOnly = true}: HandlePlaybookRunPropertyValueArgs): Promise => { + if (!propertyValues?.length) { logWarning( - 'An empty or undefined "attributeValues" array has been passed to the handlePlaybookRunAttributeValue method', + 'An empty or undefined "propertyValues" array has been passed to the handlePlaybookRunPropertyValue method', ); return []; } - const createOrUpdateRawValues = getUniqueRawsBy({raws: attributeValues, key: 'id'}); + const createOrUpdateRawValues = getUniqueRawsBy({raws: propertyValues, key: 'id'}); return this.handleRecords({ fieldName: 'id', - transformer: transformPlaybookRunAttributeValueRecord, + transformer: transformPlaybookRunPropertyValueRecord, createOrUpdateRawValues, tableName: PLAYBOOK_RUN_ATTRIBUTE_VALUE, prepareRecordsOnly, - }, 'handlePlaybookRunAttributeValue'); + }, 'handlePlaybookRunPropertyValue'); }; }; diff --git a/app/products/playbooks/database/operators/transformers/index.test.ts b/app/products/playbooks/database/operators/transformers/index.test.ts index 01bb62340..8adc0fafd 100644 --- a/app/products/playbooks/database/operators/transformers/index.test.ts +++ b/app/products/playbooks/database/operators/transformers/index.test.ts @@ -9,12 +9,12 @@ import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run'; import {PlaybookRunModel} from '@playbooks/database/models'; -import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord, transformPlaybookRunAttributeRecord, transformPlaybookRunAttributeValueRecord} from '.'; +import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord, transformPlaybookRunPropertyFieldRecord, transformPlaybookRunPropertyValueRecord} from '.'; import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist'; import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item'; -import type PlaybookRunAttributeModel from '@playbooks/types/database/models/playbook_run_attribute'; -import type PlaybookRunAttributeValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; +import type PlaybookRunPropertyFieldModel from '@playbooks/types/database/models/playbook_run_attribute'; +import type PlaybookRunPropertyValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; const {PLAYBOOK_RUN, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; @@ -978,13 +978,13 @@ describe('*** PLAYBOOK_CHECKLIST_ITEM Prepare Records Test ***', () => { }); describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { - it('=> transformPlaybookRunAttributeRecord: should return a record of type PlaybookRunAttribute for CREATE action', async () => { + it('=> transformPlaybookRunPropertyFieldRecord: should return a record of type PlaybookRunAttribute for CREATE action', async () => { expect.assertions(3); const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); expect(database).toBeTruthy(); - const preparedRecord = await transformPlaybookRunAttributeRecord({ + const preparedRecord = await transformPlaybookRunPropertyFieldRecord({ action: OperationType.CREATE, database: database!, value: { @@ -1008,15 +1008,15 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE); }); - it('=> transformPlaybookRunAttributeRecord: should return a record of type PlaybookRunAttribute for UPDATE action', async () => { + it('=> transformPlaybookRunPropertyFieldRecord: should return a record of type PlaybookRunAttribute for UPDATE action', async () => { expect.assertions(4); const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); expect(database).toBeTruthy(); - let existingRecord: PlaybookRunAttributeModel | undefined; + let existingRecord: PlaybookRunPropertyFieldModel | undefined; await database!.write(async () => { - existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { record._raw.id = 'attribute_2'; record.groupId = 'group_2'; record.name = 'Existing Attribute'; @@ -1030,7 +1030,7 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { }); }); - const preparedRecord = await transformPlaybookRunAttributeRecord({ + const preparedRecord = await transformPlaybookRunPropertyFieldRecord({ action: OperationType.UPDATE, database: database!, value: { @@ -1059,14 +1059,14 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE); }); - it('=> transformPlaybookRunAttributeRecord: should throw an error for non-create action without an existing record', async () => { + it('=> transformPlaybookRunPropertyFieldRecord: should throw an error for non-create action without an existing record', async () => { expect.assertions(2); const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); expect(database).toBeTruthy(); await expect( - transformPlaybookRunAttributeRecord({ + transformPlaybookRunPropertyFieldRecord({ action: OperationType.UPDATE, database: database!, value: { @@ -1087,13 +1087,13 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { ).rejects.toThrow('Record not found for non create action'); }); - it('=> transformPlaybookRunAttributeRecord: should keep most of the data if the partial attribute is empty', async () => { + it('=> transformPlaybookRunPropertyFieldRecord: should keep most of the data if the partial attribute is empty', async () => { const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); expect(database).toBeTruthy(); - let existingRecord: PlaybookRunAttributeModel | undefined; + let existingRecord: PlaybookRunPropertyFieldModel | undefined; await database!.write(async () => { - existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { record._raw.id = 'attribute_2'; record.groupId = 'group_2'; record.name = 'Existing Attribute'; @@ -1107,14 +1107,21 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { }); }); - const preparedRecord = await transformPlaybookRunAttributeRecord({ + const preparedRecord = await transformPlaybookRunPropertyFieldRecord({ action: OperationType.UPDATE, database: database!, value: { record: existingRecord, raw: { id: 'attribute_2', + group_id: 'group_2', + name: 'Existing Attribute', + type: 'text', + target_id: 'target_2', + target_type: 'playbook_run', + create_at: 1620000000000, update_at: 1620000004000, + delete_at: 0, }, }, }); @@ -1135,11 +1142,50 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { expect(preparedRecord.updateAt).toBe(1620000004000); }); + it('=> transformPlaybookRunPropertyFieldRecord: should serialize attrs object to string', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const attrsObject = { + options: [{id: 'opt1', name: 'Option 1'}, {id: 'opt2', name: 'Option 2'}], + parent_id: 'parent123', + sort_order: 5, + value_type: 'select', + visibility: 'always' as const, + }; + + const preparedRecord = await transformPlaybookRunPropertyFieldRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'attribute_with_object', + group_id: 'group_1', + name: 'Select Attribute', + type: 'select', + target_id: 'target_1', + target_type: 'playbook_run', + create_at: 1620000000000, + update_at: 1620000001000, + delete_at: 0, + attrs: attrsObject, // Pass as object (as API sends it) + }, + }, + }); + + expect(preparedRecord).toBeTruthy(); + + // Verify attrs was serialized to string + expect(preparedRecord!.attrs).toBe(JSON.stringify(attrsObject)); + }); it('=> transformPlaybookRunAttributeRecord: should handle CREATE with missing raw.id', async () => { const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); - const preparedRecord = await transformPlaybookRunAttributeRecord({ + const preparedRecord = await transformPlaybookRunPropertyFieldRecord({ action: OperationType.CREATE, database: database!, value: { @@ -1158,9 +1204,9 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { it('=> transformPlaybookRunAttributeRecord: should handle completely empty raw object', async () => { const database = await createTestConnection({databaseName: 'playbook_run_attribute_prepare_records', setActive: true}); - let existingRecord: PlaybookRunAttributeModel | undefined; + let existingRecord: PlaybookRunPropertyFieldModel | undefined; await database!.write(async () => { - existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE).create((record) => { record._raw.id = 'attribute_empty_raw'; record.groupId = 'group_1'; record.name = 'Existing Attribute'; @@ -1174,7 +1220,7 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { }); }); - const preparedRecord = await transformPlaybookRunAttributeRecord({ + const preparedRecord = await transformPlaybookRunPropertyFieldRecord({ action: OperationType.UPDATE, database: database!, value: { @@ -1193,21 +1239,22 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE Prepare Records Test ***', () => { }); describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { - it('=> transformPlaybookRunAttributeValueRecord: should return a record of type PlaybookRunAttributeValue for CREATE action', async () => { + it('=> transformPlaybookRunPropertyValueRecord: should return a record of type PlaybookRunAttributeValue for CREATE action', async () => { expect.assertions(3); const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); expect(database).toBeTruthy(); - const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + const preparedRecord = await transformPlaybookRunPropertyValueRecord({ action: OperationType.CREATE, database: database!, value: { record: undefined, raw: { id: 'attribute_value_1', - attribute_id: 'attribute_1', - run_id: 'playbook_run_1', + field_id: 'attribute_1', // API field name + target_id: 'playbook_run_1', // API field name + update_at: 1620000001000, value: 'Test Value', }, }, @@ -1217,31 +1264,33 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE_VALUE); }); - it('=> transformPlaybookRunAttributeValueRecord: should return a record of type PlaybookRunAttributeValue for UPDATE action', async () => { + it('=> transformPlaybookRunPropertyValueRecord: should return a record of type PlaybookRunAttributeValue for UPDATE action', async () => { expect.assertions(4); const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); expect(database).toBeTruthy(); - let existingRecord: PlaybookRunAttributeValueModel | undefined; + let existingRecord: PlaybookRunPropertyValueModel | undefined; await database!.write(async () => { - existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { record._raw.id = 'attribute_value_2'; record.attributeId = 'attribute_2'; record.runId = 'playbook_run_2'; record.value = 'Existing Value'; + record.updateAt = 1620000001000; }); }); - const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + const preparedRecord = await transformPlaybookRunPropertyValueRecord({ action: OperationType.UPDATE, database: database!, value: { record: existingRecord, raw: { id: 'attribute_value_2', - attribute_id: 'attribute_2', - run_id: 'playbook_run_2', + field_id: 'attribute_2', // API field name + target_id: 'playbook_run_2', // API field name + update_at: 1620000002000, value: 'Updated Value', }, }, @@ -1256,22 +1305,23 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN_ATTRIBUTE_VALUE); }); - it('=> transformPlaybookRunAttributeValueRecord: should throw an error for non-create action without an existing record', async () => { + it('=> transformPlaybookRunPropertyValueRecord: should throw an error for non-create action without an existing record', async () => { expect.assertions(2); const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); expect(database).toBeTruthy(); await expect( - transformPlaybookRunAttributeValueRecord({ + transformPlaybookRunPropertyValueRecord({ action: OperationType.UPDATE, database: database!, value: { record: undefined, raw: { id: 'attribute_value_3', - attribute_id: 'attribute_3', - run_id: 'playbook_run_3', + field_id: 'attribute_3', // API field name + target_id: 'playbook_run_3', // API field name + update_at: 1620000001000, value: 'Invalid Value', }, }, @@ -1279,29 +1329,32 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { ).rejects.toThrow('Record not found for non create action'); }); - it('=> transformPlaybookRunAttributeValueRecord: should keep most of the data if the partial attribute value is empty', async () => { + it('=> transformPlaybookRunPropertyValueRecord: should keep most of the data if the partial attribute value is empty', async () => { const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); expect(database).toBeTruthy(); - let existingRecord: PlaybookRunAttributeValueModel | undefined; + let existingRecord: PlaybookRunPropertyValueModel | undefined; await database!.write(async () => { - existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { record._raw.id = 'attribute_value_2'; record.attributeId = 'attribute_2'; record.runId = 'playbook_run_2'; record.value = 'Existing Value'; + record.updateAt = 1620000000000; }); }); - const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + const preparedRecord = await transformPlaybookRunPropertyValueRecord({ action: OperationType.UPDATE, database: database!, value: { record: existingRecord, raw: { id: 'attribute_value_2', - run_id: 'playbook_run_2', - attribute_id: 'attribute_2', + field_id: 'attribute_2', // API field name + target_id: 'playbook_run_2', // API field name + update_at: 1620000001000, + value: 'Existing Value', }, }, }); @@ -1314,12 +1367,95 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { expect(preparedRecord.attributeId).toBe('attribute_2'); expect(preparedRecord.runId).toBe('playbook_run_2'); expect(preparedRecord.value).toBe('Existing Value'); + expect(preparedRecord.updateAt).toBe(1620000001000); + }); + + it('=> transformPlaybookRunPropertyValueRecord: should update to empty array when multiselect value is cleared', async () => { + const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_empty_multiselect', setActive: true}); + expect(database).toBeTruthy(); + + let existingRecord: PlaybookRunPropertyValueModel | undefined; + await database!.write(async () => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { + record._raw.id = 'attribute_value_3'; + record.attributeId = 'attribute_3'; + record.runId = 'playbook_run_3'; + record.value = '["option1","option2"]'; + record.updateAt = 1620000000000; + }); + }); + + const preparedRecord = await transformPlaybookRunPropertyValueRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: existingRecord, + raw: { + id: 'attribute_value_3', + field_id: 'attribute_3', + target_id: 'playbook_run_3', + update_at: 1620000001000, + value: [] as any, // Server sends arrays for multiselect fields + }, + }, + }); + + await database?.write(async () => { + await database?.batch(preparedRecord); + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord.attributeId).toBe('attribute_3'); + expect(preparedRecord.runId).toBe('playbook_run_3'); + expect(preparedRecord.value).toBe('[]'); + expect(preparedRecord.updateAt).toBe(1620000001000); + }); + + it('=> transformPlaybookRunPropertyValueRecord: should update to empty string when value is null', async () => { + const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_null', setActive: true}); + expect(database).toBeTruthy(); + + let existingRecord: PlaybookRunPropertyValueModel | undefined; + await database!.write(async () => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { + record._raw.id = 'attribute_value_4'; + record.attributeId = 'attribute_4'; + record.runId = 'playbook_run_4'; + record.value = 'Some Value'; + record.updateAt = 1620000000000; + }); + }); + + const preparedRecord = await transformPlaybookRunPropertyValueRecord({ + action: OperationType.UPDATE, + database: database!, + value: { + record: existingRecord, + raw: { + id: 'attribute_value_4', + field_id: 'attribute_4', + target_id: 'playbook_run_4', + update_at: 1620000001000, + value: null as any, // Server can send null to clear values + }, + }, + }); + + await database?.write(async () => { + await database?.batch(preparedRecord); + }); + + expect(preparedRecord).toBeTruthy(); + expect(preparedRecord.attributeId).toBe('attribute_4'); + expect(preparedRecord.runId).toBe('playbook_run_4'); + expect(preparedRecord.value).toBe(''); + expect(preparedRecord.updateAt).toBe(1620000001000); }); it('=> transformPlaybookRunAttributeValueRecord: should handle CREATE with missing raw.id', async () => { const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); - const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + const preparedRecord = await transformPlaybookRunPropertyValueRecord({ action: OperationType.CREATE, database: database!, value: { @@ -1340,9 +1476,9 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { const database = await createTestConnection({databaseName: 'playbook_run_attribute_value_prepare_records', setActive: true}); expect(database).toBeTruthy(); - let existingRecord: PlaybookRunAttributeValueModel | undefined; + let existingRecord: PlaybookRunPropertyValueModel | undefined; await database!.write(async () => { - existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { + existingRecord = await database!.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => { record._raw.id = 'attribute_value_empty_raw'; record.attributeId = 'attribute_1'; record.runId = 'playbook_run_1'; @@ -1350,7 +1486,7 @@ describe('*** PLAYBOOK_RUN_ATTRIBUTE_VALUE Prepare Records Test ***', () => { }); }); - const preparedRecord = await transformPlaybookRunAttributeValueRecord({ + const preparedRecord = await transformPlaybookRunPropertyValueRecord({ action: OperationType.UPDATE, database: database!, value: { diff --git a/app/products/playbooks/database/operators/transformers/index.ts b/app/products/playbooks/database/operators/transformers/index.ts index f96bec618..b8866f1c5 100644 --- a/app/products/playbooks/database/operators/transformers/index.ts +++ b/app/products/playbooks/database/operators/transformers/index.ts @@ -5,12 +5,13 @@ import {OperationType} from '@constants/database'; import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index'; import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run'; +import {logError} from '@utils/log'; import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist'; import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item'; import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run'; -import type PlaybookRunAttributeModel from '@playbooks/types/database/models/playbook_run_attribute'; -import type PlaybookRunAttributeValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; +import type PlaybookRunPropertyFieldModel from '@playbooks/types/database/models/playbook_run_attribute'; +import type PlaybookRunPropertyValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; import type{TransformerArgs} from '@typings/database/database'; const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; @@ -146,13 +147,13 @@ export const transformPlaybookChecklistItemRecord = ({action, database, value}: }; /** - * transformPlaybookRunAttributeRecord: Prepares a record of the SERVER database 'PlaybookRunAttribute' table for update or create actions. + * transformPlaybookRunPropertyFieldRecord: Prepares a record of the SERVER database 'PlaybookRunAttribute' table for update or create actions. * @param {TransformerArgs} transformerArgs * @param {Database} transformerArgs.database * @param {RecordPair} transformerArgs.value * @returns {Promise} */ -export const transformPlaybookRunAttributeRecord = ({action, database, value}: TransformerArgs): Promise => { +export const transformPlaybookRunPropertyFieldRecord = ({action, database, value}: TransformerArgs): Promise => { const raw = value.raw; const record = value.record; const isCreateAction = action === OperationType.CREATE; @@ -160,7 +161,7 @@ export const transformPlaybookRunAttributeRecord = ({action, database, value}: T return Promise.reject(new Error('Record not found for non create action')); } - const fieldsMapper = (attribute: PlaybookRunAttributeModel) => { + const fieldsMapper = (attribute: PlaybookRunPropertyFieldModel) => { attribute._raw.id = isCreateAction ? (raw?.id ?? attribute.id) : record!.id; attribute.groupId = raw.group_id ?? record?.groupId ?? ''; attribute.name = raw.name ?? record?.name ?? ''; @@ -170,7 +171,13 @@ export const transformPlaybookRunAttributeRecord = ({action, database, value}: T attribute.createAt = raw.create_at ?? record?.createAt ?? 0; attribute.updateAt = raw.update_at ?? record?.updateAt ?? 0; attribute.deleteAt = raw.delete_at ?? record?.deleteAt ?? 0; - attribute.attrs = raw.attrs ?? record?.attrs ?? ''; + + // API sends attrs as object, but DB expects string - serialize it + if (raw.attrs) { + attribute.attrs = typeof raw.attrs === 'string' ? raw.attrs : JSON.stringify(raw.attrs); + } else { + attribute.attrs = record?.attrs ?? ''; + } }; return prepareBaseRecord({ @@ -183,13 +190,13 @@ export const transformPlaybookRunAttributeRecord = ({action, database, value}: T }; /** - * transformPlaybookRunAttributeValueRecord: Prepares a record of the SERVER database 'PlaybookRunAttributeValue' table for update or create actions. + * transformPlaybookRunPropertyValueRecord: Prepares a record of the SERVER database 'PlaybookRunAttributeValue' table for update or create actions. * @param {TransformerArgs} transformerArgs * @param {Database} transformerArgs.database * @param {RecordPair} transformerArgs.value * @returns {Promise} */ -export const transformPlaybookRunAttributeValueRecord = ({action, database, value}: TransformerArgs): Promise => { +export const transformPlaybookRunPropertyValueRecord = ({action, database, value}: TransformerArgs): Promise => { const raw = value.raw; const record = value.record; const isCreateAction = action === OperationType.CREATE; @@ -197,11 +204,27 @@ export const transformPlaybookRunAttributeValueRecord = ({action, database, valu return Promise.reject(new Error('Record not found for non create action')); } - const fieldsMapper = (attribute: PlaybookRunAttributeValueModel) => { + const fieldsMapper = (attribute: PlaybookRunPropertyValueModel) => { attribute._raw.id = isCreateAction ? (raw?.id ?? attribute.id) : record!.id; - attribute.attributeId = raw.attribute_id ?? record?.attributeId ?? ''; - attribute.runId = raw.run_id ?? record?.runId ?? ''; - attribute.value = raw.value ?? record?.value ?? ''; + attribute.attributeId = raw.field_id ?? record?.attributeId ?? ''; // API field_id → DB attributeId + attribute.runId = raw.target_id ?? record?.runId ?? ''; // API target_id → DB runId + + // Handle value: convert array to JSON string if needed (for multiselect fields) + // Check if 'value' field is present in raw to distinguish between "not sent" and "sent as empty" + const hasValueField = raw && 'value' in raw; + let valueToStore = hasValueField ? (raw.value ?? '') : (record?.value ?? ''); + + if (Array.isArray(valueToStore)) { + try { + valueToStore = JSON.stringify(valueToStore); + } catch (e) { + // If JSON stringify fails, log the error and store empty string + logError('Failed to stringify value array in transformPlaybookRunPropertyValueRecord:', valueToStore); + valueToStore = ''; + } + } + attribute.value = valueToStore; + attribute.updateAt = raw.update_at ?? record?.updateAt ?? 0; }; return prepareBaseRecord({ diff --git a/app/products/playbooks/database/queries/property_fields.test.ts b/app/products/playbooks/database/queries/property_fields.test.ts new file mode 100644 index 000000000..bbb1eb785 --- /dev/null +++ b/app/products/playbooks/database/queries/property_fields.test.ts @@ -0,0 +1,672 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import TestHelper from '@test/test_helper'; + +import { + observePlaybookRunPropertyFields, + getPlaybookRunPropertyFieldById, + observePlaybookRunPropertyValues, + observePlaybookRunPropertyValue, + observePlaybookRunPropertyFieldsWithValues, +} from './property_fields'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +describe('Property Fields Queries', () => { + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init(['propertyFields.test.com']); + operator = DatabaseManager.serverDatabases['propertyFields.test.com']!.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase('propertyFields.test.com'); + }); + + describe('observePlaybookRunPropertyFields', () => { + it('should observe property fields for a run', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: 'field-1', + target_id: runId, + target_type: 'run', + name: 'Priority', + type: 'select', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: 'field-2', + target_id: runId, + target_type: 'run', + name: 'Description', + type: 'text', + delete_at: 0, + }), + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyFields(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const fields = subscriptionNext.mock.calls[0][0]; + expect(fields).toHaveLength(2); + expect(fields[0].name).toBe('Priority'); + expect(fields[1].name).toBe('Description'); + }); + + it('should filter out deleted property fields', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-2'; + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: 'field-1', + target_id: runId, + target_type: 'run', + name: 'Active Field', + type: 'text', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: 'field-2', + target_id: runId, + target_type: 'run', + name: 'Deleted Field', + type: 'text', + delete_at: 1620000000000, + }), + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyFields(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const fields = subscriptionNext.mock.calls[0][0]; + expect(fields).toHaveLength(1); + expect(fields[0].name).toBe('Active Field'); + }); + + it('should only return property fields for the specified run', async () => { + const subscriptionNext = jest.fn(); + const runId1 = 'run-id-1'; + const runId2 = 'run-id-2'; + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: 'field-1', + target_id: runId1, + target_type: 'run', + name: 'Field for Run 1', + type: 'text', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: 'field-2', + target_id: runId2, + target_type: 'run', + name: 'Field for Run 2', + type: 'text', + delete_at: 0, + }), + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyFields(operator.database, runId1); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const fields = subscriptionNext.mock.calls[0][0]; + expect(fields).toHaveLength(1); + expect(fields[0].name).toBe('Field for Run 1'); + }); + + it('should return empty array when no property fields exist', async () => { + const subscriptionNext = jest.fn(); + const runId = 'nonexistent-run'; + + const result = observePlaybookRunPropertyFields(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith([]); + }); + + it('should only return fields with target_type = run', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: 'field-1', + target_id: runId, + target_type: 'run', + name: 'Run Field', + type: 'text', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: 'field-2', + target_id: runId, + target_type: 'other', + name: 'Other Field', + type: 'text', + delete_at: 0, + }), + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyFields(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const fields = subscriptionNext.mock.calls[0][0]; + expect(fields).toHaveLength(1); + expect(fields[0].name).toBe('Run Field'); + }); + }); + + describe('getPlaybookRunPropertyFieldById', () => { + it('should return the property field if found', async () => { + const fieldId = 'field-1'; + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: fieldId, + target_id: 'run-id-1', + target_type: 'run', + name: 'Priority', + type: 'select', + delete_at: 0, + }), + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + const result = await getPlaybookRunPropertyFieldById(operator.database, fieldId); + + expect(result).toBeDefined(); + expect(result!.id).toBe(fieldId); + expect(result!.name).toBe('Priority'); + }); + + it('should return undefined if the property field is not found', async () => { + const result = await getPlaybookRunPropertyFieldById(operator.database, 'nonexistent-field'); + + expect(result).toBeUndefined(); + }); + }); + + describe('observePlaybookRunPropertyValues', () => { + it('should observe property values for a run', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const fieldId1 = 'field-1'; + const fieldId2 = 'field-2'; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue(fieldId1, runId, { + id: 'value-1', + value: 'high', + }), + TestHelper.fakePlaybookRunAttributeValue(fieldId2, runId, { + id: 'value-2', + value: 'Some text description', + }), + ]; + + await operator.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyValues(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const values = subscriptionNext.mock.calls[0][0]; + expect(values).toHaveLength(2); + const valueTexts = values.map((v: any) => v.value).sort(); + expect(valueTexts).toContain('high'); + expect(valueTexts).toContain('Some text description'); + }); + + it('should only return values for the specified run', async () => { + const subscriptionNext = jest.fn(); + const runId1 = 'run-id-1'; + const runId2 = 'run-id-2'; + const fieldId = 'field-1'; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue(fieldId, runId1, { + id: 'value-1', + value: 'Value for Run 1', + }), + TestHelper.fakePlaybookRunAttributeValue(fieldId, runId2, { + id: 'value-2', + value: 'Value for Run 2', + }), + ]; + + await operator.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyValues(operator.database, runId1); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const values = subscriptionNext.mock.calls[0][0]; + expect(values).toHaveLength(1); + expect(values[0].value).toBe('Value for Run 1'); + }); + + it('should return empty array when no property values exist', async () => { + const subscriptionNext = jest.fn(); + const runId = 'nonexistent-run'; + + const result = observePlaybookRunPropertyValues(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith([]); + }); + + it('should emit when a property value changes', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const fieldId = 'field-1'; + const valueId = 'value-1'; + + // Create initial value + const initialValue = TestHelper.fakePlaybookRunAttributeValue(fieldId, runId, { + id: valueId, + value: 'initial value', + }); + + await operator.handlePlaybookRunPropertyValue({ + propertyValues: [initialValue], + prepareRecordsOnly: false, + }); + + // Subscribe to observable + const result = observePlaybookRunPropertyValues(operator.database, runId); + const subscription = result.subscribe({next: subscriptionNext}); + + // Wait for initial emission + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(subscriptionNext).toHaveBeenCalled(); + const initialValues = subscriptionNext.mock.calls[0][0]; + expect(initialValues).toHaveLength(1); + expect(initialValues[0].value).toBe('initial value'); + + // Clear previous calls + subscriptionNext.mockClear(); + + // Update the value + const updatedValue = TestHelper.fakePlaybookRunAttributeValue(fieldId, runId, { + id: valueId, + value: 'updated value', + update_at: Date.now(), + }); + + await operator.handlePlaybookRunPropertyValue({ + propertyValues: [updatedValue], + prepareRecordsOnly: false, + }); + + // Wait for the observer to emit the change + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify observer emitted with updated value + expect(subscriptionNext).toHaveBeenCalled(); + const updatedValues = subscriptionNext.mock.calls[0][0]; + expect(updatedValues).toHaveLength(1); + expect(updatedValues[0].value).toBe('updated value'); + + subscription.unsubscribe(); + }); + }); + + describe('observePlaybookRunPropertyValue', () => { + it('should observe a specific property value by field ID and run ID', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const fieldId = 'field-1'; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue(fieldId, runId, { + id: 'value-1', + value: 'medium', + }), + ]; + + await operator.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyValue(operator.database, fieldId, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const value = subscriptionNext.mock.calls[0][0]; + expect(value).toBeDefined(); + expect(value!.value).toBe('medium'); + expect(value!.attributeId).toBe(fieldId); + expect(value!.runId).toBe(runId); + }); + + it('should return undefined if property value is not found', async () => { + const subscriptionNext = jest.fn(); + const result = observePlaybookRunPropertyValue(operator.database, 'nonexistent-field', 'nonexistent-run'); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(undefined); + }); + + it('should return undefined if field ID matches but run ID does not', async () => { + const subscriptionNext = jest.fn(); + const runId1 = 'run-id-1'; + const runId2 = 'run-id-2'; + const fieldId = 'field-1'; + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue(fieldId, runId1, { + id: 'value-1', + value: 'Value for Run 1', + }), + ]; + + await operator.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyValue(operator.database, fieldId, runId2); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(undefined); + }); + }); + + describe('observePlaybookRunPropertyFieldsWithValues', () => { + it('should join property fields with their values', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const fieldId1 = 'field-1'; + const fieldId2 = 'field-2'; + const fieldId3 = 'field-3'; + + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: fieldId1, + target_id: runId, + target_type: 'run', + name: 'Priority', + type: 'select', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: fieldId2, + target_id: runId, + target_type: 'run', + name: 'Description', + type: 'text', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: fieldId3, + target_id: runId, + target_type: 'run', + name: 'Empty Field', + type: 'text', + delete_at: 0, + }), + ]; + + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue(fieldId1, runId, { + id: 'value-1', + value: 'high', + }), + TestHelper.fakePlaybookRunAttributeValue(fieldId2, runId, { + id: 'value-2', + value: 'Some description text', + }), + + // fieldId3 has no value + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + await operator.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyFieldsWithValues(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const fieldsWithValues = subscriptionNext.mock.calls[0][0]; + expect(fieldsWithValues).toHaveLength(3); + + // Check field 1 with value + expect(fieldsWithValues[0].propertyField.id).toBe(fieldId1); + expect(fieldsWithValues[0].propertyField.name).toBe('Priority'); + expect(fieldsWithValues[0].value).toBeDefined(); + expect(fieldsWithValues[0].value!.value).toBe('high'); + + // Check field 2 with value + expect(fieldsWithValues[1].propertyField.id).toBe(fieldId2); + expect(fieldsWithValues[1].propertyField.name).toBe('Description'); + expect(fieldsWithValues[1].value).toBeDefined(); + expect(fieldsWithValues[1].value!.value).toBe('Some description text'); + + // Check field 3 without value + expect(fieldsWithValues[2].propertyField.id).toBe(fieldId3); + expect(fieldsWithValues[2].propertyField.name).toBe('Empty Field'); + expect(fieldsWithValues[2].value).toBeUndefined(); + }); + + it('should return empty array when no property fields exist', async () => { + const subscriptionNext = jest.fn(); + const runId = 'nonexistent-run'; + + const result = observePlaybookRunPropertyFieldsWithValues(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith([]); + }); + + it('should handle property fields without any values', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const fieldId1 = 'field-1'; + const fieldId2 = 'field-2'; + + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: fieldId1, + target_id: runId, + target_type: 'run', + name: 'Field 1', + type: 'text', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: fieldId2, + target_id: runId, + target_type: 'run', + name: 'Field 2', + type: 'text', + delete_at: 0, + }), + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyFieldsWithValues(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const fieldsWithValues = subscriptionNext.mock.calls[0][0]; + expect(fieldsWithValues).toHaveLength(2); + expect(fieldsWithValues[0].value).toBeUndefined(); + expect(fieldsWithValues[1].value).toBeUndefined(); + }); + + it('should filter out deleted property fields even with values', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const fieldId1 = 'field-1'; + const fieldId2 = 'field-2'; + + const propertyFields = [ + TestHelper.fakePlaybookRunAttribute({ + id: fieldId1, + target_id: runId, + target_type: 'run', + name: 'Active Field', + type: 'text', + delete_at: 0, + }), + TestHelper.fakePlaybookRunAttribute({ + id: fieldId2, + target_id: runId, + target_type: 'run', + name: 'Deleted Field', + type: 'text', + delete_at: 1620000000000, + }), + ]; + + const propertyValues = [ + TestHelper.fakePlaybookRunAttributeValue(fieldId1, runId, { + id: 'value-1', + value: 'Value 1', + }), + TestHelper.fakePlaybookRunAttributeValue(fieldId2, runId, { + id: 'value-2', + value: 'Value 2', + }), + ]; + + await operator.handlePlaybookRunPropertyField({ + propertyFields, + prepareRecordsOnly: false, + }); + + await operator.handlePlaybookRunPropertyValue({ + propertyValues, + prepareRecordsOnly: false, + }); + + const result = observePlaybookRunPropertyFieldsWithValues(operator.database, runId); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalled(); + const fieldsWithValues = subscriptionNext.mock.calls[0][0]; + expect(fieldsWithValues).toHaveLength(1); + expect(fieldsWithValues[0].propertyField.name).toBe('Active Field'); + expect(fieldsWithValues[0].value!.value).toBe('Value 1'); + }); + + it('should emit when a property value changes', async () => { + const subscriptionNext = jest.fn(); + const runId = 'run-id-1'; + const fieldId = 'field-1'; + const valueId = 'value-1'; + + // Create property field + const propertyField = TestHelper.fakePlaybookRunAttribute({ + id: fieldId, + target_id: runId, + target_type: 'run', + name: 'Test Field', + type: 'text', + delete_at: 0, + }); + + // Create initial value + const initialValue = TestHelper.fakePlaybookRunAttributeValue(fieldId, runId, { + id: valueId, + value: 'initial value', + }); + + await operator.handlePlaybookRunPropertyField({ + propertyFields: [propertyField], + prepareRecordsOnly: false, + }); + + await operator.handlePlaybookRunPropertyValue({ + propertyValues: [initialValue], + prepareRecordsOnly: false, + }); + + // Subscribe to observable + const result = observePlaybookRunPropertyFieldsWithValues(operator.database, runId); + const subscription = result.subscribe({next: subscriptionNext}); + + // Wait for initial emission + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(subscriptionNext).toHaveBeenCalled(); + const initialFieldsWithValues = subscriptionNext.mock.calls[0][0]; + expect(initialFieldsWithValues).toHaveLength(1); + expect(initialFieldsWithValues[0].propertyField.id).toBe(fieldId); + expect(initialFieldsWithValues[0].value).toBeDefined(); + expect(initialFieldsWithValues[0].value!.value).toBe('initial value'); + + // Clear previous calls + subscriptionNext.mockClear(); + + // Update the value + const updatedValue = TestHelper.fakePlaybookRunAttributeValue(fieldId, runId, { + id: valueId, + value: 'updated value', + update_at: Date.now(), + }); + + await operator.handlePlaybookRunPropertyValue({ + propertyValues: [updatedValue], + prepareRecordsOnly: false, + }); + + // Wait for the observer to emit the change + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify observer emitted with updated value + expect(subscriptionNext).toHaveBeenCalled(); + const updatedFieldsWithValues = subscriptionNext.mock.calls[0][0]; + expect(updatedFieldsWithValues).toHaveLength(1); + expect(updatedFieldsWithValues[0].propertyField.id).toBe(fieldId); + expect(updatedFieldsWithValues[0].value).toBeDefined(); + expect(updatedFieldsWithValues[0].value!.value).toBe('updated value'); + + subscription.unsubscribe(); + }); + }); +}); diff --git a/app/products/playbooks/database/queries/property_fields.ts b/app/products/playbooks/database/queries/property_fields.ts new file mode 100644 index 000000000..8801c5b15 --- /dev/null +++ b/app/products/playbooks/database/queries/property_fields.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q, type Database} from '@nozbe/watermelondb'; +import {of as of$, combineLatest, type Observable} from 'rxjs'; +import {distinctUntilChanged, switchMap, map} from 'rxjs/operators'; + +import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; + +import type PlaybookRunPropertyFieldModel from '@playbooks/types/database/models/playbook_run_attribute'; +import type PlaybookRunPropertyValueModel from '@playbooks/types/database/models/playbook_run_attribute_value'; + +const {PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; + +/** + * Get all property fields for a run (by target_id = runId and target_type = 'run') + * @param database - The database instance + * @param runId - The run ID + * @returns Observable of property field models + */ +export const observePlaybookRunPropertyFields = (database: Database, runId: string): Observable => { + return database.get(PLAYBOOK_RUN_ATTRIBUTE).query( + Q.where('target_id', runId), + Q.where('target_type', 'run'), + Q.where('delete_at', 0), + ).observeWithColumns(['update_at', 'delete_at']).pipe( + distinctUntilChanged(), + ); +}; + +/** + * Get a specific property field by ID + * @param database - The database instance + * @param fieldId - The property field ID + * @returns The property field model or undefined + */ +export const getPlaybookRunPropertyFieldById = async (database: Database, fieldId: string): Promise => { + try { + const fieldRecord = await database.get(PLAYBOOK_RUN_ATTRIBUTE).find(fieldId); + return fieldRecord; + } catch { + return undefined; + } +}; + +/** + * Get all property values for a run (by run_id) + * @param database - The database instance + * @param runId - The run ID + * @returns Observable of property value models + */ +export const observePlaybookRunPropertyValues = (database: Database, runId: string): Observable => { + return database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).query( + Q.where('run_id', runId), + ).observeWithColumns(['value', 'update_at']).pipe( + distinctUntilChanged(), + ); +}; + +/** + * Get property value by field ID (attributeId in DB) and run_id + * @param database - The database instance + * @param fieldId - The property field ID (maps to attribute_id in DB) + * @param runId - The run ID + * @returns Observable of the property value model or undefined + */ +export const observePlaybookRunPropertyValue = (database: Database, fieldId: string, runId: string): Observable => { + return database.get(PLAYBOOK_RUN_ATTRIBUTE_VALUE).query( + Q.where('attribute_id', fieldId), + Q.where('run_id', runId), + Q.take(1), + ).observe().pipe( + switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), + ); +}; + +/** + * Get property fields with their values joined + * @param database - The database instance + * @param runId - The run ID + * @returns Observable of array of objects containing propertyField and optional value + */ +export const observePlaybookRunPropertyFieldsWithValues = ( + database: Database, + runId: string, +): Observable> => { + const propertyFields$ = observePlaybookRunPropertyFields(database, runId); + const propertyValues$ = observePlaybookRunPropertyValues(database, runId); + + return combineLatest([propertyFields$, propertyValues$]).pipe( + map(([propertyFields, propertyValues]) => { + // Create a map of values by attribute_id for quick lookup + const valuesMap = new Map(); + propertyValues.forEach((value) => { + valuesMap.set(value.attributeId, value); + }); + + // Join property fields with their values + return propertyFields.map((propertyField) => ({ + propertyField, + value: valuesMap.get(propertyField.id), + })); + }), + ); +}; diff --git a/app/products/playbooks/database/schema/playbook_run_attribute_value.ts b/app/products/playbooks/database/schema/playbook_run_attribute_value.ts index ed6de66dc..c95333aaa 100644 --- a/app/products/playbooks/database/schema/playbook_run_attribute_value.ts +++ b/app/products/playbooks/database/schema/playbook_run_attribute_value.ts @@ -21,5 +21,6 @@ export default tableSchema({ {name: 'attribute_id', type: 'string', isIndexed: true}, {name: 'run_id', type: 'string', isIndexed: true}, {name: 'value', type: 'string'}, + {name: 'update_at', type: 'number'}, ], }); diff --git a/app/products/playbooks/screens/playbook_run/components/index.ts b/app/products/playbooks/screens/playbook_run/components/index.ts new file mode 100644 index 000000000..c27545029 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/components/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default as PropertyField} from './property_field'; +export {default as PropertyFieldsList} from './property_fields_list'; +export {default as PropertySelectField} from './property_select_field'; + diff --git a/app/products/playbooks/screens/playbook_run/components/property_field.tsx b/app/products/playbooks/screens/playbook_run/components/property_field.tsx new file mode 100644 index 000000000..a35d5431d --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/components/property_field.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo, useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {TouchableOpacity, View} from 'react-native'; +import {Text} from 'react-native-gesture-handler'; + +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import PropertyFieldEditor from './property_field_editor'; + +import type PlaybookRunPropertyFieldModel from '@playbooks/database/models/playbook_run_attribute'; +import type PlaybookRunPropertyValueModel from '@playbooks/database/models/playbook_run_attribute_value'; + +type PropertyFieldProps = { + propertyField: PlaybookRunPropertyFieldModel; + value?: PlaybookRunPropertyValueModel; + onValueChange: (fieldId: string, newValue: string) => void; + isDisabled?: boolean; + testID: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + viewContainer: { + marginVertical: 8, + alignItems: 'center', + width: '100%', + flexDirection: 'row', + }, + labelContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + label: { + fontSize: 14, + ...typography('Body', 200, 'SemiBold'), + color: theme.centerChannelColor, + }, + value: { + fontSize: 14, + color: theme.centerChannelColor, + }, + }; +}); + +const PropertyField = ({ + propertyField, + value, + onValueChange, + isDisabled = false, + testID, +}: PropertyFieldProps) => { + const theme = useTheme(); + const intl = useIntl(); + const isTablet = useIsTablet(); + const style = getStyleSheet(theme); + + const subContainer = [style.viewContainer, {paddingHorizontal: isTablet ? 42 : 20}]; + const currentValue = value?.value || ''; + + const handlePress = useCallback(() => { + if (isDisabled) { + return; + } + + const handleSave = (newValue: string) => { + onValueChange(propertyField.id, newValue); + dismissBottomSheet(); + }; + + const renderContent = () => ( + + ); + + bottomSheet({ + title: propertyField.name, + renderContent, + snapPoints: ['50%', '50%'], + theme, + closeButtonId: 'close-property-field', + }); + }, [isDisabled, currentValue, onValueChange, propertyField.id, propertyField.name, testID, theme]); + + return ( + + + + {`${propertyField.name}: `} + {currentValue || intl.formatMessage({id: 'playbooks.property_field.tap_to_edit', defaultMessage: 'Tap to edit'})} + + + + ); +}; + +export default memo(PropertyField); + diff --git a/app/products/playbooks/screens/playbook_run/components/property_field_editor.tsx b/app/products/playbooks/screens/playbook_run/components/property_field_editor.tsx new file mode 100644 index 000000000..73ea7cf9c --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/components/property_field_editor.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View} from 'react-native'; + +import Button from '@components/button'; +import FloatingTextInput, {type FloatingTextInputRef} from '@components/floating_input/floating_text_input_label'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const getStyleSheet = makeStyleSheetFromTheme(() => { + return { + modalContent: { + padding: 20, + }, + buttonContainer: { + marginTop: 20, + }, + }; +}); + +type PropertyFieldEditorProps = { + initialValue: string; + label: string; + onSave: (value: string) => void; + theme: Theme; + testID: string; +}; + +const PropertyFieldEditor: React.FC = ({ + initialValue, + label, + onSave, + theme, + testID, +}) => { + const intl = useIntl(); + const [tempValue, setTempValue] = useState(initialValue); + const inputRef = useRef(null); + const styles = getStyleSheet(theme); + + const handleSave = useCallback(() => { + onSave(tempValue); + }, [tempValue, onSave]); + + return ( + + + +