From 4d565dbdc733ec1ab5c17b1d89b445219d1904b4 Mon Sep 17 00:00:00 2001 From: Liav Weiss <74174727+liavweiss@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:30:30 +0300 Subject: [PATCH] feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup #203 (#237) * feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup #203 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup #203 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup #203 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup #203 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup #203 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup #203 Signed-off-by: Liav Weiss (EXT-Nokia) * feat(ws): Notebooks 2.0 // Frontend // Workspace Kind Viewer // Live mockup kubeflow#203 Signed-off-by: Liav Weiss (EXT-Nokia) --------- Signed-off-by: Liav Weiss (EXT-Nokia) Co-authored-by: Liav Weiss (EXT-Nokia) --- workspaces/frontend/src/app/AppRoutes.tsx | 6 + .../pages/WorkspaceKinds/WorkspaceKinds.tsx | 686 ++++++++++++++++++ workspaces/frontend/src/shared/types.ts | 8 + 3 files changed, 700 insertions(+) create mode 100644 workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx diff --git a/workspaces/frontend/src/app/AppRoutes.tsx b/workspaces/frontend/src/app/AppRoutes.tsx index 1bd1258..8c13559 100644 --- a/workspaces/frontend/src/app/AppRoutes.tsx +++ b/workspaces/frontend/src/app/AppRoutes.tsx @@ -5,6 +5,7 @@ import { Debug } from './pages/Debug/Debug'; import { Workspaces } from './pages/Workspaces/Workspaces'; import { WorkspaceCreation } from './pages/Workspaces/Creation/WorkspaceCreation'; import '~/shared/style/MUI-theme.scss'; +import { WorkspaceKinds } from './pages/WorkspaceKinds/WorkspaceKinds'; export const isNavDataGroup = (navItem: NavDataItem): navItem is NavDataGroup => 'children' in navItem; @@ -38,6 +39,10 @@ export const useAdminDebugSettings = (): NavDataItem[] => { label: 'Debug', children: [{ label: 'Notebooks', path: '/notebookDebugSettings' }], }, + { + label: 'Workspace Kinds', + path: '/workspacekinds', + }, ]; }; @@ -55,6 +60,7 @@ const AppRoutes: React.FC = () => { return ( } /> + } /> } /> } /> } /> diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx new file mode 100644 index 0000000..9becc15 --- /dev/null +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx @@ -0,0 +1,686 @@ +import * as React from 'react'; +import { + Drawer, + DrawerContent, + DrawerContentBody, + PageSection, + Content, + Brand, + Tooltip, + Label, + SearchInput, + Toolbar, + ToolbarContent, + ToolbarItem, + Menu, + MenuContent, + MenuList, + MenuItem, + MenuToggle, + Popper, + ToolbarGroup, + ToolbarFilter, + ToolbarToggleGroup, + EmptyStateActions, + EmptyState, + EmptyStateFooter, + EmptyStateBody, + Button, + Bullseye, +} from '@patternfly/react-core'; +import { + Table, + Thead, + Tr, + Th, + Tbody, + Td, + ThProps, + ActionsColumn, + IActions, +} from '@patternfly/react-table'; +import { CodeIcon, FilterIcon, SearchIcon } from '@patternfly/react-icons'; +import { WorkspaceKind, WorkspaceKindsColumnNames } from '~/shared/types'; + +export enum ActionType { + ViewDetails, +} + +export const WorkspaceKinds: React.FunctionComponent = () => { + // Todo: Remove mock and use useWorkspaceKinds API instead. + const mockWorkspaceKinds: WorkspaceKind[] = [ + { + name: 'jupyterlab', + displayName: 'JupyterLab Notebook', + description: + 'Example of a description for JupyterLab a Workspace which runs JupyterLab in a Pod.', + deprecated: true, + deprecationMessage: + 'This WorkspaceKind was removed on 20XX-XX-XX, please use another WorkspaceKind.', + hidden: false, + icon: { + url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', + }, + logo: { + url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', + }, + podTemplate: { + podMetadata: { + labels: { + myWorkspaceKindLabel: 'my-value', + }, + annotations: { + myWorkspaceKindAnnotation: 'my-value', + }, + }, + volumeMounts: { + home: '/home/jovyan', + }, + options: { + imageConfig: { + default: 'jupyterlab_scipy_190', + values: [ + { + id: 'jupyterlab_scipy_180', + displayName: 'jupyter-scipy:v1.8.0', + labels: { + pythonVersion: '3.11', + }, + hidden: true, + redirect: { + to: 'jupyterlab_scipy_190', + message: { + text: 'This update will change...', + level: 'Info', + }, + }, + }, + ], + }, + podConfig: { + default: 'tiny_cpu', + values: [ + { + id: 'tiny_cpu', + displayName: 'Tiny CPU', + description: 'Pod with 0.1 CPU, 128 Mb RAM', + labels: { + cpu: '100m', + memory: '128Mi', + }, + }, + ], + }, + }, + }, + }, + ]; + + const mockNumberOfWorkspaces = 1; // Todo: Create a function to calculate number of workspaces for each workspace kind. + + // Table columns + const columnNames: WorkspaceKindsColumnNames = { + icon: '', + name: 'Name', + description: 'Description', + deprecated: 'Status', + numberOfWorkspaces: 'Number of workspaces', + }; + + const initialWorkspaceKinds = mockWorkspaceKinds; + const [selectedWorkspaceKind, setSelectedWorkspaceKind] = React.useState( + null, + ); + const [activeActionType, setActiveActionType] = React.useState(null); + + // Column sorting + const [activeSortIndex, setActiveSortIndex] = React.useState(null); + const [activeSortDirection, setActiveSortDirection] = React.useState<'asc' | 'desc' | null>(null); + + const getSortableRowValues = React.useCallback( + (workspaceKind: WorkspaceKind): (string | boolean | number)[] => { + const { + icon, + name, + description, + deprecated, + numOfWorkspaces: numberOfWorkspaces, + } = { + icon: '', + name: workspaceKind.name, + description: workspaceKind.description, + deprecated: workspaceKind.deprecated, + numOfWorkspaces: mockNumberOfWorkspaces, + }; + return [icon, name, description, deprecated, numberOfWorkspaces]; + }, + [], + ); + + const sortedWorkspaceKinds = React.useMemo(() => { + if (activeSortIndex === null) { + return initialWorkspaceKinds; + } + + return [...initialWorkspaceKinds].sort((a, b) => { + const aValue = getSortableRowValues(a)[activeSortIndex]; + const bValue = getSortableRowValues(b)[activeSortIndex]; + if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + return activeSortDirection === 'asc' + ? Number(aValue) - Number(bValue) + : Number(bValue) - Number(aValue); + } + return activeSortDirection === 'asc' + ? (aValue as string).localeCompare(bValue as string) + : (bValue as string).localeCompare(aValue as string); + }); + }, [initialWorkspaceKinds, activeSortIndex, activeSortDirection, getSortableRowValues]); + + const getSortParams = React.useCallback( + (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex || 0, + direction: activeSortDirection || 'asc', + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }), + [activeSortIndex, activeSortDirection], + ); + + // Set up filter - Attribute search. + const [searchNameValue, setSearchNameValue] = React.useState(''); + const [searchDescriptionValue, setSearchDescriptionValue] = React.useState(''); + const [statusSelection, setStatusSelection] = React.useState(''); + + const onSearchNameChange = React.useCallback((value: string) => { + setSearchNameValue(value); + }, []); + + const onSearchDescriptionChange = React.useCallback((value: string) => { + setSearchDescriptionValue(value); + }, []); + + const onFilter = React.useCallback( + (workspaceKind: WorkspaceKind) => { + let nameRegex: RegExp; + let descriptionRegex: RegExp; + + try { + nameRegex = new RegExp(searchNameValue, 'i'); + descriptionRegex = new RegExp(searchDescriptionValue, 'i'); + } catch { + nameRegex = new RegExp(searchNameValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + descriptionRegex = new RegExp( + searchDescriptionValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + 'i', + ); + } + + const matchesNameSearch = searchNameValue === '' || nameRegex.test(workspaceKind.name); + const matchesDescriptionSearch = + searchDescriptionValue === '' || descriptionRegex.test(workspaceKind.description); + + let matchesStatus = false; + if (statusSelection === 'Deprecated') { + matchesStatus = workspaceKind.deprecated === true; + } + if (statusSelection === 'Active') { + matchesStatus = workspaceKind.deprecated === false; + } + + return ( + matchesNameSearch && matchesDescriptionSearch && (statusSelection === '' || matchesStatus) + ); + }, + [searchNameValue, searchDescriptionValue, statusSelection], + ); + + const filteredWorkspaceKinds = React.useMemo( + () => sortedWorkspaceKinds.filter(onFilter), + [sortedWorkspaceKinds, onFilter], + ); + + // Set up name search input + const searchNameInput = React.useMemo( + () => ( + onSearchNameChange(value)} + onClear={() => onSearchNameChange('')} + /> + ), + [searchNameValue, onSearchNameChange], + ); + + // Set up description search input + const searchDescriptionInput = React.useMemo( + () => ( + onSearchDescriptionChange(value)} + onClear={() => onSearchDescriptionChange('')} + /> + ), + [searchDescriptionValue, onSearchDescriptionChange], + ); + + // Set up status single select + const [isStatusMenuOpen, setIsStatusMenuOpen] = React.useState(false); + const statusToggleRef = React.useRef(null); + const statusMenuRef = React.useRef(null); + const statusContainerRef = React.useRef(null); + + const handleStatusMenuKeys = React.useCallback( + (event: KeyboardEvent) => { + if (isStatusMenuOpen && statusMenuRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsStatusMenuOpen(!isStatusMenuOpen); + statusToggleRef.current?.focus(); + } + } + }, + [isStatusMenuOpen], + ); + + const handleStatusClickOutside = React.useCallback( + (event: MouseEvent) => { + if (isStatusMenuOpen && !statusMenuRef.current?.contains(event.target as Node)) { + setIsStatusMenuOpen(false); + } + }, + [isStatusMenuOpen], + ); + + React.useEffect(() => { + window.addEventListener('keydown', handleStatusMenuKeys); + window.addEventListener('click', handleStatusClickOutside); + return () => { + window.removeEventListener('keydown', handleStatusMenuKeys); + window.removeEventListener('click', handleStatusClickOutside); + }; + }, [isStatusMenuOpen, statusMenuRef, handleStatusClickOutside, handleStatusMenuKeys]); + + const onStatusToggleClick = React.useCallback((ev: React.MouseEvent) => { + ev.stopPropagation(); + setTimeout(() => { + const firstElement = statusMenuRef.current?.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + }, 0); + setIsStatusMenuOpen((prev) => !prev); + }, []); + + const onStatusSelect = React.useCallback( + (event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + if (typeof itemId === 'undefined') { + return; + } + + setStatusSelection(itemId.toString()); + setIsStatusMenuOpen((prev) => !prev); + }, + [], + ); + + const statusToggle = React.useMemo( + () => ( + + Filter by status + + ), + [isStatusMenuOpen, onStatusToggleClick], + ); + + const statusMenu = React.useMemo( + () => ( + + + + Deprecated + Active + + + + ), + [statusSelection, onStatusSelect], + ); + + const statusSelect = React.useMemo( + () => ( +
+ +
+ ), + [statusToggle, statusMenu, isStatusMenuOpen], + ); + + // Set up attribute selector + const [activeAttributeMenu, setActiveAttributeMenu] = React.useState< + 'Name' | 'Description' | 'Status' + >('Name'); + const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); + const attributeToggleRef = React.useRef(null); + const attributeMenuRef = React.useRef(null); + const attributeContainerRef = React.useRef(null); + + const handleAttributeMenuKeys = React.useCallback( + (event: KeyboardEvent) => { + if (!isAttributeMenuOpen) { + return; + } + if ( + attributeMenuRef.current?.contains(event.target as Node) || + attributeToggleRef.current?.contains(event.target as Node) + ) { + if (event.key === 'Escape' || event.key === 'Tab') { + setIsAttributeMenuOpen(!isAttributeMenuOpen); + attributeToggleRef.current?.focus(); + } + } + }, + [isAttributeMenuOpen], + ); + + const handleAttributeClickOutside = React.useCallback( + (event: MouseEvent) => { + if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { + setIsAttributeMenuOpen(false); + } + }, + [isAttributeMenuOpen], + ); + + React.useEffect(() => { + window.addEventListener('keydown', handleAttributeMenuKeys); + window.addEventListener('click', handleAttributeClickOutside); + return () => { + window.removeEventListener('keydown', handleAttributeMenuKeys); + window.removeEventListener('click', handleAttributeClickOutside); + }; + }, [isAttributeMenuOpen, attributeMenuRef, handleAttributeMenuKeys, handleAttributeClickOutside]); + + const onAttributeToggleClick = React.useCallback((ev: React.MouseEvent) => { + ev.stopPropagation(); + + setTimeout(() => { + const firstElement = attributeMenuRef.current?.querySelector('li > button:not(:disabled)'); + if (firstElement) { + (firstElement as HTMLElement).focus(); + } + }, 0); + + setIsAttributeMenuOpen((prev) => !prev); + }, []); + + const attributeToggle = React.useMemo( + () => ( + } + > + {activeAttributeMenu} + + ), + [isAttributeMenuOpen, onAttributeToggleClick, activeAttributeMenu], + ); + + const attributeMenu = React.useMemo( + () => ( + { + setActiveAttributeMenu(itemId?.toString() as 'Name' | 'Description' | 'Status'); + setIsAttributeMenuOpen((prev) => !prev); + }} + > + + + Name + Description + Status + + + + ), + [], + ); + + const attributeDropdown = React.useMemo( + () => ( +
+ +
+ ), + [attributeToggle, attributeMenu, isAttributeMenuOpen], + ); + + const emptyState = React.useMemo( + () => ( + + + No results match the filter criteria. Clear all filters and try again. + + + + + + + + ), + [], + ); + + // Actions + + const viewDetailsClick = React.useCallback((workspaceKind: WorkspaceKind) => { + setSelectedWorkspaceKind(workspaceKind); + setActiveActionType(ActionType.ViewDetails); + }, []); + + const workspaceKindsDefaultActions = React.useCallback( + (workspaceKind: WorkspaceKind): IActions => [ + { + id: 'view-details', + title: 'View Details', + onClick: () => viewDetailsClick(workspaceKind), + }, + ], + [viewDetailsClick], + ); + + const workspaceDetailsContent = null; // Todo: Detail need to be implemented. + + const DESCRIPTION_CHAR_LIMIT = 50; + + return ( + + + + + +

