diff --git a/assets/images/simple-illustrations/simple-illustration__virtualcard.svg b/assets/images/simple-illustrations/simple-illustration__virtualcard.svg
new file mode 100644
index 000000000000..2c1f538102a2
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__virtualcard.svg
@@ -0,0 +1,48 @@
+
+
+
diff --git a/src/CONST.ts b/src/CONST.ts
index 13d44ee883be..48870c74a699 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2130,6 +2130,10 @@ const CONST = {
CARD_NAME: 'CardName',
CONFIRMATION: 'Confirmation',
},
+ CARD_TYPE: {
+ PHYSICAL: 'physical',
+ VIRTUAL: 'virtual',
+ },
},
AVATAR_ROW_SIZE: {
DEFAULT: 4,
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 5d6b5492d15c..2740b9e336b1 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -556,8 +556,8 @@ const ONYXKEYS = {
NEW_CHAT_NAME_FORM_DRAFT: 'newChatNameFormDraft',
SUBSCRIPTION_SIZE_FORM: 'subscriptionSizeForm',
SUBSCRIPTION_SIZE_FORM_DRAFT: 'subscriptionSizeFormDraft',
- ISSUE_NEW_EXPENSIFY_CARD_FORM: 'issueNewExpensifyCardForm',
- ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardFormDraft',
+ ISSUE_NEW_EXPENSIFY_CARD_FORM: 'issueNewExpensifyCard',
+ ISSUE_NEW_EXPENSIFY_CARD_FORM_DRAFT: 'issueNewExpensifyCardDraft',
SAGE_INTACCT_CREDENTIALS_FORM: 'sageIntacctCredentialsForm',
SAGE_INTACCT_CREDENTIALS_FORM_DRAFT: 'sageIntacctCredentialsFormDraft',
NETSUITE_TOKEN_INPUT_FORM: 'netsuiteTokenInputForm',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index d548297cb854..1b9ab301c2a1 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -815,13 +815,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/expensify-card',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card` as const,
},
- // TODO: uncomment after development is done
- // WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: {
- // route: 'settings/workspaces/:policyID/expensify-card/issues-new',
- // getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const,
- // },
- // TODO: remove after development is done - this one is for testing purposes
- WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: 'settings/workspaces/expensify-card/issue-new',
+ WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW: {
+ route: 'settings/workspaces/:policyID/expensify-card/issue-new',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/expensify-card/issue-new` as const,
+ },
WORKSPACE_DISTANCE_RATES: {
route: 'settings/workspaces/:policyID/distance-rates',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const,
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index bd0824372799..5212f5b0edb7 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -92,6 +92,7 @@ import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustrati
import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg';
import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg';
import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg';
+import VirtualCard from '@assets/images/simple-illustrations/simple-illustration__virtualcard.svg';
import WalletAlt from '@assets/images/simple-illustrations/simple-illustration__wallet-alt.svg';
import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg';
import ExpensifyApprovedLogoLight from '@assets/images/subscription-details__approvedlogo--light.svg';
@@ -196,4 +197,5 @@ export {
CheckmarkCircle,
CreditCardEyes,
LockClosedOrange,
+ VirtualCard,
};
diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts
index be7ce9aca8b5..862b0ae5e928 100644
--- a/src/libs/CurrencyUtils.ts
+++ b/src/libs/CurrencyUtils.ts
@@ -97,11 +97,11 @@ function convertToFrontendAmountAsInteger(amountAsInt: number, currency: string
*
* @note we do not support any currencies with more than two decimal places.
*/
-function convertToFrontendAmountAsString(amountAsInt: number | null | undefined, currency: string = CONST.CURRENCY.USD): string {
+function convertToFrontendAmountAsString(amountAsInt: number | null | undefined, currency: string = CONST.CURRENCY.USD, withDecimals = true): string {
if (amountAsInt === null || amountAsInt === undefined) {
return '';
}
- const decimals = getCurrencyDecimals(currency);
+ const decimals = withDecimals ? getCurrencyDecimals(currency) : 0;
return convertToFrontendAmountAsInteger(amountAsInt, currency).toFixed(decimals);
}
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 75b096fa4bbe..46bc66a3e3c0 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -129,7 +129,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.REPORT_FIELDS_VALUE_SETTINGS,
SCREENS.WORKSPACE.REPORT_FIELDS_EDIT_VALUE,
],
- [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [],
+ [SCREENS.WORKSPACE.EXPENSIFY_CARD]: [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW],
};
export default FULL_SCREEN_TO_RHP_MAPPING;
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 699cb9b704e1..65c4cf30bde9 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -438,7 +438,7 @@ const config: LinkingOptions['config'] = {
path: ROUTES.WORKSPACE_PROFILE_SHARE.route,
},
[SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: {
- path: ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW,
+ path: ROUTES.WORKSPACE_EXPENSIFY_CARD_ISSUE_NEW.route,
},
[SCREENS.WORKSPACE.RATE_AND_UNIT]: {
path: ROUTES.WORKSPACE_RATE_AND_UNIT.route,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index a60316fb7768..0b5f5a9f7907 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -542,6 +542,9 @@ type SettingsNavigatorParamList = {
policyID: string;
taxID: string;
};
+ [SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW]: {
+ policyID: string;
+ };
} & ReimbursementAccountNavigatorParamList;
type NewChatNavigatorParamList = {
@@ -993,7 +996,6 @@ type FullScreenNavigatorParamList = {
[SCREENS.WORKSPACE.DISTANCE_RATES]: {
policyID: string;
};
-
[SCREENS.WORKSPACE.ACCOUNTING.ROOT]: {
policyID: string;
};
@@ -1006,6 +1008,9 @@ type FullScreenNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.EXPENSIFY_CARD]: {
+ policyID: string;
+ };
};
type OnboardingModalNavigatorParamList = {
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index f20d27ffdf22..330d9d6ef61d 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -223,6 +223,10 @@ type FilterOptionsConfig = Pick<
'sortByReportTypeInSearch' | 'canInviteUser' | 'betas' | 'selectedOptions' | 'excludeUnknownUsers' | 'excludeLogins' | 'maxRecentReportsToShow'
> & {preferChatroomsOverThreads?: boolean};
+type HasText = {
+ text?: string;
+};
+
/**
* OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can
* be configured to display different results based on the options passed to the private getOptions() method. Public
@@ -2559,6 +2563,10 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt
};
}
+function sortItemsAlphabetically(membersList: T[]): T[] {
+ return membersList.sort((a, b) => (a.text ?? '').toLowerCase().localeCompare((b.text ?? '').toLowerCase()));
+}
+
export {
getAvatarsForAccountIDs,
isCurrentUser,
@@ -2584,6 +2592,7 @@ export {
getEnabledCategoriesCount,
hasEnabledOptions,
sortCategories,
+ sortItemsAlphabetically,
sortTags,
getCategoryOptionTree,
hasEnabledTags,
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index fc9a04e2507c..9c32e19f211a 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -241,6 +241,7 @@ function getTagListName(policyTagList: OnyxEntry, orderWeight: nu
return Object.values(policyTagList).find((tag) => tag.orderWeight === orderWeight)?.name ?? '';
}
+
/**
* Gets all tag lists of a policy
*/
@@ -657,6 +658,13 @@ function getCurrentConnectionName(policy: Policy | undefined): string | undefine
return connectionKey ? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionKey] : undefined;
}
+/**
+ * Check if the policy member is deleted from the workspace
+ */
+function isDeletedPolicyEmployee(policyEmployee: PolicyEmployee, isOffline: boolean) {
+ return !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors);
+}
+
export {
canEditTaxRate,
extractPolicyIDFromPath,
@@ -690,6 +698,7 @@ export {
hasPolicyErrorFields,
hasTaxRateError,
isExpensifyTeam,
+ isDeletedPolicyEmployee,
isFreeGroupPolicy,
isInstantSubmitEnabled,
isPaidGroupPolicy,
diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts
index aea952618071..b23493c08e8e 100644
--- a/src/libs/actions/Card.ts
+++ b/src/libs/actions/Card.ts
@@ -5,10 +5,21 @@ import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFrau
import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {ExpensifyCardDetails, IssueNewCardStep} from '@src/types/onyx/Card';
+import type {ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card';
type ReplacementReason = 'damaged' | 'stolen';
+type IssueNewCardFlowData = {
+ /** Step to be set in Onyx */
+ step?: IssueNewCardStep;
+
+ /** Whether the user is editing step */
+ isEditing?: boolean;
+
+ /** Data required to be sent to issue a new card */
+ data?: Partial;
+};
+
function reportVirtualExpensifyCardFraud(cardID: number) {
const optimisticData: OnyxUpdate[] = [
{
@@ -185,9 +196,24 @@ function revealVirtualCardDetails(cardID: number): Promise
});
}
-function setIssueNewCardStep(step: IssueNewCardStep | null) {
- Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {currentStep: step});
+function setIssueNewCardStepAndData({data, isEditing, step}: IssueNewCardFlowData) {
+ Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {data, isEditing, currentStep: step});
+}
+
+function clearIssueNewCardFlow() {
+ Onyx.set(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {
+ currentStep: null,
+ data: {},
+ });
}
-export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud, revealVirtualCardDetails, setIssueNewCardStep};
+export {
+ requestReplacementExpensifyCard,
+ activatePhysicalExpensifyCard,
+ clearCardListErrors,
+ reportVirtualExpensifyCardFraud,
+ revealVirtualCardDetails,
+ setIssueNewCardStepAndData,
+ clearIssueNewCardFlow,
+};
export type {ReplacementReason};
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 936c43b56ee5..adbf5a664c82 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -43,7 +43,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type {InvitedEmailsToAccountIDs, PersonalDetailsList, PolicyEmployee, PolicyEmployeeList, Session} from '@src/types/onyx';
+import type {InvitedEmailsToAccountIDs, PersonalDetailsList, PolicyEmployeeList, Session} from '@src/types/onyx';
import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading';
@@ -304,14 +304,6 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft,
[route.params.policyID],
);
- /**
- * Check if the policy member is deleted from the workspace
- */
- const isDeletedPolicyEmployee = useCallback(
- (policyEmployee: PolicyEmployee): boolean => !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors),
- [isOffline],
- );
-
const policyOwner = policy?.owner;
const currentUserLogin = currentUserPersonalDetails.login;
const invitedPrimaryToSecondaryLogins = invertObject(policy?.primaryLoginsInvited ?? {});
@@ -320,7 +312,7 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft,
Object.entries(policy?.employeeList ?? {}).forEach(([email, policyEmployee]) => {
const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? '');
- if (isDeletedPolicyEmployee(policyEmployee)) {
+ if (PolicyUtils.isDeletedPolicyEmployee(policyEmployee, isOffline)) {
return;
}
@@ -375,13 +367,13 @@ function WorkspaceMembersPage({personalDetails, invitedEmailsToAccountIDsDraft,
invitedSecondaryLogin: details?.login ? invitedPrimaryToSecondaryLogins[details.login] ?? '' : '',
});
});
- result = result.sort((a, b) => (a.text ?? '').toLowerCase().localeCompare((b.text ?? '').toLowerCase()));
+ result = OptionsListUtils.sortItemsAlphabetically(result);
return result;
}, [
+ isOffline,
currentUserLogin,
formatPhoneNumber,
invitedPrimaryToSecondaryLogins,
- isDeletedPolicyEmployee,
isPolicyAdmin,
personalDetails,
policy?.owner,
diff --git a/src/pages/workspace/card/issueNew/AssigneeStep.tsx b/src/pages/workspace/card/issueNew/AssigneeStep.tsx
index 5012ba294518..23acb3d4a24a 100644
--- a/src/pages/workspace/card/issueNew/AssigneeStep.tsx
+++ b/src/pages/workspace/card/issueNew/AssigneeStep.tsx
@@ -1,30 +1,124 @@
-import React from 'react';
+import React, {useMemo} from 'react';
import {View} from 'react-native';
-import FormProvider from '@components/Form/FormProvider';
+import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import type {ListItem} from '@components/SelectionList/types';
+import UserListItem from '@components/SelectionList/UserListItem';
import Text from '@components/Text';
+import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
+import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import * as Card from '@userActions/Card';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
-function AssigneeStep() {
+const MINIMUM_MEMBER_TO_SHOW_SEARCH = 8;
+
+type AssigneeStepProps = {
+ // The policy that the card will be issued under
+ policy: OnyxEntry;
+};
+
+function AssigneeStep({policy}: AssigneeStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
+ const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
+
+ const isEditing = issueNewCard?.isEditing;
+
+ const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
- const submit = () => {
- // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_TYPE);
+ const submit = (assignee: ListItem) => {
+ Card.setIssueNewCardStepAndData({
+ step: isEditing ? CONST.EXPENSIFY_CARD.STEP.CONFIRMATION : CONST.EXPENSIFY_CARD.STEP.CARD_TYPE,
+ data: {
+ assigneeEmail: assignee?.login ?? '',
+ },
+ isEditing: false,
+ });
};
const handleBackButtonPress = () => {
+ if (isEditing) {
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false});
+ return;
+ }
Navigation.goBack();
+ Card.clearIssueNewCardFlow();
};
+ const shouldShowSearchInput = policy?.employeeList && Object.keys(policy.employeeList).length >= MINIMUM_MEMBER_TO_SHOW_SEARCH;
+ const textInputLabel = shouldShowSearchInput ? translate('workspace.card.issueNewCard.findMember') : undefined;
+
+ const membersDetails = useMemo(() => {
+ let membersList: ListItem[] = [];
+ if (!policy?.employeeList) {
+ return membersList;
+ }
+
+ Object.entries(policy.employeeList ?? {}).forEach(([email, policyEmployee]) => {
+ if (PolicyUtils.isDeletedPolicyEmployee(policyEmployee, isOffline)) {
+ return;
+ }
+
+ const personalDetail = PersonalDetailsUtils.getPersonalDetailByEmail(email);
+ membersList.push({
+ keyForList: email,
+ text: personalDetail?.displayName,
+ alternateText: email,
+ login: email,
+ accountID: personalDetail?.accountID,
+ icons: [
+ {
+ source: personalDetail?.avatar ?? Expensicons.FallbackAvatar,
+ name: formatPhoneNumber(email),
+ type: CONST.ICON_TYPE_AVATAR,
+ id: personalDetail?.accountID,
+ },
+ ],
+ });
+ });
+
+ membersList = OptionsListUtils.sortItemsAlphabetically(membersList);
+
+ return membersList;
+ }, [isOffline, policy?.employeeList]);
+
+ const sections = useMemo(() => {
+ if (!debouncedSearchTerm) {
+ return [
+ {
+ data: membersDetails,
+ shouldShow: true,
+ },
+ ];
+ }
+
+ const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(debouncedSearchTerm).toLowerCase();
+ const filteredOptions = membersDetails.filter((option) => !!option.text?.toLowerCase().includes(searchValue) || !!option.alternateText?.toLowerCase().includes(searchValue));
+
+ return [
+ {
+ title: undefined,
+ data: filteredOptions,
+ shouldShow: true,
+ },
+ ];
+ }, [membersDetails, debouncedSearchTerm]);
+
return (
{translate('workspace.card.issueNewCard.whoNeedsCard')}
-
- {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
-
-
+
);
}
diff --git a/src/pages/workspace/card/issueNew/CardNameStep.tsx b/src/pages/workspace/card/issueNew/CardNameStep.tsx
index 9b48d6417732..58b0748e438a 100644
--- a/src/pages/workspace/card/issueNew/CardNameStep.tsx
+++ b/src/pages/workspace/card/issueNew/CardNameStep.tsx
@@ -1,28 +1,59 @@
-import React from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
+import {useOnyx} 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 InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ValidationUtils from '@libs/ValidationUtils';
import * as Card from '@userActions/Card';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/IssueNewExpensifyCardForm';
function CardNameStep() {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const {inputCallbackRef} = useAutoFocusInput();
+ const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
- const submit = () => {
- // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CONFIRMATION);
- };
+ const isEditing = issueNewCard?.isEditing;
- const handleBackButtonPress = () => {
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT);
- };
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.CARD_TITLE]);
+ if (!values.cardTitle) {
+ errors.cardTitle = translate('common.error.fieldRequired');
+ }
+ return errors;
+ },
+ [translate],
+ );
+
+ const submit = useCallback((values: FormOnyxValues) => {
+ Card.setIssueNewCardStepAndData({
+ step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION,
+ data: {
+ cardTitle: values.cardTitle,
+ },
+ isEditing: false,
+ });
+ }, []);
+
+ const handleBackButtonPress = useCallback(() => {
+ if (isEditing) {
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false});
+ return;
+ }
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.LIMIT});
+ }, [isEditing]);
return (
{translate('workspace.card.issueNewCard.giveItName')}
- {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
-
+
);
diff --git a/src/pages/workspace/card/issueNew/CardTypeStep.tsx b/src/pages/workspace/card/issueNew/CardTypeStep.tsx
index 93b99f51d239..31b5585b91ad 100644
--- a/src/pages/workspace/card/issueNew/CardTypeStep.tsx
+++ b/src/pages/workspace/card/issueNew/CardTypeStep.tsx
@@ -1,12 +1,16 @@
import React from 'react';
import {View} from 'react-native';
-import FormProvider from '@components/Form/FormProvider';
+import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Illustrations from '@components/Icon/Illustrations';
import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import MenuItem from '@components/MenuItem';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
import * as Card from '@userActions/Card';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -14,14 +18,26 @@ import ONYXKEYS from '@src/ONYXKEYS';
function CardTypeStep() {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
- const submit = () => {
- // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE);
+ const isEditing = issueNewCard?.isEditing;
+
+ const submit = (value: ValueOf) => {
+ Card.setIssueNewCardStepAndData({
+ step: isEditing ? CONST.EXPENSIFY_CARD.STEP.CONFIRMATION : CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE,
+ data: {
+ cardType: value,
+ },
+ isEditing: false,
+ });
};
const handleBackButtonPress = () => {
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.ASSIGNEE);
+ if (isEditing) {
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false});
+ return;
+ }
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.ASSIGNEE});
};
return (
@@ -42,15 +58,32 @@ function CardTypeStep() {
/>
{translate('workspace.card.issueNewCard.chooseCardType')}
-
- {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
-
-
+
+
);
}
diff --git a/src/pages/workspace/card/issueNew/ConfirmationStep.tsx b/src/pages/workspace/card/issueNew/ConfirmationStep.tsx
index a64d6f463531..35f9fab6598f 100644
--- a/src/pages/workspace/card/issueNew/ConfirmationStep.tsx
+++ b/src/pages/workspace/card/issueNew/ConfirmationStep.tsx
@@ -1,32 +1,62 @@
import React from 'react';
import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import Navigation from '@navigation/Navigation';
import * as Card from '@userActions/Card';
import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {IssueNewCardStep} from '@src/types/onyx/Card';
+
+function getTranslationKeyForLimitType(limitType: string | undefined) {
+ switch (limitType) {
+ case CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART:
+ return 'workspace.card.issueNewCard.smartLimit';
+ case CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED:
+ return 'workspace.card.issueNewCard.fixedAmount';
+ case CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY:
+ return 'workspace.card.issueNewCard.monthly';
+ default:
+ return '';
+ }
+}
function ConfirmationStep() {
const {translate} = useLocalize();
const styles = useThemeStyles();
const {isOffline} = useNetwork();
+ const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
+
+ const data = issueNewCard?.data;
+
const submit = () => {
- // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
- Navigation.navigate(ROUTES.SETTINGS);
+ // TODO: the logic will be created when CreateExpensifyCard is ready
+ Navigation.goBack();
+ Card.clearIssueNewCardFlow();
+ };
+
+ const editStep = (step: IssueNewCardStep) => {
+ Card.setIssueNewCardStepAndData({step, isEditing: true});
};
const handleBackButtonPress = () => {
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_NAME);
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CARD_NAME});
};
+ const translationForLimitType = getTranslationKeyForLimitType(data?.limitType);
+
return (
- {translate('workspace.card.issueNewCard.letsDoubleCheck')}
-
- {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
-
+ editStep(CONST.EXPENSIFY_CARD.STEP.CARD_TYPE)}
+ />
+ editStep(CONST.EXPENSIFY_CARD.STEP.LIMIT)}
+ />
+ editStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE)}
+ />
+ editStep(CONST.EXPENSIFY_CARD.STEP.CARD_NAME)}
+ />
+
+
+
+
);
}
diff --git a/src/pages/workspace/card/issueNew/IssueNewCardPage.tsx b/src/pages/workspace/card/issueNew/IssueNewCardPage.tsx
index d63bbd56b4d0..e12835a4a1e0 100644
--- a/src/pages/workspace/card/issueNew/IssueNewCardPage.tsx
+++ b/src/pages/workspace/card/issueNew/IssueNewCardPage.tsx
@@ -1,5 +1,7 @@
import React from 'react';
import {useOnyx} from 'react-native-onyx';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import AssigneeStep from './AssigneeStep';
@@ -9,18 +11,21 @@ import ConfirmationStep from './ConfirmationStep';
import LimitStep from './LimitStep';
import LimitTypeStep from './LimitTypeStep';
-function IssueNewCardPage() {
+function IssueNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) {
const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
const {currentStep} = issueNewCard ?? {};
+ // TODO: add logic to skip Assignee step when the flow is started from the member's profile page
+ // TODO: StartIssueNewCardFlow call to API
+
switch (currentStep) {
case CONST.EXPENSIFY_CARD.STEP.ASSIGNEE:
- return ;
+ return ;
case CONST.EXPENSIFY_CARD.STEP.CARD_TYPE:
return ;
case CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE:
- return ;
+ return ;
case CONST.EXPENSIFY_CARD.STEP.LIMIT:
return ;
case CONST.EXPENSIFY_CARD.STEP.CARD_NAME:
@@ -28,9 +33,9 @@ function IssueNewCardPage() {
case CONST.EXPENSIFY_CARD.STEP.CONFIRMATION:
return ;
default:
- return ;
+ return ;
}
}
IssueNewCardPage.displayName = 'IssueNewCardPage';
-export default IssueNewCardPage;
+export default withPolicyAndFullscreenLoading(IssueNewCardPage);
diff --git a/src/pages/workspace/card/issueNew/LimitStep.tsx b/src/pages/workspace/card/issueNew/LimitStep.tsx
index dd2e80a6612a..cc65a987bd62 100644
--- a/src/pages/workspace/card/issueNew/LimitStep.tsx
+++ b/src/pages/workspace/card/issueNew/LimitStep.tsx
@@ -1,28 +1,63 @@
-import React from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
+import {useOnyx} from 'react-native-onyx';
+import AmountForm from '@components/AmountForm';
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 InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
+import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import * as ValidationUtils from '@libs/ValidationUtils';
import * as Card from '@userActions/Card';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/IssueNewExpensifyCardForm';
function LimitStep() {
const {translate} = useLocalize();
+ const {inputCallbackRef} = useAutoFocusInput();
const styles = useThemeStyles();
+ const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
+ const isEditing = issueNewCard?.isEditing;
- const submit = () => {
- // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_NAME);
- };
+ const submit = useCallback(
+ (values: FormOnyxValues) => {
+ const limit = CurrencyUtils.convertToBackendAmount(Number(values?.limit));
+ Card.setIssueNewCardStepAndData({
+ step: isEditing ? CONST.EXPENSIFY_CARD.STEP.CONFIRMATION : CONST.EXPENSIFY_CARD.STEP.CARD_NAME,
+ data: {limit},
+ isEditing: false,
+ });
+ },
+ [isEditing],
+ );
+
+ const handleBackButtonPress = useCallback(() => {
+ if (isEditing) {
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false});
+ return;
+ }
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE});
+ }, [isEditing]);
- const handleBackButtonPress = () => {
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT_TYPE);
- };
+ const validate = useCallback(
+ (values: FormOnyxValues): FormInputErrors => {
+ const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.LIMIT]);
+
+ // We only want integers to be sent as the limit
+ if (!Number(values.limit) || !Number.isInteger(Number(values.limit))) {
+ errors.limit = translate('iou.error.invalidAmount');
+ }
+ return errors;
+ },
+ [translate],
+ );
return (
{translate('workspace.card.issueNewCard.setLimit')}
- {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
-
+
);
diff --git a/src/pages/workspace/card/issueNew/LimitTypeStep.tsx b/src/pages/workspace/card/issueNew/LimitTypeStep.tsx
index b1249e33e3c4..79b9c40ef4ae 100644
--- a/src/pages/workspace/card/issueNew/LimitTypeStep.tsx
+++ b/src/pages/workspace/card/issueNew/LimitTypeStep.tsx
@@ -1,28 +1,87 @@
-import React from 'react';
+import React, {useCallback, useMemo, useState} from 'react';
import {View} from 'react-native';
-import FormProvider from '@components/Form/FormProvider';
+import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader';
import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Card from '@userActions/Card';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-function LimitTypeStep() {
+type LimitTypeStepProps = {
+ // The policy that the card will be issued under
+ policy: OnyxEntry;
+};
+
+function LimitTypeStep({policy}: LimitTypeStepProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const [issueNewCard] = useOnyx(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD);
+
+ const areApprovalsConfigured = !isEmptyObject(policy?.approver) && policy?.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL;
+ const defaultType = areApprovalsConfigured ? CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART : CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY;
+
+ const [typeSelected, setTypeSelected] = useState(issueNewCard?.data?.limitType ?? defaultType);
+
+ const isEditing = issueNewCard?.isEditing;
+
+ const submit = useCallback(() => {
+ Card.setIssueNewCardStepAndData({
+ step: isEditing ? CONST.EXPENSIFY_CARD.STEP.CONFIRMATION : CONST.EXPENSIFY_CARD.STEP.LIMIT,
+ data: {limitType: typeSelected},
+ isEditing: false,
+ });
+ }, [isEditing, typeSelected]);
- const submit = () => {
- // TODO: the logic will be created in https://github.com/Expensify/App/issues/44309
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.LIMIT);
- };
+ const handleBackButtonPress = useCallback(() => {
+ if (isEditing) {
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CONFIRMATION, isEditing: false});
+ return;
+ }
+ Card.setIssueNewCardStepAndData({step: CONST.EXPENSIFY_CARD.STEP.CARD_TYPE});
+ }, [isEditing]);
- const handleBackButtonPress = () => {
- Card.setIssueNewCardStep(CONST.EXPENSIFY_CARD.STEP.CARD_TYPE);
- };
+ const data = useMemo(() => {
+ const options = [];
+
+ if (areApprovalsConfigured) {
+ options.push({
+ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
+ text: translate('workspace.card.issueNewCard.smartLimit'),
+ alternateText: translate('workspace.card.issueNewCard.smartLimitDescription'),
+ keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
+ isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART,
+ });
+ }
+
+ options.push(
+ {
+ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
+ text: translate('workspace.card.issueNewCard.monthly'),
+ alternateText: translate('workspace.card.issueNewCard.monthlyDescription'),
+ keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
+ isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY,
+ },
+ {
+ value: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
+ text: translate('workspace.card.issueNewCard.fixedAmount'),
+ alternateText: translate('workspace.card.issueNewCard.fixedAmountDescription'),
+ keyForList: CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
+ isSelected: typeSelected === CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED,
+ },
+ );
+
+ return options;
+ }, [areApprovalsConfigured, translate, typeSelected]);
return (
{translate('workspace.card.issueNewCard.chooseLimitType')}
-
- {/* TODO: the content will be created in https://github.com/Expensify/App/issues/44309 */}
-
-
+ setTypeSelected(value)}
+ sections={[{data}]}
+ shouldDebounceRowSelect
+ />
+
);
}
diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx
index 46b073b2bd48..91a5f3b8f3f1 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx
@@ -21,6 +21,7 @@ import Navigation from '@navigation/Navigation';
import type {FullScreenNavigatorParamList} from '@navigation/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {Card, WorkspaceCardsList} from '@src/types/onyx';
import WorkspaceCardListHeader from './WorkspaceCardListHeader';
@@ -100,7 +101,7 @@ function WorkspaceExpensifyCardListPage({route}: WorkspaceExpensifyCardListPageP