Skip to content

Commit

Permalink
[v16] Support resource availability switcher in Connect (#42756)
Browse files Browse the repository at this point in the history
* Add `AvailableResourceMode` to the updated preferences in the handler

* Improve the preferences parser to handle missing properties,
as well as the entire object being undefined

* Add support for resource availability switcher

* Show requestable resources only if the cluster supports access requests

* Return an object with `searchAsRoles` and `includeRequestable`
  • Loading branch information
gzdunek authored Jun 11, 2024
1 parent 262efa6 commit b49bbb9
Show file tree
Hide file tree
Showing 9 changed files with 470 additions and 62 deletions.
16 changes: 1 addition & 15 deletions lib/teleterm/services/userpreferences/userpreferences.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,36 +161,22 @@ func Update(ctx context.Context, rootClient Client, leafClient Client, newPrefer
// and LabelsViewMode fields in UnifiedResourcePreferences.
// The fields are updated one by one (instead of passing the entire struct as new preferences)
// to prevent potential new fields from being overwritten.
// Supports oldPreferences being nil.
func updateUnifiedResourcePreferences(oldPreferences *userpreferencesv1.UnifiedResourcePreferences, newPreferences *userpreferencesv1.UnifiedResourcePreferences) *userpreferencesv1.UnifiedResourcePreferences {
updated := oldPreferences
// TODO(gzdunek): DELETE IN 16.0.0.
// We won't have to support old preferences being nil.
if oldPreferences == nil {
updated = &userpreferencesv1.UnifiedResourcePreferences{}
}

updated.DefaultTab = newPreferences.DefaultTab
updated.ViewMode = newPreferences.ViewMode
updated.LabelsViewMode = newPreferences.LabelsViewMode
updated.AvailableResourceMode = newPreferences.AvailableResourceMode

return updated
}

// updateClusterPreferences updates pinned resources in ClusterUserPreferences.
// The fields are updated one by one (instead of passing the entire struct as new preferences)
// to prevent potential new fields from being overwritten.
// Supports oldPreferences being nil.
func updateClusterPreferences(oldPreferences *userpreferencesv1.ClusterUserPreferences, newPreferences *userpreferencesv1.ClusterUserPreferences) *userpreferencesv1.ClusterUserPreferences {
updated := oldPreferences
// TODO(gzdunek): DELETE IN 16.0.0.
// We won't have to support old preferences being nil.
if oldPreferences == nil {
updated = &userpreferencesv1.ClusterUserPreferences{}
}
if updated.PinnedResources == nil {
updated.PinnedResources = &userpreferencesv1.PinnedResourcesUserPreferences{}
}

updated.PinnedResources.ResourceIds = newPreferences.PinnedResources.ResourceIds

Expand Down
14 changes: 8 additions & 6 deletions lib/teleterm/services/userpreferences/userpreferences_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ var rootPreferencesMock = &userpreferencesv1.UserPreferences{
},
},
UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{
DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_ALL,
ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_CARD,
LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_COLLAPSED,
DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_ALL,
ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_CARD,
LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_COLLAPSED,
AvailableResourceMode: userpreferencesv1.AvailableResourceMode_AVAILABLE_RESOURCE_MODE_NONE,
},
}

Expand Down Expand Up @@ -106,9 +107,10 @@ func TestUserPreferencesUpdateForRootAndLeaf(t *testing.T) {
},
},
UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{
DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_PINNED,
ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_LIST,
LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_EXPANDED,
DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_PINNED,
ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_LIST,
LabelsViewMode: userpreferencesv1.LabelsViewMode_LABELS_VIEW_MODE_EXPANDED,
AvailableResourceMode: userpreferencesv1.AvailableResourceMode_AVAILABLE_RESOURCE_MODE_REQUESTABLE,
},
}

Expand Down
272 changes: 272 additions & 0 deletions web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/**
* Teleport
* Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
*/

import { render, screen } from 'design/utils/testing';
import { mockIntersectionObserver } from 'jsdom-testing-mocks';
import { act } from '@testing-library/react';

import {
AvailableResourceMode,
DefaultTab,
ViewMode,
LabelsViewMode,
} from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb';
import { ShowResources } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';

import { UnifiedResources } from 'teleterm/ui/DocumentCluster/UnifiedResources';
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
import { ResourcesContextProvider } from 'teleterm/ui/DocumentCluster/resourcesContext';
import { ConnectMyComputerContextProvider } from 'teleterm/ui/ConnectMyComputer';
import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider';
import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import {
makeRootCluster,
rootClusterUri,
} from 'teleterm/services/tshd/testHelpers';
import { getEmptyPendingAccessRequest } from 'teleterm/ui/services/workspacesService/accessRequestsService';

import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';

const mio = mockIntersectionObserver();