Kubeflow Workspace Kinds

+

View your existing workspace kinds.

+
+
+ + { + setSearchNameValue(''); + setStatusSelection(''); + setSearchDescriptionValue(''); + }} + > + + } breakpoint="xl"> + + {attributeDropdown} + setSearchNameValue('')} + deleteLabelGroup={() => setSearchNameValue('')} + categoryName="Name" + showToolbarItem={activeAttributeMenu === 'Name'} + > + {searchNameInput} + + setSearchDescriptionValue('')} + deleteLabelGroup={() => setSearchDescriptionValue('')} + categoryName="Description" + showToolbarItem={activeAttributeMenu === 'Description'} + > + {searchDescriptionInput} + + setStatusSelection('')} + deleteLabelGroup={() => setStatusSelection('')} + categoryName="Status" + showToolbarItem={activeAttributeMenu === 'Status'} + > + {statusSelect} + + + + + + {/* */} + + + + + + ))} + + + {filteredWorkspaceKinds.length > 0 && + filteredWorkspaceKinds.map((workspaceKind, rowIndex) => ( + + + + + + + + + + + + ))} + {filteredWorkspaceKinds.length === 0 && ( + + + + )} +
+ {Object.values(columnNames).map((columnName, index) => ( + + {columnName} + +
+ + {workspaceKind.icon.url ? ( + + ) : ( + + )} + {workspaceKind.name} + + + {workspaceKind.description.length > DESCRIPTION_CHAR_LIMIT + ? `${workspaceKind.description.slice(0, DESCRIPTION_CHAR_LIMIT)}...` + : workspaceKind.description} + + + + {workspaceKind.deprecated ? ( + + + + ) : ( + + )} + {mockNumberOfWorkspaces} + ({ + ...action, + 'data-testid': `action-${action.id || ''}`, + }))} + /> +
+ {emptyState} +
+
+
+
+
+ ); +}; diff --git a/workspaces/frontend/src/shared/types.ts b/workspaces/frontend/src/shared/types.ts index a88fe8a..8adc53a 100644 --- a/workspaces/frontend/src/shared/types.ts +++ b/workspaces/frontend/src/shared/types.ts @@ -142,3 +142,11 @@ export type WorkspacesColumnNames = { lastActivity: string; redirectStatus: string; }; + +export type WorkspaceKindsColumnNames = { + icon: string; + name: string; + description: string; + deprecated: string; + numberOfWorkspaces: string; +};