diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b3c3d9a52a16..fb99108c7e97 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -534,6 +534,10 @@ const ROUTES = { route: 'workspace/:policyID/categories', getRoute: (policyID: string) => `workspace/${policyID}/categories` as const, }, + WORKSPACE_CATEGORY_SETTINGS: { + route: 'workspace/:policyID/categories/:categoryName', + getRoute: (policyID: string, categoryName: string) => `workspace/${policyID}/categories/${encodeURI(categoryName)}` as const, + }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'workspace/:policyID/categories/settings', getRoute: (policyID: string) => `workspace/${policyID}/categories/settings` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 771007e63113..cc7df01524f7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -221,6 +221,7 @@ const SCREENS = { DESCRIPTION: 'Workspace_Profile_Description', SHARE: 'Workspace_Profile_Share', NAME: 'Workspace_Profile_Name', + CATEGORY_SETTINGS: 'Category_Settings', CATEGORIES_SETTINGS: 'Categories_Settings', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index a7f65a93aff7..f812ff55851a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1756,6 +1756,7 @@ export default { collect: 'Collect', }, categories: { + categoryName: 'Category name', requiresCategory: 'Members must categorize all spend', enableCategory: 'Enable category', subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.', @@ -1763,6 +1764,7 @@ export default { title: "You haven't created any categories", subtitle: 'Add a category to organize your spend.', }, + genericFailureMessage: 'An error occurred while updating the category, please try again.', }, emptyWorkspace: { title: 'Create a workspace', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2c045d826c7d..e8fe2a1e3c71 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1780,6 +1780,7 @@ export default { collect: 'Recolectar', }, categories: { + categoryName: 'Nombre de la categoría', requiresCategory: 'Los miembros deben categorizar todos los gastos', enableCategory: 'Activar categoría', subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.', @@ -1787,6 +1788,7 @@ export default { title: 'No has creado ninguna categoría', subtitle: 'Añade una categoría para organizar tu gasto.', }, + genericFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.', }, emptyWorkspace: { title: 'Crea un espacio de trabajo', diff --git a/src/libs/API/parameters/SetWorkspaceCategoriesEnabledParams.ts b/src/libs/API/parameters/SetWorkspaceCategoriesEnabledParams.ts new file mode 100644 index 000000000000..0851dc366819 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceCategoriesEnabledParams.ts @@ -0,0 +1,10 @@ +type SetWorkspaceCategoriesEnabledParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{name: string; enabled: boolean}> + */ + categories: string; +}; + +export default SetWorkspaceCategoriesEnabledParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index a0fb81936ba3..ccb42ed44e74 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -145,6 +145,7 @@ export type {default as UnHoldMoneyRequestParams} from './UnHoldMoneyRequestPara export type {default as CancelPaymentParams} from './CancelPaymentParams'; export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount'; export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams'; +export type {default as SetWorkspaceCategoriesEnabledParams} from './SetWorkspaceCategoriesEnabledParams'; export type {default as SetWorkspaceRequiresCategoryParams} from './SetWorkspaceRequiresCategoryParams'; export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams'; export type {default as SetWorkspaceAutoReportingFrequencyParams} from './SetWorkspaceAutoReportingFrequencyParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0172732c826f..fd530d648fc2 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -113,6 +113,7 @@ const WRITE_COMMANDS = { UPDATE_WORKSPACE_DESCRIPTION: 'UpdateWorkspaceDescription', CREATE_WORKSPACE: 'CreateWorkspace', CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment', + SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', CREATE_TASK: 'CreateTask', CANCEL_TASK: 'CancelTask', @@ -260,6 +261,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT_AND_RATE]: Parameters.UpdateWorkspaceCustomUnitAndRateParams; [WRITE_COMMANDS.CREATE_WORKSPACE]: Parameters.CreateWorkspaceParams; [WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT]: Parameters.CreateWorkspaceFromIOUPaymentParams; + [WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED]: Parameters.SetWorkspaceCategoriesEnabledParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams; [WRITE_COMMANDS.CANCEL_TASK]: Parameters.CancelTaskParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index e603e785040d..527d93c2a3db 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -248,6 +248,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType, [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType, + [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: () => require('../../../pages/workspace/categories/CategorySettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index dc8f21c2ba75..7e0e6c028ff1 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -265,6 +265,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.INVITE_MESSAGE]: { path: ROUTES.WORKSPACE_INVITE_MESSAGE.route, }, + [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { + path: ROUTES.WORKSPACE_CATEGORY_SETTINGS.route, + parse: { + categoryName: (categoryName: string) => decodeURI(categoryName), + }, + }, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 6ab961778d8e..6d680ac7e190 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -186,6 +186,10 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.INVITE_MESSAGE]: { policyID: string; }; + [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 4996f7e91033..9c270c270780 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -45,6 +45,7 @@ import type { InvitedEmailsToAccountIDs, PersonalDetailsList, Policy, + PolicyCategories, PolicyMember, PolicyTagList, RecentlyUsedCategories, @@ -202,6 +203,13 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedTags = val), }); +let allPolicyCategories: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_CATEGORIES, + waitForCollectionCallback: true, + callback: (val) => (allPolicyCategories = val), +}); + /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user */ @@ -2270,7 +2278,81 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string return policyID; } -const setWorkspaceRequiresCategory = (policyID: string, requiresCategory: boolean) => { +function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Record) { + const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: null, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }; + + return acc; + }, {}), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: null, + pendingFields: { + enabled: null, + }, + }; + + return acc; + }, {}), + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'), + pendingFields: { + enabled: null, + }, + }; + + return acc; + }, {}), + }, + }, + ], + }; + + const parameters = { + policyID, + categories: JSON.stringify(Object.keys(categoriesToUpdate).map((key) => categoriesToUpdate[key])), + }; + + API.write('SetWorkspaceCategoriesEnabled', parameters, onyxData); +} + +function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { const onyxData: OnyxData = { optimisticData: [ { @@ -2322,7 +2404,21 @@ const setWorkspaceRequiresCategory = (policyID: string, requiresCategory: boolea }; API.write('SetWorkspaceRequiresCategory', parameters, onyxData); -}; +} + +function clearCategoryErrors(policyID: string, categoryName: string) { + const category = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + + if (!category) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { + [category.name]: { + errors: null, + }, + }); +} export { removeMembers, @@ -2369,5 +2465,7 @@ export { setWorkspaceAutoReportingFrequency, setWorkspaceAutoReportingMonthlyOffset, updateWorkspaceDescription, + setWorkspaceCategoryEnabled, setWorkspaceRequiresCategory, + clearCategoryErrors, }; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx new file mode 100644 index 000000000000..16f128e5ea1f --- /dev/null +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -0,0 +1,90 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setWorkspaceCategoryEnabled} from '@libs/actions/Policy'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import * as Policy from '@userActions/Policy'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; + +type CategorySettingsPageOnyxProps = { + /** Collection of categories attached to a policy */ + policyCategories: OnyxEntry; +}; + +type CategorySettingsPageProps = CategorySettingsPageOnyxProps & StackScreenProps; + +function CategorySettingsPage({route, policyCategories}: CategorySettingsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const policyCategory = policyCategories?.[route.params.categoryName]; + + if (!policyCategory) { + return ; + } + + const updateWorkspaceRequiresCategory = (value: boolean) => { + setWorkspaceCategoryEnabled(route.params.policyID, {[policyCategory.name]: {name: policyCategory.name, enabled: value}}); + }; + + return ( + + + + + + Policy.clearCategoryErrors(route.params.policyID, route.params.categoryName)} + > + + + {translate('workspace.categories.enableCategory')} + + + + + + + + + + ); +} + +CategorySettingsPage.displayName = 'CategorySettingsPage'; + +export default withOnyx({ + policyCategories: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params.policyID}`, + }, +})(CategorySettingsPage); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 721341073d72..d15011489bac 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -89,15 +89,19 @@ function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesP ); - const navigateToCategorySettings = () => { + const navigateToCategoriesSettings = () => { Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES_SETTINGS.getRoute(route.params.policyID)); }; + const navigateToCategorySettings = (category: PolicyForList) => { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, category.text)); + }; + const settingsButton = (