1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

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
This commit is contained in:
itsconquest 2022-02-25 12:22:56 +13:00 committed by GitHub
parent ff7847aaa5
commit a894e3182a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 4773 additions and 5783 deletions

View file

@ -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<string, number> = {},
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(
<UserContext.Provider value={state}>
<DashboardView />
</UserContext.Provider>
);
await expect(renderResult.findByText(/Home/)).resolves.toBeVisible();
return renderResult;
}

View file

@ -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 (
<>
<PageHeader title="Home" breadcrumbs={[{ label: 'Dashboard' }]} />
{!subscriptionsQuery.isError && (
<div className="row">
<DashboardItem
value={subscriptionsCount as number}
icon="fa fa-th-list"
type="Subscriptions"
/>
{!resourceGroupsQuery.isError && (
<DashboardItem
value={resourceGroupsCount as number}
icon="fa fa-th-list"
type="Resource groups"
/>
)}
</div>
)}
</>
);
}
export const DashboardViewAngular = r2a(DashboardView, []);

View file

@ -0,0 +1 @@
export { DashboardViewAngular, DashboardView } from './DashboardView';

View file

@ -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;

70
app/azure/queries.ts Normal file
View file

@ -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,
};
}

View file

@ -1,33 +0,0 @@
<rd-header>
<rd-header-title title-text="Home"></rd-header-title>
<rd-header-content>Dashboard</rd-header-content>
</rd-header>
<div class="row" ng-if="subscriptions">
<div class="col-sm-12 col-md-6">
<a ui-sref="azure.subscriptions">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-th-list"></i>
</div>
<div class="title">{{ subscriptions.length }}</div>
<div class="comment">Subscriptions</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
<div class="col-sm-12 col-md-6" ng-if="resourceGroups">
<a ui-sref="azure.resourceGroups">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-th-list"></i>
</div>
<div class="title">{{ resourceGroups.length }}</div>
<div class="comment">Resource groups</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
</div>

View file

@ -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();
},
]);