[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:
parent
87395e1210
commit
0b7820c2b9
13 changed files with 797 additions and 17 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
2
app/products/playbooks/types/api.d.ts
vendored
2
app/products/playbooks/types/api.d.ts
vendored
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
type ChecklistItemState = '' | 'in_progress' | 'closed' | 'skipped';
|
||||
|
||||
type ConditionAction = '' | 'hidden';
|
||||
type ConditionAction = '' | 'hidden' | 'shown_because_modified';
|
||||
|
||||
const PlaybookRunStatus = {
|
||||
InProgress: 'InProgress',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue