diff --git a/app/products/playbooks/database/operators/comparators/index.test.ts b/app/products/playbooks/database/operators/comparators/index.test.ts
index 71a27ce81..af63db1ad 100644
--- a/app/products/playbooks/database/operators/comparators/index.test.ts
+++ b/app/products/playbooks/database/operators/comparators/index.test.ts
@@ -75,4 +75,71 @@ describe('shouldHandlePlaybookChecklistItemRecord', () => {
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
});
+
+ it('should return true when condition_action changes even if update_at is the same', () => {
+ const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
+ updateAt: 112233,
+ conditionAction: '',
+ });
+ const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
+ update_at: 112233,
+ condition_action: 'hidden',
+ });
+
+ expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
+ });
+
+ it('should return true when condition_reason changes even if update_at is the same', () => {
+ const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
+ updateAt: 112233,
+ conditionReason: '',
+ });
+ const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
+ update_at: 112233,
+ condition_reason: 'Dependent task not completed',
+ });
+
+ expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
+ });
+
+ it('should return false when condition fields are unchanged and update_at is the same', () => {
+ const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
+ updateAt: 112233,
+ conditionAction: 'hidden',
+ conditionReason: 'Some reason',
+ });
+ const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
+ update_at: 112233,
+ condition_action: 'hidden',
+ condition_reason: 'Some reason',
+ });
+
+ expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(false);
+ });
+
+ it('should return true when condition_action changes from hidden to shown_because_modified', () => {
+ const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
+ updateAt: 112233,
+ conditionAction: 'hidden',
+ });
+ const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
+ update_at: 112233,
+ condition_action: 'shown_because_modified',
+ });
+
+ expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
+ });
+
+ it('should return false when condition_action is undefined in raw data', () => {
+ const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
+ updateAt: 112233,
+ conditionAction: 'hidden',
+ });
+ const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
+ update_at: 112233,
+ });
+ delete raw.condition_action;
+
+ expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(false);
+ });
});
diff --git a/app/products/playbooks/database/operators/comparators/index.ts b/app/products/playbooks/database/operators/comparators/index.ts
index d9d84210b..93ef5a9e1 100644
--- a/app/products/playbooks/database/operators/comparators/index.ts
+++ b/app/products/playbooks/database/operators/comparators/index.ts
@@ -14,5 +14,16 @@ export const shouldHandlePlaybookChecklistRecord = (existingRecord: PlaybookChec
};
export const shouldHandlePlaybookChecklistItemRecord = (existingRecord: PlaybookChecklistItemModel, raw: PartialChecklistItem): boolean => {
- return Boolean(existingRecord.updateAt !== raw.update_at);
+ // Check if update_at has changed (primary check)
+ if (existingRecord.updateAt !== raw.update_at) {
+ return true;
+ }
+
+ // Check if condition fields have changed (allows updates without update_at change)
+ const conditionActionChanged = raw.condition_action !== undefined &&
+ existingRecord.conditionAction !== raw.condition_action;
+ const conditionReasonChanged = raw.condition_reason !== undefined &&
+ existingRecord.conditionReason !== raw.condition_reason;
+
+ return Boolean(conditionActionChanged || conditionReasonChanged);
};
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx
index f55ef2e1a..6e9c5d26f 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx
@@ -424,4 +424,127 @@ describe('ChecklistItem', () => {
expect(runChecklistItem).toHaveBeenCalledWith(serverUrl, props.playbookRunId, props.checklistNumber, props.itemNumber);
});
});
+
+ describe('condition icon', () => {
+ it('should not show condition icon when no condition fields are set', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItemModel({
+ conditionReason: '',
+ conditionAction: '',
+ });
+ props.item = item;
+
+ const {queryByTestId} = renderWithIntl();
+
+ expect(queryByTestId('checklist_item.condition_icon')).toBeNull();
+ });
+
+ it('should show condition icon when conditionReason is set', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItemModel({
+ conditionReason: 'Some condition reason',
+ conditionAction: '',
+ });
+ props.item = item;
+
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item.condition_icon');
+ expect(icon).toBeVisible();
+ expect(icon.props.name).toBe('source-branch');
+ expect(icon.props.size).toBe(16);
+ });
+
+ it('should show condition icon when conditionAction is shown_because_modified', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItemModel({
+ conditionReason: '',
+ conditionAction: 'shown_because_modified',
+ });
+ props.item = item;
+
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item.condition_icon');
+ expect(icon).toBeVisible();
+ expect(icon.props.name).toBe('source-branch');
+ expect(icon.props.size).toBe(16);
+ });
+
+ it('should show condition icon with error color for shown_because_modified', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItemModel({
+ conditionReason: '',
+ conditionAction: 'shown_because_modified',
+ });
+ props.item = item;
+
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item.condition_icon');
+ expect(icon).toBeVisible();
+ expect(icon.props.color).toBe(Preferences.THEMES.denim.errorTextColor);
+ });
+
+ it('should show condition icon with normal color when conditionReason is set but not shown_because_modified', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItemModel({
+ conditionReason: 'Some condition reason',
+ conditionAction: '',
+ });
+ props.item = item;
+
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item.condition_icon');
+ expect(icon).toBeVisible();
+
+ // Normal color should be theme.centerChannelColor with 0.56 opacity
+ // Since we can't easily check the opacity, we just verify it's not the error color
+ expect(icon.props.color).not.toBe(Preferences.THEMES.denim.errorTextColor);
+ });
+
+ it('should not show condition icon when conditionAction is hidden', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItemModel({
+ conditionReason: '',
+ conditionAction: 'hidden',
+ });
+ props.item = item;
+
+ const {queryByTestId} = renderWithIntl();
+
+ expect(queryByTestId('checklist_item.condition_icon')).toBeNull();
+ });
+
+ it('should handle API format (snake_case) condition fields', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ condition_reason: 'Some condition reason',
+ condition_action: '',
+ });
+ props.item = item;
+
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item.condition_icon');
+ expect(icon).toBeVisible();
+ expect(icon.props.name).toBe('source-branch');
+ });
+
+ it('should show error color for API format shown_because_modified', () => {
+ const props = getBaseProps();
+ const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ condition_reason: '',
+ condition_action: 'shown_because_modified',
+ });
+ props.item = item;
+
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item.condition_icon');
+ expect(icon).toBeVisible();
+ expect(icon.props.color).toBe(Preferences.THEMES.denim.errorTextColor);
+ });
+ });
});
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx
index 7b9391116..d23c9b963 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx
@@ -30,7 +30,7 @@ import type UserModel from '@typings/database/models/servers/user';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
checklistItem: {
flexDirection: 'row',
- gap: 12,
+ gap: 6,
},
itemDetails: {
gap: 8,
@@ -68,6 +68,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
skippedText: {
textDecorationLine: 'line-through',
},
+ conditionIcon: {
+ marginHorizontal: 2,
+ alignSelf: 'flex-start',
+ marginTop: 4,
+ transform: [{rotate: '90deg'}],
+ },
+ titleRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ marginLeft: 2,
+ },
}));
type Props = {
@@ -104,6 +115,15 @@ const ChecklistItem = ({
const [isChecking, setIsChecking] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
+ // Extract condition fields
+ const conditionReason = 'conditionReason' in item ? item.conditionReason : item.condition_reason || '';
+ const conditionAction = 'conditionAction' in item ? item.conditionAction : item.condition_action || '';
+
+ // Determine icon display and color
+ const showConditionIcon = conditionReason !== '' || conditionAction === 'shown_because_modified';
+ const isErrorState = conditionAction === 'shown_because_modified';
+ const iconColor = isErrorState ? theme.errorTextColor : changeOpacity(theme.centerChannelColor, 0.56);
+
const checked = item.state === 'closed';
const skipped = item.state === 'skipped';
const overdue = isOverdue(item);
@@ -212,6 +232,9 @@ const ChecklistItem = ({
onRunCommand={executeCommand}
teammateNameDisplay={teammateNameDisplay}
isDisabled={isDisabled}
+ conditionReason={conditionReason}
+ showConditionIcon={showConditionIcon}
+ conditionIconColor={iconColor}
/>
), [
playbookRunId,
@@ -224,10 +247,13 @@ const ChecklistItem = ({
executeCommand,
teammateNameDisplay,
isDisabled,
+ conditionReason,
+ showConditionIcon,
+ iconColor,
]);
const onPress = useCallback(() => {
- const initialHeight = BOTTOM_SHEET_HEIGHT.base + (isDisabled ? 0 : BOTTOM_SHEET_HEIGHT.actionButtons);
+ const initialHeight = BOTTOM_SHEET_HEIGHT.base + (isDisabled ? 0 : BOTTOM_SHEET_HEIGHT.actionButtons) + (showConditionIcon ? BOTTOM_SHEET_HEIGHT.conditionSection : 0);
bottomSheet({
title: intl.formatMessage({id: 'playbook_run.checklist.taskDetails', defaultMessage: 'Task Details'}),
renderContent: renderBottomSheet,
@@ -236,17 +262,30 @@ const ChecklistItem = ({
closeButtonId: 'close-checklist-item',
scrollable: true,
});
- }, [intl, isDisabled, renderBottomSheet, theme]);
+ }, [intl, isDisabled, renderBottomSheet, theme, showConditionIcon]);
return (
{checkbox}
+ {showConditionIcon && (
+
+
+
+ )}
- {item.title}
+
+ {item.title}
+
{Boolean(item.description) && (
{item.description}
)}
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx
index 7d137aeed..c8c9ad66f 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx
@@ -1,5 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+/* eslint-disable max-lines */
import {BottomSheetScrollView} from '@gorhom/bottom-sheet';
import {act, fireEvent, waitFor} from '@testing-library/react-native';
@@ -8,6 +9,7 @@ import {ScrollView} from 'react-native';
import OptionBox from '@components/option_box';
import OptionItem from '@components/option_item';
+import {Preferences} from '@constants';
import {useIsTablet} from '@hooks/device';
import {setAssignee, setChecklistItemCommand, setDueDate} from '@playbooks/actions/remote/checklist';
import {goToEditCommand, goToSelectDate, goToSelectUser} from '@playbooks/screens/navigation';
@@ -92,6 +94,9 @@ describe('ChecklistItemBottomSheet', () => {
isDisabled: false,
currentUserTimezone: {useAutomaticTimezone: false, automaticTimezone: '', manualTimezone: 'America/New_York'},
participantIds: ['user-1', 'user-2'],
+ conditionReason: '',
+ showConditionIcon: false,
+ conditionIconColor: '#000000',
};
}
@@ -636,4 +641,94 @@ describe('ChecklistItemBottomSheet', () => {
expect(queryByTestId('checklist_item.run_command_button')).toBeNull();
});
+
+ describe('condition display', () => {
+ it('should not render condition section when showConditionIcon is false', () => {
+ const props = getBaseProps();
+ props.showConditionIcon = false;
+ const {queryByTestId} = renderWithIntl();
+
+ expect(queryByTestId('checklist_item_bottom_sheet.condition_icon')).toBeNull();
+ expect(queryByTestId('checklist_item_bottom_sheet.condition_header')).toBeNull();
+ expect(queryByTestId('checklist_item_bottom_sheet.condition_explanation')).toBeNull();
+ expect(queryByTestId('checklist_item_bottom_sheet.condition_reason')).toBeNull();
+ });
+
+ it('should render icon and all text elements when showConditionIcon is true', () => {
+ const props = getBaseProps();
+ props.showConditionIcon = true;
+ props.conditionReason = 'Incident Type: Malware';
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item_bottom_sheet.condition_icon');
+ const header = getByTestId('checklist_item_bottom_sheet.condition_header');
+ const explanation = getByTestId('checklist_item_bottom_sheet.condition_explanation');
+ const reasonText = getByTestId('checklist_item_bottom_sheet.condition_reason');
+
+ expect(icon).toBeVisible();
+ expect(header).toBeVisible();
+ expect(explanation).toBeVisible();
+ expect(reasonText).toBeVisible();
+ });
+
+ it('should use the correct icon color (normal color)', () => {
+ const props = getBaseProps();
+ props.showConditionIcon = true;
+ props.conditionReason = 'Some condition reason';
+ props.conditionIconColor = Preferences.THEMES.denim.centerChannelColor;
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item_bottom_sheet.condition_icon');
+ expect(icon.props.color).toBe(Preferences.THEMES.denim.centerChannelColor);
+ });
+
+ it('should use the correct icon color (error color)', () => {
+ const props = getBaseProps();
+ props.showConditionIcon = true;
+ props.conditionReason = '';
+ props.conditionIconColor = Preferences.THEMES.denim.errorTextColor;
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item_bottom_sheet.condition_icon');
+ expect(icon.props.color).toBe(Preferences.THEMES.denim.errorTextColor);
+ });
+
+ it('should display the correct text structure', () => {
+ const props = getBaseProps();
+ props.showConditionIcon = true;
+ props.conditionReason = 'Incident Type: Malware';
+ const {getByTestId} = renderWithIntl();
+
+ const header = getByTestId('checklist_item_bottom_sheet.condition_header');
+ const explanation = getByTestId('checklist_item_bottom_sheet.condition_explanation');
+ const reasonText = getByTestId('checklist_item_bottom_sheet.condition_reason');
+
+ expect(header.props.children).toBe('Task rendered conditionally');
+ expect(explanation.props.children).toBe('This task was rendered conditionally based on');
+ expect(reasonText.props.children).toBe('Incident Type: Malware');
+ });
+
+ it('should have correct testIDs for accessibility', () => {
+ const props = getBaseProps();
+ props.showConditionIcon = true;
+ props.conditionReason = 'Condition reason';
+ const {getByTestId} = renderWithIntl();
+
+ expect(getByTestId('checklist_item_bottom_sheet.condition_icon')).toBeDefined();
+ expect(getByTestId('checklist_item_bottom_sheet.condition_header')).toBeDefined();
+ expect(getByTestId('checklist_item_bottom_sheet.condition_explanation')).toBeDefined();
+ expect(getByTestId('checklist_item_bottom_sheet.condition_reason')).toBeDefined();
+ });
+
+ it('should use source-branch icon with size 24', () => {
+ const props = getBaseProps();
+ props.showConditionIcon = true;
+ props.conditionReason = 'Condition reason';
+ const {getByTestId} = renderWithIntl();
+
+ const icon = getByTestId('checklist_item_bottom_sheet.condition_icon');
+ expect(icon.props.name).toBe('source-branch');
+ expect(icon.props.size).toBe(24);
+ });
+ });
});
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx
index e0217781b..8395d7e06 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx
@@ -3,8 +3,9 @@
import React, {useCallback, useMemo, type ComponentProps} from 'react';
import {defineMessages, useIntl} from 'react-intl';
-import {View, Text} from 'react-native';
+import {View, Text, Platform} from 'react-native';
+import CompassIcon from '@components/compass_icon';
import MenuDivider from '@components/menu_divider';
import OptionBox from '@components/option_box';
import OptionItem, {ITEM_HEIGHT} from '@components/option_item';
@@ -65,6 +66,14 @@ const messages = defineMessages({
id: 'playbooks.checklist_item.none',
defaultMessage: 'None',
},
+ taskRenderedConditionally: {
+ id: 'playbooks.checklist_item.task_rendered_conditionally',
+ defaultMessage: 'Task rendered conditionally',
+ },
+ taskRenderedConditionallyExplanation: {
+ id: 'playbooks.checklist_item.task_rendered_conditionally_explanation',
+ defaultMessage: 'This task was rendered conditionally based on',
+ },
});
const ACTION_BUTTON_HEIGHT = 62;
@@ -78,6 +87,7 @@ const BODY_LINES_COUNT = 3;
export const BOTTOM_SHEET_HEIGHT = {
base: (N_OPTIONS * ITEM_HEIGHT) + (OPTIONS_GAP * (N_OPTIONS - 1)) + (SCROLL_CONTENT_GAP * 2) + TITLE_LINE_HEIGHT + (BODY_LINE_HEIGHT * BODY_LINES_COUNT),
actionButtons: ACTION_BUTTON_HEIGHT + SCROLL_CONTENT_GAP,
+ conditionSection: (BODY_LINE_HEIGHT * 2) + SCROLL_CONTENT_GAP + (OPTIONS_GAP * (Platform.OS === 'android' ? 2 : 1)),
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
@@ -110,6 +120,30 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
flex: {
flex: 1,
},
+ conditionSection: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: 12,
+ },
+ conditionTextContainer: {
+ flex: 1,
+ gap: 4,
+ },
+ conditionHeader: {
+ ...typography('Body', 200, 'SemiBold'),
+ color: theme.centerChannelColor,
+ },
+ conditionExplanation: {
+ ...typography('Body', 75, 'Regular'),
+ color: changeOpacity(theme.centerChannelColor, 0.72),
+ },
+ conditionReason: {
+ ...typography('Body', 75, 'SemiBold'),
+ color: changeOpacity(theme.centerChannelColor, 0.72),
+ },
+ conditionIcon: {
+ transform: [{rotate: '90deg'}],
+ },
}));
type Props = {
@@ -127,6 +161,9 @@ type Props = {
isDisabled: boolean;
currentUserTimezone: UserTimezone | null | undefined;
participantIds: string[];
+ conditionReason: string;
+ showConditionIcon: boolean;
+ conditionIconColor: string;
};
const ChecklistItemBottomSheet = ({
@@ -144,6 +181,9 @@ const ChecklistItemBottomSheet = ({
isDisabled,
currentUserTimezone,
participantIds,
+ conditionReason,
+ showConditionIcon,
+ conditionIconColor,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
@@ -292,6 +332,41 @@ const ChecklistItemBottomSheet = ({
);
+ const renderConditionSection = () => (
+ <>
+
+
+
+
+
+ {intl.formatMessage(messages.taskRenderedConditionally)}
+
+
+ {intl.formatMessage(messages.taskRenderedConditionallyExplanation)}
+
+
+ {conditionReason}
+
+
+
+ >
+ );
+
return (
{!isDisabled && renderActionButtons()}
{renderTaskDetails()}
+ {showConditionIcon && renderConditionSection()}
);
};
diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx
index 206ce8ba0..4b17c5db7 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx
@@ -43,6 +43,9 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
onRunCommand: jest.fn(),
teammateNameDisplay: General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME,
isDisabled: false,
+ conditionReason: '',
+ showConditionIcon: false,
+ conditionIconColor: '#000000',
};
}
diff --git a/app/products/playbooks/screens/playbook_run/checklist/index.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/index.test.tsx
index 5106b1fcc..0a97e999e 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/index.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/checklist/index.test.tsx
@@ -5,7 +5,7 @@ import React, {type ComponentProps} from 'react';
import DatabaseManager from '@database/manager';
import {getChecklistProgress} from '@playbooks/utils/progress';
-import {renderWithEverything, waitFor} from '@test/intl-test-helper';
+import {renderWithEverything, waitFor, act} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChecklistComponent from './checklist';
@@ -75,10 +75,87 @@ describe('Checklist', () => {
const checklist = getByTestId('checklist');
expect(checklist).toBeTruthy();
expect(checklist.props.checklist).toBe(props.checklist);
- expect(checklist.props.items).toBe(props.checklist.items);
+ expect(checklist.props.items).toStrictEqual(props.checklist.items);
expect(checklist.props.checklistProgress).toBe(mockProgressReturn);
expect(getChecklistProgress).toHaveBeenCalledWith(props.checklist.items);
});
+
+ it('should filter out hidden incomplete items', () => {
+ const props = getBaseProps();
+ props.checklist.items = [
+ TestHelper.createPlaybookItem(checklistId, 0), // visible normal item
+ {
+ ...TestHelper.createPlaybookItem(checklistId, 1),
+ condition_action: 'hidden',
+ completed_at: 0,
+ }, // hidden incomplete - should be filtered
+ {
+ ...TestHelper.createPlaybookItem(checklistId, 2),
+ state: 'closed',
+ completed_at: Date.now(),
+ }, // completed item
+ ];
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const checklist = getByTestId('checklist');
+ expect(checklist).toBeTruthy();
+
+ // Should only have 2 items (hidden incomplete is filtered out)
+ expect(checklist.props.items).toHaveLength(2);
+ expect(checklist.props.items[0].id).toBe(props.checklist.items[0].id);
+ expect(checklist.props.items[1].id).toBe(props.checklist.items[2].id);
+ });
+
+ it('should include hidden completed items', () => {
+ const props = getBaseProps();
+ props.checklist.items = [
+ TestHelper.createPlaybookItem(checklistId, 0), // visible normal item
+ {
+ ...TestHelper.createPlaybookItem(checklistId, 1),
+ condition_action: 'hidden',
+ state: 'closed',
+ completed_at: Date.now(),
+ }, // hidden but completed - should be included
+ ];
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const checklist = getByTestId('checklist');
+ expect(checklist).toBeTruthy();
+
+ // Should have both items (hidden completed is still visible)
+ expect(checklist.props.items).toHaveLength(2);
+ expect(checklist.props.items[0].id).toBe(props.checklist.items[0].id);
+ expect(checklist.props.items[1].id).toBe(props.checklist.items[1].id);
+ });
+
+ it('should include shown_because_modified items', () => {
+ const props = getBaseProps();
+ props.checklist.items = [
+ {
+ ...TestHelper.createPlaybookItem(checklistId, 0),
+ condition_action: 'shown_because_modified',
+ completed_at: 0,
+ }, // shown_because_modified incomplete
+ {
+ ...TestHelper.createPlaybookItem(checklistId, 1),
+ condition_action: 'shown_because_modified',
+ state: 'closed',
+ completed_at: Date.now(),
+ }, // shown_because_modified completed
+ ];
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const checklist = getByTestId('checklist');
+ expect(checklist).toBeTruthy();
+
+ // Should have both items (shown_because_modified items are always visible)
+ expect(checklist.props.items).toHaveLength(2);
+ expect(checklist.props.items[0].id).toBe(props.checklist.items[0].id);
+ expect(checklist.props.items[1].id).toBe(props.checklist.items[1].id);
+ });
});
describe('local run', () => {
@@ -137,5 +214,146 @@ describe('Checklist', () => {
expect(checklist.props.items[1].id).toBe(itemsIds[1]);
});
});
+
+ it('should filter out hidden incomplete items from database', async () => {
+ const checklist = TestHelper.createPlaybookChecklist('', 3, 0);
+
+ // Set condition fields on items
+ checklist.items[0].condition_action = ''; // visible
+ checklist.items[1].condition_action = 'hidden'; // hidden incomplete - should be filtered
+ checklist.items[1].completed_at = 0;
+ checklist.items[2].condition_action = ''; // visible
+ checklist.items[2].state = 'closed';
+ checklist.items[2].completed_at = Date.now();
+
+ const model = await operator.handlePlaybookChecklist({
+ prepareRecordsOnly: false,
+ checklists: [{
+ run_id: 'run-id',
+ ...checklist,
+ }],
+ processChildren: true,
+ });
+
+ const props = {
+ checklist: model[0] as PlaybookChecklistModel,
+ checklistNumber: 0,
+ channelId: 'channel-id',
+ playbookRunId: 'run-id',
+ isFinished: false,
+ isParticipant: true,
+ };
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const checklistComponent = getByTestId('checklist');
+ expect(checklistComponent).toBeTruthy();
+
+ // Should only have 2 items (hidden incomplete is filtered out)
+ expect(checklistComponent.props.items).toHaveLength(2);
+ expect(checklistComponent.props.items[0].id).toBe(checklist.items[0].id);
+ expect(checklistComponent.props.items[1].id).toBe(checklist.items[2].id);
+ });
+
+ it('should react to condition_action changes in real-time', async () => {
+ const checklist = TestHelper.createPlaybookChecklist('', 2, 0);
+ const model = await operator.handlePlaybookChecklist({
+ prepareRecordsOnly: false,
+ checklists: [{
+ run_id: 'run-id',
+ ...checklist,
+ }],
+ processChildren: true,
+ });
+
+ const props = {
+ checklist: model[0] as PlaybookChecklistModel,
+ checklistNumber: 0,
+ channelId: 'channel-id',
+ playbookRunId: 'run-id',
+ isFinished: false,
+ isParticipant: true,
+ };
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const checklistComponent = getByTestId('checklist');
+ expect(checklistComponent.props.items).toHaveLength(2);
+
+ // Get the items in their rendered order
+ const initialFirstItemId = checklistComponent.props.items[0].id;
+ const initialSecondItemId = checklistComponent.props.items[1].id;
+
+ // Update first visible item to be hidden
+ const items = await props.checklist.items.fetch();
+ const itemToHide = items.find((i) => i.id === initialFirstItemId);
+ await act(async () => {
+ database.write(async () => {
+ await itemToHide?.update((item) => {
+ item.conditionAction = 'hidden';
+ });
+ });
+ });
+
+ // Should now only have 1 visible item (the second one)
+ await waitFor(() => {
+ expect(checklistComponent.props.items).toHaveLength(1);
+ expect(checklistComponent.props.items[0].id).toBe(initialSecondItemId);
+ });
+ });
+
+ it('should react to completedAt changes for hidden items', async () => {
+ const checklist = TestHelper.createPlaybookChecklist('', 2, 0);
+
+ // Make first item hidden and incomplete
+ checklist.items[0].condition_action = 'hidden';
+ checklist.items[0].completed_at = 0;
+
+ const model = await operator.handlePlaybookChecklist({
+ prepareRecordsOnly: false,
+ checklists: [{
+ run_id: 'run-id',
+ ...checklist,
+ }],
+ processChildren: true,
+ });
+
+ const props = {
+ checklist: model[0] as PlaybookChecklistModel,
+ checklistNumber: 0,
+ channelId: 'channel-id',
+ playbookRunId: 'run-id',
+ isFinished: false,
+ isParticipant: true,
+ };
+
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const checklistComponent = getByTestId('checklist');
+
+ // Should only have 1 item (hidden incomplete is filtered out)
+ expect(checklistComponent.props.items).toHaveLength(1);
+ const visibleItemId = checklistComponent.props.items[0].id;
+
+ // Find and mark the hidden item as completed
+ const items = await props.checklist.items.fetch();
+ const hiddenItem = items.find((i) => i.id !== visibleItemId);
+ expect(hiddenItem).toBeDefined();
+
+ await act(async () => {
+ database.write(async () => {
+ await hiddenItem?.update((item) => {
+ item.completedAt = Date.now();
+ item.conditionAction = 'shown_because_modified';
+ item.state = 'closed';
+ });
+ });
+ });
+
+ // Should now have 2 visible items (hidden completed item is now shown)
+ await waitFor(() => {
+ expect(checklistComponent.props.items).toHaveLength(2);
+ });
+ });
});
});
diff --git a/app/products/playbooks/screens/playbook_run/checklist/index.ts b/app/products/playbooks/screens/playbook_run/checklist/index.ts
index b9a748df4..a9c96ff86 100644
--- a/app/products/playbooks/screens/playbook_run/checklist/index.ts
+++ b/app/products/playbooks/screens/playbook_run/checklist/index.ts
@@ -30,13 +30,24 @@ const getIds = (items: PlaybookChecklistItemModel[]) => {
return items.map((i) => i.id);
};
+const filterVisibleItems = (items: PlaybookChecklistItemModel[]) => {
+ return items.filter((item) => {
+ if (item.conditionAction === 'hidden' && !item.completedAt) {
+ return false;
+ }
+ return true;
+ });
+};
+
const enhanced = withObservables(['checklist'], ({checklist}: OwnProps) => {
if ('observe' in checklist) {
const observedChecklist = checklist.observe();
- const items = checklist.items.observeWithColumns(['state']);
- const sortedItems = combineLatest([observedChecklist, items]).pipe(
+ const items = checklist.items.observeWithColumns(['state', 'condition_action', 'state_modified']);
+ const filteredAndSortedItems = combineLatest([observedChecklist, items]).pipe(
switchMap(([cl, i]) => {
- return of$(sortItems(cl, i));
+ // Filter out hidden incomplete items
+ const visibleItems = filterVisibleItems(i);
+ return of$(sortItems(cl, visibleItems));
}),
distinctUntilChanged((a, b) => areItemsOrdersEqual(getIds(a), getIds(b))),
);
@@ -47,14 +58,22 @@ const enhanced = withObservables(['checklist'], ({checklist}: OwnProps) => {
return {
checklist: observedChecklist,
- items: sortedItems,
+ items: filteredAndSortedItems,
checklistProgress,
};
}
+ // Filter visible items for non-model checklist
+ const visibleItems = checklist.items.filter((item) => {
+ if (item.condition_action === 'hidden' && !item.completed_at) {
+ return false;
+ }
+ return true;
+ });
+
return {
checklist: of$(checklist),
- items: of$(checklist.items),
+ items: of$(visibleItems),
checklistProgress: of$(getChecklistProgress(checklist.items)),
};
});
diff --git a/app/products/playbooks/types/api.d.ts b/app/products/playbooks/types/api.d.ts
index 74dc72700..ba44bc595 100644
--- a/app/products/playbooks/types/api.d.ts
+++ b/app/products/playbooks/types/api.d.ts
@@ -3,7 +3,7 @@
type ChecklistItemState = '' | 'in_progress' | 'closed' | 'skipped';
-type ConditionAction = '' | 'hidden';
+type ConditionAction = '' | 'hidden' | 'shown_because_modified';
const PlaybookRunStatus = {
InProgress: 'InProgress',
diff --git a/app/products/playbooks/utils/progress.test.ts b/app/products/playbooks/utils/progress.test.ts
index f7703aeec..6bb5b6b0d 100644
--- a/app/products/playbooks/utils/progress.test.ts
+++ b/app/products/playbooks/utils/progress.test.ts
@@ -119,6 +119,121 @@ describe('progress utils', () => {
});
});
+ describe('getChecklistProgress with conditions', () => {
+ it('should exclude hidden incomplete items from progress', () => {
+ const items = [
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: 'closed',
+ condition_action: '',
+ completed_at: 123456,
+ }),
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: 'hidden',
+ completed_at: 0,
+ }),
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: '',
+ completed_at: 0,
+ }),
+ ];
+
+ const result = getChecklistProgress(items);
+
+ // Hidden incomplete item should be excluded, so 1 completed out of 2 visible = 50%
+ expect(result.progress).toBe(50);
+ expect(result.completed).toBe(1);
+ expect(result.totalNumber).toBe(2);
+ });
+
+ it('should include hidden completed items in progress', () => {
+ const items = [
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: 'closed',
+ condition_action: 'hidden',
+ completed_at: 123456,
+ }),
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: '',
+ completed_at: 0,
+ }),
+ ];
+
+ const result = getChecklistProgress(items);
+
+ // Hidden but completed item should be counted, so 1 completed out of 2 visible = 50%
+ expect(result.progress).toBe(50);
+ expect(result.completed).toBe(1);
+ expect(result.totalNumber).toBe(2);
+ });
+
+ it('should handle all items hidden and incomplete', () => {
+ const items = [
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: 'hidden',
+ completed_at: 0,
+ }),
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: 'hidden',
+ completed_at: 0,
+ }),
+ ];
+
+ const result = getChecklistProgress(items);
+
+ // All items hidden, so 0 total
+ expect(result.progress).toBe(0);
+ expect(result.completed).toBe(0);
+ expect(result.totalNumber).toBe(0);
+ });
+
+ it('should handle shown_because_modified items', () => {
+ const items = [
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: 'closed',
+ condition_action: 'shown_because_modified',
+ completed_at: 123456,
+ }),
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: 'shown_because_modified',
+ completed_at: 0,
+ }),
+ ];
+
+ const result = getChecklistProgress(items);
+
+ // Both items should be visible, 1 completed out of 2 = 50%
+ expect(result.progress).toBe(50);
+ expect(result.completed).toBe(1);
+ expect(result.totalNumber).toBe(2);
+ });
+
+ it('should handle API format (snake_case) condition fields', () => {
+ const items = [
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: 'hidden',
+ completed_at: 0,
+ }),
+ TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ condition_action: '',
+ completed_at: 0,
+ }),
+ ];
+
+ const result = getChecklistProgress(items);
+
+ // Hidden item excluded, so 1 visible item
+ expect(result.totalNumber).toBe(1);
+ });
+ });
+
describe('getProgressFromRun', () => {
it('should return 0 for run with no items', () => {
const run = TestHelper.fakePlaybookRun({
diff --git a/app/products/playbooks/utils/progress.ts b/app/products/playbooks/utils/progress.ts
index 50e9848c6..884c59a0b 100644
--- a/app/products/playbooks/utils/progress.ts
+++ b/app/products/playbooks/utils/progress.ts
@@ -4,9 +4,21 @@
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
export function getChecklistProgress(items: Array) {
- const skippedCount = items.filter((item) => item.state === 'skipped').length;
- const completedCount = items.filter((item) => item.state === 'closed').length;
- const totalCount = items.length - skippedCount;
+ // Filter out hidden incomplete items - they should not be counted in progress
+ const visibleItems = items.filter((item) => {
+ const conditionAction = 'conditionAction' in item ? item.conditionAction : item.condition_action;
+ const completedAt = 'completedAt' in item ? item.completedAt : item.completed_at;
+
+ // Hide if condition_action is 'hidden' AND item is not completed
+ if (conditionAction === 'hidden' && !completedAt) {
+ return false;
+ }
+ return true;
+ });
+
+ const skippedCount = visibleItems.filter((item) => item.state === 'skipped').length;
+ const completedCount = visibleItems.filter((item) => item.state === 'closed').length;
+ const totalCount = visibleItems.length - skippedCount;
const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
return {
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 5d7fcef8c..2ebd51798 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -1002,6 +1002,8 @@
"playbooks.checklist_item.run_command": "Run command",
"playbooks.checklist_item.skip": "Skip",
"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.due_date.date_at_time": "{date} at {time}",
"playbooks.due_date.none": "None",
"playbooks.edit_command.label": "Command",