This commit is contained in:
Guillermo Vayá 2025-11-12 20:46:59 +01:00 committed by GitHub
parent af2c35b03b
commit be5b6c196c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1472 additions and 175 deletions

View file

@ -17,7 +17,7 @@ export type HeaderRightButton = {
buttonType?: 'native' | 'opacity' | 'highlight';
color?: string;
iconName: string;
count?: number;
count?: number | string;
onPress: () => void;
rippleRadius?: number;
testID?: string;

View file

@ -25,6 +25,17 @@ const {
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES;
export default schemaMigrations({migrations: [
{
toVersion: 15,
steps: [
addColumns({
table: PLAYBOOK_RUN,
columns: [
{name: 'type', type: 'string'},
],
}),
],
},
{
toVersion: 14,
steps: [

View file

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

View file

@ -52,7 +52,7 @@ const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_A
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 14,
version: 15,
unsafeSql: undefined,
tables: {
[CATEGORY]: {
@ -463,6 +463,7 @@ describe('*** Test schema for SERVER database ***', () => {
items_order: {name: 'items_order', type: 'string'},
previous_reminder: {name: 'previous_reminder', type: 'number', isOptional: true},
update_at: {name: 'update_at', type: 'number'},
type: {name: 'type', type: 'string'},
},
columnArray: [
{name: 'playbook_id', type: 'string'},
@ -489,6 +490,7 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'previous_reminder', type: 'number', isOptional: true},
{name: 'items_order', type: 'string'},
{name: 'update_at', type: 'number'},
{name: 'type', type: 'string'},
],
},
[PLAYBOOK_CHECKLIST]: {

View file

@ -2,10 +2,11 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getPlaybookChecklistById} from '@playbooks/database/queries/checklist';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import TestHelper from '@test/test_helper';
import {updateChecklistItem, setChecklistItemCommand, setAssignee, setDueDate} from './checklist';
import {updateChecklistItem, setChecklistItemCommand, setAssignee, setDueDate, renameChecklist} from './checklist';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -264,3 +265,109 @@ describe('setDueDate', () => {
expect(updated!.dueDate).toBe(0);
});
});
describe('renameChecklist', () => {
it('should handle not found database', async () => {
const {error} = await renameChecklist('foo', 'checklistid', 'New Title');
expect(error).toBeTruthy();
expect((error as Error).message).toContain('foo database not found');
});
it('should handle checklist not found', async () => {
const {error} = await renameChecklist(serverUrl, 'nonexistent', 'New Title');
expect(error).toBe('Checklist not found: nonexistent');
});
it('should handle database write errors', async () => {
const runId = 'runid';
const checklist = {
...TestHelper.createPlaybookChecklist(runId, 0, 0),
run_id: runId,
order: 0,
};
await operator.handlePlaybookChecklist({checklists: [checklist], prepareRecordsOnly: false});
const originalWrite = operator.database.write;
operator.database.write = jest.fn().mockRejectedValue(new Error('Database write failed'));
const {error} = await renameChecklist(serverUrl, checklist.id, 'New Title');
expect(error).toBeTruthy();
operator.database.write = originalWrite;
});
it('should rename checklist successfully', async () => {
const runId = 'runid';
const checklist = {
...TestHelper.createPlaybookChecklist(runId, 0, 0),
run_id: runId,
order: 0,
};
await operator.handlePlaybookChecklist({checklists: [checklist], prepareRecordsOnly: false});
const newTitle = 'Updated Checklist Title';
const {data, error} = await renameChecklist(serverUrl, checklist.id, newTitle);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistById(operator.database, checklist.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe(newTitle);
});
it('should handle empty title string', async () => {
const runId = 'runid';
const checklist = {
...TestHelper.createPlaybookChecklist(runId, 0, 0),
run_id: runId,
order: 0,
};
await operator.handlePlaybookChecklist({checklists: [checklist], prepareRecordsOnly: false});
const {data, error} = await renameChecklist(serverUrl, checklist.id, '');
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistById(operator.database, checklist.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe('');
});
it('should handle whitespace-only title', async () => {
const runId = 'runid';
const checklist = {
...TestHelper.createPlaybookChecklist(runId, 0, 0),
run_id: runId,
order: 0,
};
await operator.handlePlaybookChecklist({checklists: [checklist], prepareRecordsOnly: false});
const whitespaceTitle = ' ';
const {data, error} = await renameChecklist(serverUrl, checklist.id, whitespaceTitle);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistById(operator.database, checklist.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe(whitespaceTitle);
});
it('should handle very long titles', async () => {
const runId = 'runid';
const checklist = {
...TestHelper.createPlaybookChecklist(runId, 0, 0),
run_id: runId,
order: 0,
};
await operator.handlePlaybookChecklist({checklists: [checklist], prepareRecordsOnly: false});
const longTitle = 'A'.repeat(300); // 300 characters
const {data, error} = await renameChecklist(serverUrl, checklist.id, longTitle);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistById(operator.database, checklist.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe(longTitle);
});
});

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getPlaybookChecklistById} from '@playbooks/database/queries/checklist';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import {logError} from '@utils/log';
@ -88,3 +89,24 @@ export async function setDueDate(serverUrl: string, itemId: string, date?: numbe
return {error};
}
}
export async function renameChecklist(serverUrl: string, checklistId: string, title: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const checklist = await getPlaybookChecklistById(database, checklistId);
if (!checklist) {
return {error: `Checklist not found: ${checklistId}`};
}
await database.write(async () => {
checklist.update((c) => {
c.title = title;
});
});
return {data: true};
} catch (error) {
logError('failed to rename checklist', error);
return {error};
}
}

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} from './run';
import {handlePlaybookRuns, setOwner, renamePlaybookRun} from './run';
import type {Database} from '@nozbe/watermelondb';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
@ -80,7 +80,7 @@ describe('setOwner', () => {
const {data, error} = await setOwner(serverUrl, nonExistentRunId, newOwnerId);
expect(error).toBe('Run not found');
expect(error).toBe('Checklist not found');
expect(data).toBeUndefined();
});
@ -153,3 +153,100 @@ describe('setOwner', () => {
expect(updatedRun.ownerUserId).toBe(emptyOwnerId);
});
});
describe('renamePlaybookRun', () => {
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 handle playbook run not found', async () => {
const {error} = await renamePlaybookRun(serverUrl, 'nonexistent', 'New Name');
expect(error).toBe('Playbook run not found: nonexistent');
});
it('should handle database write errors', async () => {
const runs = TestHelper.createPlaybookRuns(1, 0, 0);
await handlePlaybookRuns(serverUrl, runs, false, false);
const playbookRunId = runs[0].id;
const originalWrite = database.write;
database.write = jest.fn().mockRejectedValue(new Error('Database write failed'));
const {error} = await renamePlaybookRun(serverUrl, playbookRunId, 'New Name');
expect(error).toBeTruthy();
expect((error as Error).message).toBe('Database write failed');
database.write = originalWrite;
});
it('should rename playbook run 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 {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, newName);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updatedRun = await database.get<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.name).toBe(newName);
});
it('should handle empty name string', async () => {
const runs = TestHelper.createPlaybookRuns(1, 0, 0);
await handlePlaybookRuns(serverUrl, runs, false, false);
const playbookRunId = runs[0].id;
const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, '');
expect(error).toBeUndefined();
expect(data).toBe(true);
const updatedRun = await database.get<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.name).toBe('');
});
it('should handle whitespace-only name', async () => {
const runs = TestHelper.createPlaybookRuns(1, 0, 0);
await handlePlaybookRuns(serverUrl, runs, false, false);
const playbookRunId = runs[0].id;
const whitespaceName = ' ';
const {data, error} = await renamePlaybookRun(serverUrl, playbookRunId, whitespaceName);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updatedRun = await database.get<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.name).toBe(whitespaceName);
});
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);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updatedRun = await database.get<PlaybookRunModel>(PLAYBOOK_TABLES.PLAYBOOK_RUN).find(playbookRunId);
expect(updatedRun.name).toBe(longName);
});
});

View file

@ -15,7 +15,7 @@ export async function handlePlaybookRuns(serverUrl: string, runs: PlaybookRun[],
});
return {data};
} catch (error) {
logError('failed to handle playbook runs', error);
logError('failed to handle playbook checklist', error);
return {error};
}
}
@ -25,7 +25,7 @@ export async function setOwner(serverUrl: string, playbookRunId: string, ownerId
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const run = await getPlaybookRunById(database, playbookRunId);
if (!run) {
return {error: 'Run not found'};
return {error: 'Checklist not found'};
}
await database.write(async () => {
@ -40,3 +40,24 @@ export async function setOwner(serverUrl: string, playbookRunId: string, ownerId
return {error};
}
}
export async function renamePlaybookRun(serverUrl: string, playbookRunId: string, name: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const run = await getPlaybookRunById(database, playbookRunId);
if (!run) {
return {error: `Playbook run not found: ${playbookRunId}`};
}
await database.write(async () => {
run.update((r) => {
r.name = name;
});
});
return {data: true};
} catch (error) {
logError('failed to rename playbook run', error);
return {error};
}
}

View file

@ -9,6 +9,7 @@ import {
updateChecklistItem as localUpdateChecklistItem,
setAssignee as localSetAssignee,
setDueDate as localSetDueDate,
renameChecklist as localRenameChecklist,
} from '@playbooks/actions/local/checklist';
import {
@ -19,6 +20,8 @@ import {
setChecklistItemCommand,
setAssignee,
setDueDate,
renameChecklist,
addChecklistItem,
} from './checklist';
const serverUrl = 'baseHandler.test.com';
@ -37,6 +40,8 @@ const mockClient = {
setAssignee: jest.fn(),
setChecklistItemCommand: jest.fn(),
setDueDate: jest.fn(),
renameChecklist: jest.fn(),
addChecklistItem: jest.fn(),
};
jest.mock('@playbooks/actions/local/checklist');
@ -322,4 +327,104 @@ describe('checklist', () => {
expect(localSetDueDate).toHaveBeenCalledWith(serverUrl, itemId, 0);
});
});
describe('renameChecklist', () => {
const checklistId = 'checklist-id-1';
const newTitle = 'New Checklist Title';
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, newTitle);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(localRenameChecklist).not.toHaveBeenCalled();
});
it('should handle API exception', async () => {
mockClient.renameChecklist.mockImplementationOnce(throwFunc);
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, newTitle);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(mockClient.renameChecklist).toHaveBeenCalledWith(playbookRunId, checklistNumber, newTitle);
expect(localRenameChecklist).not.toHaveBeenCalled();
});
it('should handle local DB update failure', async () => {
mockClient.renameChecklist.mockResolvedValueOnce({});
(localRenameChecklist as jest.Mock).mockRejectedValueOnce(new Error('DB error'));
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, newTitle);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(mockClient.renameChecklist).toHaveBeenCalledWith(playbookRunId, checklistNumber, newTitle);
expect(localRenameChecklist).toHaveBeenCalledWith(serverUrl, checklistId, newTitle);
});
it('should rename checklist successfully', async () => {
mockClient.renameChecklist.mockResolvedValueOnce({});
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, newTitle);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.renameChecklist).toHaveBeenCalledWith(playbookRunId, checklistNumber, newTitle);
expect(localRenameChecklist).toHaveBeenCalledWith(serverUrl, checklistId, newTitle);
});
it('should rename checklist with empty title', async () => {
mockClient.renameChecklist.mockResolvedValueOnce({});
const emptyTitle = '';
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, emptyTitle);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.renameChecklist).toHaveBeenCalledWith(playbookRunId, checklistNumber, emptyTitle);
expect(localRenameChecklist).toHaveBeenCalledWith(serverUrl, checklistId, emptyTitle);
});
});
describe('addChecklistItem', () => {
const title = 'New Item Title';
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should handle API exception', async () => {
mockClient.addChecklistItem.mockImplementationOnce(throwFunc);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, title);
});
it('should add checklist item successfully', async () => {
mockClient.addChecklistItem.mockResolvedValueOnce({});
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, title);
});
it('should add checklist item with empty title', async () => {
mockClient.addChecklistItem.mockResolvedValueOnce({});
const emptyTitle = '';
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, emptyTitle);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, emptyTitle);
});
});
});

