Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {useServerUrl} from '@context/server';
import {addChecklistItem} from '@playbooks/actions/remote/checklist';
import ProgressBar from '@playbooks/components/progress_bar';
import {goToAddChecklistItem} from '@playbooks/screens/navigation';
import {DEFAULT_TASK_FILTERS} from '@playbooks/utils/task_filters';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {logError} from '@utils/log';
Expand All @@ -17,6 +18,8 @@ import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import Checklist from './checklist';
import ChecklistItem from './checklist_item';

import type {ReactTestInstance} from 'react-test-renderer';

const serverUrl = 'test-server-url';

jest.mock('@context/server');
Expand Down Expand Up @@ -92,6 +95,10 @@ describe('Checklist', () => {
playbookRunName: 'Test Run',
isFinished: false,
isParticipant: true,
filters: DEFAULT_TASK_FILTERS,
currentUserId: 'current-user-id',
collapseAll: false,
collapseAllEpoch: 0,
checklistProgress: {
skipped: false,
completed: 0,
Expand Down Expand Up @@ -429,4 +436,185 @@ describe('Checklist', () => {
expect(logError).not.toHaveBeenCalled();
});
});

describe('hiding items', () => {
const renderedItems = (getByTestId: (id: string) => ReactTestInstance) =>
within(getByTestId('checklist-items-container')).queryAllByTestId('checklist-item');

it('should not render a hidden incomplete item, but keep the server index of the items after it', () => {
const props = getBaseProps();
props.items = [
TestHelper.fakePlaybookChecklistItemModel({id: 'item-1', title: 'Item 1'}),
TestHelper.fakePlaybookChecklistItemModel({id: 'item-2', title: 'Hidden', conditionAction: 'hidden', completedAt: 0}),
TestHelper.fakePlaybookChecklistItemModel({id: 'item-3', title: 'Item 3'}),
];

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);
const items = renderedItems(getByTestId);

expect(items).toHaveLength(2);
expect(items[0].props.item.id).toBe('item-1');
expect(items[1].props.item.id).toBe('item-3');

// item-3 is third in the checklist, so the server still knows it as index 2 even though
// it is rendered second. Passing its rendered position instead would mutate item-2.
expect(items[0].props.itemNumber).toBe(0);
expect(items[1].props.itemNumber).toBe(2);
});

it('should render a hidden item once it has been completed', () => {
const props = getBaseProps();
props.items = [
TestHelper.fakePlaybookChecklistItemModel({id: 'item-1', title: 'Item 1'}),
TestHelper.fakePlaybookChecklistItemModel({id: 'item-2', title: 'Hidden', conditionAction: 'hidden', state: 'closed', completedAt: 123}),
];

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);

expect(renderedItems(getByTestId)).toHaveLength(2);
});
});

describe('task filters', () => {
const renderedItemIds = (getByTestId: (id: string) => ReactTestInstance) =>
within(getByTestId('checklist-items-container')).
queryAllByTestId('checklist-item').
map((item) => item.props.item.id);

function getFilterProps(): ComponentProps<typeof Checklist> {
const props = getBaseProps();
props.currentUserId = 'me';
props.items = [
TestHelper.fakePlaybookChecklistItemModel({id: 'unchecked', state: ''}),
TestHelper.fakePlaybookChecklistItemModel({id: 'checked', state: 'closed'}),
TestHelper.fakePlaybookChecklistItemModel({id: 'skipped', state: 'skipped'}),
TestHelper.fakePlaybookChecklistItemModel({id: 'mine', assigneeId: 'me'}),
TestHelper.fakePlaybookChecklistItemModel({id: 'theirs', assigneeId: 'someone-else'}),
];
return props;
}

it('should render every item with the default filters', () => {
const {getByTestId} = renderWithIntl(<Checklist {...getFilterProps()}/>);

expect(renderedItemIds(getByTestId)).toEqual(['unchecked', 'checked', 'skipped', 'mine', 'theirs']);
});

it('should hide checked items when showChecked is off', () => {
const props = getFilterProps();
props.filters = {...DEFAULT_TASK_FILTERS, showChecked: false};

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);

expect(renderedItemIds(getByTestId)).not.toContain('checked');
});

it('should hide skipped items when showSkipped is off', () => {
const props = getFilterProps();
props.filters = {...DEFAULT_TASK_FILTERS, showSkipped: false};

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);

expect(renderedItemIds(getByTestId)).not.toContain('skipped');
});

