diff --git a/lib/teleterm/apiserver/handler/handler_user_preferences.go b/lib/teleterm/apiserver/handler/handler_user_preferences.go index d963857396dce..02c55440ed709 100644 --- a/lib/teleterm/apiserver/handler/handler_user_preferences.go +++ b/lib/teleterm/apiserver/handler/handler_user_preferences.go @@ -1,16 +1,18 @@ -// Copyright 2023 Gravitational, Inc +// Teleport +// Copyright (C) 2023 Gravitational, Inc. // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. // -// http://www.apache.org/licenses/LICENSE-2.0 +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. // -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . package handler diff --git a/lib/teleterm/services/userpreferences/userpreferences.go b/lib/teleterm/services/userpreferences/userpreferences.go index f5f195b64dc48..39676d052ecd6 100644 --- a/lib/teleterm/services/userpreferences/userpreferences.go +++ b/lib/teleterm/services/userpreferences/userpreferences.go @@ -1,16 +1,18 @@ -// Copyright 2023 Gravitational, Inc +// Teleport +// Copyright (C) 2023 Gravitational, Inc. // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. // -// http://www.apache.org/licenses/LICENSE-2.0 +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. // -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . package userpreferences diff --git a/lib/teleterm/services/userpreferences/userpreferences_test.go b/lib/teleterm/services/userpreferences/userpreferences_test.go index 3acab930ce5b7..230da4a3c3f4c 100644 --- a/lib/teleterm/services/userpreferences/userpreferences_test.go +++ b/lib/teleterm/services/userpreferences/userpreferences_test.go @@ -1,16 +1,18 @@ -// Copyright 2023 Gravitational, Inc +// Teleport +// Copyright (C) 2023 Gravitational, Inc. // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. // -// http://www.apache.org/licenses/LICENSE-2.0 +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. // -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . package userpreferences diff --git a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx index 7c3793bdd5f59..d1bb683627176 100644 --- a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx +++ b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx @@ -131,7 +131,6 @@ export function FilterPanel({ padding-left: ${props => props.theme.space[2]}px; padding-right: ${props => props.theme.space[2]}px; height: 22px; - width: 128px; `} onClick={() => setExpandAllLabels(!expandAllLabels)} > diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx index 853ba559d6842..6429dbe4309c4 100644 --- a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx +++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx @@ -36,12 +36,14 @@ import { LabelsViewMode, } from 'shared/services/unifiedResourcePreferences'; -import { UnifiedResources, useUnifiedResourcesFetch } from './UnifiedResources'; +import { makeErrorAttempt, makeProcessingAttempt } from 'shared/hooks/useAsync'; + import { - SharedUnifiedResource, - UnifiedResourcesPinning, - UnifiedResourcesQueryParams, -} from './types'; + UnifiedResources, + useUnifiedResourcesFetch, + UnifiedResourcesProps, +} from './UnifiedResources'; +import { SharedUnifiedResource, UnifiedResourcesQueryParams } from './types'; export default { title: 'Shared/UnifiedResources', @@ -77,14 +79,13 @@ const story = ({ updateClusterPinnedResources: async () => undefined, }, params, + ...props }: { fetchFunc: ( params: UrlResourcesParams, signal: AbortSignal ) => Promise>; - pinning?: UnifiedResourcesPinning; - params?: Partial; -}) => { +} & Omit, 'fetchResources'>) => { const mergedParams: UnifiedResourcesQueryParams = { ...{ sort: { @@ -141,6 +142,7 @@ const story = ({ ActionButton: Connect, }, }))} + {...props} /> ); }; @@ -182,13 +184,13 @@ export const LoadingAfterScrolling = story({ }, }); -export const Errored = story({ +export const Failed = story({ fetchFunc: async () => { throw new Error('Failed to fetch'); }, }); -export const ErroredAfterScrolling = story({ +export const FailedAfterScrolling = story({ fetchFunc: async params => { if (params.startKey === 'next-key') { throw new Error('Failed to fetch'); @@ -197,6 +199,22 @@ export const ErroredAfterScrolling = story({ }, }); +export const FailedToLoadPreferences = story({ + fetchFunc: async () => ({ + agents: allResources, + }), + unifiedResourcePreferencesAttempt: makeErrorAttempt( + new Error('Network error') + ), +}); + +export const LoadingPreferences = story({ + fetchFunc: async () => ({ + agents: allResources, + }), + unifiedResourcePreferencesAttempt: makeProcessingAttempt(), +}); + export const PinningNotSupported = story({ fetchFunc: async () => { return { agents: allResources, startKey: 'next-key' }; diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx index fd6f0fc0ed35a..214f0d9166121 100644 --- a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx +++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx @@ -16,7 +16,13 @@ * along with this program. If not, see . */ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { + useEffect, + useState, + useCallback, + Children, + PropsWithChildren, +} from 'react'; import styled from 'styled-components'; import { @@ -46,6 +52,7 @@ import { makeSuccessAttempt, useAsync, Attempt as AsyncAttempt, + hasFinished, } from 'shared/hooks/useAsync'; import { useKeyBasedPagination, @@ -117,7 +124,7 @@ export type FilterKind = { disabled: boolean; }; -interface UnifiedResourcesProps { +export interface UnifiedResourcesProps { params: UnifiedResourcesQueryParams; resourcesFetchAttempt: Attempt; fetchResources(options?: { force?: boolean }): Promise; @@ -138,6 +145,13 @@ interface UnifiedResourcesProps { setParams(params: UnifiedResourcesQueryParams): void; /** A list of actions that can be performed on the selected items. */ bulkActions?: BulkAction[]; + /** + * It is an attempt for initial fetch of preferences. + * When it is in progress, the component shows loading skeleton. + * Used only in Connect, where we fetch user preferences + * while the unified resources component is visible. + */ + unifiedResourcePreferencesAttempt?: AsyncAttempt; unifiedResourcePreferences: UnifiedResourcePreferences; updateUnifiedResourcesPreferences( preferences: UnifiedResourcePreferences @@ -153,8 +167,9 @@ export function UnifiedResources(props: UnifiedResourcesProps) { fetchResources, availableKinds, pinning, - unifiedResourcePreferences, + unifiedResourcePreferencesAttempt, updateUnifiedResourcesPreferences, + unifiedResourcePreferences, bulkActions = [], } = props; @@ -354,38 +369,39 @@ export function UnifiedResources(props: UnifiedResourcesProps) { margin: 0 auto; `} > - {resourcesFetchAttempt.status === 'failed' && ( - - {/* If pinning is hidden, we hide the different tabs to select a view (All resources, pinning). - This causes this error box to cover the search bar. If pinning isn't supported, we push down the - error by 60px to not hide the search bar. - */} - - - {resourcesFetchAttempt.statusText} - {/* we don't want them to try another request with BAD REQUEST, it will just fail again. */} - {resourcesFetchAttempt.statusCode !== 400 && - resourcesFetchAttempt.statusCode !== 403 && ( - - Retry - - )} - - - - )} - {getPinnedResourcesAttempt.status === 'error' && ( - - {getPinnedResourcesAttempt.statusText} - - )} - {updatePinnedResourcesAttempt.status === 'error' && ( - - {updatePinnedResourcesAttempt.statusText} - - )} + + {resourcesFetchAttempt.status === 'failed' && ( + + Could not fetch resources: {resourcesFetchAttempt.statusText} + {/* we don't want them to try another request with BAD REQUEST, it will just fail again. */} + {resourcesFetchAttempt.statusCode !== 400 && + resourcesFetchAttempt.statusCode !== 403 && ( + + Retry + + )} + + )} + {getPinnedResourcesAttempt.status === 'error' && ( + + Could not fetch pinned resources:{' '} + {getPinnedResourcesAttempt.statusText} + + )} + {updatePinnedResourcesAttempt.status === 'error' && ( + + Could not update pinned resources:{' '} + {updatePinnedResourcesAttempt.statusText} + + )} + {unifiedResourcePreferencesAttempt?.status === 'error' && ( + + Could not fetch unified view preferences:{' '} + {unifiedResourcePreferencesAttempt.statusText} + + )} + + {props.Header} ) : ( - - setParams({ - ...params, - search: '', - query: makeAdvancedSearchQueryForLabel(label, params), - }) - } - pinnedResources={pinnedResources} - selectedResources={selectedResources} - onSelectResource={handleSelectResource} - onPinResource={handlePinResource} - pinningSupport={getResourcePinningSupport( - pinning.kind, - updatePinnedResourcesAttempt - )} - isProcessing={ - resourcesFetchAttempt.status === 'processing' || - getPinnedResourcesAttempt.status === 'processing' - } - mappedResources={resources.map(unifiedResource => ({ - item: mapResourceToViewItem(unifiedResource), - key: generateUnifiedResourceKey(unifiedResource.resource), - }))} - expandAllLabels={expandAllLabels} - /> - )} -
- - {resourcesFetchAttempt.status === 'failed' && resources.length > 0 && ( - Load more - )} - {noResults && isSearchEmpty && !params.pinnedOnly && props.NoResources} - {noResults && params.pinnedOnly && isSearchEmpty && } - {noResults && !isSearchEmpty && ( - + + setParams({ + ...params, + search: '', + query: makeAdvancedSearchQueryForLabel(label, params), + }) + } + pinnedResources={pinnedResources} + selectedResources={selectedResources} + onSelectResource={handleSelectResource} + onPinResource={handlePinResource} + pinningSupport={getResourcePinningSupport( + pinning.kind, + updatePinnedResourcesAttempt + )} + isProcessing={ + // we don't check for '' in resourcesFetchAttempt because + // `keyBasedPagination` returns to that status on abort errors. + resourcesFetchAttempt.status === 'processing' || + getPinnedResourcesAttempt.status === '' || + getPinnedResourcesAttempt.status === 'processing' || + unifiedResourcePreferencesAttempt?.status === '' || + unifiedResourcePreferencesAttempt?.status === 'processing' + } + mappedResources={ + // Hide the resources until the preferences are fetched. + // ViewComponent supports infinite scroll, so it shows both already loaded resources + // and a loading indicator if needed. + !unifiedResourcePreferencesAttempt || + hasFinished(unifiedResourcePreferencesAttempt) + ? resources.map(unifiedResource => ({ + item: mapResourceToViewItem(unifiedResource), + key: generateUnifiedResourceKey(unifiedResource.resource), + })) + : [] + } + expandAllLabels={expandAllLabels} /> - )} - +
+ + {resourcesFetchAttempt.status === 'failed' && + resources.length > 0 && ( + + Load more + + )} + {noResults && + isSearchEmpty && + !params.pinnedOnly && + props.NoResources} + {noResults && params.pinnedOnly && isSearchEmpty && } + {noResults && !isSearchEmpty && ( + + )} + + + )}
); } @@ -603,20 +640,24 @@ function NoResults({ return null; } -const ErrorBox = styled(Box)` +function ErrorsContainer(props: PropsWithChildren) { + if (!Children.toArray(props.children).length) { + return null; + } + + return {props.children}; +} + +const ErrorBox = styled(Flex)` position: sticky; - top: 0; + flex-direction: column; + top: ${props => props.theme.space[3]}px; + gap: ${props => props.theme.space[1]}px; + padding-top: ${props => props.theme.space[1]}px; + padding-bottom: ${props => props.theme.space[3]}px; z-index: 1; `; -const ErrorBoxInternal = styled(Box)` - position: absolute; - left: 0; - right: 0; - top: ${props => props.topPadding}; - margin: ${props => props.theme.space[1]}px 10% 0 10%; -`; - const INDICATOR_SIZE = '48px'; // It's important to make the footer at least as big as the loading indicator, diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx index 3a9559ced03b7..9fea557a48ff9 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx @@ -206,7 +206,7 @@ function PrintState(props: { ); } -const Layout = styled(Box).attrs({ mx: 'auto', px: 5, pt: 4 })` +const Layout = styled(Box).attrs({ mx: 'auto', px: 4, pt: 3 })` flex-direction: column; display: flex; flex: 1; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx index bf07a8b0dd31c..b78c198883ae5 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx @@ -16,13 +16,14 @@ * along with this program. If not, see . */ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { UnifiedResources as SharedUnifiedResources, useUnifiedResourcesFetch, UnifiedResourcesQueryParams, SharedUnifiedResource, + UnifiedResourcesPinning, } from 'shared/components/UnifiedResources'; import { DbProtocol, @@ -36,14 +37,14 @@ import * as icons from 'design/Icon'; import Image from 'design/Image'; import stack from 'design/assets/resources/stack.png'; -import { - UnifiedResourcePreferences, - DefaultTab, - ViewMode, - LabelsViewMode, -} from 'shared/services/unifiedResourcePreferences'; +import { DefaultTab } from 'shared/services/unifiedResourcePreferences'; + +import { Attempt } from 'shared/hooks/useAsync'; -import { UnifiedResourceResponse } from 'teleterm/services/tshd/types'; +import { + UnifiedResourceResponse, + UserPreferences, +} from 'teleterm/services/tshd/types'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import * as uri from 'teleterm/ui/uri'; import { useWorkspaceContext } from 'teleterm/ui/Documents'; @@ -63,31 +64,42 @@ import { ConnectDatabaseActionButton, } from './actionButtons'; import { useResourcesContext } from './resourcesContext'; +import { useUserPreferences } from './useUserPreferences'; export function UnifiedResources(props: { clusterUri: uri.ClusterUri; docUri: uri.DocumentUri; queryParams: DocumentClusterQueryParams; }) { - // TODO: Add user preferences to Connect. - // Until we add stored user preferences to Connect, store it in the state. - const [userPreferences, setUserPreferences] = - useState({ - defaultTab: DefaultTab.DEFAULT_TAB_ALL, - viewMode: ViewMode.VIEW_MODE_CARD, - labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED, - }); + const { userPreferencesAttempt, updateUserPreferences, userPreferences } = + useUserPreferences(props.clusterUri); + + const { unifiedResourcePreferences } = userPreferences; + + const mergedParams: UnifiedResourcesQueryParams = { + kinds: props.queryParams.resourceKinds, + sort: props.queryParams.sort, + pinnedOnly: + unifiedResourcePreferences.defaultTab === DefaultTab.DEFAULT_TAB_PINNED, + search: props.queryParams.advancedSearchEnabled + ? '' + : props.queryParams.search, + query: props.queryParams.advancedSearchEnabled + ? props.queryParams.search + : '', + }; return ( ); } @@ -95,25 +107,14 @@ export function UnifiedResources(props: { function Resources(props: { clusterUri: uri.ClusterUri; docUri: uri.DocumentUri; - queryParams: DocumentClusterQueryParams; - userPreferences: UnifiedResourcePreferences; - setUserPreferences(u: UnifiedResourcePreferences): void; + queryParams: UnifiedResourcesQueryParams; + userPreferencesAttempt?: Attempt; + userPreferences: UserPreferences; + updateUserPreferences(u: UserPreferences): Promise; }) { const appContext = useAppContext(); const { onResourcesRefreshRequest } = useResourcesContext(); - const mergedParams: UnifiedResourcesQueryParams = { - kinds: props.queryParams.resourceKinds, - sort: props.queryParams.sort, - pinnedOnly: false, //TODO: add support for pinning - search: props.queryParams.advancedSearchEnabled - ? '' - : props.queryParams.search, - query: props.queryParams.advancedSearchEnabled - ? props.queryParams.search - : '', - }; - const { documentsService, rootClusterUri } = useWorkspaceContext(); const loggedInUser = useWorkspaceLoggedInUser(); const { canUse: hasPermissionsForConnectMyComputer, agentCompatibility } = @@ -139,13 +140,13 @@ function Resources(props: { clusterUri: props.clusterUri, searchAsRoles: false, sortBy: { - isDesc: mergedParams.sort.dir === 'DESC', - field: mergedParams.sort.fieldName, + isDesc: props.queryParams.sort.dir === 'DESC', + field: props.queryParams.sort.fieldName, }, - search: mergedParams.search, - kindsList: mergedParams.kinds, - query: mergedParams.query, - pinnedOnly: mergedParams.pinnedOnly, + search: props.queryParams.search, + kindsList: props.queryParams.kinds, + query: props.queryParams.query, + pinnedOnly: props.queryParams.pinnedOnly, startKey: paginationParams.startKey, limit: paginationParams.limit, }, @@ -161,12 +162,12 @@ function Resources(props: { }, [ appContext, - mergedParams.kinds, - mergedParams.pinnedOnly, - mergedParams.query, - mergedParams.search, - mergedParams.sort.dir, - mergedParams.sort.fieldName, + props.queryParams.kinds, + props.queryParams.pinnedOnly, + props.queryParams.query, + props.queryParams.search, + props.queryParams.sort.dir, + props.queryParams.sort.fieldName, props.clusterUri, ] ), @@ -195,13 +196,36 @@ function Resources(props: { }); } + const resourceIdsList = + props.userPreferences.clusterPreferences?.pinnedResources?.resourceIdsList; + const { updateUserPreferences } = props; + const pinning = useMemo(() => { + return resourceIdsList + ? { + kind: 'supported', + getClusterPinnedResources: async () => resourceIdsList, + updateClusterPinnedResources: pinnedIds => + updateUserPreferences({ + clusterPreferences: { + pinnedResources: { resourceIdsList: pinnedIds }, + }, + }), + } + : { kind: 'not-supported' }; + }, [updateUserPreferences, resourceIdsList]); + return ( + props.updateUserPreferences({ unifiedResourcePreferences }) + } + pinning={pinning} resources={resources.map(mapToSharedResource)} resourcesFetchAttempt={attempt} fetchResources={fetch} diff --git a/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.test.tsx new file mode 100644 index 0000000000000..6872fd940f552 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.test.tsx @@ -0,0 +1,254 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { renderHook, act } from '@testing-library/react'; +import { + ViewMode, + DefaultTab, + LabelsViewMode, +} from 'shared/services/unifiedResourcePreferences'; + +import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; + +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { UserPreferences } from 'teleterm/services/tshd/types'; + +import { useUserPreferences } from './useUserPreferences'; + +const cluster = makeRootCluster(); +const preferences: UserPreferences = { + clusterPreferences: { pinnedResources: { resourceIdsList: ['abc'] } }, + unifiedResourcePreferences: { + viewMode: ViewMode.VIEW_MODE_CARD, + defaultTab: DefaultTab.DEFAULT_TAB_ALL, + labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED, + }, +}; + +test('user preferences are fetched', async () => { + const appContext = new MockAppContext(); + const getUserPreferencesPromise = Promise.resolve(preferences); + + jest + .spyOn(appContext.tshd, 'getUserPreferences') + .mockImplementation(() => getUserPreferencesPromise); + jest + .spyOn(appContext.workspacesService, 'getUnifiedResourcePreferences') + .mockReturnValue(undefined); + jest + .spyOn(appContext.workspacesService, 'setUnifiedResourcePreferences') + .mockImplementation(); + + const { result } = renderHook(() => useUserPreferences(cluster.uri), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await act(() => getUserPreferencesPromise); + + expect(result.current.userPreferences).toEqual(preferences); + expect(result.current.userPreferencesAttempt.status).toBe('success'); + + // updating the fallback + expect( + appContext.workspacesService.setUnifiedResourcePreferences + ).toHaveBeenCalledWith(cluster.uri, preferences.unifiedResourcePreferences); +}); + +test('unified resources fallback preferences are taken from a workspace', async () => { + const appContext = new MockAppContext(); + let resolveGetUserPreferencesPromise: (u: UserPreferences) => void; + const getUserPreferencesPromise = new Promise(resolve => { + resolveGetUserPreferencesPromise = resolve; + }); + + jest + .spyOn(appContext.tshd, 'getUserPreferences') + .mockImplementation(() => getUserPreferencesPromise); + jest + .spyOn(appContext.workspacesService, 'getUnifiedResourcePreferences') + .mockReturnValue(preferences.unifiedResourcePreferences); + jest + .spyOn(appContext.workspacesService, 'setUnifiedResourcePreferences') + .mockImplementation(); + + const { result } = renderHook(() => useUserPreferences(cluster.uri), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.userPreferences.unifiedResourcePreferences).toEqual( + preferences.unifiedResourcePreferences + ); + resolveGetUserPreferencesPromise(null); + await act(() => getUserPreferencesPromise); +}); + +describe('updating preferences', () => { + const appContext = new MockAppContext(); + beforeEach(() => { + jest + .spyOn(appContext.workspacesService, 'getUnifiedResourcePreferences') + .mockReturnValue(undefined); + jest + .spyOn(appContext.workspacesService, 'setUnifiedResourcePreferences') + .mockImplementation(); + }); + + it('works correctly when the initial preferences were fetched', async () => { + const getUserPreferencesPromise = Promise.resolve(preferences); + + jest + .spyOn(appContext.tshd, 'getUserPreferences') + .mockImplementation(() => getUserPreferencesPromise); + jest + .spyOn(appContext.tshd, 'updateUserPreferences') + .mockImplementation(async preferences => preferences.userPreferences); + + const { result } = renderHook(() => useUserPreferences(cluster.uri), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await act(() => getUserPreferencesPromise); + + const newPreferences: UserPreferences = { + clusterPreferences: {}, + unifiedResourcePreferences: { + viewMode: ViewMode.VIEW_MODE_LIST, + defaultTab: DefaultTab.DEFAULT_TAB_PINNED, + labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED, + }, + }; + + await act(() => result.current.updateUserPreferences(newPreferences)); + + // updating state + expect( + appContext.workspacesService.setUnifiedResourcePreferences + ).toHaveBeenCalledWith( + cluster.uri, + newPreferences.unifiedResourcePreferences + ); + expect(result.current.userPreferences.unifiedResourcePreferences).toEqual( + newPreferences.unifiedResourcePreferences + ); + + expect(result.current.userPreferencesAttempt.status).toBe('success'); + expect(appContext.tshd.updateUserPreferences).toHaveBeenCalledWith({ + clusterUri: cluster.uri, + userPreferences: newPreferences, + }); + }); + + it('works correctly when the initial preferences have not been fetched yet', async () => { + let rejectGetUserPreferencesPromise: (error: Error) => void; + const getUserPreferencesPromise = new Promise( + (resolve, reject) => { + rejectGetUserPreferencesPromise = reject; + } + ); + let resolveUpdateUserPreferencesPromise: (u: UserPreferences) => void; + const updateUserPreferencesPromise = new Promise(resolve => { + resolveUpdateUserPreferencesPromise = resolve; + }); + + jest + .spyOn(appContext.tshd, 'getUserPreferences') + .mockImplementation((requestParams, abortSignal) => { + abortSignal.addEventListener(() => + rejectGetUserPreferencesPromise(new Error('Aborted')) + ); + return getUserPreferencesPromise; + }); + jest + .spyOn(appContext.tshd, 'updateUserPreferences') + .mockImplementation(() => updateUserPreferencesPromise); + + const { result } = renderHook(() => useUserPreferences(cluster.uri), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const newPreferences: UserPreferences = { + clusterPreferences: {}, + unifiedResourcePreferences: { + viewMode: ViewMode.VIEW_MODE_LIST, + defaultTab: DefaultTab.DEFAULT_TAB_PINNED, + labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED, + }, + }; + + act(() => { + result.current.updateUserPreferences(newPreferences); + }); + + // updating state + expect( + appContext.workspacesService.setUnifiedResourcePreferences + ).toHaveBeenCalledWith( + cluster.uri, + newPreferences.unifiedResourcePreferences + ); + expect(result.current.userPreferences.unifiedResourcePreferences).toEqual( + newPreferences.unifiedResourcePreferences + ); + + expect(result.current.userPreferencesAttempt.status).toBe('processing'); + expect(appContext.tshd.updateUserPreferences).toHaveBeenCalledWith({ + clusterUri: cluster.uri, + userPreferences: newPreferences, + }); + + // suddenly, the request returns other preferences than what we wanted + // (e.g., because they were changed it in the browser in the meantime) + act(() => + resolveUpdateUserPreferencesPromise({ + clusterPreferences: { pinnedResources: { resourceIdsList: ['abc'] } }, + unifiedResourcePreferences: { + viewMode: ViewMode.VIEW_MODE_CARD, + defaultTab: DefaultTab.DEFAULT_TAB_PINNED, + labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED, + }, + }) + ); + await act(() => updateUserPreferencesPromise); + + // but our view preferences are still the same as what we sent in the update request! + expect(result.current.userPreferences.unifiedResourcePreferences).toEqual( + newPreferences.unifiedResourcePreferences + ); + expect( + result.current.userPreferences.clusterPreferences.pinnedResources + .resourceIdsList + ).toEqual(['abc']); + }); +}); diff --git a/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts new file mode 100644 index 0000000000000..533194c5df5f0 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts @@ -0,0 +1,228 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + useAsync, + Attempt, + makeEmptyAttempt, + makeProcessingAttempt, + makeErrorAttempt, + makeSuccessAttempt, + mapAttempt, + CanceledError, + hasFinished, +} from 'shared/hooks/useAsync'; + +import { + DefaultTab, + ViewMode, + UnifiedResourcePreferences, + LabelsViewMode, +} from 'shared/services/unifiedResourcePreferences'; + +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +import { routing, ClusterUri } from 'teleterm/ui/uri'; + +import { UserPreferences } from 'teleterm/services/tshd/types'; +import { retryWithRelogin } from 'teleterm/ui/utils'; +import createAbortController from 'teleterm/services/tshd/createAbortController'; + +export function useUserPreferences(clusterUri: ClusterUri): { + userPreferencesAttempt: Attempt; + updateUserPreferences(newPreferences: UserPreferences): Promise; + userPreferences: UserPreferences; +} { + const appContext = useAppContext(); + const initialFetchAttemptAbortController = useRef(createAbortController()); + // Consider storing the unified resource view preferences on the document. + // https://github.com/gravitational/teleport/pull/35251#discussion_r1424116275 + const [unifiedResourcePreferences, setUnifiedResourcePreferences] = useState< + UserPreferences['unifiedResourcePreferences'] + >( + mergeWithDefaultUnifiedResourcePreferences( + appContext.workspacesService.getUnifiedResourcePreferences( + routing.ensureRootClusterUri(clusterUri) + ) + ) || { + defaultTab: DefaultTab.DEFAULT_TAB_ALL, + viewMode: ViewMode.VIEW_MODE_CARD, + labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED, + } + ); + const [clusterPreferences, setClusterPreferences] = useState< + UserPreferences['clusterPreferences'] + >({ + // we pass an empty array, so pinning is enabled by default + pinnedResources: { resourceIdsList: [] }, + }); + + const [initialFetchAttempt, runInitialFetchAttempt] = useAsync( + useCallback( + async () => + retryWithRelogin(appContext, clusterUri, () => + appContext.tshd.getUserPreferences( + { clusterUri }, + initialFetchAttemptAbortController.current.signal + ) + ), + [appContext, clusterUri] + ) + ); + + // In a situation where the initial fetch attempt is still in progress, + // but the user has changed the preferences, we want + // to abort the previous attempt and replace it with the update attempt. + // This is done through `supersededInitialFetchAttempt`. + const [supersededInitialFetchAttempt, setSupersededInitialFetchAttempt] = + useState>(makeEmptyAttempt()); + + const [, runUpdateAttempt] = useAsync( + async (newPreferences: UserPreferences) => + retryWithRelogin(appContext, clusterUri, () => + appContext.tshd.updateUserPreferences({ + clusterUri, + userPreferences: newPreferences, + }) + ) + ); + + const updateUnifiedResourcePreferencesStateAndWorkspace = useCallback( + (unifiedResourcePreferences: UnifiedResourcePreferences) => { + const prefsWithDefaults = mergeWithDefaultUnifiedResourcePreferences( + unifiedResourcePreferences + ); + setUnifiedResourcePreferences(prefsWithDefaults); + appContext.workspacesService.setUnifiedResourcePreferences( + routing.ensureRootClusterUri(clusterUri), + prefsWithDefaults + ); + }, + [appContext.workspacesService, clusterUri] + ); + + useEffect(() => { + const fetchPreferences = async () => { + if ( + initialFetchAttempt.status === '' && + supersededInitialFetchAttempt.status === '' + ) { + const [prefs, error] = await runInitialFetchAttempt(); + if (!error) { + updateUnifiedResourcePreferencesStateAndWorkspace( + prefs?.unifiedResourcePreferences + ); + setClusterPreferences(prefs?.clusterPreferences); + } + } + }; + + fetchPreferences(); + }, [ + supersededInitialFetchAttempt.status, + runInitialFetchAttempt, + updateUnifiedResourcePreferencesStateAndWorkspace, + initialFetchAttempt.status, + ]); + + const hasUpdateSupersededInitialFetch = + initialFetchAttempt.status !== 'success' && + !hasFinished(supersededInitialFetchAttempt); + const updateUserPreferences = useCallback( + async (newPreferences: Partial): Promise => { + if (newPreferences.unifiedResourcePreferences) { + updateUnifiedResourcePreferencesStateAndWorkspace( + newPreferences.unifiedResourcePreferences + ); + } + + if (hasUpdateSupersededInitialFetch) { + setSupersededInitialFetchAttempt(makeProcessingAttempt()); + initialFetchAttemptAbortController.current.abort(); + } + + const [prefs, error] = await runUpdateAttempt(newPreferences); + if (!error) { + // We always try to update cluster preferences based on the cluster response so that the + // pinned resources are up-to-date. + // We don't do it for unified resources preferences because if the view mode got updated on + // the server while the user, say, updated a pin, we don't want to suddenly change the view + // mode. + setClusterPreferences(prefs?.clusterPreferences); + if (hasUpdateSupersededInitialFetch) { + setSupersededInitialFetchAttempt(makeSuccessAttempt(undefined)); + } + return; + } + if (!(error instanceof CanceledError)) { + if (hasUpdateSupersededInitialFetch) { + setSupersededInitialFetchAttempt(makeErrorAttempt(error)); + } + appContext.notificationsService.notifyWarning({ + title: 'Failed to update user preferences', + description: error.message, + }); + } + }, + [ + hasUpdateSupersededInitialFetch, + runUpdateAttempt, + updateUnifiedResourcePreferencesStateAndWorkspace, + appContext.notificationsService, + ] + ); + + return { + userPreferencesAttempt: + supersededInitialFetchAttempt.status !== '' + ? supersededInitialFetchAttempt + : mapAttempt(initialFetchAttempt, () => undefined), + updateUserPreferences, + userPreferences: { + unifiedResourcePreferences, + clusterPreferences, + }, + }; +} + +// TODO(gzdunek): DELETE IN 16.0.0. +// Support for UnifiedTabPreference has been added in 14.1 and for +// UnifiedViewModePreference in 14.1.5. +// We have to support these values being undefined/unset in Connect v15. +function mergeWithDefaultUnifiedResourcePreferences( + unifiedResourcePreferences: UnifiedResourcePreferences +): UnifiedResourcePreferences { + return { + defaultTab: unifiedResourcePreferences + ? unifiedResourcePreferences.defaultTab + : DefaultTab.DEFAULT_TAB_ALL, + viewMode: + unifiedResourcePreferences && + unifiedResourcePreferences.viewMode !== ViewMode.VIEW_MODE_UNSPECIFIED + ? unifiedResourcePreferences.viewMode + : ViewMode.VIEW_MODE_CARD, + labelsViewMode: + unifiedResourcePreferences && + unifiedResourcePreferences.labelsViewMode !== + LabelsViewMode.LABELS_VIEW_MODE_UNSPECIFIED + ? unifiedResourcePreferences.labelsViewMode + : LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED, + }; +} diff --git a/web/packages/teleterm/src/ui/appContext.ts b/web/packages/teleterm/src/ui/appContext.ts index 5e145952c6169..9b8c8a7161c17 100644 --- a/web/packages/teleterm/src/ui/appContext.ts +++ b/web/packages/teleterm/src/ui/appContext.ts @@ -43,6 +43,7 @@ import { UsageService } from 'teleterm/ui/services/usage'; import { ResourcesService } from 'teleterm/ui/services/resources'; import { ConnectMyComputerService } from 'teleterm/ui/services/connectMyComputer'; import { ConfigService } from 'teleterm/services/config'; +import { TshClient } from 'teleterm/services/tshd/types'; import { IAppContext } from 'teleterm/ui/types'; import { DeepLinksService } from 'teleterm/ui/services/deepLinks'; import { parseDeepLink } from 'teleterm/deepLinks'; @@ -63,6 +64,7 @@ export default class AppContext implements IAppContext { connectionTracker: ConnectionTrackerService; fileTransferService: FileTransferService; resourcesService: ResourcesService; + tshd: TshClient; /** * subscribeToTshdEvent lets you add a listener that's going to be called every time a client * makes a particular RPC to the tshd events service. The listener receives the request converted @@ -88,6 +90,7 @@ export default class AppContext implements IAppContext { constructor(config: ElectronGlobals) { const { tshClient, ptyServiceClient, mainProcessClient } = config; this.logger = new Logger('AppContext'); + this.tshd = tshClient; this.subscribeToTshdEvent = config.subscribeToTshdEvent; this.mainProcessClient = mainProcessClient; this.notificationsService = new NotificationsService(); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts index 01115d501641d..6497cb5a09829 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts @@ -86,6 +86,7 @@ describe('restoring workspace', () => { location: testWorkspace.location, }, connectMyComputer: undefined, + unifiedResourcePreferences: undefined, }, }); }); @@ -117,6 +118,7 @@ describe('restoring workspace', () => { location: clusterDocument.uri, previous: undefined, connectMyComputer: undefined, + unifiedResourcePreferences: undefined, }, }); }); @@ -142,7 +144,9 @@ describe('setActiveWorkspace', () => { throw new Error(`Got unexpected dialog ${dialog.kind}`); } - return { closeDialog: () => {} }; + return { + closeDialog: () => {}, + }; }); const { isAtDesiredWorkspace } = await workspacesService.setActiveWorkspace( @@ -186,7 +190,9 @@ describe('setActiveWorkspace', () => { throw new Error(`Got unexpected dialog ${dialog.kind}`); } - return { closeDialog: () => {} }; + return { + closeDialog: () => {}, + }; }); const { isAtDesiredWorkspace } = await workspacesService.setActiveWorkspace( diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts index 768028548fa18..f75b75d61d28e 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import { z } from 'zod'; import { useStore } from 'shared/libs/stores'; import { arrayObjectIsEqual } from 'shared/utils/highbar'; @@ -23,6 +24,13 @@ import { arrayObjectIsEqual } from 'shared/utils/highbar'; // @ts-ignore import { ResourceKind } from 'e-teleport/Workflow/NewRequest/useNewRequest'; +import { + UnifiedResourcePreferences, + DefaultTab, + ViewMode, + LabelsViewMode, +} from 'shared/services/unifiedResourcePreferences'; + import { ModalsService } from 'teleterm/ui/services/modals'; import { ClustersService } from 'teleterm/ui/services/clusters'; import { @@ -66,6 +74,7 @@ export interface Workspace { connectMyComputer?: { autoStart: boolean; }; + unifiedResourcePreferences?: UnifiedResourcePreferences; previous?: { documents: Document[]; location: DocumentUri; @@ -216,6 +225,22 @@ export class WorkspacesService extends ImmutableStore { }); } + setUnifiedResourcePreferences( + rootClusterUri: RootClusterUri, + preferences: UnifiedResourcePreferences + ): void { + this.setState(draftState => { + draftState.workspaces[rootClusterUri].unifiedResourcePreferences = + preferences; + }); + } + + getUnifiedResourcePreferences( + rootClusterUri: RootClusterUri + ): UnifiedResourcePreferences | undefined { + return this.state.workspaces[rootClusterUri].unifiedResourcePreferences; + } + /** * setActiveWorkspace changes the active workspace to that of the given root cluster. * If the root cluster doesn't have a workspace yet, setActiveWorkspace creates a default @@ -360,6 +385,9 @@ export class WorkspacesService extends ImmutableStore { } : undefined, connectMyComputer: persistedWorkspace?.connectMyComputer, + unifiedResourcePreferences: this.parseUnifiedResourcePreferences( + persistedWorkspace?.unifiedResourcePreferences + ), }; return workspaces; }, {}); @@ -373,6 +401,18 @@ export class WorkspacesService extends ImmutableStore { } } + // TODO(gzdunek): Parse the entire workspace state read from disk like below. + private parseUnifiedResourcePreferences( + unifiedResourcePreferences: unknown + // TODO(gzdunek): DELETE IN 16.0.0. See comment in useUserPreferences.ts. + ): Partial | undefined { + try { + return unifiedResourcePreferencesSchema.parse(unifiedResourcePreferences); + } catch (e) { + this.logger.error('Failed to parse unified resource preferences', e); + } + } + private reopenPreviousDocuments(clusterUri: RootClusterUri): void { this.setState(draftState => { const workspace = draftState.workspaces[clusterUri]; @@ -476,12 +516,19 @@ export class WorkspacesService extends ImmutableStore { location: workspace.previous?.location || workspace.location, documents: workspace.previous?.documents || workspace.documents, connectMyComputer: workspace.connectMyComputer, + unifiedResourcePreferences: workspace.unifiedResourcePreferences, }; } this.statePersistenceService.saveWorkspacesState(stateToSave); } } +const unifiedResourcePreferencesSchema = z.object({ + defaultTab: z.nativeEnum(DefaultTab), + viewMode: z.nativeEnum(ViewMode), + labelsViewMode: z.nativeEnum(LabelsViewMode), +}); + export type PendingAccessRequest = { [k in Exclude]: Record; }; diff --git a/web/packages/teleterm/src/ui/types.ts b/web/packages/teleterm/src/ui/types.ts index f35d245075aea..765285b05c7a0 100644 --- a/web/packages/teleterm/src/ui/types.ts +++ b/web/packages/teleterm/src/ui/types.ts @@ -34,6 +34,7 @@ import { UsageService } from 'teleterm/ui/services/usage'; import { ConfigService } from 'teleterm/services/config'; import { ConnectMyComputerService } from 'teleterm/ui/services/connectMyComputer'; import { HeadlessAuthenticationService } from 'teleterm/ui/services/headlessAuthn/headlessAuthnService'; +import { TshClient } from 'teleterm/services/tshd/types'; export interface IAppContext { clustersService: ClustersService; @@ -55,6 +56,7 @@ export interface IAppContext { configService: ConfigService; connectMyComputerService: ConnectMyComputerService; headlessAuthenticationService: HeadlessAuthenticationService; + tshd: TshClient; pullInitialState(): Promise; }