diff --git a/.env.example b/.env.example index 6a28372..4b60743 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,5 @@ PGSQL_DATABASE=widget-layout # notsecret TEST_MODE=false -BASE_LAYOUTS='[{"name":"landing-landingPage","displayName":"LandingPage","templateConfig":{"sm":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"rhel#rhel"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":1,"i":"openshift#openshift"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":2,"i":"ansible#ansible"},{"w":1,"h":5,"maxH":10,"minH":1,"cx":0,"cy":3,"i":"recentlyVisited#recentlyVisited"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"favoriteServices#favoriteServices"},{"w":1,"h":6,"maxH":10,"minH":1,"cx":0,"cy":5,"i":"subscriptions#subscriptions"},{"w":1,"h":13,"maxH":13,"minH":1,"cx":0,"cy":6,"i":"exploreCapabilities#exploreCapabilities"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":7,"i":"openshiftAi#openshiftAi"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":8,"i":"imageBuilder#imageBuilder"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":9,"i":"acs#acs"}],"md":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"rhel#rhel"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":1,"i":"openshift#openshift"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":2,"i":"ansible#ansible"},{"w":1,"h":3,"maxH":10,"minH":1,"cx":1,"cy":0,"i":"recentlyVisited#recentlyVisited"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":1,"i":"favoriteServices#favoriteServices"},{"w":1,"h":5,"maxH":10,"minH":1,"cx":1,"cy":2,"i":"subscriptions#subscriptions"},{"w":2,"h":6,"maxH":10,"minH":1,"cx":0,"cy":3,"i":"exploreCapabilities#exploreCapabilities"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"imageBuilder#imageBuilder"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":4,"i":"openshiftAi#openshiftAi"},{"w":2,"h":3,"maxH":10,"minH":1,"cx":1,"cy":3,"i":"acs#acs"}],"lg":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"rhel#rhel"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":0,"i":"openshift#openshift"},{"w":2,"h":4,"maxH":10,"minH":1,"cx":0,"cy":1,"i":"ansible#ansible"},{"w":2,"h":6,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"exploreCapabilities#exploreCapabilities"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":0,"i":"recentlyVisited#recentlyVisited"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":2,"i":"favoriteServices#favoriteServices"},{"w":1,"h":6,"maxH":10,"minH":1,"cx":2,"cy":3,"i":"subscriptions#subscriptions"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"openshiftAi#openshiftAi"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":4,"i":"imageBuilder#imageBuilder"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":4,"i":"acs#acs"}],"xl":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"rhel#rhel"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":0,"i":"openshift#openshift"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":0,"i":"ansible#ansible"},{"w":3,"h":6,"maxH":10,"minH":1,"cx":0,"cy":2,"i":"exploreCapabilities#exploreCapabilities"},{"w":1,"h":3,"maxH":10,"minH":1,"cx":3,"cy":0,"i":"recentlyVisited#recentlyVisited"},{"w":1,"h":5,"maxH":10,"minH":1,"cx":3,"cy":1,"i":"favoriteServices#favoriteServices"},{"w":1,"h":6,"maxH":10,"minH":1,"cx":3,"cy":2,"i":"subscriptions#subscriptions"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":3,"i":"openshiftAi#openshiftAi"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":3,"i":"imageBuilder#imageBuilder"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":3,"i":"acs#acs"}]},"frontendRef":"landing"}]' -WIDGET_MAPPING='[{"scope":"widgets","module":"./RandomWidget","defaults":{"w":1,"h":1,"maxH":1,"minH":1},"config":{"title":"Random Widget","icon":"CogIcon","headerLink":{"title":"","href":""}},"frontendRef":"widgets"}]' \ No newline at end of file +BASE_LAYOUTS='[{"name":"landing-landingPage","displayName":"LandingPage","templateConfig":{"sm":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"landing-./RhelWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":1,"i":"landing-./OpenShiftWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":2,"i":"landing-./AnsibleWidget"},{"w":1,"h":5,"maxH":10,"minH":1,"cx":0,"cy":3,"i":"landing-./RecentlyVisited"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"chrome-./DashboardFavorites"},{"w":1,"h":6,"maxH":10,"minH":1,"cx":0,"cy":5,"i":"subscriptionInventory-./SubscriptionsWidget"},{"w":1,"h":13,"maxH":13,"minH":1,"cx":0,"cy":6,"i":"landing-./ExploreCapabilities"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":7,"i":"landing-./OpenShiftAiWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":8,"i":"landing-./ImageBuilderWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":9,"i":"landing-./AcsWidget"}],"md":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"landing-./RhelWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":1,"i":"landing-./OpenShiftWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":2,"i":"landing-./AnsibleWidget"},{"w":1,"h":3,"maxH":10,"minH":1,"cx":1,"cy":0,"i":"landing-./RecentlyVisited"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":1,"i":"chrome-./DashboardFavorites"},{"w":1,"h":5,"maxH":10,"minH":1,"cx":1,"cy":2,"i":"subscriptionInventory-./SubscriptionsWidget"},{"w":2,"h":6,"maxH":10,"minH":1,"cx":0,"cy":3,"i":"landing-./ExploreCapabilities"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"landing-./ImageBuilderWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":4,"i":"landing-./OpenShiftAiWidget"},{"w":2,"h":3,"maxH":10,"minH":1,"cx":1,"cy":3,"i":"landing-./AcsWidget"}],"lg":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"landing-./RhelWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":0,"i":"landing-./OpenShiftWidget"},{"w":2,"h":4,"maxH":10,"minH":1,"cx":0,"cy":1,"i":"landing-./AnsibleWidget"},{"w":2,"h":6,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"landing-./ExploreCapabilities"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":0,"i":"landing-./RecentlyVisited"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":2,"i":"chrome-./DashboardFavorites"},{"w":1,"h":6,"maxH":10,"minH":1,"cx":2,"cy":3,"i":"subscriptionInventory-./SubscriptionsWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":4,"i":"landing-./OpenShiftAiWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":4,"i":"landing-./ImageBuilderWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":4,"i":"landing-./AcsWidget"}],"xl":[{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":0,"i":"landing-./RhelWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":0,"i":"landing-./OpenShiftWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":0,"i":"landing-./AnsibleWidget"},{"w":3,"h":6,"maxH":10,"minH":1,"cx":0,"cy":2,"i":"landing-./ExploreCapabilities"},{"w":1,"h":3,"maxH":10,"minH":1,"cx":3,"cy":0,"i":"landing-./RecentlyVisited"},{"w":1,"h":5,"maxH":10,"minH":1,"cx":3,"cy":1,"i":"chrome-./DashboardFavorites"},{"w":1,"h":6,"maxH":10,"minH":1,"cx":3,"cy":2,"i":"subscriptionInventory-./SubscriptionsWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":0,"cy":3,"i":"landing-./OpenShiftAiWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":1,"cy":3,"i":"landing-./ImageBuilderWidget"},{"w":1,"h":4,"maxH":10,"minH":1,"cx":2,"cy":3,"i":"landing-./AcsWidget"}]},"frontendRef":"landing"}]' +WIDGET_MAPPING='[{"scope":"landing","module":"./AcsWidget","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Advanced Cluster Security","icon":"ACSIcon","headerLink":{}},"frontendRef":"acs"},{"scope":"landing","module":"./AnsibleWidget","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Ansible Automation Platform","icon":"AnsibleIcon","headerLink":{}},"frontendRef":"ansible"},{"scope":"landing","module":"./EdgeWidget","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Edge Management","icon":"EdgeIcon","headerLink":{}},"frontendRef":"edge"},{"scope":"landing","module":"./ExploreCapabilities","defaults":{"w":3,"h":5,"maxH":10,"minH":1},"config":{"title":"Explore capabilities","icon":"RocketIcon","headerLink":{}},"frontendRef":"exploreCapabilities"},{"scope":"chrome","module":"./DashboardFavorites","featureFlag":"widget.favoriteServices.enable","defaults":{"w":1,"h":6,"maxH":10,"minH":1},"config":{"title":"My favorite services","icon":"StarIcon","headerLink":{"title":"View all services","href":"/allservices"}},"frontendRef":"favoriteServices"},{"scope":"landing","module":"./ImageBuilderWidget","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Image Builder","icon":"RhelIcon","headerLink":{}},"frontendRef":"imageBuilder"},{"scope":"sources","module":"./IntegrationsWidget","defaults":{"w":2,"h":4,"maxH":10,"minH":1},"config":{"title":"Integrations","icon":"IntegrationsIcon","headerLink":{"title":"Explore integrations","href":"/settings/integrations"},"permissions":[{"method":"featureFlag","args":["chrome-service.integrations-widget.enabled",true]},{"method":"loosePermissions","args":[["sources:*:read","integrations:endpoints:read"]]}]},"frontendRef":"integrations"},{"scope":"learningResources","module":"./BookmarkedLearningResourcesWidget","featureFlag":"widget.learningResources.enable","defaults":{"w":2,"h":4,"maxH":10,"minH":1},"config":{"title":"Bookmarked learning resources","icon":"OutlinedBookmarkIcon","headerLink":{"title":"View all","href":"/learning-resources?tab=all","featureFlag":"platform.learning-resources.global-learning-resources"}},"frontendRef":"learningResources"},{"scope":"notifications","module":"./DashboardWidget","featureFlag":"widget.notifications.enable","defaults":{"w":1,"h":3,"maxH":10,"minH":1},"config":{"title":"Events","icon":"BellIcon","headerLink":{"title":"View event log","href":"/settings/notifications/eventlog"},"permissions":[{"method":"isOrgAdmin"}]},"frontendRef":"notificationsEvents"},{"scope":"landing","module":"./OpenShiftWidget","featureFlag":"widget.openshift.enable","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Red Hat OpenShift","icon":"OpenShiftIcon","headerLink":{}},"frontendRef":"openshift"},{"scope":"landing","module":"./OpenShiftAiWidget","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Red Hat OpenShift AI","icon":"OpenShiftAiIcon","headerLink":{}},"frontendRef":"openshiftAi"},{"scope":"landing","module":"./QuayWidget","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Quay.io","icon":"QuayIcon","headerLink":{}},"frontendRef":"quay"},{"scope":"landing","module":"./RecentlyVisited","featureFlag":"widget.recentlyVisited.enable","defaults":{"w":1,"h":7,"maxH":10,"minH":1},"config":{"title":"Recently visited","icon":"HistoryIcon","headerLink":{}},"frontendRef":"recentlyVisited"},{"scope":"landing","module":"./RhelWidget","featureFlag":"widget.rhel.enable","defaults":{"w":1,"h":4,"maxH":10,"minH":1},"config":{"title":"Red Hat Enterprise Linux","icon":"RhelIcon","headerLink":{}},"frontendRef":"rhel"},{"scope":"subscriptionInventory","module":"./SubscriptionsWidget","defaults":{"w":4,"h":4,"maxH":10,"minH":1},"config":{"title":"Subscriptions","icon":"CreditCardIcon","headerLink":{"title":"Manage subscriptions","href":"/subscriptions/inventory"},"permissions":[{"method":"featureFlag","args":["chrome-service.subscriptions-widget.enabled",true]},{"method":"hasPermissions","args":[["subscriptions:products:read"]]}]},"frontendRef":"subscriptions"},{"scope":"landing","module":"./SupportCaseWidget","defaults":{"w":2,"h":4,"maxH":10,"minH":1},"config":{"title":"My support cases","icon":"HeadsetIcon","headerLink":{"title":"Open a support case","href":"https://access.redhat.com/support/cases/#/case/new/get-support?caseCreate=true"}},"frontendRef":"supportCases"}]' \ No newline at end of file diff --git a/api/WidgetMapping.go b/api/WidgetMapping.go index aecf644..0dcd265 100644 --- a/api/WidgetMapping.go +++ b/api/WidgetMapping.go @@ -6,6 +6,10 @@ type WidgetMappingRegistry struct { WidgetMappings map[string]WidgetModuleFederationMetadata `json:"widgetMappings" yaml:"widgetMappings"` } +type WidgetMappingResponse struct { + Data map[string]WidgetModuleFederationMetadata `json:"data"` +} + func (wc *WidgetModuleFederationMetadata) GetWidgetKey() string { // make key in format "scope-module<-importName>" // This will be always unique diff --git a/docs/API.md b/docs/API.md index 693889f..c8f1f1a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -21,6 +21,26 @@ For detailed instructions on generating and using identity headers, see [docs/DE ### Success Response All successful responses return the requested data with appropriate HTTP status codes (200, 201, 204). +### List Response Format +List endpoints return data in a consistent format with metadata: + +```json +{ + "data": [ + /* Array of items */ + ], + "meta": { + "count": 2 + } +} +``` + +**Endpoints using list format:** +- `GET /` - Dashboard templates list +- `GET /base-templates` - Base templates list + +**Note**: The `GET /widget-mapping` endpoint uses a different format with a `data` object containing key-value mappings instead of an array. + ### Error Response ```json { @@ -42,48 +62,66 @@ Dashboard templates are user-specific widget layouts that define how widgets are #### GET `/` Get all dashboard templates for the authenticated user. +**Query Parameters:** +- `dashboardType` (optional): Filter templates by dashboard type/base template name + **Request:** ```bash +# Get all templates curl -X GET \ 'http://localhost:8080/api/widget-layout/v1/' \ -H 'x-rh-identity: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...' + +# Filter by dashboard type +curl -X GET \ + 'http://localhost:8080/api/widget-layout/v1/?dashboardType=default-dashboard' \ + -H 'x-rh-identity: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...' ``` **Response (200 OK):** ```json -[ - { - "id": 1, - "userId": "user-123", - "createdAt": "2024-01-01T12:00:00Z", - "updatedAt": "2024-01-01T12:00:00Z", - "templateConfig": { - "sm": [ - { - "w": 2, - "h": 2, - "x": 0, - "y": 0, - "i": "widget1", - "static": false, - "maxH": 4, - "minH": 1 - } - ], - "md": [...], - "lg": [...], - "xl": [...] - }, - "templateBase": { - "name": "custom-dashboard-template", - "displayName": "My Custom Dashboard" - }, - "default": true +{ + "data": [ + { + "id": 1, + "userId": "user-123", + "createdAt": "2024-01-01T12:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z", + "templateConfig": { + "sm": [ + { + "w": 2, + "h": 2, + "x": 0, + "y": 0, + "i": "widget1", + "static": false, + "maxH": 4, + "minH": 1 + } + ], + "md": [...], + "lg": [...], + "xl": [...] + }, + "templateBase": { + "name": "custom-dashboard-template", + "displayName": "My Custom Dashboard" + }, + "default": true + } + ], + "meta": { + "count": 1 } -] +} ``` +**Auto-Creation Behavior:** +When filtering by `dashboardType`, if the user has no templates of that type but a matching base template exists, the API will automatically create and return a new template for the user with a `404` status code. + **Error Responses:** +- `404` - No templates found (may include auto-created template in response body) - `500` - Internal server error #### GET `/{dashboardTemplateId}` @@ -344,29 +382,34 @@ curl -X GET \ **Response (200 OK):** ```json -[ - { - "name": "default-dashboard", - "displayName": "Default Dashboard", - "templateConfig": { - "sm": [ - { - "w": 2, - "h": 2, - "x": 0, - "y": 0, - "i": "insights-dashboard-widget", - "static": false, - "maxH": 4, - "minH": 1 - } - ], - "md": [...], - "lg": [...], - "xl": [...] +{ + "data": [ + { + "name": "default-dashboard", + "displayName": "Default Dashboard", + "templateConfig": { + "sm": [ + { + "w": 2, + "h": 2, + "x": 0, + "y": 0, + "i": "insights-dashboard-widget", + "static": false, + "maxH": 4, + "minH": 1 + } + ], + "md": [...], + "lg": [...], + "xl": [...] + } } + ], + "meta": { + "count": 1 } -] +} ``` **Error Responses:** @@ -450,6 +493,8 @@ curl -X GET \ Widget mapping provides metadata about available widgets including their configurations, dimensions, and module federation information. +> **📝 Note**: The widget mapping endpoint returns a different format than other list endpoints. It provides a key-value mapping rather than an array with metadata. + #### GET `/widget-mapping` Get the mapping of all available widgets. @@ -462,7 +507,8 @@ curl -X GET \ **Response (200 OK):** ```json { - "insights@dashboard-widget": { + "data": { + "insights@dashboard-widget": { "scope": "insights", "module": "dashboard-widget", "importName": "DashboardWidget", @@ -504,6 +550,7 @@ curl -X GET \ "minH": 1 } } + } } ``` diff --git a/docs/WIDGET_MIGRATION.md b/docs/WIDGET_MIGRATION.md new file mode 100644 index 0000000..8003bee --- /dev/null +++ b/docs/WIDGET_MIGRATION.md @@ -0,0 +1,118 @@ +# Widget Migration Guide + +This document provides guidance for migrating widgets to the new FEO (Frontend Optimization) system with updated widget identifiers and CSS selectors. + +## Widget ID Migration + +When widgets are collected via the FEO system, their identifiers change from simple Chrome service IDs to compound identifiers that include both scope and module information. The following table shows the mapping between old and new widget identifiers: + +| Chrome Service ID | FEO Widget ID | +|-------------------------|--------------------------------------------------------| +| `acs` | `landing-./AcsWidget` | +| `ansible` | `landing-./AnsibleWidget` | +| `edge` | `landing-./EdgeWidget` | +| `exploreCapabilities` | `landing-./ExploreCapabilities` | +| `favoriteServices` | `chrome-./DashboardFavorites` | +| `imageBuilder` | `landing-./ImageBuilderWidget` | +| `integrations` | `sources-./IntegrationsWidget` | +| `learningResources` | `learningResources-./BookmarkedLearningResourcesWidget` | +| `notificationsEvents` | `notifications-./DashboardWidget` | +| `openshift` | `landing-./OpenShiftWidget` | +| `openshiftAi` | `landing-./OpenShiftAiWidget` | +| `quay` | `landing-./QuayWidget` | +| `recentlyVisited` | `landing-./RecentlyVisited` | +| `rhel` | `landing-./RhelWidget` | +| `subscriptions` | `subscriptionInventory-./SubscriptionsWidget` | +| `supportCases` | `landing-./SupportCaseWidget` | + +## CSS Selector Migration + +Due to the change in widget IDs, CSS selectors must also be updated to target the new identifiers. The new CSS class names are generated from the widget's full FEO identifier. + +### Selector Format + +CSS selectors are now based on the complete widget identifier, including special characters: `scope-widgetId`. It is common that the scope string will be duplicated in your CSS selector. Thats because the widget ID includes the scope substring. When targeting widgets with special characters in their identifiers (such as `./`), you must escape these characters in your CSS. + +### Example Migration + +For the "Explore Capabilities" widget, the CSS selector migration should be done gradually by adding new selectors alongside existing ones: + +**Step 1: Add new selectors alongside existing ones** +```scss +/* Combined selectors - both old and new target the same styles */ +.landing-exploreCapabilities, +[class*="landing-landing-./ExploreCapabilities"] { + /* Widget styles - no duplication needed */ + background-color: #f5f5f5; + border: 1px solid #ddd; + padding: 16px; + border-radius: 8px; +} + +/* Alternative: Using escaped class selector */ +.landing-exploreCapabilities, +.landing-landing-\.\/ExploreCapabilities { + /* Widget styles */ + background-color: #f5f5f5; + border: 1px solid #ddd; + padding: 16px; + border-radius: 8px; +} +``` + +**Step 2: Remove old selectors after migration is complete** +```scss +/* Only keep the new selectors once migration is fully deployed */ +[class*="landing-landing-./ExploreCapabilities"] { + /* Widget styles */ +} +``` + +### CSS Selector Best Practices + +- **Gradual migration**: Add new selectors alongside existing ones, don't replace immediately +- **Comma-separated selectors**: Use comma-separated selectors to avoid duplicating styles (e.g., `.old-selector, .new-selector { /* styles */ }`) +- **Migration window**: Keep both old and new selectors active during the transition period +- **Attribute selectors**: Use `[class*="widget-id"]` for widgets with special characters in their identifiers +- **Escaped selectors**: When using class selectors, escape special characters (`.` becomes `\.`, `/` becomes `\/`) +- **Specificity**: Be aware that attribute selectors have the same specificity as class selectors +- **Testing**: Test both old and new selectors during the migration period +- **Cleanup**: Remove old selectors only after confirming the new system is fully deployed + +### Common Special Characters + +When working with FEO widget identifiers, you may encounter these special characters that require escaping in CSS: + +| Character | Escaped Form | Usage in Widget IDs | +|-----------|--------------|---------------------| +| `.` | `\.` | Module path separator | +| `/` | `\/` | Path separator | + +## Migration Checklist + +When migrating widgets to the FEO system, follow these steps to ensure a smooth transition: + +### Phase 1: Preparation +1. **Identify affected widgets** by reviewing the widget ID mapping table above +2. **Audit existing CSS** to find all selectors targeting the old widget IDs +3. **Plan the migration timeline** to coordinate with deployment schedules + +### Phase 2: Add New Selectors +4. **Add new CSS selectors** alongside existing ones (do not replace yet) +5. **Use proper escaping** for special characters in widget identifiers +6. **Test both selector sets** to ensure styling works with old and new IDs +7. **Update widget references** in code to support both old and new identifiers + +### Phase 3: Validation +8. **Test widget functionality** to ensure proper rendering and behavior +9. **Validate CSS styling** in both old and new systems +10. **Verify responsive behavior** across different screen sizes +11. **Check for conflicts** between old and new selectors + +### Phase 4: Cleanup (After Full Migration) +12. **Remove old CSS selectors** once the new system is fully deployed +13. **Clean up old widget references** in code +14. **Update documentation** to reflect the new widget identifiers only +15. **Archive migration notes** for future reference + +For more information about widget configuration and management, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md). diff --git a/pkg/server/get_base_templates_test.go b/pkg/server/get_base_templates_test.go index 036d7ba..933f789 100644 --- a/pkg/server/get_base_templates_test.go +++ b/pkg/server/get_base_templates_test.go @@ -28,10 +28,11 @@ func TestGetBaseWidgetDashboardTemplates(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Expected status code 200") assert.Equal(t, "application/json", w.Header().Get("Content-Type"), "Content-Type should be application/json") - var templates []api.BaseWidgetDashboardTemplate - err := json.NewDecoder(w.Body).Decode(&templates) - require.NoError(t, err, "Should be able to decode response as array") - assert.Empty(t, templates, "Should return empty array when no templates exist") + var response api.BaseWidgetDashboardTemplateListResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err, "Should be able to decode response as list response") + assert.Empty(t, response.Data, "Should return empty array when no templates exist") + assert.Equal(t, 0, response.Meta.Count, "Meta count should be 0") }) t.Run("should return array of base templates", func(t *testing.T) { @@ -73,14 +74,15 @@ func TestGetBaseWidgetDashboardTemplates(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Expected status code 200") assert.Equal(t, "application/json", w.Header().Get("Content-Type"), "Content-Type should be application/json") - var templates []api.BaseWidgetDashboardTemplate - err := json.NewDecoder(w.Body).Decode(&templates) - require.NoError(t, err, "Should be able to decode response as array") - assert.Len(t, templates, 2, "Should return array with 2 templates") + var response api.BaseWidgetDashboardTemplateListResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err, "Should be able to decode response as list response") + assert.Len(t, response.Data, 2, "Should return array with 2 templates") + assert.Equal(t, 2, response.Meta.Count, "Meta count should be 2") // Verify both templates are present (order doesn't matter in map iteration) templateNames := make(map[string]string) - for _, template := range templates { + for _, template := range response.Data { templateNames[template.Name] = template.DisplayName } diff --git a/pkg/server/get_widget_mapping_test.go b/pkg/server/get_widget_mapping_test.go index c078e1a..22857fb 100644 --- a/pkg/server/get_widget_mapping_test.go +++ b/pkg/server/get_widget_mapping_test.go @@ -26,12 +26,12 @@ func TestGetWidgetMapping(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Should return 200 OK") - var response map[string]api.WidgetModuleFederationMetadata + var response api.WidgetMappingResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err, "Should be able to unmarshal response") assert.NotNil(t, response, "Response should not be nil") - assert.Empty(t, response, "Response should be empty map when no mappings exist") + assert.Empty(t, response.Data, "Response should be empty map when no mappings exist") }) t.Run("should return all widget mappings when they exist", func(t *testing.T) { @@ -81,16 +81,16 @@ func TestGetWidgetMapping(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Should return 200 OK") - var response map[string]api.WidgetModuleFederationMetadata + var response api.WidgetMappingResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err, "Should be able to unmarshal response") - assert.Len(t, response, 2, "Should return both widget mappings") + assert.Len(t, response.Data, 2, "Should return both widget mappings") // Verify first widget widget1Key := widget1.GetWidgetKey() - assert.Contains(t, response, widget1Key, "Should contain first widget mapping") - retrievedWidget1 := response[widget1Key] + assert.Contains(t, response.Data, widget1Key, "Should contain first widget mapping") + retrievedWidget1 := response.Data[widget1Key] assert.Equal(t, "insights", retrievedWidget1.Scope, "First widget scope should match") assert.Equal(t, "dashboard-widget", retrievedWidget1.Module, "First widget module should match") assert.Equal(t, "Dashboard Overview", retrievedWidget1.Config.Title, "First widget title should match") @@ -98,8 +98,8 @@ func TestGetWidgetMapping(t *testing.T) { // Verify second widget widget2Key := widget2.GetWidgetKey() - assert.Contains(t, response, widget2Key, "Should contain second widget mapping") - retrievedWidget2 := response[widget2Key] + assert.Contains(t, response.Data, widget2Key, "Should contain second widget mapping") + retrievedWidget2 := response.Data[widget2Key] assert.Equal(t, "monitoring", retrievedWidget2.Scope, "Second widget scope should match") assert.Equal(t, "alerts-widget", retrievedWidget2.Module, "Second widget module should match") assert.Equal(t, "Alert Status", retrievedWidget2.Config.Title, "Second widget title should match") @@ -112,7 +112,11 @@ func TestGetWidgetMapping(t *testing.T) { importName := "CustomComponent" featureFlag := "enable-advanced-widgets" - permissions := []string{"read:insights", "write:monitoring"} + permissions := []api.Permission{ + api.Permission{ + Method: "isOrgAdmin", + }, + } widget := api.WidgetModuleFederationMetadata{ Scope: "advanced", @@ -146,15 +150,15 @@ func TestGetWidgetMapping(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Should return 200 OK") - var response map[string]api.WidgetModuleFederationMetadata + var response api.WidgetMappingResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err, "Should be able to unmarshal response") - assert.Len(t, response, 1, "Should contain one widget mapping") + assert.Len(t, response.Data, 1, "Should contain one widget mapping") widgetKey := widget.GetWidgetKey() - assert.Contains(t, response, widgetKey, "Should contain the widget mapping") - retrievedWidget := response[widgetKey] + assert.Contains(t, response.Data, widgetKey, "Should contain the widget mapping") + retrievedWidget := response.Data[widgetKey] // Verify basic fields assert.Equal(t, "advanced", retrievedWidget.Scope, "Widget scope should match") @@ -226,26 +230,26 @@ func TestGetWidgetMapping(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Should return 200 OK") - var response map[string]api.WidgetModuleFederationMetadata + var response api.WidgetMappingResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err, "Should be able to unmarshal response") - assert.Len(t, response, 2, "Should contain both widgets despite same scope/module") + assert.Len(t, response.Data, 2, "Should contain both widgets despite same scope/module") // Verify both widgets are present with correct keys widget1Key := "scope1-module1-ImportedComponent" widget2Key := "scope1-module1" - assert.Contains(t, response, widget1Key, "Should contain widget with import name in key") - assert.Contains(t, response, widget2Key, "Should contain widget without import name in key") + assert.Contains(t, response.Data, widget1Key, "Should contain widget with import name in key") + assert.Contains(t, response.Data, widget2Key, "Should contain widget without import name in key") // Verify widget1 (with import name) - retrievedWidget1 := response[widget1Key] + retrievedWidget1 := response.Data[widget1Key] assert.Equal(t, "Widget with Import", retrievedWidget1.Config.Title, "First widget title should match") assert.Equal(t, "ImportedComponent", *retrievedWidget1.ImportName, "Import name should match") // Verify widget2 (without import name) - retrievedWidget2 := response[widget2Key] + retrievedWidget2 := response.Data[widget2Key] assert.Equal(t, "Widget without Import", retrievedWidget2.Config.Title, "Second widget title should match") assert.Nil(t, retrievedWidget2.ImportName, "Import name should be nil") }) @@ -297,14 +301,14 @@ func TestGetWidgetMapping(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Should return 200 OK on request %d", i+1) - var response map[string]api.WidgetModuleFederationMetadata + var response api.WidgetMappingResponse err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err, "Should be able to unmarshal response on request %d", i+1) - assert.Len(t, response, 1, "Should always return one widget mapping on request %d", i+1) + assert.Len(t, response.Data, 1, "Should always return one widget mapping on request %d", i+1) widgetKey := widget.GetWidgetKey() - retrievedWidget := response[widgetKey] + retrievedWidget := response.Data[widgetKey] assert.Equal(t, "Data Integrity Test", retrievedWidget.Config.Title, "Widget title should be consistent on request %d", i+1) assert.Equal(t, 2, *retrievedWidget.Defaults.Width, "Widget width should be consistent on request %d", i+1) } @@ -357,13 +361,13 @@ func TestGetWidgetMapping(t *testing.T) { assert.Equal(t, http.StatusOK, w1.Code, "Should return 200 OK") - var response1 map[string]api.WidgetModuleFederationMetadata + var response1 api.WidgetMappingResponse err := json.Unmarshal(w1.Body.Bytes(), &response1) require.NoError(t, err, "Should be able to unmarshal first response") - assert.Len(t, response1, 1, "Should have one widget") + assert.Len(t, response1.Data, 1, "Should have one widget") widgetKey := widget1.GetWidgetKey() - retrievedWidget1 := response1[widgetKey] + retrievedWidget1 := response1.Data[widgetKey] assert.Equal(t, "Original Widget", retrievedWidget1.Config.Title, "Should have original widget") // Add second widget with same key (overwrites first) @@ -376,12 +380,12 @@ func TestGetWidgetMapping(t *testing.T) { assert.Equal(t, http.StatusOK, w2.Code, "Should return 200 OK") - var response2 map[string]api.WidgetModuleFederationMetadata + var response2 api.WidgetMappingResponse err = json.Unmarshal(w2.Body.Bytes(), &response2) require.NoError(t, err, "Should be able to unmarshal second response") - assert.Len(t, response2, 1, "Should still have one widget") - retrievedWidget2 := response2[widgetKey] + assert.Len(t, response2.Data, 1, "Should still have one widget") + retrievedWidget2 := response2.Data[widgetKey] assert.Equal(t, "Overwriting Widget", retrievedWidget2.Config.Title, "Should have overwritten widget") assert.Equal(t, "overwriting-icon", retrievedWidget2.Config.Icon, "Should have overwritten icon") assert.Equal(t, 3, *retrievedWidget2.Defaults.Width, "Should have overwritten width") diff --git a/pkg/server/get_widgets_test.go b/pkg/server/get_widgets_test.go index a42ae23..122efe9 100644 --- a/pkg/server/get_widgets_test.go +++ b/pkg/server/get_widgets_test.go @@ -8,6 +8,7 @@ import ( "github.com/RedHatInsights/widget-layout-backend/api" "github.com/RedHatInsights/widget-layout-backend/pkg/database" + "github.com/RedHatInsights/widget-layout-backend/pkg/service" "github.com/RedHatInsights/widget-layout-backend/pkg/test_util" "github.com/stretchr/testify/assert" "github.com/subpop/xrhidgen" @@ -86,21 +87,22 @@ func TestGetWidgets(t *testing.T) { req = withCustomIdentityContext(req, testIdentity) w := httptest.NewRecorder() - server.GetWidgetLayout(w, req) + server.GetWidgetLayout(w, req, api.GetWidgetLayoutParams{}) assert.Equal(t, http.StatusOK, w.Code, "Expected status code 200") resp := w.Body.Bytes() - var parsedResp []api.DashboardTemplate + var parsedResp api.DashboardTemplateListResponse err := json.Unmarshal(resp, &parsedResp) assert.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, 2, len(parsedResp), "Expected two templates in response") + assert.Equal(t, 2, len(parsedResp.Data), "Expected two templates in response") + assert.Equal(t, 2, parsedResp.Meta.Count, "Expected count to be 2") // Verify that both templates are returned (order may vary) foundTemplate1 := false foundTemplate2 := false - for _, template := range parsedResp { + for _, template := range parsedResp.Data { if template.ID == template1.ID { foundTemplate1 = true assert.Equal(t, template1.UserId, template.UserId, "User ID should match for template 1") @@ -143,16 +145,17 @@ func TestGetWidgets(t *testing.T) { req = withCustomIdentityContext(req, differentUserIdentity) w := httptest.NewRecorder() - server.GetWidgetLayout(w, req) + server.GetWidgetLayout(w, req, api.GetWidgetLayoutParams{}) assert.Equal(t, http.StatusOK, w.Code, "Expected status code 200") resp := w.Body.Bytes() - var parsedResp []api.DashboardTemplate + var parsedResp api.DashboardTemplateListResponse err := json.Unmarshal(resp, &parsedResp) assert.NoError(t, err, "Response should be valid JSON") - assert.Equal(t, 0, len(parsedResp), "Expected empty list when user has no templates") + assert.Equal(t, 0, len(parsedResp.Data), "Expected empty list when user has no templates") + assert.Equal(t, 0, parsedResp.Meta.Count, "Expected count to be 0") }) t.Run("should set Content-Type to application/json", func(t *testing.T) { @@ -160,7 +163,7 @@ func TestGetWidgets(t *testing.T) { req, _ := http.NewRequest("GET", "/", nil) req = withIdentityContext(req) w := httptest.NewRecorder() - server.GetWidgetLayout(w, req) + server.GetWidgetLayout(w, req, api.GetWidgetLayoutParams{}) assert.Equal(t, "application/json", w.Header().Get("Content-Type"), "Content-Type should be application/json") }) @@ -169,9 +172,122 @@ func TestGetWidgets(t *testing.T) { req, _ := http.NewRequest("GET", "/", nil) req = withIdentityContext(req) w := httptest.NewRecorder() - server.GetWidgetLayout(w, req) - var js []api.DashboardTemplate + server.GetWidgetLayout(w, req, api.GetWidgetLayoutParams{}) + var js api.DashboardTemplateListResponse err := json.Unmarshal(w.Body.Bytes(), &js) assert.NoError(t, err, "Response should be valid JSON") }) + + t.Run("should filter templates by dashboardType parameter", func(t *testing.T) { + server := setupRouter() + testUserID := test_util.GetUniqueUserID() + testIdentity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(testUserID)}, + xrhidgen.Entitlements{}, + ) + + // Create templates with different base names using test helper + template1 := createServerTestTemplate(testUserID, "dashboard-a") + template2 := createServerTestTemplate(testUserID, "dashboard-b") + template3 := createServerTestTemplate(testUserID, "dashboard-a") + + database.DB.Create(&template1) + database.DB.Create(&template2) + database.DB.Create(&template3) + + // Test with dashboardType filter + req, _ := http.NewRequest("GET", "/?dashboardType=dashboard-a", nil) + req = withCustomIdentityContext(req, testIdentity) + w := httptest.NewRecorder() + + server.GetWidgetLayout(w, req, api.GetWidgetLayoutParams{DashboardType: stringPtr("dashboard-a")}) + + assert.Equal(t, http.StatusOK, w.Code) + + var filteredResp api.DashboardTemplateListResponse + json.Unmarshal(w.Body.Bytes(), &filteredResp) + assert.Len(t, filteredResp.Data, 2, "Should return 2 templates with dashboard-a") + assert.Equal(t, 2, filteredResp.Meta.Count, "Meta count should be 2") + }) + + t.Run("should return all templates when no dashboardType parameter", func(t *testing.T) { + server := setupRouter() + testUserID := test_util.GetUniqueUserID() + testIdentity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(testUserID)}, + xrhidgen.Entitlements{}, + ) + + // Create templates with different base names + template1 := createServerTestTemplate(testUserID, "type-x") + template2 := createServerTestTemplate(testUserID, "type-y") + + database.DB.Create(&template1) + database.DB.Create(&template2) + + // Test without dashboardType filter + req, _ := http.NewRequest("GET", "/", nil) + req = withCustomIdentityContext(req, testIdentity) + w := httptest.NewRecorder() + + server.GetWidgetLayout(w, req, api.GetWidgetLayoutParams{}) + + assert.Equal(t, http.StatusOK, w.Code) + + var allResp api.DashboardTemplateListResponse + json.Unmarshal(w.Body.Bytes(), &allResp) + assert.Len(t, allResp.Data, 2, "Should return all 2 templates when no filter") + assert.Equal(t, 2, allResp.Meta.Count, "Meta count should be 2") + }) + + t.Run("should auto-create template when user has none and base template exists", func(t *testing.T) { + server := setupRouter() + testUserID := test_util.GetUniqueUserID() + testIdentity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(testUserID)}, + xrhidgen.Entitlements{}, + ) + + // Reset and add a base template to the registry + service.BaseTemplateRegistry = api.BaseWidgetDashboardTemplateRegistry{} + baseTemplate := api.BaseWidgetDashboardTemplate{ + Name: "server-auto-test", + DisplayName: "Server Auto Test", + TemplateConfig: api.DashboardTemplateConfig{ + Sm: datatypes.NewJSONType([]api.WidgetItem{}), + Md: datatypes.NewJSONType([]api.WidgetItem{}), + Lg: datatypes.NewJSONType([]api.WidgetItem{}), + Xl: datatypes.NewJSONType([]api.WidgetItem{}), + }, + } + service.BaseTemplateRegistry.AddBase(baseTemplate) + + // Test with dashboardType filter for base template (user has no templates) + req, _ := http.NewRequest("GET", "/?dashboardType=server-auto-test", nil) + req = withCustomIdentityContext(req, testIdentity) + w := httptest.NewRecorder() + + server.GetWidgetLayout(w, req, api.GetWidgetLayoutParams{DashboardType: stringPtr("server-auto-test")}) + + assert.Equal(t, http.StatusNotFound, w.Code, "Should return 404 when auto-creating template") + + var autoResp api.DashboardTemplateListResponse + json.Unmarshal(w.Body.Bytes(), &autoResp) + assert.Len(t, autoResp.Data, 1, "Should return 1 auto-created template") + assert.Equal(t, 1, autoResp.Meta.Count, "Meta count should be 1") + assert.Equal(t, "server-auto-test", autoResp.Data[0].TemplateBase.Name) + assert.Equal(t, testUserID, autoResp.Data[0].UserId) + assert.True(t, autoResp.Data[0].Default, "Auto-created template should be default") + }) +} + +// Helper function to create test template for server tests +func createServerTestTemplate(userID, baseName string) api.DashboardTemplate { + template := test_util.MockDashboardTemplateWithSpecificUser(userID) + template.TemplateBase.Name = baseName + template.TemplateBase.DisplayName = baseName + " Display" + return template } diff --git a/pkg/server/server.go b/pkg/server/server.go index 5d40123..90cc30b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -24,10 +24,11 @@ func NewServer(r chi.Router, middlewares ...func(next http.Handler) http.Handler } // (GET /) -func (Server) GetWidgetLayout(w http.ResponseWriter, r *http.Request) { +func (Server) GetWidgetLayout(w http.ResponseWriter, r *http.Request, params api.GetWidgetLayoutParams) { w.Header().Set("Content-Type", "application/json") id := middlewares.GetUserIdentity(r.Context()) - resp, status, err := service.GetUserTemplates(id) + + resp, status, err := service.GetUserTemplates(id, params) if err != nil { logrus.Errorf("Failed to get dashboard templates: %v", err) w.WriteHeader(status) @@ -40,8 +41,17 @@ func (Server) GetWidgetLayout(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(resp) + // Create the new list response format + listResponse := api.DashboardTemplateListResponse{ + Data: resp, + Meta: api.ListResponseMeta{ + Count: len(resp), + }, + } + + // Use the status returned by the service (could be 200 or 404 when auto-creating) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(listResponse) } // (GET /{dashboardTemplateId}) @@ -206,8 +216,16 @@ func (Server) GetBaseWidgetDashboardTemplates(w http.ResponseWriter, r *http.Req templates = append(templates, template) } + // Create the new list response format + listResponse := api.BaseWidgetDashboardTemplateListResponse{ + Data: templates, + Meta: api.ListResponseMeta{ + Count: len(templates), + }, + } + w.WriteHeader(http.StatusOK) - err := json.NewEncoder(w).Encode(templates) + err := json.NewEncoder(w).Encode(listResponse) if err != nil { logrus.Errorf("Failed to encode base widget dashboard templates: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -264,8 +282,11 @@ func (Server) ForkBaseWidgetDashboardTemplateByName(w http.ResponseWriter, r *ht func (Server) GetWidgetMapping(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") mappings := service.GetWidgetMappings() + resp := api.WidgetMappingResponse{ + Data: mappings, + } w.WriteHeader(http.StatusOK) - err := json.NewEncoder(w).Encode(mappings) + err := json.NewEncoder(w).Encode(resp) if err != nil { logrus.Errorf("Failed to encode widget mappings: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) diff --git a/pkg/service/DashboardTemplate.go b/pkg/service/DashboardTemplate.go index 4d3e645..4884ee6 100644 --- a/pkg/service/DashboardTemplate.go +++ b/pkg/service/DashboardTemplate.go @@ -41,9 +41,32 @@ func GetTemplateByID(templateID int64, id identity.XRHID) (api.DashboardTemplate return template, http.StatusOK, nil } -func GetUserTemplates(id identity.XRHID) ([]api.DashboardTemplate, int, error) { +func GetUserTemplates(id identity.XRHID, params api.GetWidgetLayoutParams) ([]api.DashboardTemplate, int, error) { var templates []api.DashboardTemplate - err := database.DB.Where(api.DashboardTemplate{UserId: id.Identity.User.UserID}).Find(&templates).Error + where := api.DashboardTemplate{UserId: id.Identity.User.UserID} + if params.DashboardType != nil { + where.TemplateBase.Name = *params.DashboardType + } + query := database.DB.Where(where) + res := query.Find(&templates) + err := res.Error + if err == nil && res.RowsAffected == 0 && params.DashboardType != nil { + logrus.Infof("No dashboard templates found for user %s with type %s", id.Identity.User.UserID, *params.DashboardType) + newTemplate, status, err := ForkBaseTemplate(*params.DashboardType, id) + if err != nil { + logrus.Errorf("Failed to create new dashboard template for user %s with type %s: %v", id.Identity.User.UserID, *params.DashboardType, err) + return nil, status, err + } + + newTemplate, status, err = ChangeDefaultTemplate(int64(newTemplate.ID), id) + if err != nil { + logrus.Errorf("Failed to set new dashboard template as default for user %s with type %s: %v", id.Identity.User.UserID, *params.DashboardType, err) + return nil, status, err + } + + return []api.DashboardTemplate{newTemplate}, http.StatusNotFound, nil + } + if _, status, err := handleServiceError( err, fmt.Sprintf("No dashboard templates found for user %s", id.Identity.User.UserID), diff --git a/pkg/service/DashboardTemplate_test.go b/pkg/service/DashboardTemplate_test.go index 12fe231..abd05e3 100644 --- a/pkg/service/DashboardTemplate_test.go +++ b/pkg/service/DashboardTemplate_test.go @@ -247,6 +247,180 @@ func TestForkBaseTemplate(t *testing.T) { }) } +// Helper function to create a test template with specific base name +func createTestTemplate(userID, baseName, displayName string) api.DashboardTemplate { + template := test_util.MockDashboardTemplateWithSpecificUser(userID) + template.TemplateBase.Name = baseName + template.TemplateBase.DisplayName = displayName + return template +} + +func TestGetUserTemplates(t *testing.T) { + t.Run("should filter templates by templateBase.Name when DashboardType is provided", func(t *testing.T) { + testUserID := test_util.GetUniqueUserID() + testIdentity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(testUserID)}, + xrhidgen.Entitlements{}, + ) + + // Create templates with different base names + template1 := createTestTemplate(testUserID, "dashboard-type-1", "Dashboard Type 1") + template2 := createTestTemplate(testUserID, "dashboard-type-2", "Dashboard Type 2") + template3 := createTestTemplate(testUserID, "dashboard-type-1", "Dashboard Type 1 Copy") + + // Save templates to database + require.NoError(t, database.DB.Create(&template1).Error) + require.NoError(t, database.DB.Create(&template2).Error) + require.NoError(t, database.DB.Create(&template3).Error) + + // Test filtering by dashboard-type-1 + params := api.GetWidgetLayoutParams{DashboardType: stringPtr("dashboard-type-1")} + templates, status, err := service.GetUserTemplates(testIdentity, params) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.Len(t, templates, 2, "Should return 2 templates with dashboard-type-1") + + // Verify both returned templates have the correct base name + for _, template := range templates { + assert.Equal(t, "dashboard-type-1", template.TemplateBase.Name) + assert.Equal(t, testUserID, template.UserId) + } + }) + + t.Run("should auto-create template from base when user has none", func(t *testing.T) { + testUserID := test_util.GetUniqueUserID() + testIdentity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(testUserID)}, + xrhidgen.Entitlements{}, + ) + + // Reset and add a base template to the registry + service.BaseTemplateRegistry = api.BaseWidgetDashboardTemplateRegistry{} + baseTemplate := api.BaseWidgetDashboardTemplate{ + Name: "auto-create-test", + DisplayName: "Auto Create Test", + TemplateConfig: api.DashboardTemplateConfig{ + Sm: datatypes.NewJSONType([]api.WidgetItem{}), + Md: datatypes.NewJSONType([]api.WidgetItem{}), + Lg: datatypes.NewJSONType([]api.WidgetItem{}), + Xl: datatypes.NewJSONType([]api.WidgetItem{}), + }, + } + service.BaseTemplateRegistry.AddBase(baseTemplate) + + // Test filtering by a dashboard type that exists as base template but user has no templates + params := api.GetWidgetLayoutParams{DashboardType: stringPtr("auto-create-test")} + templates, status, err := service.GetUserTemplates(testIdentity, params) + + assert.NoError(t, err, "Should not return error when auto-creating from base template") + assert.Equal(t, http.StatusNotFound, status, "Should return 404 status (but with created template)") + assert.Len(t, templates, 1, "Should return the newly created template") + assert.Equal(t, "auto-create-test", templates[0].TemplateBase.Name) + assert.Equal(t, testUserID, templates[0].UserId) + assert.True(t, templates[0].Default, "Auto-created template should be set as default") + + // Verify template was actually saved to database + var dbTemplate api.DashboardTemplate + err = database.DB.Where("user_id = ? AND name = ?", testUserID, "auto-create-test").First(&dbTemplate).Error + assert.NoError(t, err, "Auto-created template should be saved in database") + }) + + t.Run("should return 404 error when filtering by non-existent base template", func(t *testing.T) { + testUserID := test_util.GetUniqueUserID() + testIdentity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(testUserID)}, + xrhidgen.Entitlements{}, + ) + + // Create a template with a different base name for this user + template := createTestTemplate(testUserID, "existing-dashboard", "Existing Dashboard") + require.NoError(t, database.DB.Create(&template).Error) + + // Test filtering by non-existent dashboard type (no base template exists) + params := api.GetWidgetLayoutParams{DashboardType: stringPtr("non-existent-dashboard")} + templates, status, err := service.GetUserTemplates(testIdentity, params) + + assert.Error(t, err, "Should return error when base template doesn't exist") + assert.Equal(t, http.StatusNotFound, status, "Should return 404 when base template not found") + assert.Nil(t, templates, "Should return nil templates on error") + assert.Contains(t, err.Error(), "base template", "Error should mention base template") + }) + + t.Run("should return all user templates when DashboardType is not provided", func(t *testing.T) { + testUserID := test_util.GetUniqueUserID() + testIdentity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(testUserID)}, + xrhidgen.Entitlements{}, + ) + + // Create multiple templates with different base names + templates := []api.DashboardTemplate{ + createTestTemplate(testUserID, "type-a", "Type A"), + createTestTemplate(testUserID, "type-b", "Type B"), + createTestTemplate(testUserID, "type-c", "Type C"), + } + + // Save templates to database + for _, template := range templates { + require.NoError(t, database.DB.Create(&template).Error) + } + + // Test without filtering (DashboardType is nil) + params := api.GetWidgetLayoutParams{DashboardType: nil} + result, status, err := service.GetUserTemplates(testIdentity, params) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.Len(t, result, 3, "Should return all 3 templates when no filter is applied") + + // Verify all templates belong to the test user and extract names + names := make([]string, len(result)) + for i, template := range result { + assert.Equal(t, testUserID, template.UserId) + names[i] = template.TemplateBase.Name + } + + // Verify we have all expected template names + assert.Contains(t, names, "type-a") + assert.Contains(t, names, "type-b") + assert.Contains(t, names, "type-c") + }) + + t.Run("should only return templates for the requesting user when filtering", func(t *testing.T) { + user1ID := test_util.GetUniqueUserID() + user2ID := test_util.GetUniqueUserID() + user1Identity := test_util.GenerateIdentityStructFromTemplate( + xrhidgen.Identity{}, + xrhidgen.User{UserID: stringPtr(user1ID)}, + xrhidgen.Entitlements{}, + ) + + // Create templates for both users with the same base name + template1 := createTestTemplate(user1ID, "shared-dashboard-type", "User 1 Dashboard") + template2 := createTestTemplate(user2ID, "shared-dashboard-type", "User 2 Dashboard") + + // Save templates to database + require.NoError(t, database.DB.Create(&template1).Error) + require.NoError(t, database.DB.Create(&template2).Error) + + // Test filtering as user1 - should only get user1's template + params := api.GetWidgetLayoutParams{DashboardType: stringPtr("shared-dashboard-type")} + templates, status, err := service.GetUserTemplates(user1Identity, params) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.Len(t, templates, 1, "Should return only 1 template for user1") + assert.Equal(t, "shared-dashboard-type", templates[0].TemplateBase.Name) + assert.Equal(t, user1ID, templates[0].UserId) + assert.Equal(t, "User 1 Dashboard", templates[0].TemplateBase.DisplayName) + }) +} + // Helper function for creating string pointers func stringPtr(s string) *string { return &s diff --git a/pkg/service/WidgetMapping_test.go b/pkg/service/WidgetMapping_test.go index 9040f58..1547dc5 100644 --- a/pkg/service/WidgetMapping_test.go +++ b/pkg/service/WidgetMapping_test.go @@ -189,8 +189,21 @@ func TestGetWidgetMappings(t *testing.T) { t.Run("should handle widget mapping with permissions", func(t *testing.T) { // Reset registry service.WidgetMappingRegistry = api.WidgetMappingRegistry{} + args := []interface{}{"arg1", "arg2", "arg3"} - permissions := []string{"read:widgets", "write:widgets", "admin:widgets"} + permissions := []api.Permission{ + { + Method: "permissions", + Args: &args, + }, + { + Method: "view", + }, + { + Method: "edit", + Args: &args, + }, + } widget := api.WidgetModuleFederationMetadata{ Scope: "test-scope", Module: "test-module", diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 544d01b..5b3f3bb 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -7,13 +7,20 @@ paths: get: summary: Get the dashboard templates operationId: getWidgetLayout + parameters: + - name: dashboardType + in: query + required: false + description: The type of dashboard to filter by + schema: + type: string responses: '200': description: A list of dashboard templates content: application/json: schema: - $ref: '#/components/schemas/DashboardTemplateList' + $ref: '#/components/schemas/DashboardTemplateListResponse' '500': description: Internal server error content: @@ -253,7 +260,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/BaseWidgetDashboardTemplateList' + $ref: '#/components/schemas/BaseWidgetDashboardTemplateListResponse' '500': description: Internal server error content: @@ -331,8 +338,11 @@ paths: application/json: schema: type: object - additionalProperties: - $ref: '#/components/schemas/WidgetModuleFederationMetadata' + properties: + data: + type: object + additionalProperties: + $ref: '#/components/schemas/WidgetModuleFederationMetadata' '500': description: Internal server error content: @@ -341,6 +351,22 @@ paths: $ref: '#/components/schemas/ErrorResponse' components: schemas: + Permission: + type: object + properties: + method: + type: string + description: The method for the permission + x-oapi-codegen-extra-tags: + yaml: "method" + args: + type: array + items: {} + description: The arguments for the method, can be of any type + x-oapi-codegen-extra-tags: + yaml: "args,omitempty" + required: + - method WidgetItem: type: object required: @@ -594,6 +620,27 @@ components: type: array items: $ref: '#/components/schemas/DashboardTemplate' + ListResponseMeta: + type: object + properties: + count: + type: integer + description: The total number of items in the response + required: + - count + DashboardTemplateListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DashboardTemplate' + description: The list of dashboard templates + meta: + $ref: '#/components/schemas/ListResponseMeta' + required: + - data + - meta BaseWidgetDashboardTemplate: type: object properties: @@ -619,6 +666,19 @@ components: type: array items: $ref: '#/components/schemas/BaseWidgetDashboardTemplate' + BaseWidgetDashboardTemplateListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/BaseWidgetDashboardTemplate' + description: The list of base widget dashboard templates + meta: + $ref: '#/components/schemas/ListResponseMeta' + required: + - data + - meta WidgetHeaderLink: type: object properties: @@ -644,9 +704,10 @@ components: $ref: '#/components/schemas/WidgetHeaderLink' permissions: type: array + x-oapi-codegen-extra-tags: + yaml: "permissions,omitempty" items: - # TODO: Define the permission schema - type: string + $ref: '#/components/schemas/Permission' description: The permissions required to view the widget required: - title