diff --git a/docs/dev/explanations/web-architecture.md b/docs/dev/explanations/web-architecture.md index f1334b9b..700b1e58 100644 --- a/docs/dev/explanations/web-architecture.md +++ b/docs/dev/explanations/web-architecture.md @@ -36,7 +36,7 @@ Built with [tsup](https://tsup.egoist.dev/) for fast TypeScript compilation. Out The vanilla DiracX web interface: -- **Framework**: [Next.js 15](https://nextjs.org/) with App Router +- **Framework**: [Next.js](https://nextjs.org/) with App Router - **Output**: Static export (`output: "export"`) - **Authentication**: [@axa-fr/react-oidc](https://github.com/AxaFrance/oidc-client) - **Testing**: [Cypress](https://www.cypress.io/) for end-to-end tests @@ -77,7 +77,6 @@ DiracX Web uses [Next.js folder-based routing](https://nextjs.org/docs/app/build - **Application state**: Managed via React Context (`ApplicationProvider`). - **Session storage**: Each application instance writes its state to `_State` for share/import functionality. -- **URL encoding**: Dashboard layout is encoded in the URL for sharing. ## Design system diff --git a/docs/dev/explanations/web-testing.md b/docs/dev/explanations/web-testing.md index f3008b66..ba95c89a 100644 --- a/docs/dev/explanations/web-testing.md +++ b/docs/dev/explanations/web-testing.md @@ -1,57 +1,61 @@ # Web Testing -DiracX Web uses a layered testing approach to ensure reliability at different levels of the application. +DiracX Web uses a layered testing strategy where each layer builds on the previous one: + +1. **Storybook** illustrates shared and public components that extensions can import +2. **Jest tests** verify that those stories render correctly and stay up to date +3. **E2E tests** ensure essential user interactions work end-to-end ## Testing layers -### Component tests (Jest + React Testing Library) +### Storybook (visual documentation and component showcase) -Unit and integration tests for individual React components. These tests run in a simulated DOM environment (jsdom) and verify that components render correctly, handle user interactions, and manage state properly. +[Storybook](https://storybook.js.org/) is the foundation of the testing strategy. Each shared or public component that extensions can import should have a corresponding story. Stories serve as living documentation, showing how components look and behave with different props, states, and edge cases. ```bash -npm run test:diracx-web-components +npm run doc:diracx-web-components ``` -Tests live alongside their components in `packages/diracx-web-components/test/`. +Stories live in `packages/diracx-web-components/stories/`. -**When to use**: Testing component rendering, props handling, user interactions, hooks, and context behavior. +**What to document**: All reusable components exported by `diracx-web-components` — default states, loading/error/empty states, and key variations. -### End-to-end tests (Cypress) +### Component tests (Jest + React Testing Library) -Full application tests that run in a real browser against a running DiracX backend. These tests simulate real user workflows including authentication, navigation, and data operations. +Jest tests import and render the Storybook stories using `composeStories()` from `@storybook/react`. This ensures that the stories themselves are valid and that the components they illustrate work correctly. If a story breaks, the corresponding Jest test will catch it. ```bash -export DIRACX_URL= -npm run --prefix packages/diracx-web test +npm run test:diracx-web-components ``` -Tests live in `packages/diracx-web/cypress/`. +Tests live in `packages/diracx-web-components/test/`. -**When to use**: Testing complete user flows, authentication, API integration, and cross-component interactions. +**What to test**: That stories render without errors, that key elements are present in the DOM, and that basic interactions (clicks, form inputs) produce the expected results. -### Visual documentation (Storybook) +### End-to-end tests (Cypress) -While not a testing tool per se, [Storybook](https://storybook.js.org/) serves as a visual verification layer. Each component story acts as a living example that can be visually inspected and interacted with. +E2E tests run in a real browser against a running DiracX backend. They verify that essential user workflows work correctly across the full stack — authentication, navigation, data display, and user actions. ```bash -npm run doc:diracx-web-components +export DIRACX_URL= +npm run --prefix packages/diracx-web test ``` -Stories live in `packages/diracx-web-components/stories/`. +Tests live in `packages/diracx-web/test/e2e/`. -**When to use**: Documenting component variations, verifying visual appearance, and enabling manual exploratory testing. +**What to test**: Critical user flows such as logging in, filtering and sorting data, performing actions on jobs, and verifying that interactive features (e.g., clicking a pie chart slice updates the search bar and table) work end-to-end. -## What to test at each level +## How the layers connect -| Level | Scope | Speed | Examples | -|-------|-------|-------|----------| -| Component (Jest) | Single component or hook | Fast | Button renders correctly, form validates input | -| E2E (Cypress) | Full user workflow | Slow | User logs in, submits a job, views results | -| Visual (Storybook) | Component appearance | Manual | Theme variations, responsive layouts | +| Layer | Purpose | Speed | Depends on | +|-------|---------|-------|------------| +| Storybook | Document components for extension developers | Manual | Mocks only | +| Jest | Verify stories render and behave correctly | Fast | Storybook stories | +| Cypress | Verify essential user workflows | Slow | Running backend | ## Guidelines -- Write component tests for all new components and hooks -- Add Storybook stories for reusable UI components -- Add E2E tests for critical user workflows +- Add Storybook stories for all shared/public components exported by `diracx-web-components` +- Write Jest tests that render the stories via `composeStories()` to keep stories and tests in sync +- Add E2E tests for critical user workflows and cross-component interactions - Use [Jest coverage reports](https://jestjs.io/docs/code-coverage) to identify untested code diff --git a/docs/dev/tutorials/web-extensions.md b/docs/dev/tutorials/web-extensions.md index 4484f55a..7dfd3d9c 100644 --- a/docs/dev/tutorials/web-extensions.md +++ b/docs/dev/tutorials/web-extensions.md @@ -121,13 +121,13 @@ You can either create a new repository or start from this one to build your Dira The output is set to `export` to have a static application. Images are left unoptimized because it's not well-supported with a static export. -5. **Add the nginx config** located in the [`config/nginx`](config/nginx/) directory. +5. **Add the nginx config** located in the [`config/nginx`](https://github.com/DIRACGrid/diracx-web/tree/main/packages/extensions/config/nginx) directory. This adjustment ensures that Nginx can correctly handle requests for .html files and fall back appropriately, preventing the `404: Not Found` errors encountered when accessing routes like `/auth`. (see [#57](https://github.com/DIRACGrid/diracx-web/pull/57)) 6. **Organize your pages** in the `src/app` app directory. The `` context is needed by most of the components of `diracx-web-components`, so you should include it in the layouts of your application. Use `` to require authentication on a route. You can also override some default values of certain contexts like `` for the application list. Finally, some components have some personalization options (i.e. the logo URL for the dashboard), check the [Storybook documentation](https://diracgrid.github.io/diracx-web/) to see the props of each component. - Check [the app directory](src/app/) in this example to have a reference. + Check [the app directory](https://github.com/DIRACGrid/diracx-web/tree/main/packages/extensions/src/app) in this example to have a reference. ### Architecture @@ -177,12 +177,12 @@ Having a directory dedicated to your extension components will help you keep you To add new apps to your extension, you can create new components in your extension directory. -[`testApp`](src/gubbins/components/TestApp/testApp.tsx) provides an example of a basic app component and the [Storybook documentation](https://diracgrid.github.io/diracx-web/) showcases all the components you can use from the library in an interactive interface. +[`testApp`](https://github.com/DIRACGrid/diracx-web/blob/main/packages/extensions/src/gubbins/components/TestApp/testApp.tsx) provides an example of a basic app component and the [Storybook documentation](https://diracgrid.github.io/diracx-web/) showcases all the components you can use from the library in an interactive interface. It is then pretty easy to add them to DiracX Web by extending the `applicationList` (the list of apps available in DiracX-Web) from `diracx-web-components/components`. Context providers are used to manage and share global state across the application. You can use the `ApplicationProvider` from `diracx-web-components/contexts` to pass the list of applications to the components that need it. -It is used in this example in the [(Dashboard) directory's layout.tsx]() file. +It is used in this example in the [(Dashboard) directory's layout.tsx](https://github.com/DIRACGrid/diracx-web/blob/main/packages/extensions/src/app/(dashboard)/layout.tsx) file. If you need more info on Contexts, you can check the [React documentation](https://reactjs.org/docs/context.html). @@ -205,7 +205,7 @@ const newApplicationList = [...applicationList, newApp]; ...; ``` -In this example, the new App list is defined in a [separate file](src/gubbins/applicationList.ts) +In this example, the new App list is defined in a [separate file](https://github.com/DIRACGrid/diracx-web/blob/main/packages/extensions/src/gubbins/applicationList.ts) Feel free to explore and adjust the code to fit your requirements. diff --git a/docs/user/how-to/monitor-jobs.md b/docs/user/how-to/monitor-jobs.md index 072edf70..ebfd25a0 100644 --- a/docs/user/how-to/monitor-jobs.md +++ b/docs/user/how-to/monitor-jobs.md @@ -31,8 +31,6 @@ The search bar allows you to filter jobs based on various criteria. The filters ## Use the table -By default, the jobs are displayed in a table. If you are viewing them in another chart, you can click the table button next to the search bar to switch back to the table view. - The table displays the jobs that match the criteria specified in the search bar. Each row represents a job, and the columns show various attributes of the job, such as its ID, status, type, and submission date. === "Table Management" @@ -64,4 +62,12 @@ The table displays the jobs that match the criteria specified in the search bar. ## Use the Pie Chart -You can change the visualization to use a pie chart with the button next to the search bar. The pie chart provides a hierarchical view of the jobs based on their attributes. The `Columns to plot` component lets you choose your criteria for visualizing the jobs. The chart can display two levels, and you can then click on a section of the chart to zoom into that category and see more details. +A pie chart is displayed alongside the table, showing the distribution of jobs grouped by a selected attribute. The total number of jobs is shown in the center of the donut chart. + +=== "Group by attribute" + + Use the toggle buttons above the chart to switch between different grouping attributes (e.g., Status, Site, Minor Status). Only attributes that are not quasi-unique (like Job ID or dates) are available for grouping. + +=== "Filter by clicking" + + Click on a slice of the pie chart to add a filter to the search bar. Both the table and the pie chart will update to reflect the new filter. diff --git a/package-lock.json b/package-lock.json index 390375aa..d1af0597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5020,6 +5020,74 @@ } } }, + "node_modules/@mui/x-charts": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.27.0.tgz", + "integrity": "sha512-MzP1jeiEkMOPWQfzRNo11iwbTwXcSDP7hKd3s/mSN0U4aINKpyHEQX6RAM8OkWzqK8ijTXYWRPVKaRYTLBx7+A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-charts-vendor": "8.26.0", + "@mui/x-internal-gestures": "0.4.0", + "@mui/x-internals": "8.26.0", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.26.0.tgz", + "integrity": "sha512-R//+WSWvsLJRTjTRN90EKX9sgRzAb4HQBvtUA3cTQpkGrmEjmatD4BJAm3IdRdkSagf6yKWF+ypESctyRhbwnA==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@types/d3-array": "^3.2.2", + "@types/d3-color": "^3.1.3", + "@types/d3-format": "^3.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.1", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-time-format": "^4.0.3", + "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-format": "^3.1.0", + "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", + "d3-timer": "^3.0.1", + "flatqueue": "^3.0.0", + "internmap": "^2.0.3" + } + }, "node_modules/@mui/x-date-pickers": { "version": "8.27.0", "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.27.0.tgz", @@ -5086,6 +5154,15 @@ } } }, + "node_modules/@mui/x-internal-gestures": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.4.0.tgz", + "integrity": "sha512-i0W6v9LoiNY8Yf1goOmaygtz/ncPJGBedhpDfvNg/i8BvzPwJcBaeW4rqPucJfVag9KQ8MSssBBrvYeEnrQmhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, "node_modules/@mui/x-internals": { "version": "8.26.0", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz", @@ -6574,159 +6651,24 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/d3-axis": "*", - "@types/d3-brush": "*", - "@types/d3-chord": "*", - "@types/d3-color": "*", - "@types/d3-contour": "*", - "@types/d3-delaunay": "*", - "@types/d3-dispatch": "*", - "@types/d3-drag": "*", - "@types/d3-dsv": "*", - "@types/d3-ease": "*", - "@types/d3-fetch": "*", - "@types/d3-force": "*", - "@types/d3-format": "*", - "@types/d3-geo": "*", - "@types/d3-hierarchy": "*", - "@types/d3-interpolate": "*", - "@types/d3-path": "*", - "@types/d3-polygon": "*", - "@types/d3-quadtree": "*", - "@types/d3-random": "*", - "@types/d3-scale": "*", - "@types/d3-scale-chromatic": "*", - "@types/d3-selection": "*", - "@types/d3-shape": "*", - "@types/d3-time": "*", - "@types/d3-time-format": "*", - "@types/d3-timer": "*", - "@types/d3-transition": "*", - "@types/d3-zoom": "*" - } - }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, - "node_modules/@types/d3-axis": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", - "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-brush": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", - "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "license": "MIT" - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", - "license": "MIT" - }, - "node_modules/@types/d3-dispatch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", - "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-fetch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", - "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "license": "MIT", - "dependencies": { - "@types/d3-dsv": "*" - } - }, - "node_modules/@types/d3-force": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "license": "MIT" - }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", "license": "MIT" }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "license": "MIT" - }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -6742,24 +6684,6 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, - "node_modules/@types/d3-polygon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", - "license": "MIT" - }, - "node_modules/@types/d3-quadtree": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", - "license": "MIT" - }, - "node_modules/@types/d3-random": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", - "license": "MIT" - }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -6769,18 +6693,6 @@ "@types/d3-time": "*" } }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -6808,25 +6720,6 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -6870,12 +6763,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -8988,6 +8875,12 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -10301,47 +10194,6 @@ "node": ">=10" } }, - "node_modules/d3": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", - "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-axis": "3", - "d3-brush": "3", - "d3-chord": "3", - "d3-color": "3", - "d3-contour": "4", - "d3-delaunay": "6", - "d3-dispatch": "3", - "d3-drag": "3", - "d3-dsv": "3", - "d3-ease": "3", - "d3-fetch": "3", - "d3-force": "3", - "d3-format": "3", - "d3-geo": "3", - "d3-hierarchy": "3", - "d3-interpolate": "3", - "d3-path": "3", - "d3-polygon": "3", - "d3-quadtree": "3", - "d3-random": "3", - "d3-scale": "4", - "d3-scale-chromatic": "3", - "d3-selection": "3", - "d3-shape": "3", - "d3-time": "3", - "d3-time-format": "4", - "d3-timer": "3", - "d3-transition": "3", - "d3-zoom": "3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -10354,43 +10206,6 @@ "node": ">=12" } }, - "node_modules/d3-axis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", - "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-brush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", - "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "3", - "d3-transition": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", - "dependencies": { - "d3-path": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -10400,121 +10215,6 @@ "node": ">=12" } }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", - "dependencies": { - "d3-array": "^3.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-fetch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", - "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "license": "ISC", - "dependencies": { - "d3-dsv": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -10524,27 +10224,6 @@ "node": ">=12" } }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -10566,33 +10245,6 @@ "node": ">=12" } }, - "node_modules/d3-polygon": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", - "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -10609,28 +10261,6 @@ "node": ">=12" } }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -10676,41 +10306,6 @@ "node": ">=12" } }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -10955,15 +10550,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -12731,6 +12317,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", + "integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==", + "license": "ISC" + }, "node_modules/flatted": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", @@ -13806,6 +13398,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -19199,12 +18792,6 @@ "dev": true, "license": "MIT" }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" - }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -19294,12 +18881,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -19390,6 +18971,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/sass-loader": { @@ -22275,14 +21857,12 @@ "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", "@mui/utils": "^7.0.0", + "@mui/x-charts": "^8.0.0", "@mui/x-date-pickers": "^8.0.0", "@tanstack/react-table": "^8.20.5", - "@types/d3": "^7.4.3", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "d3": "^7.9.0", - "d3-hierarchy": "^3.1.2", "dayjs": "^1.11.13", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/diracx-web-components/jest.config.js b/packages/diracx-web-components/jest.config.js index df516190..7c24e4fe 100644 --- a/packages/diracx-web-components/jest.config.js +++ b/packages/diracx-web-components/jest.config.js @@ -19,7 +19,7 @@ const config = { // To support ESM modules in Jest transformIgnorePatterns: [ - "/node_modules/(?!d3|d3-[^/]+|internmap|delaunator|robust-predicates|storybook|@storybook)", + "/node_modules/(?!storybook|@storybook|@mui/x-charts|@mui/x-internal-gestures)", ], // Tell Jest how to transform files diff --git a/packages/diracx-web-components/jest.setup.ts b/packages/diracx-web-components/jest.setup.ts index d35fa5bc..dea92341 100644 --- a/packages/diracx-web-components/jest.setup.ts +++ b/packages/diracx-web-components/jest.setup.ts @@ -1,3 +1,23 @@ import "@testing-library/jest-dom"; +// Polyfill structuredClone for jsdom (used by @mui/x-charts) +if (typeof globalThis.structuredClone === "undefined") { + globalThis.structuredClone = (val: T): T => + JSON.parse(JSON.stringify(val)); +} + +// Polyfill PointerEvent for jsdom (used by @mui/x-internal-gestures) +if (typeof globalThis.PointerEvent === "undefined") { + // @ts-expect-error -- minimal polyfill for jsdom + globalThis.PointerEvent = class PointerEvent extends MouseEvent { + public pointerId: number; + public pointerType: string; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 0; + this.pointerType = params.pointerType ?? ""; + } + }; +} + jest.mock("@axa-fr/react-oidc"); diff --git a/packages/diracx-web-components/package.json b/packages/diracx-web-components/package.json index b17ff198..c48e34e8 100644 --- a/packages/diracx-web-components/package.json +++ b/packages/diracx-web-components/package.json @@ -29,14 +29,12 @@ "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", "@mui/utils": "^7.0.0", + "@mui/x-charts": "^8.0.0", "@mui/x-date-pickers": "^8.0.0", "@tanstack/react-table": "^8.20.5", - "@types/d3": "^7.4.3", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "d3": "^7.9.0", - "d3-hierarchy": "^3.1.2", "dayjs": "^1.11.13", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ExportButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ExportButton.tsx index a2d6ca2d..f460009d 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/ExportButton.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/ExportButton.tsx @@ -141,7 +141,11 @@ export function ExportButton() { return ( <> - + diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx index 8fae681d..a964aac1 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx @@ -243,6 +243,7 @@ export function ImportButton() { <> setDialogOpen(true)} data-testid="import-button" > diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx index 902486ae..e2c020a4 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx @@ -12,7 +12,11 @@ export function ThemeToggleButton() { const { theme, toggleTheme } = useTheme(); return ( - + {theme === "light" ? ( ) : ( diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx index 4ad68ed4..9e99e280 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx @@ -367,18 +367,30 @@ export function JobDataTable({ () => ( <> - handleReschedule()}> + handleReschedule()} + > - handleKill()}> + handleKill()} + > - handleDelete()}> - + handleDelete()} + > + diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx index 4afe486f..c1f586e3 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx @@ -15,9 +15,7 @@ import { brown, } from "@mui/material/colors"; -import { lighten, darken, useTheme, Box } from "@mui/material"; - -import { TableChart, DonutSmall } from "@mui/icons-material"; +import { lighten, darken, useTheme, Box, Paper } from "@mui/material"; import { createColumnHelper, @@ -31,16 +29,11 @@ import { import { mutate } from "swr"; import { useApplicationId } from "../../hooks/application"; import { Filter } from "../../types/Filter"; -import { - Job, - SearchBody, - CategoryType, - JobMonitorChartType, -} from "../../types"; +import { Job, SearchBody, CategoryType } from "../../types"; import { useDiracxUrl } from "../../hooks"; import { JobDataTable } from "./JobDataTable"; import { JobSearchBar } from "./JobSearchBar"; -import { JobSunburst } from "./JobSunburst"; +import { JobPieChart } from "./JobPieChart"; import { getSearchJobUrl } from "./jobDataService"; /** @@ -111,8 +104,6 @@ export default function JobMonitor() { }, ); - const [chartType, setChartType] = useState(JobMonitorChartType.TABLE); - // Save the state of the app in local storage useEffect(() => { const state = { @@ -177,7 +168,7 @@ export default function JobMonitor() { backgroundColor: theme.palette.mode === "light" ? darken(statusColors[status] ?? defaultColor, 0.1) - : lighten(statusColors[status] ?? defaultColor, 0.3), + : lighten(statusColors[status] ?? defaultColor, 0.1), color: "white", fontWeight: "bold", }} @@ -318,63 +309,72 @@ export default function JobMonitor() { display: "flex", flexDirection: "column", flexGrow: 1, - overflow: "auto", + overflow: "hidden", }} > + + - , - }, - { - plotName: JobMonitorChartType.SUNBURST, - icon: , - }, - ], + {/* Table section */} + - + > + + - {chartType === JobMonitorChartType.TABLE && ( - - )} - {chartType === JobMonitorChartType.SUNBURST && ( - - )} + {/* Pie chart card */} + + + + ); } diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobPieChart.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobPieChart.tsx new file mode 100644 index 00000000..ae324c14 --- /dev/null +++ b/packages/diracx-web-components/src/components/JobMonitor/JobPieChart.tsx @@ -0,0 +1,263 @@ +"use client"; +import { useState, useCallback, useMemo } from "react"; + +import { useOidcAccessToken } from "@axa-fr/react-oidc"; +import { ColumnDef } from "@tanstack/react-table"; +import { + Box, + Typography, + CircularProgress, + Alert, + ToggleButtonGroup, + ToggleButton, +} from "@mui/material"; +import { PieChart } from "@mui/x-charts/PieChart"; + +import { useDiracxUrl } from "../../hooks/utils"; +import type { SearchBody, Job, Filter, JobSummary } from "../../types"; +import { useOIDCContext } from "../../hooks/oidcConfiguration"; +import { useJobSummary } from "./jobDataService"; +import { fromHumanReadableText } from "./JobMonitor"; + +/** Height of the chart area in pixels */ +const CHART_HEIGHT = 350; +/** Bottom margin reserved for the legend */ +const LEGEND_MARGIN = 100; +/** Maximum number of slices before aggregating into "Other" */ +const MAX_SLICES = 10; + +/** Format a number in compact notation (e.g. 1.2K, 30M) */ +function formatCompact(n: number): string { + if (n < 1000) return n.toLocaleString(); + return Intl.NumberFormat("en", { notation: "compact" }).format(n); +} + +interface JobPieChartProps { + /** The search body used for querying */ + searchBody: SearchBody; + /** Function to update the filters */ + setFilters: React.Dispatch>; + /** Status color mapping */ + statusColors: Record; + /** Column definitions from the table */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: ColumnDef[]; +} + +/** + * A pie chart component for the Job Monitor. + * Shows job distribution grouped by a selectable column. + * Clicking a slice adds a filter to the search bar. + */ +export function JobPieChart({ + searchBody, + setFilters, + statusColors, + columns, +}: JobPieChartProps) { + const { configuration } = useOIDCContext(); + const { accessToken } = useOidcAccessToken(configuration?.scope); + const diracxUrl = useDiracxUrl(); + + const [groupColumn, setGroupColumn] = useState("Status"); + + // Convert the human-readable column name to the API field name + const apiColumn = useMemo( + () => fromHumanReadableText(groupColumn, columns), + [groupColumn, columns], + ); + + // Fetch summary data via SWR + const { data, isLoading, error } = useJobSummary( + diracxUrl, + accessToken, + apiColumn, + searchBody, + ); + + // Columns available for grouping (exclude quasi-unique like JobID, dates) + const groupableColumns = useMemo( + () => + columns + .filter((column) => column.meta?.isQuasiUnique !== true) + .map((column) => ({ + id: String(column.id), + header: String(column.header), + })), + [columns], + ); + + // Transform raw summary data into pie chart format, capping at MAX_SLICES + const pieData = useMemo(() => { + if (!data) return []; + + const all = data + .map((item: JobSummary) => { + const label = String(item[apiColumn as keyof JobSummary]); + const value = Number(item["count"]); + return { + id: label, + value, + label, + color: statusColors[label] || undefined, + }; + }) + .sort((a, b) => b.value - a.value); + + if (all.length <= MAX_SLICES) return all; + + const top = all.slice(0, MAX_SLICES - 1); + const otherValue = all + .slice(MAX_SLICES - 1) + .reduce((sum, d) => sum + d.value, 0); + + return [ + ...top, + { id: "Other", value: otherValue, label: "Other", color: undefined }, + ]; + }, [data, apiColumn, statusColors]); + + // Total job count for center label + const totalJobs = useMemo( + () => pieData.reduce((sum, d) => sum + d.value, 0), + [pieData], + ); + + // Handle slice click: add a filter + const handleSliceClick = useCallback( + (_event: React.MouseEvent, sliceIndex: number) => { + if (sliceIndex >= pieData.length) return; + + const sliceId = pieData[sliceIndex].id; + + setFilters((prev) => [ + ...prev, + { + parameter: groupColumn, + operator: "eq", + value: sliceId, + }, + ]); + }, + [groupColumn, pieData, setFilters], + ); + + const handleGroupChange = ( + _event: React.MouseEvent, + newGroup: string | null, + ) => { + if (newGroup !== null) { + setGroupColumn(newGroup); + } + }; + + return ( + + {/* Group-by toggle */} + + {groupableColumns.map((col) => ( + + {col.header} + + ))} + + + {/* Chart area */} + {isLoading && ( + + + + )} + {!isLoading && error && ( + + Failed to load chart data. + + )} + {!isLoading && !error && pieData.length === 0 && ( + + No data available. + + )} + {!isLoading && !error && pieData.length > 0 && ( + + { + handleSliceClick( + _event as unknown as React.MouseEvent, + pieItemIdentifier.dataIndex, + ); + }} + slotProps={{ + legend: { + direction: "horizontal", + position: { vertical: "bottom", horizontal: "center" }, + }, + }} + /> + {/* Total count in the center of the donut */} + + + {formatCompact(totalJobs)} + + + jobs + + + + )} + + ); +} diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx index 1b6f0028..e5871df9 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx @@ -17,7 +17,6 @@ import { Operators, SearchBarTokenNature, CategoryType, - JobMonitorChartType, } from "../../types"; import { getJobSummary } from "./jobDataService"; import { fromHumanReadableText } from "./JobMonitor"; @@ -36,15 +35,6 @@ interface JobSearchBarProps { columns: ColumnDef[]; /** Function to mutate the job data */ mutateJobs: () => void; - /** Props for the plot type selector */ - plotTypeSelectorProps?: { - /** The type of the plot */ - plotType: JobMonitorChartType; - /** Function to set the plot type */ - setPlotType: React.Dispatch>; - /** List of buttons to select the type of plot */ - buttonList?: { plotName: JobMonitorChartType; icon: React.ReactNode }[]; - }; } export function JobSearchBar({ @@ -54,7 +44,6 @@ export function JobSearchBar({ handleApplyFilters, columns, mutateJobs, - plotTypeSelectorProps, }: JobSearchBarProps) { // Authentication const { configuration } = useOIDCContext(); @@ -83,7 +72,6 @@ export function JobSearchBar({ }) } allowKeyWordSearch={false} // Disable keyword search for job monitor - plotTypeSelectorProps={plotTypeSelectorProps} /> ); } @@ -139,7 +127,9 @@ async function createSuggestions({ ); data = result.data || []; } catch { - throw new Error("Failed to fetch job summary"); + // If the fetch fails, leave data empty — the search bar + // will still work, just without personalized value suggestions. + data = []; } } }; diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobSunburst.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobSunburst.tsx deleted file mode 100644 index f28cef39..00000000 --- a/packages/diracx-web-components/src/components/JobMonitor/JobSunburst.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { useState, useEffect, useRef } from "react"; - -import { scaleOrdinal, quantize, interpolateRainbow } from "d3"; - -import { useOidcAccessToken } from "@axa-fr/react-oidc"; -import { ColumnDef } from "@tanstack/react-table"; -import { useDiracxUrl } from "../../hooks/utils"; - -import type { JobSummary, SearchBody, Job, SunburstTree } from "../../types"; -import { Sunburst } from "../shared/Sunburst"; -import { useOIDCContext } from "../../hooks/oidcConfiguration"; -import { ChartView } from "../shared"; -import { getJobSummary } from "./jobDataService"; - -import { fromHumanReadableText } from "./JobMonitor"; - -/** - * Create the JobSunburst component. - * - * @param searchBody The search body to be used in the search - * @param statusColors The colors to be used for the different job statuses - * @param columns The columns to be used in the table - * @returns - */ -export function JobSunburst({ - searchBody, - statusColors, - columns, -}: { - searchBody: SearchBody; - statusColors: Record; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - columns: ColumnDef[]; -}) { - const { configuration } = useOIDCContext(); - const { accessToken } = useOidcAccessToken(configuration?.scope); - const diracxUrl = useDiracxUrl(); - - const [groupColumns, setGroupColumns] = useState(["Status"]); - const [currentPath, setCurrentPath] = useState([]); - - const [tree, setTree] = useState(undefined); - const [isLoading, setIsLoading] = useState(false); - - const lastUsedGroupColumnsRef = useRef(""); - - useEffect(() => { - const newSearch = currentPath.map((elt, index) => { - return { - parameter: fromHumanReadableText(groupColumns[index], columns), - operator: "eq", - value: elt, - }; - }); - const newSearchBody: SearchBody = { - ...searchBody, - search: searchBody.search - ? searchBody.search.concat(newSearch) - : newSearch, - }; - async function load() { - setIsLoading(true); - const res = await fetchAndBuildTree( - groupColumns.slice(currentPath.length, currentPath.length + 2), - newSearchBody, - diracxUrl, - accessToken, - columns, - ); - setTree({ - name: "", - children: res, - }); - setIsLoading(false); - } - // For optimization, only load when the used groupColumns change - if ( - lastUsedGroupColumnsRef.current !== - groupColumns.slice(0, currentPath.length + 1).join(",") && - diracxUrl && - accessToken - ) { - lastUsedGroupColumnsRef.current = groupColumns - .slice(0, currentPath.length + 1) - .join(","); - load(); - } - }, [ - columns, - groupColumns, - lastUsedGroupColumnsRef, - currentPath, - searchBody, - diracxUrl, - accessToken, - ]); - - const defaultColors = scaleOrdinal( - quantize(interpolateRainbow, (tree?.children?.length ?? 0) + 1), - ); - - function colorScales(name: string, _size: number, _depth: number): string { - if (statusColors[name]) { - return statusColors[name]; - } - if (tree?.children) { - return defaultColors(name); - } - return "#ccc"; - } - - const columnList = columns - .filter((column) => column.meta?.isQuasiUnique !== true) - .map((column) => String(column.header)); - - const hasHiddenLevels = groupColumns.length > currentPath.length + 2; - - const Chart = ( - - ); - - return ( - - ); -} - -/** - * Builds the tree from a given path - * - * @param groupColumns Array of columns to be used in the group by - * @param searchBody The search body to be used in the search - * @param diracxUrl The URL of the DiracX instance - * @param accessToken The access token to be used for authentication - * @param columns The columns to be used in the table - * @returns - */ -export async function fetchAndBuildTree( - groupColumns: string[], - searchBody: SearchBody, - diracxUrl: string | null, - accessToken: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - columns: ColumnDef[], -): Promise { - if (groupColumns.length === 0) { - return []; - } - - let data: JobSummary[] = []; - - const formatedGroupColumns = groupColumns.map((columnName) => - fromHumanReadableText(columnName, columns), - ); - - if (diracxUrl && accessToken) { - data = await getJobSummary( - diracxUrl, - formatedGroupColumns, - accessToken, - searchBody, - ).then((res) => res.data || []); - return buildTree(data, formatedGroupColumns); - } - return []; -} - -/** - * Builds a tree for the charts - * - * @param data Data to be transformed into a tree - * @param groupColumns Array of columns to be used in the group by - * @param parentPath The path to this Data (optional) - * @returns The tree corresponding or the sum if it's a leaf - */ -function buildTree( - data: JobSummary[], - groupColumns: string[], - parentPath: string[] = [], -): SunburstTree[] { - if (groupColumns.length === 0) return []; - - const current = groupColumns[0]; - - const groupedData = data.reduce>((acc, item) => { - const key: string = String(item[current]); - if (!acc[key]) { - acc[key] = []; - } - acc[key].push(item); - return acc; - }, {}); - - const total = data.reduce((sum, item) => sum + Number(item["count"]), 0); - const threshold = total * 0.05; - - const nodes: SunburstTree[] = []; - let othersValue: SunburstTree | null = null; - - for (const key in groupedData) { - const group = groupedData[key]; - const groupTotal = group.reduce( - (sum, item) => sum + Number(item["count"]), - 0, - ); - - if (groupTotal < threshold) { - // Too small group, add to "Others" - if (othersValue === null) { - if (groupColumns.length === 1) - othersValue = { name: key, value: groupTotal }; - else - othersValue = { - name: key, - children: buildTree(group, groupColumns.slice(1), [ - ...parentPath, - key, - ]), - }; - } else if (othersValue) { - othersValue = { - name: "Others", - value: - (othersValue.children - ? othersValue.children[0].value || 0 - : othersValue.value || 0) + groupTotal, - }; - } - } else { - if (groupColumns.length === 1) { - nodes.push({ - name: key, - value: groupTotal, - }); - } else { - nodes.push({ - name: key, - children: buildTree(group, groupColumns.slice(1), [ - ...parentPath, - key, - ]), - }); - } - } - } - - if (othersValue) { - nodes.push(othersValue); - } - - return nodes; -} - -/** - * - * @param size The number of jobs - * @param total The total number of jobs (optional) - * @returns A string with the number of jobs - */ -function sizeToText(size: number, total?: number): string { - if (size > 1e9) - return ( - `${(size / 1e9).toFixed(2)}B \njobs` + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size > 1e6) - return ( - `${(size / 1e6).toFixed(2)}M \njobs` + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size > 1e3) - return ( - `${(size / 1e3).toFixed(2)}k \njobs` + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size > 1) - return ( - `${size} jobs` + (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size === 1) - return `1 job` + (total ? ` (${((size / total) * 100).toFixed(2)}%)` : ""); - return ""; -} diff --git a/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts b/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts index 7cc13036..f760d95d 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts +++ b/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts @@ -231,6 +231,61 @@ export async function getJobSummary( return { data }; } +/** + * Custom hook for fetching job summary data using SWR. + * + * @param diracxUrl - The base URL of the DiracX API. + * @param accessToken - The access token for authentication. + * @param grouping - The column to group by (API field name). + * @param searchBody - The search body for filtering jobs. + * @returns The summary data, loading state, and error. + */ +export function useJobSummary( + diracxUrl: string | null, + accessToken: string | undefined, + grouping: string, + searchBody: SearchBody, +) { + const summaryUrl = + diracxUrl && accessToken ? `${diracxUrl}/api/jobs/summary` : null; + + const swrKey: [string, string, SearchBody] | null = summaryUrl + ? [summaryUrl, grouping, searchBody] + : null; + + const { + data: swrData, + error: swrError, + isLoading, + } = useSWR( + swrKey, + async ([url, _grouping, _searchBody]) => { + processSearchBody(_searchBody); + + const body = { + grouping: [_grouping], + search: _searchBody.search || [], + }; + + return await fetcher([url, accessToken!, "POST", body]); + }, + { + revalidateOnMount: true, + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + dedupingInterval: 60000, + shouldRetryOnError: false, + }, + ); + + return { + data: (swrData?.data as JobSummary[] | undefined) ?? null, + isLoading, + error: swrError, + }; +} + /** * Custom hook for fetching jobs data. * diff --git a/packages/diracx-web-components/src/components/Login/LoginForm.tsx b/packages/diracx-web-components/src/components/Login/LoginForm.tsx index dfb59698..2d93bcc1 100644 --- a/packages/diracx-web-components/src/components/Login/LoginForm.tsx +++ b/packages/diracx-web-components/src/components/Login/LoginForm.tsx @@ -160,7 +160,7 @@ export function LoginForm({ variant="h3" gutterBottom sx={{ textAlign: "center" }} - data-testid="h3-vo-name" + data-testid="vo-name" > {selectedVO} @@ -173,7 +173,7 @@ export function LoginForm({ {...params} label="Select a Virtual Organization" variant="outlined" - data-testid="autocomplete-vo-select" + data-testid="vo-select" /> )} value={selectedVO} @@ -198,7 +198,7 @@ export function LoginForm({ } label="Select a Group" onChange={handleGroupChange} - data-testid="select-group" + data-testid="group-select" > {Object.keys( metadata.virtual_organizations[selectedVO].groups, @@ -220,7 +220,7 @@ export function LoginForm({ flexGrow: 1, }} onClick={handleConfigurationChanges} - data-testid="button-login" + data-testid="login-form-button" > Login via your Identity Provider diff --git a/packages/diracx-web-components/src/components/shared/ChartView/ChartView.tsx b/packages/diracx-web-components/src/components/shared/ChartView/ChartView.tsx deleted file mode 100644 index 5b257d60..00000000 --- a/packages/diracx-web-components/src/components/shared/ChartView/ChartView.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react"; - -import { Box } from "@mui/material"; - -import { ColumnSelector } from "./ColumnSelector"; - -interface ChartViewProps { - /** The chart to be displayed */ - chart: React.ReactElement; - /** List of columns available for selection */ - columnList: string[]; - /** Currently selected group columns */ - groupColumns: string[]; - /** Function to set the group columns */ - setGroupColumns: React.Dispatch>; - /** The current path in the chart */ - currentPath: string[]; - /** Function to set the current path in the chart */ - setCurrentPath: React.Dispatch>; - /** Default group columns to be used */ - defaultColumns: string[]; - /** Optional title for the column selector */ - title?: string; -} - -/** - * Creates a component that displays a chart and allows users to select columns for grouping. - * - * @param props Props for the ChartViewLayout component - * @see ChartDisplayLayoutProps - * @returns - */ -export function ChartView({ - chart, - columnList, - groupColumns, - setGroupColumns, - currentPath, - setCurrentPath, - defaultColumns: defaultGroupColumns, - title = "Level selector", -}: ChartViewProps) { - return ( - - {/* Left Section: The chart */} - - {chart} - - - {/* Right Section: Column selection */} - - - - - ); -} diff --git a/packages/diracx-web-components/src/components/shared/ChartView/ColumnSelector.tsx b/packages/diracx-web-components/src/components/shared/ChartView/ColumnSelector.tsx deleted file mode 100644 index f8ddd260..00000000 --- a/packages/diracx-web-components/src/components/shared/ChartView/ColumnSelector.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import { - Box, - Card, - Typography, - Button, - InputLabel, - MenuItem, - FormControl, - Tooltip, -} from "@mui/material"; -import Select, { SelectChangeEvent } from "@mui/material/Select"; -import RestoreIcon from "@mui/icons-material/Restore"; -import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; -import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; -import { - draggable, - dropTargetForElements, -} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; -import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box"; -import { - Edge, - attachClosestEdge, - extractClosestEdge, -} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; - -interface SelectColumnsProps { - /** The row data*/ - columnList: string[]; - /** The columns used in the group by */ - groupColumns: string[]; - /** Setter for groupColumns */ - setGroupColumns: React.Dispatch>; - /** The current path in the tree */ - currentPath: string[]; - /** Setter for the current path in the tree */ - setCurrentPath: React.Dispatch>; - /** Default columns to use */ - defaultColumns: string[]; - /** Optional title for the column selector */ - title?: string; -} - -/** - * This component is used to select the columns to be used in the group by - * - * @param props See SelectColumnProps for more detials - * @see {@link SelectColumnProps} - * @returns A table which managed the group by on the columns - */ -export function ColumnSelector({ - columnList, - groupColumns, - setGroupColumns, - currentPath, - setCurrentPath, - defaultColumns: defaultGroupColumns, - title = "Column Selector", -}: SelectColumnsProps) { - /** - * Change the columns used for the group by - * - * @param event The event which triggers the change - * @param depth The depth in the tree - */ - const handleChange = (event: SelectChangeEvent, depth: number) => { - let newGroups = [...groupColumns]; - if (event.target.value === "None") { - // Delete a column - newGroups = newGroups.filter((_elt, index) => index !== depth); - if (newGroups.length > 0 && currentPath.length > depth) - setCurrentPath((currentPath) => - currentPath.slice(0, Math.max(0, depth - 1)), - ); - } else { - // Add or change a column - if (newGroups[depth]) { - // Change the column - newGroups[depth] = event.target.value; - if (currentPath.length > depth) - setCurrentPath((currentPath) => currentPath.slice(0, depth)); - } else { - // Add a column - newGroups.push(event.target.value); - } - } - setGroupColumns(newGroups); - }; - - /** - * Reorder columns based on drag and drop - * - * @param fromIndex The original index - * @param toIndex The target index - */ - const handleReorder = (fromIndex: number, toIndex: number) => { - if (fromIndex === toIndex) return; - - const newColumns = [...groupColumns]; - - // Only move existing columns (not the "add new" one) - if (fromIndex < newColumns.length) { - // Remove the item from its original position - const [movedItem] = newColumns.splice(fromIndex, 1); - - newColumns.splice(toIndex, 0, movedItem); - setGroupColumns(newColumns); - - // Reset the current path since we changed the hierarchy - setCurrentPath([]); - } - }; - - const resetColumnsToPlot = () => { - setGroupColumns(defaultGroupColumns); - setCurrentPath([]); - }; - - /** A arrray with one cell per column in the group by */ - const additionalChoice = []; - - for (let i = 0; i < groupColumns.length + 1; i++) { - const availableColumns = columnList.filter( - (column) => column === groupColumns[i] || !groupColumns.includes(column), - ); - - additionalChoice.push( - , - ); - } - - return ( - - - - - {title} - - - - {additionalChoice} - -
- -
-
-
- ); -} - -interface ColumnSelectProps { - /** The columns used in the group by */ - groupColumns: string[]; - /** The index of the column in the group by */ - index: number; - /** Function to handle the change of the column */ - handleChange: (event: SelectChangeEvent, index: number) => void; - /** The available columns to select from */ - availableColumns: string[]; - /** Function to handle the reordering of the columns */ - onReorder: (fromIndex: number, toIndex: number) => void; -} - -function ColumnSelect({ - groupColumns, - index, - handleChange, - availableColumns, - onReorder, -}: ColumnSelectProps) { - // Ref to use for the draggable element - const dragRef = useRef(null); - // Ref to use for the handle of the draggable element - const handleRef = useRef(null); - // Represents the closest edge to the mouse cursor - const [closestEdge, setClosestEdge] = useState(null); - - const isLastItem = index === groupColumns.length; - - useEffect(() => { - if (!dragRef.current || !handleRef.current || isLastItem) return; - - const element = dragRef.current; - const handleElement = handleRef.current; - - return combine( - // Makes the element draggable - draggable({ - element, - dragHandle: handleElement, - getInitialData: () => ({ index }), - }), - - // Makes the element a drop target - dropTargetForElements({ - element, - getData: ({ input, element }) => { - return attachClosestEdge( - { index }, - { input, element, allowedEdges: ["top", "bottom"] }, - ); - }, - - onDrag({ self, source }) { - const isSource = source.element === element; - if (isSource) { - setClosestEdge(null); - return; - } - - const closestEdge = extractClosestEdge(self.data); - const sourceIndex = source.data.index; - - if (typeof sourceIndex === "number") { - const isItemBeforeSource = index === sourceIndex - 1; - const isItemAfterSource = index === sourceIndex + 1; - - const isDropIndicatorHidden = - (isItemBeforeSource && closestEdge === "bottom") || - (isItemAfterSource && closestEdge === "top"); - - if (isDropIndicatorHidden) { - setClosestEdge(null); - return; - } - } - - setClosestEdge(closestEdge); - }, - onDragLeave() { - setClosestEdge(null); - }, - onDrop({ self, source }) { - const closestEdge = extractClosestEdge(self.data); - const fromIndex = source.data.index as number; - let toIndex = index; - - if (closestEdge === "bottom") { - toIndex = index + 1; - } - - // Only reorder if from and to indexes are different - if (fromIndex != undefined && toIndex !== fromIndex) { - onReorder(fromIndex, toIndex); - } - - setClosestEdge(null); - }, - }), - ); - }, [index, isLastItem, onReorder]); - - return ( - - {!isLastItem && ( - - - - )} - - {isLastItem && } - - - Level {index + 1} - - - - {closestEdge && } - - ); -} diff --git a/packages/diracx-web-components/src/components/shared/ChartView/index.ts b/packages/diracx-web-components/src/components/shared/ChartView/index.ts deleted file mode 100644 index f15b7b67..00000000 --- a/packages/diracx-web-components/src/components/shared/ChartView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChartView } from "./ChartView"; diff --git a/packages/diracx-web-components/src/components/shared/DataTable.tsx b/packages/diracx-web-components/src/components/shared/DataTable.tsx index a5d580ff..02a5c38e 100644 --- a/packages/diracx-web-components/src/components/shared/DataTable.tsx +++ b/packages/diracx-web-components/src/components/shared/DataTable.tsx @@ -138,7 +138,10 @@ function DataTableToolbar>({ {numSelected > 0 ? ( 1 ? "s" : ""}`}> - + 1 ? "s" : ""}`} + onClick={handleCopyIDs} + > @@ -154,7 +157,11 @@ function DataTableToolbar>({ - + diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx index fe6ed720..45e202be 100644 --- a/packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx +++ b/packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx @@ -52,7 +52,11 @@ export function DisplayTokenEquation({ : "default"; return ( - + {tokens.map((token, tokenIndex) => { if ( equationIndex === focusedTokenIndex?.equationIndex && @@ -71,6 +75,7 @@ export function DisplayTokenEquation({ key={tokenIndex} label={chipLabel} color={chipColor} + aria-label={`${token.nature}: ${chipLabel}`} onClick={(e) => handleClick(e, tokenIndex)} onDelete={isLast ? handleDelete : undefined} id={`tokenid:equation-${equationIndex}-token-${tokenIndex}`} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/PlotTypeSelector.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/PlotTypeSelector.tsx deleted file mode 100644 index 886906f5..00000000 --- a/packages/diracx-web-components/src/components/shared/SearchBar/PlotTypeSelector.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { ReactNode } from "react"; - -import { ToggleButton, ToggleButtonGroup, Tooltip } from "@mui/material"; - -interface PlotTypeSelectorProps { - /** The type of the plot */ - plotType: T; - /** Function to set the plot type */ - setPlotType: React.Dispatch>; - /** List of name and JSX elements to display as buttons */ - buttonList?: { plotName: T; icon: ReactNode }[]; -} - -/** - * Component to select the type of plot. - * - * @param plotType The type of the plot. - * @param setPlotType The setter for the plot type. - * @param buttonList List of buttons to select the type of plot. - * @returns A selector for the plot type. - */ -export function PlotTypeSelector({ - plotType, - setPlotType, - buttonList = [], -}: PlotTypeSelectorProps) { - return ( - { - if (val !== null) setPlotType(val); - }} - aria-label="text alignment" - > - {buttonList.map((button) => ( - - - {button.icon} - - - ))} - - ); -} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx index 75937b39..ecec46a4 100644 --- a/packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx +++ b/packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from "react"; -import { Box, Menu, MenuItem, IconButton } from "@mui/material"; +import { Box, Menu, MenuItem, IconButton, Tooltip } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import RefreshIcon from "@mui/icons-material/Refresh"; @@ -30,7 +30,6 @@ import { } from "./defaultFunctions"; import SearchField from "./SearchField"; -import { PlotTypeSelector } from "./PlotTypeSelector"; export interface CreateSuggestionsParams { previousToken?: SearchBarToken; @@ -39,7 +38,7 @@ export interface CreateSuggestionsParams { equationIndex?: number; } -export interface SearchBarProps { +export interface SearchBarProps { /** The filters to be applied to the search */ filters: Filter[]; /** The function to set the filters */ @@ -72,11 +71,6 @@ export interface SearchBarProps { allowKeyWordSearch?: boolean; /** Whether createSuggestions uses the currentInput parameter (default is false) */ usesCurrentInput?: boolean; - plotTypeSelectorProps?: { - plotType: T; - setPlotType: React.Dispatch>; - buttonList?: { plotName: T; icon: React.ReactNode }[]; - }; } /** @@ -86,7 +80,7 @@ export interface SearchBarProps { * @param props - The properties for the SearchBar component. * @returns The rendered SearchBar component. */ -export function SearchBar({ +export function SearchBar({ filters, setFilters, createSuggestions, @@ -95,8 +89,7 @@ export function SearchBar({ refreshFunction = convertAndApplyFilters, allowKeyWordSearch = true, usesCurrentInput = false, - plotTypeSelectorProps, -}: SearchBarProps) { +}: SearchBarProps) { const [inputValue, setInputValue] = useState(""); const [anchorEl, setAnchorEl] = useState(null); const [clickedTokenIndex, setClickedTokenIndex] = @@ -411,6 +404,8 @@ export function SearchBar({ }, alignItems: "center", }} + role="search" + aria-label="Search filters" data-testid="search-bar" > ({ - refreshFunction(tokenEquations, setFilters)} - disabled={ - !tokenEquations.every((eq) => eq.status === EquationStatus.VALID) - } - > - - + + + refreshFunction(tokenEquations, setFilters)} + disabled={ + !tokenEquations.every( + (eq) => eq.status === EquationStatus.VALID, + ) + } + > + + + + {tokenEquations.length !== 0 && ( - { - setInputValue(""); - clearFunction(setFilters, setTokenEquations); - }} - > - - + + { + setInputValue(""); + clearFunction(setFilters, setTokenEquations); + }} + > + + + )} - {/* Plot type selector if provided */} - {plotTypeSelectorProps && ( - - )} ); } diff --git a/packages/diracx-web-components/src/components/shared/Sunburst/BreadCrumbsTrail.tsx b/packages/diracx-web-components/src/components/shared/Sunburst/BreadCrumbsTrail.tsx deleted file mode 100644 index aaaa7a5f..00000000 --- a/packages/diracx-web-components/src/components/shared/Sunburst/BreadCrumbsTrail.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Breadcrumbs, Link } from "@mui/material"; - -/** - * Display a path in the breadcrumb. - * - * @param path The path to display. - * @param setPath The function to set the path. - * @returns The breadcrumb component. - */ -export function BreadCrumbsTrail({ - path, - setPath, -}: { - path: string[]; - setPath?: React.Dispatch>; -}) { - return ( - - { - if (setPath) setPath([]); - }} - sx={{ - cursor: "pointer", - "&:hover": { - textDecoration: "underline", - }, - }} - variant="h6" - > - Top - - {path.map((elt, index) => ( - { - if (setPath) setPath((oldPath) => oldPath.slice(0, index + 1)); - }} - sx={{ - cursor: "pointer", - "&:hover": { - textDecoration: "underline", - }, - }} - variant="h6" - > - {elt} - - ))} - - ); -} diff --git a/packages/diracx-web-components/src/components/shared/Sunburst/Sunburst.tsx b/packages/diracx-web-components/src/components/shared/Sunburst/Sunburst.tsx deleted file mode 100644 index fdb5e239..00000000 --- a/packages/diracx-web-components/src/components/shared/Sunburst/Sunburst.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import React, { MouseEvent, useEffect, useRef } from "react"; - -import { - HierarchyNode, - Arc, - select, - hierarchy, - partition, - arc, - quantize, - interpolateRainbow, -} from "d3"; - -import { scaleOrdinal } from "d3-scale"; - -import { Stack, useTheme, Box, Alert, Skeleton } from "@mui/material"; - -import type { SunburstTree, SunburstNode } from "../../../types"; -import { getPath, sizeToText as defaultSizeToText } from "./Utils"; -import { BreadCrumbsTrail } from "./BreadCrumbsTrail"; - -interface SunburstProps { - /** Formatted data to be displayed in the chart */ - tree: SunburstTree; - /** Boolean indicating if there are hidden levels */ - hasHiddenLevels?: boolean; - /** Function to convert the size to text */ - sizeToText?: (size: number, total?: number) => string; - /** The current path in the data tree */ - currentPath?: string[]; - /** Function to handle right-click events on the chart */ - handleRightClick?: (p: SunburstNode) => void; - /** Function to update the current path */ - setCurrentPath?: React.Dispatch>; - /** Function to generate color scales for the chart */ - colorScales?: (name: string, size: number, depth: number) => string; - /** Boolean indicating if the chart is loading */ - isLoading?: boolean; - /** Error object if there is an error */ - error?: Error | null; -} - -/** - * Create the Sunburst component. - * Adapted from https://observablehq.com/@d3/zoomable-sunburst - * - * @param props The props for the Sunburst. See SunburtProps for details - * @see {@link SunburstProps} - * @returns The Sunurst component - */ -export function Sunburst({ - tree, - hasHiddenLevels = true, - sizeToText = defaultSizeToText, - currentPath, - handleRightClick, - setCurrentPath, - colorScales, - isLoading = false, - error = null, -}: SunburstProps) { - // Create a default color scale - const defaultColorScale = (() => { - if (!tree?.children) return () => "#ccc"; - - const colorScale = scaleOrdinal( - quantize(interpolateRainbow, tree.children.length + 1), - ); - return (name: string, _size: number, _depth: number) => colorScale(name); - })(); - - // Use the provided colorScales or the default one - const finalColorScales = colorScales || defaultColorScale; - - const svgRef = useRef(null); - const tooltipRef = useRef(null); - - const theme = useTheme(); - - // Dimensions are for the ViewBox (in px) - const width = 800; - const height = 800; - - useEffect(() => { - // Avoid those specific cases - if (error || isLoading || !tree || tree.children?.length === 0) return; - - const radius: number = width / 7; - - // Compute the layout. - const hierarchyStruct = hierarchy(tree) // Create the tree - .sum((d) => d.value || 0) - .sort( - (a: HierarchyNode, b: HierarchyNode) => { - if (a.value && b.value) return b.value - a.value; - if (a.value) return -1; - if (b.value) return 1; - return 0; - }, - ); - - const root: SunburstNode = partition().size([ - 2 * Math.PI, - hierarchyStruct.height + 1, - ])(hierarchyStruct); - root.each((d) => { - d.current = d; - }); - - // Create the arc generator. - const arcGenerator: Arc = arc() - .startAngle((d) => d.x0) - .endAngle((d) => d.x1) - .padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005)) - .padRadius(radius * 1.5) - .innerRadius((d) => d.y0 * radius) - .outerRadius((d) => Math.max(d.y0 * radius, d.y1 * radius - 1)); - - // Create the SVG container. - const svg = select(svgRef.current) - .attr("viewBox", [-width / 2, -height / 2, width, width]) - .style("font", "10px sans-serif"); - - tooltipRef.current!.innerHTML = ""; // Delete the previous tooltip - - // Create the tooltip - const tooltip = select(tooltipRef.current) - .append("div") - .style("position", "absolute") - .style("visibility", "hidden") - .style("border-radius", "4px") - .style("padding", "10px") - .style("background-color", "rgba(230, 230, 230, 0.7)") - .style("color", "black") - .style("pointer-events", "none") - .style("z-index", "99") - .style("-webkit-box-shadow", "7px 7px 10px 4px rgba(0, 0, 0, 0.53)") - .style("box-shadow", "7px 7px 10px 4px rgba(0, 0, 0, 0.53)") - .text(""); - - // Remove any previous elements - svg.selectAll("*").remove(); - - // Cercle in the middle of the Sunburst - svg - .append("circle") - .datum(root) - .attr("r", radius) - .style("cursor", "pointer") - .attr("fill", "none") - .attr("pointer-events", "all") - .on("click", unZoom); - - // Append the arcs. - const path = svg - .append("g") - .selectAll("path") - .data(root.descendants().slice(1)) - .join("path") - .attr("fill", (d) => { - while (d.depth > 1) d = d.parent!; - return finalColorScales(d.data.name, d.x1 - d.x0, d.depth); - }) - .attr("fill-opacity", (d) => - arcVisible(d.current!) - ? d.data.name !== "Others" && (d.children || hasHiddenLevels) - ? 0.8 - : 0.4 - : 0, - ) - .attr("pointer-events", (d) => (arcVisible(d.current!) ? "auto" : "none")) - .attr("d", (d) => arcGenerator(d.current!)); - - function zoom(_event: MouseEvent, p: SunburstNode) { - if (setCurrentPath && currentPath) - setCurrentPath(currentPath.concat(getPath(p))); - } - - function unZoom(_event: MouseEvent, _p: SunburstNode) { - if (setCurrentPath && currentPath) - setCurrentPath(currentPath.slice(0, -1)); - } - - if (setCurrentPath) { - // If the chart can be modified - // Make them clickable if they have children. - path - .filter( - (d: SunburstNode) => - d.data.name !== "Others" && - (Array.isArray(d.children) || hasHiddenLevels), - ) - .style("cursor", "pointer") - .on("click", zoom); - } - - // Make them interact with the mouse - path - .on("mouseover", mouseOn) - .on("mouseout", mouseOut) - .on("mousemove", mouseMove) - .on("contextmenu", rightClicked); - - // Text on the chart quarters - svg - .append("g") - .attr("pointer-events", "none") - .attr("text-anchor", "middle") - .style("user-select", "none") - .selectAll("text") - .data(root.descendants().slice(1)) - .join("text") - .attr("dy", "0.35em") - .attr("fill-opacity", (d) => +labelVisible(d.current!)) - .attr("transform", (d) => labelTransform(d.current!)) - .attr("fill", theme.palette.text.primary) - .text((d) => d.data.name); - - // Text with the size in the middle (multi-line support) - const centerText = sizeToText(root.value || 0); - const lines = centerText.split("\n"); - - const textGroup = svg - .append("g") - .attr("text-anchor", "middle") - .attr("fill", theme.palette.text.primary); - - lines.forEach((line, index) => { - textGroup - .append("text") - .attr("x", 0) - .attr("y", (index - (lines.length - 1) / 2) * 35) - .attr("dominant-baseline", "middle") - .attr("font-size", "30px") - .text(line); - }); - - function arcVisible(d: SunburstNode): boolean { - return d.y1 <= 3 && d.y0 >= 1 && d.x1 > d.x0; - } - - function labelVisible(d: SunburstNode): boolean { - return d.y1 <= 3 && d.y0 >= 1 && (d.y1 - d.y0) * (d.x1 - d.x0) > 0.03; - } - - // Move the label to the right place - function labelTransform(d: SunburstNode): string { - const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI; - const y = ((d.y0 + d.y1) / 2) * radius; - return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})`; - } - - function mouseOn(event: MouseEvent, p: SunburstNode) { - const element = event.currentTarget as SVGPathElement; - if (p.children && setCurrentPath) { - select(element).transition().duration(30).attr("opacity", "0.85"); - } - tooltip.style("visibility", "visible"); - tooltip - .style("top", event.pageY - 50 + "px") - .style("left", event.pageX - 50 + "px"); - tooltip.text( - (currentPath || []).concat(getPath(p)).join("/") + - ": " + - sizeToText(p.value || 0, root.value), - ); - } - - function mouseOut(_event: MouseEvent, _p: SunburstNode) { - const element = _event.currentTarget as SVGPathElement; - select(element).transition().duration(30).attr("opacity", "1"); - tooltip.style("visibility", "hidden"); - } - - function mouseMove(event: MouseEvent, _p: SunburstNode) { - tooltip - .style("top", event.pageY - 50 + "px") - .style("left", event.pageX - 50 + "px"); - } - - function rightClicked(event: MouseEvent, p: SunburstNode) { - event.preventDefault(); - if (handleRightClick) handleRightClick(p); - tooltip.style("visibility", "hidden"); - } - }, [ - width, - height, - tree, - handleRightClick, - currentPath, - setCurrentPath, - theme, - finalColorScales, - sizeToText, - error, - hasHiddenLevels, - isLoading, - ]); - - if (error) { - return ( - - {error.message || "An error occurred while loading the data."} - - ); - } - - if (!tree.children || tree.children.length === 0) - return ( - - No data or no results match your filters. - - ); - - return ( - - - {currentPath && setCurrentPath && ( - - )} - -
- {isLoading ? ( - - ) : ( - - )} -
-
- - ); -} diff --git a/packages/diracx-web-components/src/components/shared/Sunburst/Utils.tsx b/packages/diracx-web-components/src/components/shared/Sunburst/Utils.tsx deleted file mode 100644 index 7773775e..00000000 --- a/packages/diracx-web-components/src/components/shared/Sunburst/Utils.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { SunburstNode } from "../../../types"; - -/** - * Gives the complete path to a node - * - * @param p The target node - * @returns The path - */ -export function getPath(p: SunburstNode): string[] { - const path = [p.data.name]; - let elt: SunburstNode = p; - while (elt.depth > 0) { - elt = elt.parent!; - if (elt.data.name !== "") path.push(elt.data.name); - } - return path.reverse(); -} - -/** - * Converts a size in Bytes to a human-readable format - * - * @param size The size in Bytes - * @param total The total size (optional) to calculate the percentage - * @returns A string with the size in a human-readable format - */ -export function sizeToText(size: number, total?: number): string { - if (size >= 1e18) - return ( - (size / 1e18).toFixed(2) + - " EB" + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size >= 1e15) - return ( - (size / 1e15).toFixed(2) + - " PB" + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size >= 1e12) - return ( - (size / 1e12).toFixed(2) + - " TB" + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size >= 1e9) - return ( - (size / 1e9).toFixed(2) + - " GB" + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size >= 1e6) - return ( - (size / 1e6).toFixed(2) + - " MB" + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size >= 1e3) - return ( - (size / 1e3).toFixed(2) + - " KB" + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - if (size < 1e3 && size >= 0) - return ( - size.toFixed(2) + - "B" + - (total ? ` (${((size / total) * 100).toFixed(2)}%)` : "") - ); - return "none"; -} diff --git a/packages/diracx-web-components/src/components/shared/Sunburst/index.ts b/packages/diracx-web-components/src/components/shared/Sunburst/index.ts deleted file mode 100644 index 9ff5138b..00000000 --- a/packages/diracx-web-components/src/components/shared/Sunburst/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Sunburst } from "./Sunburst"; diff --git a/packages/diracx-web-components/src/components/shared/index.ts b/packages/diracx-web-components/src/components/shared/index.ts index f0ff014e..cc6b782b 100644 --- a/packages/diracx-web-components/src/components/shared/index.ts +++ b/packages/diracx-web-components/src/components/shared/index.ts @@ -1,6 +1,4 @@ export { DataTable } from "./DataTable"; export { ErrorBox } from "./ErrorBox"; export { ApplicationSelector } from "./ApplicationSelector"; -export { ChartView } from "./ChartView/ChartView"; export { SearchBar } from "./SearchBar"; -export { Sunburst } from "./Sunburst"; diff --git a/packages/diracx-web-components/src/types/JobMonitorChartType.ts b/packages/diracx-web-components/src/types/JobMonitorChartType.ts deleted file mode 100644 index 7dc2385e..00000000 --- a/packages/diracx-web-components/src/types/JobMonitorChartType.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Enum representing the types of charts available in the Job Monitor. - */ -export enum JobMonitorChartType { - TABLE = "CHART_TYPE_TABLE", - SUNBURST = "CHART_TYPE_SUNBURST", -} diff --git a/packages/diracx-web-components/src/types/SunburstData.ts b/packages/diracx-web-components/src/types/SunburstData.ts deleted file mode 100644 index 982c5fed..00000000 --- a/packages/diracx-web-components/src/types/SunburstData.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type SunburstData = { - [key: string]: string | number | boolean; -}; diff --git a/packages/diracx-web-components/src/types/SunburstNode.ts b/packages/diracx-web-components/src/types/SunburstNode.ts deleted file mode 100644 index e758fe7b..00000000 --- a/packages/diracx-web-components/src/types/SunburstNode.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { HierarchyRectangularNode } from "d3-hierarchy"; - -import type { SunburstTree } from "./SunburstTree"; - -export interface SunburstNode extends HierarchyRectangularNode { - /** The current node in the hierarchy */ - current?: SunburstNode; -} diff --git a/packages/diracx-web-components/src/types/SunburstTree.ts b/packages/diracx-web-components/src/types/SunburstTree.ts deleted file mode 100644 index 2ffce20b..00000000 --- a/packages/diracx-web-components/src/types/SunburstTree.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type SunburstTree = { - /** The name of the node */ - name: string; - /** The value of the node if it's a leaf */ - value?: number; - /** The children of the node */ - children?: SunburstTree[]; -}; diff --git a/packages/diracx-web-components/src/types/index.ts b/packages/diracx-web-components/src/types/index.ts index 71b7ff4c..880b578e 100644 --- a/packages/diracx-web-components/src/types/index.ts +++ b/packages/diracx-web-components/src/types/index.ts @@ -15,6 +15,3 @@ export * from "./EquationStatus"; export * from "./operators"; export * from "./SearchBarTokenNature"; export * from "./CategoryType"; -export * from "./SunburstTree"; -export * from "./SunburstNode"; -export * from "./JobMonitorChartType"; diff --git a/packages/diracx-web-components/stories/ChartView.stories.tsx b/packages/diracx-web-components/stories/ChartView.stories.tsx deleted file mode 100644 index 3669f270..00000000 --- a/packages/diracx-web-components/stories/ChartView.stories.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; - -import { useState } from "react"; -import { ChartView } from "../src/components/shared"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { Sunburst } from "../src/components"; - -// Mock data for the story -const mockTree = { - name: "", - children: [ - { - name: "Production", - value: 1500, - children: [ - { name: "Running", value: 800 }, - { name: "Completed", value: 500 }, - { name: "Failed", value: 200 }, - ], - }, - { - name: "Development", - value: 800, - children: [ - { name: "Testing", value: 400 }, - { name: "Debugging", value: 300 }, - { name: "Review", value: 100 }, - ], - }, - { - name: "Maintenance", - value: 600, - children: [ - { name: "Updates", value: 300 }, - { name: "Backups", value: 200 }, - { name: "Monitoring", value: 100 }, - ], - }, - ], -}; - -const meta = { - title: "Shared/ChartView", - component: ChartView, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - return ; - }, - ], -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - chart:
Nothing
, - columnList: ["Column 1", "Column 2", "Column 3"], - groupColumns: ["Column 1"], - setGroupColumns: () => {}, - currentPath: [], - setCurrentPath: () => {}, - defaultColumns: ["Column 1"], - title: "Select Columns", - }, - argTypes: { - chart: { - control: { type: "select" }, - options: ["Sunburst", "None"], - mapping: { - Sunburst: , - None:
No Chart
, - }, - }, - setGroupColumns: { - control: { disable: true }, - }, - setCurrentPath: { - control: { disable: true }, - }, - }, - render: function ChartViewRender(args) { - const [groupColumns, setGroupColumns] = useState(args.groupColumns); - const [currentPath, setCurrentPath] = useState(args.currentPath); - - return ( - - - - ); - }, -}; diff --git a/packages/diracx-web-components/stories/SearchBar.stories.tsx b/packages/diracx-web-components/stories/SearchBar.stories.tsx index 76aaef21..abeea5b7 100644 --- a/packages/diracx-web-components/stories/SearchBar.stories.tsx +++ b/packages/diracx-web-components/stories/SearchBar.stories.tsx @@ -108,7 +108,7 @@ const createSuggestions = async ({ }; }; -const meta: Meta> = { +const meta: Meta = { title: "shared/SearchBar", component: SearchBar, parameters: { @@ -127,7 +127,7 @@ const meta: Meta> = { }; export default meta; -type Story = StoryObj>; +type Story = StoryObj; export const Default: Story = { args: { diff --git a/packages/diracx-web-components/stories/Sunburst.stories.tsx b/packages/diracx-web-components/stories/Sunburst.stories.tsx deleted file mode 100644 index 85aa4fd8..00000000 --- a/packages/diracx-web-components/stories/Sunburst.stories.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; -import { useState, useEffect } from "react"; -import { Sunburst } from "../src/components/shared/Sunburst/Sunburst"; - -// Mock data for the story -const mockTree = { - name: "", - children: [ - { - name: "Production", - value: 1500, - children: [ - { name: "Running", value: 800 }, - { name: "Completed", value: 500 }, - { name: "Failed", value: 200 }, - ], - }, - { - name: "Development", - value: 800, - children: [ - { name: "Testing", value: 400 }, - { name: "Debugging", value: 300 }, - { name: "Review", value: 100 }, - ], - }, - { - name: "Maintenance", - value: 600, - children: [ - { name: "Updates", value: 300 }, - { name: "Backups", value: 200 }, - { name: "Monitoring", value: 100 }, - ], - }, - ], -}; - -function customSizeToText(size: number): string { - return `${size} owners`; -} - -function customColorScales(name: string, _size: number, depth: number) { - // Custom color logic based on depth and name - const colors = { - 0: "#FF6B6B", // Root level - 1: "#4ECDC4", // First level - 2: "#45B7D1", // Second level - }; - - // Different colors for different categories - if (name.includes("Production")) return "#FF6B6B"; - if (name.includes("Development")) return "#4ECDC4"; - if (name.includes("Maintenance")) return "#45B7D1"; - if (name.includes("Running")) return "#2ECC71"; - if (name.includes("Failed")) return "#E74C3C"; - if (name.includes("Completed")) return "#F39C12"; - - return colors[depth as keyof typeof colors] || "#95A5A6"; -} - -const meta: Meta = { - title: "Shared/Sunburst", - component: Sunburst, - parameters: { - layout: "centered", - docs: { - description: { - component: - "A D3-based sunburst chart for hierarchical data visualization.", - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], - tags: ["autodocs"], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - tree: mockTree, - error: null, - isLoading: false, - hasHiddenLevels: false, - sizeToText: undefined, - colorScales: undefined, - }, - argTypes: { - tree: { - control: "select", - options: ["Default", "Empty"], - mapping: { - Default: mockTree, - Empty: { name: "", children: [] }, - }, - }, - sizeToText: { - control: "select", - options: ["Default", "Custom"], - mapping: { - Default: undefined, - Custom: customSizeToText, - }, - }, - colorScales: { - control: "select", - options: ["Default", "Custom"], - mapping: { - Default: undefined, - Custom: customColorScales, - }, - }, - error: { - control: "select", - options: ["None", "Error", "Custom Error"], - mapping: { - None: null, - Error: new Error(), - "Custom Error": new Error("Custom error message"), - }, - }, - }, - - render: function SunburstRender(args) { - const [currentPath, setCurrentPath] = useState([]); - const [tree, setTree] = useState(args.tree); - - useEffect(() => { - if (currentPath.length === 0) { - setTree(args.tree); - return; - } - const newChildren = - mockTree.children.filter( - (child) => currentPath[currentPath.length - 1] === child.name, - )[0]?.children || []; - const newTree = { - name: "", - children: newChildren, - }; - - setTree(newTree); - }, [currentPath, args.tree]); - - return ( - - ); - }, -}; diff --git a/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx b/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx index 452f6a39..bd3c17ec 100644 --- a/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx +++ b/packages/diracx-web-components/stories/mocks/jobDataService.mock.tsx @@ -145,6 +145,75 @@ export function rescheduleJobs( }); } +// Mock implementation of useJobSummary +export function useJobSummary( + _diracxUrl: string | null, + _accessToken: string | undefined, + _grouping: string, + _searchBody: any, +) { + return { + data: [ + { + Status: "Running", + MinorStatus: "None", + ApplicationStatus: "Accepted", + Site: "SiteA", + JobName: "Job 1", + JobType: "TypeA", + JobGroup: "GroupA", + Owner: "UserA", + OwnerGroup: "GroupA", + VO: "VOA", + UserPriority: 100, + RescheduleCounter: 0, + count: 10, + }, + { + Status: "Completed", + MinorStatus: "None", + ApplicationStatus: "Finished", + Site: "SiteB", + JobName: "Job 2", + JobType: "TypeB", + JobGroup: "GroupB", + Owner: "UserB", + OwnerGroup: "GroupB", + VO: "VOB", + UserPriority: 200, + RescheduleCounter: 1, + count: 5, + }, + { + Status: "Failed", + MinorStatus: "Error", + ApplicationStatus: "Failed", + Site: "SiteC", + JobName: "Job 3", + JobType: "TypeC", + JobGroup: "GroupC", + Owner: "UserC", + OwnerGroup: "GroupC", + VO: "VOC", + UserPriority: 300, + RescheduleCounter: 2, + count: 2, + }, + ], + isLoading: false, + error: null, + }; +} + +// Mock implementation of getSearchJobUrl +export function getSearchJobUrl( + _diracxUrl: string | null, + _page: number, + _rowsPerPage: number, +) { + return "http://mock/api/jobs/search?page=1&per_page=25"; +} + // Mock implementation of getJobSummary export async function getJobSummary( _diracxUrl: string | null, diff --git a/packages/diracx-web-components/test/ChartView.test.tsx b/packages/diracx-web-components/test/ChartView.test.tsx deleted file mode 100644 index a25a1f90..00000000 --- a/packages/diracx-web-components/test/ChartView.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { composeStories } from "@storybook/react"; -import * as stories from "../stories/ChartView.stories"; - -// Compose the stories to get actual Storybook behavior (decorators, args, etc) -const { Default } = composeStories(stories); - -describe("ChartView", () => { - it("renders the element", () => { - render(); - expect(screen.getByText("Select Columns")).toBeInTheDocument(); - expect(screen.getByText("Level 1")).toBeInTheDocument(); - }); - - it("renders custom title", () => { - render(); - expect(screen.getByText("Custom title")).toBeInTheDocument(); - }); - - it("renders with columns", () => { - render(); - - expect(screen.getByDisplayValue("Column 1")).toBeInTheDocument(); - expect(screen.getByDisplayValue("Column 2")).toBeInTheDocument(); - }); -}); diff --git a/packages/diracx-web-components/test/JobMonitor.test.tsx b/packages/diracx-web-components/test/JobMonitor.test.tsx index 12442a73..d9ac835b 100644 --- a/packages/diracx-web-components/test/JobMonitor.test.tsx +++ b/packages/diracx-web-components/test/JobMonitor.test.tsx @@ -63,10 +63,10 @@ describe("JobDataTable", () => { , ); - // Verify table headers + // Verify table headers exist (some column names also appear in the pie chart toggle) expect(getByText("ID")).toBeInTheDocument(); - expect(getByText("Status")).toBeInTheDocument(); - expect(getByText("Name")).toBeInTheDocument(); + expect(screen.getAllByText("Status").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Name").length).toBeGreaterThanOrEqual(1); // Verify job data is displayed await waitFor(() => { diff --git a/packages/diracx-web-components/test/LoginForm.test.tsx b/packages/diracx-web-components/test/LoginForm.test.tsx index 3498a5d8..a6326070 100644 --- a/packages/diracx-web-components/test/LoginForm.test.tsx +++ b/packages/diracx-web-components/test/LoginForm.test.tsx @@ -15,28 +15,28 @@ describe("LoginForm", () => { render(); // now immediately rendered - expect(screen.getByTestId("h3-vo-name")).toBeInTheDocument(); - expect(screen.queryByTestId("autocomplete-vo-select")).toBeNull(); - expect(screen.getByTestId("select-group")).toBeInTheDocument(); - expect(screen.getByTestId("button-login")).toBeInTheDocument(); + expect(screen.getByTestId("vo-name")).toBeInTheDocument(); + expect(screen.queryByTestId("vo-select")).toBeNull(); + expect(screen.getByTestId("group-select")).toBeInTheDocument(); + expect(screen.getByTestId("login-form-button")).toBeInTheDocument(); }); it("works for the MultiVO story", () => { render(); const input = screen - .getByTestId("autocomplete-vo-select") + .getByTestId("vo-select") .querySelector("input") as HTMLInputElement; // before selection - expect(screen.queryByTestId("button-login")).toBeNull(); + expect(screen.queryByTestId("login-form-button")).toBeNull(); // pick “LHCp” fireEvent.change(input, { target: { value: "LHC" } }); fireEvent.click(screen.getByText("LHCp")); - expect(screen.getByTestId("select-group")).toBeInTheDocument(); - expect(screen.getByTestId("button-login")).toBeInTheDocument(); + expect(screen.getByTestId("group-select")).toBeInTheDocument(); + expect(screen.getByTestId("login-form-button")).toBeInTheDocument(); }); it("works for the Error story", () => { @@ -45,13 +45,13 @@ describe("LoginForm", () => { expect( screen.getByText("An error occurred while fetching metadata."), ).toBeInTheDocument(); - expect(screen.queryByTestId("h3-vo-name")).toBeNull(); + expect(screen.queryByTestId("vo-name")).toBeNull(); }); it("works for the Loading story", () => { render(); expect(screen.getByText("Loading...")).toBeInTheDocument(); - expect(screen.queryByTestId("h3-vo-name")).toBeNull(); + expect(screen.queryByTestId("vo-name")).toBeNull(); }); }); diff --git a/packages/diracx-web-components/test/SearchBar.test.tsx b/packages/diracx-web-components/test/SearchBar.test.tsx index a6ed2396..7c970c8d 100644 --- a/packages/diracx-web-components/test/SearchBar.test.tsx +++ b/packages/diracx-web-components/test/SearchBar.test.tsx @@ -120,7 +120,7 @@ describe("SearchBar", () => { // Check if delete button is present await waitFor(() => { - const deleteButton = screen.getByTestId("DeleteIcon"); + const deleteButton = screen.getByTestId("clear-filters-button"); expect(deleteButton).toBeInTheDocument(); }); }); @@ -135,7 +135,7 @@ describe("SearchBar", () => { expect(screen.getByText("Running | Completed")).toBeInTheDocument(); // Click delete button - const deleteButton = screen.getByTestId("DeleteIcon"); + const deleteButton = screen.getByTestId("clear-filters-button"); user.click(deleteButton); }); diff --git a/packages/diracx-web-components/test/Sunburst.test.tsx b/packages/diracx-web-components/test/Sunburst.test.tsx deleted file mode 100644 index 941ec095..00000000 --- a/packages/diracx-web-components/test/Sunburst.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { composeStories } from "@storybook/react"; -import { Sunburst } from "../src/components/shared/Sunburst/Sunburst"; -import { SunburstTree } from "../src/types"; -import * as stories from "../stories/Sunburst.stories"; // Importing all stories to use in tests - -// Sample tree data for testing -const mockTree: SunburstTree = { - name: "Root", - value: 2900, - children: [ - { - name: "Production", - value: 1500, - children: [ - { name: "Running", value: 800 }, - { name: "Completed", value: 500 }, - { name: "Failed", value: 200 }, - ], - }, - { - name: "Development", - value: 800, - children: [ - { name: "Testing", value: 400 }, - { name: "Debugging", value: 300 }, - { name: "Review", value: 100 }, - ], - }, - { - name: "Maintenance", - value: 600, - children: [ - { name: "Updates", value: 300 }, - { name: "Backups", value: 200 }, - { name: "Monitoring", value: 100 }, - ], - }, - ], -}; - -// Sample tree data with small segments that should be grouped into "Others" - -// Empty tree for testing -const emptyTree: SunburstTree = { - name: "Empty", - value: 0, - children: [], -}; - -// Custom size to text function for testing -const customSizeToText = (size: number, total?: number) => { - if (total) { - return `${size} of ${total} (${Math.round((size / total) * 100)}%)`; - } - return `${size} items`; -}; - -describe("Sunburst Component", () => { - const { Default } = composeStories(stories); - - describe("Rendering States", () => { - test("renders loading skeleton when isLoading is true", () => { - render(); - const loadingSkeleton = screen.getByTestId("loading-skeleton"); - expect(loadingSkeleton).toBeInTheDocument(); - }); - - test("renders error message when error is provided", () => { - const errorMessage = "Failed to load data"; - render( - , - ); - const errorAlert = screen.getByText(errorMessage); - expect(errorAlert).toBeInTheDocument(); - }); - - test("renders default error message when error object has no message", () => { - render( - , - ); - const defaultErrorMessage = screen.getByText( - "An error occurred while loading the data.", - ); - expect(defaultErrorMessage).toBeInTheDocument(); - }); - - test("renders the sunburst chart when data is provided and not loading or error", () => { - render(); - // Since D3 is mocked, we check that SVG is rendered - const svg = document.querySelector("svg"); - expect(svg).toBeInTheDocument(); - }); - - test("handles empty tree data", () => { - render(); - const svg = document.querySelector("svg"); - expect(svg).toBeInTheDocument(); - // We don't expect any errors to be thrown - }); - }); - - describe("Custom Rendering", () => { - test("uses custom size to text function when provided", () => { - render( - , - ); - - // Since D3 is mocked, we can't directly test the text content - // but we can verify the component rendered without errors - const svg = document.querySelector("svg"); - expect(svg).toBeInTheDocument(); - }); - }); - - describe("Performance", () => { - test("handles large datasets without crashing", () => { - // Create a large dataset - const largeTree: SunburstTree = { - name: "Root", - value: 0, - children: [], - }; - - // Add 5000 children - for (let i = 0; i < 5000; i++) { - largeTree.children!.push({ - name: `Node ${i}`, - value: i + 1, - }); - } - - // This should render without crashing - render(); - const svg = document.querySelector("svg"); - expect(svg).toBeInTheDocument(); - }); - }); - - describe("Component Lifecycle", () => { - test("component should update when current path changes", () => { - const setCurrentPathMock = jest.fn(); - const { rerender } = render( - , - ); - - // Rerender with different current path - rerender( - , - ); - - // In a real test environment, we would check if the visualization has updated - const svg = document.querySelector("svg"); - expect(svg).toBeInTheDocument(); - }); - }); - - // The previous tests cover the main functionalities of the Sunburst component. - // Here we just ensure that the story renders correctly. - describe("Storybook Integration", () => { - test("renders the Default story correctly", () => { - render(); - expect(screen.getByText("Top")).toBeInTheDocument(); - expect(screen.getByTestId("sunburst-chart")).toBeInTheDocument(); - }); - - test("renders while loading", () => { - render(); - expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/diracx-web-components/tsconfig.json b/packages/diracx-web-components/tsconfig.json index 449ca6d6..aede4a7c 100644 --- a/packages/diracx-web-components/tsconfig.json +++ b/packages/diracx-web-components/tsconfig.json @@ -31,7 +31,6 @@ "app", "node_modules", "dist", - "tsup.config.ts", - "jest.setup.ts" + "tsup.config.ts" ] } diff --git a/packages/diracx-web/cypress.config.ts b/packages/diracx-web/cypress.config.ts index b0cb9975..ccfebed3 100644 --- a/packages/diracx-web/cypress.config.ts +++ b/packages/diracx-web/cypress.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "cypress"; export default defineConfig({ e2e: { specPattern: "test/e2e/**/*.cy.ts", - supportFile: false, + supportFile: "test/e2e/support/e2e.ts", setupNodeEvents(_on, _config) { // implement node event listeners here }, diff --git a/packages/diracx-web/test/e2e/dashboard.cy.ts b/packages/diracx-web/test/e2e/dashboard.cy.ts index 9e8faee7..4777a884 100644 --- a/packages/diracx-web/test/e2e/dashboard.cy.ts +++ b/packages/diracx-web/test/e2e/dashboard.cy.ts @@ -1,22 +1,9 @@ /// +/// describe("DashboardDrawer", { retries: { runMode: 5, openMode: 3 } }, () => { beforeEach(() => { - cy.session("login", () => { - // Visit the page where the DashboardDrawer is rendered - cy.visit("/"); - - //login - cy.get('[data-testid="button-login"]').click(); - cy.get("#login").type("admin@example.com"); - cy.get("#password").type("password"); - - // Find the login button and click on it - cy.get("button").click(); - // Grant access - cy.get(":nth-child(1) > form > .dex-btn").click(); - cy.url().should("include", "/auth"); - }); + cy.login(); cy.window().then((win) => { win.sessionStorage.setItem( "savedDashboard", diff --git a/packages/diracx-web/test/e2e/importExportState.cy.ts b/packages/diracx-web/test/e2e/importExportState.cy.ts index 539607a0..a7a1e041 100644 --- a/packages/diracx-web/test/e2e/importExportState.cy.ts +++ b/packages/diracx-web/test/e2e/importExportState.cy.ts @@ -1,20 +1,9 @@ /// +/// describe("Export and import app state", () => { beforeEach(() => { - cy.session("login", () => { - cy.visit("/"); - //login - cy.get('[data-testid="button-login"]').click(); - cy.get("#login").type("admin@example.com"); - cy.get("#password").type("password"); - - // Find the login button and click on it - cy.get("button").click(); - // Grant access - cy.get(":nth-child(1) > form > .dex-btn").click(); - cy.url().should("include", "/auth"); - }); + cy.login(); cy.visit("/"); diff --git a/packages/diracx-web/test/e2e/jobMonitor.columns.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.columns.cy.ts new file mode 100644 index 00000000..7d2a6207 --- /dev/null +++ b/packages/diracx-web/test/e2e/jobMonitor.columns.cy.ts @@ -0,0 +1,122 @@ +/// +/// + +import { + setupJobMonitorDashboard, + ensureMinimumJobs, +} from "./support/jobMonitorUtils"; + +describe("Job Monitor - Columns", () => { + beforeEach(() => { + cy.login(); + + cy.visit("/"); + setupJobMonitorDashboard(); + + cy.contains("Job Monitor").click(); + + ensureMinimumJobs(55); + }); + + it("should hide/show columns", () => { + // Make sure "VO" is not in the header and "Status" is + cy.get("table thead tr th").should("not.contain", "VO"); + cy.get("table thead tr th").should("contain", "Status"); + + // Click on the visibility icon + cy.get('[data-testid="column-visibility-button"]').click(); + cy.get('[data-testid="column-visibility-popover"]').should("be.visible"); + + // Hide the "Site" column and Show the "VO" column + cy.get('[data-testid="column-visibility-popover"]') + .contains("Site") + .parent() + .find('input[type="checkbox"]') + .click(); + cy.get('[data-testid="column-visibility-popover"]') + .contains("VO") + .parent() + .find('input[type="checkbox"]') + .click(); + + // Close the popover by clicking outside + cy.get("body").click(0, 0); + cy.get('[data-testid="column-visibility-popover"]').should("not.exist"); + + // Wait for the table to re-render with updated columns + cy.wait(1000); + + // Verify "VO" is now present and "Site" is gone + cy.get("table thead tr th").should("contain", "VO"); + cy.get("table thead tr th").should("not.contain", "Site"); + }); + + it("should resize a column", () => { + cy.get("table thead tr th") + .eq(2) + .invoke("width") + .then((initialWidth) => { + // Convert the width to a number + const initialWidthNum = Number(initialWidth); + + // Resize the column + cy.get( + ".MuiTableHead-root > .MuiTableRow-root > :nth-child(3) > .MuiBox-root", + ) + .trigger("mousedown", { which: 1 }) // Start the drag + .trigger("mousemove", { clientX: 200 }) // Move to the desired location + .trigger("mouseup"); // Release to finish resizing + + // Check if the column width has changed (it should be larger than the initial width) + cy.get("table thead tr th") + .eq(2) + .invoke("width") + .then((newWidth) => { + // Convert the new width to a number and compare + const newWidthNum = Number(newWidth); + expect(initialWidthNum).to.be.greaterThan(newWidthNum); + }); + }); + }); + + it("should sort column", () => { + let firstValue: number; + let firstValueSorted: number; + let firstValueAgain: number; + + // Get the first visible row value (e.g. 55) + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + firstValue = parseInt(text.trim(), 10); + }); + + cy.get('[data-testid="sort-JobID"]').click(); + + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + firstValueSorted = parseInt(text.trim(), 10); + expect(firstValue).to.be.greaterThan(firstValueSorted); + }); + + cy.get('[data-testid="sort-JobID"]').click(); + + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + firstValueAgain = parseInt(text.trim(), 10); + expect(firstValueAgain).to.be.greaterThan(firstValueSorted); + expect(firstValue).to.be.equal(firstValueAgain); + }); + }); +}); diff --git a/packages/diracx-web/test/e2e/jobMonitor.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.cy.ts deleted file mode 100644 index 799c61a8..00000000 --- a/packages/diracx-web/test/e2e/jobMonitor.cy.ts +++ /dev/null @@ -1,571 +0,0 @@ -/// - -describe("Job Monitor", () => { - beforeEach(() => { - cy.session("login", () => { - cy.visit("/"); - //login - cy.get('[data-testid="button-login"]').click(); - cy.get("#login").type("admin@example.com"); - cy.get("#password").type("password"); - - // Find the login button and click on it - cy.get("button").click(); - // Grant access - cy.get(":nth-child(1) > form > .dex-btn").click(); - cy.url().should("include", "/auth"); - }); - - cy.visit("/"); - // Visit the page where the Job Monitor is rendered - cy.window().then((win) => { - win.sessionStorage.setItem( - "savedDashboard", - '[{"title":"Group 2","extended":true,"items":[{"title":"Job Monitor","id":"Job Monitor0","type":"Job Monitor"},{"title":"Job Monitor 2","id":"Job Monitor 21","type":"Job Monitor"}]}]', - ); - }); - - cy.contains("Job Monitor").click(); - - // Is there a table with enough jobs? If not we should add some jobs - const checkAndAddJobs = (minNumberOfJobs: number) => { - cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { - const lastNumber = parseInt($pagination.text().split(" ").pop() || "0"); - - if (lastNumber < minNumberOfJobs) { - const numberOfJobsToAdd = minNumberOfJobs - lastNumber; - addJobs(numberOfJobsToAdd); - } else { - // Ensure the table is visible - cy.get("table").should("be.visible"); - } - }); - }; - - const addJobs = (numberOfJobs) => { - // Retrieve the access token from session storage - cy.window().then((win) => { - const sessionData = win.sessionStorage.getItem( - "oidc.vo:diracAdmin group:admin", - ); - - if (!sessionData) { - throw new Error("Access token not found in session storage"); - } - - const accessToken = JSON.parse(sessionData).tokens.accessToken; - - Cypress._.times(numberOfJobs, () => { - cy.request({ - method: "POST", - url: "/api/jobs/jdl", - headers: { - Authorization: `Bearer ${accessToken}`, - }, - body: ['Arguments = "jobDescription.xml -o LogLevel=INFO'], - }).then((response) => { - expect(response.status).to.eq(200); - }); - }); - }); - }; - - cy.contains("Loading OIDC Configuration").should("not.exist"); - cy.contains("Loading").should("not.exist"); - cy.get('[data-testid="loading-skeleton"]').should("not.exist"); - - cy.get("body").then(($body) => { - if ( - $body.find('div:contains("No data or no results match your filters.")') - .length > 0 - ) { - cy.log("No data available, adding jobs"); - addJobs(55); - // Wait for the jobs to be created - cy.wait(2000); - } else { - cy.log("Data available, checking if enough jobs are present"); - checkAndAddJobs(55); - } - - // refresh the jobs - cy.get('[data-testid="RefreshIcon"]').click(); - }); - }); - - it("should render the drawer", () => { - cy.get("header").contains("Job Monitor").should("be.visible"); - }); - - /** Pagination */ - - it("should make sure the initial pagination is correct", () => { - cy.get('[aria-label="Go to previous page"]').should("be.disabled"); - cy.get('[aria-label="Go to first page"]').should("be.disabled"); - cy.get('[aria-label="Go to next page"]').should("not.be.disabled"); - cy.get('[aria-label="Go to last page"]').should("not.be.disabled"); - - cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { - const text = $pagination.text(); // e.g., "1-25 of 55" - expect(text).to.match(/\d+[–-]\d+ of \d+/); - }); - - let firstValue: number; - let lastValue: number; - - // Get the first visible row value (e.g. 55) - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - firstValue = parseInt(text.trim(), 10); - }); - - // Scroll and get the last visible row value (e.g. 31) - cy.get('[data-testid="virtuoso-scroller"]') - .wait(100) - .scrollTo("bottom", { ensureScrollable: false }); - - cy.get("table tbody tr") - .last() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - lastValue = parseInt(text.trim(), 10); - expect(firstValue).to.be.greaterThan(lastValue); - }); - }); - - it("should go to the next page", () => { - let firstValue: number; - let firstValueNextPage: number; - - // Get the first visible row value (e.g. 55) - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - firstValue = parseInt(text.trim(), 10); - }); - - // Go to the next page - cy.get('[aria-label="Go to next page"]').click(); - - cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { - // Extract the page index, the number of items per page and the total number of items - const text = $pagination.text(); // "26-50 of 55" - expect(text).to.match(/\d+[–-]\d+ of \d+/); - - // Extract numbers using a regular expression - const match = text.match(/\d+/g); - if (match) { - const [pageIndexBegin, pageIndexEnd, totalItems] = match.map(Number); - expect(pageIndexBegin).to.equal(26); - expect(pageIndexEnd).to.equal(50); - expect(totalItems).to.be.greaterThan(50); - } - }); - - cy.get("table tbody tr") - .last() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - firstValueNextPage = parseInt(text.trim(), 10); - expect(firstValue).to.be.greaterThan(firstValueNextPage); - }); - }); - - it("should change the page size", () => { - cy.get(".MuiTablePagination-input .MuiSelect-select").click(); - - cy.get('ul[role="listbox"]') // MUI renders this when the dropdown opens - .should("be.visible") - .contains("li", "50") // Target the
  • inside the listbox with value 50 - .click(); - - cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { - // Extract the page index, the number of items per page and the total number of items - const text = $pagination.text(); // "26-50 of 55" - expect(text).to.match(/\d+[–-]\d+ of \d+/); - - // Extract numbers using a regular expression - const match = text.match(/\d+/g); - if (match) { - const [pageIndexBegin, pageIndexEnd, totalItems] = match.map(Number); - expect(pageIndexBegin).to.equal(1); - expect(pageIndexEnd).to.equal(50); - expect(totalItems).to.be.greaterThan(50); - } - }); - - cy.wait(1000); // Wait for the table to update - - let firstValue: number; - let lastValue: number; - - // Get the first visible row value (e.g. 55) - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - firstValue = parseInt(text.trim(), 10); - }); - - // Scroll and get the last visible row value - cy.get('[data-testid="virtuoso-scroller"]') - .wait(100) - .scrollTo("bottom", { ensureScrollable: false }); - - cy.get("table tbody tr") - .last() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - lastValue = parseInt(text.trim(), 10); - expect(firstValue).to.be.greaterThan(lastValue); - }); - }); - - /** Row interactions */ - - it("should display job history dialog", () => { - cy.get("table tbody tr").first().find("td").eq(3).rightclick(); - - // A context menu should appear - cy.contains("Get history").should("be.visible"); - cy.contains("Get history").click(); - - // A dialog should appear - cy.contains("Job History:").should("be.visible"); - }); - - it("should kill jobs", () => { - cy.get("[data-index=0]").click({ force: true }); - cy.get("[data-index=1]").click({ force: true }); - cy.get("[data-index=2]").click({ force: true }); - - cy.get('[data-testid="ClearIcon"] > path').click(); - - // Make sure the job status is "Killed" - cy.get("[data-index=0]").find("td").eq(2).should("contain", "Killed"); - cy.get("[data-index=1]").find("td").eq(2).should("contain.text", "Killed"); - cy.get("[data-index=2]").find("td").eq(2).should("contain.text", "Killed"); - }); - - it("should delete jobs", () => { - cy.get("[data-index=0]").as("jobItem1"); - cy.get("[data-index=1]").as("jobItem2"); - cy.get("[data-index=2]").as("jobItem3"); - cy.get("@jobItem1").click({ force: true }); - cy.get("@jobItem2").click({ force: true }); - cy.get("@jobItem3").click({ force: true }); - - cy.get('[data-testid="delete-jobs-button"] > path').click(); - - // Make sure the jobs disappeared from the table - cy.get("table").should("be.visible"); - cy.get("@jobItem1").find("td").eq(2).should("contain", "Deleted"); - cy.get("@jobItem2").find("td").eq(2).should("contain", "Deleted"); - cy.get("@jobItem3").find("td").eq(2).should("contain", "Deleted"); - }); - - // ### FIXME: The reschedule functionality is not working as expected ### - // The test below would be decommented once the reschedule functionality is fixed in diracx - - // it("should reschedule jobs", () => { - // cy.wait(1000); // Wait for the table to load - - // cy.get("[data-testid=search-field]").type("Reschedule Counter{enter}!={enter}3{enter}"); - - // cy.wait(1000); // Wait for the search to complete - - // // Create aliases for the job items - // cy.get("[data-index=0]").as("jobItem1"); - // cy.get("[data-index=1]").as("jobItem2"); - // cy.get("[data-index=2]").as("jobItem3"); - - // // First, kill the jobs to ensure they can be rescheduled - // cy.get("@jobItem1").click({ force: true }); - // cy.get("@jobItem2").click({ force: true }); - // cy.get("@jobItem3").click({ force: true }); - - // cy.get('[data-testid="ClearIcon"] > path').click(); - - // // Then, select the jobs to reschedule - // cy.get("@jobItem1").click({ force: true }); - // cy.get("@jobItem2").click({ force: true }); - // cy.get("@jobItem3").click({ force: true }); - - // cy.get('[data-testid="ReplayIcon"] > path').click({ force: true }); - // cy.get('[aria-label="Reschedule"]').click({ force: true }); - // cy.get('[data-testid="ReplayIcon"] > path').click({ force: true }); - // cy.get('[aria-label="Reschedule"]').click({ force: true }); - - // // Make sure the job status is "Received" - // cy.get("[data-index=0]").find("td").eq(2).should("contain", "Received"); - // cy.get("[data-index=1]").find("td").eq(2).should("contain", "Received"); - // cy.get("[data-index=2]").find("td").eq(2).should("contain", "Received"); - // }); - - /** Column interactions */ - - it("should hide/show columns", () => { - // Loop over the table column and make sure that "VO" is not present - cy.get("table thead tr th").each(($th) => { - if ($th.text() === "VO") { - expect($th).to.not.exist; - } - if ($th.text() === "Status") { - expect($th).to.exist; - } - }); - - cy.wait(1000); // Wait for the table to load - - // Click on the visibility icon - cy.get('[data-testid="VisibilityIcon"] > path').click(); - cy.get('[data-testid="column-visibility-popover"]').should("be.visible"); - - // Hide the "Status" column and Show the "VO" column - cy.get('[data-testid="column-visibility-popover"]') - .contains("Status") - .parent() - .find('input[type="checkbox"]') - .click(); - cy.get('[data-testid="column-visibility-popover"]') - .contains("VO") - .parent() - .find('input[type="checkbox"]') - .click(); - - // Close the popover by clicking outside - cy.get("body").click(0, 0); - cy.get('[data-testid="column-visibility-popover"]').should("not.exist"); - - // Loop over the table column and make sure that "VO" is present - cy.get("table thead tr th").each(($th) => { - if ($th.text() === "VO") { - expect($th).to.exist; - } - if ($th.text() === "Status") { - expect($th).to.not.exist; - } - }); - }); - - it("should resize a column", () => { - cy.get("table thead tr th") - .eq(2) - .invoke("width") - .then((initialWidth) => { - // Convert the width to a number - const initialWidthNum = Number(initialWidth); - - // Resize the column - cy.get( - ".MuiTableHead-root > .MuiTableRow-root > :nth-child(3) > .MuiBox-root", - ) - .trigger("mousedown", { which: 1 }) // Start the drag - .trigger("mousemove", { clientX: 200 }) // Move to the desired location - .trigger("mouseup"); // Release to finish resizing - - // Check if the column width has changed (it should be larger than the initial width) - cy.get("table thead tr th") - .eq(2) - .invoke("width") - .then((newWidth) => { - // Convert the new width to a number and compare - const newWidthNum = Number(newWidth); - expect(initialWidthNum).to.be.greaterThan(newWidthNum); - }); - }); - }); - - it("should sort column", () => { - let firstValue: number; - let firstValueSorted: number; - let firstValueAgain: number; - - // Get the first visible row value (e.g. 55) - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - firstValue = parseInt(text.trim(), 10); - }); - - cy.get('[data-testid="sort-JobID"]').click(); - - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - firstValueSorted = parseInt(text.trim(), 10); - expect(firstValue).to.be.greaterThan(firstValueSorted); - }); - - cy.get('[data-testid="sort-JobID"]').click(); - - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - firstValueAgain = parseInt(text.trim(), 10); - expect(firstValueAgain).to.be.greaterThan(firstValueSorted); - expect(firstValue).to.be.equal(firstValueAgain); - }); - }); - - /** Filters */ - - it("should handle filter addition", () => { - cy.get("table").should("be.visible"); - cy.get("[data-testid=search-bar]"); - - cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); - - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 3); - }); - - it("should handle filter editing", () => { - cy.get("table").should("be.visible"); - - cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); - - cy.get("[data-testid=search-field]").type("{leftArrow}2{enter}"); - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .contains("12") - .should("exist"); - }); - - it("should handle filter clear", () => { - cy.get("table").should("be.visible"); - - cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); - - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 3); - - cy.get('[data-testid="DeleteIcon"]').click(); - - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 0); - }); - - it("should handle filter apply and persist", () => { - cy.get("table").should("be.visible"); - - let jobID: string; - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - jobID = text.trim(); - - cy.get("[data-testid=search-field]").type( - `ID{enter}={enter}${jobID}{enter}`, - ); - }); - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 3); - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .contains("ID") - .should("exist"); - - // Wait for the filter to apply - cy.wait(1000); - - cy.get("table tbody tr").should("have.length", 1); - }); - - it("should handle filter apply and save filters in dashboard", () => { - cy.get("table").should("be.visible"); - - let jobID: string; - cy.get("table tbody tr") - .first() - .find("td") - .eq(1) - .invoke("text") - .then((text) => { - jobID = text.trim(); - - cy.get("[data-testid=search-field]").type( - `ID{enter}={enter}${jobID}{enter}`, - ); - }); - - // Wait for the filter to apply - cy.wait(1000); - - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 3); - - cy.get(".MuiButtonBase-root").contains("Job Monitor 2").click(); - - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 0); - - cy.get(".MuiButtonBase-root").contains("Job Monitor").click(); - - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 3); - }); - - it("should control the in the last operator utilization", () => { - cy.get("table").should("be.visible"); - cy.get("[data-testid=search-field]").type( - "Submission Time{enter}in the last{enter}4206942 years{enter}", - ); - - // Wait for the filter to apply - cy.wait(1000); - - cy.get('[data-testid="search-bar"]') - .find(".MuiChip-root") - .should("have.length", 3); - - cy.get("table").should("be.visible"); - }); - - /** Sunburst */ - - it("should render the sunburst chart", () => { - // Click on the sunburst button - cy.get('[role="group"]').get("[data-testid='DonutSmallIcon']").click(); - - // Make sure the sunburst chart is visible - cy.get('[data-testid="sunburst-chart"]').should("be.visible"); - - // Make sure the column selector is visible - cy.get('[data-testid="column-selector"]').should("be.visible"); - }); -}); diff --git a/packages/diracx-web/test/e2e/jobMonitor.filters.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.filters.cy.ts new file mode 100644 index 00000000..7af4344e --- /dev/null +++ b/packages/diracx-web/test/e2e/jobMonitor.filters.cy.ts @@ -0,0 +1,136 @@ +/// +/// + +import { + setupJobMonitorDashboard, + ensureMinimumJobs, +} from "./support/jobMonitorUtils"; + +describe("Job Monitor - Filters", () => { + beforeEach(() => { + cy.login(); + + cy.visit("/"); + setupJobMonitorDashboard(); + + cy.contains("Job Monitor").click(); + + ensureMinimumJobs(55); + }); + + it("should handle filter addition", () => { + cy.get("table").should("be.visible"); + cy.get("[data-testid=search-bar]"); + + cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + }); + + it("should handle filter editing", () => { + cy.get("table").should("be.visible"); + + cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); + + cy.get("[data-testid=search-field]").type("{leftArrow}2{enter}"); + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .contains("12") + .should("exist"); + }); + + it("should handle filter clear", () => { + cy.get("table").should("be.visible"); + + cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + + cy.get('[data-testid="clear-filters-button"]').click(); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 0); + }); + + it("should handle filter apply and persist", () => { + cy.get("table").should("be.visible"); + + let jobID: string; + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + jobID = text.trim(); + + cy.get("[data-testid=search-field]").type( + `ID{enter}={enter}${jobID}{enter}`, + ); + }); + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .contains("ID") + .should("exist"); + + cy.get("table tbody tr").should("have.length", 1); + }); + + it("should handle filter apply and save filters in dashboard", () => { + cy.get("table").should("be.visible"); + + let jobID: string; + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + jobID = text.trim(); + + cy.get("[data-testid=search-field]").type( + `ID{enter}={enter}${jobID}{enter}`, + ); + }); + + // Wait for the filter to apply and state to be saved + cy.wait(1000); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + + cy.get(".MuiButtonBase-root").contains("Job Monitor 2").click(); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 0); + + cy.get(".MuiButtonBase-root").contains("Job Monitor").click(); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + }); + + it("should control the in the last operator utilization", () => { + cy.get("table").should("be.visible"); + cy.get("[data-testid=search-field]").type( + "Submission Time{enter}in the last{enter}4206942 years{enter}", + ); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + + cy.get("table").should("be.visible"); + }); +}); diff --git a/packages/diracx-web/test/e2e/jobMonitor.pagination.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.pagination.cy.ts new file mode 100644 index 00000000..c3f96491 --- /dev/null +++ b/packages/diracx-web/test/e2e/jobMonitor.pagination.cy.ts @@ -0,0 +1,162 @@ +/// +/// + +import { + setupJobMonitorDashboard, + ensureMinimumJobs, +} from "./support/jobMonitorUtils"; + +describe("Job Monitor - Pagination", () => { + beforeEach(() => { + cy.login(); + + cy.visit("/"); + setupJobMonitorDashboard(); + + cy.contains("Job Monitor").click(); + + ensureMinimumJobs(55); + }); + + it("should render the drawer", () => { + cy.get("header").contains("Job Monitor").should("be.visible"); + }); + + it("should make sure the initial pagination is correct", () => { + cy.get('[aria-label="Go to previous page"]').should("be.disabled"); + cy.get('[aria-label="Go to first page"]').should("be.disabled"); + cy.get('[aria-label="Go to next page"]').should("not.be.disabled"); + cy.get('[aria-label="Go to last page"]').should("not.be.disabled"); + + cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { + const text = $pagination.text(); // e.g., "1-25 of 55" + expect(text).to.match(/\d+[–-]\d+ of \d+/); + }); + + let firstValue: number; + let lastValue: number; + + // Get the first visible row value (e.g. 55) + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + firstValue = parseInt(text.trim(), 10); + }); + + // Scroll and get the last visible row value (e.g. 31) + cy.get('[data-testid="virtuoso-scroller"]') + .wait(100) + .scrollTo("bottom", { ensureScrollable: false }); + + cy.get("table tbody tr") + .last() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + lastValue = parseInt(text.trim(), 10); + expect(firstValue).to.be.greaterThan(lastValue); + }); + }); + + it("should go to the next page", () => { + let firstValue: number; + let firstValueNextPage: number; + + // Get the first visible row value (e.g. 55) + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + firstValue = parseInt(text.trim(), 10); + }); + + // Go to the next page + cy.get('[aria-label="Go to next page"]').click(); + + cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { + // Extract the page index, the number of items per page and the total number of items + const text = $pagination.text(); // "26-50 of 55" + expect(text).to.match(/\d+[–-]\d+ of \d+/); + + // Extract numbers using a regular expression + const match = text.match(/\d+/g); + if (match) { + const [pageIndexBegin, pageIndexEnd, totalItems] = match.map(Number); + expect(pageIndexBegin).to.equal(26); + expect(pageIndexEnd).to.equal(50); + expect(totalItems).to.be.greaterThan(50); + } + }); + + cy.get("table tbody tr") + .last() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + firstValueNextPage = parseInt(text.trim(), 10); + expect(firstValue).to.be.greaterThan(firstValueNextPage); + }); + }); + + it("should change the page size", () => { + cy.get(".MuiTablePagination-input .MuiSelect-select").click(); + + cy.get('ul[role="listbox"]') // MUI renders this when the dropdown opens + .should("be.visible") + .contains("li", "50") // Target the
  • inside the listbox with value 50 + .click(); + + cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { + // Extract the page index, the number of items per page and the total number of items + const text = $pagination.text(); // "1-50 of 55" + expect(text).to.match(/\d+[–-]\d+ of \d+/); + + // Extract numbers using a regular expression + const match = text.match(/\d+/g); + if (match) { + const [pageIndexBegin, pageIndexEnd, totalItems] = match.map(Number); + expect(pageIndexBegin).to.equal(1); + expect(pageIndexEnd).to.equal(50); + expect(totalItems).to.be.greaterThan(50); + } + }); + + // Wait for the table to update with new page size + cy.get(".MuiTablePagination-displayedRows").should("contain", "1"); + + let firstValue: number; + let lastValue: number; + + // Get the first visible row value (e.g. 55) + cy.get("table tbody tr") + .first() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + firstValue = parseInt(text.trim(), 10); + }); + + // Scroll and get the last visible row value + cy.get('[data-testid="virtuoso-scroller"]') + .wait(100) + .scrollTo("bottom", { ensureScrollable: false }); + + cy.get("table tbody tr") + .last() + .find("td") + .eq(1) + .invoke("text") + .then((text) => { + lastValue = parseInt(text.trim(), 10); + expect(firstValue).to.be.greaterThan(lastValue); + }); + }); +}); diff --git a/packages/diracx-web/test/e2e/jobMonitor.pieChart.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.pieChart.cy.ts new file mode 100644 index 00000000..b3a18a1f --- /dev/null +++ b/packages/diracx-web/test/e2e/jobMonitor.pieChart.cy.ts @@ -0,0 +1,85 @@ +/// +/// + +import { + setupJobMonitorDashboard, + ensureMinimumJobs, +} from "./support/jobMonitorUtils"; + +describe("Job Monitor - Pie Chart", () => { + beforeEach(() => { + cy.login(); + + cy.visit("/"); + setupJobMonitorDashboard(); + + cy.contains("Job Monitor").click(); + + ensureMinimumJobs(55); + }); + + it("should render the pie chart alongside the table", () => { + // Both table and pie chart should be visible (no toggle needed) + cy.get("table").should("be.visible"); + cy.get('[data-testid="job-pie-chart"]').should("be.visible"); + cy.get('[data-testid="group-selector"]').should("be.visible"); + }); + + it("should add a filter when clicking a pie chart slice", () => { + // Ensure pie chart is rendered with slices + cy.get('[data-testid="job-pie-chart"]') + .find(".MuiPieArc-root") + .should("have.length.greaterThan", 0); + + // Record the number of rows before clicking + cy.get(".MuiTablePagination-displayedRows") + .invoke("text") + .then((textBefore) => { + const totalBefore = parseInt(textBefore.split("of")[1].trim(), 10); + + // Click the first pie slice + cy.get('[data-testid="job-pie-chart"]') + .find(".MuiPieArc-root.MuiPieArc-data-index-0") + .click({ force: true }); + + // A filter chip should appear in the search bar + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + + // The total number of jobs should be less than or equal to before + cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { + const totalAfter = parseInt( + $pagination.text().split("of")[1].trim(), + 10, + ); + expect(totalAfter).to.be.at.most(totalBefore); + }); + }); + }); + + it("should update the pie chart when changing the group-by column", () => { + // The default group is "Status", switch to "Site" + cy.get('[data-testid="group-selector"]').contains("Site").click(); + + // The pie chart should still render with slices + cy.get('[data-testid="job-pie-chart"]') + .find(".MuiPieArc-root") + .should("have.length.greaterThan", 0); + + // Click a slice to verify the filter uses the new group column + cy.get('[data-testid="job-pie-chart"]') + .find(".MuiPieArc-root.MuiPieArc-data-index-0") + .click({ force: true }); + + // A filter chip should appear with the Site column + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .should("have.length", 3); + + cy.get('[data-testid="search-bar"]') + .find(".MuiChip-root") + .first() + .should("contain.text", "Site"); + }); +}); diff --git a/packages/diracx-web/test/e2e/jobMonitor.rowActions.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.rowActions.cy.ts new file mode 100644 index 00000000..6869e6d1 --- /dev/null +++ b/packages/diracx-web/test/e2e/jobMonitor.rowActions.cy.ts @@ -0,0 +1,104 @@ +/// +/// + +import { + setupJobMonitorDashboard, + ensureMinimumJobs, +} from "./support/jobMonitorUtils"; + +describe("Job Monitor - Row Actions", () => { + beforeEach(() => { + cy.login(); + + cy.visit("/"); + setupJobMonitorDashboard(); + + cy.contains("Job Monitor").click(); + + ensureMinimumJobs(55); + }); + + it("should display job history dialog", () => { + cy.get("table tbody tr").first().find("td").eq(3).rightclick(); + + // A context menu should appear + cy.contains("Get history").should("be.visible"); + cy.contains("Get history").click(); + + // A dialog should appear + cy.contains("Job History:").should("be.visible"); + }); + + it("should kill jobs", () => { + cy.get("table tbody [data-index=0]").click({ force: true }); + cy.get("table tbody [data-index=1]").click({ force: true }); + cy.get("table tbody [data-index=2]").click({ force: true }); + + cy.get('[data-testid="kill-jobs-button"]').first().click(); + + // Make sure the job status is "Killed" + cy.get("table tbody [data-index=0]") + .find("td") + .eq(2) + .should("contain", "Killed"); + cy.get("table tbody [data-index=1]") + .find("td") + .eq(2) + .should("contain.text", "Killed"); + cy.get("table tbody [data-index=2]") + .find("td") + .eq(2) + .should("contain.text", "Killed"); + }); + + it("should delete jobs", () => { + cy.get("table tbody [data-index=0]").as("jobItem1"); + cy.get("table tbody [data-index=1]").as("jobItem2"); + cy.get("table tbody [data-index=2]").as("jobItem3"); + cy.get("@jobItem1").click({ force: true }); + cy.get("@jobItem2").click({ force: true }); + cy.get("@jobItem3").click({ force: true }); + + cy.get('[data-testid="delete-jobs-button"]').first().click(); + + // Make sure the jobs disappeared from the table + cy.get("table").should("be.visible"); + cy.get("@jobItem1").find("td").eq(2).should("contain", "Deleted"); + cy.get("@jobItem2").find("td").eq(2).should("contain", "Deleted"); + cy.get("@jobItem3").find("td").eq(2).should("contain", "Deleted"); + }); + + // ### FIXME: The reschedule functionality is not working as expected ### + // The test below would be decommented once the reschedule functionality is fixed in diracx + + // it("should reschedule jobs", () => { + // cy.get("[data-testid=search-field]").type("Reschedule Counter{enter}!={enter}3{enter}"); + + // // Create aliases for the job items + // cy.get("table tbody [data-index=0]").as("jobItem1"); + // cy.get("table tbody [data-index=1]").as("jobItem2"); + // cy.get("table tbody [data-index=2]").as("jobItem3"); + + // // First, kill the jobs to ensure they can be rescheduled + // cy.get("@jobItem1").click({ force: true }); + // cy.get("@jobItem2").click({ force: true }); + // cy.get("@jobItem3").click({ force: true }); + + // cy.get('[data-testid="kill-jobs-button"] > path').click(); + + // // Then, select the jobs to reschedule + // cy.get("@jobItem1").click({ force: true }); + // cy.get("@jobItem2").click({ force: true }); + // cy.get("@jobItem3").click({ force: true }); + + // cy.get('[data-testid="ReplayIcon"] > path').click({ force: true }); + // cy.get('[aria-label="Reschedule"]').click({ force: true }); + // cy.get('[data-testid="ReplayIcon"] > path').click({ force: true }); + // cy.get('[aria-label="Reschedule"]').click({ force: true }); + + // // Make sure the job status is "Received" + // cy.get("table tbody [data-index=0]").find("td").eq(2).should("contain", "Received"); + // cy.get("table tbody [data-index=1]").find("td").eq(2).should("contain", "Received"); + // cy.get("table tbody [data-index=2]").find("td").eq(2).should("contain", "Received"); + // }); +}); diff --git a/packages/diracx-web/test/e2e/loginOut.cy.ts b/packages/diracx-web/test/e2e/loginOut.cy.ts index db192f25..fa6d2512 100644 --- a/packages/diracx-web/test/e2e/loginOut.cy.ts +++ b/packages/diracx-web/test/e2e/loginOut.cy.ts @@ -16,7 +16,7 @@ describe("Login and Logout", () => { cy.url().should("include", "/auth"); // Continue with the default parameters - cy.get('[data-testid="button-login"]').click(); + cy.get('[data-testid="login-form-button"]').click(); // Extract name from baseUrl (remove http:// and port number) const domain = Cypress.config() @@ -41,9 +41,9 @@ describe("Login and Logout", () => { cy.url().should("include", "/auth"); // From now on the user is logged in - // The login buttton should not be present anymore - cy.get('[data-testid="button-login"]').should("not.exist"); - cy.contains("My Jobs").should("exist"); + // The login button should not be present anymore + cy.get('[data-testid="login-form-button"]').should("not.exist"); + cy.contains("My Jobs", { timeout: 10000 }).should("exist"); // Click on the user avatar cy.get(".MuiAvatar-root").click(); @@ -62,7 +62,7 @@ describe("Login and Logout", () => { // The user is logged out // The login button should be present - cy.get('[data-testid="button-login"]').should("exist"); + cy.get('[data-testid="login-form-button"]').should("exist"); // The user tries to access the dashboard page without being connected // The user is redirected to the /auth page diff --git a/packages/diracx-web/test/e2e/support/commands.ts b/packages/diracx-web/test/e2e/support/commands.ts new file mode 100644 index 00000000..2f440e0b --- /dev/null +++ b/packages/diracx-web/test/e2e/support/commands.ts @@ -0,0 +1,24 @@ +/// + +Cypress.Commands.add("login", () => { + cy.session("login", () => { + cy.visit("/"); + + // Login + cy.get('[data-testid="login-form-button"]').click(); + + // Handle OIDC provider login (cross-origin) + const domain = Cypress.config() + .baseUrl?.replace("https://", "") + .split(":")[0]; + + cy.origin(`http://${domain}:32002`, () => { + cy.get("#login").type("admin@example.com"); + cy.get("#password").type("password"); + cy.get("button").click(); + cy.get(":nth-child(1) > form > .dex-btn").click(); + }); + + cy.url().should("include", "/auth"); + }); +}); diff --git a/packages/diracx-web/test/e2e/support/e2e.ts b/packages/diracx-web/test/e2e/support/e2e.ts new file mode 100644 index 00000000..f887c29a --- /dev/null +++ b/packages/diracx-web/test/e2e/support/e2e.ts @@ -0,0 +1 @@ +import "./commands"; diff --git a/packages/diracx-web/test/e2e/support/index.d.ts b/packages/diracx-web/test/e2e/support/index.d.ts new file mode 100644 index 00000000..a76aa482 --- /dev/null +++ b/packages/diracx-web/test/e2e/support/index.d.ts @@ -0,0 +1,11 @@ +/// + +declare namespace Cypress { + interface Chainable { + /** + * Log in via the OIDC provider and cache the session. + * Uses cy.session() so the login flow only runs once per spec. + */ + login(): Chainable; + } +} diff --git a/packages/diracx-web/test/e2e/support/jobMonitorUtils.ts b/packages/diracx-web/test/e2e/support/jobMonitorUtils.ts new file mode 100644 index 00000000..d2afe0ed --- /dev/null +++ b/packages/diracx-web/test/e2e/support/jobMonitorUtils.ts @@ -0,0 +1,79 @@ +/** + * Set up the dashboard with two Job Monitor apps via sessionStorage. + * Call this after cy.login() and before cy.visit("/"). + */ +export function setupJobMonitorDashboard() { + cy.window().then((win) => { + win.sessionStorage.setItem( + "savedDashboard", + '[{"title":"Group 2","extended":true,"items":[{"title":"Job Monitor","id":"Job Monitor0","type":"Job Monitor"},{"title":"Job Monitor 2","id":"Job Monitor 21","type":"Job Monitor"}]}]', + ); + }); +} + +/** + * Submit jobs to the backend using the API. + */ +export function addJobs(numberOfJobs: number) { + cy.window().then((win) => { + const sessionData = win.sessionStorage.getItem( + "oidc.vo:diracAdmin group:admin", + ); + + if (!sessionData) { + throw new Error("Access token not found in session storage"); + } + + const accessToken = JSON.parse(sessionData).tokens.accessToken; + + Cypress._.times(numberOfJobs, () => { + cy.request({ + method: "POST", + url: "/api/jobs/jdl", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: ['Arguments = "jobDescription.xml -o LogLevel=INFO'], + }).then((response) => { + expect(response.status).to.eq(200); + }); + }); + }); +} + +/** + * Ensure there are at least `minNumberOfJobs` in the table. + * If not, add jobs and refresh. Call after the table is visible. + */ +export function ensureMinimumJobs(minNumberOfJobs: number) { + cy.contains("Loading OIDC Configuration").should("not.exist"); + cy.contains("Loading").should("not.exist"); + cy.get('[data-testid="loading-skeleton"]').should("not.exist"); + + cy.get("body").then(($body) => { + if ( + $body.find('div:contains("No data or no results match your filters.")') + .length > 0 + ) { + cy.log("No data available, adding jobs"); + addJobs(minNumberOfJobs); + // Wait for the jobs to be created on the backend + cy.wait(2000); + } else { + cy.log("Data available, checking if enough jobs are present"); + cy.get(".MuiTablePagination-displayedRows").then(($pagination) => { + const lastNumber = parseInt($pagination.text().split(" ").pop() || "0"); + + if (lastNumber < minNumberOfJobs) { + const numberOfJobsToAdd = minNumberOfJobs - lastNumber; + addJobs(numberOfJobsToAdd); + } else { + cy.get("table").should("be.visible"); + } + }); + } + + // Refresh the jobs + cy.get('[data-testid="refresh-search-button"]').click(); + }); +} diff --git a/packages/extensions/test/e2e/loginOut.cy.ts b/packages/extensions/test/e2e/loginOut.cy.ts index 58494cb9..8f75aef1 100644 --- a/packages/extensions/test/e2e/loginOut.cy.ts +++ b/packages/extensions/test/e2e/loginOut.cy.ts @@ -16,7 +16,7 @@ describe("Login and Logout", () => { cy.url().should("include", "/auth"); // Continue with the default parameters - cy.get('[data-testid="button-login"]').click(); + cy.get('[data-testid="login-form-button"]').click(); // Extract name from baseUrl (remove http:// and port number) const domain = Cypress.config() @@ -42,7 +42,7 @@ describe("Login and Logout", () => { // From now on the user is logged in // The login buttton should not be present anymore - cy.get('[data-testid="button-login"]').should("not.exist"); + cy.get('[data-testid="login-form-button"]').should("not.exist"); cy.visit("/"); cy.contains("Owners").should("exist"); @@ -64,7 +64,7 @@ describe("Login and Logout", () => { // The user is logged out // The login button should be present - cy.get('[data-testid="button-login"]').should("exist"); + cy.get('[data-testid="login-form-button"]').should("exist"); // The user tries to access the dashboard page without being connected // The user is redirected to the /auth page diff --git a/packages/extensions/test/e2e/ownerMonitor.cy.ts b/packages/extensions/test/e2e/ownerMonitor.cy.ts index cad7dd61..19e7bb10 100644 --- a/packages/extensions/test/e2e/ownerMonitor.cy.ts +++ b/packages/extensions/test/e2e/ownerMonitor.cy.ts @@ -5,7 +5,7 @@ describe("Owner Monitor", () => { cy.session("login", () => { cy.visit("/auth"); //login - cy.get('[data-testid="button-login"]').click(); + cy.get('[data-testid="login-form-button"]').click(); cy.get("#login").type("admin@example.com"); cy.get("#password").type("password"); @@ -54,7 +54,7 @@ describe("Owner Monitor", () => { /** Column interactions */ it("should hide/show columns", () => { // Click on the visibility icon - cy.get('[data-testid="VisibilityIcon"] > path').click(); + cy.get('[data-testid="column-visibility-button"]').click(); cy.get('[data-testid="column-visibility-popover"]').should("be.visible"); // Hide the "Owner Name" column