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