diff --git a/app/products/playbooks/actions/local/run.test.ts b/app/products/playbooks/actions/local/run.test.ts index 6daed48d7..355026106 100644 --- a/app/products/playbooks/actions/local/run.test.ts +++ b/app/products/playbooks/actions/local/run.test.ts @@ -5,7 +5,7 @@ import DatabaseManager from '@database/manager'; import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import TestHelper from '@test/test_helper'; -import {handlePlaybookRuns, setOwner, renamePlaybookRun} from './run'; +import {handlePlaybookRuns, setOwner, updatePlaybookRun} from './run'; import type {Database} from '@nozbe/watermelondb'; import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run'; @@ -154,23 +154,113 @@ describe('setOwner', () => { }); }); -describe('renamePlaybookRun', () => { +describe('updatePlaybookRun', () => { let database: Database; beforeEach(() => { database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; }); - it('should handle not found database', async () => { - const {error} = await renamePlaybookRun('foo', 'runid', 'New Name'); - expect(error).toBeTruthy(); - expect((error as Error).message).toContain('foo database not found'); + it('should update both name and summary successfully', async () => { + const runs = TestHelper.createPlaybookRuns(1, 0, 0); + await handlePlaybookRuns(serverUrl, runs, false, false); + + const playbookRunId = runs[0].id; + const newName = 'Updated Run Name'; + const newSummary = 'Updated run summary'; + + const {data, error} = await updatePlaybookRun(serverUrl, playbookRunId, newName, newSummary); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); + expect(updatedRun.name).toBe(newName); + expect(updatedRun.summary).toBe(newSummary); + }); + + it('should trim both name and summary', async () => { + const runs = TestHelper.createPlaybookRuns(1, 0, 0); + await handlePlaybookRuns(serverUrl, runs, false, false); + + const playbookRunId = runs[0].id; + const nameWithSpaces = ' Updated Run Name '; + const summaryWithSpaces = ' Updated summary '; + + const {data, error} = await updatePlaybookRun(serverUrl, playbookRunId, nameWithSpaces, summaryWithSpaces); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); + expect(updatedRun.name).toBe('Updated Run Name'); + expect(updatedRun.summary).toBe('Updated summary'); + }); + + it('should allow empty summary', async () => { + const runs = TestHelper.createPlaybookRuns(1, 0, 0); + await handlePlaybookRuns(serverUrl, runs, false, false); + + const playbookRunId = runs[0].id; + const newName = 'Updated Run Name'; + const emptySummary = ''; + + const {data, error} = await updatePlaybookRun(serverUrl, playbookRunId, newName, emptySummary); + + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); + expect(updatedRun.name).toBe(newName); + expect(updatedRun.summary).toBe(''); + }); + + it('should reject empty name', async () => { + const runs = TestHelper.createPlaybookRuns(1, 0, 0); + await handlePlaybookRuns(serverUrl, runs, false, false); + + const playbookRunId = runs[0].id; + const originalName = runs[0].name; + const originalSummary = runs[0].summary; + + const {data, error} = await updatePlaybookRun(serverUrl, playbookRunId, '', 'New summary'); + + expect(error).toBe('Name cannot be empty or whitespace-only'); + expect(data).toBeUndefined(); + + const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); + expect(updatedRun.name).toBe(originalName); + expect(updatedRun.summary).toBe(originalSummary); + }); + + it('should reject whitespace-only name', async () => { + const runs = TestHelper.createPlaybookRuns(1, 0, 0); + await handlePlaybookRuns(serverUrl, runs, false, false); + + const playbookRunId = runs[0].id; + const originalName = runs[0].name; + const originalSummary = runs[0].summary; + + const {data, error} = await updatePlaybookRun(serverUrl, playbookRunId, ' ', 'New summary'); + + expect(error).toBe('Name cannot be empty or whitespace-only'); + expect(data).toBeUndefined(); + + const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); + expect(updatedRun.name).toBe(originalName); + expect(updatedRun.summary).toBe(originalSummary); }); it('should handle playbook run not found', async () => { - const {error} = await renamePlaybookRun(serverUrl, 'nonexistent', 'New Name'); + const {error} = await updatePlaybookRun(serverUrl, 'nonexistent', 'New Name', 'New summary'); expect(error).toBe('Playbook run not found: nonexistent'); }); + it('should handle not found database', async () => { + const {error} = await updatePlaybookRun('foo', 'runid', 'New Name', 'New summary'); + expect(error).toBeTruthy(); + expect((error as Error).message).toContain('foo database not found'); + }); + it('should handle database write errors', async () => { const runs = TestHelper.createPlaybookRuns(1, 0, 0); await handlePlaybookRuns(serverUrl, runs, false, false); @@ -180,93 +270,46 @@ describe('renamePlaybookRun', () => { const originalWrite = database.write; database.write = jest.fn().mockRejectedValue(new Error('Database write failed')); - const {error} = await renamePlaybookRun(serverUrl, playbookRunId, 'New Name'); + const {error} = await updatePlaybookRun(serverUrl, playbookRunId, 'New Name', 'New summary'); expect(error).toBeTruthy(); expect((error as Error).message).toBe('Database write failed'); database.write = originalWrite; }); - it('should rename playbook run successfully', async () => { + it('should update only name when summary is unchanged', async () => { const runs = TestHelper.createPlaybookRuns(1, 0, 0); await handlePlaybookRuns(serverUrl, runs, false, false); const playbookRunId = runs[0].id; - const newName = 'Updated Run Name'; + const originalSummary = runs[0].summary; + const newName = 'New Name Only'; - const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, newName); + const {data, error} = await updatePlaybookRun(serverUrl, playbookRunId, newName, originalSummary); expect(error).toBeUndefined(); expect(data).toBe(true); const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); expect(updatedRun.name).toBe(newName); + expect(updatedRun.summary).toBe(originalSummary); }); - it('should reject empty name string', async () => { + it('should handle very long name and summary values', async () => { const runs = TestHelper.createPlaybookRuns(1, 0, 0); await handlePlaybookRuns(serverUrl, runs, false, false); const playbookRunId = runs[0].id; - const originalName = runs[0].name; + const longName = 'A'.repeat(300); + const longSummary = 'B'.repeat(1000); - const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, ''); - - expect(error).toBe('Name cannot be empty or whitespace-only'); - expect(data).toBeUndefined(); - - // Verify the name was not changed - const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); - expect(updatedRun.name).toBe(originalName); - }); - - it('should reject whitespace-only name', async () => { - const runs = TestHelper.createPlaybookRuns(1, 0, 0); - await handlePlaybookRuns(serverUrl, runs, false, false); - - const playbookRunId = runs[0].id; - const originalName = runs[0].name; - const whitespaceName = ' '; - - const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, whitespaceName); - - expect(error).toBe('Name cannot be empty or whitespace-only'); - expect(data).toBeUndefined(); - - // Verify the name was not changed - const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); - expect(updatedRun.name).toBe(originalName); - }); - - it('should handle very long names', async () => { - const runs = TestHelper.createPlaybookRuns(1, 0, 0); - await handlePlaybookRuns(serverUrl, runs, false, false); - - const playbookRunId = runs[0].id; - const longName = 'A'.repeat(300); // 300 characters - - const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, longName); + const {data, error} = await updatePlaybookRun(serverUrl, playbookRunId, longName, longSummary); expect(error).toBeUndefined(); expect(data).toBe(true); const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); expect(updatedRun.name).toBe(longName); - }); - - it('should trim leading and trailing whitespace from name', async () => { - const runs = TestHelper.createPlaybookRuns(1, 0, 0); - await handlePlaybookRuns(serverUrl, runs, false, false); - - const playbookRunId = runs[0].id; - const nameWithSpaces = ' Updated Run Name '; - - const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, nameWithSpaces); - - expect(error).toBeUndefined(); - expect(data).toBe(true); - - const updatedRun = await database.get(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId); - expect(updatedRun.name).toBe('Updated Run Name'); + expect(updatedRun.summary).toBe(longSummary); }); }); diff --git a/app/products/playbooks/actions/local/run.ts b/app/products/playbooks/actions/local/run.ts index 5003052d0..c806b9b83 100644 --- a/app/products/playbooks/actions/local/run.ts +++ b/app/products/playbooks/actions/local/run.ts @@ -41,7 +41,7 @@ export async function setOwner(serverUrl: string, playbookRunId: string, ownerId } } -export async function renamePlaybookRun(serverUrl: string, playbookRunId: string, name: string) { +export async function updatePlaybookRun(serverUrl: string, playbookRunId: string, name: string, summary?: string) { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const run = await getPlaybookRunById(database, playbookRunId); @@ -57,12 +57,15 @@ export async function renamePlaybookRun(serverUrl: string, playbookRunId: string await database.write(async () => { run.update((r) => { r.name = name.trim(); + if (summary !== undefined) { + r.summary = summary.trim(); + } }); }); return {data: true}; } catch (error) { - logError('failed to rename playbook run', error); + logError('[updatePlaybookRun]', error); return {error}; } } diff --git a/app/products/playbooks/actions/remote/runs.test.ts b/app/products/playbooks/actions/remote/runs.test.ts index 4d1ce1c8b..8ae9c717e 100644 --- a/app/products/playbooks/actions/remote/runs.test.ts +++ b/app/products/playbooks/actions/remote/runs.test.ts @@ -7,13 +7,13 @@ import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {updateLastPlaybookRunsFetchAt} from '@playbooks/actions/local/channel'; -import {handlePlaybookRuns, setOwner as localSetOwner, renamePlaybookRun as localRenamePlaybookRun} from '@playbooks/actions/local/run'; +import {handlePlaybookRuns, setOwner as localSetOwner, updatePlaybookRun as localUpdatePlaybookRun} from '@playbooks/actions/local/run'; 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'; +import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, fetchPlaybookRunsPageForParticipant, setOwner, finishRun, updatePlaybookRun, createPlaybookRun, fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from './runs'; const serverUrl = 'baseHandler.test.com'; const channelId = 'channel-id-1'; @@ -827,60 +827,72 @@ describe('postStatusUpdate', () => { }); }); -describe('renamePlaybookRun', () => { +describe('updatePlaybookRun', () => { const playbookRunId = 'playbook-run-id-1'; const newName = 'New Run Name'; + const newSummary = 'New run summary'; beforeEach(() => { jest.clearAllMocks(); - jest.mocked(localRenamePlaybookRun).mockResolvedValue({data: true}); + jest.mocked(localUpdatePlaybookRun).mockResolvedValue({data: true}); }); it('should handle client error', async () => { jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); - const result = await renamePlaybookRun(serverUrl, playbookRunId, newName); + const result = await updatePlaybookRun(serverUrl, playbookRunId, newName, newSummary, true); expect(result).toBeDefined(); expect(result.error).toBeDefined(); expect(result.data).toBeUndefined(); - expect(localRenamePlaybookRun).not.toHaveBeenCalled(); + expect(localUpdatePlaybookRun).not.toHaveBeenCalled(); }); it('should handle API exception', async () => { const clientError = new Error('Client error'); mockClient.patchPlaybookRun.mockRejectedValueOnce(clientError); - const result = await renamePlaybookRun(serverUrl, playbookRunId, newName); + const result = await updatePlaybookRun(serverUrl, playbookRunId, newName, newSummary, true); expect(result).toBeDefined(); expect(result.error).toBeDefined(); expect(result.data).toBeUndefined(); - expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName}); - expect(localRenamePlaybookRun).not.toHaveBeenCalled(); + expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName, summary: newSummary}); + expect(localUpdatePlaybookRun).not.toHaveBeenCalled(); }); it('should handle local DB update failure', async () => { mockClient.patchPlaybookRun.mockResolvedValueOnce(undefined); - jest.mocked(localRenamePlaybookRun).mockResolvedValueOnce({error: 'DB error'}); + jest.mocked(localUpdatePlaybookRun).mockResolvedValueOnce({error: 'DB error'}); - const result = await renamePlaybookRun(serverUrl, playbookRunId, newName); + const result = await updatePlaybookRun(serverUrl, playbookRunId, newName, newSummary, true); expect(result).toBeDefined(); expect(result.error).toBeDefined(); - expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName}); - expect(localRenamePlaybookRun).toHaveBeenCalledWith(serverUrl, playbookRunId, newName); + expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName, summary: newSummary}); + expect(localUpdatePlaybookRun).toHaveBeenCalledWith(serverUrl, playbookRunId, newName, newSummary); }); - it('should rename playbook run successfully', async () => { + it('should update playbook run successfully', async () => { mockClient.patchPlaybookRun.mockResolvedValueOnce(undefined); - const result = await renamePlaybookRun(serverUrl, playbookRunId, newName); + const result = await updatePlaybookRun(serverUrl, playbookRunId, newName, newSummary, true); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(true); + expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName, summary: newSummary}); + expect(localUpdatePlaybookRun).toHaveBeenCalledWith(serverUrl, playbookRunId, newName, newSummary); + }); + + it('should not include summary in API call when canEditSummary is false', async () => { + mockClient.patchPlaybookRun.mockResolvedValueOnce(undefined); + + const result = await updatePlaybookRun(serverUrl, playbookRunId, newName, newSummary, false); expect(result).toBeDefined(); expect(result.error).toBeUndefined(); expect(result.data).toBe(true); expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName}); - expect(localRenamePlaybookRun).toHaveBeenCalledWith(serverUrl, playbookRunId, newName); - + expect(localUpdatePlaybookRun).toHaveBeenCalledWith(serverUrl, playbookRunId, newName, undefined); }); }); diff --git a/app/products/playbooks/actions/remote/runs.ts b/app/products/playbooks/actions/remote/runs.ts index b390e2346..17b826d7b 100644 --- a/app/products/playbooks/actions/remote/runs.ts +++ b/app/products/playbooks/actions/remote/runs.ts @@ -6,7 +6,7 @@ import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {updateLastPlaybookRunsFetchAt} from '@playbooks/actions/local/channel'; -import {handlePlaybookRuns, setOwner as localSetOwner, renamePlaybookRun as localRenamePlaybookRun} from '@playbooks/actions/local/run'; +import {handlePlaybookRuns, setOwner as localSetOwner, updatePlaybookRun as localUpdatePlaybookRun} from '@playbooks/actions/local/run'; import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run'; import {getMaxRunUpdateAt} from '@playbooks/utils/run'; import EphemeralStore from '@store/ephemeral_store'; @@ -146,16 +146,24 @@ export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId } }; -export const renamePlaybookRun = async (serverUrl: string, playbookRunId: string, newName: string) => { +export const updatePlaybookRun = async (serverUrl: string, playbookRunId: string, name: string, summary: string, canEditSummary: boolean) => { try { const client = NetworkManager.getClient(serverUrl); - await client.patchPlaybookRun(playbookRunId, {name: newName}); + const updates: Partial = {name}; + if (canEditSummary) { + updates.summary = summary; + } + await client.patchPlaybookRun(playbookRunId, updates); // Update local database - const result = await localRenamePlaybookRun(serverUrl, playbookRunId, newName); - return result.error ? result : {data: true}; + const result = await localUpdatePlaybookRun(serverUrl, playbookRunId, name, canEditSummary ? summary : undefined); + if (result.error) { + logDebug('[updatePlaybookRun] local update failed after successful API call', getFullErrorMessage(result.error)); + return result; + } + return {data: true}; } catch (error) { - logDebug('error on renamePlaybookRun', getFullErrorMessage(error)); + logDebug('[updatePlaybookRun]', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); return {error}; } diff --git a/app/products/playbooks/constants/version.ts b/app/products/playbooks/constants/version.ts index dddcb2fda..5f90c1837 100644 --- a/app/products/playbooks/constants/version.ts +++ b/app/products/playbooks/constants/version.ts @@ -4,3 +4,8 @@ export const MINIMUM_MAJOR_VERSION = 2; export const MINIMUM_MINOR_VERSION = 3; export const MINIMUM_PATCH_VERSION = 0; + +// Minimum version required for summary editing feature +export const SUMMARY_EDIT_MINIMUM_MAJOR_VERSION = 2; +export const SUMMARY_EDIT_MINIMUM_MINOR_VERSION = 7; +export const SUMMARY_EDIT_MINIMUM_PATCH_VERSION = 0; diff --git a/app/products/playbooks/database/queries/version.test.ts b/app/products/playbooks/database/queries/version.test.ts index 2557cde97..ce1bb3d14 100644 --- a/app/products/playbooks/database/queries/version.test.ts +++ b/app/products/playbooks/database/queries/version.test.ts @@ -3,13 +3,26 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; -import {MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION} from '@playbooks/constants/version'; +import { + MINIMUM_MAJOR_VERSION, + MINIMUM_MINOR_VERSION, + MINIMUM_PATCH_VERSION, + SUMMARY_EDIT_MINIMUM_MAJOR_VERSION, + SUMMARY_EDIT_MINIMUM_MINOR_VERSION, + SUMMARY_EDIT_MINIMUM_PATCH_VERSION, +} from '@playbooks/constants/version'; -import {fetchIsPlaybooksEnabled, observeIsPlaybooksEnabled} from './version'; +import { + fetchIsPlaybooksEnabled, + fetchIsSummaryEditEnabled, + observeIsPlaybooksEnabled, + observeIsSummaryEditEnabled, +} from './version'; import type ServerDataOperator from '@database/operator/server_data_operator'; const MINIMUM_VERSION = `${MINIMUM_MAJOR_VERSION}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION}`; +const MINIMUM_SUMMARY_VERSION = `${SUMMARY_EDIT_MINIMUM_MAJOR_VERSION}.${SUMMARY_EDIT_MINIMUM_MINOR_VERSION}.${SUMMARY_EDIT_MINIMUM_PATCH_VERSION}`; describe('Playbook Version Queries', () => { let operator: ServerDataOperator; @@ -222,4 +235,106 @@ describe('Playbook Version Queries', () => { expect(result).toBe(false); }); }); + + describe('observeIsSummaryEditEnabled', () => { + it('should return false when no playbooks version is set', async () => { + const subscriptionNext = jest.fn(); + const result = observeIsSummaryEditEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it(`should return true when playbooks version meets minimum requirements (${MINIMUM_SUMMARY_VERSION})`, async () => { + const subscriptionNext = jest.fn(); + const result = observeIsSummaryEditEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: MINIMUM_SUMMARY_VERSION}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('should react to version changes', async () => { + const subscriptionNext = jest.fn(); + const result = observeIsSummaryEditEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + const aboveVersion = `${SUMMARY_EDIT_MINIMUM_MAJOR_VERSION + 1}.${SUMMARY_EDIT_MINIMUM_MINOR_VERSION + 1}.${SUMMARY_EDIT_MINIMUM_PATCH_VERSION + 1}`; + const belowVersion = `${SUMMARY_EDIT_MINIMUM_MAJOR_VERSION - 1}.${SUMMARY_EDIT_MINIMUM_MINOR_VERSION}.${SUMMARY_EDIT_MINIMUM_PATCH_VERSION}`; + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: aboveVersion}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + subscriptionNext.mockClear(); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: belowVersion}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + }); + + describe('fetchIsSummaryEditEnabled', () => { + it('should return false when no playbooks version is set', async () => { + const result = await fetchIsSummaryEditEnabled(operator.database); + expect(result).toBe(false); + }); + + it(`should return true when playbooks version meets minimum requirements (${MINIMUM_SUMMARY_VERSION})`, async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: MINIMUM_SUMMARY_VERSION}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsSummaryEditEnabled(operator.database); + expect(result).toBe(true); + }); + + it('should return true when playbooks version has higher major version', async () => { + const higherVersion = `${SUMMARY_EDIT_MINIMUM_MAJOR_VERSION + 1}.0.0`; + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: higherVersion}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsSummaryEditEnabled(operator.database); + expect(result).toBe(true); + }); + + it('should handle empty version string', async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: ''}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsSummaryEditEnabled(operator.database); + expect(result).toBe(false); + }); + + it('should return false when playbooks version is below minimum', async () => { + const belowVersion = `${SUMMARY_EDIT_MINIMUM_MAJOR_VERSION - 1}.${SUMMARY_EDIT_MINIMUM_MINOR_VERSION}.${SUMMARY_EDIT_MINIMUM_PATCH_VERSION}`; + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: belowVersion}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsSummaryEditEnabled(operator.database); + expect(result).toBe(false); + }); + }); }); diff --git a/app/products/playbooks/database/queries/version.ts b/app/products/playbooks/database/queries/version.ts index 2a0417bdc..766831ce6 100644 --- a/app/products/playbooks/database/queries/version.ts +++ b/app/products/playbooks/database/queries/version.ts @@ -6,7 +6,14 @@ import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database'; -import {MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION} from '@playbooks/constants/version'; +import { + MINIMUM_MAJOR_VERSION, + MINIMUM_MINOR_VERSION, + MINIMUM_PATCH_VERSION, + SUMMARY_EDIT_MINIMUM_MAJOR_VERSION, + SUMMARY_EDIT_MINIMUM_MINOR_VERSION, + SUMMARY_EDIT_MINIMUM_PATCH_VERSION, +} from '@playbooks/constants/version'; import {isMinimumServerVersion} from '@utils/helpers'; import type SystemModel from '@typings/database/models/servers/system'; @@ -35,8 +42,35 @@ export function observeIsPlaybooksEnabled(database: Database) { return database.get(MM_TABLES.SERVER.SYSTEM).query( Q.where('id', SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION), ).observeWithColumns(['value']).pipe( - switchMap((systems) => { + switchMap((systems: SystemModel[]) => { return of$(isPlaybooksEnabledFromSystemModel(systems)); }), ); } + +function isSummaryEditEnabledFromSystemModel(systems: SystemModel[]) { + const version = systems[0]?.value; + if (!version) { + return false; + } + + return isMinimumServerVersion( + version, + SUMMARY_EDIT_MINIMUM_MAJOR_VERSION, + SUMMARY_EDIT_MINIMUM_MINOR_VERSION, + SUMMARY_EDIT_MINIMUM_PATCH_VERSION, + ); +} + +export async function fetchIsSummaryEditEnabled(database: Database) { + const systems = await queryPlaybooksVersion(database).fetch(); + return isSummaryEditEnabledFromSystemModel(systems); +} + +export function observeIsSummaryEditEnabled(database: Database) { + return queryPlaybooksVersion(database).observeWithColumns(['value']).pipe( + switchMap((systems: SystemModel[]) => { + return of$(isSummaryEditEnabledFromSystemModel(systems)); + }), + ); +} diff --git a/app/products/playbooks/screens/navigation.test.ts b/app/products/playbooks/screens/navigation.test.ts index 57c57b2d1..8c26688b6 100644 --- a/app/products/playbooks/screens/navigation.test.ts +++ b/app/products/playbooks/screens/navigation.test.ts @@ -7,7 +7,7 @@ import {goToScreen} from '@screens/navigation'; import TestHelper from '@test/test_helper'; import {changeOpacity} from '@utils/theme'; -import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate, goToPostUpdate, goToSelectPlaybook, goToStartARun, goToRenameChecklist, goToAddChecklistItem, goToEditChecklistItem, goToRenamePlaybookRun, goToCreateQuickChecklist} from './navigation'; +import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate, goToPostUpdate, goToSelectPlaybook, goToStartARun, goToRenameChecklist, goToAddChecklistItem, goToEditChecklistItem, goToEditPlaybookRun, goToCreateQuickChecklist} from './navigation'; jest.mock('@screens/navigation', () => ({ goToScreen: jest.fn(), @@ -603,23 +603,27 @@ describe('Playbooks Navigation', () => { }); }); - describe('goToRenamePlaybookRun', () => { - it('should navigate to rename playbook run screen with correct parameters', async () => { + describe('goToEditPlaybookRun', () => { + it('should navigate to edit playbook run screen with correct parameters', async () => { const currentTitle = 'Playbook Run Title'; + const currentSummary = 'Playbook run summary'; const playbookRunId = 'run-id-123'; + const canEditSummary = true; - await goToRenamePlaybookRun(mockIntl, Preferences.THEMES.denim, currentTitle, playbookRunId); + await goToEditPlaybookRun(mockIntl, Preferences.THEMES.denim, currentTitle, currentSummary, playbookRunId, {canEditSummary}); expect(mockIntl.formatMessage).toHaveBeenCalledWith({ - id: 'playbooks.playbook_run.rename.title', - defaultMessage: 'Rename playbook run', + id: 'playbooks.playbook_run.edit.title', + defaultMessage: 'Edit playbook run', }); expect(goToScreen).toHaveBeenCalledWith( Screens.PLAYBOOK_RENAME_RUN, - 'Rename playbook run', + 'Edit playbook run', { currentTitle, + currentSummary, playbookRunId, + canEditSummary, }, ); }); diff --git a/app/products/playbooks/screens/navigation.ts b/app/products/playbooks/screens/navigation.ts index daed0066f..6e7ba20e0 100644 --- a/app/products/playbooks/screens/navigation.ts +++ b/app/products/playbooks/screens/navigation.ts @@ -129,16 +129,25 @@ export async function goToEditChecklistItem( }, options); } -export async function goToRenamePlaybookRun( +export type GoToEditPlaybookRunOptions = { + canEditSummary?: boolean; +}; + +export async function goToEditPlaybookRun( intl: IntlShape, theme: Theme, currentTitle: string, + currentSummary: string, playbookRunId: string, + options?: GoToEditPlaybookRunOptions, ) { - const title = intl.formatMessage({id: 'playbooks.playbook_run.rename.title', defaultMessage: 'Rename playbook run'}); + const {canEditSummary = true} = options ?? {}; + const title = intl.formatMessage({id: 'playbooks.playbook_run.edit.title', defaultMessage: 'Edit playbook run'}); goToScreen(Screens.PLAYBOOK_RENAME_RUN, title, { currentTitle, + currentSummary, playbookRunId, + canEditSummary, }); } diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx index d518cdbbd..6f23b494c 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx @@ -264,7 +264,7 @@ const Checklist = ({ > {items.map((item, index) => ( c.pendingCount)), currentUserId: observeCurrentUserId(database), teammateNameDisplay: observeTeammateNameDisplay(database), + canEditSummary: observeIsSummaryEditEnabled(database), }; }); diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx index f6d1e9a77..2ab4bd8b7 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx @@ -18,7 +18,7 @@ import {fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; import {showPlaybookErrorSnackbar} from '@utils/snack_bar'; -import {goToRenamePlaybookRun, goToSelectUser} from '../navigation'; +import {goToEditPlaybookRun, goToSelectUser} from '../navigation'; import ChecklistList from './checklist_list'; import ErrorState from './error_state'; @@ -75,13 +75,12 @@ jest.mocked(StatusUpdateIndicator).mockImplementation( ); jest.mock('../navigation', () => ({ - goToRenamePlaybookRun: jest.fn(), + goToEditPlaybookRun: jest.fn(), goToSelectUser: jest.fn(), })); jest.mock('@playbooks/actions/remote/runs', () => ({ finishRun: jest.fn(), - renamePlaybookRun: jest.fn(), setOwner: jest.fn(), })); @@ -141,6 +140,7 @@ describe('PlaybookRun', () => { pendingCount: 3, currentUserId: 'current-user', teammateNameDisplay: General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME, + canEditSummary: true, }; } @@ -650,11 +650,13 @@ describe('PlaybookRun', () => { act(() => { fireEvent.press(editIcon); }); - expect(goToRenamePlaybookRun).toHaveBeenCalledWith( + expect(goToEditPlaybookRun).toHaveBeenCalledWith( expect.anything(), // intl expect.anything(), // theme 'Test Playbook Run', + 'Test summary', props.playbookRun!.id, + {canEditSummary: true}, ); }); }); diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.tsx index be97f0880..063630eff 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.tsx @@ -23,7 +23,7 @@ import {showPlaybookErrorSnackbar} from '@utils/snack_bar'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {typography} from '@utils/typography'; -import {goToRenamePlaybookRun, goToSelectUser} from '../navigation'; +import {goToEditPlaybookRun, goToSelectUser} from '../navigation'; import ChecklistList from './checklist_list'; import {PropertyFieldsList} from './components'; @@ -173,6 +173,7 @@ type Props = { pendingCount: number; currentUserId: string; teammateNameDisplay: string; + canEditSummary: boolean; } export default function PlaybookRun({ @@ -185,6 +186,7 @@ export default function PlaybookRun({ componentId, currentUserId, teammateNameDisplay, + canEditSummary, }: Props) { const theme = useTheme(); const styles = getStyleSheet(theme); @@ -265,8 +267,8 @@ export default function PlaybookRun({ return; } - goToRenamePlaybookRun(intl, theme, playbookRun.name, playbookRun.id); - }, [intl, theme, playbookRun]); + goToEditPlaybookRun(intl, theme, playbookRun.name, playbookRun.summary, playbookRun.id, {canEditSummary}); + }, [intl, theme, playbookRun, canEditSummary]); const handleFinishRun = useCallback(() => { if (!playbookRun) { diff --git a/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx index e2be33d5b..2b823dff1 100644 --- a/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx +++ b/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.test.tsx @@ -8,9 +8,10 @@ import {Keyboard} from 'react-native'; import {Preferences} from '@constants'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; -import {renamePlaybookRun} from '@playbooks/actions/remote/runs'; +import {updatePlaybookRun} from '@playbooks/actions/remote/runs'; import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation'; import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import {showPlaybookErrorSnackbar} from '@utils/snack_bar'; import RenamePlaybookRunBottomSheet from './rename_playbook_run_bottom_sheet'; @@ -34,7 +35,7 @@ jest.mock('@managers/security_manager', () => ({ getShieldScreenId: jest.fn((id) => `shield-${id}`), })); jest.mock('@playbooks/actions/remote/runs', () => ({ - renamePlaybookRun: jest.fn(), + updatePlaybookRun: jest.fn(), })); jest.mock('@utils/snack_bar', () => ({ showPlaybookErrorSnackbar: jest.fn(), @@ -46,6 +47,7 @@ jest.mock('@context/server', () => ({ describe('RenamePlaybookRunBottomSheet', () => { const componentId = 'test-component-id' as any; const currentTitle = 'Original Playbook Run'; + const currentSummary = 'Original summary'; const playbookRunId = 'run-id-123'; const mockRightButton = { @@ -57,7 +59,7 @@ describe('RenamePlaybookRunBottomSheet', () => { beforeEach(() => { jest.clearAllMocks(); jest.mocked(buildNavigationButton).mockReturnValue(mockRightButton as any); - jest.mocked(renamePlaybookRun).mockResolvedValue({data: true}); + jest.mocked(updatePlaybookRun).mockResolvedValue({data: true}); jest.mocked(useNavButtonPressed).mockImplementation((buttonId, compId, callback) => { // Simulate button press when buttonId matches if (buttonId === 'save-playbook-run-name' && compId === componentId) { @@ -71,26 +73,35 @@ describe('RenamePlaybookRunBottomSheet', () => { }); }); - function getBaseProps() { + function getBaseProps(canEditSummary = true) { return { componentId, currentTitle, + currentSummary, playbookRunId, + canEditSummary, }; } - it('should render correctly with currentTitle', () => { + it('should render correctly with currentTitle and currentSummary', () => { const props = getBaseProps(); const {getByTestId, getByText} = renderWithIntlAndTheme(); - const input = getByTestId('playbooks.playbook_run.rename.input'); - expect(input).toBeTruthy(); - expect(input.props.value).toBe(currentTitle); - expect(input.props.autoFocus).toBe(true); + const titleInput = getByTestId('playbooks.playbook_run.rename.input'); + expect(titleInput).toBeTruthy(); + expect(titleInput.props.value).toBe(currentTitle); + expect(titleInput.props.autoFocus).toBe(true); - // Check label is rendered - const label = getByText('Checklist name'); - expect(label).toBeTruthy(); + const summaryInput = getByTestId('playbooks.playbook_run.edit.summary_input'); + expect(summaryInput).toBeTruthy(); + expect(summaryInput.props.value).toBe(currentSummary); + expect(summaryInput.props.multiline).toBe(true); + + // Check labels are rendered + const nameLabel = getByText('Checklist name'); + expect(nameLabel).toBeTruthy(); + const summaryLabel = getByText('Summary'); + expect(summaryLabel).toBeTruthy(); }); it('should set up navigation buttons on mount', () => { @@ -259,7 +270,7 @@ describe('RenamePlaybookRunBottomSheet', () => { }); }); - it('should call renamePlaybookRun and close when save button is pressed with valid title', async () => { + it('should call updatePlaybookRun and close when save button is pressed with valid title', async () => { const props = getBaseProps(); const {getByTestId} = renderWithIntlAndTheme(); @@ -277,7 +288,7 @@ describe('RenamePlaybookRunBottomSheet', () => { await saveCallback(); }); - expect(renamePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, newTitle); + expect(updatePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, newTitle, currentSummary, true); expect(Keyboard.dismiss).toHaveBeenCalled(); expect(popTopScreen).toHaveBeenCalledWith(componentId); }); @@ -300,8 +311,8 @@ describe('RenamePlaybookRunBottomSheet', () => { await saveCallback(); }); - expect(renamePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, 'New Playbook Run Name'); - expect(renamePlaybookRun).not.toHaveBeenCalledWith('some.server.url', playbookRunId, titleWithSpaces); + expect(updatePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, 'New Playbook Run Name', currentSummary, true); + expect(updatePlaybookRun).not.toHaveBeenCalledWith('some.server.url', playbookRunId, titleWithSpaces, currentSummary, true); }); it('should close when Android back button is pressed', () => { @@ -316,7 +327,31 @@ describe('RenamePlaybookRunBottomSheet', () => { expect(Keyboard.dismiss).toHaveBeenCalled(); expect(popTopScreen).toHaveBeenCalledWith(componentId); - expect(renamePlaybookRun).not.toHaveBeenCalled(); + expect(updatePlaybookRun).not.toHaveBeenCalled(); + }); + + it('should enable save button when only summary changes', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + const summaryInput = getByTestId('playbooks.playbook_run.edit.summary_input'); + + // Initially disabled (same as original) + expect(mockRightButton.enabled).toBe(false); + + // Update with different summary + act(() => { + fireEvent.changeText(summaryInput, 'New summary'); + }); + + // Button should be enabled now + const updatedButton = { + ...mockRightButton, + enabled: true, + }; + expect(setButtons).toHaveBeenCalledWith(componentId, { + rightButtons: [updatedButton], + }); }); it('should update navigation button when canSave changes', () => { @@ -340,5 +375,186 @@ describe('RenamePlaybookRunBottomSheet', () => { rightButtons: [{...mockRightButton, enabled: true}], }); }); + + it('should call updatePlaybookRun with updated summary', async () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + const summaryInput = getByTestId('playbooks.playbook_run.edit.summary_input'); + const newSummary = 'Updated summary text'; + + act(() => { + fireEvent.changeText(summaryInput, newSummary); + }); + + const saveCallback = (useNavButtonPressed as any).lastCallback; + await act(async () => { + await saveCallback(); + }); + + expect(updatePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, currentTitle, newSummary, true); + }); + + it('should trim summary when saving', async () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + const summaryInput = getByTestId('playbooks.playbook_run.edit.summary_input'); + const summaryWithSpaces = ' New summary with spaces '; + + act(() => { + fireEvent.changeText(summaryInput, summaryWithSpaces); + }); + + const saveCallback = (useNavButtonPressed as any).lastCallback; + await act(async () => { + await saveCallback(); + }); + + expect(updatePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, currentTitle, 'New summary with spaces', true); + }); + + it('should show error snackbar when save fails', async () => { + jest.mocked(updatePlaybookRun).mockResolvedValueOnce({error: 'Some error'}); + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + const input = getByTestId('playbooks.playbook_run.rename.input'); + act(() => { + fireEvent.changeText(input, 'New Title'); + }); + + const saveCallback = (useNavButtonPressed as any).lastCallback; + await act(async () => { + await saveCallback(); + }); + + expect(showPlaybookErrorSnackbar).toHaveBeenCalled(); + expect(popTopScreen).not.toHaveBeenCalled(); + }); + + it('should allow clearing an existing summary', async () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + const summaryInput = getByTestId('playbooks.playbook_run.edit.summary_input'); + + act(() => { + fireEvent.changeText(summaryInput, ''); + }); + + // Save button should be enabled since summary changed (from non-empty to empty) + const updatedButton = { + ...mockRightButton, + enabled: true, + }; + expect(setButtons).toHaveBeenCalledWith(componentId, { + rightButtons: [updatedButton], + }); + + const saveCallback = (useNavButtonPressed as any).lastCallback; + await act(async () => { + await saveCallback(); + }); + + expect(updatePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, currentTitle, '', true); + }); + + it('should disable save button when summary is reverted to original', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + const summaryInput = getByTestId('playbooks.playbook_run.edit.summary_input'); + + // Change summary to something different + act(() => { + fireEvent.changeText(summaryInput, 'Different Summary'); + }); + + // Button should be enabled + expect(setButtons).toHaveBeenCalledWith(componentId, { + rightButtons: [{...mockRightButton, enabled: true}], + }); + + // Change back to original summary + act(() => { + fireEvent.changeText(summaryInput, currentSummary); + }); + + // Button should be disabled since nothing changed + expect(setButtons).toHaveBeenLastCalledWith(componentId, { + rightButtons: [{...mockRightButton, enabled: false}], + }); + }); + + it('should update both name and summary when both are changed', async () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + const titleInput = getByTestId('playbooks.playbook_run.rename.input'); + const summaryInput = getByTestId('playbooks.playbook_run.edit.summary_input'); + + const newTitle = 'Brand New Title'; + const newSummary = 'Brand New Summary'; + + act(() => { + fireEvent.changeText(titleInput, newTitle); + fireEvent.changeText(summaryInput, newSummary); + }); + + const saveCallback = (useNavButtonPressed as any).lastCallback; + await act(async () => { + await saveCallback(); + }); + + expect(updatePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, newTitle, newSummary, true); + expect(popTopScreen).toHaveBeenCalledWith(componentId); + }); + + it('should hide summary input when summary editing is disabled', () => { + const props = getBaseProps(false); + const {queryByTestId, queryByText} = renderWithIntlAndTheme(); + + expect(queryByTestId('playbooks.playbook_run.edit.summary_input')).toBeNull(); + expect(queryByText('Summary')).toBeNull(); + }); + + it('should enable save button when name changes and summary editing is disabled', () => { + const props = getBaseProps(false); + const {getByTestId} = renderWithIntlAndTheme(); + + const input = getByTestId('playbooks.playbook_run.rename.input'); + + act(() => { + fireEvent.changeText(input, 'New Playbook Run Title'); + }); + + const updatedButton = { + ...mockRightButton, + enabled: true, + }; + expect(setButtons).toHaveBeenCalledWith(componentId, { + rightButtons: [updatedButton], + }); + }); + + it('should call updatePlaybookRun when summary editing is disabled', async () => { + const props = getBaseProps(false); + const {getByTestId} = renderWithIntlAndTheme(); + + const input = getByTestId('playbooks.playbook_run.rename.input'); + const newTitle = 'New Playbook Run Title'; + + act(() => { + fireEvent.changeText(input, newTitle); + }); + + const saveCallback = (useNavButtonPressed as any).lastCallback; + await act(async () => { + await saveCallback(); + }); + + expect(updatePlaybookRun).toHaveBeenCalledWith('some.server.url', playbookRunId, newTitle, currentSummary, false); + }); }); diff --git a/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.tsx index 604be735e..2a04d2e60 100644 --- a/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.tsx +++ b/app/products/playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet.tsx @@ -10,8 +10,9 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {usePreventDoubleTap} from '@hooks/utils'; import SecurityManager from '@managers/security_manager'; -import {renamePlaybookRun} from '@playbooks/actions/remote/runs'; +import {updatePlaybookRun} from '@playbooks/actions/remote/runs'; import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation'; import {showPlaybookErrorSnackbar} from '@utils/snack_bar'; @@ -20,7 +21,9 @@ import type {AvailableScreens} from '@typings/screens/navigation'; type Props = { componentId: AvailableScreens; currentTitle: string; + currentSummary: string; playbookRunId: string; + canEditSummary: boolean; } const SAVE_BUTTON_ID = 'save-playbook-run-name'; @@ -33,6 +36,7 @@ const close = (componentId: AvailableScreens): void => { const styles = StyleSheet.create({ container: { flex: 1, + gap: 16, paddingVertical: 32, paddingHorizontal: 20, }, @@ -41,7 +45,9 @@ const styles = StyleSheet.create({ const RenamePlaybookRunBottomSheet = ({ componentId, currentTitle, + currentSummary, playbookRunId, + canEditSummary, }: Props) => { const intl = useIntl(); const {formatMessage} = intl; @@ -49,10 +55,14 @@ const RenamePlaybookRunBottomSheet = ({ const serverUrl = useServerUrl(); const [title, setTitle] = useState(currentTitle); + const [summary, setSummary] = useState(currentSummary); const canSave = useMemo(() => { - return title.trim().length > 0 && title !== currentTitle; - }, [title, currentTitle]); + const nameValid = title.trim().length > 0; + const nameChanged = title !== currentTitle; + const summaryChanged = canEditSummary && summary !== currentSummary; + return nameValid && (nameChanged || summaryChanged); + }, [title, currentTitle, summary, currentSummary, canEditSummary]); const rightButton = React.useMemo(() => { const base = buildNavigationButton( @@ -78,19 +88,22 @@ const RenamePlaybookRunBottomSheet = ({ const handleSave = useCallback(async () => { if (canSave) { - const res = await renamePlaybookRun(serverUrl, playbookRunId, title.trim()); + const res = await updatePlaybookRun(serverUrl, playbookRunId, title.trim(), summary.trim(), canEditSummary); if (res.error) { showPlaybookErrorSnackbar(); } else { close(componentId); } } - }, [canSave, title, componentId, serverUrl, playbookRunId]); + }, [canSave, title, summary, componentId, serverUrl, playbookRunId, canEditSummary]); - useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]); + const onSave = usePreventDoubleTap(handleSave); + + useNavButtonPressed(SAVE_BUTTON_ID, componentId, onSave, [onSave]); useAndroidHardwareBackHandler(componentId, handleClose); - const label = formatMessage({id: 'playbooks.playbook_run.rename.label', defaultMessage: 'Checklist name'}); + const nameLabel = formatMessage({id: 'playbooks.playbook_run.rename.label', defaultMessage: 'Checklist name'}); + const summaryLabel = formatMessage({id: 'playbooks.playbook_run.edit.summary_label', defaultMessage: 'Summary'}); return ( + {canEditSummary && ( + + )} ); }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index c1952912c..82494e2a2 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1087,6 +1087,8 @@ "playbooks.only_runs_available.ok": "OK", "playbooks.only_runs_available.title": "Playbooks not available", "playbooks.participant_playbooks.title": "Playbook checklists", + "playbooks.playbook_run.edit.summary_label": "Summary", + "playbooks.playbook_run.edit.title": "Edit playbook run", "playbooks.playbook_run.error.description": "Please check your network connection or try again later.", "playbooks.playbook_run.error.title": "Unable to fetch details", "playbooks.playbook_run.finish_run_button": "Finish", @@ -1103,7 +1105,6 @@ "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", "playbooks.playbook_run.run_details": "Checklist details", "playbooks.playbook_run.status_update": "Post update", "playbooks.playbook_run.status_update_due": "Update due\n{time}",