const tests = [
{
name: 'fetches only available resources if cluster does not support access requests',
conditions: {
isClusterSupportingAccessRequests: false,
showResources: ShowResources.REQUESTABLE,
availableResourceModePreference: AvailableResourceMode.ALL,
},
expect: {
searchAsRoles: false,
includeRequestable: false,
},
},
{
name: 'fetches all resources if cluster allows listing all and user preferences says all',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.REQUESTABLE,
availableResourceModePreference: AvailableResourceMode.ALL,
},
expect: {
searchAsRoles: false,
includeRequestable: true,
},
},
{
name: 'fetches all resources if cluster allows listing all and user preferences says none',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.REQUESTABLE,
availableResourceModePreference: AvailableResourceMode.ALL,
},
expect: {
searchAsRoles: false,
includeRequestable: true,
},
},
{
name: 'fetches accessible resources if cluster allows listing all and user preferences says accessible',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.REQUESTABLE,
availableResourceModePreference: AvailableResourceMode.ACCESSIBLE,
},
expect: {
searchAsRoles: false,
includeRequestable: false,
},
},
{
name: 'fetches requestable resources if cluster allows listing all and user preferences says requestable',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.REQUESTABLE,
availableResourceModePreference: AvailableResourceMode.REQUESTABLE,
},
expect: {
searchAsRoles: true,
includeRequestable: false,
},
},
{
name: 'fetches only accessible resources if cluster does not allow listing all',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.ACCESSIBLE_ONLY,
availableResourceModePreference: AvailableResourceMode.UNSPECIFIED,
},
expect: {
searchAsRoles: false,
includeRequestable: false,
},
},
{
name: 'fetches only accessible resources if cluster does not allow listing all and user preferences says accessible',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.ACCESSIBLE_ONLY,
availableResourceModePreference: AvailableResourceMode.ALL,
},
expect: {
searchAsRoles: false,
includeRequestable: false,
},
},
{
name: 'fetches only requestable resources if cluster does not allow listing all and user preferences says requestable',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.ACCESSIBLE_ONLY,
availableResourceModePreference: AvailableResourceMode.REQUESTABLE,
},
expect: {
searchAsRoles: true,
includeRequestable: false,
},
},
{
name: 'fetches only accessible resources if cluster does not allow listing all but user preferences says all',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.ACCESSIBLE_ONLY,
availableResourceModePreference: AvailableResourceMode.ALL,
},
expect: {
searchAsRoles: false,
includeRequestable: false,
},
},
{
name: 'fetches only accessible resources if cluster does not allow listing all but user preferences says none',
conditions: {
isClusterSupportingAccessRequests: true,
showResources: ShowResources.ACCESSIBLE_ONLY,
availableResourceModePreference: AvailableResourceMode.NONE,
},
expect: {
searchAsRoles: false,
includeRequestable: false,
},
},
];

test.each(tests)('$name', async testCase => {
const doc = makeDocumentCluster();

const appContext = new MockAppContext({ platform: 'darwin' });
appContext.clustersService.setState(draft => {
draft.clusters.set(
doc.clusterUri,
makeRootCluster({
uri: doc.clusterUri,
features: {
advancedAccessWorkflows:
testCase.conditions.isClusterSupportingAccessRequests,
isUsageBasedBilling: false,
},
showResources: testCase.conditions.showResources,
})
);
});

appContext.workspacesService.setState(draftState => {
const rootClusterUri = doc.clusterUri;
draftState.rootClusterUri = rootClusterUri;
draftState.workspaces[rootClusterUri] = {
localClusterUri: doc.clusterUri,
documents: [doc],
location: doc.uri,
unifiedResourcePreferences: {
defaultTab: DefaultTab.ALL,
viewMode: ViewMode.CARD,
labelsViewMode: LabelsViewMode.COLLAPSED,
availableResourceMode:
testCase.conditions.availableResourceModePreference,
},
accessRequests: {
pending: getEmptyPendingAccessRequest(),
isBarCollapsed: true,
},
};
});

jest.spyOn(appContext.tshd, 'getUserPreferences').mockResolvedValue(
new MockedUnaryCall({
userPreferences: {
unifiedResourcePreferences: {
defaultTab: DefaultTab.ALL,
viewMode: ViewMode.CARD,
labelsViewMode: LabelsViewMode.COLLAPSED,
availableResourceMode:
testCase.conditions.availableResourceModePreference,
},
},
})
);

jest
.spyOn(appContext.resourcesService, 'listUnifiedResources')
.mockResolvedValue({
resources: [],
nextKey: '',
});

render(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<ResourcesContextProvider>
<ConnectMyComputerContextProvider rootClusterUri={doc.clusterUri}>
<UnifiedResources
clusterUri={doc.clusterUri}
docUri={doc.uri}
queryParams={doc.queryParams}
/>
</ConnectMyComputerContextProvider>
</ResourcesContextProvider>
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);

act(mio.enterAll);

await expect(
screen.findByText('Add your first resource to Teleport')
).resolves.toBeInTheDocument();

expect(appContext.resourcesService.listUnifiedResources).toHaveBeenCalledWith(
{
clusterUri: rootClusterUri,
includeRequestable: testCase.expect.includeRequestable,
kinds: [],
limit: 48,
pinnedOnly: false,
query: '',
search: '',
searchAsRoles: testCase.expect.searchAsRoles,
sortBy: {
field: 'name',
isDesc: false,
},
startKey: '',
},
new AbortController().signal
);
});
Loading

0 comments on commit b49bbb9

Please sign in to comment.