{
describe('when switching project', () => {
it('should render app for new project', async () => {
renderApp();
- const input = await screen.findByLabelText('Projects menu');
+ const input = await screen.findByLabelText('Projects');
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
diff --git a/packages/application-shell/src/components/fetch-user/fetch-user.mc.graphql b/packages/application-shell/src/components/fetch-user/fetch-user.mc.graphql
index f120de1260..b41a50ee0c 100644
--- a/packages/application-shell/src/components/fetch-user/fetch-user.mc.graphql
+++ b/packages/application-shell/src/components/fetch-user/fetch-user.mc.graphql
@@ -28,6 +28,7 @@ query FetchLoggedInUser {
expiry {
isActive
}
+ isProductionProject
}
}
idTokenUserInfo {
diff --git a/packages/application-shell/src/components/locale-switcher/locale-switcher.spec.tsx b/packages/application-shell/src/components/locale-switcher/locale-switcher.spec.tsx
new file mode 100644
index 0000000000..74dd874542
--- /dev/null
+++ b/packages/application-shell/src/components/locale-switcher/locale-switcher.spec.tsx
@@ -0,0 +1,50 @@
+import { render, fireEvent, waitFor, screen } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import LocaleSwitcher from './locale-switcher';
+
+const setProjectDataLocale = jest.fn();
+const availableLocales = ['en', 'de', 'fr'];
+
+const renderLocaleSwitcher = () => {
+ return render(
+
+
+
+ );
+};
+
+describe('LocaleSwitcher', () => {
+ it('should render and handle locale selection', async () => {
+ renderLocaleSwitcher();
+ const input = await screen.findByLabelText('Locales');
+ fireEvent.focus(input);
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ fireEvent.click(screen.getByText('fr'));
+
+ await waitFor(() => {
+ expect(setProjectDataLocale).toHaveBeenCalledWith('fr');
+ });
+ });
+ it('should open and close the locale dialog', async () => {
+ renderLocaleSwitcher();
+ const input = await screen.findByLabelText('Locales');
+ fireEvent.focus(input);
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ const iconButton = await screen.findByRole('button', {
+ name: 'Locales info',
+ });
+ fireEvent.click(iconButton);
+
+ // expect to see the dialog opens after clicking the icon button
+ const dialogText = await screen.findByText('Selecting a data locale');
+ expect(dialogText).toBeInTheDocument();
+
+ // close the dialog
+ fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }));
+ expect(dialogText).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/application-shell/src/components/locale-switcher/locale-switcher.tsx b/packages/application-shell/src/components/locale-switcher/locale-switcher.tsx
index 280fde50ef..f5c269d3d2 100644
--- a/packages/application-shell/src/components/locale-switcher/locale-switcher.tsx
+++ b/packages/application-shell/src/components/locale-switcher/locale-switcher.tsx
@@ -1,16 +1,23 @@
import { useCallback } from 'react';
-import { css } from '@emotion/react';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, useIntl } from 'react-intl';
import type {
SingleValueProps,
ValueContainerProps,
MenuListProps,
+ GroupHeadingProps,
} from 'react-select';
import { components } from 'react-select';
+import {
+ InfoDialog,
+ useModalState,
+} from '@commercetools-frontend/application-components';
import AccessibleHidden from '@commercetools-uikit/accessible-hidden';
import { designTokens } from '@commercetools-uikit/design-system';
-import { WorldIcon } from '@commercetools-uikit/icons';
+import IconButton from '@commercetools-uikit/icon-button';
+import { WorldIcon, InformationIcon } from '@commercetools-uikit/icons';
import SelectInput from '@commercetools-uikit/select-input';
+import Spacings from '@commercetools-uikit/spacings';
+import Text from '@commercetools-uikit/text';
import messages from './messages';
type Props = {
@@ -23,24 +30,10 @@ const LOCALE_SWITCHER_LABEL_ID = 'locale-switcher-label';
export const SingleValue = (props: SingleValueProps) => {
return (
-
+
-
- {props.children}
-
-
+
{props.children}
+
);
};
SingleValue.displayName = 'SingleValue';
@@ -59,14 +52,51 @@ const CustomMenuList = (props: MenuListProps) => {
return
{props.children};
};
+const CustomGroupHeading = (
+ props: GroupHeadingProps & { setIsOpen: (value: boolean) => void }
+) => {
+ const { setIsOpen, ...groupProps } = props;
+ return (
+ <>
+
+
+ {groupProps.children}
+ }
+ label="Locales info"
+ size="small"
+ onClick={() => setIsOpen(true)}
+ />
+
+
+ >
+ );
+};
+CustomGroupHeading.displayName = 'CustomGroupHeading';
+
const LocaleSwitcher = (props: Props) => {
+ const { isModalOpen, openModal, closeModal } = useModalState();
const { setProjectDataLocale } = props;
+ const getNewLine = () =>
;
+ const intl = useIntl();
+
const handleSelection = useCallback(
(event) => {
setProjectDataLocale(event.target.value);
},
[setProjectDataLocale]
);
+
+ const localeOptions = [
+ {
+ label:
,
+ options: props.availableLocales.map((locale) => ({
+ label: locale,
+ value: locale,
+ })),
+ },
+ ];
+
return (
@@ -79,14 +109,14 @@ const LocaleSwitcher = (props: Props) => {
name="locale-switcher"
aria-labelledby={LOCALE_SWITCHER_LABEL_ID}
onChange={handleSelection}
- options={props.availableLocales.map((locale) => ({
- label: locale,
- value: locale,
- }))}
+ options={localeOptions}
components={{
SingleValue,
ValueContainer: PatchedValueContainer,
MenuList: CustomMenuList,
+ GroupHeading: (groupProps) => (
+
+ ),
}}
isClearable={false}
backspaceRemovesValue={false}
@@ -94,7 +124,23 @@ const LocaleSwitcher = (props: Props) => {
horizontalConstraint={'auto'}
appearance="quiet"
maxMenuHeight={360}
+ minMenuWidth={3}
/>
+ {/* Dialog that explains the locales */}
+
+
+
);
};
diff --git a/packages/application-shell/src/components/locale-switcher/messages.ts b/packages/application-shell/src/components/locale-switcher/messages.ts
index a494b8da50..45a0fe6d54 100644
--- a/packages/application-shell/src/components/locale-switcher/messages.ts
+++ b/packages/application-shell/src/components/locale-switcher/messages.ts
@@ -4,6 +4,17 @@ export default defineMessages({
localesLabel: {
id: 'LocaleSwitcher.localesLabel',
description: 'The label for project dropdown switcher',
- defaultMessage: 'Locales menu',
+ defaultMessage: 'Locales',
+ },
+ dialogLocaleTitle: {
+ id: 'LocaleSwitcher.dialogLocaleTitle',
+ description: 'The title for the data locale dialog',
+ defaultMessage: 'Selecting a data locale',
+ },
+ dialogLocaleDescription: {
+ id: 'LocaleSwitcher.dialogLocaleDescription',
+ description: 'The description for the data locale dialog',
+ defaultMessage:
+ "The selected data locale will serve as the default setting for all localized fields within the Merchant Center, including names, descriptions, and other localized attributes.
It's important to note that this selection does not affect the interface language of the Merchant Center or any data formatting options. To modify these settings, navigate to your user profile.",
},
});
diff --git a/packages/application-shell/src/components/project-switcher/messages.ts b/packages/application-shell/src/components/project-switcher/messages.ts
index 4a884e3219..7a2d696b85 100644
--- a/packages/application-shell/src/components/project-switcher/messages.ts
+++ b/packages/application-shell/src/components/project-switcher/messages.ts
@@ -4,7 +4,7 @@ export default defineMessages({
projectsLabel: {
id: 'ProjectSwitcher.projectsLabel',
description: 'The label for project dropdown switcher',
- defaultMessage: 'Projects menu',
+ defaultMessage: 'Projects',
},
searchPlaceholder: {
id: 'ProjectSwitcher.searchPlaceholder',
diff --git a/packages/application-shell/src/components/project-switcher/project-switcher-test-utils.ts b/packages/application-shell/src/components/project-switcher/project-switcher-test-utils.ts
index 102f0f41b2..9cc1ff36db 100644
--- a/packages/application-shell/src/components/project-switcher/project-switcher-test-utils.ts
+++ b/packages/application-shell/src/components/project-switcher/project-switcher-test-utils.ts
@@ -5,6 +5,7 @@ type CreateGraphqlResponseForProjectsQueryOptions = {
numberOfProjects?: number;
getIsSuspended?: (key: string) => boolean;
getIsExpired?: (key: string) => boolean;
+ getIsProduction?: (key: string) => boolean;
};
const falsy = () => false;
@@ -13,6 +14,7 @@ export const createGraphqlResponseForProjectsQuery = ({
numberOfProjects = 4,
getIsSuspended = falsy,
getIsExpired = falsy,
+ getIsProduction = falsy,
}: CreateGraphqlResponseForProjectsQueryOptions = {}) => ({
request: {
query: ProjectsQuery,
@@ -42,7 +44,7 @@ export const createGraphqlResponseForProjectsQuery = ({
__typename: 'ProjectExpiry',
isActive: getIsExpired(key),
},
- isProductionProject: false,
+ isProductionProject: getIsProduction(key),
};
}),
},
diff --git a/packages/application-shell/src/components/project-switcher/project-switcher.spec.tsx b/packages/application-shell/src/components/project-switcher/project-switcher.spec.tsx
index 21ac844ae8..7a41ece234 100644
--- a/packages/application-shell/src/components/project-switcher/project-switcher.spec.tsx
+++ b/packages/application-shell/src/components/project-switcher/project-switcher.spec.tsx
@@ -1,5 +1,11 @@
import { mocked } from 'jest-mock';
-import { screen, renderApp, fireEvent, waitFor } from '../../test-utils';
+import {
+ screen,
+ renderApp,
+ fireEvent,
+ waitFor,
+ within,
+} from '../../test-utils';
import { location } from '../../utils/location';
import ProjectSwitcher from './project-switcher';
import { createGraphqlResponseForProjectsQuery } from './project-switcher-test-utils';
@@ -9,7 +15,8 @@ jest.mock('../../utils/location');
const render = () => {
const mockedRequest = [
createGraphqlResponseForProjectsQuery({
- getIsSuspended: (key) => key === 'key-2',
+ getIsProduction: (key) => key === 'key-1' || key === 'key-3',
+ getIsSuspended: (key) => key === 'key-2' || key === 'key-3',
getIsExpired: (key) => key === 'key-3',
}),
];
@@ -19,6 +26,19 @@ const render = () => {
});
};
+function verifyProjectOptionStamps(
+ projectOption: HTMLElement,
+ expectedText: string[] | null[]
+) {
+ const stamps = within(projectOption).queryAllByText(
+ /Production|Suspended|Trial expired/i
+ );
+
+ stamps.forEach((stamp, index) => {
+ expect(stamp.textContent).toEqual(expectedText[index]);
+ });
+}
+
describe('rendering', () => {
beforeEach(() => {
mocked(location.replace).mockClear();
@@ -26,7 +46,7 @@ describe('rendering', () => {
it('should search and select a project', async () => {
render();
- const input = await screen.findByLabelText('Projects menu');
+ const input = await screen.findByLabelText('Projects');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'key-1' } });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, which: 13 });
@@ -37,7 +57,7 @@ describe('rendering', () => {
});
it('should see no results message when search does not match any project', async () => {
render();
- const input = await screen.findByLabelText('Projects menu');
+ const input = await screen.findByLabelText('Projects');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'not existing' } });
@@ -47,10 +67,10 @@ describe('rendering', () => {
});
it('should prevent clicking on a suspended project', async () => {
render();
- const input = await screen.findByLabelText('Projects menu');
+ const input = await screen.findByLabelText('Projects');
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
- fireEvent.click(screen.getByText(/Suspended/i));
+ fireEvent.click((await screen.findAllByText(/Suspended/i))[0]);
await waitFor(() =>
expect(mocked(location.replace)).not.toHaveBeenCalled()
@@ -58,7 +78,7 @@ describe('rendering', () => {
});
it('should prevent clicking on an expired project', async () => {
render();
- const input = await screen.findByLabelText('Projects menu');
+ const input = await screen.findByLabelText('Projects');
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.click(screen.getByText(/Expired/i));
@@ -67,4 +87,25 @@ describe('rendering', () => {
expect(mocked(location.replace)).not.toHaveBeenCalled()
);
});
+ it('should render the expected stamps for each project', async () => {
+ render();
+ const input = await screen.findByLabelText('Projects');
+ fireEvent.focus(input);
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+
+ const swticherProjectsOptions = await screen.findAllByRole('option');
+
+ // We define which stamps are expected for each switcher option
+ // The stamps are ordered so we can also verified they're rendered in the correct order
+ const expectedOrderedStamps = [
+ [null], // First option has no stamps
+ ['Production'], // Second option has only one "Production" stamp
+ ['Suspended'], // Third option has only one "Suspended" stamp
+ ['Production', 'Suspended', 'Trial expired'], // Fourth option has all three stamps
+ ];
+
+ swticherProjectsOptions.forEach((projectOption, index) => {
+ verifyProjectOptionStamps(projectOption, expectedOrderedStamps[index]);
+ });
+ });
});
diff --git a/packages/application-shell/src/components/project-switcher/project-switcher.tsx b/packages/application-shell/src/components/project-switcher/project-switcher.tsx
index 046b228ff0..592022f69a 100644
--- a/packages/application-shell/src/components/project-switcher/project-switcher.tsx
+++ b/packages/application-shell/src/components/project-switcher/project-switcher.tsx
@@ -8,6 +8,7 @@ import type {
ControlProps,
} from 'react-select';
import { components } from 'react-select';
+import { ProjectStamp } from '@commercetools-frontend/application-components';
import {
useMcQuery,
oidcStorage,
@@ -17,8 +18,9 @@ import { GRAPHQL_TARGETS } from '@commercetools-frontend/constants';
import { reportErrorToSentry } from '@commercetools-frontend/sentry';
import AccessibleHidden from '@commercetools-uikit/accessible-hidden';
import { designTokens } from '@commercetools-uikit/design-system';
-import { ErrorIcon } from '@commercetools-uikit/icons';
import SelectInput from '@commercetools-uikit/select-input';
+import Spacings from '@commercetools-uikit/spacings';
+import Text from '@commercetools-uikit/text';
import type {
TProject,
TFetchUserProjectsQuery,
@@ -42,110 +44,89 @@ type OptionType = Pick<
const PROJECT_SWITCHER_LABEL_ID = 'project-switcher-label';
-export const ValueContainer = ({ ...restProps }: ValueContainerProps) => {
+export const ValueContainer = ({
+ children,
+ ...restProps
+}: ValueContainerProps) => {
return (
-
-
-
- {restProps.children}
-
-
-
+
+
+ {children}
+
+
);
};
+type TProjectStampsListProps = Pick<
+ TProject,
+ 'isProductionProject' | 'suspension' | 'expiry'
+>;
+const ProjectStampsList = (props: TProjectStampsListProps) => (
+
+ {props.isProductionProject && }
+ {props.suspension && props.suspension.isActive && (
+
+ )}
+ {props.expiry && props.expiry.isActive && }
+ {props.expiry && Boolean(props.expiry.daysLeft) && (
+
+ )}
+
+);
+
export const ProjectSwitcherOption = (props: OptionProps) => {
const project = props.data as OptionType;
+
return (
-
+
- {project.name}
- {props.isDisabled && (
-
-
-
- )}
-
-
- {project.key}
-
- {project.suspension && project.suspension.isActive && (
-
-
-
- )}
- {project.expiry && project.expiry.isActive && (
-
-
-
- )}
-
+ {project.name}
+
+
+ {project.key}
+
+
+
+