it('should hide items assigned to the current user when Me is off', () => {
const props = getFilterProps();
props.filters = {...DEFAULT_TASK_FILTERS, showAssignedToMe: false};

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);

expect(renderedItemIds(getByTestId)).not.toContain('mine');
});

it('should hide items assigned to other users when Others is off', () => {
const props = getFilterProps();
props.filters = {...DEFAULT_TASK_FILTERS, showAssignedToOthers: false};

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);

expect(renderedItemIds(getByTestId)).not.toContain('theirs');
});
Comment on lines +503 to +537

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter tests only check absence, not full result set.

These four tests use .not.toContain(...) without checking length or the full expected array, unlike the adjacent tests at lines 500 and 546 that assert the complete array. As per coding guidelines, "In tests, check array lengths in addition to individual items to ensure no extra elements exist."

✅ Proposed fix: assert the full expected set
-            expect(renderedItemIds(getByTestId)).not.toContain('checked');
+            expect(renderedItemIds(getByTestId)).toEqual(['unchecked', 'skipped', 'mine', 'theirs']);

(similarly for the showSkipped, showAssignedToMe, and showAssignedToOthers tests)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx`
around lines 503 - 537, Strengthen the four filter tests around Checklist by
asserting the complete rendered item ID array, not only that the filtered ID is
absent. Update the showChecked, showSkipped, showAssignedToMe, and
showAssignedToOthers cases to verify the expected array contents and length,
matching the adjacent tests’ full-result assertions.

Source: Coding guidelines


it('should hide unassigned items when Unassigned is off', () => {
const props = getFilterProps();
props.filters = {...DEFAULT_TASK_FILTERS, showUnassigned: false};

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);

// Only the two items that have an assignee remain.
expect(renderedItemIds(getByTestId)).toEqual(['mine', 'theirs']);
});

it('should keep the server index of items that survive a filter', () => {
const props = getFilterProps();
props.filters = {...DEFAULT_TASK_FILTERS, showChecked: false};

const {getByTestId} = renderWithIntl(<Checklist {...props}/>);
const items = within(getByTestId('checklist-items-container')).queryAllByTestId('checklist-item');

// 'checked' sits at index 1 and is filtered out; the items after it keep their own indices.
expect(items.map((i) => i.props.itemNumber)).toEqual([0, 2, 3, 4]);
});
});

describe('collapse all', () => {
it('should collapse when the run collapses all checklists', async () => {
const props = getBaseProps();
const {getByTestId, rerender} = renderWithIntl(<Checklist {...props}/>);

await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 16});
});

rerender(
<Checklist
{...props}
collapseAll={true}
collapseAllEpoch={1}
/>,
);

await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 0});
});
});

it('should re-collapse after being expanded individually', async () => {
const props = getBaseProps();
const {getByText, getByTestId, rerender} = renderWithIntl(<Checklist {...props}/>);

rerender(
<Checklist
{...props}
collapseAll={true}
collapseAllEpoch={1}
/>,
);
await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 0});
});

// Expand this one checklist on its own.
act(() => {
fireEvent.press(getByText('Test Checklist'));
});
await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 16});
});

// Collapsing all again must still collapse it, even though only the epoch changed.
rerender(
<Checklist
{...props}
collapseAll={true}
collapseAllEpoch={2}
/>,
);

await waitFor(() => {
expect(getByTestId('checklist-items-container')).toHaveAnimatedStyle({paddingVertical: 0});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import Button from '@components/button';
import CompassIcon from '@components/compass_icon';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update';
import {renameChecklist, addChecklistItem} from '@playbooks/actions/remote/checklist';
import ProgressBar from '@playbooks/components/progress_bar';
import {goToRenameChecklist, goToAddChecklistItem} from '@playbooks/screens/navigation';
import {getChecklistProgress} from '@playbooks/utils/progress';
import {isItemVisible, itemMatchesFilters, type TaskFilters} from '@playbooks/utils/task_filters';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
Expand Down Expand Up @@ -114,6 +116,10 @@ type Props = {
isFinished: boolean;
isParticipant: boolean;
checklistProgress: ReturnType<typeof getChecklistProgress>;
filters: TaskFilters;
currentUserId: string;
collapseAll: boolean;
collapseAllEpoch: number;
}

const Checklist = ({
Expand All @@ -131,6 +137,10 @@ const Checklist = ({
totalNumber,
progress,
},
filters,
currentUserId,
collapseAll,
collapseAllEpoch,
}: Props) => {
const [expanded, setExpanded] = useState(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Newly-mounted checklists ignore the current collapseAll state.

useDidUpdate (lines 153-156) skips the initial render, but expanded still defaults to true (line 145) regardless of collapseAll. A checklist that mounts for the first time while collapseAll is already true (e.g., a new checklist created mid-session) will render expanded until the next collapse-all toggle re-syncs it.

🔧 Proposed fix: seed initial state from the current prop
-    const [expanded, setExpanded] = useState(true);
+    const [expanded, setExpanded] = useState(() => !collapseAll);

Self-recovers on the next collapse/expand-all press, so impact is limited to a transient visual inconsistency.

Also applies to: 153-156

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/products/playbooks/screens/playbook_run/checklist/checklist.tsx` at line
145, Initialize the checklist’s expanded state from the current collapseAll prop
instead of always defaulting to true. Update the useState initialization near
the checklist component and preserve useDidUpdate’s synchronization behavior for
subsequent collapseAll changes, so newly mounted checklists immediately reflect
the current state.

