diff --git a/src/CONST.ts b/src/CONST.ts index 0b3ac3fd0566..d4fbd0ff6ef3 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1661,6 +1661,8 @@ const CONST = { LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, + TAG_NAME_LIMIT: 256, + TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 500, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b9e7c4d5d274..8c48cbad561f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -332,6 +332,8 @@ const ONYXKEYS = { WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_CATEGORY_CREATE_FORM: 'workspaceCategoryCreate', WORKSPACE_CATEGORY_CREATE_FORM_DRAFT: 'workspaceCategoryCreateDraft', + WORKSPACE_TAG_CREATE_FORM: 'workspaceTagCreate', + WORKSPACE_TAG_CREATE_FORM_DRAFT: 'workspaceTagCreateDraft', WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft', WORKSPACE_DESCRIPTION_FORM: 'workspaceDescriptionForm', WORKSPACE_DESCRIPTION_FORM_DRAFT: 'workspaceDescriptionFormDraft', @@ -416,6 +418,7 @@ 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_TAG_CREATE_FORM]: FormTypes.WorkspaceTagCreateForm; [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 f89ba19736ed..defb945ba8c2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -573,13 +573,17 @@ const ROUTES = { route: 'settings/workspaces/:policyID/tags', getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags` as const, }, + WORKSPACE_TAG_CREATE: { + route: 'settings/workspaces/:policyID/tags/new', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/new` as const, + }, WORKSPACE_TAGS_SETTINGS: { - route: 'workspace/:policyID/tags/settings', - getRoute: (policyID: string) => `workspace/${policyID}/tags/settings` as const, + route: 'settings/workspaces/:policyID/tags/settings', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/settings` as const, }, WORKSPACE_EDIT_TAGS: { - route: 'workspace/:policyID/tags/edit', - getRoute: (policyID: string) => `workspace/${policyID}/tags/edit` as const, + route: 'settings/workspaces/:policyID/tags/edit', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/tags/edit` as const, }, WORKSPACE_MEMBER_DETAILS: { route: 'settings/workspaces/:policyID/members/:accountID', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index dedb022415ca..4db5fd9115a5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -219,6 +219,7 @@ const SCREENS = { TAGS: 'Workspace_Tags', TAGS_SETTINGS: 'Tags_Settings', TAGS_EDIT: 'Tags_Edit', + TAG_CREATE: 'Tag_Create', CURRENCY: 'Workspace_Profile_Currency', WORKFLOWS: 'Workspace_Workflows', WORKFLOWS_PAYER: 'Workspace_Workflows_Payer', diff --git a/src/languages/en.ts b/src/languages/en.ts index 3fa7f688e008..7e442eee2236 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1834,11 +1834,14 @@ export default { requiresTag: 'Members must tag all spend', customTagName: 'Custom tag name', enableTag: 'Enable tag', + addTag: 'Add tag', subtitle: 'Tags add more detailed ways to classify costs.', emptyTags: { title: "You haven't created any tags", subtitle: 'Add a tag to track projects, locations, departments, and more.', }, + tagRequiredError: 'Tag name is required.', + existingTagError: 'A tag with this name already exists.', genericFailureMessage: 'An error occurred while updating the tag, please try again.', }, emptyWorkspace: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 85f441de653b..267581f043ee 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1858,11 +1858,14 @@ export default { requiresTag: 'Los miembros deben etiquetar todos los gastos', customTagName: 'Nombre de etiqueta personalizada', enableTag: 'Habilitar etiqueta', + addTag: 'Añadir etiqueta', subtitle: 'Las etiquetas añaden formas más detalladas de clasificar los costos.', emptyTags: { title: 'No has creado ninguna etiqueta', subtitle: 'Añade una etiqueta para realizar el seguimiento de proyectos, ubicaciones, departamentos y otros.', }, + tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.', + existingTagError: 'Ya existe una etiqueta con este nombre.', genericFailureMessage: 'Se produjo un error al actualizar la etiqueta, inténtelo nuevamente.', }, emptyWorkspace: { diff --git a/src/libs/API/parameters/CreatePolicyTagsParams.ts b/src/libs/API/parameters/CreatePolicyTagsParams.ts new file mode 100644 index 000000000000..6fd16d9ca87b --- /dev/null +++ b/src/libs/API/parameters/CreatePolicyTagsParams.ts @@ -0,0 +1,10 @@ +type CreatePolicyTagsParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{name: string;}> + */ + tags: string; +}; + +export default CreatePolicyTagsParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f482b4dc79ab..687f32f5b6de 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -173,3 +173,4 @@ export type {default as DeclineJoinRequestParams} from './DeclineJoinRequest'; export type {default as JoinPolicyInviteLinkParams} from './JoinPolicyInviteLink'; export type {default as OpenPolicyWorkflowsPageParams} from './OpenPolicyWorkflowsPageParams'; export type {default as OpenPolicyDistanceRatesPageParams} from './OpenPolicyDistanceRatesPageParams'; +export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 9392c458baf4..17c38b252e0b 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -118,6 +118,7 @@ const WRITE_COMMANDS = { CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment', SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', + CREATE_POLICY_TAG: 'CreatePolicyTag', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', SET_POLICY_REQUIRES_TAG: 'SetPolicyRequiresTag', RENAME_POLICY_TAG_LIST: 'RenamePolicyTaglist', @@ -283,6 +284,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.SET_POLICY_REQUIRES_TAG]: Parameters.SetPolicyRequiresTag; [WRITE_COMMANDS.RENAME_POLICY_TAG_LIST]: Parameters.RenamePolicyTaglist; + [WRITE_COMMANDS.CREATE_POLICY_TAG]: Parameters.CreatePolicyTagsParams; [WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams; [WRITE_COMMANDS.CANCEL_TASK]: Parameters.CancelTaskParams; [WRITE_COMMANDS.EDIT_TASK_ASSIGNEE]: Parameters.EditTaskAssigneeParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 897ac9c71a84..7df1da23d068 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -264,6 +264,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/categories/CreateCategoryPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS_SETTINGS]: () => require('../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS_EDIT]: () => require('../../../pages/workspace/tags/WorkspaceEditTagsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAG_CREATE]: () => require('../../../pages/workspace/tags/WorkspaceCreateTagPage').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/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 99f6159549c5..fd108f2c95f3 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -11,7 +11,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET, SCREENS.WORKSPACE.WORKFLOWS_PAYER, ], - [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT], + [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE], [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index a9042cb2bfac..1461c27e03e0 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -289,6 +289,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAGS_EDIT]: { path: ROUTES.WORKSPACE_EDIT_TAGS.route, }, + [SCREENS.WORKSPACE.TAG_CREATE]: { + path: ROUTES.WORKSPACE_TAG_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 07b8e59809d2..a1ec30fa303e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -175,6 +175,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; }; + [SCREENS.WORKSPACE.TAG_CREATE]: { + policyID: string; + }; [SCREENS.WORKSPACE.TAGS_SETTINGS]: { policyID: string; }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 5f6e85da71fa..48b9948fc1ff 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -2746,6 +2746,70 @@ function createPolicyCategory(policyID: string, categoryName: string) { API.write(WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES, parameters, onyxData); } +function createPolicyTag(policyID: string, tagName: string) { + const tagListName = Object.keys(allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {})[0]; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + name: tagName, + enabled: false, + errors: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + errors: null, + pendingAction: null, + }, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, + value: { + [tagListName]: { + tags: { + [tagName]: { + errors: ErrorUtils.getMicroSecondOnyxError('workspace.tags.genericFailureMessage'), + pendingAction: null, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + tags: JSON.stringify([{name: tagName}]), + }; + + API.write(WRITE_COMMANDS.CREATE_POLICY_TAG, parameters, onyxData); +} + function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { const onyxData: OnyxData = { optimisticData: [ @@ -3453,5 +3517,6 @@ export { enablePolicyTaxes, enablePolicyWorkflows, openPolicyDistanceRatesPage, + createPolicyTag, clearWorkspaceReimbursementErrors, }; diff --git a/src/pages/workspace/categories/CreateCategoryPage.tsx b/src/pages/workspace/categories/CreateCategoryPage.tsx index cfe28ba292b0..8de0e3a07980 100644 --- a/src/pages/workspace/categories/CreateCategoryPage.tsx +++ b/src/pages/workspace/categories/CreateCategoryPage.tsx @@ -9,6 +9,7 @@ import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -34,6 +35,7 @@ type CreateCategoryPageProps = WorkspaceCreateCategoryPageOnyxProps & StackScree function CreateCategoryPage({route, policyCategories}: CreateCategoryPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); const validate = useCallback( (values: FormOnyxValues) => { @@ -72,6 +74,7 @@ function CreateCategoryPage({route, policyCategories}: CreateCategoryPageProps) includeSafeAreaPaddingBottom={false} style={[styles.defaultModalContainer]} testID={CreateCategoryPage.displayName} + shouldEnableMaxHeight > diff --git a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx new file mode 100644 index 000000000000..2df7621c17d3 --- /dev/null +++ b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx @@ -0,0 +1,113 @@ +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 useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +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/WorkspaceTagCreateForm'; +import type {PolicyTagList} from '@src/types/onyx'; + +type WorkspaceCreateTagPageOnyxProps = { + /** All policy tags */ + policyTags: OnyxEntry; +}; + +type CreateTagPageProps = WorkspaceCreateTagPageOnyxProps & StackScreenProps; + +function CreateTagPage({route, policyTags}: CreateTagPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + + const validate = useCallback( + (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const tagName = values.tagName.trim(); + const {tags} = PolicyUtils.getTagList(policyTags, 0); + + if (!ValidationUtils.isRequiredFulfilled(tagName)) { + errors.tagName = 'workspace.tags.tagRequiredError'; + } else if (tags?.[tagName]) { + errors.tagName = 'workspace.tags.existingTagError'; + } else if ([...tagName].length > CONST.TAG_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, 'tagName', ['common.error.characterLimitExceedCounter', {length: [...tagName].length, limit: CONST.TAG_NAME_LIMIT}]); + } + + return errors; + }, + [policyTags], + ); + + const createTag = useCallback( + (values: FormOnyxValues) => { + Policy.createPolicyTag(route.params.policyID, values.tagName.trim()); + Keyboard.dismiss(); + Navigation.goBack(); + }, + [route.params.policyID], + ); + + return ( + + + + + + + + + + + ); +} + +CreateTagPage.displayName = 'CreateTagPage'; + +export default withOnyx({ + policyTags: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${route?.params?.policyID}`, + }, +})(CreateTagPage); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index baf3e8adb460..4ea8ba669b9b 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -115,18 +115,32 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) { Navigation.navigate(ROUTES.WORKSPACE_TAGS_SETTINGS.getRoute(route.params.policyID)); }; + const navigateToCreateTagPage = () => { + Navigation.navigate(ROUTES.WORKSPACE_TAG_CREATE.getRoute(route.params.policyID)); + }; + const isLoading = !isOffline && policyTags === undefined; - const settingsButton = ( + const headerButtons = (