Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('getActionPickerStatus', () => {

const status = getActionPickerStatus({
inputValue: 'foo',
filters: [],
filterActionsAttempt: makeSuccessAttempt([]),
allClusters: [],
actionAttempts: [makeSuccessAttempt([])],
Expand All @@ -47,10 +48,10 @@ describe('getActionPickerStatus', () => {
}),
});

expect(status.status).toBe('finished');
expect(status.inputState).toBe('some-input');

const { clustersWithExpiredCerts, nonRetryableResourceSearchErrors } =
status.status === 'finished' && status;
status.inputState === 'some-input' && status;

expect([...clustersWithExpiredCerts]).toEqual([retryableError.clusterUri]);
expect(nonRetryableResourceSearchErrors).toEqual([nonRetryableError]);
Expand All @@ -66,6 +67,7 @@ describe('getActionPickerStatus', () => {

const status = getActionPickerStatus({
inputValue: 'foo',
filters: [],
filterActionsAttempt: makeSuccessAttempt([]),
allClusters: [offlineCluster],
actionAttempts: [makeSuccessAttempt([])],
Expand All @@ -76,8 +78,9 @@ describe('getActionPickerStatus', () => {
}),
});

expect(status.status).toBe('finished');
const { clustersWithExpiredCerts } = status.status === 'finished' && status;
expect(status.inputState).toBe('some-input');
const { clustersWithExpiredCerts } =
status.inputState === 'some-input' && status;

expect(clustersWithExpiredCerts.size).toBe(2);
expect(clustersWithExpiredCerts).toContain(offlineCluster.uri);
Expand All @@ -104,6 +107,7 @@ describe('getActionPickerStatus', () => {
];
const status = getActionPickerStatus({
inputValue: 'foo',
filters: [],
filterActionsAttempt: makeSuccessAttempt([]),
allClusters: [],
actionAttempts: [makeSuccessAttempt([])],
Expand All @@ -114,8 +118,46 @@ describe('getActionPickerStatus', () => {
}),
});

expect(status.status).toBe('finished');
const { clustersWithExpiredCerts } = status.status === 'finished' && status;
expect(status.inputState).toBe('some-input');
const { clustersWithExpiredCerts } =
status.inputState === 'some-input' && status;
expect([...clustersWithExpiredCerts]).toEqual(['/clusters/foo']);
});

it('returns non-retryable errors when fetching a preview after selecting a filter fails', () => {
const nonRetryableError = new ResourceSearchError(
'/clusters/bar',
'server',
new Error('non-retryable error')
);
const resourceSearchErrors = [
new ResourceSearchError(
'/clusters/foo',
'server',
new Error('ssh: cert has expired')
),
nonRetryableError,
];
const status = getActionPickerStatus({
inputValue: '',
filters: [{ filter: 'resource-type', resourceType: 'servers' }],
filterActionsAttempt: makeSuccessAttempt([]),
allClusters: [],
actionAttempts: [makeSuccessAttempt([])],
resourceSearchAttempt: makeSuccessAttempt({
errors: resourceSearchErrors,
results: [],
search: 'foo',
}),
});

expect(status.inputState).toBe('no-input');

const { searchMode } = status.inputState === 'no-input' && status;
expect(searchMode.kind).toBe('preview');

const { nonRetryableResourceSearchErrors } =
searchMode.kind === 'preview' && searchMode;
expect(nonRetryableResourceSearchErrors).toEqual([nonRetryableError]);
});
});
125 changes: 104 additions & 21 deletions web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
SearchResultServer,
SearchResultCluster,
SearchResultResourceType,
SearchFilter,
} from 'teleterm/ui/Search/searchResult';
import * as tsh from 'teleterm/services/tshd/types';
import * as uri from 'teleterm/ui/uri';
Expand Down Expand Up @@ -152,13 +153,15 @@ export function ActionPicker(props: { input: ReactElement }) {
() =>
getActionPickerStatus({
inputValue,
filters,
filterActionsAttempt,
actionAttempts,
resourceSearchAttempt,
allClusters: clustersService.getClusters(),
}),
[
inputValue,
filters,
filterActionsAttempt,
actionAttempts,
resourceSearchAttempt,
Expand Down Expand Up @@ -243,18 +246,41 @@ const ExtraTopComponents = (props: {
}) => {
const { status, getClusterName, showErrorsInModal } = props;

switch (status.status) {
switch (status.inputState) {
case 'no-input': {
return (
<TypeToSearchItem
hasNoRemainingFilterActions={status.hasNoRemainingFilterActions}
/>
);
}
case 'processing': {
return null;
switch (status.searchMode.kind) {
case 'no-search': {
return <TypeToSearchItem hasNoRemainingFilterActions={false} />;
}
case 'preview': {
const {
nonRetryableResourceSearchErrors,
hasNoRemainingFilterActions,
} = status.searchMode;

return (
<>
<TypeToSearchItem
hasNoRemainingFilterActions={hasNoRemainingFilterActions}
/>
{nonRetryableResourceSearchErrors.length > 0 && (
<ResourceSearchErrorsItem
errors={nonRetryableResourceSearchErrors}
getClusterName={getClusterName}
showErrorsInModal={() => {
showErrorsInModal(nonRetryableResourceSearchErrors);
}}
/>
)}
</>
);
}
default: {
return assertUnreachable(status.searchMode);
}
}
}
case 'finished': {
case 'some-input': {
return (
<>
{status.nonRetryableResourceSearchErrors.length > 0 && (
Expand All @@ -281,30 +307,71 @@ const ExtraTopComponents = (props: {
}
};

/**
* ActionPickerStatus helps with displaying ExtraTopComponents. It has two goals:
*
* * Encapsulate business logic so that anything that ExtraTopComponents renders can just read
* ActionPickerStatus fields.
* * Represent only valid UI states. For example, inputState 'no-input' doesn't have hasNoResults
* field as this field would make no sense in a situation where no search requests were made.
*
* As you may notice, ActionPickerStatus doesn't say whether the search request is in progress or
* not, simply because displaying the progress bar is handled by another component. The questions
* answered by ActionPickerStatus are valid to ask no matter what the state of the request is.
*/
type ActionPickerStatus =
| { status: 'no-input'; hasNoRemainingFilterActions: boolean }
| { status: 'processing' }
| {
status: 'finished';
// no-input: The input is empty.
inputState: 'no-input';
searchMode:
| {
// no-search: The search bar is pristine, that is the input and the filters are empty.
kind: 'no-search';
}
| {
// preview: At least one filter is selected. The search bar is fetching or shows
// a preview of results matching the filters.
kind: 'preview';
hasNoRemainingFilterActions: boolean;
nonRetryableResourceSearchErrors: ResourceSearchError[];
};
}
| {
// some-input: The input is not empty. The search bar is fetching or shows results matching
// the query and filters.
inputState: 'some-input';
hasNoResults: boolean;
nonRetryableResourceSearchErrors: ResourceSearchError[];
clustersWithExpiredCerts: Set<uri.ClusterUri>;
};

export function getActionPickerStatus({
inputValue,
filters,
filterActionsAttempt,
allClusters,
actionAttempts,
resourceSearchAttempt,
}: {
inputValue: string;
filters: SearchFilter[];
filterActionsAttempt: Attempt<SearchAction[]>;
allClusters: tsh.Cluster[];
actionAttempts: Attempt<SearchAction[]>[];
resourceSearchAttempt: Attempt<CrossClusterResourceSearchResult>;
}): ActionPickerStatus {
if (!inputValue) {
const didNotSelectAnyFilters = filters.length === 0;

// If the input is empty, we fetch the preview only after the user selected some filters.
// So at this point we know that no search request was sent.
if (didNotSelectAnyFilters) {
return {
inputState: 'no-input',
searchMode: { kind: 'no-search' },
};
}

// The number of available filters the user can select changes dynamically based on how many
// clusters are in the state. That's why instead of inspecting the filters array from
// SearchContext, we inspect the actual filter actions attempt to see if any further filter
Expand All @@ -316,30 +383,46 @@ export function getActionPickerStatus({
filterActionsAttempt.status === 'success' &&
filterActionsAttempt.data.length === 0;

const nonRetryableResourceSearchErrors =
resourceSearchAttempt.status === 'success'
? resourceSearchAttempt.data.errors.filter(
err => !isRetryable(err.cause)
)
: [];

return {
status: 'no-input',
hasNoRemainingFilterActions,
inputState: 'no-input',
searchMode: {
kind: 'preview',
hasNoRemainingFilterActions,
nonRetryableResourceSearchErrors,
},
};
}

const nonRetryableResourceSearchErrors = [];
const clustersWithExpiredCerts = new Set(
allClusters.filter(c => !c.connected).map(c => c.uri)
);
const haveActionAttemptsFinished = actionAttempts.every(attempt =>
hasFinished(attempt)
);

if (!haveActionAttemptsFinished) {
return {
status: 'processing',
inputState: 'some-input',
hasNoResults: false,
nonRetryableResourceSearchErrors,
clustersWithExpiredCerts,
};
}

const hasNoResults = actionAttempts.every(
attempt => attempt.data.length === 0
);
const clustersWithExpiredCerts = new Set(
allClusters.filter(c => !c.connected).map(c => c.uri)
);
const nonRetryableResourceSearchErrors = [];

// We could assume that resourceSearchAttempt has finished since action attempts depend on it and
// we know that they all finished at this point. But we check status explicitly anyway.
if (resourceSearchAttempt.status === 'success') {
resourceSearchAttempt.data.errors.forEach(err => {
if (isRetryable(err.cause)) {
Expand All @@ -351,7 +434,7 @@ export function getActionPickerStatus({
}

return {
status: 'finished',
inputState: 'some-input',
hasNoResults,
clustersWithExpiredCerts,
nonRetryableResourceSearchErrors,
Expand Down
4 changes: 4 additions & 0 deletions web/packages/teleterm/src/ui/Search/pickers/results.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,10 @@ const AuxiliaryItems = () => (
addWindowEventListener={() => ({ cleanup: () => {} })}
ExtraTopComponent={
<>
<NoResultsItem
clustersWithExpiredCerts={new Set()}
getClusterName={routing.parseClusterName}
/>
<NoResultsItem
clustersWithExpiredCerts={new Set([clusterUri])}
getClusterName={routing.parseClusterName}
Expand Down