diff --git a/docs/developer/contribute.md b/docs/developer/contribute.md index baee8605..bf6841ea 100644 --- a/docs/developer/contribute.md +++ b/docs/developer/contribute.md @@ -16,7 +16,18 @@ - **Code Documentation:** Ensure that any code you write is well-documented. This includes: - Inline comments where necessary to explain complex logic. - Updating or creating Storybook documentation if you are contributing to the `diracx-web-components` library. -- **Writing/Updating Tests:** When you change or add new code, make sure to write or update tests accordingly. This helps maintain the reliability and stability of the codebase. + - You can use tools like [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/) to maintain code quality. + +- **Testing**: + + - **Component Testing**: Write tests for your components to ensure they work as expected. Use [Jest](https://jestjs.io/) for unit testing and snapshot testing of your React components. + - **Application Testing**: Use [Cypress](https://www.cypress.io/) for end-to-end testing to simulate real user interactions and ensure your application behaves correctly. + - **Test Coverage**: Maintain good test coverage to ensure that your critical features are well-protected during updates. Tools like Jest provide [coverage reports](https://jestjs.io/docs/code-coverage) that help you identify untested parts of your code. + + +- **Accessibility**: Make your application accessible to all users. Use semantic HTML, ARIA attributes, and test your application with different screen sizes and assistive technologies. + +By following these practices, you'll ensure that your codebase remains robust, secure, and maintainable. **Good to know:** If you create an export function or component in `diracx-web-components`, you must add it to the `index.ts` file and run `npm run build` inside `packages/diracx-web-components` to ensure the pre-commit hook passes. diff --git a/docs/developer/create_application.md b/docs/developer/create_application.md new file mode 100644 index 00000000..433ea1ba --- /dev/null +++ b/docs/developer/create_application.md @@ -0,0 +1,17 @@ +# Create an application + +## In DiracX-Web + +The applicatins created here will be available for DiracX-Web and for all the extensions. + +### Declare the application + +In the file `packages/diracx-web-components/src/components/ApplicationList.ts` you can extend the `applicationList` with your new app. You must provide a name (explicit), the component representing the new app and an icon that will appear in the `Add application` menu. You can also give two functions, `setState` and `getState`, to configure the export and import of your app. + +### Code the application + +The code of your app should be in `packages/diracx-web-components/src/components/`. The new app can and should use what already exist in `@dirac-grid/diracx-web-components`. + +In order to be compatible with the share and import buttons, the application must write its state to the session storage at `_State`. This slot is read from and written to by the corresponding functions. + +💡You can look at `JobMonitor` as an example. \ No newline at end of file diff --git a/docs/user/list_and_share_applications.md b/docs/user/list_and_share_applications.md index 22d7d8f6..6d170d97 100644 --- a/docs/user/list_and_share_applications.md +++ b/docs/user/list_and_share_applications.md @@ -90,3 +90,10 @@ When managing multiple instances of the same application, grouping can help you - :bulb: A context menu will appear. 2. Select **Delete**. - :bulb: The group and all its application instances will disappear from the sidebar. + + +### Share and import the settings of an application + +1. **Share**: You can share the status of an app by clicking on the share button in the top-right corner of the screen. After clicking, you can select which group and app you want to share and then copy a text corresponding to the states of the selected applications. + +2. **Import**: Next to the share button you can find the import button. You can paste into the window opened by the button the text corresponding to one or multiple shared apps. This will create a new group named *Imported App* with the imported applications and their settings. diff --git a/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx b/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx index 8368a1af..3e7ab95f 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx @@ -15,6 +15,8 @@ import { import { ProfileButton } from "./ProfileButton"; import { ThemeToggleButton } from "./ThemeToggleButton"; import DashboardDrawer from "./DashboardDrawer"; +import { ShareButton } from "./ShareButton"; +import { ImportButton } from "./ImportButton"; interface DashboardProps { /** The content to be displayed in the main area */ @@ -121,6 +123,8 @@ export default function Dashboard({ }} > + + diff --git a/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx b/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx index 6629cbd1..675a54ea 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx @@ -291,10 +291,21 @@ export default function DashboardDrawer({ const handleDelete = () => { if (contextState.type === "group") { + const group = userDashboard.find( + (group) => group.title === contextState.id, + ); + if (group) { + group.items.forEach((item) => { + sessionStorage.removeItem(`${item.id}_State`); + }); + } + setUserDashboard((userDashboard) => userDashboard.filter((group) => group.title !== contextState.id), ); } else if (contextState.type === "item") { + sessionStorage.removeItem(`${contextState.id}_State`); + setUserDashboard((userDashboard) => userDashboard.map((group) => { const newItems = group.items.filter( diff --git a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx index d122a268..aaf543df 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItemGroup.tsx @@ -91,11 +91,16 @@ export default function DrawerItemGroup({ // Handle renaming of the group const handleGroupRename = () => { if (renameValue.trim() === "") return; - setUserDashboard((groups) => - groups.map((group) => - group.title === title ? { ...group, title: renameValue } : group, - ), - ); + setUserDashboard((groups) => { + const count = groups.reduce( + (sum, group) => (group.title.startsWith(renameValue) ? sum + 1 : sum), + 0, + ); + const newTitle = count > 0 ? `${renameValue} (${count})` : renameValue; + return groups.map((group) => + group.title === title ? { ...group, title: newTitle } : group, + ); + }); setRenamingGroupId(null); setRenameValue(""); }; diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx new file mode 100644 index 00000000..a29dac2d --- /dev/null +++ b/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { + IconButton, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, +} from "@mui/material"; +import InputIcon from "@mui/icons-material/Input"; +import React, { useState, useContext, SetStateAction } from "react"; +import { ApplicationsContext } from "../../contexts"; +import { ApplicationMetadata, DashboardGroup } from "../../types"; +import { ApplicationState } from "../../types/ApplicationMetadata"; + +interface ImportDialogProps { + open: boolean; + onClose: () => void; + onImport: (state: string) => void; +} + +function ImportDialog({ open, onClose, onImport }: ImportDialogProps) { + const [stateText, setStateText] = useState(""); + const [error, setError] = useState(null); + + const handleImport = () => { + try { + const parsedState = JSON.parse(stateText); + onImport(parsedState); + onClose(); + setStateText(""); + setError(null); + } catch { + setError("Invalid JSON format"); + } + }; + + return ( + + + Import Application State + + + setStateText(e.target.value)} + placeholder="Paste your application state here..." + error={!!error} + helperText={error} + sx={{ mt: 2 }} + datatype="import-menu-field" + /> + + + + + + + ); +} + +/** + * ImportButton component allows users to import the state of applications. + * It provides a dialog to paste the state in JSON format. + */ +export function ImportButton() { + const [dialogOpen, setDialogOpen] = useState(false); + const [userDashboard, setUserDashboard, appList] = + useContext(ApplicationsContext); + + const handleImport = (importedState: ApplicationState) => { + const states = Array.isArray(importedState) + ? importedState + : [importedState]; + + const id: number = userDashboard.reduce( + (acc, group) => + group.title.startsWith("Imported Applications") ? acc + 1 : acc, + 0, + ); + + const newGroup = { + title: `Imported Applications${id > 0 ? ` (${id})` : ""}`, + extended: true, + items: [], + }; + + // Create a group only if there are valid states to import + if (states.some((state) => state.state !== "null")) + userDashboard.push(newGroup); + + states.forEach((state) => { + if (state.state !== "null") { + const appId = handleAppCreation( + state.appType, + state.appName, + appList, + userDashboard, + setUserDashboard, + ); + sessionStorage.setItem(`${appId}_State`, state.state); + } else { + console.warn(`No state to import for app type: ${state.appType}`); + } + }); + }; + + return ( + <> + + setDialogOpen(true)}> + + + + + setDialogOpen(false)} + onImport={handleImport} + /> + + ); +} + +/** + * Handles the creation of a new app in the dashboard drawer. + * + * @param appType - The type of the app to be created. + * @param icon - The icon component for the app. + */ +function handleAppCreation( + appType: string, + appTitle: string, + appList: ApplicationMetadata[], + userDashboard: DashboardGroup[], + setUserDashboard: React.Dispatch>, +): string { + const group = userDashboard[userDashboard.length - 1]; + + const count = userDashboard.reduce( + (sum, group) => + sum + + group.items.filter((item) => item.title.startsWith(appTitle)).length, + 0, + ); + + const title = count > 0 ? `${appTitle} (${count + 1})` : appTitle; + const appId = `${title}${userDashboard.reduce( + (sum, group) => sum + group.items.length, + 0, + )}`; + + const newApp = { + title: title, + id: appId, + type: appType, + icon: appList.find((app) => app.name === appType)?.icon || null, + }; + group.items.push(newApp); + setUserDashboard((userDashboard) => + userDashboard.map((g) => (g.title === group.title ? group : g)), + ); + + return appId; +} diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ShareButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ShareButton.tsx new file mode 100644 index 00000000..fec4b667 --- /dev/null +++ b/packages/diracx-web-components/src/components/DashboardLayout/ShareButton.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useState, useContext } from "react"; + +import { + IconButton, + Tooltip, + Menu, + Typography, + Button, + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Checkbox, + useTheme, + FormControlLabel, +} from "@mui/material"; + +import OutputIcon from "@mui/icons-material/Output"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; + +import { DashboardGroup } from "../../types/DashboardGroup"; + +import { ApplicationsContext } from "../../contexts"; + +interface ShareDialogProps { + open: boolean; + onClose: () => void; + state: string; +} + +function ShareDialog({ open, onClose, state }: ShareDialogProps) { + const theme = useTheme(); + const handleCopy = () => { + navigator.clipboard.writeText(state); + onClose(); + }; + + return ( + + Application State + + + {state} + + + + + + + + ); +} + +/** + * ShareButton component allows users to share the state of selected applications. + * It provides a menu with checkboxes for each application and a dialog to display the state. + */ +export function ShareButton() { + const [anchorEl, setAnchorEl] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedApps, setSelectedApps] = useState([]); + const [selectedState, setSelectedState] = useState(""); + const [groups, ,] = useContext(ApplicationsContext); + + // Function to handle the click event on the share button + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + setSelectedApps([]); // Reset selection when opening menu + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleAppToggle = (appId: string) => { + setSelectedApps((prev) => { + if (prev.includes(appId)) { + return prev.filter((id) => id !== appId); + } else { + return [...prev, appId]; + } + }); + }; + + // Function to handle the share action + // It collects the state of selected applications and opens the dialog + const handleShare = () => { + const states = selectedApps.map((appId) => { + const app = groups.flatMap((g) => g.items).find((a) => a.id === appId); + if (!app) return null; + + const appState = sessionStorage.getItem(`${appId}_State`); + return { + appType: app.type, + appName: app.title, + state: typeof appState === "string" ? appState : "null", + }; + }); + + setSelectedState(JSON.stringify(states, null, 2)); + setDialogOpen(true); + handleClose(); + }; + + return ( + <> + + + + + + + + {groups.map( + (group) => + group.items.length > 0 && ( +
+ + selectedApps.includes(app.id), + )} + indeterminate={ + group.items.some((app) => + selectedApps.includes(app.id), + ) && + !group.items.every((app) => + selectedApps.includes(app.id), + ) + } + onChange={() => { + const allSelected = group.items.every((app) => + selectedApps.includes(app.id), + ); + if (allSelected) { + setSelectedApps((prev) => + prev.filter( + (id) => !group.items.some((app) => app.id === id), + ), + ); + } else { + setSelectedApps((prev) => [ + ...prev, + ...group.items.map((app) => app.id), + ]); + } + }} + /> + } + /> + +
+ ), + )} + {selectedApps.length > 0 && ( + + + + )} +
+ + setDialogOpen(false)} + state={selectedState} + /> + + ); +} + +/** + * + * @param group - The group of applications + * @param selectedApps - The list of selected application IDs + * @param handleAppToggle - The function to handle toggling the checkbox + * @returns A subsection of checkboxes for the applications in the group + */ +function GroupCheckboxSection({ + group, + selectedApps, + handleAppToggle, +}: { + group: DashboardGroup; + selectedApps: string[]; + handleAppToggle: (appId: string) => void; +}) { + return ( + + {group.items.map((app) => ( + handleAppToggle(app.id)} + size="small" + data-testid={`checkbox-${app.id}`} + /> + } + /> + ))} + + ); +} diff --git a/packages/diracx-web-components/src/components/DashboardLayout/index.ts b/packages/diracx-web-components/src/components/DashboardLayout/index.ts index d18aed52..7b42e03c 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/index.ts +++ b/packages/diracx-web-components/src/components/DashboardLayout/index.ts @@ -5,3 +5,5 @@ export { default as DrawerItem } from "./DrawerItem"; export { default as DrawerItemGroup } from "./DrawerItemGroup"; export { ProfileButton } from "./ProfileButton"; export { ThemeToggleButton } from "./ThemeToggleButton"; +export { ShareButton } from "./ShareButton"; +export { ImportButton } from "./ImportButton"; diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx index c714b690..d7cd511f 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Box from "@mui/material/Box"; import { blue, @@ -41,7 +41,9 @@ import { useOIDCContext } from "../../hooks/oidcConfiguration"; import { DataTable, MenuItem } from "../shared/DataTable"; import { Job, JobHistory, SearchBody } from "../../types"; import { useDiracxUrl } from "../../hooks/utils"; +import { useApplicationId } from "../../hooks/application"; import { JobHistoryDialog } from "./JobHistoryDialog"; + import { deleteJobs, getJobHistory, @@ -57,6 +59,12 @@ import { export function JobDataTable() { const theme = useTheme(); + // Id of the application + const appId = useApplicationId(); + + // Load the initial state from local storage + const initialState = sessionStorage.getItem(`${appId}_State`); + // Authentication const { configuration } = useOIDCContext(); const { accessToken } = useOidcAccessToken(configuration?.scope); @@ -71,25 +79,41 @@ export function JobDataTable() { severity: "success", }); - // States for table settings - const [columnVisibility, setColumnVisibility] = useState({ - JobGroup: false, - JobType: false, - Owner: false, - OwnerGroup: false, - VO: false, - StartExecTime: false, - EndExecTime: false, - UserPriority: false, - }); - const [columnPinning, setColumnPinning] = useState({ - left: ["JobID"], // Pin JobID column by default - }); - const [rowSelection, setRowSelection] = useState({}); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 25, - }); + const parsedInitialState = + typeof initialState === "string" ? JSON.parse(initialState) : null; + + const [columnVisibility, setColumnVisibility] = useState( + parsedInitialState + ? parsedInitialState.columnVisibility + : { + JobGroup: false, + JobType: false, + Owner: false, + OwnerGroup: false, + VO: false, + StartExecTime: false, + EndExecTime: false, + UserPriority: false, + }, + ); + const [columnPinning, setColumnPinning] = useState( + parsedInitialState + ? parsedInitialState.columnPinning + : { + left: ["JobID"], // Pin JobID column by default + }, + ); + const [rowSelection, setRowSelection] = useState( + parsedInitialState ? parsedInitialState.rowSelection : {}, + ); + const [pagination, setPagination] = useState( + parsedInitialState + ? parsedInitialState.pagination + : { + pageIndex: 0, + pageSize: 25, + }, + ); // State for search body const [searchBody, setSearchBody] = useState({ @@ -103,6 +127,24 @@ export function JobDataTable() { const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false); const [jobHistoryData, setJobHistoryData] = useState([]); + // Save the state of the table in local storage + useEffect(() => { + const state = { + columnVisibility: { ...columnVisibility }, + columnPinning: { + left: [...(columnPinning.left || [])], + right: [...(columnPinning.right || [])], + }, + rowSelection: { ...rowSelection }, + pagination: { + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + }, + }; + + sessionStorage.setItem(`${appId}_State`, JSON.stringify(state)); + }, [columnVisibility, columnPinning, rowSelection, pagination]); + // Status colors const statusColors: Record = useMemo( () => ({ @@ -444,7 +486,6 @@ export function JobDataTable() { if (!selectedId) return; setBackdropOpen(true); setSelectedJobId(selectedId); - console.log("Selected job ID:", diracxUrl); try { const { data } = await getJobHistory( diracxUrl, diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx index 52eb8fe9..7b1819d2 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx @@ -1,14 +1,15 @@ "use client"; import { Box } from "@mui/material"; +import { useApplicationId } from "../../hooks/application"; import { JobDataTable } from "./JobDataTable"; - /** * Build the Job Monitor application * * @returns Job Monitor content */ export default function JobMonitor() { + const appId = useApplicationId(); return ( - + {/* The key is used to force a re-render of the component when the appId changes */} + ); } diff --git a/packages/diracx-web-components/src/types/ApplicationMetadata.ts b/packages/diracx-web-components/src/types/ApplicationMetadata.ts index fbabdf0f..5f642f18 100644 --- a/packages/diracx-web-components/src/types/ApplicationMetadata.ts +++ b/packages/diracx-web-components/src/types/ApplicationMetadata.ts @@ -8,3 +8,5 @@ export default interface ApplicationMetadata { component: ElementType; icon: SvgIconComponent; } + +export type ApplicationState = string; diff --git a/packages/diracx-web-components/stories/ImportButton.stories.tsx b/packages/diracx-web-components/stories/ImportButton.stories.tsx new file mode 100644 index 00000000..d81cad1d --- /dev/null +++ b/packages/diracx-web-components/stories/ImportButton.stories.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Paper } from "@mui/material"; +import { ThemeProvider } from "../src/contexts/ThemeProvider"; +import { ImportButton } from "../src/components/DashboardLayout/ImportButton"; + +const meta = { + title: "Dashboard Layout/ImportButton", + component: ImportButton, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => { + return ( + + + + + + ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/diracx-web-components/stories/ShareButton.stories.tsx b/packages/diracx-web-components/stories/ShareButton.stories.tsx new file mode 100644 index 00000000..96070b46 --- /dev/null +++ b/packages/diracx-web-components/stories/ShareButton.stories.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { Paper } from "@mui/material"; +import { ThemeProvider } from "../src/contexts/ThemeProvider"; +import { ShareButton } from "../src/components/DashboardLayout/ShareButton"; + +const meta = { + title: "Dashboard Layout/ShareButton", + component: ShareButton, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => { + return ( + + + + + + ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/diracx-web/test/e2e/shareImportState.ts b/packages/diracx-web/test/e2e/shareImportState.ts new file mode 100644 index 00000000..35e87652 --- /dev/null +++ b/packages/diracx-web/test/e2e/shareImportState.ts @@ -0,0 +1,184 @@ +/// + +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.visit( + "?dashboard=4dashboard%27%7Eextended%21true%7Eitems%2148s3*5B860795913*7A5A23-%27%29%5D%29%5D*B8+6-BFile+Catalog.%28%27title3%27%7Etype4%5B.BMy+5%27%7Eid6Monitor7%27%29%2C.8Job9*+2A-+1B%21%27%01BA9876543.-*_", + ); + }); + + it("export button should be visible", () => { + cy.get('[aria-label="Share application state"]') + .should("be.visible") + .click(); + + // Select 2 items to share + cy.get('[data-testid="export-menu"]').should("be.visible"); + cy.get('[data-testid="checkbox-JobMonitor0"]').click(); + cy.get('[data-testid="checkbox-Job Monitor 21"]').click(); + + // Share and cancel the export + cy.contains("Share 2 selected").should("be.visible").click(); + cy.contains("Cancel").should("be.visible").click(); + + // Select 1 item and share it + cy.get('[aria-label="Share application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="export-menu"]').should("be.visible"); + cy.get('[data-testid="checkbox-JobMonitor0"]').click(); + cy.contains("Share 1 selected").should("be.visible").click(); + }); + + // Test case to check the copy of the empty state + it("should copy the empty state", () => { + // Select the Job Monitor app + cy.get('[aria-label="Share application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="export-menu"]').should("be.visible"); + cy.get('[data-testid="checkbox-JobMonitor0"]').click(); + cy.contains("Share 1 selected").should("be.visible").click(); + + // Copy and assert the state + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, "writeText").as("writeTextStub"); + }); + + cy.contains("Copy").click(); + + cy.get("@writeTextStub").should( + "have.been.calledOnceWithExactly", + '[\n {\n "appType": "Job Monitor",\n "state": "null"\n }\n]', + ); + }); + + // Test case to check the copy of the non-empty state + it("should copy a non-empty state", () => { + // Open the Job Monitor app + cy.visit( + "?appId=JobMonitor0&dashboard=4dashboard%27%7Eextended%21true%7Eitems%2148s3*5B860795913*7A5A23-%27%29%5D%29%5D*B8+6-BFile+Catalog.%28%27title3%27%7Etype4%5B.BMy+5%27%7Eid6Monitor7%27%29%2C.8Job9*+2A-+1B%21%27%01BA9876543.-*_", + ); + + // Select the Job Monitor app + cy.get('[aria-label="Share application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="export-menu"]').should("be.visible"); + cy.get('[data-testid="checkbox-JobMonitor0"]').click(); + cy.contains("Share 1 selected").should("be.visible").click(); + + // Copy and assert the state + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, "writeText").as("writeTextStub"); + }); + + cy.contains("Copy").click(); + + cy.get("@writeTextStub").should( + "have.been.calledOnceWithExactly", + '[\n {\n "appType": "Job Monitor",\n "state": "{\\"columnVisibility\\":{\\"JobGroup\\":false,\\"JobType\\":false,\\"Owner\\":false,\\"OwnerGroup\\":false,\\"VO\\":false,\\"StartExecTime\\":false,\\"EndExecTime\\":false,\\"UserPriority\\":false},\\"columnPinning\\":{\\"left\\":[\\"JobID\\"],\\"right\\":[]},\\"rowSelection\\":{},\\"pagination\\":{\\"pageIndex\\":0,\\"pageSize\\":25}}"\n }\n]', + ); + }); + + it("should copy multiple states", () => { + cy.visit( + "?dashboard=6dashboard'~extended!true~items!6As4*7DA9058278214*5B7B24-58378334*')])]*DA+9-DFile+Catalog.('title4'~type5')%2C.6[.DMy+7'~id8*+9MonitorAJobB-+1D!'%01DBA987654.-*_", + ); + // Open: + // -My Jobs + cy.visit( + "?appId=JobMonitor0&dashboard=6dashboard'~extended!true~items!6As4*7DA9058278214*5B7B24-58378334*')])]*DA+9-DFile+Catalog.('title4'~type5')%2C.6[.DMy+7'~id8*+9MonitorAJobB-+1D!'%01DBA987654.-*_", + ); + // -Job Monitor 2 + cy.visit( + "?appId=Job+Monitor+21&dashboard=6dashboard'~extended!true~items!6As4*7DA9058278214*5B7B24-58378334*')])]*DA+9-DFile+Catalog.('title4'~type5')%2C.6[.DMy+7'~id8*+9MonitorAJobB-+1D!'%01DBA987654.-*_", + ); + // -Job Monitoring 3 + cy.visit( + "?appId=Job+Monitor+33&dashboard=6dashboard'~extended!true~items!6As4*7DA9058278214*5B7B24-58378334*')])]*DA+9-DFile+Catalog.('title4'~type5')%2C.6[.DMy+7'~id8*+9MonitorAJobB-+1D!'%01DBA987654.-*_", + ); + + cy.get('[aria-label="Share application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="export-menu"]').should("be.visible"); + cy.get('[data-testid="checkbox-JobMonitor0"]').click(); + cy.get('[data-testid="checkbox-Job Monitor 21"]').click(); + cy.get('[data-testid="checkbox-Job Monitor 33"]').click(); + cy.contains("Share 3 selected").should("be.visible").click(); + + cy.contains('"appType": "Job Monitor"'); + }); + + it("should the import button be visible", () => { + cy.get('[aria-label="Import application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="import-menu"]').should("be.visible"); + cy.contains("Cancel").should("be.visible").click(); + cy.contains("Paste your application state here...").should("not.exist"); + }); + + it("should not import what's not a valid JSON", () => { + cy.get('[aria-label="Import application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="import-menu"]').should("be.visible"); + cy.get('[data-testid="import-menu"]').should("be.visible"); + cy.get("#«ra»").type("Houston, we have a problem: this isn't JSON."); + cy.get("button").contains("Import").click(); + cy.contains("Invalid JSON format").should("be.visible"); + }); + + it("should import the empty state", () => { + cy.get('[aria-label="Import application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="import-menu"]').should("be.visible"); + cy.get('[data-testid="import-menu"]').should("be.visible"); + cy.get("#«ra»").type( + '[\n {\n "appType": "Job Monitor",\n "state": "null"\n }\n]', + ); + cy.get("button").contains("Import").click(); + cy.contains("Imported Applications").should("not.exist"); + }); + + // Test case to check the import of the non-empty state + it("should import the non-empty state", () => { + cy.get('[aria-label="Import application state"]') + .should("be.visible") + .click(); + cy.get('[data-testid="import-menu"]').should("be.visible"); + cy.get('[data-testid="import-menu"]').should("be.visible"); + cy.get("#«ra»").type( + `[ + { + "appType": "Job Monitor", + "state": "{\\"columnVisibility\\":{\\"JobGroup\\":false,\\"JobType\\":false,\\"Owner\\":false,\\"OwnerGroup\\":false,\\"VO\\":false,\\"StartExecTime\\":false,\\"EndExecTime\\":false,\\"UserPriority\\":false},\\"columnPinning\\":{\\"left\\":[\\"JobID\\"],\\"right\\":[]},\\"rowSelection\\":{},\\"pagination\\":{\\"pageIndex\\":0,\\"pageSize\\":25}}" + } +]`, + { parseSpecialCharSequences: false }, + ); + cy.get("button").contains("Import").click(); + cy.contains("Imported Applications").should("be.visible"); + + cy.contains("Job Monitor 3").click(); + cy.get(".MuiTypography-h4").contains("Job Monitor 3"); + cy.contains("No data or no"); + }); +});