Skip to content

Commit

Permalink
Merge pull request #52250 from margelo/cleanup/optionlistutils
Browse files Browse the repository at this point in the history
clean: moved Category OptionListUtils over to CategoryOptionListUtils
  • Loading branch information
grgia authored Nov 22, 2024
2 parents 3ed9f64 + cc51624 commit 429a8fc
Show file tree
Hide file tree
Showing 20 changed files with 1,555 additions and 1,593 deletions.
10 changes: 5 additions & 5 deletions src/components/CategoryPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {useOnyx} from 'react-native-onyx';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import * as CategoryOptionsListUtils from '@libs/CategoryOptionListUtils';
import type {Category} from '@libs/CategoryOptionListUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -27,28 +29,26 @@ function CategoryPicker({selectedCategory, policyID, onSubmit}: CategoryPickerPr
const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';

const selectedOptions = useMemo(() => {
const selectedOptions = useMemo((): Category[] => {
if (!selectedCategory) {
return [];
}

return [
{
name: selectedCategory,
accountID: undefined,
isSelected: true,
enabled: true,
},
];
}, [selectedCategory]);

const [sections, headerMessage, shouldShowTextInput] = useMemo(() => {
const categories = policyCategories ?? policyCategoriesDraft ?? {};
const validPolicyRecentlyUsedCategories = policyRecentlyUsedCategories?.filter?.((p) => !isEmptyObject(p));
const {categoryOptions} = OptionsListUtils.getFilteredOptions({
const categoryOptions = CategoryOptionsListUtils.getCategoryListSections({
searchValue: debouncedSearchValue,
selectedOptions,
includeP2P: false,
includeCategories: true,
categories,
recentlyUsedCategories: validPolicyRecentlyUsedCategories,
});
Expand Down
3 changes: 1 addition & 2 deletions src/components/Search/SearchFiltersChatsSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const defaultListOptions = {
personalDetails: [],
userToInvite: null,
currentUserOption: null,
categoryOptions: [],
headerMessage: '',
};

Expand Down Expand Up @@ -75,7 +74,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen
}, [defaultOptions, cleanSearchTerm, selectedOptions]);

const {sections, headerMessage} = useMemo(() => {
const newSections: OptionsListUtils.CategorySection[] = [];
const newSections: OptionsListUtils.Section[] = [];
if (!areOptionsInitialized) {
return {sections: [], headerMessage: undefined};
}
Expand Down
3 changes: 1 addition & 2 deletions src/components/Search/SearchFiltersParticipantsSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ const defaultListOptions = {
personalDetails: [],
currentUserOption: null,
headerMessage: '',
categoryOptions: [],
};

function getSelectedOptionData(option: Option): OptionData {
Expand Down Expand Up @@ -73,7 +72,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
}, [defaultOptions, cleanSearchTerm, selectedOptions]);

const {sections, headerMessage} = useMemo(() => {
const newSections: OptionsListUtils.CategorySection[] = [];
const newSections: OptionsListUtils.Section[] = [];
if (!areOptionsInitialized) {
return {sections: [], headerMessage: undefined};
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
const {options, areOptionsInitialized} = useOptionsList();
const searchOptions = useMemo(() => {
if (!areOptionsInitialized) {
return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: []};
return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null};
}
return OptionsListUtils.getSearchOptions(options, '', betas ?? []);
}, [areOptionsInitialized, betas, options]);
Expand Down
282 changes: 282 additions & 0 deletions src/libs/CategoryOptionListUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
// eslint-disable-next-line you-dont-need-lodash-underscore/get
import lodashGet from 'lodash/get';
import lodashSet from 'lodash/set';
import CONST from '@src/CONST';
import type {PolicyCategories} from '@src/types/onyx';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
import * as Localize from './Localize';
import type {OptionTree, SectionBase} from './OptionsListUtils';

type CategoryTreeSection = SectionBase & {
data: OptionTree[];
indexOffset?: number;
};

type Category = {
name: string;
enabled: boolean;
isSelected?: boolean;
pendingAction?: OnyxCommon.PendingAction;
};

type Hierarchy = Record<string, Category & {[key: string]: Hierarchy & Category}>;

/**
* Builds the options for the category tree hierarchy via indents
*
* @param options - an initial object array
* @param options[].enabled - a flag to enable/disable option in a list
* @param options[].name - a name of an option
* @param [isOneLine] - a flag to determine if text should be one line
*/
function getCategoryOptionTree(options: Record<string, Category> | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] {
const optionCollection = new Map<string, OptionTree>();
Object.values(options).forEach((option) => {
if (isOneLine) {
if (optionCollection.has(option.name)) {
return;
}

optionCollection.set(option.name, {
text: option.name,
keyForList: option.name,
searchText: option.name,
tooltipText: option.name,
isDisabled: !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
isSelected: !!option.isSelected,
pendingAction: option.pendingAction,
});

return;
}

option.name.split(CONST.PARENT_CHILD_SEPARATOR).forEach((optionName, index, array) => {
const indents = times(index, () => CONST.INDENTS).join('');
const isChild = array.length - 1 === index;
const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR);
const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText);
const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;

if (optionCollection.has(searchText)) {
return;
}

optionCollection.set(searchText, {
text: `${indents}${optionName}`,
keyForList: searchText,
searchText,
tooltipText: optionName,
isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled,
isSelected: isChild ? !!option.isSelected : !!selectedParentOption,
pendingAction: option.pendingAction,
});
});
});

return Array.from(optionCollection.values());
}

/**
* Builds the section list for categories
*/
function getCategoryListSections({
categories,
searchValue,
selectedOptions = [],
recentlyUsedCategories = [],
maxRecentReportsToShow = CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
}: {
categories: PolicyCategories;
selectedOptions?: Category[];
searchValue?: string;
recentlyUsedCategories?: string[];
maxRecentReportsToShow?: number;
}): CategoryTreeSection[] {
const sortedCategories = sortCategories(categories);
const enabledCategories = Object.values(sortedCategories).filter((category) => category.enabled);
const enabledCategoriesNames = enabledCategories.map((category) => category.name);
const selectedOptionsWithDisabledState: Category[] = [];
const categorySections: CategoryTreeSection[] = [];
const numberOfEnabledCategories = enabledCategories.length;

selectedOptions.forEach((option) => {
if (enabledCategoriesNames.includes(option.name)) {
const categoryObj = enabledCategories.find((category) => category.name === option.name);
selectedOptionsWithDisabledState.push({...(categoryObj ?? option), isSelected: true, enabled: true});
return;
}
selectedOptionsWithDisabledState.push({...option, isSelected: true, enabled: false});
});

if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) {
const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true);
categorySections.push({
// "Selected" section
title: '',
shouldShow: false,
data,
indexOffset: data.length,
});

return categorySections;
}

if (searchValue) {
const categoriesForSearch = [...selectedOptionsWithDisabledState, ...enabledCategories];
const searchCategories: Category[] = [];

categoriesForSearch.forEach((category) => {
if (!category.name.toLowerCase().includes(searchValue.toLowerCase())) {
return;
}
searchCategories.push({
...category,
isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name),
});
});

const data = getCategoryOptionTree(searchCategories, true);
categorySections.push({
// "Search" section
title: '',
shouldShow: true,
data,
indexOffset: data.length,
});

return categorySections;
}

if (selectedOptions.length > 0) {
const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true);
categorySections.push({
// "Selected" section
title: '',
shouldShow: false,
data,
indexOffset: data.length,
});
}

const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name);
const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name));

