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
pull/6595/head^2
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();
},
]);

View File

@ -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 <DashboardItem value={value} icon={icon} type={type} />;
}
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
value: 1,
icon: 'fa fa-th-list',
type: 'Example resource',
};
export function WithLink() {
return (
<Link to="example.page">
<DashboardItem value={1} icon="fa fa-th-list" type="Example resource" />
</Link>
);
}

View File

@ -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(<DashboardItem value={value} icon={icon} type={type} />);
}

View File

@ -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 (
<div className="col-sm-12 col-md-6" aria-label={type}>
<Widget>
<WidgetBody>
<div className="widget-icon blue pull-left">
<i className={icon} aria-hidden="true" aria-label="icon" />
</div>
<div className="title" aria-label="value">
{value}
</div>
<div className="comment" aria-label="resourceType">
{type}
</div>
</WidgetBody>
</Widget>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { useCurrentStateAndParams } from '@uirouter/react';
export function useEnvironmentId() {
const {
params: { endpointId: environmentId },
} = useCurrentStateAndParams();
if (!environmentId) {
throw new Error('endpointId url param is required');
}
return environmentId;
}

View File

@ -19,8 +19,6 @@ interface State {
user?: UserViewModel | null;
}
const state: State = {};
export const UserContext = createContext<State | null>(null);
export function useUser() {
@ -93,9 +91,7 @@ export function UserProvider({ children }: UserProviderProps) {
const [user, setUser] = useState<UserViewModel | null>(null);
useEffect(() => {
if (state.user) {
setUser(state.user);
} else if (jwt !== '') {
if (jwt !== '') {
const tokenPayload = jwtDecode(jwt) as { id: number };
loadUser(tokenPayload.id);
@ -120,7 +116,6 @@ export function UserProvider({ children }: UserProviderProps) {
async function loadUser(id: number) {
const user = await getUser(id);
state.user = user;
setUser(user);
}
}

View File

@ -20,3 +20,21 @@ export function createMockTeams(count: number) {
Name: `team${value}`,
}));
}
export function createMockSubscriptions(count: number) {
const subscriptions = _.range(1, count + 1).map((x) => ({
id: `/subscriptions/subscription-${x}`,
subscriptionId: `subscription-${x}`,
}));
return { value: subscriptions };
}
export function createMockResourceGroups(subscription: string, count: number) {
const resourceGroups = _.range(1, count + 1).map((x) => ({
id: `/subscriptions/${subscription}/resourceGroups/resourceGroup-${x}`,
name: `resourcegroup-${x}`,
}));
return { value: resourceGroups };
}

9933
yarn.lock

File diff suppressed because it is too large Load Diff