mirror of https://github.com/portainer/portainer
				
				
				
			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 clearerpull/6595/head^2
							parent
							
								
									ff7847aaa5
								
							
						
					
					
						commit
						a894e3182a
					
				| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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, []);
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export { DashboardViewAngular, DashboardView } from './DashboardView';
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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} />);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue