[MM-65160] Playbook Conditions UI (#9195)

* add new value to conditionAction

* update progress management regarding conditions

* hide items

* add test to filtering

* icon added for condition reason

* add conditional reason to bottom sheet

* improve UI

* i18n

* this might need to be reverted later. Fix for condition changes not triggering updates

* cosmetic changes

* improve branch icon positioning

* pressing on checklist item should display bottom sheet

* review improvements
This commit is contained in:
Guillermo Vayá 2025-10-12 21:49:45 +02:00 committed by GitHub
parent 87395e1210
commit 0b7820c2b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 797 additions and 17 deletions

View file

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

View file

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

View file

@ -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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
const icon = getByTestId('checklist_item.condition_icon');
expect(icon).toBeVisible();
expect(icon.props.color).toBe(Preferences.THEMES.denim.errorTextColor);
});
});
});

View file

@ -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 (
<View style={styles.checklistItem}>
<View style={styles.checkboxContainer}>
{checkbox}
</View>
{showConditionIcon && (
<PressableOpacity onPress={onPress}>
<CompassIcon
name='source-branch'
size={16}
color={iconColor}
style={styles.conditionIcon}
testID='checklist_item.condition_icon'
/>
</PressableOpacity>
)}
<View style={styles.itemDetails}>
<PressableOpacity onPress={onPress}>
<View style={styles.itemDetailsTexts}>
<Text style={[styles.itemTitle, skipped && styles.skippedText]}>{item.title}</Text>
<View style={styles.titleRow}>
<Text style={[styles.itemTitle, skipped && styles.skippedText]}>{item.title}</Text>
</View>
{Boolean(item.description) && (
<Text style={[styles.itemDescription, skipped && styles.skippedText]}>{item.description}</Text>
)}

View file

@ -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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
const icon = getByTestId('checklist_item_bottom_sheet.condition_icon');
expect(icon.props.name).toBe('source-branch');
expect(icon.props.size).toBe(24);
});
});
});

View file

@ -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 = ({
</View>
);
const renderConditionSection = () => (
<>
<MenuDivider/>
<View style={styles.conditionSection}>
<CompassIcon
name='source-branch'
size={24}
color={conditionIconColor}
style={styles.conditionIcon}
testID='checklist_item_bottom_sheet.condition_icon'
/>
<View style={styles.conditionTextContainer}>
<Text
style={styles.conditionHeader}
testID='checklist_item_bottom_sheet.condition_header'
>
{intl.formatMessage(messages.taskRenderedConditionally)}
</Text>
<Text
style={styles.conditionExplanation}
testID='checklist_item_bottom_sheet.condition_explanation'
>
{intl.formatMessage(messages.taskRenderedConditionallyExplanation)}
</Text>
<Text
style={styles.conditionReason}
testID='checklist_item_bottom_sheet.condition_reason'
>
{conditionReason}
</Text>
</View>
</View>
</>
);
return (
<View
style={styles.container}
@ -315,6 +390,7 @@ const ChecklistItemBottomSheet = ({
<MenuDivider/>
{!isDisabled && renderActionButtons()}
{renderTaskDetails()}
{showConditionIcon && renderConditionSection()}
</View>
);
};

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@
type ChecklistItemState = '' | 'in_progress' | 'closed' | 'skipped';
type ConditionAction = '' | 'hidden';
type ConditionAction = '' | 'hidden' | 'shown_because_modified';
const PlaybookRunStatus = {
InProgress: 'InProgress',

View file

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

View file

@ -4,9 +4,21 @@
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
export function getChecklistProgress(items: Array<PlaybookChecklistItem | PlaybookChecklistItemModel>) {
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 {

View file

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