From a894e3182af74d8fb3acee46aef7bddd559b6dba Mon Sep 17 00:00:00 2001 From: itsconquest Date: Fri, 25 Feb 2022 12:22:56 +1300 Subject: [PATCH] refactor(azure/aci): migrate dashboard view to react [EE-2189] (#6518) * refactor(azure/aci): migrate dashboard view to react [EE-2189] * move aggregate function to azure utils file * fix type * introduce dashboard item component * add error notificatons * hide resource groups widget if failed to load * make dashboard a default export * revert mistake * refactor based on suggestions * use object for error data instead of array * return unused utils file * move length calculations out of return statement * only return first error of resource groups queries * refactor imports/exports, fix bug with errors & add test * WIP dashboard tests * allow mocking multiple resource groups * test for total number of resource groups * update lock file to fix lint action issue * finish dashboard tests * dashboarditem story * fix(auth): remove caching of user * add option for link to dashboard item * rename dashboard test case to match file * remove optional link and update storybook * create aria label based on already provided text * change param name to be clearer --- app/azure/Dashboard/DashboardView.test.tsx | 144 + app/azure/Dashboard/DashboardView.tsx | 75 + app/azure/Dashboard/index.ts | 1 + app/azure/_module.js | 141 +- app/azure/queries.ts | 70 + app/azure/views/dashboard/dashboard.html | 33 - .../views/dashboard/dashboardController.js | 23 - .../Dashboard/DashboardItem.stories.tsx | 36 + .../Dashboard/DashboardItem.test.tsx | 35 + .../components/Dashboard/DashboardItem.tsx | 27 + app/portainer/hooks/useEnvironmentId.ts | 13 + app/portainer/hooks/useUser.tsx | 7 +- app/react-tools/test-mocks.ts | 18 + yarn.lock | 9933 +++++++---------- 14 files changed, 4773 insertions(+), 5783 deletions(-) create mode 100644 app/azure/Dashboard/DashboardView.test.tsx create mode 100644 app/azure/Dashboard/DashboardView.tsx create mode 100644 app/azure/Dashboard/index.ts create mode 100644 app/azure/queries.ts delete mode 100644 app/azure/views/dashboard/dashboard.html delete mode 100644 app/azure/views/dashboard/dashboardController.js create mode 100644 app/portainer/components/Dashboard/DashboardItem.stories.tsx create mode 100644 app/portainer/components/Dashboard/DashboardItem.test.tsx create mode 100644 app/portainer/components/Dashboard/DashboardItem.tsx create mode 100644 app/portainer/hooks/useEnvironmentId.ts diff --git a/app/azure/Dashboard/DashboardView.test.tsx b/app/azure/Dashboard/DashboardView.test.tsx new file mode 100644 index 000000000..bb6810ea2 --- /dev/null +++ b/app/azure/Dashboard/DashboardView.test.tsx @@ -0,0 +1,144 @@ +import { renderWithQueryClient, within } from '@/react-tools/test-utils'; +import { UserContext } from '@/portainer/hooks/useUser'; +import { UserViewModel } from '@/portainer/models/user'; +import { server, rest } from '@/setup-tests/server'; +import { + createMockResourceGroups, + createMockSubscriptions, +} from '@/react-tools/test-mocks'; + +import { DashboardView } from './DashboardView'; + +jest.mock('@uirouter/react', () => ({ + ...jest.requireActual('@uirouter/react'), + useCurrentStateAndParams: jest.fn(() => ({ + params: { endpointId: 1 }, + })), +})); + +test('dashboard items should render correctly', async () => { + const { getByLabelText } = await renderComponent(); + + const subscriptionsItem = getByLabelText('Subscriptions'); + expect(subscriptionsItem).toBeVisible(); + + const subscriptionElements = within(subscriptionsItem); + expect(subscriptionElements.getByLabelText('value')).toBeVisible(); + expect(subscriptionElements.getByLabelText('icon')).toHaveClass('fa-th-list'); + expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent( + 'Subscriptions' + ); + + const resourceGroupsItem = getByLabelText('Resource groups'); + expect(resourceGroupsItem).toBeVisible(); + + const resourceGroupElements = within(resourceGroupsItem); + expect(resourceGroupElements.getByLabelText('value')).toBeVisible(); + expect(resourceGroupElements.getByLabelText('icon')).toHaveClass( + 'fa-th-list' + ); + expect( + resourceGroupElements.getByLabelText('resourceType') + ).toHaveTextContent('Resource groups'); +}); + +test('when there are no subscriptions, should show 0 subscriptions and 0 resource groups', async () => { + const { getByLabelText } = await renderComponent(); + + const subscriptionElements = within(getByLabelText('Subscriptions')); + expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('0'); + + const resourceGroupElements = within(getByLabelText('Resource groups')); + expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('0'); +}); + +test('when there is subscription & resource group data, should display these', async () => { + const { getByLabelText } = await renderComponent(1, { 'subscription-1': 2 }); + + const subscriptionElements = within(getByLabelText('Subscriptions')); + expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('1'); + + const resourceGroupElements = within(getByLabelText('Resource groups')); + expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('2'); +}); + +test('should correctly show total number of resource groups across multiple subscriptions', async () => { + const { getByLabelText } = await renderComponent(2, { + 'subscription-1': 2, + 'subscription-2': 3, + }); + + const resourceGroupElements = within(getByLabelText('Resource groups')); + expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('5'); +}); + +test('when only subscriptions fail to load, dont show the dashboard', async () => { + const { queryByLabelText } = await renderComponent( + 1, + { 'subscription-1': 1 }, + 500, + 200 + ); + expect(queryByLabelText('Subscriptions')).not.toBeInTheDocument(); + expect(queryByLabelText('Resource groups')).not.toBeInTheDocument(); +}); + +test('when only resource groups fail to load, still show the subscriptions', async () => { + const { queryByLabelText } = await renderComponent( + 1, + { 'subscription-1': 1 }, + 200, + 500 + ); + expect(queryByLabelText('Subscriptions')).toBeInTheDocument(); + expect(queryByLabelText('Resource groups')).not.toBeInTheDocument(); +}); + +async function renderComponent( + subscriptionsCount = 0, + resourceGroups: Record = {}, + subscriptionsStatus = 200, + resourceGroupsStatus = 200 +) { + const user = new UserViewModel({ Username: 'user' }); + const state = { user }; + + server.use( + rest.get( + '/api/endpoints/:endpointId/azure/subscriptions', + (req, res, ctx) => + res( + ctx.json(createMockSubscriptions(subscriptionsCount)), + ctx.status(subscriptionsStatus) + ) + ), + rest.get( + '/api/endpoints/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups', + (req, res, ctx) => { + if (typeof req.params.subscriptionId !== 'string') { + throw new Error("Provided subscriptionId must be of type: 'string'"); + } + + const { subscriptionId } = req.params; + return res( + ctx.json( + createMockResourceGroups( + req.params.subscriptionId, + resourceGroups[subscriptionId] || 0 + ) + ), + ctx.status(resourceGroupsStatus) + ); + } + ) + ); + const renderResult = renderWithQueryClient( + + + + ); + + await expect(renderResult.findByText(/Home/)).resolves.toBeVisible(); + + return renderResult; +} diff --git a/app/azure/Dashboard/DashboardView.tsx b/app/azure/Dashboard/DashboardView.tsx new file mode 100644 index 000000000..4bd1f695c --- /dev/null +++ b/app/azure/Dashboard/DashboardView.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react'; + +import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId'; +import { PageHeader } from '@/portainer/components/PageHeader'; +import { DashboardItem } from '@/portainer/components/Dashboard/DashboardItem'; +import { error as notifyError } from '@/portainer/services/notifications'; +import PortainerError from '@/portainer/error'; +import { r2a } from '@/react-tools/react2angular'; + +import { useResourceGroups, useSubscriptions } from '../queries'; + +export function DashboardView() { + const environmentId = useEnvironmentId(); + + const subscriptionsQuery = useSubscriptions(environmentId); + useEffect(() => { + if (subscriptionsQuery.isError) { + notifyError( + 'Failure', + subscriptionsQuery.error as PortainerError, + 'Unable to retrieve subscriptions' + ); + } + }, [subscriptionsQuery.error, subscriptionsQuery.isError]); + + const resourceGroupsQuery = useResourceGroups( + environmentId, + subscriptionsQuery.data + ); + useEffect(() => { + if (resourceGroupsQuery.isError && resourceGroupsQuery.error) { + notifyError( + 'Failure', + resourceGroupsQuery.error as PortainerError, + `Unable to retrieve resource groups` + ); + } + }, [resourceGroupsQuery.error, resourceGroupsQuery.isError]); + + const isLoading = + subscriptionsQuery.isLoading || resourceGroupsQuery.isLoading; + if (isLoading) { + return null; + } + + const subscriptionsCount = subscriptionsQuery?.data?.length; + const resourceGroupsCount = Object.values( + resourceGroupsQuery?.resourceGroups + ).flatMap((x) => Object.values(x)).length; + + return ( + <> + + + {!subscriptionsQuery.isError && ( +
+ + {!resourceGroupsQuery.isError && ( + + )} +
+ )} + + ); +} + +export const DashboardViewAngular = r2a(DashboardView, []); diff --git a/app/azure/Dashboard/index.ts b/app/azure/Dashboard/index.ts new file mode 100644 index 000000000..a344c4fc4 --- /dev/null +++ b/app/azure/Dashboard/index.ts @@ -0,0 +1 @@ +export { DashboardViewAngular, DashboardView } from './DashboardView'; diff --git a/app/azure/_module.js b/app/azure/_module.js index 57068d13c..7cd0c77ee 100644 --- a/app/azure/_module.js +++ b/app/azure/_module.js @@ -1,82 +1,85 @@ import angular from 'angular'; +import { DashboardViewAngular } from './Dashboard/DashboardView'; import { containerInstancesModule } from './ContainerInstances'; -angular.module('portainer.azure', ['portainer.app', containerInstancesModule]).config([ - '$stateRegistryProvider', - function ($stateRegistryProvider) { - 'use strict'; +angular + .module('portainer.azure', ['portainer.app', containerInstancesModule]) + .config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + 'use strict'; - var azure = { - name: 'azure', - url: '/azure', - parent: 'endpoint', - abstract: true, - onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, EndpointProvider, Notifications, StateManager) { - return $async(async () => { - if (endpoint.Type !== 3) { - $state.go('portainer.home'); - return; - } - try { - EndpointProvider.setEndpointID(endpoint.Id); - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - EndpointProvider.setOfflineModeFromStatus(endpoint.Status); - await StateManager.updateEndpointState(endpoint); - } catch (e) { - Notifications.error('Failed loading environment', e); - $state.go('portainer.home', {}, { reload: true }); - } - }); - }, - }; - - var containerInstances = { - name: 'azure.containerinstances', - url: '/containerinstances', - views: { - 'content@': { - templateUrl: './views/containerinstances/containerinstances.html', - controller: 'AzureContainerInstancesController', + var azure = { + name: 'azure', + url: '/azure', + parent: 'endpoint', + abstract: true, + onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, EndpointProvider, Notifications, StateManager) { + return $async(async () => { + if (endpoint.Type !== 3) { + $state.go('portainer.home'); + return; + } + try { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + await StateManager.updateEndpointState(endpoint, []); + } catch (e) { + Notifications.error('Failed loading environment', e); + $state.go('portainer.home', {}, { reload: true }); + } + }); }, - }, - }; + }; - var containerInstance = { - name: 'azure.containerinstances.container', - url: '/:id', - views: { - 'content@': { - component: 'containerInstanceDetails', + var containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + templateUrl: './views/containerinstances/containerinstances.html', + controller: 'AzureContainerInstancesController', + }, }, - }, - }; + }; - var containerInstanceCreation = { - name: 'azure.containerinstances.new', - url: '/new/', - views: { - 'content@': { - component: 'createContainerInstanceView', + var containerInstance = { + name: 'azure.containerinstances.container', + url: '/:id', + views: { + 'content@': { + component: 'containerInstanceDetails', + }, }, - }, - }; + }; - var dashboard = { - name: 'azure.dashboard', - url: '/dashboard', - views: { - 'content@': { - templateUrl: './views/dashboard/dashboard.html', - controller: 'AzureDashboardController', + var containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + component: 'createContainerInstanceView', + }, }, - }, - }; + }; - $stateRegistryProvider.register(azure); - $stateRegistryProvider.register(containerInstances); - $stateRegistryProvider.register(containerInstance); - $stateRegistryProvider.register(containerInstanceCreation); - $stateRegistryProvider.register(dashboard); - }, -]); + var dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + component: 'dashboardView', + }, + }, + }; + + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstance); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); + }, + ]) + .component('dashboardView', DashboardViewAngular).name; diff --git a/app/azure/queries.ts b/app/azure/queries.ts new file mode 100644 index 000000000..373b70314 --- /dev/null +++ b/app/azure/queries.ts @@ -0,0 +1,70 @@ +import _ from 'lodash'; +import { useQueries, useQuery } from 'react-query'; + +import { EnvironmentId } from '@/portainer/environments/types'; + +import { getResourceGroups } from './services/resource-groups.service'; +import { getSubscriptions } from './services/subscription.service'; +import { Subscription } from './types'; + +export function useSubscriptions(environmentId: EnvironmentId) { + return useQuery( + 'azure.subscriptions', + () => getSubscriptions(environmentId), + { + meta: { + error: { + title: 'Failure', + message: 'Unable to retrieve Azure subscriptions', + }, + }, + } + ); +} + +export function useResourceGroups( + environmentId: EnvironmentId, + subscriptions: Subscription[] = [] +) { + const queries = useQueries( + subscriptions.map((subscription) => ({ + queryKey: [ + 'azure', + environmentId, + 'subscriptions', + subscription.subscriptionId, + 'resourceGroups', + ], + queryFn: async () => { + const groups = await getResourceGroups( + environmentId, + subscription.subscriptionId + ); + return [subscription.subscriptionId, groups] as const; + }, + meta: { + error: { + title: 'Failure', + message: 'Unable to retrieve Azure resource groups', + }, + }, + })) + ); + + return { + resourceGroups: Object.fromEntries( + _.compact( + queries.map((q) => { + if (q.data) { + return q.data; + } + + return null; + }) + ) + ), + isLoading: queries.some((q) => q.isLoading), + isError: queries.some((q) => q.isError), + error: queries.find((q) => q.error)?.error || null, + }; +} diff --git a/app/azure/views/dashboard/dashboard.html b/app/azure/views/dashboard/dashboard.html deleted file mode 100644 index eaa60a53e..000000000 --- a/app/azure/views/dashboard/dashboard.html +++ /dev/null @@ -1,33 +0,0 @@ - - - Dashboard - - - diff --git a/app/azure/views/dashboard/dashboardController.js b/app/azure/views/dashboard/dashboardController.js deleted file mode 100644 index 643f900a7..000000000 --- a/app/azure/views/dashboard/dashboardController.js +++ /dev/null @@ -1,23 +0,0 @@ -angular.module('portainer.azure').controller('AzureDashboardController', [ - '$scope', - 'AzureService', - 'Notifications', - function ($scope, AzureService, Notifications) { - function initView() { - AzureService.subscriptions() - .then(function success(data) { - var subscriptions = data; - $scope.subscriptions = subscriptions; - return AzureService.resourceGroups(subscriptions); - }) - .then(function success(data) { - $scope.resourceGroups = AzureService.aggregate(data); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to load dashboard data'); - }); - } - - initView(); - }, -]); diff --git a/app/portainer/components/Dashboard/DashboardItem.stories.tsx b/app/portainer/components/Dashboard/DashboardItem.stories.tsx new file mode 100644 index 000000000..4a60a105c --- /dev/null +++ b/app/portainer/components/Dashboard/DashboardItem.stories.tsx @@ -0,0 +1,36 @@ +import { Meta, Story } from '@storybook/react'; + +import { Link } from '@/portainer/components/Link'; + +import { DashboardItem } from './DashboardItem'; + +const meta: Meta = { + title: 'Components/DashboardItem', + component: DashboardItem, +}; +export default meta; + +interface StoryProps { + value: number; + icon: string; + type: string; +} + +function Template({ value, icon, type }: StoryProps) { + return ; +} + +export const Primary: Story = Template.bind({}); +Primary.args = { + value: 1, + icon: 'fa fa-th-list', + type: 'Example resource', +}; + +export function WithLink() { + return ( + + + + ); +} diff --git a/app/portainer/components/Dashboard/DashboardItem.test.tsx b/app/portainer/components/Dashboard/DashboardItem.test.tsx new file mode 100644 index 000000000..aaa1a33e2 --- /dev/null +++ b/app/portainer/components/Dashboard/DashboardItem.test.tsx @@ -0,0 +1,35 @@ +import { render } from '@/react-tools/test-utils'; + +import { DashboardItem } from './DashboardItem'; + +test('should show provided resource value', async () => { + const { getByLabelText } = renderComponent(1); + const value = getByLabelText('value'); + + expect(value).toBeVisible(); + expect(value).toHaveTextContent('1'); +}); + +test('should show provided icon', async () => { + const { getByLabelText } = renderComponent(0, 'fa fa-th-list'); + const icon = getByLabelText('icon'); + expect(icon).toHaveClass('fa-th-list'); +}); + +test('should show provided resource type', async () => { + const { getByLabelText } = renderComponent(0, '', 'Test'); + const title = getByLabelText('resourceType'); + + expect(title).toBeVisible(); + expect(title).toHaveTextContent('Test'); +}); + +test('should have accessibility label created from the provided resource type', async () => { + const { getByLabelText } = renderComponent(0, '', 'testLabel'); + + expect(getByLabelText('testLabel')).toBeTruthy(); +}); + +function renderComponent(value = 0, icon = '', type = '') { + return render(); +} diff --git a/app/portainer/components/Dashboard/DashboardItem.tsx b/app/portainer/components/Dashboard/DashboardItem.tsx new file mode 100644 index 000000000..7541fdb09 --- /dev/null +++ b/app/portainer/components/Dashboard/DashboardItem.tsx @@ -0,0 +1,27 @@ +import { Widget, WidgetBody } from '@/portainer/components/widget'; + +interface Props { + value: number; + icon: string; + type: string; +} + +export function DashboardItem({ value, icon, type }: Props) { + return ( +
+ + +
+