Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

clean: moved Category OptionListUtils over to CategoryOptionListUtils #52250

Merged
merged 11 commits into from
Nov 22, 2024
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
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 @@ -72,7 +72,7 @@ function SearchRouter({onRouterClose}: 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
Copy link
Contributor

Choose a reason for hiding this comment

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

why do we need lodash get here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

to be fair, idk, this is copied over from the previously existing code. I think the lodashGet function simplifies a rather complicated nested lookup for us here (we'd need to write our own helper function for this case)

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
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you explain this comment?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I see, these are the sections comments- maybe instead of const data we should use the variable name to make this clearer

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hm agree, this is just copied over code from how it was previously used. Do we need to make this change in this PR?

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
Loading