diff --git a/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx b/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx index 9590452e3d..d6e19905ba 100644 --- a/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx +++ b/src/components/additionalIntegrationSettings/AdditionalIntegrationSettingsSelector.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { ConnectionBehavior, deriveConnectionBehavior, @@ -26,6 +28,7 @@ interface AdditionalIntegrationSettingsSelectorProps { customerId: string values: AdditionalIntegrationSettingsValues onChange: (values: AdditionalIntegrationSettingsValues) => void + autoOpen?: boolean 'data-test'?: string } @@ -33,6 +36,7 @@ export const AdditionalIntegrationSettingsSelector = ({ customerId, values, onChange, + autoOpen = false, 'data-test': dataTest = ADDITIONAL_INTEGRATION_SETTINGS_SELECTOR_TEST_ID, }: AdditionalIntegrationSettingsSelectorProps) => { const { translate } = useInternationalization() @@ -41,6 +45,16 @@ export const AdditionalIntegrationSettingsSelector = ({ onSave: onChange, }) + const hasAutoOpened = useRef(false) + + useEffect(() => { + if (!autoOpen || hasAutoOpened.current) return + + hasAutoOpened.current = true + openDrawer(values) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoOpen]) + const getSubtitle = (): string => { const segments = ADDITIONAL_INTEGRATION_CATEGORIES.reduce((acc, category) => { const behavior = deriveConnectionBehavior(values[category]) diff --git a/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx b/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx index 08a103679d..ca4b003cbb 100644 --- a/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx +++ b/src/components/additionalIntegrationSettings/__tests__/AdditionalIntegrationSettingsDrawer.test.tsx @@ -158,6 +158,40 @@ describe('AdditionalIntegrationSettingsSelector', () => { } }) + describe('GIVEN the details view asks for the drawer to open on landing', () => { + const EMPTY_VALUES = { + [ConnectionCategory.Accounting]: undefined, + [ConnectionCategory.Crm]: undefined, + [ConnectionCategory.Tax]: undefined, + } + + describe('WHEN the selector mounts with autoOpen', () => { + it('THEN should open the drawer once', () => { + const { rerender } = render( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + }) + }) + }) + describe('GIVEN the selector is mounted', () => { describe('WHEN it renders', () => { it('THEN should display the entry card without opening the drawer', () => { diff --git a/src/components/connectionSelection/ConnectionCodeChip.tsx b/src/components/connectionSelection/ConnectionCodeChip.tsx new file mode 100644 index 0000000000..65ac832c23 --- /dev/null +++ b/src/components/connectionSelection/ConnectionCodeChip.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react' + +import { Avatar } from '~/components/designSystem/Avatar' +import { Chip } from '~/components/designSystem/Chip' + +type ConnectionCodeChipProps = { + code: string + avatar?: ReactNode + 'data-test'?: string +} + +export const ConnectionCodeChip = ({ + code, + avatar, + 'data-test': dataTest, +}: ConnectionCodeChipProps): JSX.Element => ( + + {!!avatar && ( + + {avatar} + + )} + {code} + + } + /> +) diff --git a/src/components/connectionSelection/ConnectionRoutingValue.tsx b/src/components/connectionSelection/ConnectionRoutingValue.tsx new file mode 100644 index 0000000000..7b8f90a355 --- /dev/null +++ b/src/components/connectionSelection/ConnectionRoutingValue.tsx @@ -0,0 +1,150 @@ +import { ReactNode } from 'react' + +import { integrationAvatarMapping, paymentAvatarMapping } from '~/components/avatarMappings' +import { + ConnectionCategory, + IntegrationConnectionCategory, +} from '~/components/customerConnections/types' +import { Typography } from '~/components/designSystem/Typography' +import { ConnectionResolvedBehaviorEnum } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useCustomerIntegrationConnections } from '~/hooks/customer/useCustomerIntegrationConnections' +import { useCustomerPaymentConnections } from '~/hooks/customer/useCustomerPaymentConnections' + +import { ConnectionCodeChip } from './ConnectionCodeChip' + +export const CONNECTION_ROUTING_CHIP_TEST_ID = 'connection-routing-chip' +export const CONNECTION_ROUTING_INHERITED_TEST_ID = 'connection-routing-inherited' +export const CONNECTION_ROUTING_SKIPPED_TEST_ID = 'connection-routing-skipped' +export const CONNECTION_ROUTING_UNRESOLVED_TEST_ID = 'connection-routing-unresolved' + +export type ConnectionRoutingDisplay = { + behavior: ConnectionResolvedBehaviorEnum + code?: string | null +} + +type ConnectionRoutingChipProps = { + routing?: ConnectionRoutingDisplay + avatar?: ReactNode +} + +const ConnectionRoutingChip = ({ routing, avatar }: ConnectionRoutingChipProps): JSX.Element => { + const { translate } = useInternationalization() + + if (routing?.behavior === ConnectionResolvedBehaviorEnum.Skip) { + return ( + + {translate('text_1789472252793x3dxbqu3x10')} + + ) + } + + if (!routing?.code) { + return ( + + {translate('text_1789382180711vi1jj3immjw')} + + ) + } + + return ( + + + + {routing.behavior === ConnectionResolvedBehaviorEnum.Inherit && ( + + {`(${translate('text_1789558944658bay5bstkcut')})`} + + )} + + ) +} + +const PaymentConnectionRoutingValue = ({ + customerId, + routing, +}: { + customerId?: string + routing?: ConnectionRoutingDisplay +}): JSX.Element => { + const { connections } = useCustomerPaymentConnections({ customerId, skip: !routing?.code }) + + const provider = connections.find((connection) => connection.code === routing?.code)?.provider + + return ( + + ) +} + +const IntegrationConnectionRoutingValue = ({ + category, + customerId, + routing, +}: { + category: IntegrationConnectionCategory + customerId?: string + routing?: ConnectionRoutingDisplay +}): JSX.Element => { + const { connections } = useCustomerIntegrationConnections({ + customerId, + category, + skip: !routing?.code, + }) + + const integrationType = connections.find( + (connection) => connection.code === routing?.code, + )?.integrationType + + return ( + + ) +} + +type ConnectionRoutingValueProps = { + category: ConnectionCategory + customerId?: string + routing?: ConnectionRoutingDisplay +} + +export const ConnectionRoutingValue = ({ + category, + customerId, + routing, +}: ConnectionRoutingValueProps): JSX.Element => { + if (category === ConnectionCategory.Payment) { + return + } + + return ( + + ) +} diff --git a/src/components/connectionSelection/ConnectionSettingsSections.tsx b/src/components/connectionSelection/ConnectionSettingsSections.tsx new file mode 100644 index 0000000000..8a09877fd1 --- /dev/null +++ b/src/components/connectionSelection/ConnectionSettingsSections.tsx @@ -0,0 +1,134 @@ +import { ReactNode } from 'react' + +import { ADDITIONAL_INTEGRATION_CATEGORIES } from '~/components/additionalIntegrationSettings/additionalIntegrationSettingsSchema' +import { findConnectionRouting } from '~/components/connectionSelection/fromConnectionRouting' +import { ConnectionCategory } from '~/components/customerConnections/types' +import { Typography } from '~/components/designSystem/Typography' +import { DetailsPage } from '~/components/layouts/DetailsPage' +import { SelectedPaymentMethod } from '~/components/paymentMethodSelection/types' +import { ConnectionCategoryEnum } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +import { PaymentMethodValue } from './PaymentMethodValue' +import { + ConnectionRoutingGridItem, + ConnectionRoutingRow, + useConnectionRoutingGridItems, +} from './useConnectionRoutingGridItems' + +export const CONNECTION_SETTINGS_PAYMENT_SECTION_TEST_ID = 'connection-settings-payment-section' +export const CONNECTION_SETTINGS_ADDITIONAL_SECTION_TEST_ID = + 'connection-settings-additional-section' + +type SectionHeaderProps = { + title: string + description: string + action?: ReactNode +} + +const SectionHeader = ({ title, description, action }: SectionHeaderProps): JSX.Element => ( +
+
+ + {title} + + + + {description} + +
+ + {!!action && action} +
+) + +type ConnectionSettingsSectionsProps = { + connections?: ConnectionRoutingRow[] | null + customerId?: string + externalCustomerId?: string + selectedPaymentMethod?: SelectedPaymentMethod + paymentDescription: string + additionalDescription: string + paymentAction?: ReactNode + additionalAction?: ReactNode + extraPaymentItems?: ConnectionRoutingGridItem[] +} + +export const ConnectionSettingsSections = ({ + connections, + customerId, + externalCustomerId, + selectedPaymentMethod, + paymentDescription, + additionalDescription, + paymentAction, + additionalAction, + extraPaymentItems = [], +}: ConnectionSettingsSectionsProps): JSX.Element => { + const { translate } = useInternationalization() + + const paymentConnectionItems = useConnectionRoutingGridItems({ + categories: [ConnectionCategory.Payment], + connections, + customerId, + }) + + const additionalConnectionItems = useConnectionRoutingGridItems({ + categories: ADDITIONAL_INTEGRATION_CATEGORIES, + connections, + customerId, + }) + + return ( + <> +
+ + + + ), + }, + ...extraPaymentItems, + ]} + /> +
+ +
+ + +
+ {additionalConnectionItems.map((item) => ( + + ))} +
+
+ + ) +} diff --git a/src/components/connectionSelection/PaymentMethodValue.tsx b/src/components/connectionSelection/PaymentMethodValue.tsx new file mode 100644 index 0000000000..f00c00ff9e --- /dev/null +++ b/src/components/connectionSelection/PaymentMethodValue.tsx @@ -0,0 +1,83 @@ +import { Chip } from '~/components/designSystem/Chip' +import { Typography } from '~/components/designSystem/Typography' +import { SelectedPaymentMethod } from '~/components/paymentMethodSelection/types' +import { useResolvedPaymentMethodDisplay } from '~/components/paymentMethodSelection/useResolvedPaymentMethodDisplay' +import { ConnectionResolvedBehaviorEnum } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useCustomerPaymentConnections } from '~/hooks/customer/useCustomerPaymentConnections' +import { usePaymentMethodsList } from '~/hooks/customer/usePaymentMethodsList' + +import { ConnectionRoutingDisplay } from './ConnectionRoutingValue' + +export const PAYMENT_METHOD_VALUE_CHIP_TEST_ID = 'payment-method-value-chip' +export const PAYMENT_METHOD_VALUE_INHERITED_TEST_ID = 'payment-method-value-inherited' + +type PaymentMethodValueProps = { + selectedPaymentMethod?: SelectedPaymentMethod + externalCustomerId?: string + customerId?: string + paymentRouting?: ConnectionRoutingDisplay +} + +export const PaymentMethodValue = ({ + selectedPaymentMethod, + externalCustomerId, + customerId, + paymentRouting, +}: PaymentMethodValueProps): JSX.Element => { + const { translate } = useInternationalization() + + const { connections, defaultConnection } = useCustomerPaymentConnections({ customerId }) + + const isSkipped = paymentRouting?.behavior === ConnectionResolvedBehaviorEnum.Skip + + const getResolvedConnection = () => { + if (isSkipped) return undefined + if (paymentRouting?.behavior === ConnectionResolvedBehaviorEnum.Specific) { + return connections.find((item) => item.code === paymentRouting.code) + } + + return defaultConnection + } + + const resolvedConnection = getResolvedConnection() + + const { data: customerPaymentMethods } = usePaymentMethodsList({ + externalCustomerId: externalCustomerId || '', + withDeleted: false, + skip: !resolvedConnection, + }) + + // A method belongs to one connection, so the customer-wide list is scoped the same way the + // edit drawer scopes it (ConnectionPaymentSettingsDrawerContent): without this the default + // card of another connection would be shown as the one paying this object. + const connectionPaymentMethods = resolvedConnection + ? customerPaymentMethods.filter( + (method) => method.paymentProviderCustomerId === resolvedConnection.id, + ) + : [] + + const { isInherited, label } = useResolvedPaymentMethodDisplay( + selectedPaymentMethod, + connectionPaymentMethods, + ) + + const showInheritedLabel = isInherited && !isSkipped + + return ( + + + + {showInheritedLabel && ( + + {`(${translate('text_1789558944658bay5bstkcut')})`} + + )} + + ) +} diff --git a/src/components/connectionSelection/__tests__/ConnectionRoutingValue.test.tsx b/src/components/connectionSelection/__tests__/ConnectionRoutingValue.test.tsx new file mode 100644 index 0000000000..34eecfd0f3 --- /dev/null +++ b/src/components/connectionSelection/__tests__/ConnectionRoutingValue.test.tsx @@ -0,0 +1,175 @@ +import { screen } from '@testing-library/react' + +import { ConnectionCategory } from '~/components/customerConnections/types' +import { + ConnectionResolvedBehaviorEnum, + IntegrationTypeEnum, + ProviderTypeEnum, +} from '~/generated/graphql' +import { render } from '~/test-utils' + +import { + CONNECTION_ROUTING_CHIP_TEST_ID, + CONNECTION_ROUTING_INHERITED_TEST_ID, + CONNECTION_ROUTING_SKIPPED_TEST_ID, + CONNECTION_ROUTING_UNRESOLVED_TEST_ID, + ConnectionRoutingValue, +} from '../ConnectionRoutingValue' + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +const mockUseCustomerPaymentConnections = jest.fn() +const mockUseCustomerIntegrationConnections = jest.fn() + +jest.mock('~/hooks/customer/useCustomerPaymentConnections', () => ({ + useCustomerPaymentConnections: (args: Record) => + mockUseCustomerPaymentConnections(args), +})) + +jest.mock('~/hooks/customer/useCustomerIntegrationConnections', () => ({ + useCustomerIntegrationConnections: (args: Record) => + mockUseCustomerIntegrationConnections(args), +})) + +describe('ConnectionRoutingValue', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockUseCustomerPaymentConnections.mockReturnValue({ + connections: [ + { id: 'pc-1', code: 'stripe-eu', name: 'Stripe EU', provider: ProviderTypeEnum.Stripe }, + ], + options: [], + defaultConnection: undefined, + isDefaultManual: false, + loading: false, + }) + + mockUseCustomerIntegrationConnections.mockReturnValue({ + connections: [ + { + id: 'ic-1', + code: 'anrok-eu', + name: 'Anrok EU', + group: '', + integrationType: IntegrationTypeEnum.Anrok, + isDefault: false, + }, + ], + options: [], + defaultConnection: undefined, + loading: false, + }) + }) + + describe('GIVEN a routing explicitly set on the billing object', () => { + describe('WHEN the behavior is specific', () => { + it('THEN should show the connection code without the inherited suffix', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).toHaveTextContent('stripe-eu') + expect(screen.queryByTestId(CONNECTION_ROUTING_INHERITED_TEST_ID)).not.toBeInTheDocument() + }) + }) + + describe('WHEN the behavior is skip', () => { + it('THEN should show the skipped state instead of a chip', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_SKIPPED_TEST_ID)).toBeInTheDocument() + expect(screen.queryByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + + describe('GIVEN a routing inherited from the customer', () => { + describe('WHEN the customer default resolves to a code', () => { + it('THEN should show the code with the inherited suffix', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).toHaveTextContent('anrok-eu') + expect(screen.getByTestId(CONNECTION_ROUTING_INHERITED_TEST_ID)).toBeInTheDocument() + }) + }) + + describe('WHEN the customer has no default', () => { + it('THEN should show the unresolved state', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_UNRESOLVED_TEST_ID)).toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the category has no routing at all', () => { + describe('WHEN nothing resolves', () => { + it('THEN should show the unresolved state', () => { + render( + , + ) + + expect(screen.getByTestId(CONNECTION_ROUTING_UNRESOLVED_TEST_ID)).toBeInTheDocument() + expect(screen.queryByTestId(CONNECTION_ROUTING_CHIP_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the connection list is only needed to resolve a provider avatar', () => { + describe('WHEN the routing carries no code', () => { + it.each([ + ['payment', ConnectionCategory.Payment, () => mockUseCustomerPaymentConnections], + ['integration', ConnectionCategory.Tax, () => mockUseCustomerIntegrationConnections], + ])('THEN should skip the %s connections query', (_, category, getMock) => { + render() + + expect(getMock()).toHaveBeenCalledWith(expect.objectContaining({ skip: true })) + }) + }) + }) + + describe('GIVEN an integration category', () => { + describe('WHEN the value renders', () => { + it('THEN should query the customer connections of that category', () => { + render( + , + ) + + expect(mockUseCustomerIntegrationConnections).toHaveBeenCalledWith({ + customerId: 'customer-1', + category: ConnectionCategory.Tax, + skip: false, + }) + }) + }) + }) +}) diff --git a/src/components/connectionSelection/__tests__/PaymentMethodValue.test.tsx b/src/components/connectionSelection/__tests__/PaymentMethodValue.test.tsx new file mode 100644 index 0000000000..bed385b057 --- /dev/null +++ b/src/components/connectionSelection/__tests__/PaymentMethodValue.test.tsx @@ -0,0 +1,203 @@ +import { screen } from '@testing-library/react' + +import { ConnectionResolvedBehaviorEnum, PaymentMethodTypeEnum } from '~/generated/graphql' +import { createMockPaymentMethod } from '~/hooks/customer/__tests__/factories/PaymentMethod.factory' +import { PaymentMethodItem } from '~/hooks/customer/usePaymentMethodsList' +import { render } from '~/test-utils' + +import { + PAYMENT_METHOD_VALUE_CHIP_TEST_ID, + PAYMENT_METHOD_VALUE_INHERITED_TEST_ID, + PaymentMethodValue, +} from '../PaymentMethodValue' + +let mockPaymentMethodsList: PaymentMethodItem[] = [] + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +jest.mock('~/hooks/useOrganizationInfos', () => ({ + useOrganizationInfos: () => ({ + organization: { defaultCurrency: 'USD' }, + intlFormatDateTimeOrgaTZ: () => ({ date: '2024-01-01' }), + hasFeatureFlag: () => true, + }), +})) + +const CONNECTION_A = { + id: 'pc-a', + code: 'stripe-a', + name: 'Stripe A', + provider: null, + isDefault: true, +} +const CONNECTION_B = { + id: 'pc-b', + code: 'stripe-b', + name: 'Stripe B', + provider: null, + isDefault: false, +} + +jest.mock('~/hooks/customer/useCustomerPaymentConnections', () => ({ + useCustomerPaymentConnections: () => ({ + connections: [CONNECTION_A, CONNECTION_B], + options: [], + defaultConnection: CONNECTION_A, + isDefaultManual: false, + loading: false, + }), +})) + +jest.mock('~/hooks/customer/usePaymentMethodsList', () => ({ + usePaymentMethodsList: () => ({ + data: mockPaymentMethodsList, + loading: false, + error: false, + refetch: jest.fn(), + }), +})) + +describe('PaymentMethodValue', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPaymentMethodsList = [] + }) + + describe('GIVEN the object selects a specific provider method', () => { + describe('WHEN it renders', () => { + it('THEN should show the formatted method as a chip without the customer-default label', () => { + const paymentMethod = createMockPaymentMethod({ + paymentProviderCustomerId: CONNECTION_A.id, + }) + + mockPaymentMethodsList = [paymentMethod] + + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).toHaveTextContent('4242') + expect(screen.queryByTestId(PAYMENT_METHOD_VALUE_INHERITED_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the object falls back to the customer default', () => { + describe('WHEN it renders', () => { + it('THEN should show the customer-default label next to the chip', () => { + mockPaymentMethodsList = [ + createMockPaymentMethod({ isDefault: true, paymentProviderCustomerId: CONNECTION_A.id }), + ] + + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).toBeInTheDocument() + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_INHERITED_TEST_ID)).toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the object is paid manually', () => { + describe('WHEN it renders', () => { + it('THEN should show the manual label as a chip', () => { + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).toBeInTheDocument() + expect(screen.queryByTestId(PAYMENT_METHOD_VALUE_INHERITED_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the customer has several payment connections', () => { + describe('WHEN the object routes to a connection that owns no method', () => { + it("THEN should not show another connection's default card", () => { + mockPaymentMethodsList = [ + createMockPaymentMethod({ + id: 'pm_connection_a', + isDefault: true, + paymentProviderCustomerId: CONNECTION_A.id, + }), + ] + + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).not.toHaveTextContent('4242') + }) + }) + + describe('WHEN the object routes to the connection that owns the method', () => { + it('THEN should show that card', () => { + mockPaymentMethodsList = [ + createMockPaymentMethod({ + id: 'pm_connection_b', + isDefault: true, + paymentProviderCustomerId: CONNECTION_B.id, + }), + ] + + render( + , + ) + + expect(screen.getByTestId(PAYMENT_METHOD_VALUE_CHIP_TEST_ID)).toHaveTextContent('4242') + }) + }) + + describe('WHEN the object skips the payment connection', () => { + it('THEN should show the manual label without the customer-default suffix', () => { + mockPaymentMethodsList = [ + createMockPaymentMethod({ isDefault: true, paymentProviderCustomerId: CONNECTION_A.id }), + ] + + render( + , + ) + + expect(screen.queryByTestId(PAYMENT_METHOD_VALUE_INHERITED_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/components/connectionSelection/__tests__/useConnectionRoutingGridItems.test.tsx b/src/components/connectionSelection/__tests__/useConnectionRoutingGridItems.test.tsx new file mode 100644 index 0000000000..801b5cb33a --- /dev/null +++ b/src/components/connectionSelection/__tests__/useConnectionRoutingGridItems.test.tsx @@ -0,0 +1,105 @@ +import { renderHook } from '@testing-library/react' + +import { ConnectionCategory } from '~/components/customerConnections/types' +import { ConnectionCategoryEnum, ConnectionResolvedBehaviorEnum } from '~/generated/graphql' +import { render } from '~/test-utils' + +import { + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS, + useConnectionRoutingGridItems, +} from '../useConnectionRoutingGridItems' + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +const mockConnectionRoutingValue = jest.fn() + +jest.mock('../ConnectionRoutingValue', () => ({ + ConnectionRoutingValue: (props: Record) => { + mockConnectionRoutingValue(props) + + return null + }, +})) + +const CONNECTIONS = [ + { + category: ConnectionCategoryEnum.Payment, + behavior: ConnectionResolvedBehaviorEnum.Specific, + code: 'stripe-eu', + }, + { + category: ConnectionCategoryEnum.Tax, + behavior: ConnectionResolvedBehaviorEnum.Inherit, + code: 'anrok-eu', + }, +] + +const renderItems = (categories: ConnectionCategory[]) => + renderHook(() => + useConnectionRoutingGridItems({ + categories, + connections: CONNECTIONS, + customerId: 'customer-1', + }), + ) + +describe('useConnectionRoutingGridItems', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('GIVEN a list of categories', () => { + describe('WHEN the items are built', () => { + it('THEN should return one labeled row per category, in order', () => { + const { result } = renderItems([ + ConnectionCategory.Payment, + ConnectionCategory.Tax, + ConnectionCategory.Accounting, + ConnectionCategory.Crm, + ]) + + expect(result.current.map(({ label }) => label)).toEqual([ + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Payment], + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Tax], + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Accounting], + CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[ConnectionCategory.Crm], + ]) + }) + }) + }) + + describe('GIVEN the billing object routes some categories', () => { + describe('WHEN a category has a routing', () => { + it('THEN should hand that routing to the value', () => { + const { result } = renderItems([ConnectionCategory.Tax]) + + render(<>{result.current[0].value}) + + expect(mockConnectionRoutingValue).toHaveBeenCalledWith( + expect.objectContaining({ + category: ConnectionCategory.Tax, + customerId: 'customer-1', + routing: expect.objectContaining({ + behavior: ConnectionResolvedBehaviorEnum.Inherit, + code: 'anrok-eu', + }), + }), + ) + }) + }) + + describe('WHEN a category is missing from the connections', () => { + it('THEN should hand an undefined routing to the value', () => { + const { result } = renderItems([ConnectionCategory.Crm]) + + render(<>{result.current[0].value}) + + expect(mockConnectionRoutingValue).toHaveBeenCalledWith( + expect.objectContaining({ category: ConnectionCategory.Crm, routing: undefined }), + ) + }) + }) + }) +}) diff --git a/src/components/connectionSelection/useConnectionRoutingGridItems.tsx b/src/components/connectionSelection/useConnectionRoutingGridItems.tsx new file mode 100644 index 0000000000..170521d88f --- /dev/null +++ b/src/components/connectionSelection/useConnectionRoutingGridItems.tsx @@ -0,0 +1,56 @@ +import { ReactNode } from 'react' + +import { findConnectionRouting } from '~/components/connectionSelection/fromConnectionRouting' +import { ConnectionCategory } from '~/components/customerConnections/types' +import { ConnectionCategoryEnum } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +import { ConnectionRoutingDisplay, ConnectionRoutingValue } from './ConnectionRoutingValue' + +export const CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS: Record = { + [ConnectionCategory.Payment]: 'text_1789557972392jhb3cywzp35', + [ConnectionCategory.Tax]: 'text_1789557972392kzvs8grsxor', + [ConnectionCategory.Accounting]: 'text_1789557972392ui25nl8slm3', + [ConnectionCategory.Crm]: 'text_1728658962985xpfdvl5ru8a', +} + +const CONNECTION_CATEGORY_TO_API_CATEGORY: Record = { + [ConnectionCategory.Payment]: ConnectionCategoryEnum.Payment, + [ConnectionCategory.Tax]: ConnectionCategoryEnum.Tax, + [ConnectionCategory.Accounting]: ConnectionCategoryEnum.Accounting, + [ConnectionCategory.Crm]: ConnectionCategoryEnum.Crm, +} + +export type ConnectionRoutingRow = ConnectionRoutingDisplay & { + category: ConnectionCategoryEnum +} + +export type ConnectionRoutingGridItem = { + label: string + value: ReactNode +} + +interface UseConnectionRoutingGridItemsArgs { + categories: ConnectionCategory[] + connections?: ConnectionRoutingRow[] | null + customerId?: string +} + +export const useConnectionRoutingGridItems = ({ + categories, + connections, + customerId, +}: UseConnectionRoutingGridItemsArgs): ConnectionRoutingGridItem[] => { + const { translate } = useInternationalization() + + return categories.map((category) => ({ + label: translate(CONNECTION_CATEGORY_CONNECTION_LABEL_KEYS[category]), + value: ( + + ), + })) +} diff --git a/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx b/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx index 0ad88f3db4..78d44de388 100644 --- a/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx +++ b/src/components/paymentSettings/connectionFirst/ConnectionPaymentSettingsSelector.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { ConnectionBehavior, deriveConnectionBehavior, @@ -28,6 +30,7 @@ interface ConnectionPaymentSettingsSelectorProps { connection: SelectedConnection paymentMethod: SelectedPaymentMethod onChange: (values: ConnectionPaymentSettingsValues) => void + autoOpen?: boolean 'data-test'?: string } @@ -52,6 +55,7 @@ export const ConnectionPaymentSettingsSelector = ({ connection, paymentMethod, onChange, + autoOpen = false, 'data-test': dataTest = CONNECTION_PAYMENT_SETTINGS_SELECTOR_TEST_ID, }: ConnectionPaymentSettingsSelectorProps) => { const { translate } = useInternationalization() @@ -64,6 +68,16 @@ export const ConnectionPaymentSettingsSelector = ({ const seededConnection = seedConnection(connection, paymentMethod) + const hasAutoOpened = useRef(false) + + useEffect(() => { + if (!autoOpen || hasAutoOpened.current) return + + hasAutoOpened.current = true + openDrawer({ connection: seededConnection, paymentMethod }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoOpen]) + return ( { mockPaymentMethodFieldsProps.current = null }) + describe('GIVEN the details view asks for the drawer to open on landing', () => { + describe('WHEN the selector mounts with autoOpen', () => { + it('THEN should open the drawer once, seeded with the current values', () => { + const { rerender } = render( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + expect(mockOpen).toHaveBeenCalledTimes(1) + }) + }) + + describe('WHEN the selector mounts without autoOpen', () => { + it('THEN should not open the drawer', () => { + render( + , + ) + + expect(mockOpen).not.toHaveBeenCalled() + }) + }) + }) + describe('GIVEN the selector is mounted', () => { describe('WHEN it renders', () => { it('THEN should display the entry card without opening the drawer', () => { diff --git a/src/components/wallets/WalletExternalApps.tsx b/src/components/wallets/WalletExternalApps.tsx new file mode 100644 index 0000000000..fdc3433b7c --- /dev/null +++ b/src/components/wallets/WalletExternalApps.tsx @@ -0,0 +1,72 @@ +import { ReactNode } from 'react' +import { generatePath } from 'react-router' + +import { ConnectionSettingsSections } from '~/components/connectionSelection/ConnectionSettingsSections' +import { ButtonLink } from '~/components/designSystem/ButtonLink' +import { EDIT_WALLET_ROUTE } from '~/core/router' +import { WalletDetailsFragment } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +export const WALLET_EXTERNAL_APPS_CONTAINER_TEST_ID = 'wallet-external-apps-container' +export const WALLET_EXTERNAL_APPS_EDIT_PAYMENT_TEST_ID = 'wallet-external-apps-edit-payment' +export const WALLET_EXTERNAL_APPS_EDIT_ADDITIONAL_TEST_ID = 'wallet-external-apps-edit-additional' + +type WalletExternalAppsProps = { + wallet?: WalletDetailsFragment | null + canEditWallet: boolean +} + +const WalletExternalApps = ({ wallet, canEditWallet }: WalletExternalAppsProps): JSX.Element => { + const { translate } = useInternationalization() + + const customerId = wallet?.customer?.id + + if (!wallet) { + return <> + } + + const renderEditLink = ( + routerState: Record, + dataTest: string, + ): ReactNode | null => { + if (!canEditWallet || !customerId) return null + + return ( + + {translate('text_63e51ef4985f0ebd75c212fc')} + + ) + } + + return ( +
+ +
+ ) +} + +export default WalletExternalApps diff --git a/src/components/wallets/WalletInformations.tsx b/src/components/wallets/WalletInformations.tsx index 382b1f38c3..db72b73eeb 100644 --- a/src/components/wallets/WalletInformations.tsx +++ b/src/components/wallets/WalletInformations.tsx @@ -9,7 +9,7 @@ import { useResolvedPaymentMethodValue } from '~/components/paymentMethodSelecti import { ViewTypeEnum } from '~/core/constants/billingObjectViewTypes' import { intlFormatNumber } from '~/core/formats/intlFormatNumber' import { deserializeAmount, getCurrencyPrecision } from '~/core/serializers/serializeAmount' -import { CurrencyEnum, WalletDetailsFragment } from '~/generated/graphql' +import { CurrencyEnum, FeatureFlagEnum, WalletDetailsFragment } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { usePaymentMethodsList } from '~/hooks/customer/usePaymentMethodsList' import { useCustomerInvoiceCustomSections } from '~/hooks/useCustomerInvoiceCustomSections' @@ -17,6 +17,7 @@ import { useOrganizationInfos } from '~/hooks/useOrganizationInfos' import { tw } from '~/styles/utils' export const WALLET_INFORMATIONS_CONTAINER_TEST_ID = 'wallet-informations-container' +export const WALLET_INFORMATIONS_PAYMENT_SECTION_TEST_ID = 'wallet-informations-payment-section' type WalletInformationsProps = { wallet?: WalletDetailsFragment | null @@ -36,8 +37,11 @@ const SectionTitle = ({ title, subtitle }: { title: string; subtitle: string }) const WalletInformations = ({ wallet }: WalletInformationsProps) => { const { translate } = useInternationalization() - const { intlFormatDateTimeOrgaTZ, organization: { defaultCurrency } = {} } = - useOrganizationInfos() + const { + intlFormatDateTimeOrgaTZ, + hasFeatureFlag, + organization: { defaultCurrency } = {}, + } = useOrganizationInfos() const { data: paymentMethodsList } = usePaymentMethodsList({ externalCustomerId: wallet?.customer?.externalId || '', @@ -86,7 +90,10 @@ const WalletInformations = ({ wallet }: WalletInformationsProps) => { const showAppliesToSection = !!wallet?.appliesTo?.feeTypes?.length || !!wallet?.appliesTo?.billableMetrics?.length - const showPaymentSection = paymentMethodValue !== '-' || showWalletInvoiceCustomSectionsRow + // With multi_connection on this row lives on the External apps tab (WalletExternalApps). + const showPaymentMethodRow = + !hasFeatureFlag(FeatureFlagEnum.MultiConnection) && paymentMethodValue !== '-' + const showPaymentSection = showPaymentMethodRow || showWalletInvoiceCustomSectionsRow return (
@@ -206,7 +213,10 @@ const WalletInformations = ({ wallet }: WalletInformationsProps) => { )} {showPaymentSection && ( -
+
{ `wallet-recurring-rules-rule-${index}` const WALLET_RECURRING_RULES_TOPUP_TYPE_TEST_ID = 'wallet-recurring-rules-topup-type' +export const WALLET_RECURRING_RULES_EDIT_PAYMENT_TEST_ID = (index: number) => + `wallet-recurring-rules-edit-payment-${index}` +export const WALLET_RECURRING_RULES_EDIT_ADDITIONAL_TEST_ID = (index: number) => + `wallet-recurring-rules-edit-additional-${index}` + const YES_TRANSLATION_KEY = 'text_1764160009979jzn4xunn1z8' const NO_TRANSLATION_KEY = 'text_176416000997957yqelmt2m2' @@ -44,17 +55,23 @@ const SectionTitle = ({ title, subtitle }: { title: string; subtitle: string }) const RecurringRuleBlock = ({ rule, + ruleIndex, wallet, paymentMethodsList, customerIcsData, + canEditWallet, }: { rule: WalletRecurringRule + ruleIndex: number wallet: WalletDetailsFragment paymentMethodsList?: PaymentMethodList customerIcsData: CustomerIcsData + canEditWallet: boolean }) => { const { translate } = useInternationalization() - const { intlFormatDateTimeOrgaTZ } = useOrganizationInfos() + const { intlFormatDateTimeOrgaTZ, hasFeatureFlag } = useOrganizationInfos() + + const isMultiConnectionEnabled = hasFeatureFlag(FeatureFlagEnum.MultiConnection) const paymentMethodValue = useResolvedPaymentMethodValue( { @@ -107,10 +124,34 @@ const RecurringRuleBlock = ({ customerIcsData, }) - const showPaymentSection = paymentMethodValue !== '-' || showInvoiceCustomSectionsRow + const showPaymentSection = + isMultiConnectionEnabled || paymentMethodValue !== '-' || showInvoiceCustomSectionsRow + + const renderPaymentSection = () => { + if (isMultiConnectionEnabled) { + return ( + + ) + } + + return ( + + ) + } return ( - <> +
- {showPaymentSection && ( - - )} - + {showPaymentSection && renderPaymentSection()} +
+ ) +} + +const RecurringRuleConnectionSections = ({ + rule, + ruleIndex, + wallet, + showInvoiceCustomSectionsRow, + canEditWallet, +}: { + rule: WalletRecurringRule + ruleIndex: number + wallet: WalletDetailsFragment + showInvoiceCustomSectionsRow: boolean + canEditWallet: boolean +}): JSX.Element => { + const { translate } = useInternationalization() + + const customerId = wallet.customer?.id + + // The wallet form only ever opens and saves recurringTransactionRules[0] (see TopUpSection), + // so an Edit on any later rule would silently edit the first one. + const renderEditLink = (dataTest: string): React.ReactNode => { + if (!canEditWallet || !customerId || ruleIndex !== 0) return null + + return ( + + {translate('text_63e51ef4985f0ebd75c212fc')} + + ) + } + + return ( + + ), + }, + ] + : [] + } + /> ) } @@ -290,6 +393,7 @@ const RecurringRulePaymentSection = ({ type WalletRecurringRulesProps = { wallet?: WalletDetailsFragment | null + canEditWallet?: boolean } /** @@ -297,7 +401,7 @@ type WalletRecurringRulesProps = { * Editing goes through the wallet edition form — the rules have no dedicated * mutation, they only travel nested in updateCustomerWallet. */ -const WalletRecurringRules = ({ wallet }: WalletRecurringRulesProps) => { +const WalletRecurringRules = ({ wallet, canEditWallet = false }: WalletRecurringRulesProps) => { const { translate } = useInternationalization() const { isPremium } = useCurrentUser() @@ -351,9 +455,11 @@ const WalletRecurringRules = ({ wallet }: WalletRecurringRulesProps) => {
))} diff --git a/src/components/wallets/__tests__/WalletExternalApps.test.tsx b/src/components/wallets/__tests__/WalletExternalApps.test.tsx new file mode 100644 index 0000000000..880de58a94 --- /dev/null +++ b/src/components/wallets/__tests__/WalletExternalApps.test.tsx @@ -0,0 +1,235 @@ +import { screen } from '@testing-library/react' + +import { ButtonLinkBaseProps } from '~/components/designSystem/ButtonLink' +import { + ConnectionCategoryEnum, + ConnectionResolvedBehaviorEnum, + PaymentMethodTypeEnum, + WalletDetailsFragment, +} from '~/generated/graphql' +import { createMockPaymentMethod } from '~/hooks/customer/__tests__/factories/PaymentMethod.factory' +import { PaymentMethodItem } from '~/hooks/customer/usePaymentMethodsList' +import { render } from '~/test-utils' + +import WalletExternalApps, { + WALLET_EXTERNAL_APPS_CONTAINER_TEST_ID, + WALLET_EXTERNAL_APPS_EDIT_ADDITIONAL_TEST_ID, + WALLET_EXTERNAL_APPS_EDIT_PAYMENT_TEST_ID, +} from '../WalletExternalApps' + +let mockPaymentMethodsList: PaymentMethodItem[] = [] + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ translate: (key: string) => key }), +})) + +jest.mock('~/hooks/useOrganizationInfos', () => ({ + useOrganizationInfos: () => ({ + organization: { defaultCurrency: 'USD' }, + intlFormatDateTimeOrgaTZ: () => ({ date: '2024-01-01' }), + hasFeatureFlag: () => true, + }), +})) + +const PAYMENT_CONNECTION = { + id: 'pc-1', + code: 'stripe-eu', + name: 'Stripe EU', + provider: null, + isDefault: true, +} + +jest.mock('~/hooks/customer/useCustomerPaymentConnections', () => ({ + useCustomerPaymentConnections: () => ({ + connections: [PAYMENT_CONNECTION], + options: [], + defaultConnection: PAYMENT_CONNECTION, + isDefaultManual: false, + loading: false, + }), +})) + +jest.mock('~/hooks/customer/usePaymentMethodsList', () => ({ + usePaymentMethodsList: () => ({ + data: mockPaymentMethodsList, + loading: false, + error: false, + refetch: jest.fn(), + }), +})) + +const mockConnectionRoutingValue = jest.fn() + +jest.mock('~/components/connectionSelection/ConnectionRoutingValue', () => ({ + ConnectionRoutingValue: (props: Record) => { + mockConnectionRoutingValue(props) + + return null + }, +})) + +// routerState (the drawer auto-open intent) never reaches the DOM, so the Edit +// links can only be asserted through the props they receive. +const mockButtonLink = jest.fn() + +jest.mock('~/components/designSystem/ButtonLink', () => ({ + ButtonLink: (props: ButtonLinkBaseProps & { 'data-test'?: string }) => { + mockButtonLink(props) + + return