From 8c759ae3ec4ac9dca44b0ca74739e1b6df2ec5b2 Mon Sep 17 00:00:00 2001 From: theau Date: Tue, 17 Jun 2025 12:56:47 +0200 Subject: [PATCH] feat: new Search Bar for the Job Monitor --- docs/user/monitor_jobs.md | 33 +- .../diracx-web-components/.storybook/main.ts | 4 +- .../diracx-web-components/eslint.config.js | 9 + packages/diracx-web-components/jest.config.js | 2 +- .../components/JobMonitor/JobDataTable.tsx | 2 +- .../src/components/JobMonitor/JobMonitor.tsx | 81 ++-- .../components/JobMonitor/JobSearchBar.tsx | 255 ++++++++++++ .../{JobDataService.ts => jobDataService.ts} | 78 +++- .../src/components/Login/LoginForm.tsx | 1 - .../src/components/shared/FilterForm.tsx | 348 ---------------- .../src/components/shared/FilterToolbar.tsx | 249 ----------- .../shared/SearchBar/DatePicker.tsx | 108 +++++ .../shared/SearchBar/DisplayTokenEquation.tsx | 90 ++++ .../components/shared/SearchBar/SearchBar.tsx | 358 ++++++++++++++++ .../shared/SearchBar/SearchField.tsx | 393 ++++++++++++++++++ .../src/components/shared/SearchBar/Utils.tsx | 338 +++++++++++++++ .../shared/SearchBar/defaultFunctions.tsx | 62 +++ .../src/components/shared/SearchBar/index.ts | 2 + .../src/components/shared/index.ts | 3 +- .../diracx-web-components/src/global.d.ts | 5 +- .../src/types/CategoryType.ts | 10 + .../src/types/EquationAndTokenIndex.tsx | 4 + .../src/types/EquationStatus.ts | 5 + .../diracx-web-components/src/types/Filter.ts | 13 +- .../src/types/JobSummary.ts | 3 + .../src/types/SearchBarEquation.ts | 9 + .../src/types/SearchBarSuggestions.ts | 13 + .../src/types/SearchBarToken.ts | 16 + .../src/types/SearchBarTokenNature.ts | 7 + .../diracx-web-components/src/types/index.ts | 9 + .../src/types/operators.ts | 115 +++++ .../stories/FilterForm.stories.tsx | 82 ---- .../stories/FilterToolbar.stories.tsx | 88 ---- .../stories/JobMonitor.stories.tsx | 51 +-- .../stories/SearchBar.stories.tsx | 180 ++++++++ ...ervice.mock.tsx => jobDataService.mock.ts} | 96 ++++- .../stories/mocks/react-oidc.mock.tsx | 2 +- .../test/Dashboard.test.tsx | 1 + .../test/DataTable.test.tsx | 1 + .../test/ErrorBox.test.tsx | 1 + .../test/FilterForm.test.tsx | 45 -- .../test/FilterToolbar.test.tsx | 43 -- .../test/JobMonitor.test.tsx | 34 +- .../test/LoginForm.test.tsx | 1 + .../test/SearchBar.test.tsx | 156 +++++++ packages/diracx-web/cypress.config.ts | 4 +- packages/diracx-web/test/e2e/jobMonitor.cy.ts | 169 +++----- 47 files changed, 2463 insertions(+), 1116 deletions(-) create mode 100644 packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx rename packages/diracx-web-components/src/components/JobMonitor/{JobDataService.ts => jobDataService.ts} (71%) delete mode 100644 packages/diracx-web-components/src/components/shared/FilterForm.tsx delete mode 100644 packages/diracx-web-components/src/components/shared/FilterToolbar.tsx create mode 100644 packages/diracx-web-components/src/components/shared/SearchBar/DatePicker.tsx create mode 100644 packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx create mode 100644 packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx create mode 100644 packages/diracx-web-components/src/components/shared/SearchBar/SearchField.tsx create mode 100644 packages/diracx-web-components/src/components/shared/SearchBar/Utils.tsx create mode 100644 packages/diracx-web-components/src/components/shared/SearchBar/defaultFunctions.tsx create mode 100644 packages/diracx-web-components/src/components/shared/SearchBar/index.ts create mode 100644 packages/diracx-web-components/src/types/CategoryType.ts create mode 100644 packages/diracx-web-components/src/types/EquationAndTokenIndex.tsx create mode 100644 packages/diracx-web-components/src/types/EquationStatus.ts create mode 100644 packages/diracx-web-components/src/types/JobSummary.ts create mode 100644 packages/diracx-web-components/src/types/SearchBarEquation.ts create mode 100644 packages/diracx-web-components/src/types/SearchBarSuggestions.ts create mode 100644 packages/diracx-web-components/src/types/SearchBarToken.ts create mode 100644 packages/diracx-web-components/src/types/SearchBarTokenNature.ts create mode 100644 packages/diracx-web-components/src/types/operators.ts delete mode 100644 packages/diracx-web-components/stories/FilterForm.stories.tsx delete mode 100644 packages/diracx-web-components/stories/FilterToolbar.stories.tsx create mode 100644 packages/diracx-web-components/stories/SearchBar.stories.tsx rename packages/diracx-web-components/stories/mocks/{JobDataService.mock.tsx => jobDataService.mock.ts} (61%) delete mode 100644 packages/diracx-web-components/test/FilterForm.test.tsx delete mode 100644 packages/diracx-web-components/test/FilterToolbar.test.tsx create mode 100644 packages/diracx-web-components/test/SearchBar.test.tsx diff --git a/docs/user/monitor_jobs.md b/docs/user/monitor_jobs.md index be6b997f..95fc67c4 100644 --- a/docs/user/monitor_jobs.md +++ b/docs/user/monitor_jobs.md @@ -1,9 +1,34 @@ -# Managing your jobs +# Job Monitor documentation -## Basics +## The search bar -### + The search bar allows you to filter jobs based on various criteria. The filters are represented as equations in the search bar, where each equation consists of a job attribute, an operator, and a value. + A resarch is automatically performed when all the equations in the search bar are valid. + ### Create a filter + To create a filter, click on the search bar and start typing. Suggestions will appear based on the available job attributes. You can select a suggestion to choose the criterion. After that, you can either type or select an operator and a value to filter by. + The search bar only suggests attributes, operators, and values that are available in your current set of jobs. -## Advanced + ### Edit a filter + To edit a filter, click on the filter in the search bar. You can change the operator or value by clicking on them. You can also use the arrow keys to navigate through the equations and edit them. + + ### Remove a filter + To remove a filter, you can either press the `Backspace` key to remove the last token or right-click on the equation to remove the entire equation. + + +## Use the table +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. + +### Actions on the table +You can click on the eye icon to select more columns to display in the table. +You can sort the table by clicking on the column headers. Clicking on a column header will sort the table in ascending order, and clicking again will sort it in descending order. +You can set the page size by clicking on the `Row per page` dropdown at the bottom of the table. This allows you to control how many jobs are displayed per page. + +### Actions on a job +You can do a right-click on a job to open the `Job History`. +You can select one or more jobs by clicking on the checkboxes next to each job. Once you have selected jobs, you can perform actions on them using the buttons at the top of the table. The available actions include: +- **Get IDs**: This button will copy the IDs of the selected jobs to the clipboard. +- **Rechedule**: This button will reschedule the selected jobs. +- **Kill**: This button will kill the selected jobs. +- **Delete**: This button will delete the selected jobs. diff --git a/packages/diracx-web-components/.storybook/main.ts b/packages/diracx-web-components/.storybook/main.ts index e2990ab9..91784996 100644 --- a/packages/diracx-web-components/.storybook/main.ts +++ b/packages/diracx-web-components/.storybook/main.ts @@ -42,8 +42,8 @@ const config: StorybookConfig = { "../../hooks/metadata": require.resolve( "../stories/mocks/metadata.mock.tsx", ), - "./JobDataService": require.resolve( - "../stories/mocks/JobDataService.mock.tsx", + "./jobDataService": require.resolve( + "../stories/mocks/jobDataService.mock.ts", ), }; return config; diff --git a/packages/diracx-web-components/eslint.config.js b/packages/diracx-web-components/eslint.config.js index 09a3ff21..7e87332c 100644 --- a/packages/diracx-web-components/eslint.config.js +++ b/packages/diracx-web-components/eslint.config.js @@ -64,6 +64,15 @@ export default [ "react/prop-types": "off", "react/react-in-jsx-scope": "off", "react/destructuring-assignment": ["error", "always"], + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], "no-restricted-properties": [ "error", { diff --git a/packages/diracx-web-components/jest.config.js b/packages/diracx-web-components/jest.config.js index b1dd0865..313a7a3d 100644 --- a/packages/diracx-web-components/jest.config.js +++ b/packages/diracx-web-components/jest.config.js @@ -13,7 +13,7 @@ const config = { moduleNameMapper: { "^@axa-fr/react-oidc$": "/stories/mocks/react-oidc.mock.tsx", "^../../hooks/metadata$": "/stories/mocks/metadata.mock.tsx", - "^./JobDataService$": "/stories/mocks/JobDataService.mock.tsx", + "^./jobDataService$": "/stories/mocks/jobDataService.mock.ts", }, }; diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx index 4e8248e3..49d85401 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx @@ -35,7 +35,7 @@ import { refreshJobs, rescheduleJobs, useJobs, -} from "./JobDataService"; +} from "./jobDataService"; /** * Job Data Table props diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx index 53423372..d00dde3c 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx @@ -20,13 +20,14 @@ import { RowSelectionState, VisibilityState, PaginationState, + ColumnDef, } from "@tanstack/react-table"; import { useApplicationId } from "../../hooks/application"; -import { FilterToolbar } from "../shared/FilterToolbar"; -import { InternalFilter } from "../../types/Filter"; -import { Job, SearchBody } from "../../types"; +import { Filter } from "../../types/Filter"; +import { Job, SearchBody, CategoryType } from "../../types"; import { JobDataTable } from "./JobDataTable"; +import { JobSearchBar } from "./JobSearchBar"; /** * Build the Job Monitor application @@ -44,14 +45,14 @@ export default function JobMonitor() { typeof initialState === "string" ? JSON.parse(initialState) : null; // State for filters - const [filters, setFilters] = useState( + const [filters, setFilters] = useState( parsedInitialState ? parsedInitialState.filters : [], ); // State for search body const [searchBody, setSearchBody] = useState({ search: parsedInitialState - ? parsedInitialState.filters.map((filter: InternalFilter) => ({ + ? parsedInitialState.filters.map((filter: Filter) => ({ parameter: filter.parameter, operator: filter.operator, value: filter.value, @@ -98,7 +99,7 @@ export default function JobMonitor() { // Save the state of the app in local storage useEffect(() => { const state = { - filters: [...filters.filter((filter) => filter.isApplied)], + filters: [...filters], columnVisibility: { ...columnVisibility }, columnPinning: { left: [...(columnPinning.left || [])], @@ -123,15 +124,10 @@ export default function JobMonitor() { // Handle the application of filters const handleApplyFilters = () => { - // Switch the applied state of the filters - setFilters((filters) => - filters.map((filter) => ({ ...filter, isApplied: true })), - ); - setSearchBody((prev) => ({ ...prev, search: filters.map(({ parameter, operator, value, values }) => ({ - parameter, + parameter: fromHumanReadableText(parameter, columns), operator, value, values, @@ -143,18 +139,6 @@ export default function JobMonitor() { })); }; - const handleRemoveAllFilters = useCallback(() => { - setSearchBody((prevState) => ({ - ...prevState, - search: [], - })); - setPagination((prevState) => ({ - ...prevState, - pageIndex: 0, - })); - setFilters([]); - }, [setFilters]); - // Status colors const statusColors: Record = useMemo( () => ({ @@ -213,13 +197,17 @@ export default function JobMonitor() { columnHelper.accessor("JobID", { id: "JobID", header: "ID", - meta: { type: "number" }, + meta: { type: CategoryType.NUMBER, hideSuggestion: true }, }), columnHelper.accessor("Status", { id: "Status", header: "Status", cell: (info) => renderStatusCell(info.getValue()), - meta: { type: "category", values: Object.keys(statusColors).sort() }, + meta: { + type: CategoryType.STRING, + values: Object.keys(statusColors).sort(), + hideSuggestion: false, + }, }), columnHelper.accessor("MinorStatus", { id: "MinorStatus", @@ -248,17 +236,17 @@ export default function JobMonitor() { columnHelper.accessor("LastUpdateTime", { id: "LastUpdateTime", header: "Last Update Time", - meta: { type: "date" }, + meta: { type: CategoryType.DATE, hideSuggestion: true }, }), columnHelper.accessor("HeartBeatTime", { id: "HeartBeatTime", header: "Last Sign of Life", - meta: { type: "date" }, + meta: { type: CategoryType.DATE, hideSuggestion: true }, }), columnHelper.accessor("SubmissionTime", { id: "SubmissionTime", header: "Submission Time", - meta: { type: "date" }, + meta: { type: CategoryType.DATE, hideSuggestion: true }, }), columnHelper.accessor("Owner", { id: "Owner", @@ -275,17 +263,22 @@ export default function JobMonitor() { columnHelper.accessor("StartExecTime", { id: "StartExecTime", header: "Start Execution Time", - meta: { type: "date" }, + meta: { type: CategoryType.DATE, hideSuggestion: true }, }), columnHelper.accessor("EndExecTime", { id: "EndExecTime", header: "End Execution Time", - meta: { type: "date" }, + meta: { type: CategoryType.DATE, hideSuggestion: true }, }), columnHelper.accessor("UserPriority", { id: "UserPriority", header: "User Priority", - meta: { type: "number" }, + meta: { type: CategoryType.NUMBER }, + }), + columnHelper.accessor("RescheduleCounter", { + id: "RescheduleCounter", + header: "Reschedule Counter", + meta: { type: CategoryType.NUMBER }, }), ], [columnHelper, renderStatusCell, statusColors], @@ -300,12 +293,12 @@ export default function JobMonitor() { overflow: "hidden", }} > - [], +): string { + const index = columns.findIndex((column) => column.header === name); + if (index !== -1) { + return columns[index].id || name; // Return the id if it exists, otherwise + } + return name; +} diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx new file mode 100644 index 00000000..fe716689 --- /dev/null +++ b/packages/diracx-web-components/src/components/JobMonitor/JobSearchBar.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { useOidcAccessToken } from "@axa-fr/react-oidc"; +import { useOIDCContext } from "../../hooks/oidcConfiguration"; +import { useDiracxUrl } from "../../hooks/utils"; +import { SearchBar } from "../shared/SearchBar/SearchBar"; +import { + Filter, + JobSummary, + SearchBarSuggestions, + SearchBarToken, + SearchBarTokenEquation, + SearchBody, + Job, + Operators, + SearchBarTokenNature, + CategoryType, +} from "../../types"; +import { getJobSummary } from "./jobDataService"; +import { fromHumanReadableText } from "./JobMonitor"; + +interface JobSearchBarProps { + /** The filters */ + filters: Filter[]; + /** The function to set the filters */ + setFilters: React.Dispatch>; + /** The search body to send along with the request */ + searchBody: SearchBody; + /** The function to apply the filters */ + handleApplyFilters: () => void; + /** The columns to display in the job monitor */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: ColumnDef[]; +} + +export function JobSearchBar({ + filters, + searchBody, + setFilters, + handleApplyFilters, + columns, +}: JobSearchBarProps) { + const { configuration } = useOIDCContext(); + const { accessToken } = useOidcAccessToken(configuration?.scope); + const oldFilters = useRef(""); + + const diracxUrl = useDiracxUrl(); + + useEffect(() => { + const currentFilters = JSON.stringify(filters); + if (oldFilters.current !== currentFilters) { + oldFilters.current = currentFilters; + handleApplyFilters(); + } + }, [filters, handleApplyFilters]); + + return ( + + createSuggestions( + diracxUrl, + accessToken, + previousToken, + previousEquation, + columns, + searchBody, + equationIndex, + ) + } + allowKeyWordSearch={false} // Disable keyword search for job monitor + /> + ); +} + +/** + * Creates suggestions for the search bar based on the current tokens + * If necessary, it fetches job summaries from the server to get personalized suggestions + * + * @param diracxUrl The URL of the DiracX server. + * @param accessToken The access token for authentication, which can be undefined if not authenticated. + * @param previousToken The previous token, which can be undefined if no token is focused. + * @param previousEquation The previous equation, which can be undefined if no equation is focused. + * @param columns The columns to be used for suggestions, which are used to determine the categories and types. + * @param searchBody The search body to be sent along with the request (optional). + * @param searchBodyIndex The index of the search body, which is used to determine the current search context (optional). + * @returns A list of suggestions based on the current tokens and data. + */ +async function createSuggestions( + diracxUrl: string | null, + accessToken: string | undefined, + previousToken: SearchBarToken | undefined, + previousEquation: SearchBarTokenEquation | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: ColumnDef[], + searchBody: SearchBody, + searchBodyIndex?: number, +): Promise { + let data: JobSummary[] = []; + + const search = [...(searchBody?.search || [])]; + + const newSearchBody = { + ...searchBody, + search: search.slice(0, searchBodyIndex), + }; + + const fetchJobSummary = async (category: string) => { + if (diracxUrl && accessToken) { + try { + const result = await getJobSummary( + diracxUrl, + [category], + accessToken, + newSearchBody, + ); + data = result.data || []; + } catch { + throw new Error("Failed to fetch job summary"); + } + } + }; + + if ( + !previousToken || + !previousEquation || + previousEquation.items.length === 0 || + previousToken.nature === SearchBarTokenNature.CUSTOM || + previousToken.nature === SearchBarTokenNature.VALUE + ) { + const items = columns.map((column) => column.header as string); + const type = columns.map( + (column) => column.meta?.type || CategoryType.STRING, + ) as CategoryType[]; + const hideSuggestion = columns.map( + (column) => column.meta?.hideSuggestion || false, + ); + + return { + items: items, + type, + nature: Array(items.length).fill(SearchBarTokenNature.CATEGORY), + hideSuggestion: hideSuggestion, + }; + } + + // Here, we need personalized suggestions based on the previous token + if (previousToken.nature === SearchBarTokenNature.OPERATOR) { + if (previousToken.label === Operators.LAST.getDisplay()) { + return { + items: ["minute", "hour", "day", "week", "month", "year"], + nature: Array(6).fill(SearchBarTokenNature.VALUE), + type: Array(6).fill(CategoryType.DATE), + hideSuggestion: Array(6).fill(previousToken.hideSuggestion), + }; + } + + if (!previousToken.hideSuggestion) { + // Load the suggestions for the selected category + const category = fromHumanReadableText( + previousEquation.items[0].label as string, + columns, + ); + await fetchJobSummary(category); + const items = data.map( + (item) => item[category as keyof JobSummary] as string, + ); + + return { + items: items, + nature: Array(items.length).fill(SearchBarTokenNature.VALUE), + type: Array(items.length).fill(CategoryType.STRING), + hideSuggestion: Array(items.length).fill(previousToken.hideSuggestion), + }; + } + } + if (previousToken.nature === SearchBarTokenNature.CATEGORY) { + let items: string[] = []; + switch (previousToken.type) { + case CategoryType.STRING: + items = Operators.getStringOperators().map((op) => op.getDisplay()); + return { + items: items, + type: Array(items.length).fill(CategoryType.STRING), + nature: Array(items.length).fill(SearchBarTokenNature.OPERATOR), + hideSuggestion: Array(items.length).fill( + previousToken.hideSuggestion, + ), + }; + case CategoryType.NUMBER: + items = Operators.getNumberOperators().map((op) => op.getDisplay()); + return { + items: items, + type: Array(items.length).fill(CategoryType.NUMBER), + nature: Array(items.length).fill(SearchBarTokenNature.OPERATOR), + hideSuggestion: Array(items.length).fill( + previousToken.hideSuggestion, + ), + }; + case CategoryType.BOOLEAN: + items = Operators.getBooleanOperators().map((op) => op.getDisplay()); + return { + items: items, + type: Array(items.length).fill(CategoryType.BOOLEAN), + nature: Array(items.length).fill(SearchBarTokenNature.OPERATOR), + hideSuggestion: Array(items.length).fill( + previousToken.hideSuggestion, + ), + }; + case CategoryType.DATE: + items = Operators.getDateOperators().map((op) => op.getDisplay()); + return { + items: items, + type: Array(items.length).fill(CategoryType.DATE), + nature: Array(items.length).fill(SearchBarTokenNature.OPERATOR), + hideSuggestion: Array(items.length).fill( + previousToken.hideSuggestion, + ), + }; + case CategoryType.CUSTOM: + items = Operators.getDefaultOperators().map((op) => op.getDisplay()); + return { + items: items, + nature: Array(items.length).fill(SearchBarTokenNature.OPERATOR), + type: Array(items.length).fill(CategoryType.CUSTOM), + hideSuggestion: Array(items.length).fill( + previousToken.hideSuggestion, + ), + }; + + // We don't want suggestions for the number and in case of a custom token + default: + return { + items: [], + nature: [], + type: [], + hideSuggestion: [], + }; + } + } + + return { + items: [], + nature: [], + type: [], + hideSuggestion: [], + }; +} diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataService.ts b/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts similarity index 71% rename from packages/diracx-web-components/src/components/JobMonitor/JobDataService.ts rename to packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts index 9684f7f2..385d69d7 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataService.ts +++ b/packages/diracx-web-components/src/components/JobMonitor/jobDataService.ts @@ -7,18 +7,36 @@ import utc from "dayjs/plugin/utc"; dayjs.extend(utc); import { fetcher } from "../../hooks/utils"; import { Filter, SearchBody, Job, JobHistory } from "../../types"; +import type { JobSummary } from "../../types"; + +type TimeUnit = "minute" | "hour" | "day" | "month" | "year"; function processSearchBody(searchBody: SearchBody) { searchBody.search = searchBody.search?.map((filter: Filter) => { if (filter.operator == "last") { - return { - parameter: filter.parameter, - operator: "gt", - value: dayjs() - .subtract(1, filter.value as "hour" | "day" | "month" | "year") - .toISOString(), - values: filter.values, - }; + const valueStr = filter.value as string; + const match = valueStr.match(/^(\d+)\s*(minute|hour|day|month|year)s?$/i); + + if (match) { + const amount = parseInt(match[1], 10); + const unit = match[2].toLowerCase() as TimeUnit; + + return { + parameter: filter.parameter, + operator: "gt", + value: dayjs().subtract(amount, unit).toISOString(), + values: filter.values, + }; + } else { + return { + parameter: filter.parameter, + operator: "gt", + value: dayjs() + .subtract(1, filter.value as TimeUnit) + .toISOString(), + values: filter.values, + }; + } } return filter; }); @@ -27,6 +45,7 @@ function processSearchBody(searchBody: SearchBody) { /** * Custom hook for fetching jobs data. * + * @param diracxUrl - The base URL of the DiracX API. * @param accessToken - The access token for authentication. * @param searchBody - The search body for filtering jobs. * @param page - The page number for pagination. @@ -58,6 +77,7 @@ export const useJobs = ( /** * Refreshes the jobs by mutating the SWR cache with the search body and pagination values * + * @param diracxUrl - The base URL of the DiracX API. * @param accessToken - The access token for authentication. * @param searchBody - The search body containing the filters and search criteria. * @param page - The page number for pagination. @@ -82,6 +102,7 @@ export const refreshJobs = ( /** * Deletes jobs with the specified IDs. * + * @param diracxUrl - The base URL of the DiracX API. * @param selectedIds - An array of job IDs to delete. * @param accessToken - The authentication token. */ @@ -120,6 +141,7 @@ type StatusBody = { /** * Kills the specified jobs. * + * @param diracxUrl - The base URL of the DiracX API. * @param selectedIds - An array of job IDs to be killed. * @param token - The authentication token. * @returns A Promise that resolves to an object containing the response headers and data. @@ -154,6 +176,7 @@ export function killJobs( /** * Reschedules the specified jobs. * + * @param diracxUrl - The base URL of the DiracX API. * @param selectedIds - An array of job IDs to be rescheduled. * @param token - The authentication token. * @returns A Promise that resolves to an object containing the response headers and data. @@ -173,6 +196,8 @@ export function rescheduleJobs( /** * Retrieves the job history for a given job ID. + * + * @param diracxUrl - The base URL of the DiracX API. * @param jobId - The ID of the job. * @param token - The authentication token. * @returns A Promise that resolves to an object containing the headers and data of the job history. @@ -203,3 +228,40 @@ export async function getJobHistory( return { data: data[0].LoggingInfo }; } + +/** + * Retrieves the job summary for a given grouping. + * + * @param diracxUrl - The base URL of the DiracX API. + * @param grouping - An array of strings representing the grouping fields. + * @param accessToken - The authentication token. + * @param searchBody - The search body to be sent along with the request (optional). + * @returns A Promise that resolves to an object containing the job summary data. + */ +export async function getJobSummary( + diracxUrl: string | null, + grouping: string[], + accessToken: string, + searchBody?: SearchBody, +): Promise<{ data: JobSummary[] }> { + if (!diracxUrl) { + throw new Error("Invalid URL generated for fetching job summary."); + } + + if (searchBody) processSearchBody(searchBody); + + const summaryUrl = `${diracxUrl}/api/jobs/summary`; + const body = { + grouping: grouping, + search: searchBody?.search || [], + }; + // Expect the response to be an array of objects with all the grouping fields + const { data } = await fetcher>([ + summaryUrl, + accessToken, + "POST", + body, + ]); + + return { data }; +} diff --git a/packages/diracx-web-components/src/components/Login/LoginForm.tsx b/packages/diracx-web-components/src/components/Login/LoginForm.tsx index f17ae233..6299bce8 100644 --- a/packages/diracx-web-components/src/components/Login/LoginForm.tsx +++ b/packages/diracx-web-components/src/components/Login/LoginForm.tsx @@ -61,7 +61,6 @@ export function LoginForm({ if (isAuthenticated) { sessionStorage.removeItem(OIDC_LOGIN_ATTEMPTED_KEY); const redirect = getParam("redirect"); - console.log("Redirecting to:", redirect); if (redirect) { setPath(redirect); } else { diff --git a/packages/diracx-web-components/src/components/shared/FilterForm.tsx b/packages/diracx-web-components/src/components/shared/FilterForm.tsx deleted file mode 100644 index ed6cc486..00000000 --- a/packages/diracx-web-components/src/components/shared/FilterForm.tsx +++ /dev/null @@ -1,348 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { - Button, - FormControl, - InputLabel, - MenuItem, - Select, - SelectChangeEvent, - Stack, - TextField, -} from "@mui/material"; -import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; -import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; -import { ColumnDef } from "@tanstack/react-table"; -import dayjs from "dayjs"; -import { InternalFilter } from "../../types/Filter"; -import "dayjs/locale/en-gb"; // needed by LocalizationProvider to format Dates to dd-mm-yyyy - -/** - * Filter form props - * @property {ColumnDef[]} columns - the columns on which to filter - * @property {function} handleFilterChange - the function to call when a filter is changed - * @property {function} handleFilterMenuClose - the function to call when the filter menu is closed - * @property {InternalFilter[]} filters - the filters for the table - * @property {number} selectedFilterId - the id of the selected filter - */ -export interface FilterFormProps> { - /** The columns of the data table */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - columns: ColumnDef[]; - /** The function to call when a filter is changed */ - handleFilterChange: (index: number, tempFilter: InternalFilter) => void; - /** The function to call when the filter menu is closed */ - handleFilterMenuClose: () => void; - /** The filters for the table */ - filters: InternalFilter[]; - /** The function to set the filters */ - setFilters: React.Dispatch>; - /** The id of the selected filter */ - selectedFilterId: number | undefined; -} - -/** - * Filter form component - * - * @returns a FilterForm component - */ -export function FilterForm>({ - columns, - filters, - setFilters, - handleFilterChange, - handleFilterMenuClose, - selectedFilterId, -}: FilterFormProps) { - const [tempFilter, setTempFilter] = useState(null); - // Find the index using the filter ID - const filterIndex = filters.findIndex((f) => f.id === selectedFilterId); - - // Set the temp filter - useEffect(() => { - if (filterIndex !== -1) { - setTempFilter(filters[filterIndex]); - } else { - setTempFilter({ - id: Date.now(), - parameter: "", - operator: "eq", - value: "", - isApplied: false, - }); - } - }, [filters, filterIndex]); - - if (!tempFilter) return null; - - const onChange = (field: string, value: string | string[] | undefined) => { - setTempFilter((prevFilter: InternalFilter | null) => { - if (prevFilter === null) { - return null; // or initialize a new Filter object as appropriate - } - // Ensuring all fields of Filter are always defined - const updatedFilter: InternalFilter = { - ...prevFilter, - [field]: value, - }; - return updatedFilter; - }); - }; - - const applyChanges = () => { - if (filterIndex === -1) { - setFilters([...filters, tempFilter]); - } else { - handleFilterChange(filterIndex, tempFilter); - } - handleFilterMenuClose(); - }; - - const selectedColumn = columns.find((c) => c.id == tempFilter.parameter); - - const columnType = selectedColumn?.meta?.type || "default"; - const isCategory = Array.isArray(selectedColumn?.meta?.values); - const isDateTime = columnType === "date"; - const isNumber = columnType === "number"; - - const operatorOptions = { - date: ["last", "gt", "lt"], - category: ["eq", "neq", "in", "not in", "like"], - number: ["eq", "neq", "gt", "lt", "in", "not in", "like"], - default: ["eq", "neq", "gt", "lt", "like"], - }; - - const defaultOperators = { - date: "last", - category: "eq", - number: "eq", - default: "eq", - }; - - const operatorLabels: { [operator: string]: string } = { - eq: "equals to", - neq: "not equals to", - last: "in the last", - gt: "is greater than", - lt: "is lower than", - in: "is in", - "not in": "is not in", - like: "like", - }; - - const getOperatorType = () => { - if (isDateTime) return "date"; - if (isCategory) return "category"; - if (isNumber) return "number"; - return "default"; - }; - - const operatorType = getOperatorType(); - const operators = operatorOptions[operatorType]; - - const operatorSelector = ( - - Operator - - - ); - - const valueSelector = () => { - if (!tempFilter) return null; - - const isMultiple = ["in", "not in"].includes(tempFilter.operator); - const selectValue = isMultiple - ? ((tempFilter.values || []) as string[]) - : ((tempFilter.value || "") as string); - - const handleValueChange = (e: SelectChangeEvent) => { - const value = e.target.value; - if (isMultiple) { - onChange("values", value as string[]); - } else { - onChange("value", value as string); - } - }; - - if (isDateTime) { - if (tempFilter.operator !== "last") { - return ( - - - onChange("value", e?.toISOString() || "")} - views={["year", "day", "hours", "minutes", "seconds"]} - /> - - - ); - } else { - return ( - - Value - - - ); - } - } - - if (isCategory && tempFilter.operator !== "like") { - return ( - - Value - - - ); - } - - if (isNumber) { - if (!["in", "not in", "like"].includes(tempFilter.operator)) { - return ( - - onChange("value", e.target.value)} - type="number" - /> - - ); - } else if (isMultiple) { - return ( - - onChange("values", e.target.value.split(","))} - /> - - ); - } - } - - return ( - - onChange("value", e.target.value)} - /> - - ); - }; - - return ( - - - Parameter - - - - {operatorSelector} - - {valueSelector()} - - - - ); -} diff --git a/packages/diracx-web-components/src/components/shared/FilterToolbar.tsx b/packages/diracx-web-components/src/components/shared/FilterToolbar.tsx deleted file mode 100644 index 34bb43a2..00000000 --- a/packages/diracx-web-components/src/components/shared/FilterToolbar.tsx +++ /dev/null @@ -1,249 +0,0 @@ -"use client"; - -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { grey } from "@mui/material/colors"; -import { FilterList, Delete, Send, Refresh } from "@mui/icons-material"; -import Chip from "@mui/material/Chip"; -import Button from "@mui/material/Button"; -import { Alert, Box, Popover, Stack, Tooltip } from "@mui/material"; -import { ColumnDef } from "@tanstack/react-table"; -import { InternalFilter } from "../../types/Filter"; -import { FilterForm } from "./FilterForm"; -import "../../hooks/theme"; - -/** - * Filter toolbar component - * @param {FilterToolbarProps} props - the props for the component - */ -export interface FilterToolbarProps> { - /** The columns of the data table */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - columns: ColumnDef[]; - /** The filters */ - filters: InternalFilter[]; - /** The function to set the filters */ - setFilters: React.Dispatch>; - /** The function to apply the filters */ - handleApplyFilters: () => void; - /** The function to remove all filters */ - handleClearFilters: () => void; -} - -/** - * Filter toolbar component - * - * @returns a FilterToolbar component - */ -export function FilterToolbar>({ - columns, - filters, - setFilters, - handleApplyFilters, - handleClearFilters, -}: FilterToolbarProps) { - const [anchorEl, setAnchorEl] = useState(null); - const [selectedFilter, setSelectedFilter] = useState( - null, - ); - const addFilterButtonRef = useRef(null); - - // Filter actions - const handleAddFilter = useCallback(() => { - // Create a new filter: it will not be used - // It is just a placeholder to open the filter form - const newFilter = { - id: Date.now(), - parameter: "", - operator: "eq", - value: "", - isApplied: false, - }; - setSelectedFilter(newFilter); - setAnchorEl(addFilterButtonRef.current); - }, [setSelectedFilter, setAnchorEl]); - - const handleFilterChange = (index: number, newFilter: InternalFilter) => { - const updatedFilters = filters.map((filter, i) => - i === index ? newFilter : filter, - ); - setFilters(updatedFilters); - }; - - const open = Boolean(anchorEl); - - // Filter menu - /** - * Handle the filter menu open - * @param {React.MouseEvent} event - the event that triggered the menu open - */ - const handleFilterMenuOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - /** - * Handle the filter menu close - */ - const handleFilterMenuClose = () => { - setAnchorEl(null); - }; - - const handleRemoveFilter = (index: number) => { - setFilters(filters.filter((_, i) => i !== index)); - }; - - const changesUnapplied = useCallback(() => { - return filters.some((filter) => !filter.isApplied); - }, [filters]); - - function debounce void>( - func: T, - wait: number, - ) { - let timeout: ReturnType | undefined; - - return function executedFunction(event: KeyboardEvent) { - clearTimeout(timeout); - timeout = setTimeout(() => func(event), wait); - }; - } - - // Keyboard shortcuts - useEffect(() => { - const handleKeyPress = (event: KeyboardEvent) => { - if (event.altKey && event.shiftKey) { - switch (event.key.toLowerCase()) { - case "a": - event.preventDefault(); - event.stopPropagation(); - handleAddFilter(); - break; - case "p": - event.preventDefault(); - event.stopPropagation(); - handleApplyFilters(); - break; - case "c": - event.preventDefault(); - event.stopPropagation(); - handleClearFilters(); - break; - default: - break; - } - } - }; - - // Debounce the keypress handler to avoid rapid successive invocations - const debouncedHandleKeyPress = debounce(handleKeyPress, 300); - - // Add event listener - window.addEventListener("keydown", debouncedHandleKeyPress); - - // Remove event listener on cleanup - return () => { - window.removeEventListener("keydown", debouncedHandleKeyPress); - }; - }, [handleAddFilter, handleApplyFilters, handleClearFilters]); - - return ( - <> - - - - - - - - - - - - - - - - - - - {filters.map((filter: InternalFilter, index: number) => ( - { - handleFilterMenuOpen(event); // Open the menu - setSelectedFilter(filter); // Set the selected filter - }} - onDelete={() => { - handleRemoveFilter(index); - }} - sx={{ - m: 0.5, - backgroundColor: filter.isApplied ? "primary.main" : grey[500], - }} - className={ - filter.isApplied ? "chip-filter-applied" : "chip-filter-unapplied" - } - /> - ))} - - - - - - {changesUnapplied() && ( - - - Some filter changes have not been applied. Please click on - "Apply filters" to update your results. - - - )} - - ); -} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/DatePicker.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/DatePicker.tsx new file mode 100644 index 00000000..d67d69f4 --- /dev/null +++ b/packages/diracx-web-components/src/components/shared/SearchBar/DatePicker.tsx @@ -0,0 +1,108 @@ +import { + LocalizationProvider, + DateTimePicker, + DateTimePickerProps, +} from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import React, { KeyboardEvent, useState } from "react"; +import "dayjs/locale/en-gb"; // Import the locale for dayjs +import { TextField, TextFieldProps } from "@mui/material"; + +interface CustomDateTimePickerProps + extends Omit, "value" | "onChange"> { + value: string | null; + onDateAccepted: (value: string | null) => void; + handleArrowKeyDown: (event: KeyboardEvent) => void; + handleBackspaceKeyDown: (event: KeyboardEvent) => void; + inputRef?: React.Ref; +} + +/** + * + * @param value - The current value of the date-time picker. + * @param onDateAccepted - Callback function to handle when the date is accepted. + * @param handleArrowKeyDown - Callback function to handle arrow key down events. + * @param handleBackspaceKeyDown - Callback function to handle backspace key down events. + * @param inputRef - Ref to the input element for direct manipulation. + * @returns + */ +export function MyDateTimePicker({ + value, + onDateAccepted, + handleArrowKeyDown, + handleBackspaceKeyDown, + inputRef, + ...props +}: CustomDateTimePickerProps) { + const [dateValue, setDateValue] = useState( + value ? dayjs(value) : dayjs(), + ); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + if (dateValue?.isValid()) onDateAccepted(dateValue.toISOString()); + } + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + if (handleArrowKeyDown) { + handleArrowKeyDown(event); + } + } + if (event.key === "Backspace") { + if ( + handleBackspaceKeyDown && + inputRef && + typeof inputRef !== "function" && + inputRef.current?.selectionStart === 0 + ) { + handleBackspaceKeyDown(event); + } + } + }; + + return ( + +
e.stopPropagation()}> + + value={dateValue} + onChange={(val) => setDateValue(val)} + views={["year", "month", "day", "hours", "minutes", "seconds"]} + onAccept={(val, _ctx) => + onDateAccepted(val ? val.toISOString() : null) + } + slots={{ + textField: ForwardedTextField, // Use the forwarded ref TextField + }} + slotProps={{ + textField: { + onKeyDown: handleKeyDown, + inputRef: inputRef, // Pass the ref to the TextField + }, + }} + sx={{ + // Create a red border if the date is invalid + "& .MuiInput-underline:before": { + borderBottom: dateValue?.isValid() ? "none" : "1px solid red", + }, + "& .MuiInput-underline:after": { + borderBottom: dateValue?.isValid() ? "none" : "1px solid red", + }, + }} + {...props} + /> +
+
+ ); +} + +/** + * ForwardedTextField is a wrapper around the MUI TextField component + * that allows it to be used with the DateTimePicker component. + * It forwards the ref to the input element and applies custom styles. + */ +const ForwardedTextField = React.forwardRef( + function ForwardedTextField(props, ref) { + return ; + }, +); diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx new file mode 100644 index 00000000..db96a27c --- /dev/null +++ b/packages/diracx-web-components/src/components/shared/SearchBar/DisplayTokenEquation.tsx @@ -0,0 +1,90 @@ +import { Button, ButtonGroup, Box } from "@mui/material"; + +import { + type SearchBarTokenEquation, + type EquationAndTokenIndex, + EquationStatus, +} from "../../../types"; + +import { convertListToString } from "./Utils"; + +/** + * Displays a token equation as a button group. + * + * @param tokensEquation The token equation to display. + * @param handleClick Function to handle click events on tokens. + * @param handleRightClick Function to handle right-click events. + * @param equationIndex The index of the equation in the list. + * @param DynamicSearchField A dynamic search field component to render when focused. + * @param focusedTokenIndex The index of the currently focused token, if any. + */ +export function DisplayTokenEquation({ + tokensEquation, + handleClick, + handleRightClick, + equationIndex, + DynamicSearchField, + focusedTokenIndex, +}: { + tokensEquation: SearchBarTokenEquation; + handleClick: ( + e: React.MouseEvent, + tokenIndex: number, + ) => void; + handleRightClick: () => void; + equationIndex: number; + DynamicSearchField: React.ReactNode; + focusedTokenIndex: EquationAndTokenIndex | null; +}) { + const tokens = tokensEquation.items; + + const buttonColor = + tokensEquation.status === EquationStatus.VALID + ? "green" + : tokensEquation.status === EquationStatus.INVALID + ? "red" + : tokensEquation.status === EquationStatus.WAITING + ? "orange" + : "grey"; + + return ( + + + {tokens.map((token, tokenIndex) => { + if ( + equationIndex === focusedTokenIndex?.equationIndex && + tokenIndex === focusedTokenIndex.tokenIndex + ) { + return DynamicSearchField; + } + let buttonLabel = token.label; + if (typeof token.label !== "string") { + buttonLabel = convertListToString(token.label); + } + return ( + + ); + })} + + + ); +} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx new file mode 100644 index 00000000..4987f43c --- /dev/null +++ b/packages/diracx-web-components/src/components/shared/SearchBar/SearchBar.tsx @@ -0,0 +1,358 @@ +import React, { useState, useRef, useEffect } from "react"; + +import { Box, Menu, MenuItem, IconButton } from "@mui/material"; + +import DeleteIcon from "@mui/icons-material/Delete"; + +import { + Filter, + SearchBarToken, + SearchBarTokenEquation, + SearchBarSuggestions, + EquationAndTokenIndex, + EquationStatus, + SearchBarTokenNature, + CategoryType, +} from "../../../types"; + +import { + handleEquationsVerification, + getPreviousEquationAndToken, + convertFilterToTokenEquation, +} from "./Utils"; + +import { DisplayTokenEquation } from "./DisplayTokenEquation"; + +import { + convertAndApplyFilters, + defaultClearFunction, +} from "./defaultFunctions"; + +import SearchField from "./SearchField"; + +export interface SearchBarProps { + /** The filters to be applied to the search */ + filters: Filter[]; + /** The function to set the filters */ + setFilters: React.Dispatch>; + /** The data to be used for suggestions */ + createSuggestions: ( + previousToken: SearchBarToken | undefined, + previousEquation: SearchBarTokenEquation | undefined, + equationIndex?: number, + ) => Promise; + /** The function to call when the search is performed (optional) */ + searchFunction?: ( + equations: SearchBarTokenEquation[], + setFilters: React.Dispatch>, + ) => void; + /** The function to call when the search is cleared (optional) */ + clearFunction?: ( + setFilters: React.Dispatch>, + setTokenEquations: React.Dispatch< + React.SetStateAction + >, + ) => void; + /** Whether to allow keyword search or not (default is true) */ + allowKeyWordSearch?: boolean; +} + +/** + * The SearchBar component allows users to create and manage search filters + * using a dynamic input field that supports token equations. + * + * @param props - The properties for the SearchBar component. + * @returns The rendered SearchBar component. + */ +export function SearchBar({ + filters, + setFilters, + createSuggestions, + searchFunction = convertAndApplyFilters, + clearFunction = defaultClearFunction, + allowKeyWordSearch = true, +}: SearchBarProps) { + const [inputValue, setInputValue] = useState(""); + const [anchorEl, setAnchorEl] = useState(null); + const [clickedTokenIndex, setClickedTokenIndex] = + useState(null); + const [focusedTokenIndex, setFocusedTokenIndex] = + useState(null); + + const inputRef = useRef(null); + const searchTimerRef = useRef(null); + const lastSearchedEquationsRef = useRef(""); + const lastClickedTokenIndexRef = useRef(null); + const [tokenEquations, setTokenEquations] = useState< + SearchBarTokenEquation[] + >([]); + + const [suggestions, setSuggestions] = useState({ + items: [], + nature: [], + type: [], + hideSuggestion: [], + }); + + const { previousEquation, previousToken } = getPreviousEquationAndToken( + focusedTokenIndex, + tokenEquations, + ); + + if ( + previousEquation === undefined && + focusedTokenIndex !== null && + tokenEquations.length === 0 + ) { + setFocusedTokenIndex(null); + setInputValue(""); + } + + // Effect to initialize the token equations from filters + useEffect(() => { + if (tokenEquations.length !== 0) return; // Avoid reloading if already loaded + + async function load() { + const promises = filters.map(async (filter, filterIndex) => + convertFilterToTokenEquation(filter, filterIndex, createSuggestions), + ); + const newTokenEquations = await Promise.all(promises); + setTokenEquations(newTokenEquations); + } + + if (filters.length !== 0 && tokenEquations.length === 0) load(); + }, [filters, createSuggestions]); + + // Create a list of options based on the current tokens and data + useEffect(() => { + async function load() { + const result = await createSuggestions( + previousToken, + previousEquation, + focusedTokenIndex?.equationIndex, + ); + setSuggestions(result); + } + load(); + }, [previousEquation, previousToken, createSuggestions]); + + // Timer to delay the search function + // This effect will trigger the searchFonction after a delay if the equations are valid + useEffect(() => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + const allEquationsValid = tokenEquations.every( + (eq) => eq.status === EquationStatus.VALID, + ); + + const currentEquationsString = JSON.stringify( + tokenEquations.map((eq) => ({ + items: eq.items.map((item) => ({ label: item.label, type: item.type })), + status: eq.status, + })), + ); + + const hasChanged = + currentEquationsString !== lastSearchedEquationsRef.current; + + if (allEquationsValid && searchFunction && hasChanged) { + searchTimerRef.current = setTimeout(() => { + lastSearchedEquationsRef.current = currentEquationsString; + searchFunction(tokenEquations, setFilters); + }, 800); + } + + return () => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + }; + }, [tokenEquations, searchFunction, setFilters]); + + // Always focus the input field + useEffect(() => { + inputRef.current?.focus(); + }, [focusedTokenIndex]); + + const handleOptionMenuOpen = ( + event: React.MouseEvent, + equationIndex: number, + tokenIndex: number, + ) => { + if ( + (tokenEquations[equationIndex].items[tokenIndex].suggestions?.items || []) + .length > 0 + ) { + setAnchorEl(event.currentTarget); + setClickedTokenIndex({ equationIndex, tokenIndex }); + } + }; + + const handleOptionMenuClose = () => { + setAnchorEl(null); + setClickedTokenIndex(null); + }; + + const handleOptionSelect = ( + option: string, + nature: SearchBarTokenNature, + type: CategoryType, + hideSuggestion: boolean, + ) => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + + if (clickedTokenIndex !== null) { + const updatedTokens = [...tokenEquations]; // Create a copy of the token equations + const updatedToken = updatedTokens[clickedTokenIndex.equationIndex]; // The equation being edited + + updatedToken.items[clickedTokenIndex.tokenIndex] = { + ...updatedToken.items[clickedTokenIndex.tokenIndex], + type: type, // Change the type + nature: nature, // Change the nature + label: option, + hideSuggestion, + }; + + updatedTokens[clickedTokenIndex.equationIndex] = updatedToken; // Update the equation in the list + setTokenEquations(updatedTokens); + handleEquationsVerification(updatedTokens, setTokenEquations); + } + handleOptionMenuClose(); + }; + + const DynamicSearchField = ( + + ); + + // Update the suggestions of the selected token if it exists + useEffect(() => { + async function updateSuggestions() { + if ( + clickedTokenIndex === null || + lastClickedTokenIndexRef.current === JSON.stringify(clickedTokenIndex) + ) + return; + const { previousEquation, previousToken } = getPreviousEquationAndToken( + clickedTokenIndex, + tokenEquations, + ); + tokenEquations[clickedTokenIndex.equationIndex].items[ + clickedTokenIndex.tokenIndex + ].suggestions = await createSuggestions( + previousToken, + previousEquation, + clickedTokenIndex.equationIndex, + ); + + setTokenEquations([...tokenEquations]); // Update the state to trigger a re-render + } + updateSuggestions(); + lastClickedTokenIndexRef.current = JSON.stringify(clickedTokenIndex); + }, [clickedTokenIndex, tokenEquations, createSuggestions]); + + /** + * The suggestions of the selected token + */ + const currentSuggestions: SearchBarSuggestions = + clickedTokenIndex !== null + ? tokenEquations[clickedTokenIndex.equationIndex].items[ + clickedTokenIndex.tokenIndex + ].suggestions || { + items: [], + nature: [], + type: [], + hideSuggestion: [], + } + : { items: [], nature: [], type: [], hideSuggestion: [] }; + + return ( + { + inputRef.current?.focus(); + }} + sx={{ + width: 1, + display: "flex", + border: "1px solid", + borderColor: "grey.400", + overflow: "auto", + borderRadius: 1, + ":focus-within": { + borderColor: "primary.main", + }, + }} + data-testid="search-bar" + > + + {tokenEquations.map((equation, index) => ( + + handleOptionMenuOpen(e, index, tokenIndex) + } + handleRightClick={() => + setTokenEquations((prev) => [ + ...prev.filter((_, i) => i !== index), + ]) + } // Remove the equation on right click + equationIndex={index} + DynamicSearchField={DynamicSearchField} // The dynamic search field can be in the middle of the equations + focusedTokenIndex={focusedTokenIndex} + /> + ))} + {!focusedTokenIndex && DynamicSearchField} + {/* Otherwise, the search field is at the end */} + + {clickedTokenIndex !== null && + currentSuggestions.items.map((option, idx) => ( + + handleOptionSelect( + option, + currentSuggestions.nature[idx], + currentSuggestions.type[idx], + currentSuggestions.hideSuggestion[idx], + ) + } + > + {option} + + ))} + + + {tokenEquations.length !== 0 && ( + { + setInputValue(""); + clearFunction(setFilters, setTokenEquations); + }} + disabled={tokenEquations.length === 0} + sx={{ marginLeft: "auto", width: "40px", height: "40px" }} + > + + + )} + + ); +} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/SearchField.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/SearchField.tsx new file mode 100644 index 00000000..cf154f78 --- /dev/null +++ b/packages/diracx-web-components/src/components/shared/SearchBar/SearchField.tsx @@ -0,0 +1,393 @@ +import { useState, useRef, useEffect } from "react"; + +import { Autocomplete, TextField } from "@mui/material"; + +import { + SearchBarTokenEquation, + SearchBarSuggestions, + EquationAndTokenIndex, + CategoryType, + SearchBarTokenNature, + EquationStatus, + Operators, +} from "../../../types"; + +import { + getPreviousEquationAndToken, + handleEquationsVerification, + getTokenMetadata, + convertListToString, +} from "./Utils"; + +import "dayjs/locale/en-gb"; // Import the locale for dayjs + +import { MyDateTimePicker } from "./DatePicker"; + +interface SearchFieldProps { + inputValue: string; + setInputValue: React.Dispatch>; + inputRef: React.RefObject; + setTokenEquations: React.Dispatch< + React.SetStateAction + >; + tokenEquations: SearchBarTokenEquation[]; + suggestions: SearchBarSuggestions; + focusedTokenIndex: EquationAndTokenIndex | null; + setFocusedTokenIndex: React.Dispatch< + React.SetStateAction + >; + allowKeyWordSearch?: boolean; +} + +export default function SearchField({ + inputValue, + setInputValue, + inputRef, + setTokenEquations, + tokenEquations, + suggestions, + focusedTokenIndex, + setFocusedTokenIndex, + allowKeyWordSearch = true, +}: SearchFieldProps) { + const optionSelectedRef = useRef(false); + const { previousEquation, previousToken } = getPreviousEquationAndToken( + focusedTokenIndex, + tokenEquations, + ); + const [placeholder, setPlaceholder] = useState(""); + + useEffect(() => { + setPlaceholder( + previousToken + ? previousToken.nature === SearchBarTokenNature.OPERATOR + ? "Enter a value" + : previousToken.nature === SearchBarTokenNature.CATEGORY + ? "Enter an operator" + : "Enter a category" + : "Enter a category", + ); + }, [previousToken]); + + /** + * Create a new token based on the input value and type. + * @param label The label for the new token. + * @param nature The nature of the token (e.g., "category", "custom", "operator", "value", ...). + * @param type The type of the token (e.g., "string", "custom", "number", "bool", ...). + */ + function handleTokenCreation( + label: string, + nature: SearchBarTokenNature, + type: CategoryType, + hideSuggestion: boolean, + ) { + if (!allowKeyWordSearch && nature === SearchBarTokenNature.CUSTOM) return; + + const formatedLabel = /\||,/.test(label) + ? label.split(/,|\|/).map((item) => item.trim()) + : label.trim(); + + if (focusedTokenIndex) { + // If a token is focused, update the focused token + const updatedTokens = [...tokenEquations]; + const equationIndex = focusedTokenIndex.equationIndex; + const tokenIndex = focusedTokenIndex.tokenIndex; + + if (updatedTokens[equationIndex]) { + updatedTokens[equationIndex].items[tokenIndex] = { + label: formatedLabel, + type: type, + nature: nature, + suggestions: + nature === SearchBarTokenNature.CATEGORY ? undefined : suggestions, + hideSuggestion: hideSuggestion, + }; + handleEquationsVerification(updatedTokens, setTokenEquations); + } + setFocusedTokenIndex(null); + } else { + // If no token is focused, create a new token + if ( + previousEquation && + previousEquation.status === EquationStatus.WAITING + ) { + previousEquation.items.push({ + label: formatedLabel, + type: type, + nature: nature, + suggestions: suggestions, + hideSuggestion: hideSuggestion, + }); + handleEquationsVerification([...tokenEquations], setTokenEquations); + } else { + // We are creating a new equation + const newLastEquation: SearchBarTokenEquation = { + status: + type === CategoryType.CUSTOM + ? EquationStatus.VALID + : EquationStatus.WAITING, + items: [ + { + label: formatedLabel, + type: type, + nature: nature, + suggestions: undefined, + hideSuggestion: hideSuggestion, + }, + ], + }; + handleEquationsVerification( + [...tokenEquations, newLastEquation], + setTokenEquations, + ); + } + } + setInputValue(""); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + + function handleArrowKeyDown(e: React.KeyboardEvent) { + const input = inputRef.current; + + if (!input) return; + + const isDatePicker = + previousToken?.type === CategoryType.DATE && + previousToken?.nature === SearchBarTokenNature.OPERATOR && + previousToken.label !== Operators.LAST.getDisplay(); + + const isAtLeftEdge = input.selectionStart === 0; + const isAtRightEdge = isDatePicker + ? input.selectionEnd === 19 // Assuming that the date picker input has a fixed width of 19 characters + : input.selectionEnd === inputValue.length; + + let newFocusedTokenIndex: EquationAndTokenIndex | null = null; + + if (e.key === "ArrowLeft" && isAtLeftEdge && tokenEquations.length > 0) { + e.preventDefault(); + if (focusedTokenIndex === null) { + // If no token is focused, focus the last token + newFocusedTokenIndex = { + equationIndex: tokenEquations.length - 1, + tokenIndex: + tokenEquations[tokenEquations.length - 1].items.length - 1, + }; + } else if (focusedTokenIndex !== null) { + // If a token is focused, move the focus to the left + const { equationIndex, tokenIndex } = focusedTokenIndex; + if (tokenIndex > 0) { + newFocusedTokenIndex = { + equationIndex: equationIndex, + tokenIndex: tokenIndex - 1, + }; + } else if (equationIndex > 0) { + newFocusedTokenIndex = { + equationIndex: equationIndex - 1, + tokenIndex: tokenEquations[equationIndex - 1].items.length - 1, + }; + } + } + } + + if ( + e.key === "ArrowRight" && + isAtRightEdge && + focusedTokenIndex !== null && + tokenEquations.length > 0 + ) { + const { equationIndex, tokenIndex } = focusedTokenIndex; + if (tokenIndex < tokenEquations[equationIndex].items.length - 1) { + // If there are more tokens in the current equation, move the focus to the right + newFocusedTokenIndex = { + equationIndex: equationIndex, + tokenIndex: tokenIndex + 1, + }; + } else if (equationIndex < tokenEquations.length - 1) { + // If there are more equations, move to the first token of the next equation + newFocusedTokenIndex = { + equationIndex: equationIndex + 1, + tokenIndex: 0, + }; + } else { + setFocusedTokenIndex(null); + setInputValue(""); + } + } + + if (newFocusedTokenIndex) { + setInputValue( + convertListToString( + tokenEquations[newFocusedTokenIndex.equationIndex].items[ + newFocusedTokenIndex.tokenIndex + ].label, + ), + ); + + setFocusedTokenIndex(newFocusedTokenIndex); + } + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + + function handleBackspaceKeyDown() { + if (inputValue === "" && tokenEquations.length > 0) { + const updatedTokens = [...tokenEquations]; + const lastEquation = updatedTokens[updatedTokens.length - 1]; + if (lastEquation.items.length > 1) { + lastEquation.items = lastEquation.items.slice(0, -1); + } else { + updatedTokens.pop(); + } + handleEquationsVerification(updatedTokens, setTokenEquations); + setFocusedTokenIndex(null); + } + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + + // Calculate the width of the input field based on the input value length + const width = Math.min(Math.max(inputValue.length * 8 + 50, 150), 800); + + const handleDateAccepted = (newValue: string | null) => { + if (newValue) { + handleTokenCreation( + newValue, + SearchBarTokenNature.VALUE, + CategoryType.DATE, + false, + ); + } + }; + + if ( + previousToken?.nature === SearchBarTokenNature.OPERATOR && + previousToken?.type === CategoryType.DATE && + previousToken.label !== Operators.LAST.getDisplay() + ) { + return ( + + ); + } + + return ( + { + setInputValue(value); + }} + sx={{ + minWidth: "180px", + width: "auto", + maxWidth: 0.9, + }} + disableClearable={true} + options={suggestions.items} + value={inputValue} + onHighlightChange={(_e, option) => { + optionSelectedRef.current = option !== null; + }} + renderInput={(params) => ( + ) => { + if (e.key === "Enter" && inputValue.trim()) { + if (optionSelectedRef.current) { + optionSelectedRef.current = false; + return; + } + const { nature, type, hideSuggestion } = getTokenMetadata( + inputValue.trim(), + suggestions, + previousToken, + ); + // Always create token on Enter press, regardless of operator type + handleTokenCreation( + inputValue.trim(), + nature, + type, + hideSuggestion, + ); + } + if (e.key === "Backspace") { + handleBackspaceKeyDown(); + } + + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + handleArrowKeyDown(e); + } + + if (e.key === "Tab") { + e.preventDefault(); + setInputValue((prev) => { + const options = suggestions.items.filter((val) => { + return val.toLowerCase().startsWith(prev.toLowerCase()); + }); + return options[0] || prev; + }); + } + if (e.key === "Escape") { + setFocusedTokenIndex(null); + setInputValue(""); + } + }} + /> + )} + onChange={(_e, value: string | null) => { + if (value !== null && value !== "") { + optionSelectedRef.current = true; + + // Check if previous token is "in" or "not in" operator + if ( + previousToken && + (previousToken.label === Operators.IN.getDisplay() || + previousToken.label === Operators.NOT_IN.getDisplay()) + ) { + // For "in" and "not in" operators, accumulate values with " | " separator + // Don't create token immediately - wait for Enter press + if (inputValue.trim() === "") { + setInputValue(value); + } else { + // Additional value selection - append with separator + setInputValue((prev) => { + return inputRef.current?.value + " | " + prev.trim(); + }); + } + return; + } + + // For all other operators, create token immediately + const { nature, type, hideSuggestion } = getTokenMetadata( + value.trim(), + suggestions, + previousToken, + ); + handleTokenCreation(value, nature, type, hideSuggestion); + } + }} + /> + ); +} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/Utils.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/Utils.tsx new file mode 100644 index 00000000..c5596f6b --- /dev/null +++ b/packages/diracx-web-components/src/components/shared/SearchBar/Utils.tsx @@ -0,0 +1,338 @@ +import { + SearchBarTokenEquation, + SearchBarToken, + Filter, + SearchBarSuggestions, + EquationAndTokenIndex, + CategoryType, + SearchBarTokenNature, + EquationStatus, + Operators, +} from "../../../types"; + +/** + * @param tokenEquations The list of token equations to be verified. + * @param setTokenEquations A function to update the state of token equations. + * This function verifies the validity of each equation and updates their status. + */ +export function handleEquationsVerification( + tokenEquations: SearchBarTokenEquation[], + setTokenEquations: React.Dispatch< + React.SetStateAction + >, +) { + tokenEquations = tokenEquations.map(handleEquationVerification); + + if ( + tokenEquations.length > 0 && + tokenEquations[tokenEquations.length - 1].status === + EquationStatus.INVALID && + tokenEquations[tokenEquations.length - 1].items.length < 3 + ) { + tokenEquations[tokenEquations.length - 1].status = EquationStatus.WAITING; + } + + setTokenEquations([...tokenEquations]); +} + +/** + * @param tokenEquation The equation to be verified. + * @returns The equation with its status updated based on its validity. + */ +function handleEquationVerification( + tokenEquation: SearchBarTokenEquation, +): SearchBarTokenEquation { + const freeTextOperators = Operators.getFreeTextOperators().map((operator) => + operator.getDisplay(), + ); + + // Sometimes, an equation can be a single token, e.g., a keyword + if (tokenEquation.items.length === 1) { + tokenEquation.status = + tokenEquation.items[0].nature === SearchBarTokenNature.CUSTOM + ? EquationStatus.VALID + : EquationStatus.INVALID; + return tokenEquation; + } + + if (tokenEquation.items.length !== 3) { + tokenEquation.status = EquationStatus.INVALID; + return tokenEquation; + } + + // Check the structure of the equation + if ( + tokenEquation.items[0].nature !== SearchBarTokenNature.CATEGORY || + tokenEquation.items[1].nature !== SearchBarTokenNature.OPERATOR || + tokenEquation.items[2].nature !== SearchBarTokenNature.VALUE + ) { + tokenEquation.status = EquationStatus.INVALID; + return tokenEquation; + } + + // When the equation are build from the storage, the types are not set yet + if (tokenEquation.items[2].type === CategoryType.UNKNOWN) { + tokenEquation.status = EquationStatus.VALID; + return tokenEquation; + } + + // For a normal equation, we check is consistency based on the type of the first token + switch (tokenEquation.items[0].type) { + case CategoryType.STRING: + if ( + freeTextOperators.includes(tokenEquation.items[1].label as string) || + (tokenEquation.items[1].type === CategoryType.STRING && + tokenEquation.items[2].type === CategoryType.STRING) + ) + tokenEquation.status = EquationStatus.VALID; + else tokenEquation.status = EquationStatus.INVALID; + break; + + case CategoryType.NUMBER: + if ( + freeTextOperators.includes(tokenEquation.items[1].label as string) || + (tokenEquation.items[1].type === CategoryType.NUMBER && + !Number.isNaN(Number(tokenEquation.items[2].label))) + ) + tokenEquation.status = EquationStatus.VALID; + else tokenEquation.status = EquationStatus.INVALID; + break; + + case CategoryType.BOOLEAN: + if ( + tokenEquation.items[1].type === CategoryType.BOOLEAN && + (tokenEquation.items[2].label === "true" || + tokenEquation.items[2].label === "false") + ) + tokenEquation.status = EquationStatus.VALID; + else tokenEquation.status = EquationStatus.INVALID; + break; + + case CategoryType.DATE: + if ( + tokenEquation.items[1].type === CategoryType.DATE && + (tokenEquation.items[1].label === Operators.GREATER_THAN.getDisplay() || + tokenEquation.items[1].label === Operators.LESS_THAN.getDisplay()) && + /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test( + tokenEquation.items[2].label as string, + ) + ) + tokenEquation.status = EquationStatus.VALID; + else if ( + tokenEquation.items[1].label === Operators.LAST.getDisplay() && + typeof tokenEquation.items[2].label == "string" + ) { + const pattern = + /^(minute|hour|day|week|month|year)$|^(\d+)\s+(minutes|hours|days|weeks|months|years)$/; + + const match = tokenEquation.items[2].label.match(pattern); + if (!match) { + tokenEquation.status = EquationStatus.INVALID; + return tokenEquation; + } + + if (match[1]) tokenEquation.status = EquationStatus.VALID; + else { + const quantity = parseInt(match[2], 10); + const unit = match[3]; + const years = (() => { + switch (unit) { + case "minutes": + return quantity / (60 * 24 * 365); + case "hours": + return quantity / (24 * 365); + case "days": + return quantity / 365; + case "weeks": + return quantity / 52; + case "months": + return quantity / 12; + case "years": + return quantity; + default: + return 0; + } + })(); + tokenEquation.status = + years < 2025 ? EquationStatus.VALID : EquationStatus.INVALID; + } + } + } + + return tokenEquation; +} + +/** + * + * @param focusedTokenIndex The index of the focused token, or null if no token is focused. + * The structure is { equationIndex: number, tokenIndex: number }. + * @param tokenEquations The list of token equations. + * @returns An object containing the index of the previous equation and the index of the previous token. + */ +export function getPreviousEquationAndToken( + focusedTokenIndex: EquationAndTokenIndex | null, + tokenEquations: SearchBarTokenEquation[], +) { + if (focusedTokenIndex) { + if (focusedTokenIndex.tokenIndex > 0) { + const previousEquation: SearchBarTokenEquation | undefined = + tokenEquations[focusedTokenIndex.equationIndex]; + const previousToken = + previousEquation?.items[focusedTokenIndex.tokenIndex - 1]; + return { previousEquation, previousToken }; + } + if ( + focusedTokenIndex.equationIndex === 0 && + focusedTokenIndex.tokenIndex === 0 + ) { + return { previousEquation: undefined, previousToken: undefined }; + } + // else + const previousEquation = + tokenEquations[focusedTokenIndex.equationIndex - 1] || undefined; + const previousToken = + previousEquation.items[previousEquation.items.length - 1] || undefined; + return { previousEquation, previousToken }; + } + // else + const lastEquation = + tokenEquations.length > 0 + ? tokenEquations[tokenEquations.length - 1] + : undefined; + const lastToken = lastEquation?.items[lastEquation.items.length - 1]; + + return { previousEquation: lastEquation, previousToken: lastToken }; +} + +/** + * Returns the type of a token based on the previous token and equation. + * @param value The value of the token to be checked. + * @param suggestions The suggestions object containing items and their types. + * @param lastToken The last token in the equation, which can be undefined + * @returns The type of the token, which can be "custom", "value", "operator", "custom_value", or a category type. + */ +export function getTokenMetadata( + value: string, + suggestions: SearchBarSuggestions, + lastToken: SearchBarToken | undefined, +): { + nature: SearchBarTokenNature; + type: CategoryType; + hideSuggestion: boolean; +} { + const index = suggestions.items.indexOf(value); + if (index >= 0) { + return { + nature: suggestions.nature[index], + type: suggestions.type[index], + hideSuggestion: suggestions.hideSuggestion[index], + }; + } + if (lastToken && lastToken.nature === SearchBarTokenNature.OPERATOR) { + // If the last token is an operator, we assume the current token is a value + return { + nature: SearchBarTokenNature.VALUE, + type: CategoryType.CUSTOM, + hideSuggestion: lastToken.hideSuggestion, + }; + } + return { + nature: SearchBarTokenNature.CUSTOM, + type: CategoryType.CUSTOM, + hideSuggestion: true, + }; +} + +/** + * + * @param labelList The list of labels to be converted to a string. + * @returns A string representation of the label list, with each label separated by " | ". + */ +export function convertListToString(labelList: string[] | string): string { + if (Array.isArray(labelList)) { + return labelList + .reduce((acc, label) => acc + label + " | ", "") + .slice(0, -3); // Remove the last " | " + } + return labelList; +} + +/** + * This function converts a filter object into a SearchBarTokenEquation. + * + * @param filter The filter object containing the parameter, operator, and value(s). + * @param filterIndex The index of the filter in the list of filters. + * @param createSuggestions The function to create suggestions based on the previous token and equation. + * @returns A promise that resolves to a SearchBarTokenEquation object representing the filter. + */ +export async function convertFilterToTokenEquation( + filter: Filter, + filterIndex: number, + createSuggestions: ( + previousToken: SearchBarToken | undefined, + previousEquation: SearchBarTokenEquation | undefined, + filterIndex?: number, + ) => Promise, +): Promise { + const newEquation: SearchBarTokenEquation = { + items: [ + { + label: filter.parameter, + nature: SearchBarTokenNature.CATEGORY, + type: CategoryType.UNKNOWN, + hideSuggestion: true, + }, + { + label: Operators.getDisplayFromInternal(filter.operator), + nature: SearchBarTokenNature.OPERATOR, + type: CategoryType.UNKNOWN, + hideSuggestion: true, + }, + { + label: filter.value || filter.values || "", + nature: SearchBarTokenNature.VALUE, + type: CategoryType.UNKNOWN, + hideSuggestion: true, + }, + ], + status: EquationStatus.VALID, + }; + + // For the category + const suggestions_categories = await createSuggestions( + undefined, + undefined, + filterIndex, + ); + + newEquation.items[0].type = + suggestions_categories.type[ + suggestions_categories.items.indexOf(filter.parameter) + ] || SearchBarTokenNature.CATEGORY; + + // For the operator + const suggestions_operators = await createSuggestions( + newEquation.items[0], + newEquation, + filterIndex, + ); + + newEquation.items[1].type = + suggestions_operators.type[ + suggestions_operators.items.indexOf( + Operators.getDisplayFromInternal(filter.operator), + ) + ] || SearchBarTokenNature.OPERATOR; + + // For the value(s) + const suggestions_values = await createSuggestions( + newEquation.items[1], + newEquation, + filterIndex, + ); + + newEquation.items[1].suggestions = suggestions_operators; + newEquation.items[2].suggestions = suggestions_values; + + return newEquation; +} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/defaultFunctions.tsx b/packages/diracx-web-components/src/components/shared/SearchBar/defaultFunctions.tsx new file mode 100644 index 00000000..e2e26ed6 --- /dev/null +++ b/packages/diracx-web-components/src/components/shared/SearchBar/defaultFunctions.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +import { SearchBarTokenEquation, Filter, Operators } from "../../../types"; + +/** + * Function to convert token equations to internal filters + * @param equations The token equations to convert + * @param setFilters The function to set the filters state + */ +export function convertAndApplyFilters( + equations: SearchBarTokenEquation[], + setFilters: React.Dispatch>, +) { + const newFilters: Filter[] = []; + + equations.forEach((equation) => { + if (equation.items.length === 3) { + let value = undefined; + let values = undefined; + if ( + equation.items[1].label === Operators.IN.getDisplay() || + equation.items[1].label === Operators.NOT_IN.getDisplay() + ) { + // Handle multi-value filters + values = Array.isArray(equation.items[2].label) + ? equation.items[2].label + : [equation.items[2].label]; + } else { + // Handle single-value filters + value = + typeof equation.items[2].label === "string" + ? equation.items[2].label + : undefined; + } + + newFilters.push({ + parameter: equation.items[0].label as string, + operator: Operators.getInternalFromDisplay( + equation.items[1].label as string, + ), + value: value, + values: values, + }); + } + }); + setFilters(newFilters); +} + +/** + * Function to clear the filters and token equations + * @param setFilters Function to set the filters state + * @param setTokenEquations Function to set the token equations state + */ +export function defaultClearFunction( + setFilters: React.Dispatch>, + setTokenEquations: React.Dispatch< + React.SetStateAction + >, +) { + setFilters([]); + setTokenEquations([]); +} diff --git a/packages/diracx-web-components/src/components/shared/SearchBar/index.ts b/packages/diracx-web-components/src/components/shared/SearchBar/index.ts new file mode 100644 index 00000000..76b5bf2a --- /dev/null +++ b/packages/diracx-web-components/src/components/shared/SearchBar/index.ts @@ -0,0 +1,2 @@ +export { SearchBar } from "./SearchBar"; +export type { SearchBarProps } from "./SearchBar"; diff --git a/packages/diracx-web-components/src/components/shared/index.ts b/packages/diracx-web-components/src/components/shared/index.ts index 2faa033b..a44c4698 100644 --- a/packages/diracx-web-components/src/components/shared/index.ts +++ b/packages/diracx-web-components/src/components/shared/index.ts @@ -1,5 +1,4 @@ export { DataTable } from "./DataTable"; -export { FilterForm } from "./FilterForm"; -export { FilterToolbar } from "./FilterToolbar"; export { ErrorBox } from "./ErrorBox"; export { ApplicationSelector } from "./ApplicationSelector"; +export * from "./SearchBar"; diff --git a/packages/diracx-web-components/src/global.d.ts b/packages/diracx-web-components/src/global.d.ts index 26fa0f1b..cab8932a 100644 --- a/packages/diracx-web-components/src/global.d.ts +++ b/packages/diracx-web-components/src/global.d.ts @@ -4,11 +4,14 @@ export {}; import "@tanstack/react-table"; +import { CategoryType } from "./types"; + /* eslint-disable @typescript-eslint/no-unused-vars */ declare module "@tanstack/react-table" { // Extend ColumnMeta to include custom properties interface ColumnMeta { - type?: "string" | "number" | "date" | "category"; + type?: CategoryType; values?: string[]; // Optional values for category-type fields + hideSuggestion?: boolean; // Whether to hide suggestions for this column } } diff --git a/packages/diracx-web-components/src/types/CategoryType.ts b/packages/diracx-web-components/src/types/CategoryType.ts new file mode 100644 index 00000000..d853b511 --- /dev/null +++ b/packages/diracx-web-components/src/types/CategoryType.ts @@ -0,0 +1,10 @@ +/** This file defines the types used in the search bar. */ + +export enum CategoryType { + NUMBER = "number", + STRING = "string", + BOOLEAN = "boolean", + DATE = "date", + CUSTOM = "custom", + UNKNOWN = "unknown", +} diff --git a/packages/diracx-web-components/src/types/EquationAndTokenIndex.tsx b/packages/diracx-web-components/src/types/EquationAndTokenIndex.tsx new file mode 100644 index 00000000..4aae6adb --- /dev/null +++ b/packages/diracx-web-components/src/types/EquationAndTokenIndex.tsx @@ -0,0 +1,4 @@ +export type EquationAndTokenIndex = { + equationIndex: number; + tokenIndex: number; +}; diff --git a/packages/diracx-web-components/src/types/EquationStatus.ts b/packages/diracx-web-components/src/types/EquationStatus.ts new file mode 100644 index 00000000..e9564d6c --- /dev/null +++ b/packages/diracx-web-components/src/types/EquationStatus.ts @@ -0,0 +1,5 @@ +export enum EquationStatus { + VALID = "valid", + INVALID = "invalid", + WAITING = "waiting", +} diff --git a/packages/diracx-web-components/src/types/Filter.ts b/packages/diracx-web-components/src/types/Filter.ts index 1ae2e0b2..07edab7c 100644 --- a/packages/diracx-web-components/src/types/Filter.ts +++ b/packages/diracx-web-components/src/types/Filter.ts @@ -1,11 +1,10 @@ "use client"; /** Filter type - * @property {number} id - the id of the filter - * @property {string} column - the column to filter by + * @property {string} parameter - the column to filter by * @property {string} operator - the operator to use for the filter * @property {string} value - the value to filter by - * @property {string[]} values - the values to filter by if there are multiple + * @property {string[]} values - the values to filter by if they are multiple */ export interface Filter { parameter: string; @@ -13,11 +12,3 @@ export interface Filter { value?: string; values?: string[]; } - -/** Internal Filter type - * @property {number} id - the id of the filter - */ -export interface InternalFilter extends Filter { - id: number; - isApplied: boolean; -} diff --git a/packages/diracx-web-components/src/types/JobSummary.ts b/packages/diracx-web-components/src/types/JobSummary.ts new file mode 100644 index 00000000..d3213192 --- /dev/null +++ b/packages/diracx-web-components/src/types/JobSummary.ts @@ -0,0 +1,3 @@ +export type JobSummary = { + [key: string]: string | number | boolean; +}; diff --git a/packages/diracx-web-components/src/types/SearchBarEquation.ts b/packages/diracx-web-components/src/types/SearchBarEquation.ts new file mode 100644 index 00000000..cd082e4f --- /dev/null +++ b/packages/diracx-web-components/src/types/SearchBarEquation.ts @@ -0,0 +1,9 @@ +import { EquationStatus } from "./EquationStatus"; +import type { SearchBarToken } from "./SearchBarToken"; + +export type SearchBarTokenEquation = { + // The status of the equation, e.g., "valid", "invalid", "waiting" + status: EquationStatus; + // The items in the equation, which are tokens + items: SearchBarToken[]; +}; diff --git a/packages/diracx-web-components/src/types/SearchBarSuggestions.ts b/packages/diracx-web-components/src/types/SearchBarSuggestions.ts new file mode 100644 index 00000000..c04e2f45 --- /dev/null +++ b/packages/diracx-web-components/src/types/SearchBarSuggestions.ts @@ -0,0 +1,13 @@ +import type { CategoryType } from "./CategoryType"; +import type { SearchBarTokenNature } from "./SearchBarTokenNature"; + +export type SearchBarSuggestions = { + /** The list of the suggestions */ + items: string[]; + /** The nature of each suggestion (category, operator, value) */ + nature: SearchBarTokenNature[]; + /** The type of each suggestion (string, number, bool)*/ + type: CategoryType[]; + /** Booleans indicating if the suggestions should be hidden */ + hideSuggestion: boolean[]; +}; diff --git a/packages/diracx-web-components/src/types/SearchBarToken.ts b/packages/diracx-web-components/src/types/SearchBarToken.ts new file mode 100644 index 00000000..edfa7c6b --- /dev/null +++ b/packages/diracx-web-components/src/types/SearchBarToken.ts @@ -0,0 +1,16 @@ +import type { SearchBarSuggestions } from "./SearchBarSuggestions"; +import type { CategoryType } from "./CategoryType"; +import type { SearchBarTokenNature } from "./SearchBarTokenNature"; + +export type SearchBarToken = { + /** The label can be a single string or an array of strings for multi-value tokens */ + label: string | string[]; + /** The type of the token, e.g., "string", "number", "bool", ... */ + type: CategoryType; + /** The nature of the token, e.g., "category", "operator", "value" */ + nature: SearchBarTokenNature; + /** Boolean indicating if we should hide the suggestions */ + hideSuggestion: boolean; + /** Optional suggestions for the token, useful for auto-completion or hints */ + suggestions?: SearchBarSuggestions; +}; diff --git a/packages/diracx-web-components/src/types/SearchBarTokenNature.ts b/packages/diracx-web-components/src/types/SearchBarTokenNature.ts new file mode 100644 index 00000000..a1b15ef0 --- /dev/null +++ b/packages/diracx-web-components/src/types/SearchBarTokenNature.ts @@ -0,0 +1,7 @@ +/** This file defines the nature of suggestions in the search bar component. */ +export enum SearchBarTokenNature { + CATEGORY = "category", + OPERATOR = "operator", + VALUE = "value", + CUSTOM = "custom", +} diff --git a/packages/diracx-web-components/src/types/index.ts b/packages/diracx-web-components/src/types/index.ts index 1764ca6a..880b578e 100644 --- a/packages/diracx-web-components/src/types/index.ts +++ b/packages/diracx-web-components/src/types/index.ts @@ -6,3 +6,12 @@ export * from "./SearchBody"; export * from "./Job"; export * from "./JobHistory"; export * from "./ApplicationSettings"; +export * from "./SearchBarToken"; +export * from "./SearchBarEquation"; +export * from "./JobSummary"; +export * from "./SearchBarSuggestions"; +export * from "./EquationAndTokenIndex"; +export * from "./EquationStatus"; +export * from "./operators"; +export * from "./SearchBarTokenNature"; +export * from "./CategoryType"; diff --git a/packages/diracx-web-components/src/types/operators.ts b/packages/diracx-web-components/src/types/operators.ts new file mode 100644 index 00000000..6ca4660b --- /dev/null +++ b/packages/diracx-web-components/src/types/operators.ts @@ -0,0 +1,115 @@ +export class Operators { + static readonly EGUALS = new Operators("=", "eq"); + static readonly NOT_EQUALS = new Operators("!=", "neq"); + static readonly GREATER_THAN = new Operators(">", "gt"); + static readonly LESS_THAN = new Operators("<", "lt"); + static readonly IN = new Operators("is in", "in"); + static readonly NOT_IN = new Operators("is not in", "not in"); + static readonly LIKE = new Operators("like", "like"); + static readonly LAST = new Operators("in the last", "last"); + + private constructor( + private readonly display: string, + private readonly internal: string, + ) {} + + getInternal(): string { + return this.internal; + } + + getDisplay(): string { + return this.display; + } + + static getDisplayFromInternal(internal: string): string { + const operator = Operators.getAll().find( + (op) => op.getInternal() === internal, + ); + if (operator) { + return operator.getDisplay(); + } + throw new Error(`Operator with internal name "${internal}" not found.`); + } + + static getInternalFromDisplay(display: string): string { + const operator = Operators.getAll().find( + (op) => op.getDisplay() === display, + ); + if (operator) { + return operator.getInternal(); + } + throw new Error(`Operator with display name "${display}" not found.`); + } + + static getAll(): Operators[] { + return [ + Operators.EGUALS, + Operators.NOT_EQUALS, + Operators.GREATER_THAN, + Operators.LESS_THAN, + Operators.IN, + Operators.NOT_IN, + Operators.LIKE, + Operators.LAST, + ]; + } + + static getAllDisplays(): string[] { + return Operators.getAll().map((op) => op.getDisplay()); + } + + static getAllInternals(): string[] { + return Operators.getAll().map((op) => op.getInternal()); + } + + static getStringOperators(): Operators[] { + return [ + Operators.EGUALS, + Operators.NOT_EQUALS, + Operators.IN, + Operators.NOT_IN, + Operators.LIKE, + ]; + } + + static getNumberOperators(): Operators[] { + return [ + Operators.EGUALS, + Operators.NOT_EQUALS, + Operators.GREATER_THAN, + Operators.LESS_THAN, + Operators.IN, + Operators.NOT_IN, + Operators.LIKE, + ]; + } + + static getBooleanOperators(): Operators[] { + return [Operators.EGUALS, Operators.NOT_EQUALS]; + } + + static getDateOperators(): Operators[] { + return [Operators.GREATER_THAN, Operators.LESS_THAN, Operators.LAST]; + } + + static getDefaultOperators(): Operators[] { + return [ + Operators.EGUALS, + Operators.NOT_EQUALS, + Operators.GREATER_THAN, + Operators.LESS_THAN, + Operators.LIKE, + ]; + } + + static getFreeTextOperators(): Operators[] { + return [ + Operators.LIKE, + Operators.IN, + Operators.NOT_IN, + Operators.LAST, + Operators.GREATER_THAN, + Operators.LESS_THAN, + ]; + } +} diff --git a/packages/diracx-web-components/stories/FilterForm.stories.tsx b/packages/diracx-web-components/stories/FilterForm.stories.tsx deleted file mode 100644 index c987fb00..00000000 --- a/packages/diracx-web-components/stories/FilterForm.stories.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import { Paper } from "@mui/material"; -import { createColumnHelper } from "@tanstack/react-table"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { - FilterForm, - FilterFormProps, -} from "../src/components/shared/FilterForm"; - -interface SimpleItem extends Record { - id: number; - name: string; - email: string; -} - -const columnHelper = createColumnHelper(); - -const columnDefs = [ - columnHelper.accessor("id", { - header: "ID", - id: "id", - meta: { type: "number" }, - }), - columnHelper.accessor("name", { - header: "Name", - id: "name", - meta: { type: "string" }, - }), - columnHelper.accessor("email", { - header: "Email", - id: "email", - meta: { type: "string" }, - }), -]; - -const meta: Meta> = { - title: "shared/FilterForm", - component: FilterForm, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - argTypes: { - columns: { - control: { disable: true }, - description: "`array` of tan stack `Column`", - required: true, - }, - filters: { control: { disable: true } }, - setFilters: { control: { disable: true } }, - handleFilterChange: { control: { disable: true } }, - handleFilterMenuClose: { control: { disable: true } }, - selectedFilterId: { control: { disable: true } }, - }, - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - columns: columnDefs, - filters: [ - { id: 0, parameter: "id", operator: "eq", value: "1", isApplied: false }, - ], - setFilters: () => {}, - handleFilterChange: () => {}, - handleFilterMenuClose: () => {}, - selectedFilterId: 0, - }, -}; diff --git a/packages/diracx-web-components/stories/FilterToolbar.stories.tsx b/packages/diracx-web-components/stories/FilterToolbar.stories.tsx deleted file mode 100644 index a1b8882d..00000000 --- a/packages/diracx-web-components/stories/FilterToolbar.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import { useArgs } from "@storybook/core/preview-api"; -import { Paper } from "@mui/material"; -import { createColumnHelper } from "@tanstack/react-table"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { - FilterToolbar, - FilterToolbarProps, -} from "../src/components/shared/FilterToolbar"; - -interface SimpleItem extends Record { - id: number; - name: string; - email: string; -} - -const columnHelper = createColumnHelper(); - -const columnDefs = [ - columnHelper.accessor("id", { - id: "id", - header: "ID", - meta: { type: "number" }, - }), - columnHelper.accessor("name", { - id: "name", - header: "Name", - meta: { type: "string" }, - }), - columnHelper.accessor("email", { - id: "email", - header: "Email", - meta: { type: "string" }, - }), -]; - -const meta: Meta> = { - title: "shared/FilterToolbar", - component: FilterToolbar, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - argTypes: { - columns: { - control: { disable: true }, - description: "`array` of tan stack `Column`", - required: true, - }, - filters: { control: { disable: true } }, - setFilters: { control: { disable: true } }, - handleApplyFilters: { control: { disable: true } }, - handleClearFilters: { control: { disable: true } }, - }, - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], -}; - -export default meta; -type Story = StoryObj>; - -export const Default: Story = { - args: { - columns: columnDefs, - filters: [ - { id: 0, parameter: "id", operator: "eq", value: "1", isApplied: true }, - { id: 1, parameter: "id", operator: "neq", value: "2", isApplied: false }, - ], - setFilters: () => {}, - handleApplyFilters: () => {}, - handleClearFilters: () => {}, - }, - render: (props) => { - const [{ filters }, updateArgs] = useArgs(); - props.setFilters = (filters) => updateArgs({ filters }); - props.handleApplyFilters = () => updateArgs({ appliedFilters: filters }); - return {...props} />; - }, -}; diff --git a/packages/diracx-web-components/stories/JobMonitor.stories.tsx b/packages/diracx-web-components/stories/JobMonitor.stories.tsx index 766356f9..b84e860a 100644 --- a/packages/diracx-web-components/stories/JobMonitor.stories.tsx +++ b/packages/diracx-web-components/stories/JobMonitor.stories.tsx @@ -1,10 +1,8 @@ import { StoryObj, Meta } from "@storybook/react"; -import { Paper } from "@mui/material"; -import { ApplicationsContext } from "../src/contexts/ApplicationsProvider"; -import { NavigationProvider } from "../src/contexts/NavigationProvider"; +import { Box } from "@mui/material"; import { ThemeProvider } from "../src/contexts/ThemeProvider"; import JobMonitor from "../src/components/JobMonitor/JobMonitor"; -import { setJobsMock, setJobHistoryMock } from "./mocks/JobDataService.mock"; +import { setJobsMock, setJobHistoryMock } from "./mocks/jobDataService.mock"; const meta = { title: "Job Monitor/JobMonitor", @@ -14,49 +12,12 @@ const meta = { }, tags: ["autodocs"], decorators: [ - (Story) => { - return ( - - - - - - ); - }, (Story) => ( - "/"} - setPath={() => {}} - getSearchParams={() => { - const url = new URLSearchParams(); - url.append("appId", "example"); - return url; - }} - > - {}, - [], - "", - () => {}, - ]} - > + + - - + + ), ], async beforeEach() {}, diff --git a/packages/diracx-web-components/stories/SearchBar.stories.tsx b/packages/diracx-web-components/stories/SearchBar.stories.tsx new file mode 100644 index 00000000..4d4004c5 --- /dev/null +++ b/packages/diracx-web-components/stories/SearchBar.stories.tsx @@ -0,0 +1,180 @@ +import { Meta, StoryObj } from "@storybook/react"; +import React, { useState } from "react"; +import { Paper } from "@mui/material"; +import { action } from "@storybook/addon-actions"; +import { + SearchBar, + SearchBarProps, +} from "../src/components/shared/SearchBar/SearchBar"; +import { + Filter, + SearchBarToken, + SearchBarTokenEquation, + SearchBarSuggestions, + EquationStatus, +} from "../src/types"; +import { ThemeProvider } from "../src/contexts/ThemeProvider"; + +// Exemples d'équations de tokens +const sampleFilters: Filter[] = [ + { + operator: "eq", + parameter: "JobID", + value: "12345", + }, + { + operator: "in", + parameter: "Status", + values: ["Running", "Completed"], + }, +]; +function customClearFunction( + _setFilters: React.Dispatch>, + setTokenEquations: React.Dispatch< + React.SetStateAction + >, +) { + setTokenEquations((prev) => + prev.filter((eq) => eq.status === EquationStatus.VALID), + ); +} + +const createSuggestions = async ( + previousToken: SearchBarToken | undefined, + previousEquation: SearchBarTokenEquation | undefined, +): Promise => { + // Simulate fetching suggestions based on the previous token and equation + if ( + !previousToken || + !previousEquation || + previousToken.type.startsWith("custom") || + previousToken.type.startsWith("value") + ) + return { + items: [ + "JobID", + "Status", + "Site", + "JobType", + "JobGroup", + "UserPriority", + "RescheduleCounter", + ], + type: Array(7).fill("string"), + nature: Array(7).fill("category"), + hideSuggestion: Array(7).fill(false), + }; + + if (previousToken.nature === "category") + return { + items: ["=", "!=", "in", "not in", "like", "<", ">"], + type: Array(50).fill("string"), + nature: Array(50).fill("operator"), + hideSuggestion: Array(50).fill(false), + }; + + let items: string[] = []; + switch (previousEquation.items[0].label) { + case "JobID": + items = ["12345", "67890", "54321"]; + break; + case "Status": + items = ["Running", "Completed", "Failed", "Pending"]; + break; + case "Site": + items = ["Site A", "Site B", "Site C"]; + break; + case "JobType": + items = ["Type A", "Type B", "Type C"]; + break; + case "JobGroup": + items = ["Group A", "Group B", "Group C"]; + break; + case "UserPriority": + items = ["1", "2", "3"]; + break; + case "RescheduleCounter": + items = ["0", "1", "2"]; + break; + default: + items = []; + } + + return { + items: items, + type: Array(items.length).fill("string"), + nature: Array(items.length).fill("value"), + hideSuggestion: Array(items.length).fill(previousToken.hideSuggestion), + }; +}; + +const meta: Meta = { + title: "shared/SearchBar", + component: SearchBar, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + filters: [], + clearFunction: customClearFunction, + }, + argTypes: { + clearFunction: { + control: "select", + options: ["CustomClearFunction", "Default"], + mapping: { + customClearFunction: customClearFunction, + default: undefined, + }, + }, + }, + render: (args) => { + const [filters, setFilters] = useState(args.filters); + + return ( + + ); + }, +}; + +export const WithPrefilledTokens: Story = { + args: { + filters: sampleFilters, + setFilters: action("setFilters"), + searchFunction: action("searchTriggered"), + allowKeyWordSearch: true, + }, + render: (args) => { + const [filters, setFilters] = useState(args.filters); + + return ( + + ); + }, +}; diff --git a/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx b/packages/diracx-web-components/stories/mocks/jobDataService.mock.ts similarity index 61% rename from packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx rename to packages/diracx-web-components/stories/mocks/jobDataService.mock.ts index eec4eb9a..786c3394 100644 --- a/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx +++ b/packages/diracx-web-components/stories/mocks/jobDataService.mock.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -import { Job, JobHistory, SearchBody } from "../../src/types"; +import { Job, JobHistory, SearchBody, JobSummary } from "../../src/types"; // Mock data store for jobs let mockJobsResponse: { @@ -43,11 +43,11 @@ export function setJobHistoryMock(data: { // Mock implementation of `useJobs` export const useJobs = ( - diracxUrl: string | null, - accessToken: string, - searchBody: any, - page: number, - rowsPerPage: number, + _diracxUrl: string | null, + _accessToken: string, + _searchBody: any, + _page: number, + _rowsPerPage: number, ) => { if (mockJobsResponse.error) { return { @@ -80,9 +80,9 @@ export const useJobs = ( // Mock implementation of `getJobHistory` export const getJobHistory = async ( - diracxUrl: string | null, - jobId: number, - accessToken: string, + _diracxUrl: string | null, + _jobId: number, + _accessToken: string, ): Promise<{ data: JobHistory[] }> => { if (mockJobHistoryResponse.error) { throw mockJobHistoryResponse.error; @@ -92,11 +92,11 @@ export const getJobHistory = async ( // Mock implementation of refreshJobs export const refreshJobs = ( - diracxUrl: string | null, - accessToken: string, - searchBody: SearchBody, - page: number, - rowsPerPage: number, + _diracxUrl: string | null, + _accessToken: string, + _searchBody: SearchBody, + _page: number, + _rowsPerPage: number, ) => { // Just a mock, doesn't need to do anything return Promise.resolve(); @@ -104,9 +104,9 @@ export const refreshJobs = ( // Mock implementation of deleteJobs export function deleteJobs( - diracxUrl: string | null, - selectedIds: readonly number[], - accessToken: string, + _diracxUrl: string | null, + _selectedIds: readonly number[], + _accessToken: string, ): Promise<{ headers: Headers; data: any }> { return Promise.resolve({ headers: new Headers(), @@ -116,9 +116,9 @@ export function deleteJobs( // Mock implementation of killJobs export function killJobs( - diracxUrl: string | null, + _diracxUrl: string | null, selectedIds: readonly number[], - accessToken: string, + _accessToken: string, ): Promise<{ headers: Headers; data: any }> { return Promise.resolve({ headers: new Headers(), @@ -137,9 +137,9 @@ export function killJobs( // Mock implementation of rescheduleJobs export function rescheduleJobs( - diracxUrl: string | null, + _diracxUrl: string | null, selectedIds: readonly number[], - accessToken: string, + _accessToken: string, ): Promise<{ headers: Headers; data: any }> { return Promise.resolve({ headers: new Headers(), @@ -155,3 +155,57 @@ export function rescheduleJobs( }, }); } + +// Mock implementation of getJobSummary +export async function getJobSummary( + _diracxUrl: string | null, + _grouping: string[], + _accessToken: string, +): Promise<{ data: JobSummary[] }> { + return Promise.resolve({ + 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, + }, + { + 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, + }, + { + 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, + }, + ], + }); +} diff --git a/packages/diracx-web-components/stories/mocks/react-oidc.mock.tsx b/packages/diracx-web-components/stories/mocks/react-oidc.mock.tsx index 6c8a0786..c5d7be1d 100644 --- a/packages/diracx-web-components/stories/mocks/react-oidc.mock.tsx +++ b/packages/diracx-web-components/stories/mocks/react-oidc.mock.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import React from "react"; const jestFn = // Storybook runs in the browser – `jest` is not defined there diff --git a/packages/diracx-web-components/test/Dashboard.test.tsx b/packages/diracx-web-components/test/Dashboard.test.tsx index dfc499aa..609ab725 100644 --- a/packages/diracx-web-components/test/Dashboard.test.tsx +++ b/packages/diracx-web-components/test/Dashboard.test.tsx @@ -3,6 +3,7 @@ import { composeStories } from "@storybook/react"; import { useOidc, useOidcAccessToken } from "@axa-fr/react-oidc"; import { useMediaQuery } from "@mui/material"; import * as stories from "../stories/Dashboard.stories"; +import "@testing-library/jest-dom"; // Compose your Storybook stories (this will include all decorators/args) const { Default } = composeStories(stories); diff --git a/packages/diracx-web-components/test/DataTable.test.tsx b/packages/diracx-web-components/test/DataTable.test.tsx index 1260b9f5..e6857307 100644 --- a/packages/diracx-web-components/test/DataTable.test.tsx +++ b/packages/diracx-web-components/test/DataTable.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; import { composeStories } from "@storybook/react"; import * as stories from "../stories/DataTable.stories"; diff --git a/packages/diracx-web-components/test/ErrorBox.test.tsx b/packages/diracx-web-components/test/ErrorBox.test.tsx index 54e432d4..18a7b3f2 100644 --- a/packages/diracx-web-components/test/ErrorBox.test.tsx +++ b/packages/diracx-web-components/test/ErrorBox.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { composeStories } from "@storybook/react"; import * as stories from "../stories/ErrorBox.stories"; +import "@testing-library/jest-dom"; // Compose the stories to get actual Storybook behavior (decorators, args, etc) const { Default } = composeStories(stories); diff --git a/packages/diracx-web-components/test/FilterForm.test.tsx b/packages/diracx-web-components/test/FilterForm.test.tsx deleted file mode 100644 index 7b3c0c53..00000000 --- a/packages/diracx-web-components/test/FilterForm.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { render, screen, fireEvent, within } from "@testing-library/react"; -import { composeStories } from "@storybook/react"; -import * as stories from "../stories/FilterForm.stories"; - -// Compose the stories to get actual Storybook behavior (decorators, args, etc) -const { Default } = composeStories(stories); - -describe("FilterForm", () => { - it("renders the filter form with correct initial values", () => { - render(); - // By default: ID = 1, operator = "equals to", value = "1" - expect( - screen.getByTestId("filter-form-select-parameter"), - ).toHaveTextContent("ID"); - expect(screen.getByTestId("filter-form-select-operator")).toHaveTextContent( - "equals to", - ); - expect(screen.getByLabelText("Value")).toHaveValue(1); - }); - - it("allows changing the value", () => { - render(); - const valueInput = screen.getByLabelText("Value"); - fireEvent.change(valueInput, { target: { value: "42" } }); - expect(valueInput).toHaveValue(42); - }); - - it("allows changing the parameter (column)", () => { - render(); - const columnSelect = screen.getByTestId("filter-form-select-parameter"); - const button = within(columnSelect).getByRole("combobox"); - fireEvent.mouseDown(button); - fireEvent.click(screen.getByText("Name")); - expect(columnSelect).toHaveTextContent("Name"); - }); - - it("allows changing the operator", () => { - render(); - const operatorSelect = screen.getByTestId("filter-form-select-operator"); - const button = within(operatorSelect).getByRole("combobox"); - fireEvent.mouseDown(button); - fireEvent.click(screen.getByText("not equals to")); - expect(operatorSelect).toHaveTextContent("not equals to"); - }); -}); diff --git a/packages/diracx-web-components/test/FilterToolbar.test.tsx b/packages/diracx-web-components/test/FilterToolbar.test.tsx deleted file mode 100644 index 740a77ce..00000000 --- a/packages/diracx-web-components/test/FilterToolbar.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { composeStories } from "@storybook/react"; -import * as stories from "../stories/FilterToolbar.stories"; - -const { Default } = composeStories(stories); - -describe("FilterToolbar", () => { - it("shows the three main buttons", () => { - render(); - expect( - screen.getByRole("button", { name: /add filter/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /apply filters/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /clear all filters/i }), - ).toBeInTheDocument(); - }); - - it("renders filter chips with correct applied / unapplied classes", () => { - render(); - // chip text is rendered inside a div/span, we search the chip root by nearest div - const appliedChip = screen.getByText("id eq 1").closest("div"); - const unappliedChip = screen.getByText("id neq 2").closest("div"); // story renamed automaticly - - expect(appliedChip).toHaveClass("chip-filter-applied"); - expect(unappliedChip).toHaveClass("chip-filter-unapplied"); - }); - - it("warns the user about unapplied filters", () => { - render(); - expect( - screen.getByText(/Some filter changes have not been applied/i), - ).toBeInTheDocument(); - }); - - it("opens the filter form popper when *Add filter* is clicked", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: /add filter/i })); - expect(screen.getByRole("presentation")).toBeInTheDocument(); // the MUI Popper - }); -}); diff --git a/packages/diracx-web-components/test/JobMonitor.test.tsx b/packages/diracx-web-components/test/JobMonitor.test.tsx index 64e6ca1f..3452bd5a 100644 --- a/packages/diracx-web-components/test/JobMonitor.test.tsx +++ b/packages/diracx-web-components/test/JobMonitor.test.tsx @@ -8,6 +8,7 @@ import { import { composeStories } from "@storybook/react"; import { VirtuosoMockContext } from "react-virtuoso"; import * as stories from "../stories/JobMonitor.stories"; +import "@testing-library/jest-dom"; // Compose Storybook stories (includes all decorators/args) const { Default, Loading, Empty, Error } = composeStories(stories); @@ -16,39 +17,42 @@ describe("JobMonitor", () => { it("renders the job monitor component", async () => { const { getByTestId, getByText } = render(); - expect(getByTestId("add-filter-button")).toBeInTheDocument(); - expect(getByTestId("apply-filters-button")).toBeInTheDocument(); - expect(getByTestId("clear-filters-button")).toBeInTheDocument(); - - // Verify job data is displayed await waitFor(() => { + expect(getByTestId("search-bar")).toBeInTheDocument(); + // Verify job data is displayed expect(getByText("List of Jobs")).toBeInTheDocument(); }); }); - it("renders loading state while fetching data", () => { + it("renders loading state while fetching data", async () => { const { getByTestId } = render(); // Verify loading state - expect(getByTestId("loading-skeleton")).toBeInTheDocument(); + await waitFor(() => { + expect(getByTestId("loading-skeleton")).toBeInTheDocument(); + }); }); - it("renders error state when data fetch fails", () => { + it("renders error state when data fetch fails", async () => { const { getByText } = render(); // Verify error message - expect( - getByText("An error occurred while fetching data. Reload the page."), - ).toBeInTheDocument(); + await waitFor(() => { + expect( + getByText("An error occurred while fetching data. Reload the page."), + ).toBeInTheDocument(); + }); }); - it("renders empty state when no jobs are found", () => { + it("renders empty state when no jobs are found", async () => { const { getByText } = render(); // Verify empty state message - expect( - getByText("No data or no results match your filters."), - ).toBeInTheDocument(); + await waitFor(() => { + expect( + getByText("No data or no results match your filters."), + ).toBeInTheDocument(); + }); }); }); diff --git a/packages/diracx-web-components/test/LoginForm.test.tsx b/packages/diracx-web-components/test/LoginForm.test.tsx index 3498a5d8..fe69f6c6 100644 --- a/packages/diracx-web-components/test/LoginForm.test.tsx +++ b/packages/diracx-web-components/test/LoginForm.test.tsx @@ -2,6 +2,7 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { composeStories } from "@storybook/react"; import * as stories from "../stories/LoginForm.stories"; import { useOidc } from "../stories/mocks/react-oidc.mock"; +import "@testing-library/jest-dom"; const { SingleVO, MultiVO, Error, Loading } = composeStories(stories); diff --git a/packages/diracx-web-components/test/SearchBar.test.tsx b/packages/diracx-web-components/test/SearchBar.test.tsx new file mode 100644 index 00000000..4d581c13 --- /dev/null +++ b/packages/diracx-web-components/test/SearchBar.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { composeStories } from "@storybook/react"; +import userEvent from "@testing-library/user-event"; +import * as stories from "../stories/SearchBar.stories"; +import "@testing-library/jest-dom"; + +// Compose the stories to get actual Storybook behavior (decorators, args, etc) +const { Default, WithPrefilledTokens } = composeStories(stories); + +describe("SearchBar", () => { + it("renders the component", async () => { + render(); + await waitFor(() => { + expect( + screen.getByPlaceholderText("Enter a category"), + ).toBeInTheDocument(); + }); + }); + + it("renders with preffiled tokens", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("12345")).toBeInTheDocument(); + expect(screen.getByText("Running | Completed")).toBeInTheDocument(); + }); + }); + + it("shows autocomplete suggestions when clicking in search field", async () => { + const user = userEvent.setup(); + render(); + + const searchInput = screen.getByPlaceholderText("Enter a category"); + + // Click in the search field + await user.click(searchInput); + + // Type to trigger autocomplete + await user.type(searchInput, "S"); + + // Check if suggestions appear + await waitFor(() => { + expect(screen.getByText("Status")).toBeInTheDocument(); + }); + + // Check if Site suggestion also appears + expect(screen.getByText("Site")).toBeInTheDocument(); + }); + + it("creates a token when selecting from autocomplete", async () => { + const user = userEvent.setup(); + render(); + + const searchInput = screen.getByPlaceholderText("Enter a category"); + + // Type and select a category + await user.type(searchInput, "Status"); + await user.keyboard("{Enter}"); + + // Check if token is created + await waitFor(() => { + expect(screen.getByText("Status")).toBeInTheDocument(); + }); + + // Check if placeholder changes for operator + expect( + screen.getByPlaceholderText("Enter an operator"), + ).toBeInTheDocument(); + }); + + it("shows operator suggestions after selecting category", async () => { + const user = userEvent.setup(); + render(); + + const searchInput = screen.getByPlaceholderText("Enter a category"); + + // Create a category token + await user.type(searchInput, "Status"); + await user.keyboard("{Enter}"); + + // Type operator + const operatorInput = screen.getByPlaceholderText("Enter an operator"); + await user.type(operatorInput, "="); + + // Check if operator suggestions appear + await waitFor(() => { + expect(screen.getByText("=")).toBeInTheDocument(); + }); + }); + + it("shows token menu when clicking on existing token", async () => { + const user = userEvent.setup(); + render(); + + // Find and click on an existing token + await waitFor(() => { + const tokenButton = screen.getByText("12345"); + user.click(tokenButton); + }); + + // Check if menu appears + await waitFor( + () => { + // Assuming the menu shows options for the token + const menu = screen.getByRole("menu", { hidden: true }); + expect(menu).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + }); + + it("shows delete button when tokens exist", async () => { + render(); + + // Check if delete button is present + await waitFor(() => { + const deleteButton = screen.getByTestId("DeleteIcon"); + expect(deleteButton).toBeInTheDocument(); + }); + }); + + it("removes all tokens when clicking delete button", async () => { + const user = userEvent.setup(); + render(); + + // Verify tokens exist first + await waitFor(() => { + expect(screen.getByText("12345")).toBeInTheDocument(); + expect(screen.getByText("Running | Completed")).toBeInTheDocument(); + + // Click delete button + const deleteButton = screen.getByTestId("DeleteIcon"); + user.click(deleteButton); + }); + + // Check if tokens are removed + await waitFor(() => { + expect(screen.queryByText("12345")).not.toBeInTheDocument(); + expect(screen.queryByText("Running | Completed")).not.toBeInTheDocument(); + }); + }); + + it("focuses search field when clicking on search bar area", async () => { + const user = userEvent.setup(); + render(); + + // Find the search bar container + const searchBar = screen.getByTestId("search-bar"); + const searchInput = screen.getByPlaceholderText("Enter a category"); + + // Click on the search bar area + await user.click(searchBar); + + // Check if input is focused + expect(searchInput).toHaveFocus(); + }); +}); diff --git a/packages/diracx-web/cypress.config.ts b/packages/diracx-web/cypress.config.ts index 1f7c7b0d..b0cb9975 100644 --- a/packages/diracx-web/cypress.config.ts +++ b/packages/diracx-web/cypress.config.ts @@ -4,9 +4,11 @@ export default defineConfig({ e2e: { specPattern: "test/e2e/**/*.cy.ts", supportFile: false, - setupNodeEvents(on, config) { + setupNodeEvents(_on, _config) { // implement node event listeners here }, }, chromeWebSecurity: false, + // Automatically scroll to the center of the viewport before to click on an element + scrollBehavior: "center", }); diff --git a/packages/diracx-web/test/e2e/jobMonitor.cy.ts b/packages/diracx-web/test/e2e/jobMonitor.cy.ts index e59576c9..63702f19 100644 --- a/packages/diracx-web/test/e2e/jobMonitor.cy.ts +++ b/packages/diracx-web/test/e2e/jobMonitor.cy.ts @@ -243,25 +243,25 @@ describe("Job Monitor", () => { }); it("should kill jobs", () => { - cy.get("[data-index=1]").click(); - cy.get("[data-index=2]").click(); - cy.get("[data-index=3]").click(); + 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=1]").find("td").eq(2).should("contain", "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"); - cy.get("[data-index=3]").find("td").eq(2).should("contain.text", "Killed"); }); it("should delete jobs", () => { cy.get("[data-index=1]").as("jobItem1"); cy.get("[data-index=2]").as("jobItem2"); cy.get("[data-index=3]").as("jobItem3"); - cy.get("@jobItem1").click(); - cy.get("@jobItem2").click(); - cy.get("@jobItem3").click(); + 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(); @@ -273,16 +273,17 @@ describe("Job Monitor", () => { }); it("should reschedule jobs", () => { - cy.get("[data-index=1]").click(); - cy.get("[data-index=2]").click(); - cy.get("[data-index=3]").click(); + cy.get("[data-index=1]").click({ force: true }); + cy.get("[data-index=2]").click({ force: true }); + cy.get("[data-index=3]").click({ force: true }); - cy.get('[data-testid="ReplayIcon"] > path').click(); + 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=1]").find("td").eq(2).should("contain", "Received"); - cy.get("[data-index=1]").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"); + cy.get("[data-index=3]").find("td").eq(2).should("contain", "Received"); }); /** Column interactions */ @@ -398,147 +399,91 @@ describe("Job Monitor", () => { it("should handle filter addition", () => { cy.get("table").should("be.visible"); - cy.get("button").contains("Add filter").click(); - - // "Apply filters" button should not be visible (only the refresh button) - cy.get("button").contains("Refresh page").should("exist"); - cy.get("button").contains("Apply filters").should("not.exist"); - - cy.get( - '[data-testid="filter-form-select-parameter"] > .MuiSelect-select', - ).click(); - cy.get('[data-value="JobID"]').click(); - cy.get("#value").type("1"); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); - - cy.get(".MuiChip-label").should("be.visible"); - // Filters should not be applied yet - cy.get("table tbody tr").should("not.have.length", 1); - - // "Apply filters" button should be visible (not the refresh button) - cy.get("button").contains("Apply filters").should("exist"); - cy.get("button").contains("Refresh page").should("not.exist"); - }); - - it("should handle filter deletion", () => { - cy.get("table").should("be.visible"); - cy.get("button").contains("Add filter").click(); - - cy.get( - '[data-testid="filter-form-select-parameter"] > .MuiSelect-select', - ).click(); - cy.get('[data-value="JobName"]').click(); - cy.get("#value").type("test"); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); + cy.get("[data-testid=search-bar]"); - cy.get(".MuiChip-label").should("be.visible"); + cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); - cy.get('[data-testid="CancelIcon"]').click(); - cy.get(".MuiChip-label").should("not.exist"); + cy.get('[role="group"]').find("button").should("have.length", 3); }); it("should handle filter editing", () => { cy.get("table").should("be.visible"); - cy.get("button").contains("Add filter").click(); - cy.get( - '[data-testid="filter-form-select-parameter"] > .MuiSelect-select', - ).click(); - cy.get('[data-value="JobName"]').click(); - cy.get("#value").type("test"); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); + cy.get("[data-testid=search-field]").type("Name{enter}={enter}test{enter}"); - cy.get(".MuiChip-label").should("be.visible"); - - cy.get(".MuiChip-label").click(); - cy.get("#value").clear().type("test2"); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); - - cy.get(".MuiChip-label").contains("test2").should("be.visible"); + cy.get("[data-testid=search-field]").type("{leftArrow}2{enter}"); + cy.get('[role="group"]').find("button").contains("test2").should("exist"); }); it("should handle filter clear", () => { cy.get("table").should("be.visible"); - cy.get("button").contains("Add filter").click(); - - cy.get( - '[data-testid="filter-form-select-parameter"] > .MuiSelect-select', - ).click(); - cy.get('[data-value="JobName"]').click(); - cy.get("#value").type("test"); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); - cy.get(".MuiChip-label").should("be.visible"); + cy.get("[data-testid=search-field]").type("Name{enter}={enter}test{enter}"); - cy.get("button").contains("Add filter").click(); - cy.get( - '[data-testid="filter-form-select-parameter"] > .MuiSelect-select', - ).click(); - cy.get('[data-value="JobName"]').click(); - cy.get("#value").type("test2"); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); + cy.get("[data-testid=search-field]").type("ID{enter}={enter}1{enter}"); - cy.get(".MuiChip-label").should("be.visible"); + cy.get('[role="group"]').find("button").should("have.length", 6); - cy.get("button").contains("Clear all").click(); + cy.get('[data-testid="DeleteIcon"]').click(); - cy.get(".MuiChip-label").should("not.exist"); + cy.get('[role="group"]').should("not.exist"); }); it("should handle filter apply and persist", () => { cy.get("table").should("be.visible"); - cy.get("button").contains("Add filter").click(); - let jobId: string; - - // Get the first visible row value (e.g. 55) + let jobID: string; cy.get("table tbody tr") .first() .find("td") .eq(1) .invoke("text") .then((text) => { - jobId = text.trim(); + jobID = text.trim(); - cy.get( - '[data-testid="filter-form-select-parameter"] > .MuiSelect-select', - ).click(); - cy.get('[data-value="JobID"]').click(); - cy.get("#value").type(jobId); + cy.get("[data-testid=search-field]").type( + `ID{enter}={enter}${jobID}{enter}`, + ); }); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); - - cy.get(".MuiChip-label").should("be.visible"); + cy.get('[role="group"]').find("button").should("have.length", 3); + cy.get('[role="group"]').find("button").contains("ID").should("exist"); - cy.get("button").contains("Apply").click(); - cy.wait(500); - cy.reload(); + // Wait for the filter to apply + cy.wait(1000); - cy.contains("Job Monitor").click(); - cy.get(".MuiChip-label").should("be.visible"); 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"); - cy.get("button").contains("Add filter").click(); - cy.get( - '[data-testid="filter-form-select-parameter"] > .MuiSelect-select', - ).click(); - cy.get('[data-value="JobID"]').click(); - cy.get("#value").type("5"); - cy.get('[data-testid="filter-form-add-button"]').contains("Add").click(); + cy.get("[data-testid=search-field]").type("ID{enter}={enter}5{enter}"); - cy.get(".MuiChip-label").should("be.visible"); + // Wait for the filter to apply + cy.wait(1000); + + cy.get('[role="group"]').find("button").should("have.length", 3); - cy.get("button").contains("Apply").click(); - cy.wait(500); cy.get(".MuiButtonBase-root").contains("Job Monitor 2").click(); - cy.get(".MuiChip-label").should("not.exist"); + cy.get('[role="group"]').should("not.exist"); cy.get(".MuiButtonBase-root").contains("Job Monitor").click(); - cy.get(".MuiChip-label").should("be.visible"); + + cy.get('[role="group"]').find("button").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('[role="group"]').find("button").should("have.length", 3); + + cy.get("table").should("be.visible"); }); });