[MM-66995] Add editing summary for a checklist (#9414)

* Add editing summary for a checklist

* fix spacing

* fix duplication issue due to rerender off-screen

* remove worktree from cursor as I'm not using that

* add tests

* address PR review feedback

- add double-tap prevention to save button
- rename goToRenamePlaybookRun to goToEditPlaybookRun for consistency
- add debug log when local DB update fails after successful API call

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update playbook run rename spacing

* [MM-66995] Edit summary gatekeep (#9490)

* gatekeeping summary edit

* Fix playbook run summary edit gating

* more tests

* Use options object for playbook run edit

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Guillermo Vayá 2026-02-05 17:55:53 +01:00 committed by GitHub
parent a9258543bb
commit 882c2ec57b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 618 additions and 137 deletions

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 {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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(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<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.name).toBe('Updated Run Name');
expect(updatedRun.summary).toBe(longSummary);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -264,7 +264,7 @@ const Checklist = ({
>
{items.map((item, index) => (
<ChecklistItem
key={item.id}
key={`calc-${item.id}`}
item={item}
channelId={channelId}
checklistNumber={checklistNumber}

View file

@ -8,6 +8,7 @@ import {distinctUntilChanged, map, switchMap} from 'rxjs/operators';
import {queryPlaybookChecklistByRun} from '@playbooks/database/queries/checklist';
import {queryPlaybookChecklistItemsByChecklists} from '@playbooks/database/queries/item';
import {observeParticipantsIdsFromPlaybookModel, observePlaybookRunById, queryParticipantsFromAPIRun} from '@playbooks/database/queries/run';
import {observeIsSummaryEditEnabled} from '@playbooks/database/queries/version';
import {areItemsOrdersEqual} from '@playbooks/utils/items_order';
import {isOverdue, isPending} from '@playbooks/utils/run';
import {observeCurrentUserId} from '@queries/servers/system';
@ -58,6 +59,7 @@ const enhanced = withObservables(['playbookRunId', 'playbookRun'], ({playbookRun
pendingCount: of$(pendingCount),
currentUserId: observeCurrentUserId(database),
teammateNameDisplay: observeTeammateNameDisplay(database),
canEditSummary: observeIsSummaryEditEnabled(database),
};
}
@ -104,6 +106,7 @@ const enhanced = withObservables(['playbookRunId', 'playbookRun'], ({playbookRun
pendingCount: countObserver.pipe(map((c) => c.pendingCount)),
currentUserId: observeCurrentUserId(database),
teammateNameDisplay: observeTeammateNameDisplay(database),
canEditSummary: observeIsSummaryEditEnabled(database),
};
});

View file

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

View file

@ -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) {

View file

@ -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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
@ -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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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(<RenamePlaybookRunBottomSheet {...props}/>);
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);
});
});

View file

@ -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<string>(currentTitle);
const [summary, setSummary] = useState<string>(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 (
<View
@ -98,13 +111,24 @@ const RenamePlaybookRunBottomSheet = ({
style={styles.container}
>
<FloatingTextInput
label={label}
label={nameLabel}
onChangeText={setTitle}
testID='playbooks.playbook_run.rename.input'
value={title}
theme={theme}
autoFocus={true}
/>
{canEditSummary && (
<FloatingTextInput
label={summaryLabel}
onChangeText={setSummary}
testID='playbooks.playbook_run.edit.summary_input'
value={summary}
theme={theme}
multiline={true}
multilineInputHeight={100}
/>
)}
</View>
);
};

View file

@ -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}",