Playbook run attributes UI (#9279)

* migrate naming

* temp

* temp 2

* fixed button when editing

* some bugs solved

* fix bugs

* add localization

* some review requests

* review comments

* upgrade db to 16

* Refactor property fields handling to batch database operations for improved performance

* Enhance error handling in PropertyFieldsListComponent by logging fetch errors and improving type definitions for property fields

* added tests for value change

* fix missalignment

* fix indent & typing

* fix test version

* missing version
This commit is contained in:
Guillermo Vayá 2026-01-06 11:07:10 +01:00 committed by GitHub
parent 83769b9265
commit 6dd4a01145
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3224 additions and 187 deletions

View file

@ -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 = '';
}

View file

@ -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/`;

View file

@ -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: [

View file

@ -45,7 +45,7 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 16,
version: 17,
tables: [
CategorySchema,
CategoryChannelSchema,

View file

@ -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]: {

View file

@ -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<PlaybookRunAttributeModel>(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<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(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<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(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<PlaybookRunAttributeModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE).query().fetch();
const storedValues = await database.get<PlaybookRunAttributeValueModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN_ATTRIBUTE_VALUE).query().fetch();
expect(storedFields.length).toBe(3);
expect(storedValues.length).toBe(3);
});
});

View file

@ -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};
}
}

View file

@ -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();
});
});

View file

@ -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};
}
};

View file

@ -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';

View file

@ -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};

View file

@ -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');
});
});

View file

@ -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<void>;
// Property Fields
fetchRunPropertyFields: (runId: string, updatedSince?: number) => Promise<PlaybookRunPropertyField[]>;
fetchRunPropertyValues: (runId: string, updatedSince?: number) => Promise<PlaybookRunPropertyValue[]>;
setRunPropertyValue: (runId: string, fieldId: string, value: string, fieldType?: string) => Promise<PlaybookRunPropertyValue>;
}
const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -229,6 +234,45 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(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;

View file

@ -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';

View file

@ -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<PlaybookRunAttributeModel>(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunAttributeModel) => {
playbook_run_attribute = await database.get<PlaybookRunPropertyFieldModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_RUN_ATTRIBUTE).create((attribute: PlaybookRunAttributeModel) => {
attributeWithoutAttrs = await database.get<PlaybookRunPropertyFieldModel>(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;
});
});

View file

@ -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;

View file

@ -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<PlaybookRunModel>(PLAYBOOK_RUN).create((run) => applyRunData(run, mockRun));
await database.get<PlaybookRunAttributeModel>(PLAYBOOK_RUN_ATTRIBUTE).create((attr) => applyAttributeData(attr, mockAttribute));
playbook_run_attribute_value = await database.get<PlaybookRunAttributeValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attrVal) => applyAttributeValueData(attrVal, mockAttributeValue));
await database.get<PlaybookRunPropertyFieldModel>(PLAYBOOK_RUN_ATTRIBUTE).create((attr) => applyAttributeData(attr, mockAttribute));
playbook_run_attribute_value = await database.get<PlaybookRunPropertyValueModel>(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<PlaybookRunAttributeValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunAttributeValueModel) => {
attributeValueWithEmptyValue = await database.get<PlaybookRunPropertyValueModel>(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<PlaybookRunAttributeValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunAttributeValueModel) => {
attributeValueWithLongText = await database.get<PlaybookRunPropertyValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((attributeValue: PlaybookRunPropertyValueModel) => {
applyAttributeValueData(attributeValue, mockData);
attributeValue.value = longValue;
});

View file

@ -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<PlaybookRunAttributeModel>;
@immutableRelation(PLAYBOOK_RUN_ATTRIBUTE, 'attribute_id') attribute!: Relation<PlaybookRunPropertyFieldModel>;
/** run : The playbook run this attribute belongs to */
@immutableRelation(PLAYBOOK_RUN, 'run_id') run!: Relation<PlaybookRunModel>;

View file

@ -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<PartialPlaybookRunAttribute>((attribute, index) => ({
...attribute,
run_id: 'playbook_run_1',
].map<PartialPlaybookRunPropertyField>((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<PartialPlaybookRunAttribute>((attribute, index) => ({
...attribute,
run_id: 'playbook_run_1',
].map<PartialPlaybookRunPropertyField>((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();

View file

@ -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<Model[]>;
handlePlaybookChecklist: (args: HandlePlaybookChecklistArgs) => Promise<Model[]>;
handlePlaybookChecklistItem: (args: HandlePlaybookChecklistItemArgs) => Promise<PlaybookChecklistItemModel[]>;
handlePlaybookRunAttribute: (args: HandlePlaybookRunAttributeArgs) => Promise<Model[]>;
handlePlaybookRunAttributeValue: (args: HandlePlaybookRunAttributeValueArgs) => Promise<Model[]>;
handlePlaybookRunPropertyField: (args: HandlePlaybookRunPropertyFieldArgs) => Promise<Model[]>;
handlePlaybookRunPropertyValue: (args: HandlePlaybookRunPropertyValueArgs) => Promise<Model[]>;
}
const PlaybookHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superclass: TBase) => class extends superclass {
@ -157,6 +157,37 @@ const PlaybookHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supe
const childRecords = await this.handlePlaybookChecklist({checklists, prepareRecordsOnly: true, processChildren});
batchRecords.push(...childRecords);
// Process property_fields and property_values
const propertyFields = uniqueRaws.reduce<PartialPlaybookRunPropertyField[]>((res, raw) => {
if (raw.property_fields?.length) {
res.push(...raw.property_fields);
}
return res;
}, []);
const propertyValues = uniqueRaws.reduce<PartialPlaybookRunPropertyValue[]>((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 = <TBase extends Constructor<ServerDataOperatorBase>>(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<Model[]>} - 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<Model[]>} - A promise that resolves to an array of handled playbook run property field records.
*/
handlePlaybookRunAttribute = async ({attributes, prepareRecordsOnly = true}: HandlePlaybookRunAttributeArgs): Promise<Model[]> => {
if (!attributes?.length) {
handlePlaybookRunPropertyField = async ({propertyFields, prepareRecordsOnly = true}: HandlePlaybookRunPropertyFieldArgs): Promise<Model[]> => {
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<Model[]>} - 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<Model[]>} - A promise that resolves to an array of handled playbook run property value records.
*/
handlePlaybookRunAttributeValue = async ({attributeValues, prepareRecordsOnly = true}: HandlePlaybookRunAttributeValueArgs): Promise<Model[]> => {
if (!attributeValues?.length) {
handlePlaybookRunPropertyValue = async ({propertyValues, prepareRecordsOnly = true}: HandlePlaybookRunPropertyValueArgs): Promise<Model[]> => {
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');
};
};

View file

@ -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<PlaybookRunAttributeModel>(PLAYBOOK_RUN_ATTRIBUTE).create((record) => {
existingRecord = await database!.get<PlaybookRunPropertyFieldModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_RUN_ATTRIBUTE).create((record) => {
existingRecord = await database!.get<PlaybookRunPropertyFieldModel>(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<PlaybookRunAttributeModel>(PLAYBOOK_RUN_ATTRIBUTE).create((record) => {
existingRecord = await database!.get<PlaybookRunPropertyFieldModel>(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<PlaybookRunAttributeValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => {
existingRecord = await database!.get<PlaybookRunPropertyValueModel>(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<PlaybookRunAttributeValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => {
existingRecord = await database!.get<PlaybookRunPropertyValueModel>(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<PlaybookRunPropertyValueModel>(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<PlaybookRunPropertyValueModel>(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<PlaybookRunAttributeValueModel>(PLAYBOOK_RUN_ATTRIBUTE_VALUE).create((record) => {
existingRecord = await database!.get<PlaybookRunPropertyValueModel>(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: {

View file

@ -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<PlaybookRunAttributeModel>}
*/
export const transformPlaybookRunAttributeRecord = ({action, database, value}: TransformerArgs<PlaybookRunAttributeModel, PartialPlaybookRunAttribute>): Promise<PlaybookRunAttributeModel> => {
export const transformPlaybookRunPropertyFieldRecord = ({action, database, value}: TransformerArgs<PlaybookRunPropertyFieldModel, PartialPlaybookRunPropertyField>): Promise<PlaybookRunPropertyFieldModel> => {
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<PlaybookRunAttributeValueModel>}
*/
export const transformPlaybookRunAttributeValueRecord = ({action, database, value}: TransformerArgs<PlaybookRunAttributeValueModel, PartialPlaybookRunAttributeValue>): Promise<PlaybookRunAttributeValueModel> => {
export const transformPlaybookRunPropertyValueRecord = ({action, database, value}: TransformerArgs<PlaybookRunPropertyValueModel, PartialPlaybookRunPropertyValue>): Promise<PlaybookRunPropertyValueModel> => {
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({

View file

@ -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();
});
});
});

View file

@ -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<PlaybookRunPropertyFieldModel[]> => {
return database.get<PlaybookRunPropertyFieldModel>(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<PlaybookRunPropertyFieldModel | undefined> => {
try {
const fieldRecord = await database.get<PlaybookRunPropertyFieldModel>(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<PlaybookRunPropertyValueModel[]> => {
return database.get<PlaybookRunPropertyValueModel>(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<PlaybookRunPropertyValueModel | undefined> => {
return database.get<PlaybookRunPropertyValueModel>(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<Array<{propertyField: PlaybookRunPropertyFieldModel; value?: PlaybookRunPropertyValueModel}>> => {
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<string, PlaybookRunPropertyValueModel>();
propertyValues.forEach((value) => {
valuesMap.set(value.attributeId, value);
});
// Join property fields with their values
return propertyFields.map((propertyField) => ({
propertyField,
value: valuesMap.get(propertyField.id),
}));
}),
);
};

View file

@ -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'},
],
});

View file

@ -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';

View file

@ -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 = () => (
<PropertyFieldEditor
initialValue={currentValue}
label={propertyField.name}
onSave={handleSave}
theme={theme}
testID={testID}
/>
);
bottomSheet({
title: propertyField.name,
renderContent,
snapPoints: ['50%', '50%'],
theme,
closeButtonId: 'close-property-field',
});
}, [isDisabled, currentValue, onValueChange, propertyField.id, propertyField.name, testID, theme]);
return (
<View
testID={testID}
style={subContainer}
>
<TouchableOpacity
onPress={handlePress}
disabled={isDisabled}
activeOpacity={0.8}
>
<View style={style.labelContainer}>
<Text style={style.label}>{`${propertyField.name}: `}</Text>
<Text style={style.value}>{currentValue || intl.formatMessage({id: 'playbooks.property_field.tap_to_edit', defaultMessage: 'Tap to edit'})}</Text>
</View>
</TouchableOpacity>
</View>
);
};
export default memo(PropertyField);

View file

@ -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<PropertyFieldEditorProps> = ({
initialValue,
label,
onSave,
theme,
testID,
}) => {
const intl = useIntl();
const [tempValue, setTempValue] = useState(initialValue);
const inputRef = useRef<FloatingTextInputRef>(null);
const styles = getStyleSheet(theme);
const handleSave = useCallback(() => {
onSave(tempValue);
}, [tempValue, onSave]);
return (
<View style={styles.modalContent}>
<FloatingTextInput
ref={inputRef}
rawInput={true}
disableFullscreenUI={true}
editable={true}
keyboardType='default'
label={label}
onChangeText={setTempValue}
testID={`${testID}.input.modal`}
theme={theme}
value={tempValue}
autoFocus={true}
/>
<View style={styles.buttonContainer}>
<Button
onPress={handleSave}
text={intl.formatMessage({id: 'mobile.managed_app.save', defaultMessage: 'Update'})}
theme={theme}
size='lg'
emphasis='primary'
/>
</View>
</View>
);
};
export default PropertyFieldEditor;

View file

@ -0,0 +1,204 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {ActivityIndicator, Text, View} from 'react-native';
import {useTheme} from '@context/theme';
import {fetchPlaybookRunPropertyFields, updatePlaybookRunPropertyValue} from '@playbooks/actions/remote/property_fields';
import {observePlaybookRunPropertyFieldsWithValues} from '@playbooks/database/queries/property_fields';
import {sortPropertyFieldsByOrder} from '@playbooks/utils/property_fields';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {PropertyField, PropertySelectField} from './index';
import type PlaybookRunPropertyFieldModel from '@playbooks/database/models/playbook_run_attribute';
import type PlaybookRunPropertyValueModel from '@playbooks/database/models/playbook_run_attribute_value';
import type {WithDatabaseArgs} from '@typings/database/database';
const messages = defineMessages({
propertyFields: {
id: 'playbooks.playbook_run.property_fields',
defaultMessage: 'Property Fields',
},
});
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
container: {
gap: 12,
marginTop: 16,
},
headerContainer: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
header: {
...typography('Heading', 200, 'SemiBold'),
color: theme.centerChannelColor,
},
loadingContainer: {
paddingVertical: 16,
alignItems: 'center',
},
emptyContainer: {
paddingVertical: 16,
},
emptyText: {
...typography('Body', 100, 'Regular'),
color: changeOpacity(theme.centerChannelColor, 0.72),
},
}));
type PropertyFieldsListProps = WithDatabaseArgs & {
serverUrl: string;
runId: string;
isReadOnly: boolean;
};
type PropertyFieldsWithValues = {
propertyField: PlaybookRunPropertyFieldModel;
value?: PlaybookRunPropertyValueModel;
};
const PropertyFieldsListComponent = ({
serverUrl,
runId,
isReadOnly,
propertyFieldsWithValues = [],
}: PropertyFieldsListProps & {propertyFieldsWithValues?: PropertyFieldsWithValues[]}) => {
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const [updatingFieldId, setUpdatingFieldId] = useState<string | null>(null);
// Fetch property fields when component mounts to ensure they're loaded
useEffect(() => {
fetchPlaybookRunPropertyFields(serverUrl, runId, false).catch((error) => {
logError('failed to fetch playbook run property fields', getFullErrorMessage(error) ?? 'unknown error');
});
}, [serverUrl, runId]);
// Sort property fields by sort_order
const sortedPropertyFields = useMemo(() => {
if (!propertyFieldsWithValues || propertyFieldsWithValues.length === 0) {
return [];
}
const fields = propertyFieldsWithValues.map((item) => item.propertyField);
return sortPropertyFieldsByOrder(fields);
}, [propertyFieldsWithValues]);
// Create a map of values by field ID for quick lookup
const valuesMap = useMemo(() => {
const map = new Map<string, PlaybookRunPropertyValueModel>();
if (propertyFieldsWithValues) {
propertyFieldsWithValues.forEach((item) => {
if (item.value) {
map.set(item.propertyField.id, item.value);
}
});
}
return map;
}, [propertyFieldsWithValues]);
const handleValueChange = useCallback(async (fieldId: string, newValue: string, fieldType?: string) => {
if (isReadOnly) {
return;
}
setUpdatingFieldId(fieldId);
const result = await updatePlaybookRunPropertyValue(serverUrl, runId, fieldId, newValue, fieldType);
setUpdatingFieldId(null);
if (result.error) {
showPlaybookErrorSnackbar();
}
}, [isReadOnly, serverUrl, runId]);
// Render property fields
const renderPropertyFields = () => {
if (sortedPropertyFields.length === 0) {
return null;
}
return sortedPropertyFields.map((propertyField: PlaybookRunPropertyFieldModel) => {
const value = valuesMap.get(propertyField.id);
const isUpdating = updatingFieldId === propertyField.id;
const isDisabled = isReadOnly || isUpdating;
const testID = `playbook_run.property_field.${propertyField.id}`;
if (propertyField.type === 'text') {
return (
<PropertyField
key={propertyField.id}
propertyField={propertyField}
value={value}
onValueChange={handleValueChange}
isDisabled={isDisabled}
testID={testID}
/>
);
}
if (propertyField.type === 'select' || propertyField.type === 'multiselect') {
// Include value's updateAt in key to force re-render when value changes
// Use propertyField.id as fallback to ensure uniqueness when there's no value
const valueKey = value?.updateAt ?? propertyField.id;
const containerKey = `${propertyField.id}-container-${valueKey}`;
const fieldKey = `${propertyField.id}-field-${valueKey}`;
return (
<View key={containerKey}>
<PropertySelectField
key={fieldKey}
propertyField={propertyField}
value={value}
onValueChange={handleValueChange}
isDisabled={isDisabled}
isMultiselect={propertyField.type === 'multiselect'}
testID={testID}
/>
{isUpdating && (
<View style={styles.loadingContainer}>
<ActivityIndicator
size='small'
color={theme.buttonBg}
/>
</View>
)}
</View>
);
}
return null;
});
};
return (
<View
style={styles.container}
testID='playbook_run.property_fields_list'
>
<View style={styles.headerContainer}>
<Text style={styles.header}>
{intl.formatMessage(messages.propertyFields)}
</Text>
</View>
{renderPropertyFields()}
</View>
);
};
const enhanced = withObservables(['runId'], ({database, runId}: PropertyFieldsListProps) => {
return {
propertyFieldsWithValues: observePlaybookRunPropertyFieldsWithValues(database, runId),
};
});
export default withDatabase(enhanced(PropertyFieldsListComponent));

View file

@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {TouchableOpacity, View} from 'react-native';
import {Text} from 'react-native-gesture-handler';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {formatPropertyFieldOptionsForSelector, getPropertyValueDisplay} from '@playbooks/utils/property_fields';
import {goToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getSelectedOptionIds} from '@utils/user';
import type PlaybookRunPropertyFieldModel from '@playbooks/database/models/playbook_run_attribute';
import type PlaybookRunPropertyValueModel from '@playbooks/database/models/playbook_run_attribute_value';
type PropertySelectFieldProps = {
propertyField: PlaybookRunPropertyFieldModel;
value?: PlaybookRunPropertyValueModel;
onValueChange: (fieldId: string, newValue: string, fieldType?: string) => void;
isDisabled?: boolean;
isMultiselect: boolean;
testID: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
viewContainer: {
marginVertical: 8,
alignItems: 'center',
width: '100%',
flexDirection: 'row',
},
touchableContainer: {
flex: 1,
},
labelContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
},
label: {
fontSize: 14,
...typography('Body', 200, 'SemiBold'),
color: theme.centerChannelColor,
},
value: {
fontSize: 14,
color: theme.centerChannelColor,
flexShrink: 1,
},
};
});
const PropertySelectField = ({
propertyField,
value,
onValueChange,
isDisabled = false,
isMultiselect = false,
testID,
}: PropertySelectFieldProps) => {
const theme = useTheme();
const intl = useIntl();
const isTablet = useIsTablet();
const styles = getStyleSheet(theme);
// Convert stored value to selected format for selector screen
const selectedValue = useMemo(() => {
if (!value?.value) {
return isMultiselect ? [] : undefined;
}
const selectedIds = getSelectedOptionIds(value.value, isMultiselect ? 'multiselect' : 'select');
if (isMultiselect) {
return selectedIds;
}
return selectedIds[0] || undefined;
}, [value?.value, isMultiselect]);
// Get options from property field attrs
const options = useMemo(() => {
return formatPropertyFieldOptionsForSelector(propertyField);
}, [propertyField]);
// Get display text
const displayText = useMemo(() => {
const text = getPropertyValueDisplay(propertyField, value);
if (!text) {
return intl.formatMessage({id: 'playbooks.property_field.tap_to_select', defaultMessage: 'Tap to select'});
}
return text;
}, [propertyField, value, intl]);
const handleSelect = useCallback((newSelection?: SelectedDialogOption) => {
if (!newSelection) {
onValueChange(propertyField.id, '', propertyField.type);
return;
}
if (Array.isArray(newSelection)) {
// Multiselect: send array of IDs as comma-separated string
// The client will convert to array based on field type
const selectedIds = newSelection.map((option) => option.value);
const commaSeparated = selectedIds.join(',');
onValueChange(propertyField.id, commaSeparated, propertyField.type);
} else {
// Single select: store the option ID directly as string
onValueChange(propertyField.id, newSelection.value, propertyField.type);
}
}, [propertyField.id, propertyField.type, onValueChange]);
// Open selector screen when tapped
const handlePress = useCallback(() => {
if (isDisabled) {
return;
}
goToScreen(Screens.INTEGRATION_SELECTOR, propertyField.name, {
dataSource: '',
options,
handleSelect,
selected: selectedValue,
isMultiselect,
});
}, [isDisabled, propertyField.name, options, handleSelect, selectedValue, isMultiselect]);
const subContainer = [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 20}];
// Always render as text that opens selector when tapped
return (
<View
testID={testID}
style={subContainer}
>
<TouchableOpacity
onPress={handlePress}
disabled={isDisabled}
activeOpacity={0.8}
style={styles.touchableContainer}
>
<View style={styles.labelContainer}>
<Text style={styles.label}>{`${propertyField.name}: `}</Text>
<Text style={styles.value}>{displayText}</Text>
</View>
</TouchableOpacity>
</View>
);
};
export default memo(PropertySelectField);

View file

@ -26,6 +26,7 @@ import {typography} from '@utils/typography';
import {goToRenamePlaybookRun, goToSelectUser} from '../navigation';
import ChecklistList from './checklist_list';
import {PropertyFieldsList} from './components';
import ErrorState from './error_state';
import OutOfDateHeader from './out_of_date_header';
import StatusUpdateIndicator from './status_update_indicator';
@ -393,6 +394,11 @@ export default function PlaybookRun({
/>
)}
</View>
<PropertyFieldsList
serverUrl={serverUrl}
runId={playbookRun.id}
isReadOnly={readOnly}
/>
<View style={styles.tasksContainer}>
<View style={styles.tasksHeaderContainer}>
<Text style={styles.tasksHeader}>

View file

@ -43,7 +43,7 @@ describe('StatusUpdateIndicator', () => {
/>,
);
const text = getByText(/Update due/);
const text = getByText('Update due', {exact: false});
expect(text).toHaveStyle({color: changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.72)});
const icon = getByTestId('compass-icon');
@ -61,7 +61,7 @@ describe('StatusUpdateIndicator', () => {
/>,
);
const text = getByText(/Update overdue/);
const text = getByText('Update overdue', {exact: false});
expect(text).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator});
const icon = getByTestId('compass-icon');

View file

@ -72,7 +72,7 @@ type TimelineEvent = {
type PlaybookRunStatusType = typeof PlaybookRunStatus[keyof typeof PlaybookRunStatus];
type PlaybookRunAttribute = {
type PlaybookRunPropertyField = {
id: string;
group_id: string;
name: string;
@ -82,13 +82,19 @@ type PlaybookRunAttribute = {
create_at: number;
update_at: number;
delete_at: number;
attrs?: string;
attrs?: string | {
options?: Array<{id: string; name: string}>;
parent_id?: string;
sort_order?: number;
value_type?: string;
};
}
type PlaybookRunAttributeValue = {
type PlaybookRunPropertyValue = {
id: string;
attribute_id: string;
run_id: string;
field_id: string;
target_id: string;
update_at: number;
value: string;
}
@ -137,6 +143,8 @@ type PlaybookRun = {
status_posts: StatusPost[];
checklists: PlaybookChecklist[];
metrics_data: RunMetricData[];
property_fields?: PlaybookRunPropertyField[];
property_values?: PlaybookRunPropertyValue[];
update_at: number;
items_order: string[];
status_update_broadcast_channels_enabled: boolean;

View file

@ -20,6 +20,9 @@ interface PlaybookRunAttributeValueModelInterface extends Model {
/** value : The value of the attribute */
value: string;
/** updateAt : The timestamp when this attribute value was last updated */
updateAt: number;
/** attribute : The attribute this attribute value belongs to */
attribute: Relation<PlaybookRunAttributeModel>;

View file

@ -18,5 +18,7 @@ type PartialPlaybookRun = PartialWithId<PlaybookRun>;
type PartialChecklist = PartialWithId<PlaybookChecklist> & WithRunId;
type PartialChecklistItem = PartialWithId<PlaybookChecklistItem> & WithChecklistId;
type PartialPlaybookRunAttribute = PartialWithId<PlaybookRunAttribute>;
type PartialPlaybookRunAttributeValue = PartialWithId<PlaybookRunAttributeValue> & WithRunId & WithAttributeId;
// Types matching API naming (property_field/property_value)
type PartialPlaybookRunPropertyField = PartialWithId<PlaybookRunPropertyField>;
type PartialPlaybookRunPropertyValue = PartialWithId<PlaybookRunPropertyValue>;

View file

@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
parsePropertyFieldAttrs,
getPropertyFieldSortOrder,
sortPropertyFieldsByOrder,
formatPropertyFieldOptionsForSelector,
getOptionNameById,
getPropertyValueDisplay,
} from './property_fields';
import type PlaybookRunPropertyFieldModel from '@playbooks/database/models/playbook_run_attribute';
import type PlaybookRunPropertyValueModel from '@playbooks/database/models/playbook_run_attribute_value';
describe('property_fields utils', () => {
const makeField = (overrides: Partial<PlaybookRunPropertyFieldModel> = {}): PlaybookRunPropertyFieldModel => ({
id: 'field-id',
groupId: 'group-id',
name: 'Field Name',
type: 'text',
targetId: 'run-id',
targetType: 'run',
createAt: 0,
updateAt: 0,
deleteAt: 0,
attrs: undefined,
...overrides,
} as PlaybookRunPropertyFieldModel);
const makeValue = (overrides: Partial<PlaybookRunPropertyValueModel> = {}): PlaybookRunPropertyValueModel => ({
id: 'value-id',
attributeId: 'field-id',
runId: 'run-id',
value: '',
updateAt: 0,
...overrides,
} as PlaybookRunPropertyValueModel);
test('parsePropertyFieldAttrs parses valid JSON', () => {
const json = JSON.stringify({
options: [{id: '1', name: 'One'}],
parent_id: 'p',
sort_order: 2,
value_type: 'text',
visibility: 'when_set',
});
const parsed = parsePropertyFieldAttrs(json);
expect(parsed).toEqual({
options: [{id: '1', name: 'One'}],
parent_id: 'p',
sort_order: 2,
value_type: 'text',
});
});
test('parsePropertyFieldAttrs handles invalid/missing JSON', () => {
expect(parsePropertyFieldAttrs(undefined)).toBeNull();
expect(parsePropertyFieldAttrs('not-json')).toBeNull();
expect(parsePropertyFieldAttrs(JSON.stringify('string'))).toBeNull();
});
test('getPropertyFieldSortOrder defaults to 999', () => {
const field = makeField();
expect(getPropertyFieldSortOrder(field)).toBe(999);
});
test('getPropertyFieldSortOrder reads sort_order from attrs', () => {
const field = makeField({attrs: JSON.stringify({sort_order: 5})});
expect(getPropertyFieldSortOrder(field)).toBe(5);
});
test('sortPropertyFieldsByOrder sorts by sort_order with default last', () => {
const a = makeField({attrs: JSON.stringify({sort_order: 10}), name: 'a'} as any);
const b = makeField({attrs: JSON.stringify({sort_order: 1}), name: 'b'} as any);
const c = makeField({name: 'c'} as any);
const sorted = sortPropertyFieldsByOrder([a, b, c]);
expect(sorted).toEqual([b, a, c]);
});
test('formatPropertyFieldOptionsForSelector returns formatted options for select', () => {
const field = makeField({
type: 'select',
attrs: JSON.stringify({options: [{id: 'id1', name: 'Name 1'}]}),
});
expect(formatPropertyFieldOptionsForSelector(field)).toEqual([
{text: 'Name 1', value: 'id1'},
]);
});
test('formatPropertyFieldOptionsForSelector returns [] for non-select or missing options', () => {
const textField = makeField({type: 'text'});
const selectNoOptions = makeField({type: 'select', attrs: JSON.stringify({})});
expect(formatPropertyFieldOptionsForSelector(textField)).toEqual([]);
expect(formatPropertyFieldOptionsForSelector(selectNoOptions)).toEqual([]);
});
test('getOptionNameById returns name or falls back to id', () => {
const options = [{id: 'x', name: 'Ex'}];
expect(getOptionNameById(options, 'x')).toBe('Ex');
expect(getOptionNameById(options, 'y')).toBe('y');
});
test('getPropertyValueDisplay returns text for text fields', () => {
const field = makeField({type: 'text'});
const value = makeValue({value: 'hello'});
expect(getPropertyValueDisplay(field, value)).toBe('hello');
});
test('getPropertyValueDisplay maps select id to name and falls back to id', () => {
const field = makeField({
type: 'select',
attrs: JSON.stringify({options: [{id: 'id1', name: 'Name 1'}]}),
});
expect(getPropertyValueDisplay(field, makeValue({value: 'id1'}))).toBe('Name 1');
expect(getPropertyValueDisplay(field, makeValue({value: 'unknown'}))).toBe('unknown');
});
test('getPropertyValueDisplay formats multiselect as comma-separated names', () => {
const field = makeField({
type: 'multiselect',
attrs: JSON.stringify({options: [
{id: 'a', name: 'Alpha'},
{id: 'b', name: 'Beta'},
]}),
});
const value = makeValue({value: JSON.stringify(['a', 'b'])});
expect(getPropertyValueDisplay(field, value)).toBe('Alpha, Beta');
});
test('getPropertyValueDisplay handles multiselect fallbacks and empty', () => {
const field = makeField({type: 'multiselect', attrs: JSON.stringify({options: []})});
expect(getPropertyValueDisplay(field, makeValue({value: ''}))).toBe('');
// Comma string fallback
expect(getPropertyValueDisplay(field, makeValue({value: 'x,y'}))).toBe('x, y');
});
});

View file

@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {safeParseJSON} from '@utils/helpers';
import {logWarning} from '@utils/log';
import {getSelectedOptionIds} from '@utils/user';
import type PlaybookRunPropertyFieldModel from '@playbooks/database/models/playbook_run_attribute';
import type PlaybookRunPropertyValueModel from '@playbooks/database/models/playbook_run_attribute_value';
type PropertyFieldAttrs = {
options?: Array<{id: string; name: string}>;
parent_id?: string;
sort_order: number;
value_type: string;
} | null;
export const parsePropertyFieldAttrs = (attrs?: string): PropertyFieldAttrs => {
if (!attrs) {
return null;
}
try {
const parsed = safeParseJSON(attrs) as Record<string, unknown> | null | undefined;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const options = Array.isArray((parsed as any).options) ? (parsed as any).options as Array<{id: string; name: string}> : undefined;
const parent_id = typeof (parsed as any).parent_id === 'string' ? (parsed as any).parent_id : undefined;
const sort_order = typeof (parsed as any).sort_order === 'number' ? (parsed as any).sort_order : 999;
const value_type = typeof (parsed as any).value_type === 'string' ? (parsed as any).value_type : '';
return {options, parent_id, sort_order, value_type};
} catch {
return null;
}
};
export const getPropertyFieldSortOrder = (propertyField: PlaybookRunPropertyFieldModel): number => {
const attrs = parsePropertyFieldAttrs(propertyField.attrs);
if (!attrs || typeof attrs.sort_order !== 'number') {
return 999;
}
return attrs.sort_order;
};
export const sortPropertyFieldsByOrder = (propertyFields: PlaybookRunPropertyFieldModel[]): PlaybookRunPropertyFieldModel[] => {
return [...propertyFields].sort((a, b) => getPropertyFieldSortOrder(a) - getPropertyFieldSortOrder(b));
};
export const formatPropertyFieldOptionsForSelector = (propertyField: PlaybookRunPropertyFieldModel): DialogOption[] => {
if (propertyField.type !== 'select' && propertyField.type !== 'multiselect') {
return [];
}
const attrs = parsePropertyFieldAttrs(propertyField.attrs);
const options = attrs?.options;
if (!options || !Array.isArray(options)) {
return [];
}
return options.map((o) => ({text: o.name, value: o.id}));
};
export const getOptionNameById = (options: Array<{id: string; name: string}>, optionId: string): string => {
const found = options.find((o) => o.id === optionId);
return found ? found.name : optionId;
};
export const getPropertyValueDisplay = (
propertyField: PlaybookRunPropertyFieldModel,
value?: PlaybookRunPropertyValueModel,
): string => {
if (!value) {
return '';
}
const rawValue = value.value || '';
if (propertyField.type === 'text') {
return rawValue;
}
const attrs = parsePropertyFieldAttrs(propertyField.attrs);
const options = attrs?.options || [];
if (propertyField.type === 'select') {
if (!rawValue) {
return '';
}
return getOptionNameById(options, rawValue);
}
if (propertyField.type === 'multiselect') {
const selectedIds = getSelectedOptionIds(rawValue, 'multiselect');
if (!selectedIds.length) {
return '';
}
const names = selectedIds.map((id) => getOptionNameById(options, id));
return names.join(', ');
}
logWarning('Unknown playbook run propertyField type, will use raw value', propertyField.type);
// Unknown type fallback
return rawValue;
};

View file

@ -760,6 +760,7 @@
"mobile.manage_members.remove_member": "Remove from Channel",
"mobile.manage_members.section_title_admins": "CHANNEL ADMINS",
"mobile.manage_members.section_title_members": "MEMBERS",
"mobile.managed_app.save": "Update",
"mobile.managed.biometric_failed": "Biometric or Passcode authentication failed.",
"mobile.managed.blocked_by": "Blocked by {vendor}",
"mobile.managed.exit": "Exit",
@ -1082,6 +1083,7 @@
"playbooks.playbook_run.owner": "Owner",
"playbooks.playbook_run.participants": "Participants",
"playbooks.playbook_run.participants_title": "Participants",
"playbooks.playbook_run.property_fields": "Property Fields",
"playbooks.playbook_run.rename.button": "Save",
"playbooks.playbook_run.rename.label": "Checklist name",
"playbooks.playbook_run.rename.title": "Rename playbook run",
@ -1112,6 +1114,8 @@
"playbooks.post_update.placeholder": "Enter your update message",
"playbooks.post_update.post.button": "Post",
"playbooks.post_update.title": "Post update",
"playbooks.property_field.tap_to_edit": "Tap to edit",
"playbooks.property_field.tap_to_select": "Tap to select",
"playbooks.retrospective_not_available.description": "Only Playbook Checklists are available on mobile. To fill the Run Retrospective, please use the desktop or web app.",
"playbooks.retrospective_not_available.ok": "OK",
"playbooks.retrospective_not_available.title": "Playbooks Retrospective not available",

View file

@ -1120,7 +1120,7 @@ class TestHelperSingleton {
update_at: 0,
});
createPlaybookRunAttribute = (prefix: string, index: number): PlaybookRunAttribute => ({
createPlaybookRunAttribute = (prefix: string, index: number): PlaybookRunPropertyField => ({
id: `${prefix}-attribute_${index}`,
group_id: 'group_1',
name: `Attribute ${index + 1}`,
@ -1133,10 +1133,11 @@ class TestHelperSingleton {
attrs: '',
});
createPlaybookRunAttributeValue = (attributeId: string, runId: string, index: number): PlaybookRunAttributeValue => ({
createPlaybookRunAttributeValue = (attributeId: string, runId: string, index: number): PlaybookRunPropertyValue => ({
id: `${runId}-${attributeId}-value_${index}`,
attribute_id: attributeId,
run_id: runId,
field_id: attributeId,
target_id: runId,
update_at: Date.now(),
value: `Value ${index + 1}`,
});
@ -1360,7 +1361,7 @@ class TestHelperSingleton {
};
};
fakePlaybookRunAttribute = (overwrite: Partial<PlaybookRunAttribute> = {}): PlaybookRunAttribute => {
fakePlaybookRunAttribute = (overwrite: Partial<PlaybookRunPropertyField> = {}): PlaybookRunPropertyField => {
return {
id: this.generateId(),
group_id: this.generateId(),
@ -1376,11 +1377,12 @@ class TestHelperSingleton {
};
};
fakePlaybookRunAttributeValue = (attributeId: string, runId: string, overwrite: Partial<PlaybookRunAttributeValue> = {}): PlaybookRunAttributeValue => {
fakePlaybookRunAttributeValue = (attributeId: string, runId: string, overwrite: Partial<PlaybookRunPropertyValue> = {}): PlaybookRunPropertyValue => {
return {
id: this.generateId(),
attribute_id: attributeId,
run_id: runId,
field_id: attributeId,
target_id: runId,
update_at: Date.now(),
value: 'Test Value',
...overwrite,
};
@ -1408,6 +1410,7 @@ class TestHelperSingleton {
attributeId: this.generateId(),
runId: this.generateId(),
value: 'Test Value',
updateAt: Date.now(),
attribute: this.fakeRelation(),
run: this.fakeRelation(),
...overwrite,