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.
Comment on lines +318 to +320
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(philosophical tangent ahead)

To elaborate on that, for example ExtraTopComponents only needs to know if there are no search results, no matter what the status of the request is.

If the request is in progress, the hasNoResults is false because we didn't finish fetching yet.

If the request is done, hasNoResults can be true/false depending on what the response is.

Had we returned request status next to hasNoResults, it'd make it possible to introduce nonsensical states, e.g. hasNoResults: true, requestState: 'processing'. But since we don't tell what the status of the request is, every combination of fields that are currently present in this type makes sense.


This is all possible only because getActionPickerStatus knows what the actual status of the request is, so ActionPickerStatus can act as a proxy when answering the questions, without the components in ExtraTopComponents having to worry whether the request is in progress or not. This is not the equivalent of forgetting to check the isLoading flag.

*/
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';
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel kinda uneasy about having two sources of truth when it comes to searchMode. It is defined in useSearch.tsx as well:

function getResourceSearchMode(
search: string,
filters: SearchFilter[]
): ResourceSearchMode {
// Trim the search to avoid sending requests with limit set to 100 if the user just pressed some
// spaces.
const trimmedSearch = search.trim();
if (!trimmedSearch) {
// The preview should be fetched only when at least one filter is selected. Otherwise we'd send
// three requests for each connected cluster when the search bar gets open.
if (filters.length >= 1) {
return 'preview';
}
return 'no-search';
}
return 'full-search';
}

Perhaps it'd make more sense for getActionPickerStatus to accept searchMode coming from useSearch as an argument instead of accepting both inputValue and filters and repeating the logic.

searchMode would need to be extracted from useResourceSearch though so that it can be known without waiting for the search to resolve. This could be done by exporting getResourceSearchMode and then passing searchMode to both the function returned from useResourceSearch and to getActionPickerStatus.

On top of that, those search modes are not 100% the same, useResourceSearch trims the input to avoid making unnecessary requests, so that logic would need to be made consistent between these two places.

Alas, this would require more time then I have at the moment. Plus ActionPickerStatus is still subject to change, for example when taking filters into account when displaying offline clusters (#24190 (comment)).

}
| {
// 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