View file

@ -9,6 +9,7 @@ import {
updateChecklistItem as localUpdateChecklistItem,
setAssignee as localSetAssignee,
setDueDate as localSetDueDate,
renameChecklist as localRenameChecklist,
} from '@playbooks/actions/local/checklist';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
@ -156,3 +157,43 @@ export const setDueDate = async (
return {error};
}
};
export const renameChecklist = async (
serverUrl: string,
playbookRunId: string,
checklistNumber: number,
checklistId: string,
newTitle: string,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
// Patch the playbook run with updated checklists
await client.renameChecklist(playbookRunId, checklistNumber, newTitle);
// Update local database
await localRenameChecklist(serverUrl, checklistId, newTitle);
return {data: true};
} catch (error) {
logDebug('error on renameChecklist', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const addChecklistItem = async (
serverUrl: string,
playbookRunId: string,
checklistNumber: number,
title: string,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.addChecklistItem(playbookRunId, checklistNumber, title);
return {data: true};
} catch (error) {
logDebug('error on addChecklistItem', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -7,12 +7,12 @@ 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} from '@playbooks/actions/local/run';
import {handlePlaybookRuns, setOwner as localSetOwner, renamePlaybookRun as localRenamePlaybookRun} 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 {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, fetchPlaybookRunsPageForParticipant, setOwner, finishRun, createPlaybookRun, fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from './runs';
import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, fetchPlaybookRunsPageForParticipant, setOwner, finishRun, renamePlaybookRun, createPlaybookRun, fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from './runs';
const serverUrl = 'baseHandler.test.com';
const channelId = 'channel-id-1';
@ -28,6 +28,7 @@ const mockClient = {
finishRun: jest.fn(),
createPlaybookRun: jest.fn(),
postStatusUpdate: jest.fn(),
patchPlaybookRun: jest.fn(),
};
jest.mock('@playbooks/database/queries/run');
@ -530,7 +531,6 @@ describe('createPlaybookRun', () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await createPlaybookRun(serverUrl, playbookId, ownerUserId, teamId, name, description);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.data).toBeUndefined();
@ -717,5 +717,53 @@ describe('postStatusUpdate', () => {
const result = await postStatusUpdate(serverUrl, playbookRunID, payload, ids);
expect(result.error).toBeDefined();
});
});
describe('renamePlaybookRun', () => {
const playbookRunId = 'playbook-run-id-1';
const newName = 'New Run Name';
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(localRenamePlaybookRun).mockResolvedValue({data: true});
});
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await renamePlaybookRun(serverUrl, playbookRunId, newName);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.data).toBeUndefined();
expect(localRenamePlaybookRun).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);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(result.data).toBeUndefined();
expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName});
expect(localRenamePlaybookRun).not.toHaveBeenCalled();
});
it('should rename playbook run successfully', async () => {
mockClient.patchPlaybookRun.mockResolvedValueOnce(undefined);
const result = await renamePlaybookRun(serverUrl, playbookRunId, newName);
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);
});
});

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} from '@playbooks/actions/local/run';
import {handlePlaybookRuns, setOwner as localSetOwner, renamePlaybookRun as localRenamePlaybookRun} from '@playbooks/actions/local/run';
import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import {getMaxRunUpdateAt} from '@playbooks/utils/run';
import EphemeralStore from '@store/ephemeral_store';
@ -136,6 +136,20 @@ export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId
}
};
export const renamePlaybookRun = async (serverUrl: string, playbookRunId: string, newName: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.patchPlaybookRun(playbookRunId, {name: newName});
await localRenamePlaybookRun(serverUrl, playbookRunId, newName);
return {data: true};
} catch (error) {
logDebug('error on renamePlaybookRun', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const createPlaybookRun = async (
serverUrl: string,
playbook_id: string,

View file

@ -547,7 +547,6 @@ describe('postStatusUpdate', () => {
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.postStatusUpdate(playbookRunID, payload, ids);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
@ -965,3 +964,163 @@ describe('createPlaybookRun', () => {
).rejects.toThrow('Network error');
});
});
describe('patchPlaybookRun', () => {
test('should patch with multiple field updates', async () => {
const playbookRunId = 'run123';
const updates = {
name: 'Updated Name',
owner_user_id: 'user456',
description: 'Updated description',
};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}`;
const expectedOptions = {method: 'patch', body: updates};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.patchPlaybookRun(playbookRunId, updates);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should patch with single field update', async () => {
const playbookRunId = 'run123';
const updates = {name: 'New Name'};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}`;
const expectedOptions = {method: 'patch', body: updates};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.patchPlaybookRun(playbookRunId, updates);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle network errors', async () => {
const playbookRunId = 'run123';
const updates = {name: 'New Name'};
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.patchPlaybookRun(playbookRunId, updates)).rejects.toThrow('Network error');
});
test('should handle empty updates object', async () => {
const playbookRunId = 'run123';
const updates = {};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}`;
const expectedOptions = {method: 'patch', body: updates};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.patchPlaybookRun(playbookRunId, updates);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
});
describe('renameChecklist', () => {
test('should rename successfully', async () => {
const playbookRunId = 'run123';
const checklistNumber = 1;
const newName = 'Updated Checklist Name';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/rename`;
const expectedOptions = {method: 'put', body: {title: newName}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.renameChecklist(playbookRunId, checklistNumber, newName);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should rename with zero index', async () => {
const playbookRunId = 'run123';
const checklistNumber = 0;
const newName = 'First Checklist';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/rename`;
const expectedOptions = {method: 'put', body: {title: newName}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.renameChecklist(playbookRunId, checklistNumber, newName);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle network errors', async () => {
const playbookRunId = 'run123';
const checklistNumber = 1;
const newName = 'Updated Name';
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.renameChecklist(playbookRunId, checklistNumber, newName)).rejects.toThrow('Network error');
});
test('should handle empty name', async () => {
const playbookRunId = 'run123';
const checklistNumber = 1;
const newName = '';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/rename`;
const expectedOptions = {method: 'put', body: {title: newName}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.renameChecklist(playbookRunId, checklistNumber, newName);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
});
describe('addChecklistItem', () => {
test('should add item successfully', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const title = 'New Item';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/add`;
const expectedOptions = {method: 'post', body: {title}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.addChecklistItem(playbookRunId, checklistNum, title);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should add item with empty title', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const title = '';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/add`;
const expectedOptions = {method: 'post', body: {title}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.addChecklistItem(playbookRunId, checklistNum, title);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle network errors', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const title = 'New Item';
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.addChecklistItem(playbookRunId, checklistNum, title)).rejects.toThrow('Network error');
});
test('should handle invalid checklist number', async () => {
const playbookRunId = 'run123';
const checklistNum = -1;
const title = 'New Item';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/add`;
const expectedOptions = {method: 'post', body: {title}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.addChecklistItem(playbookRunId, checklistNum, title);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
});

View file

@ -14,6 +14,7 @@ export interface ClientPlaybooksMix {
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
fetchPlaybookRunMetadata: (id: string) => Promise<PlaybookRunMetadata>;
patchPlaybookRun: (playbookRunId: string, updates: Partial<PlaybookRun>) => Promise<void>;
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
// Run Management
@ -27,8 +28,9 @@ export interface ClientPlaybooksMix {
restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
setAssignee: (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => Promise<void>;
setDueDate: (playbookRunId: string, checklistNum: number, itemNum: number, date?: number) => Promise<void>;
addChecklistItem: (playbookRunId: string, checklistNum: number, title: string) => Promise<void>;
// skipChecklist: (playbookRunID: string, checklistNum: number) => Promise<void>;
renameChecklist: (playbookRunId: string, checklistNumber: number, newName: string) => Promise<void>;
// Slash Commands
runChecklistItemSlashCommand: (playbookRunId: string, checklistNumber: number, itemNumber: number) => Promise<{trigger_id: string}>;
@ -85,6 +87,20 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
);
};
patchPlaybookRun = async (playbookRunId: string, updates: Partial<PlaybookRun>) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}`,
{method: 'patch', body: updates},
);
};
renameChecklist = async (playbookRunId: string, checklistNumber: number, newName: string) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNumber}/rename`,
{method: 'put', body: {title: newName}},
);
};
setOwner = async (playbookRunId: string, ownerId: string) => {
const data = await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/owner`,
@ -188,6 +204,13 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
);
};
addChecklistItem = async (playbookRunId: string, checklistNum: number, title: string) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNum}/add`,
{method: 'post', body: {title}},
);
};
// Slash Commands
runChecklistItemSlashCommand = async (playbookRunId: string, checklistNumber: number, itemNumber: number) => {
const data = await this.doFetch(

View file

@ -36,7 +36,7 @@ describe('PlaybookRunsOption', () => {
const optionItem = getByTestId('option-item');
expect(optionItem).toBeTruthy();
expect(optionItem.props.label).toBe('Playbook runs');
expect(optionItem.props.label).toBe('Playbook checklists');
expect(optionItem.props.info).toBe('3');
expect(optionItem.props.icon).toBe('product-playbooks');
expect(optionItem.props.type).toBe('arrow');
@ -49,7 +49,7 @@ describe('PlaybookRunsOption', () => {
const slideUpPanelItem = getByTestId('slide-up-panel-item');
expect(slideUpPanelItem).toBeTruthy();
expect(slideUpPanelItem.props.text).toBe('Playbook runs');
expect(slideUpPanelItem.props.text).toBe('Playbook checklists');
expect(slideUpPanelItem.props.leftIcon).toBe('product-playbooks');
expect(slideUpPanelItem.props.rightIcon).toBe('chevron-right');
expect(slideUpPanelItem.props.onPress).toBeDefined();

View file

@ -20,7 +20,7 @@ type Props = {
const messages = defineMessages({
playbookRuns: {
id: 'playbooks.playbooks_runs.title',
defaultMessage: 'Playbook runs',
defaultMessage: 'Playbook checklists',
},
});

View file

@ -25,7 +25,7 @@ describe('PlaybooksButton', () => {
const icon = getByTestId('channel_list.playbooks.button-icon');
expect(icon).toHaveProp('name', 'product-playbooks');
expect(getByText('Playbook runs')).toBeTruthy();
expect(getByText('Playbook checklists')).toBeTruthy();
});
it('calls goToParticipantPlaybooks when pressed', () => {

View file

@ -75,7 +75,7 @@ const PlaybooksButton = () => {
/>
<FormattedText
id='playbooks.home_button.title'
defaultMessage='Playbook runs'
defaultMessage='Playbook checklists'
style={textStyle}
/>
</View>

View file

@ -12,8 +12,8 @@ describe('EmptyState', () => {
);
// Verify correct title and description are shown
expect(getByText('No in progress runs')).toBeTruthy();
expect(getByText('When a run starts in this channel, youll see it here.')).toBeTruthy();
expect(getByText('Nothing in progress')).toBeTruthy();
expect(getByText('When a checklist starts in this channel, youll see it here.')).toBeTruthy();
});
it('renders finished empty state correctly', () => {
@ -22,7 +22,7 @@ describe('EmptyState', () => {
);
// Verify correct title and description are shown
expect(getByText('No finished runs')).toBeTruthy();
expect(getByText('When a run in this channel finishes, youll see it here.')).toBeTruthy();
expect(getByText('Nothing finished')).toBeTruthy();
expect(getByText('When a checklist in this channel finishes, youll see it here.')).toBeTruthy();
});
});

View file

@ -40,19 +40,19 @@ const getStylesFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
const messages = defineMessages({
inProgressTitle: {
id: 'playbooks.runs.in_progress.title',
defaultMessage: 'No in progress runs',
defaultMessage: 'Nothing in progress',
},
finishedTitle: {
id: 'playbooks.runs.finished.title',
defaultMessage: 'No finished runs',
defaultMessage: 'Nothing finished',
},
inProgressDescription: {
id: 'playbooks.runs.in_progress.description',
defaultMessage: 'When a run starts in this channel, youll see it here.',
defaultMessage: 'When a checklist starts in this channel, youll see it here.',
},
finishedDescription: {
id: 'playbooks.runs.finished.description',
defaultMessage: 'When a run in this channel finishes, youll see it here.',
defaultMessage: 'When a checklist in this channel finishes, youll see it here.',
},
});

View file

@ -67,7 +67,7 @@ describe('PlaybookCard', () => {
expect(userAvatarsStack.props.users).toEqual(props.participants);
expect(userAvatarsStack.props.channelId).toBe((props.run as PlaybookRunModel).channelId);
expect(userAvatarsStack.props.location).toBe(props.location);
expect(userAvatarsStack.props.bottomSheetTitle.defaultMessage).toBe('Run Participants');
expect(userAvatarsStack.props.bottomSheetTitle.defaultMessage).toBe('Participants');
const progressBar = getByTestId('progress-bar');
expect(progressBar.props.progress).toBe(50);
@ -93,7 +93,7 @@ describe('PlaybookCard', () => {
expect(userAvatarsStack.props.users).toEqual(props.participants);
expect(userAvatarsStack.props.channelId).toBe(props.run.channel_id);
expect(userAvatarsStack.props.location).toBe(props.location);
expect(userAvatarsStack.props.bottomSheetTitle.defaultMessage).toBe('Run Participants');
expect(userAvatarsStack.props.bottomSheetTitle.defaultMessage).toBe('Participants');
const progressBar = getByTestId('progress-bar');
expect(progressBar.props.progress).toBe(50);

View file

@ -78,7 +78,7 @@ type Props = {
owner?: UserModel;
};
const bottomSheetTitleMessage = defineMessage({id: 'playbook.participants', defaultMessage: 'Run Participants'});
const bottomSheetTitleMessage = defineMessage({id: 'playbook.participants', defaultMessage: 'Participants'});
const PlaybookCard = ({
run,

View file

@ -226,7 +226,7 @@ describe('RunList', () => {
props.inProgressRuns = [inProgressRun];
const {getByText} = renderWithIntlAndTheme(<RunList {...props}/>);
expect(getByText('Start a new run')).toBeTruthy();
expect(getByText('New')).toBeTruthy();
});
it('calls goToSelectPlaybook when Start a new run button is pressed', () => {
@ -235,7 +235,7 @@ describe('RunList', () => {
const {getByText} = renderWithIntlAndTheme(<RunList {...props}/>);
act(() => {
fireEvent.press(getByText('Start a new run'));
fireEvent.press(getByText('New'));
});
expect(goToSelectPlaybook).toHaveBeenCalledTimes(1);
@ -348,7 +348,7 @@ describe('RunList', () => {
const {getByText} = renderWithIntlAndTheme(<RunList {...props}/>);
act(() => {
fireEvent.press(getByText('Start a new run'));
fireEvent.press(getByText('New'));
});
expect(goToSelectPlaybook).toHaveBeenCalledTimes(1);

View file

@ -108,7 +108,7 @@ describe('Participants', () => {
renderContent: expect.any(Function),
initialSnapIndex: 1,
snapPoints: expect.any(Array),
title: 'Run Participants',
title: 'Participants',
theme: expect.any(Object),
}));
});
@ -146,7 +146,7 @@ describe('Participants', () => {
const args = jest.mocked(bottomSheet).mock.calls[0][0];
const Content = args.renderContent;
const {getByText} = renderWithEverything(<Content/>, {database, serverUrl});
expect(getByText('Run Participants')).toBeTruthy();
expect(getByText('Participants')).toBeTruthy();
});
});
@ -163,7 +163,7 @@ describe('Participants', () => {
const args = jest.mocked(bottomSheet).mock.calls[0][0];
const Content = args.renderContent;
const {queryByText} = renderWithEverything(<Content/>, {database, serverUrl});
expect(queryByText('Run Participants')).toBeNull();
expect(queryByText('Participants')).toBeNull();
});
});

View file

@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const PLAYBOOK_RUN_TYPES = {
PlaybookType: 'playbook' as const,
ChannelChecklistType: 'channelChecklist' as const,
} as const;
export type PlaybookRunType = typeof PLAYBOOK_RUN_TYPES[keyof typeof PLAYBOOK_RUN_TYPES];

View file

@ -6,6 +6,9 @@ export const PARTICIPANT_PLAYBOOKS = 'ParticipantPlaybooks';
export const PLAYBOOK_RUN = 'PlaybookRun';
export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
export const PLAYBOOK_POST_UPDATE = 'PlaybookPostUpdate';
export const PLAYBOOK_RENAME_CHECKLIST = 'PlaybookRenameChecklist';
export const PLAYBOOK_ADD_CHECKLIST_ITEM = 'PlaybookAddChecklistItem';
export const PLAYBOOK_RENAME_RUN = 'PlaybookRenameRun';
export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
export const PLAYBOOKS_SELECT_PLAYBOOK = 'PlaybooksSelectPlaybook';
@ -17,6 +20,9 @@ export default {
PLAYBOOK_RUN,
PLAYBOOK_EDIT_COMMAND,
PLAYBOOK_POST_UPDATE,
PLAYBOOK_RENAME_CHECKLIST,
PLAYBOOK_ADD_CHECKLIST_ITEM,
PLAYBOOK_RENAME_RUN,
PLAYBOOK_SELECT_USER,
PLAYBOOKS_SELECT_DATE,
PLAYBOOKS_SELECT_PLAYBOOK,

View file

@ -10,6 +10,7 @@ import {safeParseJSONStringArray} from '@utils/helpers';
import type {Query, Relation} from '@nozbe/watermelondb';
import type PlaybookRunModelInterface from '@playbooks//types/database/models/playbook_run';
import type {PlaybookRunType} from '@playbooks/constants/playbook_run';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type {SyncStatus} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
@ -51,9 +52,12 @@ export default class PlaybookRunModel extends Model implements PlaybookRunModelI
[PLAYBOOK_CHECKLIST]: {type: 'has_many', foreignKey: 'run_id'},
};
/** playbook_id : The id of the playbook this run belongs to */
/** playbook_id : The id of the playbook this run belongs to (empty string if it is a channel checklist) */
@field('playbook_id') playbookId!: string;
/** type : The type of run ('playbook' or 'channelChecklist') */
@field('type') type!: PlaybookRunType;
/** name : Name of the playbook run */
@field('name') name!: string;

View file

@ -17,7 +17,8 @@ const SERVER_URL = `playbookRunAttributeValueModel.test.${Date.now()}.com`;
const applyRunData = (run: PlaybookRunModel, mockData: PlaybookRun) => {
run._raw.id = mockData.id;
run.playbookId = mockData.playbook_id;
run.playbookId = mockData.playbook_id ?? '';
run.type = mockData.type ?? 'playbook';
run.postId = mockData.post_id ?? null;
run.ownerUserId = mockData.owner_user_id;
run.teamId = mockData.team_id;

View file

@ -4,6 +4,7 @@
import {OperationType} from '@constants/database';
import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
@ -33,6 +34,7 @@ export const transformPlaybookRunRecord = ({action, database, value}: Transforme
const fieldsMapper = (run: PlaybookRunModel) => {
run._raw.id = isCreateAction ? (raw?.id ?? run.id) : run.id;
run.playbookId = raw.playbook_id ?? record?.playbookId ?? '';
run.type = raw.type ?? record?.type ?? (run.playbookId ? PLAYBOOK_RUN_TYPES.PlaybookType : PLAYBOOK_RUN_TYPES.ChannelChecklistType);
run.postId = raw.post_id ?? record?.postId ?? null;
run.ownerUserId = raw.owner_user_id ?? record?.ownerUserId ?? '';
run.teamId = raw.team_id ?? record?.teamId ?? '';

View file

@ -34,5 +34,6 @@ export default tableSchema({
{name: 'previous_reminder', type: 'number', isOptional: true},
{name: 'items_order', type: 'string'}, // JSON string
{name: 'update_at', type: 'number'},
{name: 'type', type: 'string'},
],
});

View file

@ -10,6 +10,9 @@ import {render} from '@test/intl-test-helper';
import EditCommand from './edit_command';
import ParticipantPlaybooks from './participant_playbooks';
import PlaybookRun from './playbook_run';
import AddChecklistItemBottomSheet from './playbook_run/checklist/add_checklist_item_bottom_sheet';
import RenameChecklistBottomSheet from './playbook_run/checklist/rename_checklist_bottom_sheet';
import RenamePlaybookRunBottomSheet from './playbook_run/rename_playbook_run_bottom_sheet';
import PlaybookRuns from './playbooks_runs';
import PostUpdate from './post_update';
import SelectDate from './select_date';
@ -78,6 +81,25 @@ jest.mock('@playbooks/screens/post_update', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('@playbooks/screens/playbook_run/checklist/rename_checklist_bottom_sheet', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(RenameChecklistBottomSheet).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_RENAME_CHECKLIST}</Text>);
jest.mock('@playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(AddChecklistItemBottomSheet).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_ADD_CHECKLIST_ITEM}</Text>);
jest.mock('@playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(RenamePlaybookRunBottomSheet).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_RENAME_RUN}</Text>);
jest.mocked(PostUpdate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_POST_UPDATE}</Text>);
describe('Screen Registration', () => {

View file

@ -16,6 +16,12 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/edit_command').default);
case Screens.PLAYBOOK_POST_UPDATE:
return withServerDatabase(require('@playbooks/screens/post_update').default);
case Screens.PLAYBOOK_RENAME_CHECKLIST:
return withServerDatabase(require('@playbooks/screens/playbook_run/checklist/rename_checklist_bottom_sheet').default);
case Screens.PLAYBOOK_ADD_CHECKLIST_ITEM:
return withServerDatabase(require('@playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet').default);
case Screens.PLAYBOOK_RENAME_RUN:
return withServerDatabase(require('@playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet').default);
case Screens.PLAYBOOK_SELECT_USER:
return withServerDatabase(require('@playbooks/screens/select_user').default);
case Screens.PLAYBOOKS_SELECT_DATE:

View file

@ -33,11 +33,11 @@ describe('Playbooks Navigation', () => {
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.playbooks_runs.title',
defaultMessage: 'Playbook runs',
defaultMessage: 'Playbook checklists',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_RUNS,
'Playbook runs',
'Playbook checklists',
{channelId},
expect.objectContaining({
topBar: expect.objectContaining({
@ -59,11 +59,11 @@ describe('Playbooks Navigation', () => {
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.playbook_run.title',
defaultMessage: 'Playbook run',
defaultMessage: 'Playbook checklist',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_RUN,
'Playbook run',
'Playbook checklist',
{playbookRunId},
{},
);
@ -79,11 +79,11 @@ describe('Playbooks Navigation', () => {
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.playbook_run.title',
defaultMessage: 'Playbook run',
defaultMessage: 'Playbook checklist',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_RUN,
'Playbook run',
'Playbook checklist',
{playbookRunId, playbookRun},
{},
);
@ -273,11 +273,11 @@ describe('Playbooks Navigation', () => {
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.participant_playbooks.title',
defaultMessage: 'Playbook runs',
defaultMessage: 'Playbook checklists',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PARTICIPANT_PLAYBOOKS,
'Playbook runs',
'Playbook checklists',
{},
{},
);
@ -307,11 +307,11 @@ describe('Playbooks Navigation', () => {
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.playbook_run.title',
defaultMessage: 'Playbook run',
defaultMessage: 'Playbook checklist',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_RUN,
'Playbook run',
'Playbook checklist',
{playbookRunId: mockPlaybookRun.id},
{},
);
@ -339,11 +339,11 @@ describe('Playbooks Navigation', () => {
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.playbook_run.title',
defaultMessage: 'Playbook run',
defaultMessage: 'Playbook checklist',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_RUN,
'Playbook run',
'Playbook checklist',
{playbookRunId: mockPlaybookRunModel.id},
{},
);
@ -401,7 +401,7 @@ describe('Playbooks Navigation', () => {
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_SELECT_PLAYBOOK,
'Start a run',
'New',
{channelId},
{
topBar: {
@ -419,7 +419,7 @@ describe('Playbooks Navigation', () => {
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_SELECT_PLAYBOOK,
'Start a run',
'New',
{channelId: undefined},
{
topBar: {
@ -446,7 +446,7 @@ describe('Playbooks Navigation', () => {
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_START_A_RUN,
'Start a run',
'New',
{playbook, onRunCreated, channelId},
{
topBar: {
@ -470,7 +470,7 @@ describe('Playbooks Navigation', () => {
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_START_A_RUN,
'Start a run',
'New',
{playbook, onRunCreated, channelId: undefined},
{
topBar: {

View file

@ -14,7 +14,7 @@ import type {Options as RNNOptions} from 'react-native-navigation';
export function goToPlaybookRuns(intl: IntlShape, channelId: string, channelName: string) {
const theme = getThemeFromState();
const title = intl.formatMessage({id: 'playbooks.playbooks_runs.title', defaultMessage: 'Playbook runs'});
const title = intl.formatMessage({id: 'playbooks.playbooks_runs.title', defaultMessage: 'Playbook checklists'});
goToScreen(Screens.PLAYBOOKS_RUNS, title, {channelId}, {
topBar: {
subtitle: {
@ -26,12 +26,12 @@ export function goToPlaybookRuns(intl: IntlShape, channelId: string, channelName
}
export function goToParticipantPlaybooks(intl: IntlShape) {
const title = intl.formatMessage({id: 'playbooks.participant_playbooks.title', defaultMessage: 'Playbook runs'});
const title = intl.formatMessage({id: 'playbooks.participant_playbooks.title', defaultMessage: 'Playbook checklists'});
goToScreen(Screens.PARTICIPANT_PLAYBOOKS, title, {}, {});
}
export async function goToPlaybookRun(intl: IntlShape, playbookRunId: string, playbookRun?: PlaybookRun) {
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook run'});
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook checklist'});
goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId, playbookRun}, {});
}
@ -47,7 +47,7 @@ export async function goToPlaybookRunWithChannelSwitch(intl: IntlShape, serverUr
}
// Then navigate to the playbook run
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook run'});
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook checklist'});
goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId: playbookRun.id}, {});
}
@ -84,6 +84,47 @@ export async function goToEditCommand(
}, options);
}
export async function goToRenameChecklist(
intl: IntlShape,
theme: Theme,
runName: string,
currentTitle: string,
onSave: (newTitle: string) => void,
) {
const title = intl.formatMessage({id: 'playbooks.checklist.rename.title', defaultMessage: 'Rename checklist'});
const options = getSubtitleOptions(theme, runName);
goToScreen(Screens.PLAYBOOK_RENAME_CHECKLIST, title, {
currentTitle,
onSave,
}, options);
}
export async function goToAddChecklistItem(
intl: IntlShape,
theme: Theme,
runName: string,
onSave: (title: string) => void,
) {
const title = intl.formatMessage({id: 'playbooks.checklist_item.add.title', defaultMessage: 'New Task'});
const options = getSubtitleOptions(theme, runName);
goToScreen(Screens.PLAYBOOK_ADD_CHECKLIST_ITEM, title, {
onSave,
}, options);
}
export async function goToRenamePlaybookRun(
intl: IntlShape,
theme: Theme,
currentTitle: string,
onSave: (newTitle: string) => void,
) {
const title = intl.formatMessage({id: 'playbooks.playbook_run.rename.title', defaultMessage: 'Rename playbook run'});
goToScreen(Screens.PLAYBOOK_RENAME_RUN, title, {
currentTitle,
onSave,
});
}
export async function goToSelectUser(
theme: Theme,
runName: string,
@ -122,7 +163,8 @@ export async function goToSelectPlaybook(
theme: Theme,
channelId?: string,
) {
const title = intl.formatMessage({id: 'playbooks.select_playbook.title', defaultMessage: 'Start a run'});
const title = intl.formatMessage({id: 'playbooks.select_playbook.title', defaultMessage: 'New'});
goToScreen(Screens.PLAYBOOKS_SELECT_PLAYBOOK, title, {channelId}, {
topBar: {
subtitle: {
@ -134,7 +176,7 @@ export async function goToSelectPlaybook(
}
export async function goToStartARun(intl: IntlShape, theme: Theme, playbook: Playbook, onRunCreated: (run: PlaybookRun) => void, channelId?: string) {
const title = intl.formatMessage({id: 'playbooks.start_a_run.title', defaultMessage: 'Start a run'});
const title = intl.formatMessage({id: 'playbooks.start_a_run.title', defaultMessage: 'New'});
const subtitle = playbook.title;
goToScreen(Screens.PLAYBOOKS_START_A_RUN, title, {playbook, onRunCreated, channelId}, {
topBar: {

View file

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
onSave: (title: string) => void;
}
const SAVE_BUTTON_ID = 'add-checklist-item';
const close = (componentId: AvailableScreens): void => {
Keyboard.dismiss();
popTopScreen(componentId);
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingVertical: 32,
paddingHorizontal: 20,
},
});
const AddChecklistItemBottomSheet = ({
componentId,
onSave,
}: Props) => {
const intl = useIntl();
const {formatMessage} = intl;
const theme = useTheme();
const [title, setTitle] = useState<string>('');
const [canSave, setCanSave] = useState(false);
const rightButton = React.useMemo(() => {
const base = buildNavigationButton(
SAVE_BUTTON_ID,
'playbooks.checklist_item.add.save',
undefined,
formatMessage({id: 'playbooks.checklist_item.add.save', defaultMessage: 'Add'}),
);
base.enabled = canSave;
base.color = theme.sidebarHeaderTextColor;
return base;
}, [formatMessage, canSave, theme.sidebarHeaderTextColor]);
useEffect(() => {
setButtons(componentId, {
rightButtons: [rightButton],
});
}, [rightButton, componentId]);
useEffect(() => {
setCanSave(title.trim().length > 0);
}, [title]);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
const handleSave = useCallback(() => {
if (title.trim().length > 0) {
onSave(title.trim());
close(componentId);
}
}, [title, componentId, onSave]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);
const label = formatMessage({id: 'playbooks.checklist_item.add.label', defaultMessage: 'Task name'});
return (
<View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.container}
>
<FloatingTextInput
label={label}
onChangeText={setTitle}
testID='playbooks.checklist_item.add.input'
value={title}
theme={theme}
autoFocus={true}
/>
</View>
);
};
export default AddChecklistItemBottomSheet;

View file

@ -47,6 +47,7 @@ describe('Checklist', () => {
items: mockItems,
channelId: 'channel-id-1',
playbookRunId: 'run-id-1',
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
checklistProgress: {

View file

@ -2,12 +2,16 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity, type LayoutChangeEvent, useWindowDimensions, StyleSheet} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {renameChecklist, addChecklistItem} from '@playbooks/actions/remote/checklist';
import ProgressBar from '@playbooks/components/progress_bar';
import {goToRenameChecklist, goToAddChecklistItem} from '@playbooks/screens/navigation';
import {getChecklistProgress} from '@playbooks/utils/progress';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
@ -37,6 +41,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
fontSize: 18,
color: changeOpacity(theme.centerChannelColor, 0.56),
},
editIconContainer: {
alignItems: 'flex-end',
},
editIcon: {
fontSize: 18,
color: changeOpacity(theme.centerChannelColor, 0.56),
paddingHorizontal: 4,
},
progressText: {
...typography('Body', 100, 'Regular'),
color: changeOpacity(theme.centerChannelColor, 0.48),
@ -62,7 +74,29 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
textDecorationLine: 'line-through',
},
titleContainer: {
flexShrink: 1,
flex: 1,
},
progressAndEditContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginLeft: 'auto',
},
addButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
gap: 6,
},
addButtonText: {
...typography('Body', 100, 'SemiBold'),
color: changeOpacity(theme.centerChannelColor, 0.64),
},
addButtonIcon: {
fontSize: 18,
color: changeOpacity(theme.centerChannelColor, 0.64),
},
}));
@ -72,6 +106,7 @@ type Props = {
items: Array<PlaybookChecklistItemModel | PlaybookChecklistItem>;
channelId: string;
playbookRunId: string;
playbookRunName: string;
isFinished: boolean;
isParticipant: boolean;
checklistProgress: ReturnType<typeof getChecklistProgress>;
@ -83,6 +118,7 @@ const Checklist = ({
items,
channelId,
playbookRunId,
playbookRunName,
isFinished,
isParticipant,
checklistProgress: {
@ -93,6 +129,8 @@ const Checklist = ({
},
}: Props) => {
const [expanded, setExpanded] = useState(true);
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const height = useSharedValue(0);
@ -102,6 +140,23 @@ const Checklist = ({
setExpanded((prev) => !prev);
}, []);
const handleRename = useCallback((newTitle: string) => {
renameChecklist(serverUrl, playbookRunId, checklistNumber, checklist.id, newTitle);
}, [serverUrl, playbookRunId, checklist.id, checklistNumber]);
const handleEditPress = useCallback((e: any) => {
e.stopPropagation();
goToRenameChecklist(intl, theme, playbookRunName, checklist.title, handleRename);
}, [intl, theme, playbookRunName, checklist.title, handleRename]);
const handleAddItem = useCallback((title: string) => {
addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
}, [serverUrl, playbookRunId, checklistNumber]);
const handleAddPress = useCallback(() => {
goToAddChecklistItem(intl, theme, playbookRunName, handleAddItem);
}, [intl, theme, playbookRunName, handleAddItem]);
const animatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(expanded ? height.value : 0, {duration: 300}),
@ -145,7 +200,15 @@ const Checklist = ({
{checklist.title}
</Text>
</View>
<Text style={styles.progressText}>{`${completed} / ${totalNumber} done`}</Text>
<View style={styles.progressAndEditContainer}>
<Text style={styles.progressText}>{`${completed} / ${totalNumber} done`}</Text>
<TouchableOpacity onPress={handleEditPress}>
<CompassIcon
name='pencil-outline'
style={styles.editIcon}
/>
</TouchableOpacity>
</View>
</View>
<ProgressBar
progress={progress}
@ -167,6 +230,21 @@ const Checklist = ({
isDisabled={isFinished || !isParticipant}
/>
))}
{!isFinished && isParticipant && (
<TouchableOpacity
style={styles.addButton}
onPress={handleAddPress}
testID='add-checklist-item-button'
>
<CompassIcon
name='plus'
style={styles.addButtonIcon}
/>
<Text style={styles.addButtonText}>
{intl.formatMessage({id: 'playbooks.checklist_item.add.button', defaultMessage: 'New'})}
</Text>
</TouchableOpacity>
)}
</Animated.View>
{/* This is a hack to get the height of the checklist items */}
<View
@ -184,6 +262,17 @@ const Checklist = ({
isDisabled={isFinished || !isParticipant}
/>
))}
{!isFinished && isParticipant && (
<View style={styles.addButton}>
<CompassIcon
name='plus'
style={styles.addButtonIcon}
/>
<Text style={styles.addButtonText}>
{intl.formatMessage({id: 'playbooks.checklist_item.add.button', defaultMessage: 'New'})}
</Text>
</View>
)}
</View>
</View>
);

View file

@ -63,6 +63,7 @@ describe('Checklist', () => {
checklistNumber: 0,
channelId: 'channel-id',
playbookRunId: 'run-id',
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
};
@ -181,6 +182,7 @@ describe('Checklist', () => {
checklistNumber: 0,
channelId: 'channel-id',
playbookRunId: 'run-id',
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
};
@ -240,6 +242,7 @@ describe('Checklist', () => {
checklistNumber: 0,
channelId: 'channel-id',
playbookRunId: 'run-id',
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
};
@ -271,6 +274,7 @@ describe('Checklist', () => {
checklistNumber: 0,
channelId: 'channel-id',
playbookRunId: 'run-id',
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
};
@ -323,6 +327,7 @@ describe('Checklist', () => {
checklistNumber: 0,
channelId: 'channel-id',
playbookRunId: 'run-id',
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
};

View file

@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
currentTitle: string;
onSave: (newTitle: string) => void;
}
const SAVE_BUTTON_ID = 'save-checklist-name';
const close = (componentId: AvailableScreens): void => {
Keyboard.dismiss();
popTopScreen(componentId);
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingVertical: 32,
paddingHorizontal: 20,
},
});
const RenameChecklistBottomSheet = ({
componentId,
currentTitle,
onSave,
}: Props) => {
const intl = useIntl();
const {formatMessage} = intl;
const theme = useTheme();
const [title, setTitle] = useState<string>(currentTitle);
const [canSave, setCanSave] = useState(false);
const rightButton = React.useMemo(() => {
const base = buildNavigationButton(
SAVE_BUTTON_ID,
'playbooks.checklist.rename.button',
undefined,
formatMessage({id: 'playbooks.checklist.rename.button', defaultMessage: 'Save'}),
);
base.enabled = canSave;
base.color = theme.sidebarHeaderTextColor;
return base;
}, [formatMessage, canSave, theme.sidebarHeaderTextColor]);
useEffect(() => {
setButtons(componentId, {
rightButtons: [rightButton],
});
}, [rightButton, componentId]);
useEffect(() => {
setCanSave(title.trim().length > 0 && title !== currentTitle);
}, [title, currentTitle]);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
const handleSave = useCallback(() => {
if (title.trim().length > 0) {
onSave(title.trim());
close(componentId);
}
}, [title, componentId, onSave]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);
const label = formatMessage({id: 'playbooks.checklist.rename.label', defaultMessage: 'Section name'});
return (
<View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.container}
>
<FloatingTextInput
label={label}
onChangeText={setTitle}
testID='playbooks.checklist.rename.input'
value={title}
theme={theme}
autoFocus={true}
/>
</View>
);
};
export default RenameChecklistBottomSheet;

View file

@ -31,6 +31,7 @@ describe('ChecklistList', () => {
checklists: mockChecklists,
channelId: 'channel-id-1',
playbookRunId: 'run-id-1',
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
};

View file

@ -12,6 +12,7 @@ type Props = {
checklists: Array<PlaybookChecklistModel | PlaybookChecklist>;
channelId: string;
playbookRunId: string;
playbookRunName: string;
isFinished: boolean;
isParticipant: boolean;
}
@ -26,6 +27,7 @@ const ChecklistList = ({
checklists,
channelId,
playbookRunId,
playbookRunName,
isFinished,
isParticipant,
}: Props) => {
@ -37,6 +39,7 @@ const ChecklistList = ({
checklist={checklist}
channelId={channelId}
playbookRunId={playbookRunId}
playbookRunName={playbookRunName}
checklistNumber={index}
isFinished={isFinished}
isParticipant={isParticipant}

View file

@ -17,7 +17,7 @@ describe('ErrorState', () => {
it('renders error state correctly', () => {
const {getByText} = renderWithIntl(<ErrorState/>);
expect(getByText('Unable to fetch run details')).toBeTruthy();
expect(getByText('Unable to fetch details')).toBeTruthy();
expect(getByText('Please check your network connection or try again later.')).toBeTruthy();
});

View file

@ -38,7 +38,7 @@ const getStylesFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
const messages = defineMessages({
title: {
id: 'playbooks.playbook_run.error.title',
defaultMessage: 'Unable to fetch run details',
defaultMessage: 'Unable to fetch details',
},
description: {
id: 'playbooks.playbook_run.error.description',

View file

@ -193,7 +193,7 @@ describe('PlaybookRun', () => {
const userAvatarsStack = getByTestId('user-avatars-stack');
expect(userAvatarsStack.props.users).toBe(props.participants);
expect(userAvatarsStack.props.location).toBe('PlaybookRun');
expect(userAvatarsStack.props.bottomSheetTitle.defaultMessage).toBe('Run Participants');
expect(userAvatarsStack.props.bottomSheetTitle.defaultMessage).toBe('Participants');
props.owner = undefined;
rerender(<PlaybookRun {...props}/>);
@ -330,7 +330,7 @@ describe('PlaybookRun', () => {
const {getByText} = renderWithEverything(<PlaybookRun {...props}/>, {database});
expect(getByText('Finish Run')).toBeTruthy();
expect(getByText('Finish')).toBeTruthy();
});
it('handles finish run button press', () => {
@ -338,12 +338,12 @@ describe('PlaybookRun', () => {
props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId}));
const {getByText} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const finishRunButton = getByText('Finish Run');
const finishRunButton = getByText('Finish');
fireEvent.press(finishRunButton);
expect(Alert.alert).toHaveBeenCalledWith(
'Finish Run',
'There are 3 tasks pending.\n\nAre you sure you want to finish the run for all participants?',
'Finish',
'There are 3 tasks pending.\n\nAre you sure you want to finish the checklist for all participants?',
[
{text: 'Cancel', style: 'cancel'},
{text: 'Finish', style: 'destructive', onPress: expect.any(Function)},
@ -358,7 +358,7 @@ describe('PlaybookRun', () => {
const props = getBaseProps();
const {queryByText} = renderWithEverything(<PlaybookRun {...props}/>, {database});
expect(queryByText('Finish Run')).toBeNull();
expect(queryByText('Finish')).toBeNull();
});
it('shows the error snackbar when finishing run fails', async () => {
@ -367,7 +367,7 @@ describe('PlaybookRun', () => {
jest.mocked(finishRun).mockResolvedValue({error: 'error'});
const {getByText} = renderWithEverything(<PlaybookRun {...props}/>, {database});
const finishRunButton = getByText('Finish Run');
const finishRunButton = getByText('Finish');
fireEvent.press(finishRunButton);
const finishAction = jest.mocked(Alert.alert).mock.calls[0][2]![1];

View file

@ -15,6 +15,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {finishRun, setOwner} from '@playbooks/actions/remote/runs';
import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run';
import {getRunScheduledTimestamp, isRunFinished} from '@playbooks/utils/run';
import {openUserProfileModal, popTopScreen} from '@screens/navigation';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
@ -52,11 +53,11 @@ const messages = defineMessages({
},
participantsTitle: {
id: 'playbooks.playbook_run.participants_title',
defaultMessage: 'Run Participants',
defaultMessage: 'Participants',
},
runDetails: {
id: 'playbooks.playbook_run.run_details',
defaultMessage: 'Run details',
defaultMessage: 'Checklist details',
},
overdue: {
id: 'playbooks.playbook_run.overdue',
@ -68,11 +69,11 @@ const messages = defineMessages({
},
finishRunDialogTitle: {
id: 'playbooks.playbook_run.finish_run_dialog_title',
defaultMessage: 'Finish Run',
defaultMessage: 'Finish',
},
finishRunDialogDescription: {
id: 'playbooks.playbook_run.finish_run_dialog_description',
defaultMessage: 'There are {pendingCount} {pendingCount, plural, =1 {task} other {tasks}} pending.\n\nAre you sure you want to finish the run for all participants?',
defaultMessage: 'There are {pendingCount} {pendingCount, plural, =1 {task} other {tasks}} pending.\n\nAre you sure you want to finish the checklist for all participants?',
},
finishRunDialogCancel: {
id: 'playbooks.playbook_run.finish_run_dialog_cancel',
@ -84,7 +85,7 @@ const messages = defineMessages({
},
finishRunButton: {
id: 'playbooks.playbook_run.finish_run_button',
defaultMessage: 'Finish Run',
defaultMessage: 'Finish',
},
});
@ -100,9 +101,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
gap: 10,
alignItems: 'flex-start',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
title: {
...typography('Heading', 400, 'SemiBold'),
color: theme.centerChannelColor,
flex: 1,
},
editIcon: {
fontSize: 18,
color: changeOpacity(theme.centerChannelColor, 0.56),
paddingHorizontal: 4,
},
infoText: {
...typography('Body', 100, 'Regular'),
@ -186,6 +198,8 @@ export default function PlaybookRun({
const isFinished = isRunFinished(playbookRun);
const readOnly = isFinished || !isParticipant;
const playbookRunType = useMemo(() => playbookRun?.type || 'playbook', [playbookRun]);
const containerStyle = useMemo(() => {
return [
styles.container,
@ -231,6 +245,26 @@ export default function PlaybookRun({
);
}, [handleSelectOwner, intl, owner, participants, playbookRun?.name, theme]);
// this will be back once there is a rename function on the server side
// const handleRename = useCallback(async (newName: string) => {
// if (!playbookRun) {
// return;
// }
// const res = await renamePlaybookRun(serverUrl, playbookRun.id, newName);
// if (res.error) {
// showPlaybookErrorSnackbar();
// }
// }, [playbookRun, serverUrl]);
// const handleEditPress = useCallback(() => {
// if (!playbookRun) {
// return;
// }
// goToRenamePlaybookRun(intl, theme, playbookRun.name, handleRename);
// }, [intl, theme, playbookRun, handleRename]);
const handleFinishRun = useCallback(() => {
if (!playbookRun) {
return;
@ -279,7 +313,16 @@ export default function PlaybookRun({
<ScrollView contentContainerStyle={styles.scrollView}>
<View style={styles.intro}>
<View style={styles.titleAndDescription}>
<Text style={styles.title}>{playbookRun.name}</Text>
<View style={styles.titleRow}>
<Text style={styles.title}>{playbookRun.name}</Text>
{/* This will be back once there is a rename function on the server side
<TouchableOpacity onPress={handleEditPress}>
<CompassIcon
name='pencil-outline'
style={styles.editIcon}
/>
</TouchableOpacity> */}
</View>
{isFinished && (
<Tag
message={messages.finished}
@ -330,12 +373,14 @@ export default function PlaybookRun({
)}
</View>
)}
<StatusUpdateIndicator
isFinished={isFinished}
timestamp={getRunScheduledTimestamp(playbookRun)}
isParticipant={isParticipant}
playbookRunId={playbookRun.id}
/>
{playbookRunType !== PLAYBOOK_RUN_TYPES.ChannelChecklistType && (
<StatusUpdateIndicator
isFinished={isFinished}
timestamp={getRunScheduledTimestamp(playbookRun)}
isParticipant={isParticipant}
playbookRunId={playbookRun.id}
/>
)}
</View>
<View style={styles.tasksContainer}>
<View style={styles.tasksHeaderContainer}>
@ -353,6 +398,7 @@ export default function PlaybookRun({
checklists={checklists}
channelId={channelId}
playbookRunId={playbookRun.id}
playbookRunName={playbookRun.name}
isFinished={isRunFinished(playbookRun)}
isParticipant={isParticipant}
/>

View file

@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import SecurityManager from '@managers/security_manager';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
currentTitle: string;
onSave: (newTitle: string) => void;
}
const SAVE_BUTTON_ID = 'save-playbook-run-name';
const close = (componentId: AvailableScreens): void => {
Keyboard.dismiss();
popTopScreen(componentId);
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingVertical: 32,
paddingHorizontal: 20,
},
});
const RenamePlaybookRunBottomSheet = ({
componentId,
currentTitle,
onSave,
}: Props) => {
const intl = useIntl();
const {formatMessage} = intl;
const theme = useTheme();
const [title, setTitle] = useState<string>(currentTitle);
const [canSave, setCanSave] = useState(false);
const rightButton = React.useMemo(() => {
const base = buildNavigationButton(
SAVE_BUTTON_ID,
'playbooks.playbook_run.rename.button',
undefined,
formatMessage({id: 'playbooks.playbook_run.rename.button', defaultMessage: 'Save'}),
);
base.enabled = canSave;
base.color = theme.sidebarHeaderTextColor;
return base;
}, [formatMessage, canSave, theme.sidebarHeaderTextColor]);
useEffect(() => {
setButtons(componentId, {
rightButtons: [rightButton],
});
}, [rightButton, componentId]);
useEffect(() => {
setCanSave(title.trim().length > 0 && title !== currentTitle);
}, [title, currentTitle]);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
const handleSave = useCallback(() => {
if (title.trim().length > 0) {
onSave(title.trim());
close(componentId);
}
}, [title, componentId, onSave]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);
const label = formatMessage({id: 'playbooks.playbook_run.rename.label', defaultMessage: 'Checklist name'});
return (
<View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.container}
>
<FloatingTextInput
label={label}
onChangeText={setTitle}
testID='playbooks.playbook_run.rename.input'
value={title}
theme={theme}
autoFocus={true}
/>
</View>
);
};
export default RenamePlaybookRunBottomSheet;

View file

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

View file

@ -71,7 +71,7 @@ const messages = defineMessages({
},
finished: {
id: 'playbooks.playbook_run.status_update_finished',
defaultMessage: 'Run finished\n{time}',
defaultMessage: 'Finished at\n{time}',
},
update: {
id: 'playbooks.playbook_run.status_update',

View file

@ -140,7 +140,7 @@ describe('PostUpdate', () => {
const {getByText, getByTestId} = renderWithIntlAndTheme(<PostUpdate {...props}/>);
await waitFor(() => {
expect(getByText(/This update for the run/)).toBeTruthy();
expect(getByText(/This update for the checklist/)).toBeTruthy();
const input = getByTestId('FloatingTextInput');
expect(input).toHaveProp('value', 'Default message template');
expect(input).toHaveProp('label', 'Update message');
@ -161,7 +161,7 @@ describe('PostUpdate', () => {
expect(selector.props.options).toHaveLength(6);
const toggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
expect(toggle).toHaveProp('label', 'Also mark the run as finished');
expect(toggle).toHaveProp('label', 'Also mark the checklist as finished');
expect(toggle).toHaveProp('selected', false);
expect(toggle).toHaveProp('type', 'toggle');
});
@ -221,7 +221,7 @@ describe('PostUpdate', () => {
const {getByText} = renderWithIntlAndTheme(<PostUpdate {...props}/>);
await waitFor(() => {
expect(getByText('This update for the run Test Run will be broadcasted to 2 channels and 2 direct messages.')).toBeTruthy();
expect(getByText('This update for the checklist Test Run will be broadcasted to 2 channels and 2 direct messages.')).toBeTruthy();
});
});
@ -430,15 +430,15 @@ describe('PostUpdate', () => {
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
'Confirm finish run',
'Are you sure you want to finish the run Test Run for all participants?',
'Confirm finish checklist',
'Are you sure you want to finish the checklist Test Run for all participants?',
expect.arrayContaining([
expect.objectContaining({
text: 'Cancel',
style: 'cancel',
}),
expect.objectContaining({
text: 'Finish run',
text: 'Finish',
}),
]),
);
@ -470,15 +470,15 @@ describe('PostUpdate', () => {
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
'Confirm finish run',
'There are 3 outstanding tasks. Are you sure you want to finish the run Test Run for all participants?',
'Confirm finish checklist',
'There are 3 outstanding tasks. Are you sure you want to finish the checklist Test Run for all participants?',
expect.arrayContaining([
expect.objectContaining({
text: 'Cancel',
style: 'cancel',
}),
expect.objectContaining({
text: 'Finish run',
text: 'Finish',
}),
]),
);
@ -507,7 +507,7 @@ describe('PostUpdate', () => {
// Simulate alert confirm button press
const alertCall = jest.mocked(Alert.alert).mock.calls[0];
const confirmButton = alertCall[2]?.find((button) => button.text === 'Finish run');
const confirmButton = alertCall[2]?.find((button) => button.text === 'Finish');
await act(async () => {
confirmButton?.onPress?.();

View file

@ -226,12 +226,12 @@ const PostUpdate = ({
const onPostUpdate = useCallback(() => {
if (alsoMarkRunAsFinished) {
let message = intl.formatMessage({id: 'playbooks.post_update.confirm.message', defaultMessage: 'Are you sure you want to finish the run {runName} for all participants?'}, {runName});
let message = intl.formatMessage({id: 'playbooks.post_update.confirm.message', defaultMessage: 'Are you sure you want to finish the checklist {runName} for all participants?'}, {runName});
if (outstanding > 0) {
message = intl.formatMessage({id: 'playbooks.post_update.confirm.message.with_tasks', defaultMessage: 'There {outstanding, plural, =1 {is # outstanding task} other {are # outstanding tasks}}. Are you sure you want to finish the run {runName} for all participants?'}, {runName, outstanding});
message = intl.formatMessage({id: 'playbooks.post_update.confirm.message.with_tasks', defaultMessage: 'There {outstanding, plural, =1 {is # outstanding task} other {are # outstanding tasks}}. Are you sure you want to finish the checklist {runName} for all participants?'}, {runName, outstanding});
}
Alert.alert(
intl.formatMessage({id: 'playbooks.post_update.confirm.title', defaultMessage: 'Confirm finish run'}),
intl.formatMessage({id: 'playbooks.post_update.confirm.title', defaultMessage: 'Confirm finish checklist'}),
message,
[
{
@ -239,7 +239,7 @@ const PostUpdate = ({
style: 'cancel',
},
{
text: intl.formatMessage({id: 'playbooks.post_update.confirm.confirm', defaultMessage: 'Finish run'}),
text: intl.formatMessage({id: 'playbooks.post_update.confirm.confirm', defaultMessage: 'Finish'}),
onPress: onConfirm,
},
],
@ -279,7 +279,7 @@ const PostUpdate = ({
introMessage = (
<FormattedMessage
id='playbooks.post_update.intro'
defaultMessage='This update for the run <Bold>{runName}</Bold> will be broadcasted to {hasChannels, select, true {<Bold>{broadcastChannelCount, plural, =1 {one channel} other {{broadcastChannelCount, number} channels}}</Bold>} other {}}{hasFollowersAndChannels, select, true { and } other {}}{hasFollowers, select, true {<Bold>{followersChannelCount, plural, =1 {one direct message} other {{followersChannelCount, number} direct messages}}</Bold>} other {}}.'
defaultMessage='This update for the checklist <Bold>{runName}</Bold> will be broadcasted to {hasChannels, select, true {<Bold>{broadcastChannelCount, plural, =1 {one channel} other {{broadcastChannelCount, number} channels}}</Bold>} other {}}{hasFollowersAndChannels, select, true { and } other {}}{hasFollowers, select, true {<Bold>{followersChannelCount, plural, =1 {one direct message} other {{followersChannelCount, number} direct messages}}</Bold>} other {}}.'
values={introMessageValues}
/>
);
@ -310,7 +310,7 @@ const PostUpdate = ({
label={intl.formatMessage({id: 'playbooks.post_update.label.next_update', defaultMessage: 'Timer for next update'})}
/>
<OptionItem
label={intl.formatMessage({id: 'playbooks.post_update.label.also_mark_run_as_finished', defaultMessage: 'Also mark the run as finished'})}
label={intl.formatMessage({id: 'playbooks.post_update.label.also_mark_run_as_finished', defaultMessage: 'Also mark the checklist as finished'})}
action={setAlsoMarkRunAsFinished}
testID='playbooks.post_update.selector.also_mark_run_as_finished'
selected={alsoMarkRunAsFinished}

View file

@ -96,7 +96,7 @@ describe('PlaybookRow', () => {
<PlaybookRow {...props}/>,
);
expect(getByText(/No runs in progress/)).toBeTruthy();
expect(getByText(/No checklists in progress/)).toBeTruthy();
});
it('displays single run in progress correctly', () => {
@ -107,7 +107,7 @@ describe('PlaybookRow', () => {
<PlaybookRow {...props}/>,
);
expect(getByText(/1 run in progress/)).toBeTruthy();
expect(getByText(/1 checklist in progress/)).toBeTruthy();
});
it('displays multiple runs in progress correctly', () => {
@ -118,7 +118,7 @@ describe('PlaybookRow', () => {
<PlaybookRow {...props}/>,
);
expect(getByText(/5 runs in progress/)).toBeTruthy();
expect(getByText(/5 checklists in progress/)).toBeTruthy();
});
it('calls onPress when pressed', async () => {

View file

@ -72,12 +72,12 @@ const PlaybookRow = ({
if (activeRuns === 0) {
return intl.formatMessage({
id: 'playbooks.row.no_runs',
defaultMessage: 'No runs in progress',
defaultMessage: 'No checklists in progress',
});
}
return intl.formatMessage({
id: 'playbooks.row.runs',
defaultMessage: '{count} {count, plural, one {run} other {runs}} in progress',
defaultMessage: '{count} {count, plural, one {checklist} other {checklists}} in progress',
}, {count: activeRuns});
};

View file

@ -288,7 +288,7 @@ describe('SelectUser', () => {
const sections = customSection(profiles);
expect(sections).toHaveLength(2);
expect(sections[0].id).toBe('RUN PARTICIPANTS');
expect(sections[0].id).toBe('PARTICIPANTS');
expect(sections[0].data).toContain(participantUser);
expect(sections[1].id).toBe('NOT PARTICIPATING');
expect(sections[1].data).toContain(nonParticipantUser);

View file

@ -40,7 +40,7 @@ export type Props = {
const messages = defineMessages({
participants: {
id: 'playbooks.select_user.participants',
defaultMessage: 'RUN PARTICIPANTS',
defaultMessage: 'PARTICIPANTS',
},
notParticipants: {
id: 'playbooks.select_user.not_participants',

View file

@ -184,11 +184,11 @@ function StartARun({
<FloatingTextInput
label={intl.formatMessage({
id: 'playbooks.start_run.run_name_label',
defaultMessage: 'Run name',
defaultMessage: 'Name',
})}
placeholder={intl.formatMessage({
id: 'playbooks.start_run.run_name_placeholder',
defaultMessage: 'Add a name for your run',
defaultMessage: 'Add a name',
})}
value={runName}
onChangeText={setRunName}
@ -196,13 +196,13 @@ function StartARun({
testID='start_run.run_name_input'
error={runName.trim() ? undefined : intl.formatMessage({
id: 'playbooks.start_run.run_name_error',
defaultMessage: 'Please add a name for this run',
defaultMessage: 'Please add a name',
})}
/>
<FloatingTextInput
label={intl.formatMessage({
id: 'playbooks.start_run.run_description_label',
defaultMessage: 'Run description',
defaultMessage: 'Description',
})}
value={runDescription}
onChangeText={setRunDescription}

View file

@ -92,6 +92,8 @@ type PlaybookRunAttributeValue = {
value: string;
}
type PlaybookRunType = typeof PLAYBOOK_RUN_TYPES[keyof typeof PLAYBOOK_RUN_TYPES];
type PlaybookRun = {
id: string;
name: string;
@ -108,7 +110,8 @@ type PlaybookRun = {
create_at: number;
end_at: number;
post_id?: string;
playbook_id: string;
playbook_id?: string;
type?: PlaybookRunType;
current_status: PlaybookRunStatusType;
last_status_update_at: number;
reminder_post_id?: string;

View file

@ -23,6 +23,9 @@ declare class PlaybookRunModel extends Model {
// Foreign key to the playbook that generated this run
playbookId: string;
// The type of run ('playbook' or 'channelChecklist')
type: PlaybookRunType;
// ID of the post that created the run (nullable)
postId: string | null;

View file

@ -6,7 +6,7 @@ import React, {type ComponentProps} from 'react';
import NavigationHeader from '@components/navigation_header';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {createPlaybookRun, fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {goToPlaybookRun, goToPlaybookRuns} from '@playbooks/screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {renderWithIntl, waitFor} from '@test/intl-test-helper';
@ -38,6 +38,7 @@ describe('ChannelHeader', () => {
return {
channelId: 'channel-id',
channelType: 'O',
currentUserId: 'current-user-id',
displayName: 'Test Channel',
teamId: 'team-id',
hasPlaybookRuns: false,
@ -60,17 +61,20 @@ describe('ChannelHeader', () => {
jest.clearAllMocks();
});
it('does not show playbook button when there are no active runs', () => {
it('shows playbook button with "+" when there are no active runs', () => {
const props = getBaseProps();
props.hasPlaybookRuns = false;
props.playbooksActiveRuns = 0;
renderWithIntl(<ChannelHeader {...props}/>);
props.isPlaybooksEnabled = true;
const navHeader = jest.mocked(NavigationHeader).mock.calls[0][0];
expect(navHeader.rightButtons).toEqual(
const {getByTestId} = renderWithIntl(<ChannelHeader {...props}/>);
const navHeader = getByTestId('navigation-header');
expect(navHeader.props.rightButtons).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
expect.objectContaining({
iconName: 'product-playbooks',
count: '+',
}),
]),
);
@ -168,6 +172,44 @@ describe('ChannelHeader', () => {
expect(goToPlaybookRun).not.toHaveBeenCalled();
});
it('creates a new playbook run when clicking + button with no active runs', async () => {
const mockCreatedRun = {
id: 'new-run-id',
channel_id: 'channel-id',
name: 'Test Channel Checklist',
} as any;
jest.mocked(createPlaybookRun).mockResolvedValue({data: mockCreatedRun});
jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValue({runs: [mockCreatedRun]});
const props = getBaseProps();
props.playbooksActiveRuns = 0;
props.hasPlaybookRuns = false;
props.displayName = 'Test Channel';
const {getByTestId} = renderWithIntl(<ChannelHeader {...props}/>);
const navHeader = getByTestId('navigation-header');
const playbookButton = (navHeader.props as ComponentProps<typeof NavigationHeader>).rightButtons?.find((button) => button.iconName === 'product-playbooks');
expect(playbookButton).toBeTruthy();
expect(playbookButton?.count).toBe('+');
playbookButton?.onPress();
await waitFor(() => {
expect(createPlaybookRun).toHaveBeenCalledWith(
serverUrl,
'', // empty playbook_id
'current-user-id',
'team-id',
'Test Channel Checklist',
'', // empty description
'channel-id',
);
expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, 'channel-id');
expect(goToPlaybookRun).toHaveBeenCalledWith(expect.anything(), 'new-run-id');
});
});
it('should not fetch runs when playbooks are disabled', async () => {
const ephemeralGetSpy = jest.spyOn(EphemeralStore, 'getChannelPlaybooksSynced');

View file

@ -19,14 +19,17 @@ import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {usePreventDoubleTap} from '@hooks/utils';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {createPlaybookRun, fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {goToPlaybookRun, goToPlaybookRuns} from '@playbooks/screens/navigation';
import {BOTTOM_SHEET_ANDROID_OFFSET} from '@screens/bottom_sheet';
import ChannelBanner from '@screens/channel/header/channel_banner';
import {bottomSheet, popTopScreen, showModal} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {isTypeDMorGM} from '@utils/channel';
import {getFullErrorMessage} from '@utils/errors';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -40,6 +43,7 @@ type ChannelProps = {
canAddBookmarks: boolean;
channelId: string;
channelType: ChannelType;
currentUserId: string;
customStatus?: UserCustomStatus;
isBookmarksEnabled: boolean;
isCustomStatusEnabled: boolean;
@ -93,6 +97,7 @@ const ChannelHeader = ({
channelId,
channelType,
componentId,
currentUserId,
customStatus,
displayName,
hasBookmarks,
@ -214,22 +219,51 @@ const ChannelHeader = ({
});
}, [isTablet, callsAvailable, isDMorGM, hasPlaybookRuns, theme, onTitlePress, channelId]);
const handleCreateQuickRun = useCallback(async () => {
const runName = `${displayName} Checklist`;
const res = await createPlaybookRun(
serverUrl,
'', // empty playbook_id
currentUserId,
teamId,
runName,
'', // empty description
channelId,
);
if (res.error || !res.data) {
logDebug('error on createPlaybookRun', getFullErrorMessage(res.error));
showPlaybookErrorSnackbar();
return;
}
// Fetch updated runs and navigate to the new run
await fetchPlaybookRunsForChannel(serverUrl, channelId);
await goToPlaybookRun(intl, res.data.id);
}, [serverUrl, currentUserId, teamId, displayName, channelId, intl]);
const openPlaybooksRuns = useCallback(() => {
// If no active runs, create a new one instead
if (playbooksActiveRuns === 0) {
handleCreateQuickRun();
return;
}
if (activeRunId) {
goToPlaybookRun(intl, activeRunId);
return;
}
goToPlaybookRuns(intl, channelId, displayName);
}, [activeRunId, channelId, displayName, intl]);
}, [playbooksActiveRuns, handleCreateQuickRun, activeRunId, channelId, displayName, intl]);
const rightButtons = useMemo(() => {
const buttons: HeaderRightButton[] = [];
if (playbooksActiveRuns && !isDMorGM) {
if (isPlaybooksEnabled && !isDMorGM) {
buttons.push({
iconName: 'product-playbooks',
onPress: openPlaybooksRuns,
buttonType: 'opacity',
count: playbooksActiveRuns,
count: playbooksActiveRuns || '+',
});
}
@ -250,7 +284,7 @@ const ChannelHeader = ({
});
return buttons;
}, [playbooksActiveRuns, isDMorGM, onChannelQuickAction, openPlaybooksRuns]);
}, [isPlaybooksEnabled, playbooksActiveRuns, isDMorGM, onChannelQuickAction, openPlaybooksRuns]);
let title = displayName;
if (isOwnDirectMessage) {

View file

@ -111,6 +111,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps
return {
canAddBookmarks,
channelType,
currentUserId,
customStatus,
displayName,
hasBookmarks,

View file

@ -183,7 +183,7 @@ describe('components/categories_list', () => {
/>,
{database},
);
expect(wrapper.queryByText('Playbook runs')).not.toBeTruthy();
expect(wrapper.queryByText('Playbook checklists')).not.toBeTruthy();
});
it('should render channel list with Playbooks menu if playbooks feature is enabled', () => {
@ -198,6 +198,6 @@ describe('components/categories_list', () => {
/>,
{database},
);
expect(wrapper.getByText('Playbook runs')).toBeTruthy();
expect(wrapper.getByText('Playbook checklists')).toBeTruthy();
});
});

View file

@ -233,7 +233,7 @@ describe('parseAndHandleDeepLink', () => {
await parseAndHandleDeepLink('https://existingserver.com/playbooks/playbooks/7b35c77a645e1906e03a2c330f', intl);
expect(alertSpy).toHaveBeenCalledWith(
intl.formatMessage({id: 'playbooks.only_runs_available.title', defaultMessage: 'Playbooks not available'}),
intl.formatMessage({id: 'playbooks.only_runs_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.'}),
intl.formatMessage({id: 'playbooks.only_runs_available.description', defaultMessage: 'Only Playbook Checklists are available on mobile. To access the Playbook, please use the desktop or web app.'}),
[{text: intl.formatMessage({id: 'playbooks.only_runs_available.ok', defaultMessage: 'OK'})}],
);
});
@ -244,8 +244,8 @@ describe('parseAndHandleDeepLink', () => {
jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com');
await parseAndHandleDeepLink('https://existingserver.com/playbooks/runs/7b35c77a645e1906e03a2c330f/retrospective', intl);
expect(alertSpy).toHaveBeenCalledWith(
intl.formatMessage({id: 'playbooks.retrospective_not_available.title', defaultMessage: 'Playbooks Run Retrospective not available'}),
intl.formatMessage({id: 'playbooks.retrospective_not_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To fill the Run Retrospective, please use the desktop or web app.'}),
intl.formatMessage({id: 'playbooks.retrospective_not_available.title', defaultMessage: 'Playbooks Retrospective not available'}),
intl.formatMessage({id: 'playbooks.retrospective_not_available.description', defaultMessage: 'Only Playbook Checklists are available on mobile. To fill the Run Retrospective, please use the desktop or web app.'}),
[{text: intl.formatMessage({id: 'playbooks.retrospective_not_available.ok', defaultMessage: 'OK'})}],
);
});
@ -273,8 +273,8 @@ describe('parseAndHandleDeepLink', () => {
// Re-import to apply mocks
await parseAndHandleDeepLink('https://existingserver.com/playbooks/runs/7b35c77a645e1906e03a2c330f', intl);
expect(alertSpy).toHaveBeenCalledWith(
intl.formatMessage({id: 'playbooks.fetch_error.title', defaultMessage: 'Unable to open Run'}),
intl.formatMessage({id: 'playbooks.fetch_error.description', defaultMessage: "You don't have permission to view this run, or it may no longer exist."}),
intl.formatMessage({id: 'playbooks.fetch_error.title', defaultMessage: 'Unable to open Checklist'}),
intl.formatMessage({id: 'playbooks.fetch_error.description', defaultMessage: "You don't have permission to view this, or it may no longer exist."}),
[{text: intl.formatMessage({id: 'playbooks.fetch_error.OK', defaultMessage: 'Okay'})}],
);
});

View file

@ -118,7 +118,7 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int
// Alert that playbooks should be access from the webapp or desktop app
Alert.alert(
intl.formatMessage({id: 'playbooks.only_runs_available.title', defaultMessage: 'Playbooks not available'}),
intl.formatMessage({id: 'playbooks.only_runs_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.'}),
intl.formatMessage({id: 'playbooks.only_runs_available.description', defaultMessage: 'Only Playbook Checklists are available on mobile. To access the Playbook, please use the desktop or web app.'}),
[{
text: intl.formatMessage({id: 'playbooks.only_runs_available.ok', defaultMessage: 'OK'}),
}],
@ -127,8 +127,8 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int
}
case DeepLink.PlaybookRunsRetrospective: {
Alert.alert(
intl.formatMessage({id: 'playbooks.retrospective_not_available.title', defaultMessage: 'Playbooks Run Retrospective not available'}),
intl.formatMessage({id: 'playbooks.retrospective_not_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To fill the Run Retrospective, please use the desktop or web app.'}),
intl.formatMessage({id: 'playbooks.retrospective_not_available.title', defaultMessage: 'Playbooks Retrospective not available'}),
intl.formatMessage({id: 'playbooks.retrospective_not_available.description', defaultMessage: 'Only Playbook Checklists are available on mobile. To fill the Run Retrospective, please use the desktop or web app.'}),
[{
text: intl.formatMessage({id: 'playbooks.retrospective_not_available.ok', defaultMessage: 'OK'}),
}],
@ -145,8 +145,8 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int
const {error} = await fetchPlaybookRun(existingServerUrl, deepLinkData.playbookRunId);
if (error) {
Alert.alert(
intl.formatMessage({id: 'playbooks.fetch_error.title', defaultMessage: 'Unable to open Run'}),
intl.formatMessage({id: 'playbooks.fetch_error.description', defaultMessage: "You don't have permission to view this run, or it may no longer exist."}),
intl.formatMessage({id: 'playbooks.fetch_error.title', defaultMessage: 'Unable to open Checklist'}),
intl.formatMessage({id: 'playbooks.fetch_error.description', defaultMessage: "You don't have permission to view this, or it may no longer exist."}),
[{
text: intl.formatMessage({id: 'playbooks.fetch_error.OK', defaultMessage: 'Okay'}),
}],

View file

@ -988,7 +988,11 @@
"playbook_run.checklist.taskDetails": "Task Details",
"playbook_run.out_of_date_header.message": "Unable to connect to server. Content may be out of date. Last updated {lastUpdated}.",
"playbook.last_updated": "Last update {date}",
"playbook.participants": "Run Participants",
"playbook.participants": "Participants",
"playbooks.checklist_item.add.button": "New",
"playbooks.checklist_item.add.label": "Task name",
"playbooks.checklist_item.add.save": "Add",
"playbooks.checklist_item.add.title": "New Task",
"playbooks.checklist_item.assignee": "Assignee",
"playbooks.checklist_item.check": "Check",
"playbooks.checklist_item.checked": "Checked",
@ -1001,6 +1005,9 @@
"playbooks.checklist_item.skipped": "Skipped",
"playbooks.checklist_item.task_rendered_conditionally": "Task rendered conditionally",
"playbooks.checklist_item.task_rendered_conditionally_explanation": "This task was rendered conditionally based on",
"playbooks.checklist.rename.button": "Save",
"playbooks.checklist.rename.label": "Checklist name",
"playbooks.checklist.rename.title": "Rename checklist",
"playbooks.create_run.select_playbook.no_results": "No Results",
"playbooks.due_date.date_at_time": "{date} at {time}",
"playbooks.due_date.none": "None",
@ -1010,46 +1017,49 @@
"playbooks.edit_command.title": "Slash command",
"playbooks.edit_due_date.clear.button": "Clear",
"playbooks.edit_due_date.save.button": "Save",
"playbooks.fetch_error.description": "You don't have permission to view this run, or it may no longer exist.",
"playbooks.fetch_error.description": "You don't have permission to view this, or it may no longer exist.",
"playbooks.fetch_error.OK": "Okay",
"playbooks.fetch_error.title": "Unable to open Run",
"playbooks.home_button.title": "Playbook runs",
"playbooks.fetch_error.title": "Unable to open Checklist",
"playbooks.home_button.title": "Playbook checklists",
"playbooks.not_enabled_or_unsupported.description": "Playbooks are either not enabled on this server or the Playbooks version is not supported. Please contact your system administrator.",
"playbooks.not_enabled_or_unsupported.OK": "OK",
"playbooks.not_enabled_or_unsupported.title": "Playbooks not available",
"playbooks.only_runs_available.description": "Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.",
"playbooks.only_runs_available.description": "Only Playbook Checklists are available on mobile. To access the Playbook, please use the desktop or web app.",
"playbooks.only_runs_available.ok": "OK",
"playbooks.only_runs_available.title": "Playbooks not available",
"playbooks.participant_playbooks.title": "Playbook runs",
"playbooks.participant_playbooks.title": "Playbook checklists",
"playbooks.playbook_run.error.description": "Please check your network connection or try again later.",
"playbooks.playbook_run.error.title": "Unable to fetch run details",
"playbooks.playbook_run.finish_run_button": "Finish Run",
"playbooks.playbook_run.error.title": "Unable to fetch details",
"playbooks.playbook_run.finish_run_button": "Finish",
"playbooks.playbook_run.finish_run_dialog_cancel": "Cancel",
"playbooks.playbook_run.finish_run_dialog_description": "There are {pendingCount} {pendingCount, plural, =1 {task} other {tasks}} pending.\n\nAre you sure you want to finish the run for all participants?",
"playbooks.playbook_run.finish_run_dialog_description": "There are {pendingCount} {pendingCount, plural, =1 {task} other {tasks}} pending.\n\nAre you sure you want to finish the checklist for all participants?",
"playbooks.playbook_run.finish_run_dialog_finish": "Finish",
"playbooks.playbook_run.finish_run_dialog_title": "Finish Run",
"playbooks.playbook_run.finish_run_dialog_title": "Finish",
"playbooks.playbook_run.finished": "Finished",
"playbooks.playbook_run.overdue": "{num} {num, plural, =1 {task} other {tasks}} overdue",
"playbooks.playbook_run.owner": "Owner",
"playbooks.playbook_run.participants": "Participants",
"playbooks.playbook_run.participants_title": "Run Participants",
"playbooks.playbook_run.run_details": "Run details",
"playbooks.playbook_run.participants_title": "Participants",
"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}",
"playbooks.playbook_run.status_update_finished": "Run finished\n{time}",
"playbooks.playbook_run.status_update_finished": "Finished at\n{time}",
"playbooks.playbook_run.status_update_overdue": "Update overdue\n{time}",
"playbooks.playbook_run.tasks": "Tasks",
"playbooks.playbook_run.title": "Playbook run",
"playbooks.playbooks_runs.title": "Playbook runs",
"playbooks.playbook_run.title": "Playbook checklist",
"playbooks.playbooks_runs.title": "Playbook checklists",
"playbooks.post_update.confirm.cancel": "Cancel",
"playbooks.post_update.confirm.confirm": "Finish run",
"playbooks.post_update.confirm.message": "Are you sure you want to finish the run {runName} for all participants?",
"playbooks.post_update.confirm.message.with_tasks": "There {outstanding, plural, =1 {is # outstanding task} other {are # outstanding tasks}}. Are you sure you want to finish the run {runName} for all participants?",
"playbooks.post_update.confirm.title": "Confirm finish run",
"playbooks.post_update.intro": "This update for the run <Bold>{runName}</Bold> will be broadcasted to {hasChannels, select, true {<Bold>{broadcastChannelCount, plural, =1 {one channel} other {{broadcastChannelCount, number} channels}}</Bold>} other {}}{hasFollowersAndChannels, select, true { and } other {}}{hasFollowers, select, true {<Bold>{followersChannelCount, plural, =1 {one direct message} other {{followersChannelCount, number} direct messages}}</Bold>} other {}}.",
"playbooks.post_update.confirm.confirm": "Finish",
"playbooks.post_update.confirm.message": "Are you sure you want to finish the checklist {runName} for all participants?",
"playbooks.post_update.confirm.message.with_tasks": "There {outstanding, plural, =1 {is # outstanding task} other {are # outstanding tasks}}. Are you sure you want to finish the checklist {runName} for all participants?",
"playbooks.post_update.confirm.title": "Confirm finish checklist",
"playbooks.post_update.intro": "This update for the checklist <Bold>{runName}</Bold> will be broadcasted to {hasChannels, select, true {<Bold>{broadcastChannelCount, plural, =1 {one channel} other {{broadcastChannelCount, number} channels}}</Bold>} other {}}{hasFollowersAndChannels, select, true { and } other {}}{hasFollowers, select, true {<Bold>{followersChannelCount, plural, =1 {one direct message} other {{followersChannelCount, number} direct messages}}</Bold>} other {}}.",
"playbooks.post_update.intro.no_followers_or_broadcast_channels": "This update will be saved to the overview page.",
"playbooks.post_update.label": "Update message",
"playbooks.post_update.label.also_mark_run_as_finished": "Also mark the run as finished",
"playbooks.post_update.label.also_mark_run_as_finished": "Also mark the checklist as finished",
"playbooks.post_update.label.next_update": "Timer for next update",
"playbooks.post_update.option.1_day": "1 day",
"playbooks.post_update.option.1_hour": "1 hour",
@ -1060,42 +1070,42 @@
"playbooks.post_update.placeholder": "Enter your update message",
"playbooks.post_update.post.button": "Post",
"playbooks.post_update.title": "Post update",
"playbooks.retrospective_not_available.description": "Only Playbook Runs are available on mobile. To fill the Run Retrospective, please use the desktop or web app.",
"playbooks.retrospective_not_available.description": "Only Playbook Checklists are available on mobile. To fill the Run Retrospective, please use the desktop or web app.",
"playbooks.retrospective_not_available.ok": "OK",
"playbooks.retrospective_not_available.title": "Playbooks Run Retrospective not available",
"playbooks.retrospective_not_available.title": "Playbooks Retrospective not available",
"playbooks.row.last_used": "Last used {time}",
"playbooks.row.never_used": "Never used",
"playbooks.row.no_runs": "No runs in progress",
"playbooks.row.runs": "{count} {count, plural, one {run} other {runs}} in progress",
"playbooks.run_list.cached_warning_message": "Showing cached data only. Some playbook runs or updates may be missing from this list.",
"playbooks.row.no_runs": "No checklists in progress",
"playbooks.row.runs": "{count} {count, plural, one {checklist} other {checklists}} in progress",
"playbooks.run_list.cached_warning_message": "Showing cached data only. Some updates may be missing from this list.",
"playbooks.run_list.cached_warning_title": "Cannot reach the server",
"playbooks.run_list.tab_finished": "Finished",
"playbooks.run_list.tab_in_progress": "In Progress",
"playbooks.runs.finished.description": "When a run in this channel finishes, youll see it here.",
"playbooks.runs.finished.title": "No finished runs",
"playbooks.runs.in_progress.description": "When a run starts in this channel, youll see it here.",
"playbooks.runs.in_progress.title": "No in progress runs",
"playbooks.runs.finished.description": "When a checklist in this channel finishes, youll see it here.",
"playbooks.runs.finished.title": "Nothing finished",
"playbooks.runs.in_progress.description": "When a checklist starts in this channel, youll see it here.",
"playbooks.runs.in_progress.title": "Nothing in progress",
"playbooks.runs.show_more": "Show More",
"playbooks.runs.start_a_new_run": "Start a new run",
"playbooks.runs.start_a_new_run": "New",
"playbooks.select_date.title": "Due date",
"playbooks.select_playbook.in_this_channel": "In This Channel",
"playbooks.select_playbook.other_playbooks": "Other Playbooks",
"playbooks.select_playbook.subtitle": "Select a playbook",
"playbooks.select_playbook.title": "Start a run",
"playbooks.select_playbook.title": "New",
"playbooks.select_playbook.your_playbooks": "Your Playbooks",
"playbooks.select_user.no_assignee": "No Assignee",
"playbooks.select_user.not_participants": "NOT PARTICIPATING",
"playbooks.select_user.participants": "RUN PARTICIPANTS",
"playbooks.start_a_run.title": "Start a run",
"playbooks.select_user.participants": "PARTICIPANTS",
"playbooks.start_a_run.title": "New",
"playbooks.start_run.channel_label": "Channel",
"playbooks.start_run.create_new_channel": "Create a new channel",
"playbooks.start_run.create_new_channel.private_channel": "Private channel",
"playbooks.start_run.create_new_channel.public_channel": "Public channel",
"playbooks.start_run.link_existing_channel": "Link to an existing channel",
"playbooks.start_run.run_description_label": "Run description",
"playbooks.start_run.run_name_error": "Please add a name for this run",
"playbooks.start_run.run_name_label": "Run name",
"playbooks.start_run.run_name_placeholder": "Add a name for your run",
"playbooks.start_run.run_description_label": "Description",
"playbooks.start_run.run_name_error": "Please add a name",
"playbooks.start_run.run_name_label": "Name",
"playbooks.start_run.run_name_placeholder": "Add a name",
"playbooks.status_update_post.invalid_status_update_props": "Playbooks status update post with invalid properties",
"playbooks.status_update_post.num_tasks": "**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked",
"playbooks.status_update_post.participants": "{numParticipants, number} {numParticipants, plural, =1 {participant} other {participants}}",

View file

@ -16,6 +16,7 @@ import {Ringtone} from '@constants/calls';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
import DatabaseManager from '@database/manager';
import {PLAYBOOK_RUN_TYPES} from '@playbooks/constants/playbook_run';
import {prepareCommonSystemValues} from '@queries/servers/system';
import type {APIClientInterface} from '@mattermost/react-native-network-client';
@ -1175,6 +1176,7 @@ class TestHelperSingleton {
update_at: Date.now() + i,
items_order: checklists.map((checklist) => checklist.id),
status_update_broadcast_channels_enabled: false,
type: PLAYBOOK_RUN_TYPES.PlaybookType,
});
}
return playbookRuns;
@ -1250,6 +1252,7 @@ class TestHelperSingleton {
return {
...this.fakeModel(),
playbookId: this.generateId(),
type: 'playbook',
postId: null,
ownerUserId: this.basicUser?.id || '',
teamId: this.basicTeam?.id || '',