Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8e06636
Implement sortReportActions function
roryabraham Nov 9, 2022
117732a
Move sortReportActions to the top of ReportActionsUtils
roryabraham Nov 9, 2022
e1ac5b2
Preemptively re-implement getMostRecentIOUReportActionID
roryabraham Nov 9, 2022
698555d
Merge branch 'Rory-MostRecentIOUReportActionID' into Rory-SortReportA…
roryabraham Nov 9, 2022
a20d183
Merge branch 'Rory-SequenceNumReportActionID' into Rory-SortReportAct…
roryabraham Nov 9, 2022
f7652b7
use sortReportActions to sort view
roryabraham Nov 9, 2022
c023645
Fix typo and some outdated item.action references
roryabraham Nov 9, 2022
ec49005
Fix typo and add FIXME for created
roryabraham Nov 9, 2022
60db445
Merge branch 'main' into Rory-SortReportActionsByCreated
roryabraham Nov 11, 2022
afa1665
Merge branch 'main' into Rory-SortReportActionsByCreated
roryabraham Nov 22, 2022
02a3d34
Remove outdated FIXME
roryabraham Nov 22, 2022
c8f36fb
Update a few new usages of sortBy with sequenceNumber
roryabraham Nov 22, 2022
4cc45c4
Fix a few bugs from bad merge
roryabraham Nov 22, 2022
5885686
Reverse sorting order in ReportActionsView for inverted FlatList
roryabraham Nov 22, 2022
878b260
Implement faster inverted sorting order
roryabraham Nov 22, 2022
78e5342
Fix variable naming
roryabraham Nov 22, 2022
d7b94a8
Remove outdated comment
roryabraham Nov 22, 2022
6d877b3
Merge branch 'main' into Rory-SortReportActionsByCreated
roryabraham Nov 28, 2022
54e0423
Remove actionName from sorting criteria and tests
roryabraham Nov 28, 2022
f31d037
Address review comments
roryabraham Nov 29, 2022
90b18f9
Rename shouldInvertSortingOrder to shouldSortInDescendingOrder
roryabraham Nov 29, 2022
0deab40
Rename sortReportActions to getSortedReportActions
roryabraham Nov 29, 2022
007c5b8
Merge branch 'main' into Rory-SortReportActionsByCreated
roryabraham Nov 29, 2022
f6bca7c
Move function back to where it was to reduce diff
roryabraham Nov 29, 2022
07e5fab
Add filtering back to reportActions
roryabraham Nov 29, 2022
3f78a8c
Filter out any unsupported action types
roryabraham Nov 29, 2022
21bac92
Extract filter function to ReportActionsUtils
roryabraham Nov 29, 2022
0f826a7
Add tests for filter function
roryabraham Nov 29, 2022
9db4179
Fix automated test
roryabraham Nov 29, 2022
9e876e8
Simplify boolean
roryabraham Nov 30, 2022
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
77 changes: 52 additions & 25 deletions src/libs/ReportActionsUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,46 @@ function isDeletedAction(reportAction) {
}

