diff --git a/packages/manager/apps/ips/README.md b/packages/manager/apps/ips/README.md new file mode 100644 index 000000000000..616579cd8100 --- /dev/null +++ b/packages/manager/apps/ips/README.md @@ -0,0 +1,3 @@ +# @ovh-ux/manager-ips-app + +> ips manager app diff --git a/packages/manager/apps/ips/cucumber.js b/packages/manager/apps/ips/cucumber.js new file mode 100644 index 000000000000..8e6abbfbca8f --- /dev/null +++ b/packages/manager/apps/ips/cucumber.js @@ -0,0 +1,20 @@ +const isCI = process.env.CI; + +module.exports = { + default: { + paths: ['e2e/features/**/*.feature'], + require: [ + '../../../../playwright-helpers/bdd-setup.ts', + 'e2e/**/*.step.ts', + ], + requireModule: ['ts-node/register'], + format: [ + 'summary', + isCI ? 'progress' : 'progress-bar', + !isCI && ['html', 'e2e/reports/cucumber-results-report.html'], + !isCI && ['usage-json', 'e2e/reports/cucumber-usage-report.json'], + ].filter(Boolean), + formatOptions: { snippetInterface: 'async-await' }, + retry: 1, + }, +}; diff --git a/packages/manager/apps/ips/e2e/features/error.feature b/packages/manager/apps/ips/e2e/features/error.feature new file mode 100644 index 000000000000..e20e56f5f592 --- /dev/null +++ b/packages/manager/apps/ips/e2e/features/error.feature @@ -0,0 +1,12 @@ +Feature: Error + + Scenario Outline: Display an error if request fails + Given The service to fetch the data is + When User navigates to Home page + Then User "" the list of data + Then User sees error + + Examples: + | apiOk | sees | anyError | + | OK | sees | no | + | KO | doesn't see | an | diff --git a/packages/manager/apps/ips/e2e/features/onboarding.feature b/packages/manager/apps/ips/e2e/features/onboarding.feature new file mode 100644 index 000000000000..6f9786c696be --- /dev/null +++ b/packages/manager/apps/ips/e2e/features/onboarding.feature @@ -0,0 +1,7 @@ +Feature: Onboarding page + + Scenario: User wants to find informations related to ips + Given User has 0 elements in the Listing page + When User navigates to Listing page + Then User gets redirected to Onboarding page + Then User sees 3 guides diff --git a/packages/manager/apps/ips/e2e/step-definitions/error.step.ts b/packages/manager/apps/ips/e2e/step-definitions/error.step.ts new file mode 100644 index 000000000000..fcbf5d4b453a --- /dev/null +++ b/packages/manager/apps/ips/e2e/step-definitions/error.step.ts @@ -0,0 +1,53 @@ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; +import { ICustomWorld } from '../../../../../../playwright-helpers'; +import { ConfigParams, getUrl, setupNetwork } from '../utils'; +import { title } from '../../public/translations/listing/Messages_fr_FR.json'; +import { + manager_error_page_title, + manager_error_page_action_home_label, + manager_error_page_action_reload_label, +} from '../../public/translations/ips/error/Messages_fr_FR.json'; + +Given('The service to fetch the data is {word}', function( + this: ICustomWorld, + apiState: 'OK' | 'KO', +) { + this.handlersConfig.isKo = apiState === 'KO'; +}); + +When('User navigates to Home page', async function( + this: ICustomWorld, +) { + await setupNetwork(this); + await this.page.goto(this.testContext.initialUrl || getUrl('root'), { + waitUntil: 'load', + }); +}); + +Then('User {string} the list of data', async function( + this: ICustomWorld, + see: 'sees' | "doesn't see", +) { + if (see === 'sees') { + const titleElement = await this.page.getByText(title); + await expect(titleElement).toBeVisible(); + } +}); + +Then('User sees {word} error', async function( + this: ICustomWorld, + anyError: 'an' | 'no', +) { + if (anyError === 'an') { + await expect(this.page.getByText(manager_error_page_title)).toBeVisible(); + + await expect( + this.page.getByText(manager_error_page_action_home_label), + ).toBeVisible(); + + await expect( + this.page.getByText(manager_error_page_action_reload_label), + ).toBeVisible(); + } +}); diff --git a/packages/manager/apps/ips/e2e/step-definitions/onboarding.step.ts b/packages/manager/apps/ips/e2e/step-definitions/onboarding.step.ts new file mode 100644 index 000000000000..67b20b6e52c8 --- /dev/null +++ b/packages/manager/apps/ips/e2e/step-definitions/onboarding.step.ts @@ -0,0 +1,32 @@ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; +import { ICustomWorld } from '../../../../../../playwright-helpers'; +import { ConfigParams, getUrl, setupNetwork } from '../utils'; + +Given('User has {int} elements in the Listing page', function( + this: ICustomWorld, + nb: number, +) { + this.handlersConfig.nb = nb; +}); + +When('User navigates to Listing page', async function( + this: ICustomWorld, +) { + await setupNetwork(this); + await this.page.goto(getUrl('listing'), { waitUntil: 'load' }); +}); + +Then('User gets redirected to Onboarding page', async function( + this: ICustomWorld, +) { + await expect(this.page).toHaveURL(getUrl('onboarding')); +}); + +Then('User sees {int} guides', async function( + this: ICustomWorld, + nbGuides: number, +) { + const guides = await this.page.locator('osds-tile'); + await expect(guides).toHaveCount(nbGuides); +}); diff --git a/packages/manager/apps/ips/e2e/utils/constants.ts b/packages/manager/apps/ips/e2e/utils/constants.ts new file mode 100644 index 000000000000..866e6dadabe9 --- /dev/null +++ b/packages/manager/apps/ips/e2e/utils/constants.ts @@ -0,0 +1,7 @@ +import { urls } from '../../src/routes/routes.constant'; + +export const appUrl = 'http://localhost:9001/app'; + +export type AppRoute = keyof typeof urls; + +export const getUrl = (route: AppRoute) => `${appUrl}/#${urls[route]}`; diff --git a/packages/manager/apps/ips/e2e/utils/index.tsx b/packages/manager/apps/ips/e2e/utils/index.tsx new file mode 100644 index 000000000000..24a453c58aa9 --- /dev/null +++ b/packages/manager/apps/ips/e2e/utils/index.tsx @@ -0,0 +1,2 @@ +export * from './network'; +export * from './constants'; diff --git a/packages/manager/apps/ips/e2e/utils/network.ts b/packages/manager/apps/ips/e2e/utils/network.ts new file mode 100644 index 000000000000..46d894b9a08e --- /dev/null +++ b/packages/manager/apps/ips/e2e/utils/network.ts @@ -0,0 +1,28 @@ +import { BrowserContext } from '@playwright/test'; +import { + ICustomWorld, + toPlaywrightMockHandler, + Handler, +} from '../../../../../../playwright-helpers'; +import { + GetAuthenticationMocks, + getAuthenticationMocks, +} from '../../../../../../playwright-helpers/mocks/auth'; +import { getExampleMocks, GetExampleMocksParams } from '../../mocks'; + +export type ConfigParams = GetAuthenticationMocks & GetExampleMocksParams; + +export const getConfig = (params: ConfigParams): Handler[] => + [getAuthenticationMocks, getExampleMocks].flatMap((getMocks) => + getMocks(params), + ); + +export const setupNetwork = async (world: ICustomWorld) => + Promise.all( + getConfig({ + ...((world?.handlersConfig as ConfigParams) || ({} as ConfigParams)), + isAuthMocked: true, + }) + .reverse() + .map(toPlaywrightMockHandler(world.context as BrowserContext)), + ); diff --git a/packages/manager/apps/ips/index.html b/packages/manager/apps/ips/index.html new file mode 100644 index 000000000000..ec4e92d05007 --- /dev/null +++ b/packages/manager/apps/ips/index.html @@ -0,0 +1,22 @@ + + + + + + + + OVHcloud + + + + + +
+ + + diff --git a/packages/manager/apps/ips/mocks/example/example-data.json b/packages/manager/apps/ips/mocks/example/example-data.json new file mode 100644 index 000000000000..16b09c4e47fd --- /dev/null +++ b/packages/manager/apps/ips/mocks/example/example-data.json @@ -0,0 +1,11 @@ +[ + { + "id": 20374 + }, + { + "id": 20375 + }, + { + "id": 20379 + } +] diff --git a/packages/manager/apps/ips/mocks/example/example.ts b/packages/manager/apps/ips/mocks/example/example.ts new file mode 100644 index 000000000000..77c033e7b392 --- /dev/null +++ b/packages/manager/apps/ips/mocks/example/example.ts @@ -0,0 +1,30 @@ +import { Handler } from '../../../../../../playwright-helpers'; +import exampleList from './example-data.json'; + +export type GetExampleMocksParams = { isKo?: boolean; nb?: number }; + +export const getExampleMocks = ({ + isKo, + nb = Number.POSITIVE_INFINITY, +}: GetExampleMocksParams): Handler[] => [ + { + url: '*', + response: isKo + ? { + message: 'Example error', + } + : exampleList.slice(0, nb), + status: isKo ? 500 : 200, + api: 'v6', + }, + { + url: '*', + response: isKo + ? { + message: 'Example error', + } + : exampleList.slice(0, nb), + status: isKo ? 500 : 200, + api: 'v2', + }, +]; diff --git a/packages/manager/apps/ips/mocks/index.ts b/packages/manager/apps/ips/mocks/index.ts new file mode 100644 index 000000000000..4356d0ac05ac --- /dev/null +++ b/packages/manager/apps/ips/mocks/index.ts @@ -0,0 +1 @@ +export * from './example/example'; diff --git a/packages/manager/apps/ips/package.json b/packages/manager/apps/ips/package.json new file mode 100644 index 000000000000..4dcb9a8e00df --- /dev/null +++ b/packages/manager/apps/ips/package.json @@ -0,0 +1,59 @@ +{ + "name": "@ovh-ux/manager-ips-app", + "version": "0.0.0", + "private": true, + "description": "ips manager app", + "repository": { + "type": "git", + "url": "git+https://github.com/ovh/manager.git", + "directory": "packages/manager/apps/ips" + }, + "license": "BSD-3-Clause", + "author": "OVH SAS", + "scripts": { + "build": "tsc && vite build", + "dev": "tsc && vite", + "start": "lerna exec --stream --scope='@ovh-ux/manager-ips-app' --include-dependencies -- npm run build --if-present", + "start:dev": "lerna exec --stream --scope='@ovh-ux/manager-ips-app' --include-dependencies -- npm run dev --if-present", + "start:watch": "lerna exec --stream --parallel --scope='@ovh-ux/manager-ips-app' --include-dependencies -- npm run dev:watch --if-present", + "test:e2e": "tsc && node ../../../../scripts/run-playwright-bdd.js", + "test:e2e:ci": "tsc && node ../../../../scripts/run-playwright-bdd.js --ci" + }, + "dependencies": { + "@ovh-ux/manager-config": "*", + "@ovh-ux/manager-core-api": "*", + "@ovh-ux/manager-core-utils": "*", + "@ovh-ux/manager-react-components": "*", + "@ovh-ux/manager-react-core-application": "*", + "@ovh-ux/manager-react-shell-client": "*", + "@ovh-ux/manager-tailwind-config": "*", + "@ovh-ux/request-tagger": "*", + "@ovhcloud/ods-common-core": "17.2.1", + "@ovhcloud/ods-common-theming": "17.2.1", + "@ovhcloud/ods-components": "17.2.1", + "@ovhcloud/ods-theme-blue-jeans": "17.2.1", + "@tanstack/react-query": "^5.51.21", + "@tanstack/react-query-devtools": "^5.51.21", + "axios": "^1.1.2", + "clsx": "^1.2.1", + "i18next": "^23.8.2", + "i18next-http-backend": "^2.4.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^14.0.5", + "react-router-dom": "^6.3.0", + "tailwindcss": "^3.4.4" + }, + "devDependencies": { + "@cucumber/cucumber": "^10.3.1", + "@ovh-ux/manager-vite-config": "*", + "@playwright/test": "^1.41.2", + "typescript": "^5.1.6", + "vite": "^5.2.13" + }, + "regions": [ + "CA", + "EU", + "US" + ] +} diff --git a/packages/manager/apps/ips/playwright.config.ts b/packages/manager/apps/ips/playwright.config.ts new file mode 100644 index 000000000000..feb249bcbe3f --- /dev/null +++ b/packages/manager/apps/ips/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + workers: 3, + fullyParallel: false, + timeout: 30 * 1000, + reporter: [['html', { open: 'on-failure' }]], + expect: { + timeout: 20000, + }, + use: { + // Collect trace when retrying the failed test. + trace: 'retain-on-failure', + }, + testMatch: '**/*.e2e.ts', + webServer: { + command: 'yarn run dev', + url: 'http://localhost:9000/', + }, +}); diff --git a/packages/manager/apps/ips/postcss.config.js b/packages/manager/apps/ips/postcss.config.js new file mode 100644 index 000000000000..12a703d900da --- /dev/null +++ b/packages/manager/apps/ips/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/manager/apps/ips/public/translations/dashboard/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/dashboard/Messages_fr_FR.json new file mode 100644 index 000000000000..f42a27b3366a --- /dev/null +++ b/packages/manager/apps/ips/public/translations/dashboard/Messages_fr_FR.json @@ -0,0 +1,7 @@ +{ + "title": "Dashboard page", + "error_service": "No services info", + "general_informations": "Informations générales", + "tab2": "Tab 2", + "back_link": "Retour à la liste" +} diff --git a/packages/manager/apps/ips/public/translations/ips/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/ips/Messages_fr_FR.json new file mode 100644 index 000000000000..c5bf7278da50 --- /dev/null +++ b/packages/manager/apps/ips/public/translations/ips/Messages_fr_FR.json @@ -0,0 +1,6 @@ +{ + "title": "Bienvenue uapp", + "crumb": "ips", + "tabs_2": "Tabs 2", + "onboarding": "Onboarding" +} diff --git a/packages/manager/apps/ips/public/translations/ips/error/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/ips/error/Messages_fr_FR.json new file mode 100644 index 000000000000..2c575c63588e --- /dev/null +++ b/packages/manager/apps/ips/public/translations/ips/error/Messages_fr_FR.json @@ -0,0 +1,8 @@ +{ + "manager_error_page_title": "Oops …!", + "manager_error_page_button_cancel": "Annuler", + "manager_error_page_detail_code": "Code d'erreur : ", + "manager_error_page_action_reload_label": "Réessayer", + "manager_error_page_action_home_label": "Retour à la page d'accueil", + "manager_error_page_default": "Une erreur est survenue lors du chargement de la page." +} diff --git a/packages/manager/apps/ips/public/translations/listing/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/listing/Messages_fr_FR.json new file mode 100644 index 000000000000..c882bc92cfec --- /dev/null +++ b/packages/manager/apps/ips/public/translations/listing/Messages_fr_FR.json @@ -0,0 +1,4 @@ +{ + "title": "Listing page", + "listing_resultats": "résultats" +} diff --git a/packages/manager/apps/ips/public/translations/onboarding/Messages_fr_FR.json b/packages/manager/apps/ips/public/translations/onboarding/Messages_fr_FR.json new file mode 100644 index 000000000000..2866b83cbef9 --- /dev/null +++ b/packages/manager/apps/ips/public/translations/onboarding/Messages_fr_FR.json @@ -0,0 +1,13 @@ +{ + "title": "ips", + "description": "Découvrez des services de stockage managés qui s’appuient sur le système de fichiers OpenZFS. Bénéficiez en quelques clics d’espaces de stockage centralisés pour entreposer ou sauvegarder vos données et fichiers.", + "orderButtonLabel": "Commander un ips", + "moreInfoButtonLabel": "En savoir plus sur ips", + "guideCategory": "Tutoriel", + "guide1Title": "Premiers pas avec un ips", + "guide1Description": "Découvrez comment gérer un NAS-HA depuis l'espace-client OVHcloud", + "guide2Title": "Monter votre NAS via un partage NFS", + "guide2Description": "Découvrez comment monter un NAS via un partage NFS", + "guide3Title": "Monter votre NAS sur Windows Server via CIFS", + "guide3Description": "Découvrez comment monter un NAS sur Windows Server via le protocole CIFS" +} diff --git a/packages/manager/apps/ips/src/App.tsx b/packages/manager/apps/ips/src/App.tsx new file mode 100644 index 000000000000..9bedb39c2cae --- /dev/null +++ b/packages/manager/apps/ips/src/App.tsx @@ -0,0 +1,35 @@ +import React, { useEffect, useContext } from 'react'; +import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { odsSetup } from '@ovhcloud/ods-common-core'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { RouterProvider, createHashRouter } from 'react-router-dom'; +import { Routes } from './routes/routes'; + +odsSetup(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 300_000, + }, + }, +}); + +function App() { + const { shell } = useContext(ShellContext); + const router = createHashRouter(Routes); + + useEffect(() => { + shell.ux.hidePreloader(); + }, []); + + return ( + + + + + ); +} + +export default App; diff --git a/packages/manager/apps/ips/src/assets/error-banner-oops.png b/packages/manager/apps/ips/src/assets/error-banner-oops.png new file mode 100644 index 000000000000..413028afad19 Binary files /dev/null and b/packages/manager/apps/ips/src/assets/error-banner-oops.png differ diff --git a/packages/manager/apps/ips/src/components/Breadcrumb/Breadcrumb.tsx b/packages/manager/apps/ips/src/components/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 000000000000..651cb135b490 --- /dev/null +++ b/packages/manager/apps/ips/src/components/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { OsdsBreadcrumb } from '@ovhcloud/ods-components/react'; +import { + useBreadcrumb, + BreadcrumbItem, +} from '@/hooks/breadcrumb/useBreadcrumb'; +import appConfig from '@/ips.config'; + +export interface BreadcrumbProps { + customRootLabel?: string; + appName?: string; + items?: BreadcrumbItem[]; +} + +function Breadcrumb({ customRootLabel }: BreadcrumbProps): JSX.Element { + const label = customRootLabel || appConfig.rootLabel; + + const breadcrumbItems = useBreadcrumb({ + rootLabel: label, + appName: 'ips', + }); + return ; +} + +export default Breadcrumb; diff --git a/packages/manager/apps/ips/src/components/Error/Error.scss b/packages/manager/apps/ips/src/components/Error/Error.scss new file mode 100644 index 000000000000..c73220cd3be3 --- /dev/null +++ b/packages/manager/apps/ips/src/components/Error/Error.scss @@ -0,0 +1,18 @@ +.manager-error-page { + margin-left: auto; + margin-right: auto; + max-width: 600px; + width: 100%; + display: grid; + height: 100%; + overflow: hidden; + .manager-error-page-image { + img { + width: 100%; + } + } + .manager-error-page-footer { + text-align: right; + overflow: hidden; + } +} diff --git a/packages/manager/apps/ips/src/components/Error/Error.tsx b/packages/manager/apps/ips/src/components/Error/Error.tsx new file mode 100644 index 000000000000..66f2a411a7fa --- /dev/null +++ b/packages/manager/apps/ips/src/components/Error/Error.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; +import { + ErrorMessage, + TRACKING_LABELS, +} from '@ovh-ux/manager-react-components/src/components/'; +import { ErrorBanner } from '@ovh-ux/manager-react-components'; + +interface ErrorObject { + [key: string]: any; +} + +function getTrackingTypology(error: ErrorMessage) { + if (error?.detail?.status && Math.floor(error.detail.status / 100) === 4) { + return [401, 403].includes(error.detail.status) + ? TRACKING_LABELS.UNAUTHORIZED + : TRACKING_LABELS.SERVICE_NOT_FOUND; + } + return TRACKING_LABELS.PAGE_LOAD; +} + +const Errors: React.FC = ({ error }) => { + const navigate = useNavigate(); + const location = useLocation(); + const { shell } = React.useContext(ShellContext); + const { tracking, environment } = shell; + const env = environment.getEnvironment(); + + React.useEffect(() => { + env.then((response) => { + const { applicationName } = response; + const name = `errors::${getTrackingTypology(error)}::${applicationName}`; + tracking.trackPage({ + name, + level2: '81', + type: 'navigation', + page_category: location.pathname, + }); + }); + }, []); + + return ( + navigate(location.pathname, { replace: true })} + onRedirectHome={() => navigate('/', { replace: true })} + /> + ); +}; + +export default Errors; diff --git a/packages/manager/apps/ips/src/components/Loading/Loading.tsx b/packages/manager/apps/ips/src/components/Loading/Loading.tsx new file mode 100644 index 000000000000..1f8e45b3d2ed --- /dev/null +++ b/packages/manager/apps/ips/src/components/Loading/Loading.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { OsdsSpinner } from '@ovhcloud/ods-components/react'; + +export default function Loading() { + return ( +
+
+ +
+
+ ); +} diff --git a/packages/manager/apps/ips/src/data/api/ips.ts b/packages/manager/apps/ips/src/data/api/ips.ts new file mode 100644 index 000000000000..d35d1e2ebab0 --- /dev/null +++ b/packages/manager/apps/ips/src/data/api/ips.ts @@ -0,0 +1,64 @@ +import { fetchIcebergV6, apiClient } from '@ovh-ux/manager-core-api'; + +export type GetipListParams = { + /** Filter the value of campus property (ilike) (alpha) */ + campus: any; + /** Filter the value of description property (like) */ + description: any; + /** Filter resources on IAM tags */ + iamTags: any; + /** Filter the value of ip property (contains or equals) */ + ip: any; + /** Filter the value of isAdditionalIp property (=) (alpha) */ + isAdditionalIp: any; + /** Filter the value of routedTo.serviceName property (like) */ + routedToserviceName: any; + /** Filter the value of type property (=) */ + type: any; + /** Filter the value of version property (=) (alpha) */ + version: any; +}; + +export const getipListQueryKey = ['get/ip']; + +/** + * List the ip.Ip objects : Your OVH IPs + */ +export const getipList = async (params: GetipListParams): Promise => + apiClient.v6.get('/ip', { data: params }); + +export type GetipIpParams = { + /** */ + ip?: any; +}; + +export const getipIpQueryKey = (params: GetipIpParams) => [ + `get/ip/${params.ip}`, +]; + +/** + * Your IP : Get this object properties + */ +export const getipIp = async (params: GetipIpParams): Promise => + apiClient.v6.get(`/ip/${params.ip}`); + +/** + * Get listing with iceberg V6 + */ +export const getListingIcebergV6 = async ({ + pageSize, + page, +}: { + pageSize: number; + page: number; +}) => { + const { data, status, totalCount } = await fetchIcebergV6({ + route: `/ip`, + pageSize, + page, + }); + if (status > 400) { + throw new Error(); + } + return { data, status, totalCount }; +}; diff --git a/packages/manager/apps/ips/src/hooks/breadcrumb/useBreadcrumb.tsx b/packages/manager/apps/ips/src/hooks/breadcrumb/useBreadcrumb.tsx new file mode 100644 index 000000000000..d61852b056a4 --- /dev/null +++ b/packages/manager/apps/ips/src/hooks/breadcrumb/useBreadcrumb.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState, useContext } from 'react'; +import { useLocation } from 'react-router-dom'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; + +export type BreadcrumbItem = { + label: string | undefined; + href?: string; +}; + +export interface BreadcrumbProps { + rootLabel?: string; + appName?: string; + projectId?: string; + items?: BreadcrumbItem[]; +} + +export const useBreadcrumb = ({ rootLabel, appName }: BreadcrumbProps) => { + const { shell } = useContext(ShellContext); + const [root, setRoot] = useState([]); + const [paths, setPaths] = useState([]); + const location = useLocation(); + const pathnames = location.pathname.split('/').filter((x) => x); + + useEffect(() => { + const fetchRoot = async () => { + try { + const response = await shell?.navigation.getURL(appName, '#/', {}); + const rootItem = { + label: rootLabel, + href: String(response), + }; + setRoot([rootItem]); + } catch { + // Fetch navigation error + } + }; + fetchRoot(); + }, [rootLabel, appName, shell?.navigation]); + + useEffect(() => { + const pathsTab = pathnames.map((value) => ({ + label: value, + href: `/#/${appName}/${value}`, + })); + setPaths(pathsTab); + }, [location]); + + return [...root, ...paths]; +}; diff --git a/packages/manager/apps/ips/src/hooks/guide/useGuideUtils.tsx b/packages/manager/apps/ips/src/hooks/guide/useGuideUtils.tsx new file mode 100644 index 000000000000..2971e624318e --- /dev/null +++ b/packages/manager/apps/ips/src/hooks/guide/useGuideUtils.tsx @@ -0,0 +1,100 @@ +import { useContext, useEffect, useState } from 'react'; +import { CountryCode } from '@ovh-ux/manager-config'; +import { ShellContext } from '@ovh-ux/manager-react-shell-client'; + +const docUrl = 'https://docs.ovh.com'; + +type GuideLinks = { [key in CountryCode]: string }; + +const GUIDE_LIST: { [guideName: string]: Partial } = { + guideLink1: { + DE: '/update-path', + ES: '/update-path', + IE: '/en/update-path', + IT: '/update-path', + PL: '/update-path', + PT: '/update-path', + FR: '/update-path', + GB: '/update-path', + CA: '/update-path', + QC: '/update-path', + WE: '/update-path', + WS: '/update-path', + US: '/update-path', + }, + guideLink2: { + DE: '/guide-link-2-path', + ES: '/guide-link-2-path', + IE: '/en/guide-link-2-path', + IT: '/guide-link-2-path', + PL: '/guide-link-2-path', + PT: '/guide-link-2-path', + FR: '/guide-link-2-path', + GB: '/guide-link-2-path', + CA: '/update-path', + QC: '/update-path', + WE: '/update-path', + WS: '/update-path', + US: '/update-path', + }, + guideLink3: { + DE: '/guide-link-3-path', + ES: '/guide-link-3-path', + IE: '/en/guide-link-3-path', + IT: '/guide-link-3-path', + PL: '/guide-link-3-path', + PT: '/guide-link-3-path', + FR: '/guide-link-3-path', + GB: '/guide-link-3-path', + CA: '/update-path', + QC: '/update-path', + WE: '/update-path', + WS: '/update-path', + US: '/update-path', + }, + /* + addNewGuideLink : { + DEFAULT: '/guide-link-3-path', + DE: '/guide-link-3-path', + ES: '/guide-link-3-path', + ... + } + */ +}; + +type GetGuideLinkProps = { + name?: string; + subsidiary: CountryCode | string; +}; + +function getGuideListLink({ subsidiary }: GetGuideLinkProps) { + const list: { [guideName: string]: string } = {}; + const keys = Object.entries(GUIDE_LIST); + keys.forEach((key) => { + list[key[0]] = docUrl + GUIDE_LIST[key[0]][subsidiary as CountryCode]; + }); + return list; +} + +interface GuideLinkProps { + [guideName: string]: string; +} + +function useGuideUtils() { + const { shell } = useContext(ShellContext); + const { environment } = shell; + const [list, setList] = useState({}); + + useEffect(() => { + const getSubSidiary = async () => { + const env = await environment.getEnvironment(); + const { ovhSubsidiary } = env.getUser(); + const guideList = getGuideListLink({ subsidiary: ovhSubsidiary }); + setList(guideList); + }; + getSubSidiary(); + }, []); + return list as GuideLinkProps; +} + +export default useGuideUtils; diff --git a/packages/manager/apps/ips/src/index.scss b/packages/manager/apps/ips/src/index.scss new file mode 100644 index 000000000000..65dd5f63a7df --- /dev/null +++ b/packages/manager/apps/ips/src/index.scss @@ -0,0 +1 @@ +@tailwind utilities; diff --git a/packages/manager/apps/ips/src/index.tsx b/packages/manager/apps/ips/src/index.tsx new file mode 100644 index 000000000000..d2e5dc36fa6c --- /dev/null +++ b/packages/manager/apps/ips/src/index.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + ShellContext, + initShellContext, + initI18n, +} from '@ovh-ux/manager-react-shell-client'; +import App from './App'; +import '@ovhcloud/ods-theme-blue-jeans/dist/index.css'; +import './index.scss'; +import './vite-hmr'; + +import { UNIVERSE, SUB_UNIVERSE, APP_NAME, LEVEL2 } from './tracking.constant'; + +const trackingContext = { + chapter1: UNIVERSE, + chapter2: SUB_UNIVERSE, + chapter3: APP_NAME, + appName: APP_NAME, + pageTheme: UNIVERSE, + level2Config: LEVEL2, +}; + +const init = async (appName: string) => { + const context = await initShellContext(appName, trackingContext); + + await initI18n({ + context, + reloadOnLocaleChange: true, + defaultNS: appName, + ns: ['listing', 'dashboard', 'onboarding'], + }); + + const region = context.environment.getRegion(); + context.shell.tracking.setConfig(region, LEVEL2); + try { + await import(`./config-${region}.js`); + } catch (error) { + // nothing to do + } + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , + ); +}; + +init('ips'); diff --git a/packages/manager/apps/ips/src/ips.config.ts b/packages/manager/apps/ips/src/ips.config.ts new file mode 100644 index 000000000000..1bdac937e1dd --- /dev/null +++ b/packages/manager/apps/ips/src/ips.config.ts @@ -0,0 +1,8 @@ +export default { + listing: { + datagrid: { + serviceKey: 'ip', + }, + }, + rootLabel: 'ips', +}; diff --git a/packages/manager/apps/ips/src/pages/404.tsx b/packages/manager/apps/ips/src/pages/404.tsx new file mode 100644 index 000000000000..d052f1ebcbbf --- /dev/null +++ b/packages/manager/apps/ips/src/pages/404.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export default function NotFound() { + // @TODO: add a redirection here in order to catch /:serviceName given from iframe + + return

404 - route not found

; +} diff --git a/packages/manager/apps/ips/src/pages/index.tsx b/packages/manager/apps/ips/src/pages/index.tsx new file mode 100644 index 000000000000..2b1fec26d6fe --- /dev/null +++ b/packages/manager/apps/ips/src/pages/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +export default function Ips() { + const { t } = useTranslation('ips'); + + return ( +
+

{t('title')}

+
Start your application
+
+ ); +} diff --git a/packages/manager/apps/ips/src/pages/layout.tsx b/packages/manager/apps/ips/src/pages/layout.tsx new file mode 100644 index 000000000000..81ae31937de8 --- /dev/null +++ b/packages/manager/apps/ips/src/pages/layout.tsx @@ -0,0 +1,31 @@ +import React, { useEffect, useContext } from 'react'; +import { defineCurrentPage } from '@ovh-ux/request-tagger'; +import { Outlet, useLocation, useMatches } from 'react-router-dom'; +import { + useOvhTracking, + useRouteSynchro, + ShellContext, +} from '@ovh-ux/manager-react-shell-client'; + +export default function Layout() { + const location = useLocation(); + const { shell } = useContext(ShellContext); + const matches = useMatches(); + const { trackCurrentPage } = useOvhTracking(); + useRouteSynchro(); + + useEffect(() => { + const match = matches.slice(-1); + defineCurrentPage(`app.ips-${match[0]?.id}`); + }, [location]); + + useEffect(() => { + trackCurrentPage(); + }, [location]); + + useEffect(() => { + shell.ux.hidePreloader(); + }, []); + + return ; +} diff --git a/packages/manager/apps/ips/src/pages/listing/index.tsx b/packages/manager/apps/ips/src/pages/listing/index.tsx new file mode 100644 index 000000000000..7219057e4640 --- /dev/null +++ b/packages/manager/apps/ips/src/pages/listing/index.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useLocation } from 'react-router-dom'; + +import { OsdsLink } from '@ovhcloud/ods-components/react'; +import { ODS_THEME_COLOR_INTENT } from '@ovhcloud/ods-common-theming'; +import { + Datagrid, + DataGridTextCell, + useResourcesIcebergV6, + dataType, + BaseLayout, +} from '@ovh-ux/manager-react-components'; + +import Loading from '@/components/Loading/Loading'; +import ErrorBanner from '@/components/Error/Error'; +import Breadcrumb from '@/components/Breadcrumb/Breadcrumb'; + +import appConfig from '@/ips.config'; + +export default function Listing() { + const myConfig = appConfig; + const serviceKey = myConfig.listing?.datagrid?.serviceKey; + const [columns, setColumns] = useState([]); + const navigate = useNavigate(); + const location = useLocation(); + const { t } = useTranslation('listing'); + const { + flattenData, + isError, + error, + totalCount, + hasNextPage, + fetchNextPage, + isLoading, + status, + sorting, + setSorting, + pageIndex, + } = useResourcesIcebergV6({ + route: `/ip`, + queryKey: ['ips', `/ip`], + }); + + const navigateToDashboard = (label: string) => { + const path = + location.pathname.indexOf('pci') > -1 ? `${location.pathname}/` : '/'; + navigate(`${path}${label}`); + }; + + useEffect(() => { + if (columns && status === 'success' && flattenData?.length > 0) { + const newColumns = Object.keys(flattenData[0]) + .filter((element) => element !== 'iam') + .map((element) => ({ + id: element, + label: element, + type: 'string', + cell: (props: any) => { + const label = props[element] as string; + if (typeof label === 'string' || typeof label === 'number') { + if (serviceKey === element) + return ( + + navigateToDashboard(label)} + > + {label} + + + ); + return {label}; + } + return
-
; + }, + })); + setColumns(newColumns); + } + }, [flattenData]); + + if (isError) { + return ; + } + + if (isLoading && pageIndex === 1) { + return ( +
+ +
+ ); + } + + const header = { + title: t('title'), + }; + + return ( + } header={header}> + + {columns && flattenData && ( + + )} + + + ); +} diff --git a/packages/manager/apps/ips/src/pages/onboarding/index.scss b/packages/manager/apps/ips/src/pages/onboarding/index.scss new file mode 100644 index 000000000000..995e5c3b0be6 --- /dev/null +++ b/packages/manager/apps/ips/src/pages/onboarding/index.scss @@ -0,0 +1,10 @@ +.tile-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 30px; + padding-top: 3rem; + + @media (min-width: 1024px) { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/packages/manager/apps/ips/src/pages/onboarding/index.tsx b/packages/manager/apps/ips/src/pages/onboarding/index.tsx new file mode 100644 index 000000000000..93e59c16a128 --- /dev/null +++ b/packages/manager/apps/ips/src/pages/onboarding/index.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, OnboardingLayout } from '@ovh-ux/manager-react-components'; +import useGuideUtils from '@/hooks/guide/useGuideUtils'; +import Breadcrumb from '@/components/Breadcrumb/Breadcrumb'; +import onboardingImgSrc from './onboarding-img.png'; + +export default function Onboarding() { + const { t } = useTranslation('onboarding'); + const link = useGuideUtils(); + + const tileList = [ + { + id: 1, + texts: { + title: t('guide1Title'), + description: t('guide1Description'), + category: t('guideCategory'), + }, + href: link?.guideLink1, + }, + { + id: 2, + texts: { + title: t('guide2Title'), + description: t('guide2Description'), + category: t('guideCategory'), + }, + href: link?.guideLink2, + }, + { + id: 3, + texts: { + title: t('guide3Title'), + description: t('guide3Description'), + category: t('guideCategory'), + }, + href: link?.guideLink3, + }, + ]; + + const title: string = t('title'); + const description: string = t('description'); + const imgSrc = { + src: onboardingImgSrc, + }; + + return ( + <> + + + {tileList.map((tile) => ( + + ))} + + + ); +} diff --git a/packages/manager/apps/ips/src/pages/onboarding/onboarding-img.png b/packages/manager/apps/ips/src/pages/onboarding/onboarding-img.png new file mode 100644 index 000000000000..1ac8d6473c95 Binary files /dev/null and b/packages/manager/apps/ips/src/pages/onboarding/onboarding-img.png differ diff --git a/packages/manager/apps/ips/src/routes/routes.constant.ts b/packages/manager/apps/ips/src/routes/routes.constant.ts new file mode 100644 index 000000000000..a685b2c8fbb4 --- /dev/null +++ b/packages/manager/apps/ips/src/routes/routes.constant.ts @@ -0,0 +1,11 @@ +export const subRoutes = { + root: '/ip', + onboarding: 'onboarding', + order: 'order', +}; + +export const urls = { + root: subRoutes.root, + onboarding: `${subRoutes.root}/${subRoutes.onboarding}`, + listing: `${subRoutes.root}`, +}; diff --git a/packages/manager/apps/ips/src/routes/routes.tsx b/packages/manager/apps/ips/src/routes/routes.tsx new file mode 100644 index 000000000000..3c538fa5cfd2 --- /dev/null +++ b/packages/manager/apps/ips/src/routes/routes.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { RouteObject } from 'react-router-dom'; +import { PageType } from '@ovh-ux/manager-react-shell-client'; +import NotFound from '@/pages/404'; +import { urls } from '@/routes/routes.constant'; + +const lazyRouteConfig = (importFn: CallableFunction): Partial => { + return { + lazy: async () => { + const { default: moduleDefault, ...moduleExports } = await importFn(); + return { + Component: moduleDefault, + ...moduleExports, + }; + }, + }; +}; + +export const Routes: any = [ + { + path: '/', + ...lazyRouteConfig(() => import('@/pages/layout')), + children: [ + { + id: 'listing', + path: urls.listing, + ...lazyRouteConfig(() => import('@/pages/listing')), + handle: { + tracking: { + pageName: 'listing', + pageType: PageType.listing, + }, + }, + }, + { + id: 'onboarding', + path: urls.onboarding, + ...lazyRouteConfig(() => import('@/pages/onboarding')), + handle: { + tracking: { + pageName: 'onboarding', + pageType: PageType.onboarding, + }, + }, + }, + ], + }, + { + path: '*', + element: , + }, +]; diff --git a/packages/manager/apps/ips/src/tracking.constant.ts b/packages/manager/apps/ips/src/tracking.constant.ts new file mode 100644 index 000000000000..77ba71b30117 --- /dev/null +++ b/packages/manager/apps/ips/src/tracking.constant.ts @@ -0,0 +1,20 @@ +export const LEVEL2 = { + EU: { + config: { + level2: '81', + }, + }, + CA: { + config: { + level2: '81', + }, + }, + US: { + config: { + level2: '81', + }, + }, +}; +export const UNIVERSE = 'Dedicated'; +export const SUB_UNIVERSE = 'Network'; +export const APP_NAME = 'ips'; diff --git a/packages/manager/apps/ips/src/vite-hmr.ts b/packages/manager/apps/ips/src/vite-hmr.ts new file mode 100644 index 000000000000..473d87630039 --- /dev/null +++ b/packages/manager/apps/ips/src/vite-hmr.ts @@ -0,0 +1,5 @@ +if (import.meta.hot) { + import.meta.hot.on('iframe-reload', () => { + window.location.reload(); + }); +} diff --git a/packages/manager/apps/ips/tailwind.config.js b/packages/manager/apps/ips/tailwind.config.js new file mode 100644 index 000000000000..657ab11bb87d --- /dev/null +++ b/packages/manager/apps/ips/tailwind.config.js @@ -0,0 +1,14 @@ +import path from 'path'; +import config from '@ovh-ux/manager-tailwind-config'; + +/** @type {import('tailwindcss').Config} */ +module.exports = { + ...config, + content: [ + './src/**/*.{js,jsx,ts,tsx}', + path.join( + path.dirname(require.resolve('@ovh-ux/manager-react-components')), + '**/*.{js,jsx,ts,tsx}', + ), + ], +}; diff --git a/packages/manager/apps/ips/tsconfig.json b/packages/manager/apps/ips/tsconfig.json new file mode 100644 index 000000000000..e2104f471575 --- /dev/null +++ b/packages/manager/apps/ips/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "es2020"], + "noEmit": true, + "target": "es2020", + "types": ["vite/client", "node"], + "module": "ES2020", + "moduleResolution": "node", + "removeComments": true, + "outDir": "dist", + "esModuleInterop": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "noImplicitAny": true, + "declaration": true, + "resolveJsonModule": true, + "allowJs": true, + "jsx": "react", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "types", "src/__tests__"] +} diff --git a/packages/manager/apps/ips/vite.config.mjs b/packages/manager/apps/ips/vite.config.mjs new file mode 100644 index 000000000000..f33ab6dc98cd --- /dev/null +++ b/packages/manager/apps/ips/vite.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import { getBaseConfig } from '@ovh-ux/manager-vite-config'; +import { resolve } from 'path'; + +export default defineConfig({ + ...getBaseConfig(), + root: resolve(process.cwd()), +});