const intl = useIntl();
Expand All @@ -140,10 +150,24 @@ const Checklist = ({
const height = useSharedValue(0);
const windowDimensions = useWindowDimensions();

// Follow the run-level collapse/expand control whenever the user triggers it.
useDidUpdate(() => {
setExpanded(!collapseAll);
}, [collapseAll, collapseAllEpoch]);

const toggleExpanded = useCallback(() => {
setExpanded((prev) => !prev);
}, []);

// `items` holds every item in the checklist's canonical order, so an item's position here is the
// index the server expects. Pair each item with that index before filtering, otherwise hiding an
// item would shift the indices sent to the server for the items after it.
// Not memoized: WatermelonDB mutates model instances in place, so a memo keyed on the array
// identity would keep serving a stale result after an item's state or assignee changes.
const visibleItems = items.
map((item, itemNumber) => ({item, itemNumber})).
filter(({item}) => isItemVisible(item) && itemMatchesFilters(item, filters, currentUserId));

const handleRename = useCallback(async (newTitle: string) => {
const res = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklist.id, newTitle);
if ('error' in res && res.error) {
Expand Down Expand Up @@ -234,13 +258,13 @@ const Checklist = ({
style={[styles.checklistItemsContainer, animatedStyle]}
testID='checklist-items-container'
>
{items.map((item, index) => (
{visibleItems.map(({item, itemNumber}) => (
<ChecklistItem
key={item.id}
item={item}
channelId={channelId}
checklistNumber={checklistNumber}
itemNumber={index}
itemNumber={itemNumber}
playbookRunId={playbookRunId}
isDisabled={isFinished || !isParticipant}
/>
Expand All @@ -262,13 +286,13 @@ const Checklist = ({
style={calculatorStyle}
onLayout={calculatorOnLayout}
>
{items.map((item, index) => (
{visibleItems.map(({item, itemNumber}) => (
<ChecklistItem
key={`calc-${item.id}`}
item={item}
channelId={channelId}
checklistNumber={checklistNumber}
itemNumber={index}
itemNumber={itemNumber}
playbookRunId={playbookRunId}
isDisabled={isFinished || !isParticipant}
/>
Expand Down
Loading
Loading