/**
* Sorts the report actions by sequence number, filters out any that should not be shown and formats them for display.
* Sort an array of reportActions by their created timestamp first, and reportActionID second
* This gives us a stable order even in the case of multiple reportActions created on the same millisecond
*
* @param {Array} reportActions
* @param {Boolean} shouldSortInDescendingOrder
* @returns {Array}
*/
function getSortedReportActions(reportActions) {
return _.chain(reportActions)
.sortBy('sequenceNumber')
.filter(action => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU

// All comment actions are shown unless they are deleted and non-pending
|| (action.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && (!isDeletedAction(action) || !_.isEmpty(action.pendingAction)))
|| action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED
|| action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED)
.map((item, index) => ({action: item, index}))
.value()
.reverse();
function getSortedReportActions(reportActions, shouldSortInDescendingOrder = false) {
if (!_.isArray(reportActions)) {
throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`);
}
const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1;
reportActions.sort((first, second) => {
if (first.created !== second.created) {
return (first.created < second.created ? -1 : 1) * invertedMultiplier;
}

return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier;
});
return reportActions;
}

/**
* Filter out any reportActions which should not be displayed.
*
* @param {Array} reportActions
* @returns {Array}
*/
function filterReportActionsForDisplay(reportActions) {
return _.filter(reportActions, (reportAction) => {
// Filter out any unsupported reportAction types
if (!_.has(CONST.REPORT.ACTIONS.TYPE, reportAction.actionName)) {
return false;
}

// All other actions are displayed except deleted, non-pending actions
const isDeleted = isDeletedAction(reportAction);
const isPending = !_.isEmpty(reportAction.pendingAction);
return !isDeleted || isPending;
});
}

/**
Expand All @@ -65,10 +88,13 @@ function getSortedReportActions(reportActions) {
* @returns {String}
*/
function getMostRecentIOUReportActionID(reportActions) {
return _.chain(reportActions)
.where({actionName: CONST.REPORT.ACTIONS.TYPE.IOU})
.max(action => action.sequenceNumber)
.value().reportActionID;
const iouActions = _.where(reportActions, {actionName: CONST.REPORT.ACTIONS.TYPE.IOU});
if (_.isEmpty(iouActions)) {
return null;
}

const sortedReportActions = getSortedReportActions(iouActions);
return _.last(sortedReportActions).reportActionID;
}

/**
Expand All @@ -82,7 +108,7 @@ function getMostRecentIOUReportActionID(reportActions) {
function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) {
// Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list.
// If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete.
const previousAction = _.find(_.drop(reportActions, actionIndex + 1), action => isNetworkOffline || (action.action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE));
const previousAction = _.find(_.drop(reportActions, actionIndex + 1), action => isNetworkOffline || (action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE));
const currentAction = reportActions[actionIndex];

// It's OK for there to be no previous action, and in that case, false will be returned
Expand All @@ -92,17 +118,17 @@ function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) {
}

// Comments are only grouped if they happen within 5 minutes of each other
if (moment(currentAction.action.created).unix() - moment(previousAction.action.created).unix() > 300) {
if (moment(currentAction.created).unix() - moment(previousAction.created).unix() > 300) {
return false;
}

// Do not group if previous or current action was a renamed action
if (previousAction.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED
|| currentAction.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED
|| currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
return false;
}

return currentAction.action.actorEmail === previousAction.action.actorEmail;
return currentAction.actorEmail === previousAction.actorEmail;
}

/**
Expand All @@ -114,7 +140,7 @@ function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) {
function getLastVisibleMessageText(reportID, actionsToMerge = {}) {
const parser = new ExpensiMark();
const actions = _.toArray(lodashMerge({}, allReportActions[reportID], actionsToMerge));
const sortedActions = _.sortBy(actions, 'sequenceNumber');
const sortedActions = getSortedReportActions(actions);
const lastMessageIndex = _.findLastIndex(sortedActions, action => (
!isDeletedAction(action)
));
Expand Down Expand Up @@ -142,7 +168,7 @@ function getOptimisticLastReadSequenceNumberForDeletedAction(reportID, actionsTo

// Otherwise, we must find the first previous index of an action that is not deleted and less than the lastReadSequenceNumber
const actions = _.toArray(lodashMerge({}, allReportActions[reportID], actionsToMerge));
const sortedActions = _.sortBy(actions, 'sequenceNumber');
const sortedActions = getSortedReportActions(actions);
const lastMessageIndex = _.findLastIndex(sortedActions, action => (
!isDeletedAction(action) && action.sequenceNumber <= lastReadSequenceNumber
));
Expand All @@ -156,9 +182,10 @@ function getOptimisticLastReadSequenceNumberForDeletedAction(reportID, actionsTo
}

export {
getSortedReportActions,
filterReportActionsForDisplay,
getOptimisticLastReadSequenceNumberForDeletedAction,
getLastVisibleMessageText,
getSortedReportActions,
getMostRecentIOUReportActionID,
isDeletedAction,
isConsecutiveActionMadeByPreviousActor,
Expand Down
21 changes: 7 additions & 14 deletions src/pages/home/report/ReportActionsList.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,7 @@ const propTypes = {
report: reportPropTypes.isRequired,

/** Sorted actions prepared for display */
sortedReportActions: PropTypes.arrayOf(PropTypes.shape({
/** Index of the action in the array */
index: PropTypes.number,

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.

index isn't needed anymore? I see it used in renderItem but maybe the update item: reportAction, takes care of this? Just curious

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this isn't needed anymore. The index you see in renderItem comes from React Native itself.

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.

Thanks for the tip


/** The action itself */
action: PropTypes.shape(reportActionPropTypes),
})).isRequired,
sortedReportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)).isRequired,

/** The ID of the most recent IOU report action connected with the shown report */
mostRecentIOUReportActionID: PropTypes.string,
Expand Down Expand Up @@ -108,7 +102,7 @@ class ReportActionsList extends React.Component {
* @return {String}
*/
keyExtractor(item) {
return item.action.reportActionID;
return item.reportActionID;
}

/**
Expand All @@ -118,27 +112,26 @@ class ReportActionsList extends React.Component {
* See: https://reactnative.dev/docs/optimizing-flatlist-configuration#avoid-anonymous-function-on-renderitem
*
* @param {Object} args
* @param {Object} args.item
* @param {Number} args.index
*
* @returns {React.Component}
*/
renderItem({
item,
item: reportAction,
index,
}) {
// When the new indicator should not be displayed we explicitly set it to 0. The marker should never be shown above the
// created action (which will have sequenceNumber of 0) so we use 0 to indicate "hidden".
const shouldDisplayNewIndicator = this.props.newMarkerSequenceNumber > 0
&& item.action.sequenceNumber === this.props.newMarkerSequenceNumber
&& !ReportActionsUtils.isDeletedAction(item.action);
&& reportAction.sequenceNumber === this.props.newMarkerSequenceNumber
&& !ReportActionsUtils.isDeletedAction(reportAction);
return (
<ReportActionItem
report={this.props.report}
action={item.action}
action={reportAction}
displayAsGroup={ReportActionsUtils.isConsecutiveActionMadeByPreviousActor(this.props.sortedReportActions, index)}
shouldDisplayNewIndicator={shouldDisplayNewIndicator}
isMostRecentIOUReportAction={item.action.reportActionID === this.props.mostRecentIOUReportActionID}
isMostRecentIOUReportAction={reportAction.reportActionID === this.props.mostRecentIOUReportActionID}
hasOutstandingIOU={this.props.report.hasOutstandingIOU}
index={index}
/>
Expand Down
13 changes: 11 additions & 2 deletions src/pages/home/report/ReportActionsView.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class ReportActionsView extends React.Component {
};

this.currentScrollOffset = 0;
this.sortedReportActions = ReportActionsUtils.getSortedReportActions(props.reportActions);
this.sortedReportActions = this.getSortedReportActionsForDisplay(props.reportActions);
this.mostRecentIOUReportActionID = ReportActionsUtils.getMostRecentIOUReportActionID(props.reportActions);
this.trackScroll = this.trackScroll.bind(this);
this.toggleFloatingMessageCounter = this.toggleFloatingMessageCounter.bind(this);
Expand Down Expand Up @@ -130,7 +130,7 @@ class ReportActionsView extends React.Component {

shouldComponentUpdate(nextProps, nextState) {
if (!_.isEqual(nextProps.reportActions, this.props.reportActions)) {
this.sortedReportActions = ReportActionsUtils.getSortedReportActions(nextProps.reportActions);
this.sortedReportActions = this.getSortedReportActionsForDisplay(nextProps.reportActions);
this.mostRecentIOUReportActionID = ReportActionsUtils.getMostRecentIOUReportActionID(nextProps.reportActions);
return true;
}
Expand Down Expand Up @@ -247,6 +247,15 @@ class ReportActionsView extends React.Component {
Report.unsubscribeFromReportChannel(this.props.report.reportID);
}

/**
* @param {Object} reportActions
* @returns {Array}
*/
getSortedReportActionsForDisplay(reportActions) {
const sortedReportActions = ReportActionsUtils.getSortedReportActions(_.values(reportActions), true);
return ReportActionsUtils.filterReportActionsForDisplay(sortedReportActions);
}

/**
* @returns {Boolean}
*/
Expand Down
12 changes: 12 additions & 0 deletions tests/ui/UnreadIndicatorsTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,18 @@ function signInAndGetAppWithUnreadChat() {
sequenceNumber: 0,
created: MOMENT_TEN_MINUTES_AGO.format(MOMENT_FORMAT),
reportActionID: NumberUtils.rand64(),
message: [
{
style: 'strong',
text: '__FAKE__',
type: 'TEXT',
},
{
style: 'normal',
text: 'created this report',
type: 'TEXT',
},
],
},
1: TestHelper.buildTestReportComment(USER_B_EMAIL, 1, MOMENT_TEN_MINUTES_AGO.add(10, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID),
2: TestHelper.buildTestReportComment(USER_B_EMAIL, 2, MOMENT_TEN_MINUTES_AGO.add(20, 'seconds').format(MOMENT_FORMAT), USER_B_ACCOUNT_ID),
Expand Down
123 changes: 123 additions & 0 deletions tests/unit/ReportActionsUtilsTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import CONST from '../../src/CONST';
import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils';

describe('ReportActionsUtils', () => {
describe('getSortedReportActions', () => {
const cases = [
[
[
// This is the highest created timestamp, so should appear last
{
created: '2022-11-09 22:27:01.825',
reportActionID: '8401445780099176',
},
{
created: '2022-11-09 22:27:01.600',
reportActionID: '6401435781022176',
},

// These reportActions were created in the same millisecond so should appear ordered by reportActionID
{
created: '2022-11-09 22:26:48.789',
reportActionID: '2962390724708756',
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '1609646094152486',
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '1661970171066218',
},
],
[
{
created: '2022-11-09 22:26:48.789',
reportActionID: '1609646094152486',
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '1661970171066218',
},
{
created: '2022-11-09 22:26:48.789',
reportActionID: '2962390724708756',
},
{
created: '2022-11-09 22:27:01.600',
reportActionID: '6401435781022176',
},
{
created: '2022-11-09 22:27:01.825',
reportActionID: '8401445780099176',
},
],
],
];

test.each(cases)('sorts by created, then actionName, then reportActionID', (input, expectedOutput) => {
const result = ReportActionsUtils.getSortedReportActions(input);
expect(result).toStrictEqual(expectedOutput);
});

test.each(cases)('in descending order', (input, expectedOutput) => {
const result = ReportActionsUtils.getSortedReportActions(input, true);
expect(result).toStrictEqual(expectedOutput.reverse());
});
});

describe('filterReportActionsForDisplay', () => {
it('should filter out non-whitelisted actions', () => {
const input = [
{
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
message: [{html: 'Hello world'}],
},
{
actionName: CONST.REPORT.ACTIONS.TYPE.CLOSED,
message: [{html: 'Hello world'}],
},
{
actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
message: [{html: 'Hello world'}],
},
{
actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
message: [{html: 'Hello world'}],
},
{
actionName: CONST.REPORT.ACTIONS.TYPE.RENAMED,
message: [{html: 'Hello world'}],
},
{
actionName: 'REIMBURSED',
message: [{html: 'Hello world'}],
},
];
const result = ReportActionsUtils.filterReportActionsForDisplay(input);
input.pop();
expect(result).toStrictEqual(input);
});

it('should filter out deleted, non-pending comments', () => {
const input = [
{
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
message: [{html: 'Hello world'}],
},
{
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
message: [{html: ''}],
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
},
{
actionName: CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT,
message: [{html: ''}],
},
];
const result = ReportActionsUtils.filterReportActionsForDisplay(input);
input.pop();
expect(result).toStrictEqual(input);
});
});
});