diff --git a/api/gen/proto/go/userpreferences/v1/unified_resource_preferences.pb.go b/api/gen/proto/go/userpreferences/v1/unified_resource_preferences.pb.go index 4b2839b40b414..7e531f94eccc3 100644 --- a/api/gen/proto/go/userpreferences/v1/unified_resource_preferences.pb.go +++ b/api/gen/proto/go/userpreferences/v1/unified_resource_preferences.pb.go @@ -86,6 +86,58 @@ func (DefaultTab) EnumDescriptor() ([]byte, []int) { return file_teleport_userpreferences_v1_unified_resource_preferences_proto_rawDescGZIP(), []int{0} } +// ViewMode is the view mode selected in the unified resource Web UI +type ViewMode int32 + +const ( + ViewMode_VIEW_MODE_UNSPECIFIED ViewMode = 0 + // CARD is the card view + ViewMode_VIEW_MODE_CARD ViewMode = 1 + // LIST is the list view + ViewMode_VIEW_MODE_LIST ViewMode = 2 +) + +// Enum value maps for ViewMode. +var ( + ViewMode_name = map[int32]string{ + 0: "VIEW_MODE_UNSPECIFIED", + 1: "VIEW_MODE_CARD", + 2: "VIEW_MODE_LIST", + } + ViewMode_value = map[string]int32{ + "VIEW_MODE_UNSPECIFIED": 0, + "VIEW_MODE_CARD": 1, + "VIEW_MODE_LIST": 2, + } +) + +func (x ViewMode) Enum() *ViewMode { + p := new(ViewMode) + *p = x + return p +} + +func (x ViewMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ViewMode) Descriptor() protoreflect.EnumDescriptor { + return file_teleport_userpreferences_v1_unified_resource_preferences_proto_enumTypes[1].Descriptor() +} + +func (ViewMode) Type() protoreflect.EnumType { + return &file_teleport_userpreferences_v1_unified_resource_preferences_proto_enumTypes[1] +} + +func (x ViewMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ViewMode.Descriptor instead. +func (ViewMode) EnumDescriptor() ([]byte, []int) { + return file_teleport_userpreferences_v1_unified_resource_preferences_proto_rawDescGZIP(), []int{1} +} + // UnifiedResourcePreferences are preferences used in the Unified Resource web UI type UnifiedResourcePreferences struct { state protoimpl.MessageState @@ -94,6 +146,8 @@ type UnifiedResourcePreferences struct { // default_tab is the default tab selected in the unified resource web UI DefaultTab DefaultTab `protobuf:"varint,1,opt,name=default_tab,json=defaultTab,proto3,enum=teleport.userpreferences.v1.DefaultTab" json:"default_tab,omitempty"` + // view_mode is the view mode selected in the unified resource Web UI + ViewMode ViewMode `protobuf:"varint,2,opt,name=view_mode,json=viewMode,proto3,enum=teleport.userpreferences.v1.ViewMode" json:"view_mode,omitempty"` } func (x *UnifiedResourcePreferences) Reset() { @@ -135,6 +189,13 @@ func (x *UnifiedResourcePreferences) GetDefaultTab() DefaultTab { return DefaultTab_DEFAULT_TAB_UNSPECIFIED } +func (x *UnifiedResourcePreferences) GetViewMode() ViewMode { + if x != nil { + return x.ViewMode + } + return ViewMode_VIEW_MODE_UNSPECIFIED +} + var File_teleport_userpreferences_v1_unified_resource_preferences_proto protoreflect.FileDescriptor var file_teleport_userpreferences_v1_unified_resource_preferences_proto_rawDesc = []byte{ @@ -143,25 +204,35 @@ var file_teleport_userpreferences_v1_unified_resource_preferences_proto_rawDesc 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1b, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, - 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x22, 0x66, 0x0a, - 0x1a, 0x55, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x48, 0x0a, 0x0b, 0x64, - 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x74, 0x61, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x27, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, - 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x54, 0x61, 0x62, 0x52, 0x0a, 0x64, 0x65, 0x66, 0x61, 0x75, - 0x6c, 0x74, 0x54, 0x61, 0x62, 0x2a, 0x56, 0x0a, 0x0a, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, - 0x54, 0x61, 0x62, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x5f, 0x54, - 0x41, 0x42, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, - 0x12, 0x13, 0x0a, 0x0f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x5f, 0x54, 0x41, 0x42, 0x5f, - 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, - 0x5f, 0x54, 0x41, 0x42, 0x5f, 0x50, 0x49, 0x4e, 0x4e, 0x45, 0x44, 0x10, 0x02, 0x42, 0x59, 0x5a, - 0x57, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, - 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2f, 0x67, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, - 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, - 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x22, 0xaa, 0x01, + 0x0a, 0x1a, 0x55, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x48, 0x0a, 0x0b, + 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x74, 0x61, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x27, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, + 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x54, 0x61, 0x62, 0x52, 0x0a, 0x64, 0x65, 0x66, 0x61, + 0x75, 0x6c, 0x74, 0x54, 0x61, 0x62, 0x12, 0x42, 0x0a, 0x09, 0x76, 0x69, 0x65, 0x77, 0x5f, 0x6d, + 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x74, 0x65, 0x6c, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x69, 0x65, 0x77, 0x4d, 0x6f, 0x64, 0x65, + 0x52, 0x08, 0x76, 0x69, 0x65, 0x77, 0x4d, 0x6f, 0x64, 0x65, 0x2a, 0x56, 0x0a, 0x0a, 0x44, 0x65, + 0x66, 0x61, 0x75, 0x6c, 0x74, 0x54, 0x61, 0x62, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x46, 0x41, + 0x55, 0x4c, 0x54, 0x5f, 0x54, 0x41, 0x42, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, + 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, + 0x5f, 0x54, 0x41, 0x42, 0x5f, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, + 0x46, 0x41, 0x55, 0x4c, 0x54, 0x5f, 0x54, 0x41, 0x42, 0x5f, 0x50, 0x49, 0x4e, 0x4e, 0x45, 0x44, + 0x10, 0x02, 0x2a, 0x4d, 0x0a, 0x08, 0x56, 0x69, 0x65, 0x77, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x19, + 0x0a, 0x15, 0x56, 0x49, 0x45, 0x57, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, + 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x56, 0x49, 0x45, + 0x57, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x43, 0x41, 0x52, 0x44, 0x10, 0x01, 0x12, 0x12, 0x0a, + 0x0e, 0x56, 0x49, 0x45, 0x57, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x4c, 0x49, 0x53, 0x54, 0x10, + 0x02, 0x42, 0x59, 0x5a, 0x57, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, + 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x70, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -176,19 +247,21 @@ func file_teleport_userpreferences_v1_unified_resource_preferences_proto_rawDesc return file_teleport_userpreferences_v1_unified_resource_preferences_proto_rawDescData } -var file_teleport_userpreferences_v1_unified_resource_preferences_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_teleport_userpreferences_v1_unified_resource_preferences_proto_enumTypes = make([]protoimpl.EnumInfo, 2) var file_teleport_userpreferences_v1_unified_resource_preferences_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_teleport_userpreferences_v1_unified_resource_preferences_proto_goTypes = []interface{}{ (DefaultTab)(0), // 0: teleport.userpreferences.v1.DefaultTab - (*UnifiedResourcePreferences)(nil), // 1: teleport.userpreferences.v1.UnifiedResourcePreferences + (ViewMode)(0), // 1: teleport.userpreferences.v1.ViewMode + (*UnifiedResourcePreferences)(nil), // 2: teleport.userpreferences.v1.UnifiedResourcePreferences } var file_teleport_userpreferences_v1_unified_resource_preferences_proto_depIdxs = []int32{ 0, // 0: teleport.userpreferences.v1.UnifiedResourcePreferences.default_tab:type_name -> teleport.userpreferences.v1.DefaultTab - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 1, // 1: teleport.userpreferences.v1.UnifiedResourcePreferences.view_mode:type_name -> teleport.userpreferences.v1.ViewMode + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_teleport_userpreferences_v1_unified_resource_preferences_proto_init() } @@ -215,7 +288,7 @@ func file_teleport_userpreferences_v1_unified_resource_preferences_proto_init() File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_teleport_userpreferences_v1_unified_resource_preferences_proto_rawDesc, - NumEnums: 1, + NumEnums: 2, NumMessages: 1, NumExtensions: 0, NumServices: 0, diff --git a/api/proto/teleport/userpreferences/v1/unified_resource_preferences.proto b/api/proto/teleport/userpreferences/v1/unified_resource_preferences.proto index 7b66ede935fa9..ce9da16775c1c 100644 --- a/api/proto/teleport/userpreferences/v1/unified_resource_preferences.proto +++ b/api/proto/teleport/userpreferences/v1/unified_resource_preferences.proto @@ -22,6 +22,8 @@ option go_package = "github.com/gravitational/teleport/api/gen/proto/go/userpref message UnifiedResourcePreferences { // default_tab is the default tab selected in the unified resource web UI DefaultTab default_tab = 1; + // view_mode is the view mode selected in the unified resource Web UI + ViewMode view_mode = 2; } // DefaultTab is the default tab selected in the unified resource web UI @@ -32,3 +34,12 @@ enum DefaultTab { // PINNED is only pinned resources DEFAULT_TAB_PINNED = 2; } + +// ViewMode is the view mode selected in the unified resource Web UI +enum ViewMode { + VIEW_MODE_UNSPECIFIED = 0; + // CARD is the card view + VIEW_MODE_CARD = 1; + // LIST is the list view + VIEW_MODE_LIST = 2; +} diff --git a/lib/auth/userpreferences/userpreferencesv1/service_test.go b/lib/auth/userpreferences/userpreferencesv1/service_test.go index 0d1f3f5c3dfcf..f8ae03e60ead1 100644 --- a/lib/auth/userpreferences/userpreferencesv1/service_test.go +++ b/lib/auth/userpreferences/userpreferencesv1/service_test.go @@ -60,6 +60,7 @@ func TestService_GetUserPreferences(t *testing.T) { Theme: userpreferencesv1.Theme_THEME_LIGHT, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_ALL, + ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_CARD, }, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{}, diff --git a/lib/services/local/userpreferences.go b/lib/services/local/userpreferences.go index 1da1a7d8147fc..a976e854f50bc 100644 --- a/lib/services/local/userpreferences.go +++ b/lib/services/local/userpreferences.go @@ -43,6 +43,7 @@ func DefaultUserPreferences() *userpreferencesv1.UserPreferences { Theme: userpreferencesv1.Theme_THEME_LIGHT, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_ALL, + ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_CARD, }, Onboard: &userpreferencesv1.OnboardUserPreferences{ PreferredResources: []userpreferencesv1.Resource{}, diff --git a/lib/services/local/userpreferences_test.go b/lib/services/local/userpreferences_test.go index a8436f77937f5..4caaf80e78b1c 100644 --- a/lib/services/local/userpreferences_test.go +++ b/lib/services/local/userpreferences_test.go @@ -121,6 +121,7 @@ func TestUserPreferencesCRUD(t *testing.T) { Theme: defaultPref.Theme, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_PINNED, + ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_CARD, }, ClusterPreferences: defaultPref.ClusterPreferences, }, @@ -230,6 +231,7 @@ func TestUserPreferencesCRUD(t *testing.T) { Theme: userpreferencesv1.Theme_THEME_LIGHT, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_PINNED, + ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_LIST, }, Assist: &userpreferencesv1.AssistUserPreferences{ PreferredLogins: []string{"baz"}, @@ -255,6 +257,7 @@ func TestUserPreferencesCRUD(t *testing.T) { Theme: userpreferencesv1.Theme_THEME_LIGHT, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ DefaultTab: userpreferencesv1.DefaultTab_DEFAULT_TAB_PINNED, + ViewMode: userpreferencesv1.ViewMode_VIEW_MODE_LIST, }, Assist: &userpreferencesv1.AssistUserPreferences{ PreferredLogins: []string{"baz"}, diff --git a/lib/web/userpreferences.go b/lib/web/userpreferences.go index 9e2113bdb32ef..b3f2585d3e562 100644 --- a/lib/web/userpreferences.go +++ b/lib/web/userpreferences.go @@ -52,6 +52,7 @@ type ClusterUserPreferencesResponse struct { type UnifiedResourcePreferencesResponse struct { DefaultTab userpreferencesv1.DefaultTab `json:"defaultTab"` + ViewMode userpreferencesv1.ViewMode `json:"viewMode"` } // UserPreferencesResponse is the JSON response for the user preferences. @@ -120,6 +121,7 @@ func makePreferenceRequest(req UserPreferencesResponse) *userpreferencesv1.Upser Theme: req.Theme, UnifiedResourcePreferences: &userpreferencesv1.UnifiedResourcePreferences{ DefaultTab: req.UnifiedResourcePreferences.DefaultTab, + ViewMode: req.UnifiedResourcePreferences.ViewMode, }, Assist: &userpreferencesv1.AssistUserPreferences{ PreferredLogins: req.Assist.PreferredLogins, @@ -199,6 +201,7 @@ func assistUserPreferencesResponse(resp *userpreferencesv1.AssistUserPreferences func unifiedResourcePreferencesResponse(resp *userpreferencesv1.UnifiedResourcePreferences) UnifiedResourcePreferencesResponse { return UnifiedResourcePreferencesResponse{ DefaultTab: resp.DefaultTab, + ViewMode: resp.ViewMode, } } diff --git a/web/packages/design/src/Checkbox/Checkbox.tsx b/web/packages/design/src/Checkbox/Checkbox.tsx index d939e418f2d93..e276d2844d8ef 100644 --- a/web/packages/design/src/Checkbox/Checkbox.tsx +++ b/web/packages/design/src/Checkbox/Checkbox.tsx @@ -76,5 +76,6 @@ export const StyledCheckbox = styled.input.attrs({ type: 'checkbox' })` color: ${props => props.theme.colors.levels.deep}; position: absolute; right: 1px; + top: -1px; } `; diff --git a/web/packages/design/src/Icon/Icons.story.tsx b/web/packages/design/src/Icon/Icons.story.tsx index b80cf5238adf6..debc0d506ec90 100644 --- a/web/packages/design/src/Icon/Icons.story.tsx +++ b/web/packages/design/src/Icon/Icons.story.tsx @@ -150,6 +150,7 @@ export const Icons = () => ( + @@ -157,6 +158,7 @@ export const Icons = () => ( + diff --git a/web/packages/design/src/Icon/Icons/Rows.tsx b/web/packages/design/src/Icon/Icons/Rows.tsx new file mode 100644 index 0000000000000..85e0a03d47a6e --- /dev/null +++ b/web/packages/design/src/Icon/Icons/Rows.tsx @@ -0,0 +1,66 @@ +/* +Copyright 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 + + http://www.apache.org/licenses/LICENSE-2.0 + +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. +*/ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function Rows({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + ); +} diff --git a/web/packages/design/src/Icon/Icons/SquaresFour.tsx b/web/packages/design/src/Icon/Icons/SquaresFour.tsx new file mode 100644 index 0000000000000..0fdc39a68116e --- /dev/null +++ b/web/packages/design/src/Icon/Icons/SquaresFour.tsx @@ -0,0 +1,81 @@ +/* +Copyright 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 + + http://www.apache.org/licenses/LICENSE-2.0 + +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. +*/ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function SquaresFour({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + + + ); +} diff --git a/web/packages/design/src/Icon/assets/Rows.svg b/web/packages/design/src/Icon/assets/Rows.svg new file mode 100644 index 0000000000000..e21fd0986e516 --- /dev/null +++ b/web/packages/design/src/Icon/assets/Rows.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/packages/design/src/Icon/assets/SquaresFour.svg b/web/packages/design/src/Icon/assets/SquaresFour.svg new file mode 100644 index 0000000000000..1fe966c62ad9b --- /dev/null +++ b/web/packages/design/src/Icon/assets/SquaresFour.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/packages/design/src/Icon/index.ts b/web/packages/design/src/Icon/index.ts index 0aee3a6e4bc6e..75e16f40e03cc 100644 --- a/web/packages/design/src/Icon/index.ts +++ b/web/packages/design/src/Icon/index.ts @@ -138,6 +138,7 @@ export { PushPinFilled } from './Icons/PushPinFilled'; export { Question } from './Icons/Question'; export { Refresh } from './Icons/Refresh'; export { Restore } from './Icons/Restore'; +export { Rows } from './Icons/Rows'; export { Server } from './Icons/Server'; export { Share } from './Icons/Share'; export { ShieldCheck } from './Icons/ShieldCheck'; @@ -145,6 +146,7 @@ export { Sliders } from './Icons/Sliders'; export { SlidersVertical } from './Icons/SlidersVertical'; export { Speed } from './Icons/Speed'; export { Spinner } from './Icons/Spinner'; +export { SquaresFour } from './Icons/SquaresFour'; export { Stars } from './Icons/Stars'; export { Sun } from './Icons/Sun'; export { SyncAlt } from './Icons/SyncAlt'; diff --git a/web/packages/design/src/theme/themes/sharedStyles.ts b/web/packages/design/src/theme/themes/sharedStyles.ts index c27d698cd1f1d..8fbf081cdae27 100644 --- a/web/packages/design/src/theme/themes/sharedStyles.ts +++ b/web/packages/design/src/theme/themes/sharedStyles.ts @@ -31,6 +31,7 @@ export const sharedStyles: SharedStyles = { '0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px rgba(0, 0, 0, 0.14), 0px 1px 3px rgba(0, 0, 0, 0.12)', '0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12)', '0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px rgba(0, 0, 0, 0.14), 0px 1px 18px rgba(0, 0, 0, 0.12)', + '0px 1px 10px 0px rgba(0, 0, 0, 0.12), 0px 4px 5px 0px rgba(0, 0, 0, 0.14), 0px 2px 4px -1px rgba(0, 0, 0, 0.20)', ], breakpoints: { mobile: 400 + sidebarWidth, diff --git a/web/packages/shared/components/ToolTip/HoverTooltip.tsx b/web/packages/shared/components/ToolTip/HoverTooltip.tsx new file mode 100644 index 0000000000000..26dbbc1454aed --- /dev/null +++ b/web/packages/shared/components/ToolTip/HoverTooltip.tsx @@ -0,0 +1,102 @@ +/** + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Popover, Flex, Text } from 'design'; + +export const HoverTooltip: React.FC<{ + tipContent: string | undefined; + showOnlyOnOverflow?: boolean; + className?: string; +}> = ({ tipContent, children, showOnlyOnOverflow = false, className }) => { + const [anchorEl, setAnchorEl] = useState(); + const open = Boolean(anchorEl); + + function handlePopoverOpen(event: React.MouseEvent) { + const { target } = event; + + if (showOnlyOnOverflow) { + // Calculate whether the content is overflowing the parent in order to determine + // whether we want to show the tooltip. + if ( + target instanceof Element && + target.scrollWidth > target.parentElement.offsetWidth + ) { + setAnchorEl(event.currentTarget); + } + return; + } + + setAnchorEl(event.currentTarget); + } + + function handlePopoverClose() { + setAnchorEl(null); + } + + // Don't render the tooltip if the content is undefined. + if (!tipContent) { + return <>{children}; + } + + return ( + + {children} + + + {tipContent} + + + + ); +}; + +const modalCss = () => ` + pointer-events: none; +`; + +const StyledOnHover = styled(Text)` + color: ${props => props.theme.colors.text.main}; + background-color: ${props => props.theme.colors.tooltip.background}; + max-width: 350px; +`; diff --git a/web/packages/shared/components/ToolTip/index.ts b/web/packages/shared/components/ToolTip/index.ts index f7132fff08675..e23fc0d274bc6 100644 --- a/web/packages/shared/components/ToolTip/index.ts +++ b/web/packages/shared/components/ToolTip/index.ts @@ -15,3 +15,4 @@ */ export { ToolTipInfo } from './ToolTip'; +export { HoverTooltip } from './HoverTooltip'; diff --git a/web/packages/shared/components/UnifiedResources/CardsView/CardsView.tsx b/web/packages/shared/components/UnifiedResources/CardsView/CardsView.tsx new file mode 100644 index 0000000000000..3c8c2e0cd8ddf --- /dev/null +++ b/web/packages/shared/components/UnifiedResources/CardsView/CardsView.tsx @@ -0,0 +1,71 @@ +/** + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { Flex } from 'design'; + +import { FETCH_MORE_SIZE } from '../UnifiedResources'; + +import { ResourceViewProps } from '../types'; + +import { LoadingSkeleton } from '../LoadingSkeleton'; +import { LoadingCard } from '../LoadingCard'; + +import { ResourceCard } from './ResourceCard'; + +export function CardsView({ + mappedResources, + onLabelClick, + pinnedResources, + selectedResources, + onSelectResource, + onPinResource, + isProcessing, + pinningSupport, +}: ResourceViewProps) { + return ( + + {mappedResources.map(({ item, key }) => ( + onSelectResource(key)} + pinResource={() => onPinResource(key)} + /> + ))} + {/* Using index as key here is ok because these elements never change order */} + {isProcessing && ( + } /> + )} + + ); +} + +const CardsContainer = styled(Flex)` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); +`; diff --git a/web/packages/shared/components/UnifiedResources/ResourceCard.story.tsx b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx similarity index 82% rename from web/packages/shared/components/UnifiedResources/ResourceCard.story.tsx rename to web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx index 2aee09524b66d..7119ba5bb2418 100644 --- a/web/packages/shared/components/UnifiedResources/ResourceCard.story.tsx +++ b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.story.tsx @@ -31,15 +31,17 @@ import { nodes } from 'teleport/Nodes/fixtures'; import makeApp from 'teleport/services/apps/makeApps'; -import { ResourceCard, PinningSupport } from './ResourceCard'; - import { - makeUnifiedResourceCardApp, - makeUnifiedResourceCardDatabase, - makeUnifiedResourceCardKube, - makeUnifiedResourceCardNode, - makeUnifiedResourceCardDesktop, -} from './cards'; + makeUnifiedResourceViewItemApp, + makeUnifiedResourceViewItemDatabase, + makeUnifiedResourceViewItemKube, + makeUnifiedResourceViewItemNode, + makeUnifiedResourceViewItemDesktop, +} from '../shared/viewItemsFactory'; + +import { PinningSupport } from '../types'; + +import { ResourceCard } from './ResourceCard'; const additionalResources = [ makeApp({ @@ -80,7 +82,7 @@ const additionalResources = [ const meta: Meta = { component: ResourceCard, - title: 'Shared/UnifiedResources/ResourceCard', + title: 'Shared/UnifiedResources/Items', }; const Grid = styled.div` @@ -100,24 +102,24 @@ export const Cards: Story = { {[ ...apps.map(resource => - makeUnifiedResourceCardApp(resource, { ActionButton }) + makeUnifiedResourceViewItemApp(resource, { ActionButton }) ), ...databases.map(resource => - makeUnifiedResourceCardDatabase(resource, { + makeUnifiedResourceViewItemDatabase(resource, { ActionButton, }) ), ...kubes.map(resource => - makeUnifiedResourceCardKube(resource, { ActionButton }) + makeUnifiedResourceViewItemKube(resource, { ActionButton }) ), ...nodes.map(resource => - makeUnifiedResourceCardNode(resource, { ActionButton }) + makeUnifiedResourceViewItemNode(resource, { ActionButton }) ), ...additionalResources.map(resource => - makeUnifiedResourceCardApp(resource, { ActionButton }) + makeUnifiedResourceViewItemApp(resource, { ActionButton }) ), ...desktops.map(resource => - makeUnifiedResourceCardDesktop(resource, { ActionButton }) + makeUnifiedResourceViewItemDesktop(resource, { ActionButton }) ), ].map((res, i) => ( diff --git a/web/packages/shared/components/UnifiedResources/ResourceCard.tsx b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.tsx similarity index 65% rename from web/packages/shared/components/UnifiedResources/ResourceCard.tsx rename to web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.tsx index 1c6007f00d84b..501cbaa5e4356 100644 --- a/web/packages/shared/components/UnifiedResources/ResourceCard.tsx +++ b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.tsx @@ -14,34 +14,27 @@ * limitations under the License. */ -import React, { - useCallback, - useState, - useEffect, - useLayoutEffect, - useRef, -} from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef } from 'react'; import styled, { css } from 'styled-components'; -import { Box, ButtonIcon, ButtonLink, Flex, Label, Text } from 'design'; -import copyToClipboard from 'design/utils/copyToClipboard'; +import { Box, ButtonLink, Flex, Label, Text } from 'design'; import { StyledCheckbox } from 'design/Checkbox'; -import { ResourceIcon, ResourceIconName } from 'design/ResourceIcon'; -import { Icon, Copy, Check, PushPinFilled, PushPin } from 'design/Icon'; +import { ResourceIcon } from 'design/ResourceIcon'; -import { - HoverTooltip, - PINNING_NOT_SUPPORTED_MESSAGE, -} from './UnifiedResources'; +import { makeLabelTag } from 'teleport/components/formatters'; -import type { ResourceLabel } from 'teleport/services/agents'; // Since we do a lot of manual resizing and some absolute positioning, we have +import { HoverTooltip } from 'shared/components/ToolTip'; + +import { ResourceItemProps } from '../types'; +import { PinButton } from '../shared/PinButton'; +import { CopyButton } from '../shared/CopyButton'; // Since we do a lot of manual resizing and some absolute positioning, we have // to put some layout constants in place here. -const labelRowHeight = 20; // px +const labelHeight = 20; // px const labelVerticalMargin = 1; // px -const labelHeight = labelRowHeight * labelVerticalMargin; +const labelRowHeight = labelHeight + labelVerticalMargin * 2; /** * This box serves twofold purpose: first, it prevents the underlying icon from @@ -53,40 +46,12 @@ const ResTypeIconBox = styled(Box)` line-height: 0; `; -export enum PinningSupport { - Supported = 'Supported', - /** - * Disables pinning functionality if a leaf cluster hasn't been upgraded yet. - * Shows an appropriate message on hover. - * */ - NotSupported = 'NotSupported', - /** Disables the pinning button. */ - Disabled = 'Disabled', - /** Hides the pinning button completely. */ - Hidden = 'Hidden', -} - -type Props = { - name: string; - primaryIconName: ResourceIconName; - SecondaryIcon: typeof Icon; - description: { primary?: string; secondary?: string }; - labels: ResourceLabel[]; - ActionButton: React.ReactElement; - onLabelClick?: (label: ResourceLabel) => void; - pinResource: () => void; - selectResource: () => void; - selected: boolean; - pinned: boolean; - pinningSupport: PinningSupport; -}; - export function ResourceCard({ name, primaryIconName, SecondaryIcon, onLabelClick, - description, + cardViewProps, ActionButton, labels, pinningSupport, @@ -94,17 +59,17 @@ export function ResourceCard({ pinResource, selectResource, selected, -}: Props) { +}: Omit) { + const { primaryDesc, secondaryDesc } = cardViewProps; + const [showMoreLabelsButton, setShowMoreLabelsButton] = useState(false); const [showAllLabels, setShowAllLabels] = useState(false); const [numMoreLabels, setNumMoreLabels] = useState(0); - const [isNameOverflowed, setIsNameOverflowed] = useState(false); const [hovered, setHovered] = useState(false); const innerContainer = useRef(null); const labelsInnerContainer = useRef(null); - const nameText = useRef(null); const collapseTimeout = useRef>(null); // This effect installs a resize observer whose purpose is to detect the size @@ -114,16 +79,6 @@ export function ResourceCard({ if (!labelsInnerContainer.current) return; const observer = new ResizeObserver(entries => { - // This check will let us know if the name text has overflowed. We do this - // to conditionally render a tooltip for only overflowed names - if ( - nameText.current?.scrollWidth > - nameText.current?.parentElement.offsetWidth - ) { - setIsNameOverflowed(true); - } else { - setIsNameOverflowed(false); - } const container = entries[0]; // We're taking labelRowHeight * 1.5 just in case some glitch adds or @@ -203,7 +158,7 @@ export function ResourceCard({ pinned={pinned} selected={selected} > - {selected ? 'Deselect' : 'Select'}}> + - + props.theme.space[9]}px; + transition: none; + left: 16px; + `} + > + + - {isNameOverflowed ? ( - {name}}> - - {name} - - - ) : ( - + + {name} - )} + - {hovered && } + {hovered && } {ActionButton} - {description.primary && ( - + {primaryDesc && ( + - {description.primary} + {primaryDesc} )} - {description.secondary && ( - + {secondaryDesc && ( + - {description.secondary} + {secondaryDesc} )} @@ -278,11 +237,10 @@ export function ResourceCard({ + {numMoreLabels} more {labels.map((label, i) => { - const { name, value } = label; - const labelText = `${name}: ${value}`; + const labelText = makeLabelTag(label); return ( onLabelClick?.(label)} kind="secondary" @@ -301,34 +259,6 @@ export function ResourceCard({ ); } -function CopyButton({ name }: { name: string }) { - const copySuccess = 'Copied!'; - const copyDefault = 'Click to copy'; - const copyAnchorEl = useRef(null); - const [copiedText, setCopiedText] = useState(copyDefault); - - const handleCopy = useCallback(() => { - setCopiedText(copySuccess); - copyToClipboard(name); - // Change to default text after 1 second - setTimeout(() => { - setCopiedText(copyDefault); - }, 1000); - }, [name]); - - return ( - {copiedText}}> - - {copiedText === copySuccess ? ( - - ) : ( - - )} - - - ); -} - /** * The outer container's purpose is to reserve horizontal space on the resource * grid. It holds the inner container that normally holds a regular layout of @@ -359,6 +289,20 @@ const CardOuterContainer = styled(Box)` ${CardContainer}:hover & { background-color: ${props => props.theme.colors.levels.surface}; + + // We use a pseudo element for the shadow with position: absolute in order to prevent + // the shadow from increasing the size of the layout and causing scrollbar flicker. + :after { + box-shadow: ${props => props.theme.boxShadow[3]}; + border-radius: ${props => props.theme.radii[3]}px; + content: ''; + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + } } `; @@ -376,6 +320,11 @@ const CardInnerContainer = styled(Flex)` ${props => props.theme.colors.spotBackground[0]}; border-radius: ${props => props.theme.radii[3]}px; background-color: ${props => getBackgroundColor(props)}; + + :hover { + // Make the border invisible instead of removing it, this is to prevent things from shifting due to the size change. + border: ${props => props.theme.borders[2]} rgba(0, 0, 0, 0); + } `; const getBackgroundColor = props => { @@ -411,6 +360,7 @@ const StyledLabel = styled(Label)` text-overflow: ellipsis; white-space: nowrap; cursor: pointer; + line-height: ${labelHeight - labelVerticalMargin}px; `; /** @@ -445,69 +395,3 @@ const MoreLabelsButton = styled(ButtonLink)` transition: visibility 0s; transition: background 150ms; `; - -function PinButton({ - pinned, - pinningSupport, - hovered, - setPinned, -}: { - pinned: boolean; - pinningSupport: PinningSupport; - hovered: boolean; - setPinned: (id: string) => void; -}) { - const copyAnchorEl = useRef(null); - const tipContent = getTipContent(pinningSupport, pinned); - - const shouldShowButton = - pinningSupport !== PinningSupport.Hidden && (pinned || hovered); - const shouldDisableButton = - pinningSupport === PinningSupport.Disabled || - pinningSupport === PinningSupport.NotSupported; - - const $content = pinned ? ( - - ) : ( - - ); - - return ( - props.theme.space[9]}px; - transition: none; - left: 16px; - `} - disabled={shouldDisableButton} - setRef={copyAnchorEl} - size={0} - onClick={setPinned} - > - {tipContent ? ( - {tipContent}}>{$content} - ) : ( - $content - )} - {tipContent}}> - - ); -} - -function getTipContent( - pinningSupport: PinningSupport, - pinned: boolean -): string { - switch (pinningSupport) { - case PinningSupport.NotSupported: - return PINNING_NOT_SUPPORTED_MESSAGE; - case PinningSupport.Supported: - return pinned ? 'Unpin' : 'Pin'; - default: - return ''; - } -} diff --git a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx index 2db3fcb524eb7..2c561db170ecd 100644 --- a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx +++ b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx @@ -21,9 +21,18 @@ import { SortDir } from 'design/DataTable/types'; import { Text, Flex, Box } from 'design'; import Menu, { MenuItem } from 'design/Menu'; import { StyledCheckbox } from 'design/Checkbox'; -import { ArrowUp, ArrowDown, ChevronDown } from 'design/Icon'; +import { + ArrowUp, + ArrowDown, + ChevronDown, + SquaresFour, + Rows, +} from 'design/Icon'; + +import { UnifiedViewModePreference } from 'teleport/services/userPreferences/types'; + +import { HoverTooltip } from 'shared/components/ToolTip'; -import { HoverTooltip } from './UnifiedResources'; import { SharedUnifiedResource, UnifiedResourcesQueryParams } from './types'; const kindToLabel: Record = { @@ -47,6 +56,8 @@ interface FilterPanelProps { selectVisible: () => void; selected: boolean; BulkActions?: React.ReactElement; + currentViewMode: UnifiedViewModePreference; + onSelectViewMode: (viewMode: UnifiedViewModePreference) => void; } export function FilterPanel({ @@ -56,6 +67,8 @@ export function FilterPanel({ selectVisible, selected, BulkActions, + currentViewMode, + onSelectViewMode, }: FilterPanelProps) { const { sort, kinds } = params; @@ -84,9 +97,7 @@ export function FilterPanel({ alignItems="center" > - {selected ? 'Deselect all' : 'Select all'}} - > + - + {BulkActions} + - Filter types}> + = props => { return ( - Sort by}> + props.theme.colors.spotBackground[0]}; + border-color: ${props => props.theme.colors.spotBackground[2]}; `} textTransform="none" size="small" @@ -344,7 +359,7 @@ const SortMenu: React.FC = props => { handleSelect('name')}>Name handleSelect('kind')}>Type - Sort direction}> + = props => { width: 0px; // remove extra width around the button icon border-top-left-radius: 0; border-bottom-left-radius: 0; - border-color: ${props => props.theme.colors.spotBackground[0]}; + border-color: ${props => props.theme.colors.spotBackground[2]}; `} size="small" > @@ -380,6 +395,78 @@ function kindArraysEqual(arr1: string[], arr2: string[]) { return true; } +function ViewModeSwitch({ + currentViewMode, + onSelectViewMode, +}: { + currentViewMode: UnifiedViewModePreference; + onSelectViewMode: (viewMode: UnifiedViewModePreference) => void; +}) { + return ( + + onSelectViewMode(UnifiedViewModePreference.Card)} + css={` + border-right: 1px solid + ${props => props.theme.colors.spotBackground[2]}; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + `} + > + + + onSelectViewMode(UnifiedViewModePreference.List)} + css={` + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + `} + > + + + + ); +} + +const ViewModeSwitchContainer = styled.div` + height: 22px; + width: 48px; + border: 1px solid ${props => props.theme.colors.spotBackground[2]}; + border-radius: 4px; + display: flex; + + .selected { + background-color: ${props => props.theme.colors.spotBackground[1]}; + + :hover { + background-color: ${props => props.theme.colors.spotBackground[1]}; + } + } +`; + +const ViewModeSwitchButton = styled.button` + height: 100%; + width: 50%; + overflow: hidden; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + background-color: transparent; + + :hover { + background-color: ${props => props.theme.colors.spotBackground[0]}; + } +`; + const FiltersExistIndicator = styled.div` position: absolute; top: -4px; diff --git a/web/packages/shared/components/UnifiedResources/ListView/ListView.tsx b/web/packages/shared/components/UnifiedResources/ListView/ListView.tsx new file mode 100644 index 0000000000000..61fc58a3d6185 --- /dev/null +++ b/web/packages/shared/components/UnifiedResources/ListView/ListView.tsx @@ -0,0 +1,62 @@ +/** + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +import React from 'react'; + +import { Flex, Box, Indicator } from 'design'; + +import { ResourceViewProps } from '../types'; + +import { ResourceListItem } from './ResourceListItem'; + +export function ListView({ + mappedResources, + onLabelClick, + pinnedResources, + selectedResources, + onSelectResource, + onPinResource, + pinningSupport, + isProcessing, +}: ResourceViewProps) { + return ( + + {mappedResources.map(({ item, key }) => ( + onSelectResource(key)} + pinResource={() => onPinResource(key)} + /> + ))} + {/* TODO (rudream): Add skeleton loader */} + {isProcessing && ( + + + + )} + + ); +} diff --git a/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.story.tsx b/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.story.tsx new file mode 100644 index 0000000000000..4077f569fff80 --- /dev/null +++ b/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.story.tsx @@ -0,0 +1,134 @@ +/** + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; + +import { ButtonBorder, Flex } from 'design'; + +import { apps } from 'teleport/Apps/fixtures'; +import { databases } from 'teleport/Databases/fixtures'; + +import { kubes } from 'teleport/Kubes/fixtures'; +import { desktops } from 'teleport/Desktops/fixtures'; +import { nodes } from 'teleport/Nodes/fixtures'; + +import makeApp from 'teleport/services/apps/makeApps'; + +import { + makeUnifiedResourceViewItemApp, + makeUnifiedResourceViewItemDatabase, + makeUnifiedResourceViewItemKube, + makeUnifiedResourceViewItemNode, + makeUnifiedResourceViewItemDesktop, +} from '../shared/viewItemsFactory'; + +import { PinningSupport } from '../types'; + +import { ResourceListItem } from './ResourceListItem'; + +const additionalResources = [ + makeApp({ + name: 'An application with an awfully long name that will be truncated', + uri: 'https://you.should.be.ashamed.of.yourself.for.picking.such.ungodly.long.domain.names/', + description: 'I love the smell of word wrapping in the morning.', + awsConsole: false, + labels: [ + { + name: 'some-rather-long-label-name', + value: + "I don't like to be labeled, do you? I find labels opressive. Label " + + "me, and I'll label you back. Or at least truncate my one.", + }, + ], + clusterId: 'one', + fqdn: 'jenkins.one', + }), + makeApp({ + name: 'An application with a lot of labels', + uri: 'http://localhost/', + labels: [ + { name: 'day1', value: 'a partridge in a pear tree' }, + { name: 'day2', value: 'two turtle doves' }, + { name: 'day3', value: 'three French hens' }, + { name: 'day4', value: 'four calling birds' }, + { name: 'day5', value: 'five gold rings' }, + { name: 'day6', value: 'six geese a-laying' }, + { name: 'day7', value: 'seven swans a-swimming' }, + { name: 'day8', value: 'eight maids a-milking' }, + { name: 'day9', value: 'nine ladies dancing' }, + { name: 'day10', value: 'ten lords a-leaping' }, + { name: 'day11', value: 'eleven pipers piping' }, + { name: 'day12', value: 'twelve drummers drumming' }, + ], + }), +]; + +const meta: Meta = { + component: ResourceListItem, + title: 'Shared/UnifiedResources/Items', +}; + +export default meta; +type Story = StoryObj; + +const ActionButton = Action; + +export const ListItems: Story = { + render() { + return ( + + {[ + ...apps.map(resource => + makeUnifiedResourceViewItemApp(resource, { ActionButton }) + ), + ...databases.map(resource => + makeUnifiedResourceViewItemDatabase(resource, { + ActionButton, + }) + ), + ...kubes.map(resource => + makeUnifiedResourceViewItemKube(resource, { ActionButton }) + ), + ...nodes.map(resource => + makeUnifiedResourceViewItemNode(resource, { ActionButton }) + ), + ...additionalResources.map(resource => + makeUnifiedResourceViewItemApp(resource, { ActionButton }) + ), + ...desktops.map(resource => + makeUnifiedResourceViewItemDesktop(resource, { ActionButton }) + ), + ].map((res, i) => ( + {}} + selectResource={() => {}} + selected={false} + pinningSupport={PinningSupport.Supported} + name={res.name} + primaryIconName={res.primaryIconName} + SecondaryIcon={res.SecondaryIcon} + listViewProps={res.listViewProps} + labels={res.labels} + ActionButton={res.ActionButton} + /> + ))} + + ); + }, +}; diff --git a/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.tsx b/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.tsx new file mode 100644 index 0000000000000..89cc40754163c --- /dev/null +++ b/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.tsx @@ -0,0 +1,335 @@ +/** + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; + +import { Box, ButtonIcon, Flex, Label, Text } from 'design'; +import { StyledCheckbox } from 'design/Checkbox'; +import { Tags } from 'design/Icon'; +import { ResourceIcon } from 'design/ResourceIcon'; + +import { makeLabelTag } from 'teleport/components/formatters'; + +import { HoverTooltip } from 'shared/components/ToolTip'; + +import { ResourceItemProps } from '../types'; +import { PinButton } from '../shared/PinButton'; +import { CopyButton } from '../shared/CopyButton'; + +export function ResourceListItem({ + name, + primaryIconName, + SecondaryIcon, + onLabelClick, + listViewProps, + ActionButton, + labels, + pinningSupport, + pinned, + pinResource, + selectResource, + selected, +}: Omit) { + const { description, resourceType, addr } = listViewProps; + + const [showLabels, setShowLabels] = useState(false); + const [hovered, setHovered] = useState(false); + + const showLabelsButton = labels.length > 0 && (hovered || showLabels); + + // Determines which column the resource type text should end at. + // We do this because if there is no address, or the labels button + // isn't showing, we want to let the resource type be able to extend + // and use the free space that's left. + const resourceTypeColumnEnd = () => { + if (!addr) { + if (!showLabelsButton) { + return 'grid-column-end: labels-btn;'; + } + return 'grid-column-end: address;'; + } + return ''; + }; + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {/* checkbox */} + + + + + {/* pin button */} + + + {/* icon */} + + + {/* name and description */} + + + + {name} + + + {description} + + + + {hovered && } + + + + {/* type */} + + + + + + + {resourceType} + + + + + {/* address */} + + + {addr} + + + + {/* show labels button */} + {showLabelsButton && ( + + setShowLabels(prevState => !prevState)} + className={showLabels ? 'active' : ''} + > + + + + )} + + {/* action button */} + + {ActionButton} + + + {/* labels */} + {showLabels && ( + + {labels.map((label, i) => { + const labelText = makeLabelTag(label); + // We can use the index i as the key since it will always be unique to this label. + return ( + + ); + })} + + )} + + + ); +} + +const ResTypeIconBox = styled(Box)` + line-height: 0; +`; + +const RowContainer = styled(Box)` + transition: all 150ms; + position: relative; + + :hover { + background-color: ${props => props.theme.colors.levels.surface}; + + // We use a pseudo element for the shadow with position: absolute in order to prevent + // the shadow from increasing the size of the layout and causing scrollbar flicker. + :after { + box-shadow: ${props => props.theme.boxShadow[3]}; + content: ''; + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + } + } +`; + +const RowInnerContainer = styled(Flex)` + display: grid; + grid-template-columns: 22px 24px 36px 2fr 1fr 1fr 32px min-content; + column-gap: ${props => props.theme.space[3]}px; + grid-template-rows: 56px min-content; + grid-template-areas: + 'checkbox pin icon name type address labels-btn button' + '. . labels labels labels labels labels labels'; + align-items: center; + height: 100%; + min-width: 100%; + padding-right: ${props => props.theme.space[3]}px; + padding-left: ${props => props.theme.space[3]}px; + + background-color: ${props => getBackgroundColor(props)}; + + border-bottom: ${props => props.theme.borders[2]} + ${props => props.theme.colors.spotBackground[0]}; + + :hover { + // Make the border invisible instead of removing it, this is to prevent things from shifting due to the size change. + border-bottom: ${props => props.theme.borders[2]} rgba(0, 0, 0, 0); + } +`; + +const getBackgroundColor = props => { + if (props.selected) { + return props.theme.colors.interactive.tonal.primary[2]; + } + if (props.pinned) { + return props.theme.colors.interactive.tonal.primary[0]; + } + return 'transparent'; +}; + +const Name = styled(Text)` + white-space: nowrap; + line-height: 20px; + font-weight: 300; +`; + +const Description = styled(Text)` + white-space: nowrap; + font-size: 12px; + color: ${props => props.theme.colors.text.muted}; +`; + +const ShowLabelsButton = styled(ButtonIcon)` + .active { + background: ${props => props.theme.colors.buttons.secondary.default}; + + &:hover, + &:focus { + background: ${props => props.theme.colors.buttons.secondary.hover}; + } + &:active { + background: ${props => props.theme.colors.buttons.secondary.active}; + } + } +`; diff --git a/web/packages/shared/components/UnifiedResources/ResourceTab.tsx b/web/packages/shared/components/UnifiedResources/ResourceTab.tsx index b2e41b1a98c0f..d0a1a31518d05 100644 --- a/web/packages/shared/components/UnifiedResources/ResourceTab.tsx +++ b/web/packages/shared/components/UnifiedResources/ResourceTab.tsx @@ -18,10 +18,9 @@ import React from 'react'; import styled from 'styled-components'; import { Box, Text } from 'design'; -import { - HoverTooltip, - PINNING_NOT_SUPPORTED_MESSAGE, -} from './UnifiedResources'; +import { HoverTooltip } from 'shared/components/ToolTip'; + +import { PINNING_NOT_SUPPORTED_MESSAGE } from './UnifiedResources'; export const ResourceTab = ({ title, @@ -43,7 +42,7 @@ export const ResourceTab = ({ if (disabled) { return ( - {PINNING_NOT_SUPPORTED_MESSAGE}}> + {$tab} ); diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx index 5b4eeae0234f9..4d98fc74a9df6 100644 --- a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx +++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { ButtonBorder } from 'design'; @@ -28,11 +28,17 @@ import { UrlResourcesParams } from 'teleport/config'; import { ResourcesResponse } from 'teleport/services/agents'; import { - UnifiedResources, + UnifiedResourcePreferences, + UnifiedTabPreference, + UnifiedViewModePreference, +} from 'teleport/services/userPreferences/types'; + +import { UnifiedResources, useUnifiedResourcesFetch } from './UnifiedResources'; +import { + SharedUnifiedResource, UnifiedResourcesPinning, - useUnifiedResourcesFetch, -} from './UnifiedResources'; -import { SharedUnifiedResource, UnifiedResourcesQueryParams } from './types'; + UnifiedResourcesQueryParams, +} from './types'; export default { title: 'Shared/UnifiedResources', @@ -86,6 +92,10 @@ const story = ({ ...params, }; return () => { + const [userPrefs, setUserPrefs] = useState({ + defaultTab: UnifiedTabPreference.All, + viewMode: UnifiedViewModePreference.Card, + }); const { fetch, attempt, resources } = useUnifiedResourcesFetch({ fetchFunc, }); @@ -101,7 +111,8 @@ const story = ({ params={mergedParams} setParams={() => undefined} pinning={pinning} - updateUnifiedResourcesPreferences={() => undefined} + unifiedResourcePreferences={userPrefs} + updateUnifiedResourcesPreferences={setUserPrefs} onLabelClick={() => undefined} NoResources={undefined} fetchResources={fetch} diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx index 02790287afa9d..45248eb2e40f1 100644 --- a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx +++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx @@ -23,7 +23,6 @@ import { ButtonLink, ButtonSecondary, Text, - Popover, ButtonBorder, } from 'design'; import { Icon, Magnifier, PushPin } from 'design/Icon'; @@ -34,9 +33,11 @@ import './unifiedStyles.css'; import { ResourcesResponse, ResourceLabel } from 'teleport/services/agents'; import { UnifiedTabPreference, + UnifiedViewModePreference, UnifiedResourcePreferences, } from 'teleport/services/userPreferences/types'; +import { HoverTooltip } from 'shared/components/ToolTip'; import { makeEmptyAttempt, makeSuccessAttempt, @@ -50,26 +51,23 @@ import { } from 'shared/hooks/useInfiniteScroll'; import { Attempt } from 'shared/hooks/useAttemptNext'; -import { SharedUnifiedResource, UnifiedResourcesQueryParams } from './types'; import { - makeUnifiedResourceCardNode, - makeUnifiedResourceCardDatabase, - makeUnifiedResourceCardKube, - makeUnifiedResourceCardApp, - makeUnifiedResourceCardDesktop, - makeUnifiedResourceCardUserGroup, -} from './cards'; + SharedUnifiedResource, + PinningSupport, + UnifiedResourcesPinning, + UnifiedResourcesQueryParams, +} from './types'; import { ResourceTab } from './ResourceTab'; -import { ResourceCard, PinningSupport } from './ResourceCard'; import { FilterPanel } from './FilterPanel'; -import { LoadingSkeleton } from './LoadingSkeleton'; -import { LoadingCard } from './LoadingCard'; +import { CardsView } from './CardsView/CardsView'; +import { ListView } from './ListView/ListView'; +import { mapResourceToViewItem } from './shared/viewItemsFactory'; // get 48 resources to start const INITIAL_FETCH_SIZE = 48; // increment by 24 every fetch -const FETCH_MORE_SIZE = 24; +export const FETCH_MORE_SIZE = 24; export const PINNING_NOT_SUPPORTED_MESSAGE = 'This cluster does not support pinning resources. To enable, upgrade to 14.1 or newer.'; @@ -85,20 +83,6 @@ const tabs: { label: string; value: UnifiedTabPreference }[] = [ }, ]; -export type UnifiedResourcesPinning = - | { - kind: 'supported'; - /** `getClusterPinnedResources` has to be stable, it is used in `useEffect`. */ - getClusterPinnedResources(): Promise; - updateClusterPinnedResources(pinned: string[]): Promise; - } - | { - kind: 'not-supported'; - } - | { - kind: 'hidden'; - }; - /* * BulkAction describes a component that allows you to perform an action * on multiple selected resources @@ -147,6 +131,7 @@ interface UnifiedResourcesProps { onLabelClick(label: ResourceLabel): void; /** A list of actions that can be performed on the selected items. */ bulkActions?: BulkAction[]; + unifiedResourcePreferences: UnifiedResourcePreferences; updateUnifiedResourcesPreferences( preferences: UnifiedResourcePreferences ): void; @@ -162,6 +147,7 @@ export function UnifiedResources(props: UnifiedResourcesProps) { onLabelClick, availableKinds, pinning, + unifiedResourcePreferences, updateUnifiedResourcesPreferences, bulkActions = [], } = props; @@ -230,7 +216,7 @@ export function UnifiedResources(props: UnifiedResourcesProps) { pinnedResources.includes(resource) ); - const handleSelectResources = (resourceId: string) => { + const handleSelectResource = (resourceId: string) => { setSelectedResources(prevResources => { if (selectedResources.includes(resourceId)) { return prevResources.filter(i => i !== resourceId); @@ -290,7 +276,17 @@ export function UnifiedResources(props: UnifiedResourcesProps) { }); setSelectedResources([]); setUpdatePinnedResources(makeEmptyAttempt()); - updateUnifiedResourcesPreferences({ defaultTab: value }); + updateUnifiedResourcesPreferences({ + ...unifiedResourcePreferences, + defaultTab: value, + }); + }; + + const selectViewMode = (viewMode: UnifiedViewModePreference) => { + updateUnifiedResourcesPreferences({ + ...unifiedResourcePreferences, + viewMode, + }); }; const getSelectedResources = () => { @@ -327,6 +323,11 @@ export function UnifiedResources(props: UnifiedResourcesProps) { ]; }; + const ViewComponent = + unifiedResourcePreferences.viewMode === UnifiedViewModePreference.List + ? ListView + : CardsView; + return (
{selectedResources.length > 0 && ( @@ -396,14 +399,11 @@ export function UnifiedResources(props: UnifiedResourcesProps) { {text} ); - if (tooltip) { - return ( - {tooltip}}> - {$button} - - ); - } - return $button; + return ( + + {$button} + + ); } )} @@ -434,41 +434,25 @@ export function UnifiedResources(props: UnifiedResourcesProps) { {pinning.kind === 'not-supported' && params.pinnedOnly ? ( ) : ( - - {resources - .map(unifiedResource => ({ - card: mapResourceToCard(unifiedResource), - key: generateUnifiedResourceKey(unifiedResource.resource), - })) - .map(({ card, key }) => ( - handleSelectResources(key)} - pinResource={() => handlePinResource(key)} - /> - ))} - {/* Using index as key here is ok because these elements never change order */} - {(resourcesFetchAttempt.status === 'processing' || - getPinnedResourcesAttempt.status === 'processing') && ( - } - /> + + isProcessing={ + resourcesFetchAttempt.status === 'processing' || + getPinnedResourcesAttempt.status === 'processing' + } + mappedResources={resources.map(unifiedResource => ({ + item: mapResourceToViewItem(unifiedResource), + key: generateUnifiedResourceKey(unifiedResource.resource), + }))} + /> )}
@@ -520,7 +504,7 @@ function getResourcePinningSupport( return PinningSupport.Supported; } -export function generateUnifiedResourceKey( +function generateUnifiedResourceKey( resource: SharedUnifiedResource['resource'] ): string { if (resource.kind === 'node') { @@ -586,11 +570,6 @@ function NoResults({ return null; } -const ResourcesContainer = styled(Flex)` - display: grid; - grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); -`; - const ErrorBox = styled(Box)` position: sticky; top: 0; @@ -617,75 +596,3 @@ const ListFooter = styled.div` min-height: ${INDICATOR_SIZE}; text-align: center; `; - -// TODO (avatus) extract to the shared package in ToolTip -export const HoverTooltip: React.FC<{ - tipContent: React.ReactElement; - fontSize?: number; -}> = ({ tipContent, fontSize = 10, children }) => { - const [anchorEl, setAnchorEl] = useState(); - const open = Boolean(anchorEl); - - function handlePopoverOpen(event) { - setAnchorEl(event.currentTarget); - } - - function handlePopoverClose() { - setAnchorEl(null); - } - - return ( - - {children} - - - {tipContent} - - - - ); -}; - -const modalCss = () => ` - pointer-events: none; -`; - -const StyledOnHover = styled(Text)` - color: ${props => props.theme.colors.text.main}; - background-color: ${props => props.theme.colors.tooltip.background}; - max-width: 350px; -`; - -function mapResourceToCard({ resource, ui }: SharedUnifiedResource) { - switch (resource.kind) { - case 'node': - return makeUnifiedResourceCardNode(resource, ui); - case 'db': - return makeUnifiedResourceCardDatabase(resource, ui); - case 'kube_cluster': - return makeUnifiedResourceCardKube(resource, ui); - case 'app': - return makeUnifiedResourceCardApp(resource, ui); - case 'windows_desktop': - return makeUnifiedResourceCardDesktop(resource, ui); - case 'user_group': - return makeUnifiedResourceCardUserGroup(resource, ui); - } -} diff --git a/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx new file mode 100644 index 0000000000000..cba518e47c8c6 --- /dev/null +++ b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx @@ -0,0 +1,78 @@ +/** + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +import React, { useState, useRef, useEffect } from 'react'; + +import ButtonIcon from 'design/ButtonIcon'; +import { Check, Copy } from 'design/Icon'; +import copyToClipboard from 'design/utils/copyToClipboard'; + +import { HoverTooltip } from 'shared/components/ToolTip'; + +export function CopyButton({ + name, + mr, + ml, +}: { + name: string; + mr?: number; + ml?: number; +}) { + const copySuccess = 'Copied!'; + const copyDefault = 'Click to copy'; + const timeout = useRef>(); + const copyAnchorEl = useRef(null); + const [copiedText, setCopiedText] = useState(copyDefault); + + const clearCurrentTimeout = () => { + if (timeout.current) { + clearTimeout(timeout.current); + timeout.current = undefined; + } + }; + + const handleCopy = () => { + clearCurrentTimeout(); + setCopiedText(copySuccess); + copyToClipboard(name); + // Change to default text after 1 second + timeout.current = setTimeout(() => { + setCopiedText(copyDefault); + }, 1000); + }; + + useEffect(() => { + return () => clearCurrentTimeout(); + }, []); + + return ( + + + {copiedText === copySuccess ? ( + + ) : ( + + )} + + + ); +} diff --git a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx new file mode 100644 index 0000000000000..f1e382ebf35ca --- /dev/null +++ b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx @@ -0,0 +1,90 @@ +/** + * Copyright 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + */ + +import React, { useRef } from 'react'; + +import { PushPinFilled, PushPin } from 'design/Icon'; +import ButtonIcon from 'design/ButtonIcon'; + +import { HoverTooltip } from 'shared/components/ToolTip'; + +import { PinningSupport } from '../types'; + +import { PINNING_NOT_SUPPORTED_MESSAGE } from '../UnifiedResources'; + +export function PinButton({ + pinned, + pinningSupport, + hovered, + setPinned, + className, +}: { + pinned: boolean; + pinningSupport: PinningSupport; + hovered: boolean; + setPinned: () => void; + className?: string; +}) { + const copyAnchorEl = useRef(null); + const tipContent = getTipContent(pinningSupport, pinned); + + const shouldShowButton = + pinningSupport !== PinningSupport.Hidden && (pinned || hovered); + const shouldDisableButton = + pinningSupport === PinningSupport.Disabled || + pinningSupport === PinningSupport.NotSupported; + + const $content = pinned ? ( + + ) : ( + + ); + + return ( + + {tipContent && shouldShowButton ? ( + {$content} + ) : ( + $content + )} + + + ); +} + +function getTipContent( + pinningSupport: PinningSupport, + pinned: boolean +): string { + switch (pinningSupport) { + case PinningSupport.NotSupported: + return PINNING_NOT_SUPPORTED_MESSAGE; + case PinningSupport.Supported: + return pinned ? 'Unpin' : 'Pin'; + default: + return ''; + } +} diff --git a/web/packages/shared/components/UnifiedResources/cards.ts b/web/packages/shared/components/UnifiedResources/shared/viewItemsFactory.ts similarity index 63% rename from web/packages/shared/components/UnifiedResources/cards.ts rename to web/packages/shared/components/UnifiedResources/shared/viewItemsFactory.ts index 336a856306614..552a0f759c3b6 100644 --- a/web/packages/shared/components/UnifiedResources/cards.ts +++ b/web/packages/shared/components/UnifiedResources/shared/viewItemsFactory.ts @@ -14,133 +14,151 @@ * limitations under the License. */ -import React from 'react'; -import { ResourceIconName } from 'design/ResourceIcon'; - import { - Icon, Application as ApplicationIcon, Database as DatabaseIcon, Kubernetes as KubernetesIcon, Server as ServerIcon, Desktop as DesktopIcon, } from 'design/Icon'; +import { ResourceIconName } from 'design/ResourceIcon'; import { DbProtocol } from 'shared/services/databases'; import { NodeSubKind } from 'shared/services'; import { - UnifiedResourceKube, - UnifiedResourceNode, + UnifiedResourceViewItem, UnifiedResourceUi, - UnifiedResourceDatabase, + UnifiedResourceNode, UnifiedResourceApp, - UnifiedResourceUserGroup, + UnifiedResourceDatabase, UnifiedResourceDesktop, -} from './types'; - -export interface UnifiedResourceCard { - name: string; - description: { - primary?: string; - secondary?: string; - }; - labels: { - name: string; - value: string; - }[]; - primaryIconName: ResourceIconName; - SecondaryIcon: typeof Icon; - ActionButton: React.ReactElement; -} + UnifiedResourceKube, + UnifiedResourceUserGroup, + SharedUnifiedResource, +} from '../types'; -export function makeUnifiedResourceCardNode( +export function makeUnifiedResourceViewItemNode( resource: UnifiedResourceNode, ui: UnifiedResourceUi -): UnifiedResourceCard { +): UnifiedResourceViewItem { + const nodeSubKind = formatNodeSubKind(resource.subKind); + const addressIfNotTunnel = resource.tunnel ? '' : resource.addr; + return { name: resource.hostname, SecondaryIcon: ServerIcon, primaryIconName: 'Server', ActionButton: ui.ActionButton, labels: resource.labels, - description: { - primary: formatNodeSubKind(resource.subKind), - secondary: resource.tunnel ? '' : resource.addr, + cardViewProps: { + primaryDesc: nodeSubKind, + secondaryDesc: addressIfNotTunnel, + }, + listViewProps: { + resourceType: nodeSubKind, + addr: addressIfNotTunnel, }, }; } -export function makeUnifiedResourceCardDatabase( +export function makeUnifiedResourceViewItemDatabase( resource: UnifiedResourceDatabase, ui: UnifiedResourceUi -): UnifiedResourceCard { +): UnifiedResourceViewItem { return { name: resource.name, SecondaryIcon: DatabaseIcon, primaryIconName: getDatabaseIconName(resource.protocol), ActionButton: ui.ActionButton, labels: resource.labels, - description: { primary: resource.type, secondary: resource.description }, + listViewProps: { + description: resource.description, + resourceType: resource.type, + }, + cardViewProps: { + primaryDesc: resource.type, + secondaryDesc: resource.description, + }, }; } -export function makeUnifiedResourceCardKube( +export function makeUnifiedResourceViewItemKube( resource: UnifiedResourceKube, ui: UnifiedResourceUi -): UnifiedResourceCard { +): UnifiedResourceViewItem { return { name: resource.name, SecondaryIcon: KubernetesIcon, primaryIconName: 'Kube', ActionButton: ui.ActionButton, labels: resource.labels, - description: { primary: 'Kubernetes' }, + cardViewProps: { + primaryDesc: 'Kubernetes', + }, + listViewProps: { + resourceType: 'Kubernetes', + }, }; } -export function makeUnifiedResourceCardApp( +export function makeUnifiedResourceViewItemApp( resource: UnifiedResourceApp, ui: UnifiedResourceUi -): UnifiedResourceCard { +): UnifiedResourceViewItem { return { name: resource.name, SecondaryIcon: ApplicationIcon, primaryIconName: guessAppIcon(resource), ActionButton: ui.ActionButton, labels: resource.labels, - description: { - primary: resource.description, - secondary: resource.addrWithProtocol, + cardViewProps: { + primaryDesc: resource.description, + secondaryDesc: resource.addrWithProtocol, + }, + listViewProps: { + resourceType: resource.samlApp ? 'SAML Application' : 'Application', + description: resource.samlApp ? '' : resource.description, + addr: resource.addrWithProtocol, }, }; } -export function makeUnifiedResourceCardDesktop( +export function makeUnifiedResourceViewItemDesktop( resource: UnifiedResourceDesktop, ui: UnifiedResourceUi -): UnifiedResourceCard { +): UnifiedResourceViewItem { return { name: resource.name, SecondaryIcon: DesktopIcon, primaryIconName: 'Windows', ActionButton: ui.ActionButton, labels: resource.labels, - description: { primary: 'Windows', secondary: resource.addr }, + cardViewProps: { + primaryDesc: 'Windows', + secondaryDesc: resource.addr, + }, + listViewProps: { + resourceType: 'Windows', + addr: resource.addr, + }, }; } -export function makeUnifiedResourceCardUserGroup( +export function makeUnifiedResourceViewItemUserGroup( resource: UnifiedResourceUserGroup, ui: UnifiedResourceUi -): UnifiedResourceCard { +): UnifiedResourceViewItem { return { name: resource.name, SecondaryIcon: ServerIcon, primaryIconName: 'Server', ActionButton: ui.ActionButton, labels: resource.labels, - description: {}, + cardViewProps: {}, + listViewProps: { + resourceType: 'User Group', + }, }; } @@ -209,3 +227,20 @@ function getDatabaseIconName(protocol: DbProtocol): ResourceIconName { return 'Database'; } } + +export function mapResourceToViewItem({ resource, ui }: SharedUnifiedResource) { + switch (resource.kind) { + case 'node': + return makeUnifiedResourceViewItemNode(resource, ui); + case 'db': + return makeUnifiedResourceViewItemDatabase(resource, ui); + case 'kube_cluster': + return makeUnifiedResourceViewItemKube(resource, ui); + case 'app': + return makeUnifiedResourceViewItemApp(resource, ui); + case 'windows_desktop': + return makeUnifiedResourceViewItemDesktop(resource, ui); + case 'user_group': + return makeUnifiedResourceViewItemUserGroup(resource, ui); + } +} diff --git a/web/packages/shared/components/UnifiedResources/types.ts b/web/packages/shared/components/UnifiedResources/types.ts index 377ccbecfa58a..84663db1b685b 100644 --- a/web/packages/shared/components/UnifiedResources/types.ts +++ b/web/packages/shared/components/UnifiedResources/types.ts @@ -18,6 +18,9 @@ import React from 'react'; import { ResourceLabel } from 'teleport/services/agents'; +import { ResourceIconName } from 'design/ResourceIcon'; +import { Icon } from 'design/Icon'; + import { DbProtocol } from 'shared/services/databases'; import { NodeSubKind } from 'shared/services'; @@ -30,6 +33,7 @@ export type UnifiedResourceApp = { awsConsole: boolean; addrWithProtocol?: string; friendlyName?: string; + samlApp: boolean; }; export interface UnifiedResourceDatabase { @@ -100,3 +104,84 @@ export type UnifiedResourcesQueryParams = { // TODO(bl-nero): Remove this once filters are expressed as advanced search. kinds?: string[]; }; +export interface UnifiedResourceViewItem { + name: string; + labels: { + name: string; + value: string; + }[]; + primaryIconName: ResourceIconName; + SecondaryIcon: typeof Icon; + ActionButton: React.ReactElement; + cardViewProps: CardViewSpecificProps; + listViewProps: ListViewSpecificProps; +} + +export enum PinningSupport { + Supported = 'Supported', + /** + * Disables pinning functionality if a leaf cluster hasn't been upgraded yet. + * Shows an appropriate message on hover. + * */ + NotSupported = 'NotSupported', + /** Disables the pinning button. */ + Disabled = 'Disabled', + /** Hides the pinning button completely. */ + Hidden = 'Hidden', +} + +export type ResourceItemProps = { + name: string; + primaryIconName: ResourceIconName; + SecondaryIcon: typeof Icon; + cardViewProps: CardViewSpecificProps; + listViewProps: ListViewSpecificProps; + labels: ResourceLabel[]; + ActionButton: React.ReactElement; + onLabelClick?: (label: ResourceLabel) => void; + pinResource: () => void; + selectResource: () => void; + selected: boolean; + pinned: boolean; + pinningSupport: PinningSupport; +}; + +// Props that are needed for the Card view. +// The reason we need this separately defined is because unlike with the list view, what we display in the +// description sections of a card varies based on the type of its resource. For example, for applications, +// instead of showing the `Application` type under the name like we would for other resources, we show the description. +type CardViewSpecificProps = { + primaryDesc?: string; + secondaryDesc?: string; +}; + +type ListViewSpecificProps = { + description?: string; + addr?: string; + resourceType: string; +}; + +export type UnifiedResourcesPinning = + | { + kind: 'supported'; + /** `getClusterPinnedResources` has to be stable, it is used in `useEffect`. */ + getClusterPinnedResources(): Promise; + updateClusterPinnedResources(pinned: string[]): Promise; + } + | { + kind: 'not-supported'; + } + | { + kind: 'hidden'; + }; + +export type ResourceViewProps = { + onLabelClick: (label: ResourceLabel) => void; + pinnedResources: string[]; + selectedResources: string[]; + onSelectResource: (resourceId: string) => void; + onPinResource: (resourceId: string) => void; + pinningSupport: PinningSupport; + isProcessing: boolean; + mappedResources: { item: UnifiedResourceViewItem; key: string }[]; +}; diff --git a/web/packages/shared/components/UnifiedResources/unifiedStyles.css b/web/packages/shared/components/UnifiedResources/unifiedStyles.css index 384769cb96fc7..67e72bbf99ef3 100644 --- a/web/packages/shared/components/UnifiedResources/unifiedStyles.css +++ b/web/packages/shared/components/UnifiedResources/unifiedStyles.css @@ -23,6 +23,18 @@ container-type: inline-size; } +.CardsContainer { + @container (min-width: 1600px) { + grid-template-columns: repeat(4, minmax(400px, 1fr)); + } +} + +.ListContainer { + display: flex; + flex-direction: column; + width: 100%; +} + .SearchPanel { width: 100%; @container (min-width: 800px) { diff --git a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx index c988957a66c37..1fecd08886e52 100644 --- a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx +++ b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx @@ -20,8 +20,8 @@ import { Flex } from 'design'; import { UnifiedResources as SharedUnifiedResources, - UnifiedResourcesPinning, useUnifiedResourcesFetch, + UnifiedResourcesPinning, } from 'shared/components/UnifiedResources'; import useStickyClusterId from 'teleport/useStickyClusterId'; @@ -176,6 +176,7 @@ function ClusterResources({ params={params} fetchResources={fetch} resourcesFetchAttempt={attempt} + unifiedResourcePreferences={preferences.unifiedResourcePreferences} updateUnifiedResourcesPreferences={preferences => { updatePreferences({ unifiedResourcePreferences: preferences }); }} diff --git a/web/packages/teleport/src/services/userPreferences/types.ts b/web/packages/teleport/src/services/userPreferences/types.ts index c0dc727c757c8..24d5e6ad4466e 100644 --- a/web/packages/teleport/src/services/userPreferences/types.ts +++ b/web/packages/teleport/src/services/userPreferences/types.ts @@ -28,6 +28,11 @@ export enum UnifiedTabPreference { Pinned = 2, } +export enum UnifiedViewModePreference { + Card = 1, + List = 2, +} + export enum ClusterResource { RESOURCE_UNSPECIFIED = 0, RESOURCE_WINDOWS_DESKTOPS = 1, @@ -68,6 +73,8 @@ export interface UserClusterPreferences { export interface UnifiedResourcePreferences { // defaultTab is the default tab selected in the unified resource view defaultTab: UnifiedTabPreference; + // viewMode is the view mode selected in the unified resource view (Card/List). + viewMode: UnifiedViewModePreference; } export type GetUserClusterPreferencesResponse = UserClusterPreferences; diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.ts index 4860ab1e1dc35..4b625b513d385 100644 --- a/web/packages/teleport/src/services/userPreferences/userPreferences.ts +++ b/web/packages/teleport/src/services/userPreferences/userPreferences.ts @@ -21,6 +21,7 @@ import { ViewMode } from 'teleport/Assist/types'; import { ThemePreference, UnifiedTabPreference, + UnifiedViewModePreference, } from 'teleport/services/userPreferences/types'; import { KeysEnum } from '../localStorage'; @@ -92,6 +93,7 @@ export function makeDefaultUserPreferences(): UserPreferences { }, unifiedResourcePreferences: { defaultTab: UnifiedTabPreference.All, + viewMode: UnifiedViewModePreference.Card, }, clusterPreferences: makeDefaultUserClusterPreferences(), };