diff --git a/.eslintrc.yml b/.eslintrc.yml index 93d5a668d..5612e359c 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -99,7 +99,7 @@ overrides: '@typescript-eslint/explicit-module-boundary-types': off '@typescript-eslint/no-unused-vars': 'error' '@typescript-eslint/no-explicit-any': 'error' - 'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either' }] + 'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }] 'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }] 'react/jsx-no-bind': off 'no-await-in-loop': 'off' diff --git a/app/react/components/LinkButton.tsx b/app/react/components/LinkButton.tsx index 94a9175ff..825bc8538 100644 --- a/app/react/components/LinkButton.tsx +++ b/app/react/components/LinkButton.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { ComponentProps } from 'react'; import { Button } from './buttons'; @@ -8,12 +9,14 @@ export function LinkButton({ params, disabled, children, + className, ...props }: ComponentProps & ComponentProps) { const button = ( + + ); } diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx index 98cc458a3..59320ed70 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.test.tsx @@ -48,7 +48,7 @@ async function renderComponent( const queries = renderWithQueryClient( - + ); diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index 62e25a1dd..d3259eda7 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -2,6 +2,7 @@ import { ReactNode, useEffect, useState } from 'react'; import clsx from 'clsx'; import { HardDrive, RefreshCcw } from 'lucide-react'; import _ from 'lodash'; +import { useStore } from 'zustand'; import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState'; import { @@ -23,6 +24,8 @@ import { useAgentVersionsList } from '@/react/portainer/environments/queries/use import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service'; import { useUser } from '@/react/hooks/useUser'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { environmentStore } from '@/react/hooks/current-environment-store'; +import { useListSelection } from '@/react/hooks/useListSelection'; import { TableFooter } from '@@/datatables/TableFooter'; import { TableActions, TableContainer, TableTitle } from '@@/datatables'; @@ -43,7 +46,7 @@ import styles from './EnvironmentList.module.css'; import { UpdateBadge } from './UpdateBadge'; interface Props { - onClickItem(environment: Environment): void; + onClickBrowse(environment: Environment): void; onRefresh(): void; } @@ -67,8 +70,14 @@ enum ConnectionType { const storageKey = 'home_endpoints'; -export function EnvironmentList({ onClickItem, onRefresh }: Props) { +export function EnvironmentList({ onClickBrowse, onRefresh }: Props) { + const [selectedItems, handleChangeSelect] = useListSelection( + [], + (a, b) => a.Id === b.Id + ); + const { isAdmin } = useUser(); + const { environmentId: currentEnvironmentId } = useStore(environmentStore); const [platformTypes, setPlatformTypes] = useHomePageFilter< Filter[] >('platformType', []); @@ -128,6 +137,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { status: statusFilter, tagIds: tagFilter?.length ? tagFilter : undefined, groupIds: groupFilter, + provisioned: true, edgeDevice: false, tagsPartialMatch: true, agentVersions: agentVersions.map((a) => a.value), @@ -219,6 +229,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { sort: sortByFilter, order: sortByDescending ? 'desc' : 'asc', }} + selectedItems={selectedItems} />
@@ -315,7 +326,12 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { groupName={ groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name } - onClick={onClickItem} + onClickBrowse={() => onClickBrowse(env)} + isActive={env.Id === currentEnvironmentId} + isSelected={selectedItems.some( + (selectedEnv) => selectedEnv.Id === env.Id + )} + onSelect={(selected) => handleChangeSelect(env, selected)} /> )) )} diff --git a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx index d0dfb931d..bb933bc70 100644 --- a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx @@ -15,22 +15,31 @@ import '@reach/dialog/styles.css'; export interface Props { environments: Environment[]; envQueryParams: Query; + selectedItems: Array; } -export function KubeconfigButton({ environments, envQueryParams }: Props) { +export function KubeconfigButton({ + environments, + envQueryParams, + selectedItems, +}: Props) { const [isOpen, setIsOpen] = useState(false); - if (!environments) { - return null; - } - if (!isKubeconfigButtonVisible(environments)) { - return null; + // return null; } return ( <> - {prompt()} @@ -65,6 +74,11 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) { env.Id) + : environments.map((env) => env.Id) + } /> ) ); diff --git a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx index 60631bbdd..903695b32 100644 --- a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx @@ -1,35 +1,40 @@ import { X } from 'lucide-react'; import clsx from 'clsx'; import { useState } from 'react'; -import { DialogOverlay } from '@reach/dialog'; +import { DialogContent, DialogOverlay } from '@reach/dialog'; import { downloadKubeconfigFile } from '@/react/kubernetes/services/kubeconfig.service'; import * as notifications from '@/portainer/services/notifications'; -import { EnvironmentType } from '@/react/portainer/environments/types'; +import { + Environment, + EnvironmentType, +} from '@/react/portainer/environments/types'; import { usePaginationLimitState } from '@/react/hooks/usePaginationLimitState'; import { usePublicSettings } from '@/react/portainer/settings/queries'; import { Query, useEnvironmentList, } from '@/react/portainer/environments/queries/useEnvironmentList'; +import { useListSelection } from '@/react/hooks/useListSelection'; import { PaginationControls } from '@@/PaginationControls'; import { Checkbox } from '@@/form-components/Checkbox'; import { Button } from '@@/buttons'; -import { useSelection } from './KubeconfigSelection'; import styles from './KubeconfigPrompt.module.css'; import '@reach/dialog/styles.css'; export interface KubeconfigPromptProps { envQueryParams: Query; onClose: () => void; + selectedItems: Array; } const storageKey = 'home_endpoints'; export function KubeconfigPrompt({ envQueryParams, onClose, + selectedItems, }: KubeconfigPromptProps) { const [page, setPage] = useState(1); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); @@ -38,8 +43,10 @@ export function KubeconfigPrompt({ select: (settings) => expiryMessage(settings.KubeconfigExpiry), }); - const { selection, toggle: toggleSelection, selectionSize } = useSelection(); - const { environments, totalCount } = useEnvironmentList({ + const [selection, toggleSelection] = + useListSelection(selectedItems); + + const { environments, totalCount, isLoading } = useEnvironmentList({ ...envQueryParams, page, pageLimit, @@ -49,84 +56,96 @@ export function KubeconfigPrompt({ EnvironmentType.EdgeAgentOnKubernetes, ], }); - const isAllPageSelected = environments.every((env) => selection[env.Id]); + const isAllPageSelected = + !isLoading && + environments + .filter((env) => env.Status <= 2) + .every((env) => selection.includes(env.Id)); return ( -
-
-
- -
Download kubeconfig file
-
-
-
-
- - Select the kubernetes environments to add to the kubeconfig - file. You may select across multiple pages. - - {expiryQuery.data} -
-
-
-
- + +
+
+
+ +
Download kubeconfig file
-
-
- {environments.map((env) => ( -
- - toggleSelection(env.Id, !selection[env.Id]) - } - /> -
- ))} -
-
- +
+
+ + Select the kubernetes environments to add to the kubeconfig + file. You may select across multiple pages. + + {expiryQuery.data} +
+
+
+
+
+
+
+ {environments + .filter((env) => env.Status <= 2) + .map((env) => ( +
+ + toggleSelection(env.Id, !selection.includes(env.Id)) + } + /> +
+ ))} +
+
+ +
+
+
+
+ +
-
- - -
-
+
); @@ -139,12 +158,12 @@ export function KubeconfigPrompt({ } async function confirmKubeconfigSelection() { - if (selectionSize === 0) { + if (selection.length === 0) { notifications.warning('No environment was selected', ''); return; } try { - await downloadKubeconfigFile(Object.keys(selection).map(Number)); + await downloadKubeconfigFile(selection); onClose(); } catch (e) { notifications.error('Failed downloading kubeconfig file', e as Error); diff --git a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigSelection.ts b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigSelection.ts deleted file mode 100644 index a3ac3a23c..000000000 --- a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigSelection.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useState } from 'react'; - -import { EnvironmentId } from '@/react/portainer/environments/types'; - -export function useSelection() { - const [selection, setSelection] = useState>( - {} - ); - - const selectionSize = Object.keys(selection).length; - - return { selection, toggle, selectionSize }; - - function toggle(id: EnvironmentId, selected: boolean) { - setSelection((prevSelection) => { - const newSelection = { ...prevSelection }; - - if (!selected) { - delete newSelection[id]; - } else { - newSelection[id] = true; - } - - return newSelection; - }); - } -} diff --git a/app/react/portainer/HomeView/EnvironmentList/index.ts b/app/react/portainer/HomeView/EnvironmentList/index.ts index deebd7a85..7ab62caf9 100644 --- a/app/react/portainer/HomeView/EnvironmentList/index.ts +++ b/app/react/portainer/HomeView/EnvironmentList/index.ts @@ -1,10 +1 @@ -import { react2angular } from '@/react-tools/react2angular'; - -import { EnvironmentList } from './EnvironmentList'; - -export { EnvironmentList }; - -export const EnvironmentListAngular = react2angular(EnvironmentList, [ - 'onClickItem', - 'onRefresh', -]); +export { EnvironmentList } from './EnvironmentList'; diff --git a/app/react/portainer/HomeView/HomeView.tsx b/app/react/portainer/HomeView/HomeView.tsx index 4bef996ab..2be4f52bd 100644 --- a/app/react/portainer/HomeView/HomeView.tsx +++ b/app/react/portainer/HomeView/HomeView.tsx @@ -39,7 +39,7 @@ export function HomeView() { ) : ( )} @@ -64,7 +64,7 @@ export function HomeView() { } } - function handleClickItem(environment: Environment) { + function handleBrowseClick(environment: Environment) { if (isEdgeEnvironment(environment.Type)) { setConnectingToEdgeEndpoint(true); } diff --git a/app/react/sidebar/EnvironmentSidebar.tsx b/app/react/sidebar/EnvironmentSidebar.tsx index 950440785..762d6d25d 100644 --- a/app/react/sidebar/EnvironmentSidebar.tsx +++ b/app/react/sidebar/EnvironmentSidebar.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { X, Slash } from 'lucide-react'; import clsx from 'clsx'; import angular from 'angular'; +import { useStore } from 'zustand'; import { PlatformType, @@ -11,9 +12,9 @@ import { } from '@/react/portainer/environments/types'; import { getPlatformType } from '@/react/portainer/environments/utils'; import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment'; -import { useLocalStorage } from '@/react/hooks/useLocalStorage'; -import { EndpointProviderInterface } from '@/portainer/services/endpointProvider'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { EndpointProviderInterface } from '@/portainer/services/endpointProvider'; +import { environmentStore } from '@/react/hooks/current-environment-store'; import { Icon } from '@@/Icon'; @@ -98,35 +99,34 @@ function Content({ environment, onClear }: ContentProps) { function useCurrentEnvironment() { const { params } = useCurrentStateAndParams(); const router = useRouter(); - const [environmentId, setEnvironmentId] = useLocalStorage< - EnvironmentId | undefined - >('environmentId', undefined, sessionStorage); + const envStore = useStore(environmentStore); + const { setEnvironmentId } = envStore; useEffect(() => { const environmentId = parseInt(params.endpointId, 10); if (params.endpointId && !Number.isNaN(environmentId)) { setEnvironmentId(environmentId); } - }, [params.endpointId, setEnvironmentId]); + }, [setEnvironmentId, params.endpointId, params.environmentId]); - return { query: useEnvironment(environmentId), clearEnvironment }; + return { query: useEnvironment(envStore.environmentId), clearEnvironment }; function clearEnvironment() { const $injector = angular.element(document).injector(); $injector.invoke( /* @ngInject */ (EndpointProvider: EndpointProviderInterface) => { EndpointProvider.setCurrentEndpoint(null); - if (!params.endpointId) { + if (!params.endpointId && !params.environmentId) { document.title = 'Portainer'; } } ); - if (params.endpointId) { + if (params.endpointId || params.environmentId) { router.stateService.go('portainer.home'); } - setEnvironmentId(undefined); + envStore.clear(); } }