Add resource pinning to Unified Resource cards#32980
Conversation
| params: ResourceFilter; | ||
| setParams: (params: ResourceFilter) => void; | ||
| setSort: (sort: SortType) => void; | ||
| selectAll: () => void; |
There was a problem hiding this comment.
hrm, the meaning of selectAll would mean to select all possible resources, but it really means selectAll for the already fetched resources and i'm not sure that's accurate in the infinite view 🤔
There was a problem hiding this comment.
Thats fair. The designs call for 'Select All' and it has a similar functionality as things like Gmail where "select" is everything that is visible, not everything on every page. I know we have infinite scroll so its not quite a page but the concept applies. I'll reach out to Kenny to see if we can change the language. Or, should we keep the language but change the code name?
There was a problem hiding this comment.
i'm just concerned that user's will get the wrong idea, but maybe i'm overthinking it
There was a problem hiding this comment.
IMO at least we should change the name to select visible (both the language and the code). But TBH, the bulk action doesn't seem really useful for resource pinning, why would I want to pin all my resources?
EDIT: I don't know why this comment wasn't submitted yesterday :(
There was a problem hiding this comment.
I can change to select visible. I'll let Kenny know.
As far as bulk actions, this is just one of the many bulk actions coming. Sure, pinning all the resources on a default page might not make sense, but imagine you enter some search query and have 5 results and you want to pin all of those. This is when a bulk "select visible" would make sense
|
I added 3c536ef to support "pinned resources tab by default if they have some". since the tab was controlled by the params, we needed a way to set that before going out and fetching stuff to avoid double fetching. |
I think you might have tested right in the middle of the |
| resourceType: 'unified_resource', | ||
| }; | ||
|
|
||
| export const HoverTooltip: React.FC<{ |
There was a problem hiding this comment.
We have ToolTipInfo in shared, maybe we could extract Tooltip from it and use it here and as the building block for ToolTipInfo?
There was a problem hiding this comment.
I will add a TODO for this. The positioning and root component (span vs flex) would add a lot of changes and this PR is already getting quite large. Sound good?
|
|
||
| import { UnifiedTabPreference } from 'teleport/services/userPreferences/types'; | ||
|
|
||
| export const ResourceTab = ({ title, value, selectedTab, onChange }: Props) => { |
There was a problem hiding this comment.
I'd make this component more "dumb", it doesn't need to know value or selectedTab.
Instead, we can pass here only isSelected: boolean, onSelect(): void and title.
The actual logic would be implemented by the parent.
| onClick={selectTab} | ||
| > | ||
| <TabText selected={selected}>{title}</TabText> | ||
| <TabTextUnderline selected={selected} /> |
There was a problem hiding this comment.
I think we could just use border-bottom on Box.
| useEffect(() => { | ||
| const handleKeyDown = event => { | ||
| if (event.key === 'Escape') { | ||
| setSelectedResources([]); | ||
| } | ||
| }; | ||
|
|
||
| document.addEventListener('keydown', handleKeyDown); | ||
|
|
||
| return () => { | ||
| document.removeEventListener('keydown', handleKeyDown); | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
IMO it's better to set event listeners on DOM elements. When you add it to window/document it will be triggered on every 'Escape' event.
For example, you can select a few items, then open a modal. When you press 'Escape', it will both close the modal and deselect the items.
Here, I'd try to add it on the FeatureBox.
There was a problem hiding this comment.
After some more testing, this doesn't quite work. I'm not exactly sure why. The best way I can describe it without diving into it is that the 'focus' is lost or something. If i select a box and press Escape, it works. but if i click anywhere else on the page, the Escape key no longer works. I'll keep diving into this
| updateClusterPreferences({ | ||
| pinnedResources: [...pinnedResources, resourceId], | ||
| }); |
There was a problem hiding this comment.
When updating the state that depends on the previous state we should use callback https://react.dev/reference/react/useState#setstate.
| }; | ||
|
|
||
| const selectTab = (value: UnifiedTabPreference) => { | ||
| const pinnedOnly = value === UnifiedTabPreference.PINNED ? true : null; |
There was a problem hiding this comment.
| const pinnedOnly = value === UnifiedTabPreference.PINNED ? true : null; | |
| const pinnedOnly = value === UnifiedTabPreference.PINNED; |
| selectedResources.includes(resourceKey(resource)) | ||
| ); | ||
|
|
||
| const selectAll = () => { |
There was a problem hiding this comment.
It's more like toggleSelectAll.
| if (storedPreferences) { | ||
| setClusterPreferences(storedPreferences); | ||
|
|
||
| return; |
| clusterPreferences, | ||
| } as UserPreferences; | ||
|
|
||
| console.log({ nextPreferences }); |
| } | ||
|
|
||
| export enum UnifiedTabPreference { | ||
| ALL = 1, |
There was a problem hiding this comment.
According to TS conventions it should be All and Pinned.
| params: ResourceFilter; | ||
| setParams: (params: ResourceFilter) => void; | ||
| setSort: (sort: SortType) => void; | ||
| selectAll: () => void; |
There was a problem hiding this comment.
IMO at least we should change the name to select visible (both the language and the code). But TBH, the bulk action doesn't seem really useful for resource pinning, why would I want to pin all my resources?
EDIT: I don't know why this comment wasn't submitted yesterday :(
|
|
||
| const handlePinResource = (resourceId: string) => { | ||
| if (pinnedResources.includes(resourceId)) { | ||
| const handlePinResource = useCallback( |
There was a problem hiding this comment.
Ah sorry, my previous comment about using a callback was confusing. What I wanted to say is that for state updates we can use callback from useState to calculate the new state that is based on the previous state.
E.g:
const [state, setState] = useState(0);
setState(prevState => prevState + 1);But here updateClusterPreferences is a function that calls also some external APIs, so we won't be able to use that setState callback.
We can remove the useCallback wrapper.
| updateClusterPreferences({ | ||
| pinnedResources: pinnedResources.filter(i => i !== resourceId), | ||
| }); |
There was a problem hiding this comment.
Now that I looked into updateClusterPreferences, I see that we don't handle API errors.
| pinResource={(id: string) => console.log(id)} | ||
| selectResource={(id: string) => console.log(id)} |
There was a problem hiding this comment.
I'd just provide an empty function.
|
|
||
| import { UnifiedTabPreference } from 'teleport/services/userPreferences/types'; | ||
|
|
||
| export const ResourceTab = ({ title, value, isSelected, onChange }: Props) => { |
There was a problem hiding this comment.
What about the value? As I wrote in my previous comment, we don't need it at this level.
| async function loadClusterPreferences() { | ||
| const storedPreferences = storage.getUserClusterPreferences(); | ||
| try { | ||
| const preferences = await service.getUserClusterPreferences(clusterId); | ||
| storage.setUserClusterPreferences(preferences); | ||
| setClusterPreferences(preferences); | ||
| } catch (error) { | ||
| if (storedPreferences) { | ||
| setClusterPreferences(storedPreferences); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
I have some concerns about this mechanism:
- In a situation where I have a slow connection (or just the request takes more time), and I quickly switch between clusters I can end up with
clusterIdfrom a cluster A, but preferences from a cluster B. That would happen because we don't cancel the requests and a race condition can happen. - In case of an error, we read the cluster preferences for the last cluster that was fetched successfully (and this can be any cluster).
To solve 1. I think we should cancel the request when we change the cluster.
As of storing the preferences, I don't see much value in keeping them in local storage, and reading them only in case of an error (but maybe I don't see something).
IMO much more useful would be caching the preferences per cluster in a map in the JS state. Thanks to this, we could avoid sending a request every time when the cluster is changed.
When doing this, we could probably omit the suggestion about cancelling the requests, but then we would have to store attempts in that cache, which may be problematic I think.
There was a problem hiding this comment.
Admittedly, this mechanism is just following previous conventions for how the rest of our user preferences are stored. I supposed we could change this one entirely but I'll have to reach out to Ryan to see why he made it this way to make sure I'm not missing anything.
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| loadClusterPreferences(); |
There was a problem hiding this comment.
This should be wrapped in an attempt, similarly to the regular request for preferences. When the request is processing, we should show a loading indicator.
d43d08c to
06af5ca
Compare
author Michael Myers <michael.myers@goteleport.com> 1694885352 -0500 committer Michael Myers <michael.myers@goteleport.com> 1696979801 -0500 gpgsig -----BEGIN PGP SIGNATURE----- iQGzBAABCAAdFiEETMffNTGCRZBrrPo/svAculE8l1QFAmUl21kACgkQsvAculE8 l1Qn3wwAjHFOvII2t68vDH4HD93nX3vxSDED9DLgjjFSCjklKTuWVC9Vxb+vO4kL XrbZxQiEN/Q10Z5DSXB1scn6SP2kgk9Ae2TMfZJskTMd9hLhZJVuhwzAW4/brj9t 3zIW4k7uBQF2sCGN7aG8+sSuNl43lacaJDN550MuGZwagRrgXXNDyNUdn7AzULVX PcMyKrcp5kuN+uWIRb7z1ZJR+s4m1wyI1MoX8ikUrBdWwk6bTFFXM3TvxFCtNb2j 8+ILDFWJ6HZ0k9Sx2ExQAbN9M76BQcWqE06BhuLQ9UTUIbw0pmk2j/DjlaOlZhjF LlHEjM8se3rO7Gqy5Kj1lPs6j3AIwUqfhEwE9pWRl10P1oCxv47MHhs9jfZFkihl yWUm7ey1Un36wl46F3/POdT4Fzf0PwavCRogcMIUo5J6xqKM47C3jY8szjympeJW lp7g0nvNhvgbGE0kW2FZQFmuQfZdGtiZ6gVPeIER9cKwJYWtiEOURqd6/R99j+q8 29I8CwLA =qgBc -----END PGP SIGNATURE----- Add pinned resources to the web UI
c0c12bd to
28cc31a
Compare
|
I'm very sorry for the rebase. I started merging in my large chain of PRs and had some nasty conflicts. Feedback commits start from here. |
| /> | ||
| ); | ||
| })} | ||
| {clusterPreferencesAttempt.status !== 'processing' && |
There was a problem hiding this comment.
When a normal request is processing we are displaying LoadingCard, shouldn't we do the same for this attempt?
There was a problem hiding this comment.
We should probably make the conditional to show the loading cards based on an OR of normal request processing or cluster preferences processing. especially since there is already a delay on the loading cards showing up in the first place. if we had it individually for each request, we'd have a double delay. both those requests happen 'in parallel' so the loading makes sense, but the cards should only be displayed if the clusterPreferencesAttempt isn't processing (and let the normal request do its thing as normal)
kimlisa
left a comment
There was a problem hiding this comment.
i'll help manually test with a leaf tomorrow
| {getPinnedResourcesAttempt.status === 'error' && ( | ||
| <ErrorBox> | ||
| <Danger>{getPinnedResourcesAttempt.statusText}</Danger> | ||
| </ErrorBox> | ||
| )} | ||
| {updatePinnedResourcesAttempt.status === 'error' && ( | ||
| <ErrorBox> | ||
| <Danger>{updatePinnedResourcesAttempt.statusText}</Danger> | ||
| </ErrorBox> | ||
| )} |
There was a problem hiding this comment.
maybe i'm thinking too much into it. if a get pinned failed, a user can just refresh the browser. but what about if updating a pin failed? because the UI looks like it succeeded with a failed banner (but if you refresh, the action we wanted to do disappears)
should we:
- undo the action?
- if pinning, unpin on failure?
- if unpinning, pin on failure?
- or have a
re-attemptbutton to do the same action the user wanted? - or a dialog that says pinning/unpinning failed, and either re-attemt, or cancel, and on cancel, undo action?
There was a problem hiding this comment.
I can just disable the button until the request succeeds and if it does, then update the state. that seems pretty standard for most network requests. that way we dont have to keep track of any previous state or actions
| ); | ||
|
|
||
| useEffect(() => { | ||
| getPinnedResources(); |
There was a problem hiding this comment.
We should also clean up the "update" attempt, so if you had an error on a cluster A and you switch to a cluster B you won't see the old error.
useEffect(() => {
setUpdatePinnedResources(makeEmptyAttempt());
getPinnedResources();
}, [clusterId, getPinnedResources, setUpdatePinnedResources]);
BTW I'm going to refactor it; when changing the cluster, the whole Resources component will be re-mounted and this cleanup logic will no longer be needed.
| clusterId: string, | ||
| pinnedResources: string[] | ||
| ) => { | ||
| await setTimeout(() => {}, 1000); |
| pinnedResources: string[] | ||
| ) => { | ||
| await setTimeout(() => {}, 1000); | ||
| let currentPrefs = { ...clusterPreferences[clusterId] }; |
There was a problem hiding this comment.
I realized that {...undefined} returns {} so if (currentPrefs) always returns true.
Since we are using useRef we can just mutate the value:
if (!clusterPreferences[clusterId]) {
clusterPreferences[clusterId] = {}
}
clusterPreferences.current[clusterId].pinnedResources = pinnnedResources;| updatePinnedResources, | ||
| setUpdatePinnedResources, | ||
| ] = useAsync( | ||
| useCallback( |
There was a problem hiding this comment.
We removed useCallback from updateClusterPinnedResources, we can remove it here as well.
| const loadingCardArray = new Array(FETCH_MORE_SIZE).fill(undefined); | ||
|
|
||
| export const PINNING_NOT_SUPPORTED_MESSAGE = | ||
| 'This cluster does not support pinning resources. To enable, upgrade to 14.1 or newer.'; |
There was a problem hiding this comment.
| 'This cluster does not support pinning resources. To enable, upgrade to 14.1 or newer.'; | |
| 'This cluster does not support pinning resources. To enable, upgrade to version 14.1 or newer.'; |
|
Ok, I think this should be it. Tested all the happy paths and error paths. Please break it! |
kimlisa
left a comment
There was a problem hiding this comment.
works great, thanks for all the changes
* parent b905eaf author Michael Myers <michael.myers@goteleport.com> 1694885352 -0500 committer Michael Myers <michael.myers@goteleport.com> 1696979801 -0500 gpgsig -----BEGIN PGP SIGNATURE----- iQGzBAABCAAdFiEETMffNTGCRZBrrPo/svAculE8l1QFAmUl21kACgkQsvAculE8 l1Qn3wwAjHFOvII2t68vDH4HD93nX3vxSDED9DLgjjFSCjklKTuWVC9Vxb+vO4kL XrbZxQiEN/Q10Z5DSXB1scn6SP2kgk9Ae2TMfZJskTMd9hLhZJVuhwzAW4/brj9t 3zIW4k7uBQF2sCGN7aG8+sSuNl43lacaJDN550MuGZwagRrgXXNDyNUdn7AzULVX PcMyKrcp5kuN+uWIRb7z1ZJR+s4m1wyI1MoX8ikUrBdWwk6bTFFXM3TvxFCtNb2j 8+ILDFWJ6HZ0k9Sx2ExQAbN9M76BQcWqE06BhuLQ9UTUIbw0pmk2j/DjlaOlZhjF LlHEjM8se3rO7Gqy5Kj1lPs6j3AIwUqfhEwE9pWRl10P1oCxv47MHhs9jfZFkihl yWUm7ey1Un36wl46F3/POdT4Fzf0PwavCRogcMIUo5J6xqKM47C3jY8szjympeJW lp7g0nvNhvgbGE0kW2FZQFmuQfZdGtiZ6gVPeIER9cKwJYWtiEOURqd6/R99j+q8 29I8CwLA =qgBc -----END PGP SIGNATURE----- Add pinned resources to the web UI * Fix merge conflict * Add abort controller * Use local abort controller * Fix lint and stories * Show loading cards on cluster preferences attempt * Replace clusterPrefs with useCallbacks * Add pinning disabled state * Fix lint * Remove unneeded useCallback * Remove not_supported flag if prefs exist in cache * Show pinned resources error if not supported
* parent b905eaf author Michael Myers <michael.myers@goteleport.com> 1694885352 -0500 committer Michael Myers <michael.myers@goteleport.com> 1696979801 -0500 gpgsig -----BEGIN PGP SIGNATURE----- iQGzBAABCAAdFiEETMffNTGCRZBrrPo/svAculE8l1QFAmUl21kACgkQsvAculE8 l1Qn3wwAjHFOvII2t68vDH4HD93nX3vxSDED9DLgjjFSCjklKTuWVC9Vxb+vO4kL XrbZxQiEN/Q10Z5DSXB1scn6SP2kgk9Ae2TMfZJskTMd9hLhZJVuhwzAW4/brj9t 3zIW4k7uBQF2sCGN7aG8+sSuNl43lacaJDN550MuGZwagRrgXXNDyNUdn7AzULVX PcMyKrcp5kuN+uWIRb7z1ZJR+s4m1wyI1MoX8ikUrBdWwk6bTFFXM3TvxFCtNb2j 8+ILDFWJ6HZ0k9Sx2ExQAbN9M76BQcWqE06BhuLQ9UTUIbw0pmk2j/DjlaOlZhjF LlHEjM8se3rO7Gqy5Kj1lPs6j3AIwUqfhEwE9pWRl10P1oCxv47MHhs9jfZFkihl yWUm7ey1Un36wl46F3/POdT4Fzf0PwavCRogcMIUo5J6xqKM47C3jY8szjympeJW lp7g0nvNhvgbGE0kW2FZQFmuQfZdGtiZ6gVPeIER9cKwJYWtiEOURqd6/R99j+q8 29I8CwLA =qgBc -----END PGP SIGNATURE----- Add pinned resources to the web UI * Fix merge conflict * Add abort controller * Use local abort controller * Fix lint and stories * Show loading cards on cluster preferences attempt * Replace clusterPrefs with useCallbacks * Add pinning disabled state * Fix lint * Remove unneeded useCallback * Remove not_supported flag if prefs exist in cache * Show pinned resources error if not supported


Closes #32075
In support of #30418
33c063688b9c4eb4edc158f284c0000b.mp4
This PR can be built and tested completely from this branch. It's a bit chunky but you can generally follow the commits. (the one adding the code to the UI is a bit large)
How to test.
You can deselect all by pressing "escape". Also, it is purposefully made to not deselect after performing a bulk action.