diff --git a/patches/react-native-draggable-flatlist/details.md b/patches/react-native-draggable-flatlist/details.md index 0f72087f6309..6f33ab7c7bbe 100644 --- a/patches/react-native-draggable-flatlist/details.md +++ b/patches/react-native-draggable-flatlist/details.md @@ -22,3 +22,10 @@ - Upstream PR/issue: https://github.com/computerjazz/react-native-draggable-flatlist/issues/509 - E/App issue: [#87362](https://github.com/Expensify/App/issues/87362) - PR Introducing Patch: [#90617](https://github.com/Expensify/App/pull/90617) + +### [react-native-draggable-flatlist+4.0.3+004+fix-nested-scroll-measurement.patch](react-native-draggable-flatlist+4.0.3+004+fix-nested-scroll-measurement.patch) + +- Reason: Measures nested lists relative to the outer scroll view's native ref. Fabric rejects numeric handles, and patch 002 skipped that measurement entirely. The section offset is needed for grouped column drag autoscrolling. +- Upstream PR/issue: [#544](https://github.com/computerjazz/react-native-draggable-flatlist/pull/544) tracks the original measurement warning. +- E/App issue: [#101300](https://github.com/Expensify/App/issues/101300) +- PR Introducing Patch: [#102457](https://github.com/Expensify/App/pull/102457) diff --git a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+004+fix-nested-scroll-measurement.patch b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+004+fix-nested-scroll-measurement.patch new file mode 100644 index 000000000000..01e3ecf438fc --- /dev/null +++ b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+004+fix-nested-scroll-measurement.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx +index b84ee99..929c14f 100644 +--- a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx +@@ -1,5 +1,5 @@ + import React, { useRef, useState } from "react"; +-import { findNodeHandle, LogBox } from "react-native"; ++import { LogBox } from "react-native"; + import Animated, { + useDerivedValue, + useSharedValue, +@@ -48,7 +48,7 @@ function NestableDraggableFlatListInner( + }); + + const onListContainerLayout = useStableCallback(async ({ containerRef }) => { +- const nodeHandle = findNodeHandle(scrollableRef.current); ++ const nativeScrollRef = scrollableRef.current?.getNativeScrollRef(); + + const onSuccess = (_x: number, y: number) => { + listVerticalOffset.value = y; +@@ -57,12 +57,12 @@ function NestableDraggableFlatListInner( + console.log("## nested draggable list measure fail"); + }; + +- if (typeof nodeHandle === "number" ) { ++ if (!nativeScrollRef) { + return; + } + + //@ts-ignore +- containerRef.current.measureLayout(nodeHandle, onSuccess, onFail); ++ containerRef.current?.measureLayout(nativeScrollRef, onSuccess, onFail); + }); + + const onDragBegin: DraggableFlatListProps["onDragBegin"] = useStableCallback( diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7e9324d82ecf..b5751e748f74 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1969,6 +1969,7 @@ const CONST = { PRINT: 'print', CHANGE_WORKSPACE: 'changeWorkspace', CHANGE_APPROVER: 'changeApprover', + CUSTOMIZE_FIELDS: 'customizeFields', VIEW_DETAILS: 'viewDetails', DELETE: 'delete', RETRACT: 'retract', @@ -2029,6 +2030,7 @@ const CONST = { HOLD: 'hold', REMOVE_HOLD: 'removeHold', SPLIT: 'split', + CUSTOMIZE_FIELDS: 'customizeFields', VIEW_DETAILS: 'viewDetails', DELETE: 'delete', REJECT: 'reject', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f870557af1e2..145ac30e40e4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -953,6 +953,9 @@ const ONYXKEYS = { /** Stores the user's report details columns preference */ NVP_REPORT_DETAILS_COLUMNS: 'nvp_reportDetailsColumns', + /** JSON selection and order of fields in the expense view, independent of table columns. */ + NVP_EXPENSE_FIELDS: 'nvp_expenseFields', + /** Dragged table column widths, keyed by `columnResizingID` then column key. Per device, since ideal widths depend on window size. */ TABLE_COLUMN_WIDTHS: 'tableColumnWidths', @@ -2070,6 +2073,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_LAYOUT_GROUP_BY]: string; [ONYXKEYS.NVP_REPORT_LAYOUT_OPTION]: string; [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; + [ONYXKEYS.NVP_EXPENSE_FIELDS]: string; [ONYXKEYS.TABLE_COLUMN_WIDTHS]: OnyxTypes.TableColumnWidths; [ONYXKEYS.SINGLE_RHP_WIDTH]: number; [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7534ce53db52..998cd4ce2109 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2892,6 +2892,10 @@ const ROUTES = { route: 'r/:reportID/change-approver/reassign', getRoute: (reportID: string) => `r/${reportID}/change-approver/reassign` as const, }, + REPORT_SETTINGS_FIELDS: { + route: 'r/:reportID/settings/fields', + getRoute: (reportID: string) => `r/${reportID}/settings/fields` as const, + }, REPORT_SETTINGS_COLUMNS: { route: 'r/:reportID/settings/columns', getRoute: (reportID: string) => `r/${reportID}/settings/columns` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c82b9bf67db1..a241c701dffc 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -479,6 +479,7 @@ const SCREENS = { VISIBILITY: 'Report_Settings_Visibility', DYNAMIC_SETTINGS_VISIBILITY: 'Dynamic_Report_Settings_Visibility', COLUMNS: 'Report_Settings_Columns', + FIELDS: 'Report_Settings_Fields', }, NEW_TASK: { diff --git a/src/components/ColumnsSettingsList.tsx b/src/components/ColumnsSettingsList.tsx index a065dd38942d..63a333002048 100644 --- a/src/components/ColumnsSettingsList.tsx +++ b/src/components/ColumnsSettingsList.tsx @@ -7,53 +7,39 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getSearchColumnTranslationKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type {ComponentRef} from 'react'; import React, {useRef, useState} from 'react'; -import {View} from 'react-native'; +import {AccessibilityInfo, View} from 'react-native'; +import type {ColumnItem} from './ColumnsSettingsListItem'; +import type {DraggableListRenderItemParams} from './DraggableList/types'; import type {SearchCustomColumnIds} from './Search/types'; import type {ListItem} from './SelectionList/types'; import Button from './Button'; +import ColumnsSettingsListItem from './ColumnsSettingsListItem'; import DraggableList from './DraggableList'; +import DraggableScrollContainer from './DraggableList/ScrollContainer'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import ScreenWrapper from './ScreenWrapper'; -import ScrollView from './ScrollView'; -import MultiSelectListItem from './SelectionList/ListItem/MultiSelectListItem'; import Text from './Text'; import TextLink from './TextLink'; -type ColumnItem = { - /** Display label for the column */ - text: string; - - value: SearchCustomColumnIds; - keyForList: SearchCustomColumnIds; - - /** Whether the column is currently enabled/visible */ - isSelected: boolean; - - /** Whether the column toggle is disabled (e.g. for required columns) */ - isDisabled: boolean; - - /** Whether the column cannot be reordered via drag */ - isDragDisabled: boolean; - - /** Element rendered on the left side of the list item (e.g. drag handle) */ - leftElement: React.JSX.Element; -}; - type ColumnsSettingsListProps = { + /** Defaults to search.columns when the picker uses its standard title. */ + titleKey?: TranslationPaths; + allColumns: SearchCustomColumnIds[]; /** The default set of selected columns when no customization has been applied */ defaultSelectedColumns: SearchCustomColumnIds[]; - /** The currently selected and ordered columns */ + /** The columns displayed in the table, in their current order */ currentColumns: SearchCustomColumnIds[]; /** Columns that cannot be deselected by the user */ @@ -68,14 +54,15 @@ type ColumnsSettingsListProps = { /** The default columns for the active group-by mode when no customization has been applied */ defaultGroupColumns?: SearchCustomColumnIds[]; - /** Callback fired with the updated column list when the user saves changes */ - onSave: (columns: SearchCustomColumnIds[]) => void; + /** Async handlers can validate current preferences before closing the picker. */ + onSave: (columns: SearchCustomColumnIds[]) => void | Promise; /** The active Search type, used to resolve type-specific column labels */ type?: SearchDataTypes; }; function ColumnsSettingsList({ + titleKey = 'search.columns', allColumns, defaultSelectedColumns, currentColumns, @@ -89,12 +76,12 @@ function ColumnsSettingsList({ const theme = useTheme(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['DragHandles']); - const {translate, localeCompare} = useLocalize(); + const {translate, localeCompare, numberFormat} = useLocalize(); const isGrouped = !!groupBy; const allCustomColumns = [...groupColumns, ...allColumns]; - const defaultCustomColumns = new Set([...defaultGroupColumns, ...defaultSelectedColumns]); + const defaultCustomColumns = new Set([...defaultGroupColumns, ...defaultSelectedColumns].filter((column) => allCustomColumns.includes(column))); const sortColumns = (columnsToSort: ColumnItem[]): ColumnItem[] => { const selected = columnsToSort.filter((col) => col.isSelected); @@ -108,10 +95,10 @@ function ColumnsSettingsList({ return [...selected, ...unselected]; }; - const defaultColumns = allCustomColumns.map((columnId) => ({ - columnId, - isSelected: defaultCustomColumns.has(columnId), - })); + const defaultColumns = [ + ...[...defaultCustomColumns].map((columnId) => ({columnId, isSelected: true})), + ...[...new Set(allCustomColumns)].filter((columnId) => !defaultCustomColumns.has(columnId)).map((columnId) => ({columnId, isSelected: false})), + ]; const [columns, setColumns] = useState(() => { const selectedColumnIds = currentColumns.filter((columnId) => allCustomColumns.includes(columnId)); @@ -120,7 +107,10 @@ function ColumnsSettingsList({ return defaultColumns; } - const selected = selectedColumnIds.map((columnId) => ({columnId, isSelected: true})); + const selected = selectedColumnIds.map((columnId) => ({ + columnId, + isSelected: true, + })); const unselected = allCustomColumns.filter((columnId) => !selectedColumnIds.includes(columnId)).map((columnId) => ({columnId, isSelected: false})); return [...selected, ...unselected]; @@ -196,10 +186,11 @@ function ColumnsSettingsList({ const groupLength = groupColumnsList.length; const disabledIndexes = combinedItems.flatMap((item, index) => (item.isDisabled ? [index] : [])); const containerRef = useRef>(null); + const [isDragging, setIsDragging] = useState(false); const {focusedIndex, setFocusedIndex} = useListKeyboardNav({ containerRef, - isActive: isGrouped, + isActive: isGrouped && !isDragging, itemKeys: combinedItems.map((item) => item.value), disabledIndexes, }); @@ -208,16 +199,28 @@ function ColumnsSettingsList({ const typeFocusedIndex = focusedIndex >= groupLength ? focusedIndex - groupLength : -1; const onGroupDragEnd = ({data}: {data: typeof allColumnsList}) => { - const newGroupColumns = data.map((item) => ({columnId: item.value, isSelected: item.isSelected})); - const existingTypeColumns = typeColumnsList.map((item) => ({columnId: item.value, isSelected: item.isSelected})); + const newGroupColumns = data.map((item) => ({ + columnId: item.value, + isSelected: item.isSelected, + })); + const existingTypeColumns = typeColumnsList.map((item) => ({ + columnId: item.value, + isSelected: item.isSelected, + })); const newColumns = [...existingTypeColumns, ...newGroupColumns]; setColumns(newColumns); setFocusedIndex(-1); }; const onTypeDragEnd = ({data}: {data: typeof allColumnsList}) => { - const newTypeColumns = data.map((item) => ({columnId: item.value, isSelected: item.isSelected})); - const existingGroupColumns = groupColumnsList.map((item) => ({columnId: item.value, isSelected: item.isSelected})); + const newTypeColumns = data.map((item) => ({ + columnId: item.value, + isSelected: item.isSelected, + })); + const existingGroupColumns = groupColumnsList.map((item) => ({ + columnId: item.value, + isSelected: item.isSelected, + })); const newColumns = [...existingGroupColumns, ...newTypeColumns]; setColumns(newColumns); setFocusedIndex(-1); @@ -231,14 +234,46 @@ function ColumnsSettingsList({ onSave(selectedColumnIds); }; - const renderItem = ({item, isFocused}: {item: ListItem; isFocused?: boolean}) => { + const moveColumn = (item: ColumnItem, offset: number) => { + const isGroupColumn = groupColumns.includes(item.value); + const columnList = isGroupColumn ? groupColumnsList : typeColumnsList; + const index = columnList.findIndex((column) => column.value === item.value); + const targetIndex = index + offset; + const targetItem = columnList.at(targetIndex); + if (index < 0 || targetIndex < 0 || item.isDragDisabled || !targetItem || targetItem.isDragDisabled) { + return; + } + + const reorderedColumns = [...columnList]; + reorderedColumns.splice(index, 1); + reorderedColumns.splice(targetIndex, 0, item); + const onDragEnd = isGroupColumn ? onGroupDragEnd : onTypeDragEnd; + onDragEnd({data: reorderedColumns}); + AccessibilityInfo.announceForAccessibility( + translate('search.columnMoved', { + columnName: item.text, + position: numberFormat(targetIndex + 1), + total: numberFormat(columnList.filter((column) => !column.isDragDisabled).length), + }), + ); + }; + + const renderItem = ({item, isFocused, drag, isActive}: DraggableListRenderItemParams) => { + const columnList = groupColumns.includes(item.value) ? groupColumnsList : typeColumnsList; + const index = columnList.findIndex((column) => column.value === item.value); + const canMoveUp = !item.isDragDisabled && index > 0 && !columnList.at(index - 1)?.isDragDisabled; + const nextColumn = columnList.at(index + 1); + const canMoveDown = !item.isDragDisabled && !!nextColumn && !nextColumn.isDragDisabled; + return ( - moveColumn(item, -1) : undefined} + onMoveDown={canMoveDown ? () => moveColumn(item, 1) : undefined} /> ); }; @@ -250,15 +285,13 @@ function ColumnsSettingsList({ offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom > - - {!isDefaultState && {translate('search.resetColumns')}} - + {!isDefaultState && {translate('search.resetColumns')}} - - {isGrouped ? ( + {isGrouped ? ( + {translate('search.groupColumns')} @@ -269,7 +302,9 @@ function ColumnsSettingsList({ data={groupColumnsList} keyExtractor={(item) => item.value} onDragEnd={onGroupDragEnd} + onDragActiveChange={setIsDragging} onSelectRow={onSelectItem} + shouldRestrictDragToEnabledItems isItemDragDisabled={(item) => item.isDragDisabled} isItemDisabled={(item) => item.isDisabled} renderItem={renderItem} @@ -287,26 +322,28 @@ function ColumnsSettingsList({ data={typeColumnsList} keyExtractor={(item) => item.value} onDragEnd={onTypeDragEnd} + onDragActiveChange={setIsDragging} onSelectRow={onSelectItem} + shouldRestrictDragToEnabledItems isItemDragDisabled={(item) => item.isDragDisabled} isItemDisabled={(item) => item.isDisabled} renderItem={renderItem} focusedIndex={typeFocusedIndex} /> - ) : ( - item.value} - onDragEnd={onTypeDragEnd} - onSelectRow={onSelectItem} - isItemDragDisabled={(item) => item.isDragDisabled} - isItemDisabled={(item) => item.isDisabled} - renderItem={renderItem} - /> - )} - + + ) : ( + item.value} + onDragEnd={onTypeDragEnd} + onSelectRow={onSelectItem} + shouldRestrictDragToEnabledItems + isItemDragDisabled={(item) => item.isDragDisabled} + isItemDisabled={(item) => item.isDisabled} + renderItem={renderItem} + /> + )}