diff --git a/assets/images/document-slash.svg b/assets/images/document-slash.svg index 25a4c96038b4..ebb183142e40 100644 --- a/assets/images/document-slash.svg +++ b/assets/images/document-slash.svg @@ -1,6 +1 @@ - - - - - - + diff --git a/src/CONST.ts b/src/CONST.ts index d4fbd0ff6ef3..6b7ede6f0e10 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1414,6 +1414,11 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', }, + CATEGORIES_BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, DISTANCE_RATES_BULK_ACTION_TYPES: { DELETE: 'delete', DISABLE: 'disable', diff --git a/src/languages/en.ts b/src/languages/en.ts index 7e442eee2236..c602b2fad14c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1752,6 +1752,7 @@ export default { testTransactions: 'Test transactions', issueAndManageCards: 'Issue and manage cards', reconcileCards: 'Reconcile cards', + selected: ({selectedNumber}) => `${selectedNumber} selected`, settlementFrequency: 'Settlement frequency', deleteConfirmation: 'Are you sure you want to delete this workspace?', unavailable: 'Unavailable workspace', @@ -1766,7 +1767,6 @@ export default { moreFeatures: 'More features', requested: 'Requested', distanceRates: 'Distance rates', - selected: ({selectedNumber}) => `${selectedNumber} selected`, }, type: { free: 'Free', @@ -1774,6 +1774,10 @@ export default { collect: 'Collect', }, categories: { + deleteCategories: 'Delete categories', + disableCategories: 'Disable categories', + enableCategories: 'Enable categories', + deleteFailureMessage: 'An error occurred while deleting the category, please try again.', categoryName: 'Category name', requiresCategory: 'Members must categorize all spend', enableCategory: 'Enable category', diff --git a/src/languages/es.ts b/src/languages/es.ts index 267581f043ee..bb54aa6e51f9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1776,6 +1776,7 @@ export default { testTransactions: 'Transacciones de prueba', issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', + selected: ({selectedNumber}) => `${selectedNumber} seleccionados`, settlementFrequency: 'Frecuencia de liquidación', deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', unavailable: 'Espacio de trabajo no disponible', @@ -1790,7 +1791,6 @@ export default { moreFeatures: 'Más características', requested: 'Solicitado', distanceRates: 'Tasas de distancia', - selected: ({selectedNumber}) => `${selectedNumber} seleccionados`, }, type: { free: 'Gratis', @@ -1798,6 +1798,10 @@ export default { collect: 'Recolectar', }, categories: { + deleteCategories: 'Eliminar categorías', + disableCategories: 'Desactivar categorías', + enableCategories: 'Activar categorías', + deleteFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.', categoryName: 'Nombre de la categoría', requiresCategory: 'Los miembros deben categorizar todos los gastos', enableCategory: 'Activar categoría', diff --git a/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts new file mode 100644 index 000000000000..07a8103a9b06 --- /dev/null +++ b/src/libs/API/parameters/DeleteWorkspaceCategoriesParams.ts @@ -0,0 +1,10 @@ +type DeleteWorkspaceCategoriesParams = { + policyID: string; + /** + * A JSON-encoded string representing an array of category names to be deleted. + * Each element in the array is a string that specifies the name of a category. + */ + categories: string; +}; + +export default DeleteWorkspaceCategoriesParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 687f32f5b6de..1cf59f0eafea 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -152,6 +152,7 @@ export type {default as UpdateWorkspaceMembersRoleParams} from './UpdateWorkspac 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 DeleteWorkspaceCategoriesParams} from './DeleteWorkspaceCategoriesParams'; export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams'; export type {default as SetWorkspaceAutoReportingFrequencyParams} from './SetWorkspaceAutoReportingFrequencyParams'; export type {default as SetWorkspaceAutoReportingMonthlyOffsetParams} from './SetWorkspaceAutoReportingMonthlyOffsetParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 17c38b252e0b..fa110933c18a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -120,6 +120,7 @@ const WRITE_COMMANDS = { CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', CREATE_POLICY_TAG: 'CreatePolicyTag', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', + DELETE_WORKSPACE_CATEGORIES: 'DeleteWorkspaceCategories', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist', CREATE_TASK: 'CreateTask', @@ -282,6 +283,7 @@ type WriteCommandParameters = { [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.DELETE_WORKSPACE_CATEGORIES]: Parameters.DeleteWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglist; [WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 4689fd03ebd0..d5575869444a 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -4,7 +4,7 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Policy, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Navigation from './Navigation/Navigation'; @@ -30,6 +30,13 @@ function hasPolicyMemberError(policyMembers: OnyxEntry): boolean return Object.values(policyMembers ?? {}).some((member) => Object.keys(member?.errors ?? {}).length > 0); } +/** + * Check if the policy has any errors within the categories. + */ +function hasPolicyCategoriesError(policyCategories: OnyxEntry): boolean { + return Object.keys(policyCategories ?? {}).some((categoryName) => Object.keys(policyCategories?.[categoryName]?.errors ?? {}).length > 0); +} + /** * Check if the policy has any error fields. */ @@ -295,6 +302,7 @@ export { getPathWithoutPolicyID, getPolicyMembersByIdWithoutCurrentUser, goBackFromInvalidPolicy, + hasPolicyCategoriesError, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 48b9948fc1ff..68ed15ece9a9 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -64,6 +64,7 @@ import type { PersonalDetailsList, Policy, PolicyCategories, + PolicyCategory, PolicyMember, PolicyTagList, RecentlyUsedCategories, @@ -2693,7 +2694,7 @@ function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Recor categories: JSON.stringify(Object.keys(categoriesToUpdate).map((key) => categoriesToUpdate[key])), }; - API.write('SetWorkspaceCategoriesEnabled', parameters, onyxData); + API.write(WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED, parameters, onyxData); } function createPolicyCategory(policyID: string, categoryName: string) { @@ -2861,124 +2862,193 @@ function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolea requiresCategory, }; - API.write('SetWorkspaceRequiresCategory', parameters, onyxData); + API.write(WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY, parameters, onyxData); } -function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { +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, + }, + }); +} + +function deleteWorkspaceCategories(policyID: string, categoryNamesToDelete: string[]) { const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - requiresTag, - errors: { - requiresTag: null, - }, - pendingFields: { - requiresTag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: categoryNamesToDelete.reduce>>((acc, categoryName) => { + acc[categoryName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + return acc; + }, {}), }, ], successData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - errors: { - requiresTag: null, - }, - pendingFields: { - requiresTag: null, - }, - }, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: categoryNamesToDelete.reduce>((acc, categoryName) => { + acc[categoryName] = null; + return acc; + }, {}), }, ], failureData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - requiresTag: !requiresTag, - errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), - pendingFields: { - requiresTag: null, - }, - }, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: categoryNamesToDelete.reduce>>((acc, categoryName) => { + acc[categoryName] = { + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.deleteFailureMessage'), + }; + return acc; + }, {}), }, ], }; const parameters = { policyID, - requiresTag, + categories: JSON.stringify(categoryNamesToDelete), }; - API.write(WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG, parameters, onyxData); + API.write(WRITE_COMMANDS.DELETE_WORKSPACE_CATEGORIES, parameters, onyxData); } -function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: string; newName: string}, policyTags: OnyxEntry) { - const newName = policyTagListName.newName; - const oldName = policyTagListName.oldName; - - if (oldName === newName) { +/** + * Accept user join request to a workspace + */ +function acceptJoinRequest(reportID: string, reportAction: OnyxEntry) { + const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT; + if (!reportAction) { return; } - const oldPolicyTags = policyTags?.[oldName] ?? {}; - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - value: { - [newName]: {...oldPolicyTags, name: newName, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, - [oldName]: null, + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportAction.reportActionID]: { + originalMessage: {choice}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - value: { - [newName]: {pendingAction: null}, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportAction.reportActionID]: { + originalMessage: {choice}, + pendingAction: null, }, }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, - value: { - [newName]: null, - [oldName]: {...oldPolicyTags, errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage')}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportAction.reportActionID]: { + originalMessage: {choice: ''}, + pendingAction: null, }, }, - ], - }; + }, + ]; + const parameters = { - policyID, - oldName, - newName, + requests: JSON.stringify({ + [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: { + requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}], + }, + }), }; - API.write(WRITE_COMMANDS.RENAME_POLICY_TAG_LIST, parameters, onyxData); + API.write(WRITE_COMMANDS.ACCEPT_JOIN_REQUEST, parameters, {optimisticData, failureData, successData}); } -function clearCategoryErrors(policyID: string, categoryName: string) { - const category = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; - - if (!category) { +/** + * Decline user join request to a workspace + */ +function declineJoinRequest(reportID: string, reportAction: OnyxEntry) { + if (!reportAction) { return; } + const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE; + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportAction.reportActionID]: { + originalMessage: {choice}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { - [category.name]: { - errors: null, + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportAction.reportActionID]: { + originalMessage: {choice}, + pendingAction: null, + }, + }, }, - }); + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportAction.reportActionID]: { + originalMessage: {choice: ''}, + pendingAction: null, + }, + }, + }, + ]; + + const parameters = { + requests: JSON.stringify({ + [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: { + requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}], + }, + }), + }; + + API.write(WRITE_COMMANDS.DECLINE_JOIN_REQUEST, parameters, {optimisticData, failureData, successData}); +} + +function openPolicyDistanceRatesPage(policyID?: string) { + if (!policyID) { + return; + } + + const params: OpenPolicyDistanceRatesPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE, params); } function navigateWhenEnableFeature(policyID: string, featureRoute: Route) { @@ -3321,131 +3391,105 @@ function enablePolicyWorkflows(policyID: string, enabled: boolean) { } } -/** - * Accept user join request to a workspace - */ -function acceptJoinRequest(reportID: string, reportAction: OnyxEntry) { - const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT; - if (!reportAction) { - return; - } - - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [reportAction.reportActionID]: { - originalMessage: {choice}, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, +function renamePolicyTaglist(policyID: string, policyTagListName: {oldName: string; newName: string}, policyTags: OnyxEntry) { + const newName = policyTagListName.newName; + const oldName = policyTagListName.oldName; + const oldPolicyTags = policyTags?.[oldName] ?? {}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [newName]: {...oldPolicyTags, name: newName, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + [oldName]: null, }, }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [reportAction.reportActionID]: { - originalMessage: {choice}, - pendingAction: null, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [newName]: {pendingAction: null}, + [oldName]: null, }, }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [reportAction.reportActionID]: { - originalMessage: {choice: ''}, - pendingAction: null, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + errors: { + [oldName]: oldName, + [newName]: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), + }, + [newName]: null, + [oldName]: oldPolicyTags, }, }, - }, - ]; - + ], + }; const parameters = { - requests: JSON.stringify({ - [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: { - requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}], - }, - }), + policyID, + oldName, + newName, }; - API.write(WRITE_COMMANDS.ACCEPT_JOIN_REQUEST, parameters, {optimisticData, failureData, successData}); + API.write(WRITE_COMMANDS.RENAME_POLICY_TAG_LIST, parameters, onyxData); } -/** - * Decline user join request to a workspace - */ -function declineJoinRequest(reportID: string, reportAction: OnyxEntry) { - if (!reportAction) { - return; - } - const choice = CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE; - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [reportAction.reportActionID]: { - originalMessage: {choice}, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, +function setPolicyRequiresTag(policyID: string, requiresTag: boolean) { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresTag, + errors: {requiresTag: null}, + pendingFields: { + requiresTag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, }, }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [reportAction.reportActionID]: { - originalMessage: {choice}, - pendingAction: null, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errors: { + requiresTag: null, + }, + pendingFields: { + requiresTag: null, + }, }, }, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: { - [reportAction.reportActionID]: { - originalMessage: {choice: ''}, - pendingAction: null, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + requiresTag: !requiresTag, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), + pendingFields: { + requiresTag: null, + }, }, }, - }, - ]; + ], + }; const parameters = { - requests: JSON.stringify({ - [(reportAction.originalMessage as OriginalMessageJoinPolicyChangeLog['originalMessage']).policyID]: { - requests: [{accountID: reportAction?.actorAccountID, adminsRoomMessageReportActionID: reportAction.reportActionID}], - }, - }), + policyID, + requiresTag, }; - API.write(WRITE_COMMANDS.DECLINE_JOIN_REQUEST, parameters, {optimisticData, failureData, successData}); -} - -function openPolicyDistanceRatesPage(policyID?: string) { - if (!policyID) { - return; - } - - const params: OpenPolicyDistanceRatesPageParams = {policyID}; - - API.read(READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE, params); + API.write(WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG, parameters, onyxData); } export { @@ -3519,4 +3563,5 @@ export { openPolicyDistanceRatesPage, createPolicyTag, clearWorkspaceReimbursementErrors, + deleteWorkspaceCategories, }; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 38828a0406ef..c1b3a490fec0 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -48,6 +48,9 @@ type WorkspaceMenuItem = { type WorkspaceInitialPageOnyxProps = { /** Bank account attached to free plan */ reimbursementAccount: OnyxEntry; + + /** Collection of categories attached to a policy */ + policyCategories: OnyxEntry; }; type WorkspaceInitialPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceInitialPageOnyxProps & StackScreenProps; @@ -57,7 +60,7 @@ function dismissError(policyID: string) { Policy.removeWorkspace(policyID); } -function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, reimbursementAccount}: WorkspaceInitialPageProps) { +function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, reimbursementAccount, policyCategories}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); const policy = policyDraft?.id ? policyDraft : policyProp; const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); @@ -97,6 +100,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r }, [policyID, policyName]); const hasMembersError = PolicyUtils.hasPolicyMemberError(policyMembers); + const hasPolicyCategoryError = PolicyUtils.hasPolicyCategoriesError(policyCategories); const hasGeneralSettingsError = !isEmptyObject(policy?.errorFields?.generalSettings ?? {}) || !isEmptyObject(policy?.errorFields?.avatar ?? {}); const shouldShowProtectedItems = PolicyUtils.isPolicyAdmin(policy); const isPaidGroupPolicy = PolicyUtils.isPaidGroupPolicy(policy); @@ -170,6 +174,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r translationKey: 'workspace.common.categories', icon: Expensicons.Folder, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)))), + brickRoadIndicator: hasPolicyCategoryError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, routeName: SCREENS.WORKSPACE.CATEGORIES, }); } @@ -302,5 +307,8 @@ export default withPolicyAndFullscreenLoading( reimbursementAccount: { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, }, + policyCategories: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params?.policyID ?? '0'}`, + }, })(WorkspaceInitialPage), ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 3f2ef8ce6aa6..d22b822359f9 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,9 +1,11 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -11,6 +13,7 @@ import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import TableListItem from '@components/SelectionList/TableListItem'; +import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; import useLocalize from '@hooks/useLocalize'; @@ -18,6 +21,7 @@ import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {deleteWorkspaceCategories, setWorkspaceCategoryEnabled} from '@libs/actions/Policy'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -30,13 +34,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; -type PolicyForList = { - value: string; - text: string; +type PolicyOption = ListItem & { + /** Category name is used as a key for the selectedCategories state */ keyForList: string; - isSelected: boolean; - rightElement: React.ReactNode; }; type WorkspaceCategoriesOnyxProps = { @@ -55,6 +57,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat const theme = useTheme(); const {translate} = useLocalize(); const [selectedCategories, setSelectedCategories] = useState>({}); + const dropdownButtonRef = useRef(null); function fetchCategories() { Policy.openPolicyCategoriesPage(route.params.policyID); @@ -67,43 +70,63 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const categoryList = useMemo( + const categoryList = useMemo( () => 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')} - - - + .map((value) => { + const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.values(value.pendingFields ?? {}).length > 0; + return { + text: value.name, + keyForList: value.name, + isSelected: !!selectedCategories[value.name], + isDisabled, + pendingAction: value.pendingAction, + errors: value.errors ?? undefined, + 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], + ), + }; + }), + [ + policyCategories, + selectedCategories, + styles.alignSelfCenter, + styles.buttonOpacityDisabled, + styles.flexRow, + styles.label, + styles.p1, + styles.pl2, + styles.textSupporting, + theme.icon, + translate, + ], ); - const toggleCategory = (category: PolicyForList) => { - setSelectedCategories((prev) => ({ - ...prev, - [category.value]: !prev[category.value], - })); + const toggleCategory = (category: PolicyOption) => { + setSelectedCategories((prev) => { + if (prev[category.keyForList]) { + const {[category.keyForList]: omittedCategory, ...newCategories} = prev; + return newCategories; + } + return {...prev, [category.keyForList]: true}; + }); }; const toggleAllCategories = () => { - const isAllSelected = categoryList.every((category) => !!selectedCategories[category.value]); - setSelectedCategories(isAllSelected ? {} : Object.fromEntries(categoryList.map((item) => [item.value, true]))); + const availableCategories = categoryList.filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const isAllSelected = availableCategories.length === Object.keys(selectedCategories).length; + setSelectedCategories(isAllSelected ? {} : Object.fromEntries(availableCategories.map((item) => [item.keyForList, true]))); }; const getCustomListHeader = () => ( @@ -113,42 +136,122 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat ); - const navigateToCategoriesSettings = () => { - Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES_SETTINGS.getRoute(route.params.policyID)); + const navigateToCategorySettings = (category: PolicyOption) => { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, category.keyForList)); }; - const navigateToCategorySettings = (category: PolicyForList) => { - Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, category.text)); + const navigateToCategoriesSettings = () => { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES_SETTINGS.getRoute(route.params.policyID)); }; const navigateToCreateCategoryPage = () => { Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_CREATE.getRoute(route.params.policyID)); }; - const isLoading = !isOffline && policyCategories === undefined; + const dismissError = (item: PolicyOption) => { + Policy.clearCategoryErrors(route.params.policyID, item.keyForList); + }; + + const selectedCategoriesArray = Object.keys(selectedCategories).filter((key) => selectedCategories[key]); + + const getHeaderButtons = () => { + const options: Array>> = []; + + if (selectedCategoriesArray.length > 0) { + options.push({ + icon: Expensicons.Trashcan, + text: translate('workspace.categories.deleteCategories'), + value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DELETE, + onSelected: () => { + setSelectedCategories({}); + deleteWorkspaceCategories(route.params.policyID, selectedCategoriesArray); + }, + }); + + const enabledCategories = selectedCategoriesArray.filter((categoryName) => policyCategories?.[categoryName].enabled); + if (enabledCategories.length > 0) { + const categoriesToDisable = selectedCategoriesArray + .filter((categoryName) => policyCategories?.[categoryName].enabled) + .reduce>((acc, categoryName) => { + acc[categoryName] = { + name: categoryName, + enabled: false, + }; + return acc; + }, {}); - const headerButtons = ( - - {!PolicyUtils.hasAccountingConnections(policy) && ( + options.push({ + icon: Expensicons.DocumentSlash, + text: translate('workspace.categories.disableCategories'), + value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.DISABLE, + onSelected: () => { + setSelectedCategories({}); + setWorkspaceCategoryEnabled(route.params.policyID, categoriesToDisable); + }, + }); + } + + const disabledCategories = selectedCategoriesArray.filter((categoryName) => !policyCategories?.[categoryName].enabled); + if (disabledCategories.length > 0) { + const categoriesToEnable = selectedCategoriesArray + .filter((categoryName) => !policyCategories?.[categoryName].enabled) + .reduce>((acc, categoryName) => { + acc[categoryName] = { + name: categoryName, + enabled: true, + }; + return acc; + }, {}); + options.push({ + icon: Expensicons.Document, + text: translate('workspace.categories.enableCategories'), + value: CONST.POLICY.CATEGORIES_BULK_ACTION_TYPES.ENABLE, + onSelected: () => { + setSelectedCategories({}); + setWorkspaceCategoryEnabled(route.params.policyID, categoriesToEnable); + }, + }); + } + + return ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {selectedNumber: selectedCategoriesArray.length})} + options={options} + style={[isSmallScreenWidth && styles.w50, isSmallScreenWidth && styles.mb3]} + /> + ); + } + + return ( + + {!PolicyUtils.hasAccountingConnections(policy) && ( +