Skip to content

Commit b16a951

Browse files
authored
Merge pull request #82904 from mukhrr/feat/82252-custom-report-layout-columns
Feat/82252 custom report layout columns
2 parents 84782fa + eed03cb commit b16a951

23 files changed

+806
-576
lines changed

src/CONST/index.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,7 +1220,6 @@ const CONST = {
12201220
EXPORT: 'export',
12211221
PAY: 'pay',
12221222
MERGE: 'merge',
1223-
REPORT_LAYOUT: 'reportLayout',
12241223
DUPLICATE: 'duplicate',
12251224
DUPLICATE_REPORT: 'duplicateReport',
12261225
},
@@ -7199,6 +7198,24 @@ const CONST = {
71997198
CHAT: {},
72007199
};
72017200
},
7201+
get REPORT_DETAILS_CUSTOM_COLUMNS() {
7202+
return {
7203+
RECEIPT: this.TABLE_COLUMNS.RECEIPT,
7204+
DATE: this.TABLE_COLUMNS.DATE,
7205+
MERCHANT: this.TABLE_COLUMNS.MERCHANT,
7206+
DESCRIPTION: this.TABLE_COLUMNS.DESCRIPTION,
7207+
CARD: this.TABLE_COLUMNS.CARD,
7208+
CATEGORY: this.TABLE_COLUMNS.CATEGORY,
7209+
TAG: this.TABLE_COLUMNS.TAG,
7210+
EXCHANGE_RATE: this.TABLE_COLUMNS.EXCHANGE_RATE,
7211+
ORIGINAL_AMOUNT: this.TABLE_COLUMNS.ORIGINAL_AMOUNT,
7212+
REIMBURSABLE: this.TABLE_COLUMNS.REIMBURSABLE,
7213+
BILLABLE: this.TABLE_COLUMNS.BILLABLE,
7214+
TAX_RATE: this.TABLE_COLUMNS.TAX_RATE,
7215+
TAX_AMOUNT: this.TABLE_COLUMNS.TAX_AMOUNT,
7216+
AMOUNT: this.TABLE_COLUMNS.TOTAL_AMOUNT,
7217+
};
7218+
},
72027219
get GROUP_CUSTOM_COLUMNS() {
72037220
return {
72047221
FROM: {
@@ -8508,7 +8525,6 @@ const CONST = {
85088525
MERGE: 'MoreMenu-Merge',
85098526
CHANGE_WORKSPACE: 'MoreMenu-ChangeWorkspace',
85108527
CHANGE_APPROVER: 'MoreMenu-ChangeApprover',
8511-
REPORT_LAYOUT: 'MoreMenu-ReportLayout',
85128528
DELETE: 'MoreMenu-Delete',
85138529
RETRACT: 'MoreMenu-Retract',
85148530
REOPEN: 'MoreMenu-Reopen',

src/ONYXKEYS.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,9 @@ const ONYXKEYS = {
643643
/** Stores the user's report layout group-by preference */
644644
NVP_REPORT_LAYOUT_GROUP_BY: 'nvp_expensify_groupByOption',
645645

646+
/** Stores the user's report details columns preference */
647+
NVP_REPORT_DETAILS_COLUMNS: 'nvp_reportDetailsColumns',
648+
646649
/** Partial transaction data used for MFA authorize transaction preview */
647650
TRANSACTIONS_PENDING_3DS_REVIEW: 'transactionsPending3DSReview',
648651

@@ -1431,6 +1434,7 @@ type OnyxValuesMapping = {
14311434
[ONYXKEYS.NVP_EXPENSE_RULES]: OnyxTypes.ExpenseRule[];
14321435
[ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE]: DistanceExpenseType;
14331436
[ONYXKEYS.NVP_REPORT_LAYOUT_GROUP_BY]: string;
1437+
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
14341438
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
14351439
[ONYXKEYS.IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN]: boolean;
14361440
[ONYXKEYS.PERSONAL_POLICY_ID]: string;

src/ROUTES.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,10 @@ const ROUTES = {
809809
route: 'r/:reportID/settings/report-layout',
810810
getRoute: (reportID: string) => `r/${reportID}/settings/report-layout` as const,
811811
},
812+
REPORT_SETTINGS_COLUMNS: {
813+
route: 'r/:reportID/settings/columns',
814+
getRoute: (reportID: string) => `r/${reportID}/settings/columns` as const,
815+
},
812816
SPLIT_BILL_DETAILS: {
813817
route: 'r/:reportID/split/:reportActionID',
814818
getRoute: (reportID: string | undefined, reportActionID: string, backTo?: string) => {

src/SCREENS.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ const SCREENS = {
445445
WRITE_CAPABILITY: 'Report_Settings_Write_Capability',
446446
VISIBILITY: 'Report_Settings_Visibility',
447447
REPORT_LAYOUT: 'Report_Settings_Report_Layout',
448+
COLUMNS: 'Report_Settings_Columns',
448449
},
449450

450451
NEW_TASK: {
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import React, {useState} from 'react';
2+
import {View} from 'react-native';
3+
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
4+
import useLocalize from '@hooks/useLocalize';
5+
import useTheme from '@hooks/useTheme';
6+
import useThemeStyles from '@hooks/useThemeStyles';
7+
import {getSearchColumnTranslationKey} from '@libs/SearchUIUtils';
8+
import Button from './Button';
9+
import DraggableList from './DraggableList';
10+
import HeaderWithBackButton from './HeaderWithBackButton';
11+
import Icon from './Icon';
12+
import ScreenWrapper from './ScreenWrapper';
13+
import ScrollView from './ScrollView';
14+
import type {SearchCustomColumnIds} from './Search/types';
15+
import type {ListItem} from './SelectionList/types';
16+
import MultiSelectListItem from './SelectionListWithSections/MultiSelectListItem';
17+
import Text from './Text';
18+
import TextLink from './TextLink';
19+
20+
type ColumnItem = {
21+
/** Display label for the column */
22+
text: string;
23+
24+
/** Column identifier value */
25+
value: SearchCustomColumnIds;
26+
27+
/** Unique key used for list rendering */
28+
keyForList: SearchCustomColumnIds;
29+
30+
/** Whether the column is currently enabled/visible */
31+
isSelected: boolean;
32+
33+
/** Whether the column toggle is disabled (e.g. for required columns) */
34+
isDisabled: boolean;
35+
36+
/** Whether the column cannot be reordered via drag */
37+
isDragDisabled: boolean;
38+
39+
/** Element rendered on the left side of the list item (e.g. drag handle) */
40+
leftElement: React.JSX.Element;
41+
};
42+
43+
type ColumnsSettingsListProps = {
44+
/** All available column IDs that can be displayed */
45+
allColumns: SearchCustomColumnIds[];
46+
47+
/** The default set of selected columns when no customization has been applied */
48+
defaultSelectedColumns: SearchCustomColumnIds[];
49+
50+
/** The currently selected and ordered columns */
51+
currentColumns: SearchCustomColumnIds[];
52+
53+
/** Columns that cannot be deselected by the user */
54+
requiredColumns: Set<SearchCustomColumnIds>;
55+
56+
/** The active group-by field (e.g. category, tag) */
57+
groupBy?: string;
58+
59+
/** The currently selected columns specific to the active group-by mode */
60+
groupColumns?: SearchCustomColumnIds[];
61+
62+
/** The default columns for the active group-by mode when no customization has been applied */
63+
defaultGroupColumns?: SearchCustomColumnIds[];
64+
65+
/** Callback fired with the updated column list when the user saves changes */
66+
onSave: (columns: SearchCustomColumnIds[]) => void;
67+
};
68+
69+
function ColumnsSettingsList({allColumns, defaultSelectedColumns, currentColumns, requiredColumns, groupBy, groupColumns = [], defaultGroupColumns = [], onSave}: ColumnsSettingsListProps) {
70+
const theme = useTheme();
71+
const styles = useThemeStyles();
72+
const icons = useMemoizedLazyExpensifyIcons(['DragHandles']);
73+
const {translate, localeCompare} = useLocalize();
74+
75+
const allCustomColumns = [...groupColumns, ...allColumns];
76+
const defaultCustomColumns = new Set([...defaultGroupColumns, ...defaultSelectedColumns]);
77+
78+
const sortColumns = (columnsToSort: ColumnItem[]): ColumnItem[] => {
79+
const selected = columnsToSort.filter((col) => col.isSelected);
80+
const unselected = columnsToSort
81+
.filter((col) => !col.isSelected)
82+
.sort((a, b) => {
83+
const textA = translate(getSearchColumnTranslationKey(a.value));
84+
const textB = translate(getSearchColumnTranslationKey(b.value));
85+
return localeCompare(textA, textB);
86+
});
87+
return [...selected, ...unselected];
88+
};
89+
90+
const defaultColumns = allCustomColumns.map((columnId) => ({
91+
columnId,
92+
isSelected: defaultCustomColumns.has(columnId),
93+
}));
94+
95+
const [columns, setColumns] = useState(() => {
96+
const selectedColumnIds = currentColumns.filter((columnId) => allCustomColumns.includes(columnId));
97+
98+
if (!selectedColumnIds.length) {
99+
return defaultColumns;
100+
}
101+
102+
const selected = selectedColumnIds.map((columnId) => ({columnId, isSelected: true}));
103+
const unselected = allCustomColumns.filter((columnId) => !selectedColumnIds.includes(columnId)).map((columnId) => ({columnId, isSelected: false}));
104+
105+
return [...selected, ...unselected];
106+
});
107+
108+
const selectedColumnIds = columns.filter((col) => col.isSelected).map((col) => col.columnId);
109+
110+
const allColumnsList = sortColumns(
111+
columns.map(({columnId, isSelected}) => {
112+
const isRequired = requiredColumns.has(columnId);
113+
const isEffectivelySelected = isRequired || isSelected;
114+
const isDragDisabled = !isEffectivelySelected;
115+
return {
116+
text: translate(getSearchColumnTranslationKey(columnId)),
117+
value: columnId,
118+
keyForList: columnId,
119+
isSelected: isEffectivelySelected,
120+
isDisabled: isRequired,
121+
isDragDisabled,
122+
leftElement: (
123+
<View style={[styles.mr3, isDragDisabled && styles.cursorDisabled]}>
124+
<Icon
125+
src={icons.DragHandles}
126+
fill={theme.icon}
127+
additionalStyles={isDragDisabled && styles.opacitySemiTransparent}
128+
/>
129+
</View>
130+
),
131+
};
132+
}),
133+
);
134+
135+
const typeColumnsList = allColumnsList.filter((column) => allColumns.includes(column.keyForList));
136+
const groupColumnsList = allColumnsList.filter((column) => groupColumns.includes(column.keyForList));
137+
138+
const isDefaultState =
139+
columns.length === defaultColumns.length &&
140+
columns.every((col, index) => col.columnId === defaultColumns.at(index)?.columnId && col.isSelected === defaultColumns.at(index)?.isSelected);
141+
142+
const onSelectItem = (item: ListItem) => {
143+
const updatedColumnId = item.keyForList as SearchCustomColumnIds;
144+
145+
if (requiredColumns.has(updatedColumnId)) {
146+
return;
147+
}
148+
149+
setColumns((prevColumns) => {
150+
const columnToUpdate = prevColumns.find((col) => col.columnId === updatedColumnId);
151+
152+
if (!columnToUpdate) {
153+
return prevColumns;
154+
}
155+
156+
const newIsSelected = !columnToUpdate.isSelected;
157+
158+
if (newIsSelected) {
159+
const selectedCols = prevColumns.filter((col) => col.isSelected);
160+
const unselected = prevColumns.filter((col) => !col.isSelected && col.columnId !== updatedColumnId);
161+
const unselectedSorted = unselected.sort((a, b) => {
162+
const textA = translate(getSearchColumnTranslationKey(a.columnId));
163+
const textB = translate(getSearchColumnTranslationKey(b.columnId));
164+
return localeCompare(textA, textB);
165+
});
166+
return [...selectedCols, {columnId: updatedColumnId, isSelected: true}, ...unselectedSorted];
167+
}
168+
169+
const updatedColumns = prevColumns.map((col) => (col.columnId === updatedColumnId ? {...col, isSelected: false} : col));
170+
return updatedColumns;
171+
});
172+
};
173+
174+
const onGroupDragEnd = ({data}: {data: typeof allColumnsList}) => {
175+
const newGroupColumns = data.map((item) => ({columnId: item.value, isSelected: item.isSelected}));
176+
const existingTypeColumns = typeColumnsList.map((item) => ({columnId: item.value, isSelected: item.isSelected}));
177+
const newColumns = [...existingTypeColumns, ...newGroupColumns];
178+
setColumns(newColumns);
179+
};
180+
181+
const onTypeDragEnd = ({data}: {data: typeof allColumnsList}) => {
182+
const newTypeColumns = data.map((item) => ({columnId: item.value, isSelected: item.isSelected}));
183+
const existingGroupColumns = groupColumnsList.map((item) => ({columnId: item.value, isSelected: item.isSelected}));
184+
const newColumns = [...existingGroupColumns, ...newTypeColumns];
185+
setColumns(newColumns);
186+
};
187+
188+
const resetColumns = () => {
189+
setColumns(defaultColumns);
190+
};
191+
192+
const handleSave = () => {
193+
onSave(selectedColumnIds);
194+
};
195+
196+
const renderItem = ({item}: {item: ListItem}) => {
197+
return (
198+
<MultiSelectListItem
199+
item={item}
200+
showTooltip={false}
201+
onSelectRow={onSelectItem}
202+
isDisabled={item.isDisabled}
203+
/>
204+
);
205+
};
206+
207+
return (
208+
<ScreenWrapper
209+
testID="ColumnsSettingsList"
210+
shouldShowOfflineIndicatorInWideScreen
211+
offlineIndicatorStyle={styles.mtAuto}
212+
includeSafeAreaPaddingBottom
213+
>
214+
<HeaderWithBackButton title={translate('search.columns')}>
215+
{!isDefaultState && <TextLink onPress={resetColumns}>{translate('search.resetColumns')}</TextLink>}
216+
</HeaderWithBackButton>
217+
<View style={styles.flex1}>
218+
<ScrollView
219+
style={styles.flex1}
220+
contentContainerStyle={styles.flex1}
221+
>
222+
{!!groupBy && (
223+
<>
224+
<View style={[styles.ph5, styles.pb3]}>
225+
<Text style={styles.textLabelSupporting}>{translate('search.groupColumns')}</Text>
226+
</View>
227+
228+
<DraggableList
229+
disableScroll
230+
data={groupColumnsList}
231+
keyExtractor={(item) => item.value}
232+
onDragEnd={onGroupDragEnd}
233+
renderItem={renderItem}
234+
/>
235+
236+
<View style={styles.dividerLine} />
237+
238+
<View style={[styles.ph5, styles.pv3]}>
239+
<Text style={styles.textLabelSupporting}>{translate('search.expenseColumns')}</Text>
240+
</View>
241+
</>
242+
)}
243+
244+
<DraggableList
245+
disableScroll
246+
data={typeColumnsList}
247+
keyExtractor={(item) => item.value}
248+
onDragEnd={onTypeDragEnd}
249+
renderItem={renderItem}
250+
/>
251+
</ScrollView>
252+
</View>
253+
<View style={[styles.ph5, styles.pb5]}>
254+
<Button
255+
large
256+
success
257+
pressOnEnter
258+
text={translate('common.save')}
259+
onPress={handleSave}
260+
/>
261+
</View>
262+
</ScreenWrapper>
263+
);
264+
}
265+
266+
export default ColumnsSettingsList;

src/components/MoneyReportHeader.tsx

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,18 +1497,6 @@ function MoneyReportHeader({
14971497
Navigation.navigate(ROUTES.REPORT_CHANGE_APPROVER.getRoute(moneyRequestReport.reportID, Navigation.getActiveRoute()));
14981498
},
14991499
},
1500-
[CONST.REPORT.SECONDARY_ACTIONS.REPORT_LAYOUT]: {
1501-
text: translate('reportLayout.reportLayout'),
1502-
icon: expensifyIcons.Feed,
1503-
value: CONST.REPORT.SECONDARY_ACTIONS.REPORT_LAYOUT,
1504-
sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REPORT_LAYOUT,
1505-
onSelected: () => {
1506-
if (!moneyRequestReport) {
1507-
return;
1508-
}
1509-
Navigation.navigate(ROUTES.REPORT_SETTINGS_REPORT_LAYOUT.getRoute(moneyRequestReport.reportID));
1510-
},
1511-
},
15121500
[CONST.REPORT.SECONDARY_ACTIONS.DELETE]: {
15131501
text: translate('common.delete'),
15141502
icon: expensifyIcons.Trashcan,

src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,7 @@ function MoneyRequestReportActionsList({
882882
}
883883
keyboardShouldPersistTaps="handled"
884884
onScroll={trackVerticalScrolling}
885-
contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt2]}
885+
contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]}
886886
ref={reportScrollManager.ref}
887887
ListEmptyComponent={!isOffline && showReportActionsLoadingState ? <ReportActionsListLoadingSkeleton /> : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState
888888
removeClippedSubviews={false}

0 commit comments

Comments
 (0)