if (numberOfEnabledCategories < CONST.STANDARD_LIST_ITEM_LIMIT) {
const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState);
categorySections.push({
// "All" section when items amount less than the threshold
title: '',
shouldShow: false,
data,
indexOffset: data.length,
});

return categorySections;
}

const filteredRecentlyUsedCategories = recentlyUsedCategories
.filter(
(categoryName) =>
!selectedOptionNames.includes(categoryName) && categories[categoryName]?.enabled && categories[categoryName]?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
)
.map((categoryName) => ({
name: categoryName,
enabled: categories[categoryName].enabled ?? false,
}));

if (filteredRecentlyUsedCategories.length > 0) {
const cutRecentlyUsedCategories = filteredRecentlyUsedCategories.slice(0, maxRecentReportsToShow);

const data = getCategoryOptionTree(cutRecentlyUsedCategories, true);
categorySections.push({
// "Recent" section
title: Localize.translateLocal('common.recent'),
shouldShow: true,
data,
indexOffset: data.length,
});
}

const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState);
categorySections.push({
// "All" section when items amount more than the threshold
title: Localize.translateLocal('common.all'),
shouldShow: true,
data,
indexOffset: data.length,
});

return categorySections;
}

/**
* Sorts categories using a simple object.
* It builds an hierarchy (based on an object), where each category has a name and other keys as subcategories.
* Via the hierarchy we avoid duplicating and sort categories one by one. Subcategories are being sorted alphabetically.
*/
function sortCategories(categories: Record<string, Category>): Category[] {
// Sorts categories alphabetically by name.
const sortedCategories = Object.values(categories).sort((a, b) => a.name.localeCompare(b.name));

// An object that respects nesting of categories. Also, can contain only uniq categories.
const hierarchy: Hierarchy = {};
/**
* Iterates over all categories to set each category in a proper place in hierarchy
* It gets a path based on a category name e.g. "Parent: Child: Subcategory" -> "Parent.Child.Subcategory".
* {
* Parent: {
* name: "Parent",
* Child: {
* name: "Child"
* Subcategory: {
* name: "Subcategory"
* }
* }
* }
* }
*/
sortedCategories.forEach((category) => {
const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR);
const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy;
lodashSet(hierarchy, path, {
...existedValue,
name: category.name,
pendingAction: category.pendingAction,
});
});

/**
* A recursive function to convert hierarchy into an array of category objects.
* The category object contains base 2 properties: "name" and "enabled".
* It iterates each key one by one. When a category has subcategories, goes deeper into them. Also, sorts subcategories alphabetically.
*/
const flatHierarchy = (initialHierarchy: Hierarchy) =>
Object.values(initialHierarchy).reduce((acc: Category[], category) => {
const {name, pendingAction, ...subcategories} = category;
if (name) {
const categoryObject: Category = {
name,
pendingAction,
enabled: categories[name]?.enabled ?? false,
};

acc.push(categoryObject);
}

if (!isEmptyObject(subcategories)) {
const nestedCategories = flatHierarchy(subcategories);

acc.push(...nestedCategories.sort((a, b) => a.name.localeCompare(b.name)));
}

return acc;
}, []);

return flatHierarchy(hierarchy);
}

export {getCategoryListSections, getCategoryOptionTree, sortCategories};

export type {Category, SectionBase as CategorySectionBase, CategoryTreeSection, Hierarchy};
Loading

0 comments on commit 429a8fc

Please sign in to comment.