diff --git a/src/CONST.ts b/src/CONST.ts index 74b5e486b175..9176168f9706 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1646,6 +1646,7 @@ const CONST = { FORM_CHARACTER_LIMIT: 50, LEGAL_NAMES_CHARACTER_LIMIT: 150, LOGIN_CHARACTER_LIMIT: 254, + CATEGORY_NAME_LIMIT: 256, TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 500, @@ -1726,6 +1727,7 @@ const CONST = { MAX_64BIT_LEFT_PART: 92233, MAX_64BIT_MIDDLE_PART: 7203685, MAX_64BIT_RIGHT_PART: 4775807, + INVALID_CATEGORY_NAME: '###', // When generating a random value to fit in 7 digits (for the `middle` or `right` parts above), this is the maximum value to multiply by Math.random(). MAX_INT_FOR_RANDOM_7_DIGIT_VALUE: 10000000, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f6b5c635e4ae..fd33a59c6feb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -326,6 +326,8 @@ const ONYXKEYS = { ADD_DEBIT_CARD_FORM: 'addDebitCardForm', ADD_DEBIT_CARD_FORM_DRAFT: 'addDebitCardFormDraft', WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', + WORKSPACE_CATEGORY_CREATE_FORM: 'workspaceCategoryCreate', + WORKSPACE_CATEGORY_CREATE_FORM_DRAFT: 'workspaceCategoryCreateDraft', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', WORKSPACE_DESCRIPTION_FORM: 'workspaceDescriptionForm', WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft', @@ -407,6 +409,7 @@ type AllOnyxKeys = DeepValueOf; type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm; [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; + [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_CREATE_FORM]: FormTypes.WorkspaceCategoryCreateForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cfc287ba2cdc..bc8af4df5686 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -546,6 +546,10 @@ const ROUTES = { route: 'workspace/:policyID/categories/settings', getRoute: (policyID: string) => `workspace/${policyID}/categories/settings` as const, }, + WORKSPACE_CATEGORY_CREATE: { + route: 'workspace/:policyID/categories/new', + getRoute: (policyID: string) => `workspace/${policyID}/categories/new` as const, + }, WORKSPACE_TAGS: { route: 'workspace/:policyID/tags', getRoute: (policyID: string) => `workspace/${policyID}/tags` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2369fe435feb..17073d289f8f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -223,6 +223,7 @@ const SCREENS = { DESCRIPTION: 'Workspace_Profile_Description', SHARE: 'Workspace_Profile_Share', NAME: 'Workspace_Profile_Name', + CATEGORY_CREATE: 'Category_Create', CATEGORY_SETTINGS: 'Category_Settings', CATEGORIES_SETTINGS: 'Categories_Settings', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 0a52cca62ef5..1ba613610723 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1770,6 +1770,10 @@ export default { subtitle: 'Add a category to organize your spend.', }, genericFailureMessage: 'An error occurred while updating the category, please try again.', + addCategory: 'Add category', + categoryRequiredError: 'Category name is required.', + existingCategoryError: 'A category with this name already exists.', + invalidCategoryName: 'Invalid category name.', }, tags: { requiresTag: 'Members must tag all spend', diff --git a/src/languages/es.ts b/src/languages/es.ts index 013255c1e11e..acbb70308167 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1794,6 +1794,10 @@ export default { 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.', + addCategory: 'Añadir categoría', + categoryRequiredError: 'Lo nombre de la categoría es obligatorio.', + existingCategoryError: 'Ya existe una categoría con este nombre.', + invalidCategoryName: 'Lo nombre de la categoría es invalido.', }, tags: { requiresTag: 'Los miembros deben etiquetar todos los gastos', diff --git a/src/libs/API/parameters/CreateWorkspaceCategoriesParams.ts b/src/libs/API/parameters/CreateWorkspaceCategoriesParams.ts new file mode 100644 index 000000000000..629a66c2e657 --- /dev/null +++ b/src/libs/API/parameters/CreateWorkspaceCategoriesParams.ts @@ -0,0 +1,10 @@ +type CreateWorkspaceCategoriesParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{name: string;}> + */ + categories: string; +}; + +export default CreateWorkspaceCategoriesParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 00e8b5e761ad..211bc06f26a3 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -149,6 +149,7 @@ export type {default as AcceptACHContractForBankAccount} from './AcceptACHContra export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams'; export type {default as UpdateWorkspaceMembersRoleParams} from './UpdateWorkspaceMembersRoleParams'; export type {default as SetWorkspaceCategoriesEnabledParams} from './SetWorkspaceCategoriesEnabledParams'; +export type {default as CreateWorkspaceCategoriesParams} from './CreateWorkspaceCategoriesParams'; 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 ee4ce1ea3670..115355210f75 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -115,6 +115,7 @@ const WRITE_COMMANDS = { CREATE_WORKSPACE: 'CreateWorkspace', CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment', SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', + CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', CREATE_TASK: 'CreateTask', CANCEL_TASK: 'CancelTask', @@ -264,6 +265,7 @@ type WriteCommandParameters = { [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.CREATE_WORKSPACE_CATEGORIES]: Parameters.CreateWorkspaceCategoriesParams; [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 545641957c9a..cee9e31cedea 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -251,6 +251,7 @@ const SettingsModalStackNavigator = createModalStackNavigator 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.WORKSPACE.CATEGORY_CREATE]: () => require('../../../pages/workspace/categories/CreateCategoryPage').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, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index 7959999ee813..7cca4ed466d4 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -6,7 +6,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], [SCREENS.WORKSPACE.WORKFLOWS]: [SCREENS.WORKSPACE.WORKFLOWS_APPROVER, SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY, SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET], - [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS], + [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS], }; export default CENTRAL_PANE_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 3ceb3c1ac7df..d3eb6d6a4c2b 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -280,6 +280,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, + [SCREENS.WORKSPACE.CATEGORY_CREATE]: { + path: ROUTES.WORKSPACE_CATEGORY_CREATE.route, + }, [SCREENS.REIMBURSEMENT_ACCOUNT]: { path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 6790dd5f8f10..12b2802cb3fa 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -93,6 +93,7 @@ type CentralPaneNavigatorParamList = { }; [SCREENS.WORKSPACE.TAGS]: { policyID: string; + categoryName: string; }; }; @@ -197,6 +198,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.INVITE_MESSAGE]: { policyID: string; }; + [SCREENS.WORKSPACE.CATEGORY_CREATE]: { + policyID: string; + }; [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { policyID: string; categoryName: string; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 5b916148c6ee..b510edd7dcf4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -242,6 +242,13 @@ function extractPolicyIDFromPath(path: string) { return path.match(CONST.REGEX.POLICY_ID_FROM_PATH)?.[1]; } +/** + * Whether the policy has active accounting integration connections + */ +function hasAccountingConnections(policy: OnyxEntry) { + return Boolean(policy?.connections); +} + function getPathWithoutPolicyID(path: string) { return path.replace(CONST.REGEX.PATH_WITHOUT_POLICY_ID, '/'); } @@ -263,6 +270,7 @@ function goBackFromInvalidPolicy() { export { getActivePolicies, + hasAccountingConnections, hasPolicyMemberError, hasPolicyError, hasPolicyErrorFields, diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index aa64611b210f..38cc11756068 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -2435,6 +2435,56 @@ function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Recor API.write('SetWorkspaceCategoriesEnabled', parameters, onyxData); } +function createPolicyCategory(policyID: string, categoryName: string) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + name: categoryName, + enabled: true, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: null, + pendingAction: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + [categoryName]: { + errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'), + pendingAction: null, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + categories: JSON.stringify([{name: categoryName}]), + }; + + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES, parameters, onyxData); +} + function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { const onyxData: OnyxData = { optimisticData: [ @@ -2552,5 +2602,6 @@ export { updateWorkspaceDescription, setWorkspaceCategoryEnabled, setWorkspaceRequiresCategory, + createPolicyCategory, clearCategoryErrors, }; diff --git a/src/pages/workspace/categories/CreateCategoryPage.tsx b/src/pages/workspace/categories/CreateCategoryPage.tsx new file mode 100644 index 000000000000..cfe28ba292b0 --- /dev/null +++ b/src/pages/workspace/categories/CreateCategoryPage.tsx @@ -0,0 +1,110 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {Keyboard} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspaceCategoryCreateForm'; +import type {PolicyCategories} from '@src/types/onyx'; + +type WorkspaceCreateCategoryPageOnyxProps = { + /** All policy categories */ + policyCategories: OnyxEntry; +}; + +type CreateCategoryPageProps = WorkspaceCreateCategoryPageOnyxProps & StackScreenProps; + +function CreateCategoryPage({route, policyCategories}: CreateCategoryPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const validate = useCallback( + (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const categoryName = values.categoryName.trim(); + + if (!ValidationUtils.isRequiredFulfilled(categoryName)) { + errors.categoryName = 'workspace.categories.categoryRequiredError'; + } else if (policyCategories?.[categoryName]) { + errors.categoryName = 'workspace.categories.existingCategoryError'; + } else if (categoryName === CONST.INVALID_CATEGORY_NAME) { + errors.categoryName = 'workspace.categories.invalidCategoryName'; + } else if ([...categoryName].length > CONST.CATEGORY_NAME_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 code units. + ErrorUtils.addErrorMessage(errors, 'categoryName', ['common.error.characterLimitExceedCounter', {length: [...categoryName].length, limit: CONST.CATEGORY_NAME_LIMIT}]); + } + + return errors; + }, + [policyCategories], + ); + + const createCategory = useCallback( + (values: FormOnyxValues) => { + Policy.createPolicyCategory(route.params.policyID, values.categoryName.trim()); + Keyboard.dismiss(); + Navigation.goBack(); + }, + [route.params.policyID], + ); + + return ( + + + + + + + + + + + ); +} + +CreateCategoryPage.displayName = 'CreateCategoryPage'; + +export default withOnyx({ + policyCategories: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route?.params?.policyID}`, + }, +})(CreateCategoryPage); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index c6e2fe0c4a70..52d18d8de276 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -18,7 +18,9 @@ import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import type {CentralPaneNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; @@ -38,13 +40,16 @@ type PolicyForList = { }; type WorkspaceCategoriesOnyxProps = { + /** The policy the user is accessing. */ + policy: OnyxEntry; + /** Collection of categories attached to a policy */ policyCategories: OnyxEntry; }; type WorkspaceCategoriesPageProps = WorkspaceCategoriesOnyxProps & StackScreenProps; -function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesPageProps) { +function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCategoriesPageProps) { const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); const theme = useTheme(); @@ -64,25 +69,28 @@ function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesP const categoryList = useMemo( () => - Object.values(policyCategories ?? {}).map((value) => ({ - value: value.name, - text: value.name, - keyForList: value.name, - isSelected: !!selectedCategories[value.name], - rightElement: ( - - - {value.enabled ? translate('workspace.common.enabled') : translate('workspace.common.disabled')} - - - + Object.values(policyCategories ?? {}) + .sort((a, b) => localeCompare(a.name, b.name)) + .map((value) => ({ + value: value.name, + text: value.name, + keyForList: value.name, + isSelected: !!selectedCategories[value.name], + pendingAction: value.pendingAction, + rightElement: ( + + + {value.enabled ? translate('workspace.common.enabled') : translate('workspace.common.disabled')} + + + + - - ), - })), + ), + })), [policyCategories, selectedCategories, styles.alignSelfCenter, styles.flexRow, styles.label, styles.p1, styles.pl2, styles.textSupporting, theme.icon, translate], ); @@ -113,10 +121,24 @@ function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesP Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, category.text)); }; + const navigateToCreateCategoryPage = () => { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_CREATE.getRoute(route.params.policyID)); + }; + const isLoading = !isOffline && policyCategories === undefined; - const settingsButton = ( + const headerButtons = ( + {!PolicyUtils.hasAccountingConnections(policy) && ( +