Forráskód Böngészése

[ui-adminpage] Port usage analytics endpoints to public (#4126)

Ananya_Agarwal 3 hónapja
szülő
commit
5d07911107

+ 1 - 1
desktop/core/src/desktop/js/apps/admin/Components/utils.ts

@@ -16,7 +16,7 @@
 
 export const SERVER_LOGS_API_URL = '/api/v1/logs';
 export const CHECK_CONFIG_EXAMPLES_API_URL = '/api/v1/check_config';
-export const ANALYTICS_PREFERENCES_API_URL = '/about/update_preferences';
 export const INSTALL_APP_EXAMPLES_API_URL = '/api/v1/install_app_examples';
 export const INSTALL_AVAILABLE_EXAMPLES_API_URL = '/api/v1/available_app_examples';
 export const HUE_DOCS_CONFIG_URL = 'https://docs.gethue.com/administrator/configuration/';
+export const USAGE_ANALYTICS_API_URL = '/api/v1/usage_analytics';

+ 57 - 39
desktop/core/src/desktop/js/apps/admin/Overview/Analytics.tsx

@@ -14,63 +14,81 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import React, { useState } from 'react';
+import React from 'react';
 import huePubSub from '../../../utils/huePubSub';
 import { i18nReact } from '../../../utils/i18nReact';
 import Input from 'cuix/dist/components/Input';
-import { post } from '../../../api/utils';
-import { ANALYTICS_PREFERENCES_API_URL } from '../Components/utils';
+import { USAGE_ANALYTICS_API_URL } from '../Components/utils';
+import { HueAlert } from '../../../reactComponents/GlobalAlert/types';
+import { GLOBAL_INFO_TOPIC } from '../../../reactComponents/GlobalAlert/events';
+import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
+import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
+import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper';
+import { HttpMethod } from '../../../api/utils';
 import './Overview.scss';
 
-interface UpdatePreferences {
-  status: number;
-  message?: string;
+interface UsageAnalyticsResponse {
+  collectUsage?: boolean;
 }
 
 const Analytics = (): JSX.Element => {
-  const [collectUsage, setCollectUsage] = useState<boolean>(false);
   const { t } = i18nReact.useTranslation();
+  const {
+    data: usageAnalyticsData,
+    loading: loadingAnalytics,
+    error: usageAnalyticsError,
+    reloadData
+  } = useLoadData<UsageAnalyticsResponse>(USAGE_ANALYTICS_API_URL);
 
-  const saveCollectUsagePreference = async (collectUsage: boolean) => {
-    const response = await post<UpdatePreferences>(ANALYTICS_PREFERENCES_API_URL, {
-      collect_usage: collectUsage
-    });
-
-    if (response.status === 0) {
-      const successMessage = collectUsage
+  const {
+    save: updateAnalyticsPreference,
+    loading: updatingAnalyticsPreference,
+    error: updateAnalyticsPreferenceError
+  } = useSaveData<{ collect_usage: boolean }>(USAGE_ANALYTICS_API_URL, {
+    method: HttpMethod.PUT,
+    onSuccess: response => {
+      reloadData();
+      const successMessage = response.collect_usage
         ? t('Analytics have been activated.')
         : t('Analytics have been deactivated.');
-      huePubSub.publish('hue.global.info', { message: successMessage });
-    } else {
-      const errorMessage = collectUsage
-        ? t('Failed to activate analytics.')
-        : t('Failed to deactivate analytics.');
-      huePubSub.publish('hue.global.error', { message: errorMessage });
+      huePubSub.publish<HueAlert>(GLOBAL_INFO_TOPIC, { message: successMessage });
     }
-  };
+  });
+
+  const errors = [
+    {
+      enabled: !!usageAnalyticsError,
+      message: usageAnalyticsError ?? t('An unknown error occurred while fetching data.')
+    },
+    {
+      enabled: !!updateAnalyticsPreferenceError,
+      message: updateAnalyticsPreferenceError ?? t('Failed to update analytics.')
+    }
+  ];
 
-  const handleCheckboxChange = event => {
-    const newPreference = event.target.checked;
-    setCollectUsage(newPreference);
-    saveCollectUsagePreference(newPreference);
+  const handleAnalyticsCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    updateAnalyticsPreference({ collect_usage: event.target.checked });
   };
 
   return (
-    <div className="overview-analytics">
-      <h3>{t('Anonymous usage analytics')}</h3>
-      <div className="analytics-checkbox-container">
-        <Input
-          type="checkbox"
-          className="analytics__checkbox-icon"
-          id="usage_analytics"
-          checked={collectUsage}
-          onChange={handleCheckboxChange}
-        />
-        <label htmlFor="usage_analytics" className="usage__analytics">
-          {t('Help improve Hue with anonymous usage analytics.')}
-        </label>
+    <LoadingErrorWrapper loading={loadingAnalytics || updatingAnalyticsPreference} errors={errors}>
+      <div className="overview-analytics">
+        <h3>{t('Anonymous usage analytics')}</h3>
+        <div className="analytics-checkbox-container">
+          <Input
+            type="checkbox"
+            className="analytics__checkbox-icon"
+            id="usage_analytics"
+            checked={!!usageAnalyticsData?.collectUsage}
+            onChange={handleAnalyticsCheckboxChange}
+            disabled={loadingAnalytics || updatingAnalyticsPreference}
+          />
+          <label htmlFor="usage_analytics" className="usage__analytics">
+            {t('Help improve Hue with anonymous usage analytics.')}
+          </label>
+        </div>
       </div>
-    </div>
+    </LoadingErrorWrapper>
   );
 };
 

+ 250 - 50
desktop/core/src/desktop/js/apps/admin/Overview/OverviewTab.test.tsx

@@ -14,23 +14,31 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import '@testing-library/jest-dom';
 import React from 'react';
+import '@testing-library/jest-dom';
 import { render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import Overview from './OverviewTab';
-import * as hueConfigModule from '../../../config/hueConfig';
 import Examples from './Examples';
 import Analytics from './Analytics';
 import {
   INSTALL_APP_EXAMPLES_API_URL,
-  INSTALL_AVAILABLE_EXAMPLES_API_URL
+  INSTALL_AVAILABLE_EXAMPLES_API_URL,
+  USAGE_ANALYTICS_API_URL
 } from '../Components/utils';
-import { get, post } from '../../../api/utils';
+import { get, post, sendApiRequest } from '../../../api/utils';
+import * as hueConfigModule from '../../../config/hueConfig';
+import huePubSub from '../../../utils/huePubSub';
 
 jest.mock('../../../api/utils', () => ({
   post: jest.fn(),
-  get: jest.fn()
+  get: jest.fn(),
+  sendApiRequest: jest.fn(),
+  HttpMethod: {
+    POST: 'post',
+    PUT: 'put',
+    PATCH: 'patch'
+  }
 }));
 
 jest.mock('./ConfigStatus', () => () => <div>MockedConfigStatusComponent</div>);
@@ -39,6 +47,10 @@ jest.mock('../../../config/hueConfig', () => ({
   getLastKnownConfig: jest.fn()
 }));
 
+jest.mock('../../../utils/huePubSub', () => ({
+  publish: jest.fn()
+}));
+
 describe('OverviewTab', () => {
   beforeEach(() => {
     (hueConfigModule.getLastKnownConfig as jest.Mock).mockReturnValue({
@@ -49,33 +61,13 @@ describe('OverviewTab', () => {
     jest.clearAllMocks();
   });
 
-  test('renders the Tabs with the correct tab labels', () => {
+  it('renders the Tabs with the correct tab labels', () => {
     render(<Overview />);
     expect(screen.getByText('Config Status')).toBeInTheDocument();
     expect(screen.getByText('Examples')).toBeInTheDocument();
     expect(screen.getByText('Analytics')).toBeInTheDocument();
   });
 
-  describe('Analytics Component', () => {
-    test('renders Analytics tab and can interact with the checkbox', async () => {
-      (post as jest.Mock).mockResolvedValue({ status: 0, message: 'Success' });
-      render(<Analytics />);
-      const checkbox = screen.getByLabelText(/Help improve Hue with anonymous usage analytics./i);
-
-      expect(checkbox).not.toBeChecked();
-      await userEvent.click(checkbox);
-      expect(checkbox).toBeChecked();
-      expect(post).toHaveBeenCalledWith('/about/update_preferences', {
-        collect_usage: true
-      });
-      userEvent.click(checkbox);
-      await waitFor(() => expect(checkbox).not.toBeChecked());
-      expect(post).toHaveBeenCalledWith('/about/update_preferences', {
-        collect_usage: false
-      });
-    });
-  });
-
   describe('Examples component', () => {
     const availableAppsResponse = {
       apps: {
@@ -91,55 +83,263 @@ describe('OverviewTab', () => {
         }
         return Promise.reject();
       });
-
       (post as jest.Mock).mockImplementation(() =>
         Promise.resolve({ status: 0, message: 'Success' })
       );
     });
 
+    it('fetch and display available apps', async () => {
+      render(<Examples />);
+      expect(get).toHaveBeenCalledWith(INSTALL_AVAILABLE_EXAMPLES_API_URL, {});
+      for (const [, appName] of Object.entries(availableAppsResponse.apps)) {
+        expect(await screen.findByText(appName)).toBeInTheDocument();
+      }
+    });
+
+    it('post call to install apps without data like hive when the install button is clicked', async () => {
+      render(<Examples />);
+      const installButton = await screen.findByText('Hive');
+      await userEvent.click(installButton);
+      expect(post).toHaveBeenCalledWith(INSTALL_APP_EXAMPLES_API_URL, { app_name: 'hive' });
+    });
+
+    it('post call to install Solr Search example and its data when the install button is clicked', async () => {
+      render(<Examples />);
+      const solrData = ['log_analytics_demo', 'twitter_demo', 'yelp_demo'];
+      const installButton = await screen.findByText('Solr Search');
+      await userEvent.click(installButton);
+      for (const dataEntry of solrData) {
+        expect(post).toHaveBeenCalledWith(INSTALL_APP_EXAMPLES_API_URL, {
+          app_name: 'search',
+          data: dataEntry
+        });
+      }
+      expect(post).toHaveBeenCalledTimes(solrData.length);
+    });
+  });
+
+  describe('Analytics component', () => {
+    const mockAnalyticsData = { collectUsage: true };
+    const mockAnalyticsDataDisabled = { collectUsage: false };
+    const expectedFetchOptions = {
+      silenceErrors: true,
+      ignoreSuccessErrors: true
+    };
+    const expectedPostOptions = {
+      silenceErrors: true,
+      ignoreSuccessErrors: true,
+      qsEncodeData: false
+    };
+
+    const renderAnalyticsAndWaitForLoad = async () => {
+      render(<Analytics />);
+      await waitFor(() => {
+        expect(get).toHaveBeenCalledWith(USAGE_ANALYTICS_API_URL, undefined, expectedFetchOptions);
+      });
+    };
+
+    const getCheckboxElement = () => screen.getByRole('checkbox') as HTMLInputElement;
+
+    const expectCheckboxState = async (checked: boolean) => {
+      const checkbox = getCheckboxElement();
+      await waitFor(() => {
+        expect(checkbox.checked).toBe(checked);
+      });
+      return checkbox;
+    };
+
+    beforeEach(() => {
+      (get as jest.Mock).mockImplementation(url => {
+        if (url === USAGE_ANALYTICS_API_URL) {
+          return Promise.resolve(mockAnalyticsData);
+        }
+        return Promise.reject();
+      });
+      (sendApiRequest as jest.Mock).mockImplementation(() =>
+        Promise.resolve({ collect_usage: true })
+      );
+    });
+
     afterEach(() => {
       jest.clearAllMocks();
     });
 
-    test('fetch and display available apps', async () => {
-      render(<Examples />);
+    it('renders analytics title and checkbox', async () => {
+      await renderAnalyticsAndWaitForLoad();
+
+      expect(screen.getByText('Anonymous usage analytics')).toBeInTheDocument();
+      expect(
+        screen.getByLabelText('Help improve Hue with anonymous usage analytics.')
+      ).toBeInTheDocument();
+      expect(screen.getByRole('checkbox')).toBeInTheDocument();
+    });
+
+    it('fetches and displays analytics data on mount', async () => {
+      await renderAnalyticsAndWaitForLoad();
+      await expectCheckboxState(true);
+    });
+
+    it('checkbox reflects analytics data state when disabled', async () => {
+      (get as jest.Mock).mockResolvedValue(mockAnalyticsDataDisabled);
+      await renderAnalyticsAndWaitForLoad();
+      await expectCheckboxState(false);
+    });
+
+    it('shows loading state during initial data fetch', () => {
+      (get as jest.Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
+      render(<Analytics />);
+      expect(screen.getAllByTestId('loading-error-wrapper__spinner')[0]).toBeInTheDocument();
+    });
+
+    it('shows error message when data fetch fails', async () => {
+      const errorMessage = 'Failed to fetch analytics data';
+      (get as jest.Mock).mockRejectedValue(errorMessage);
+      render(<Analytics />);
 
       await waitFor(() => {
-        expect(get).toHaveBeenCalledWith(INSTALL_AVAILABLE_EXAMPLES_API_URL, {});
-        Object.entries(availableAppsResponse.apps).forEach(([, appName]) => {
-          expect(screen.getByText(appName)).toBeInTheDocument();
-        });
+        expect(screen.getByTestId('loading-error-wrapper__errors')).toBeInTheDocument();
       });
     });
 
-    test('post call to install apps without data like hive when the install button is clicked', async () => {
-      render(<Examples />);
+    it('handles checkbox toggle to enable analytics', async () => {
+      (get as jest.Mock).mockResolvedValue(mockAnalyticsDataDisabled);
+      await renderAnalyticsAndWaitForLoad();
+
+      const checkbox = await expectCheckboxState(false);
+      await userEvent.click(checkbox);
 
       await waitFor(() => {
-        const installButton = screen.getByText('Hive');
-        userEvent.click(installButton);
-        expect(post).toHaveBeenCalledWith(INSTALL_APP_EXAMPLES_API_URL, { app_name: 'hive' });
+        expect(sendApiRequest).toHaveBeenCalledWith(
+          'put',
+          USAGE_ANALYTICS_API_URL,
+          { collect_usage: true },
+          expectedPostOptions
+        );
       });
     });
 
-    test('post call to install Solr Search example and its data when the install button is clicked', async () => {
-      render(<Examples />);
+    it('handles checkbox toggle to disable analytics', async () => {
+      await renderAnalyticsAndWaitForLoad();
 
-      const solrData = ['log_analytics_demo', 'twitter_demo', 'yelp_demo'];
-      await waitFor(() => screen.getByText('Solr Search'));
-      const installButton = screen.getByText('Solr Search');
-      userEvent.click(installButton);
+      const checkbox = await expectCheckboxState(true);
+      await userEvent.click(checkbox);
+
+      await waitFor(() => {
+        expect(sendApiRequest).toHaveBeenCalledWith(
+          'put',
+          USAGE_ANALYTICS_API_URL,
+          { collect_usage: false },
+          expectedPostOptions
+        );
+      });
+    });
+
+    it('disables checkbox during save operation', async () => {
+      (sendApiRequest as jest.Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
+      await renderAnalyticsAndWaitForLoad();
+
+      const checkbox = await expectCheckboxState(true);
+      await userEvent.click(checkbox);
+
+      await waitFor(() => {
+        const currentCheckbox = getCheckboxElement();
+        expect(currentCheckbox.disabled).toBe(true);
+      });
+    });
+
+    it('shows loading state during save operation', async () => {
+      (sendApiRequest as jest.Mock).mockImplementation(() => new Promise(() => {}));
+      await renderAnalyticsAndWaitForLoad();
+
+      const checkbox = await expectCheckboxState(true);
+      await userEvent.click(checkbox);
+
+      await waitFor(() => {
+        expect(screen.getAllByTestId('loading-error-wrapper__spinner')[0]).toBeInTheDocument();
+      });
+    });
+
+    it('shows error message when save operation fails', async () => {
+      const errorMessage = 'Failed to update analytics preference';
+      (sendApiRequest as jest.Mock).mockRejectedValue(errorMessage);
+      await renderAnalyticsAndWaitForLoad();
+
+      const checkbox = await expectCheckboxState(true);
+      await userEvent.click(checkbox);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('loading-error-wrapper__errors')).toBeInTheDocument();
+      });
+    });
+
+    it('publishes success message when analytics are enabled', async () => {
+      (get as jest.Mock).mockResolvedValue(mockAnalyticsDataDisabled);
+      (sendApiRequest as jest.Mock).mockResolvedValue({ collect_usage: true });
+      await renderAnalyticsAndWaitForLoad();
+
+      const checkbox = await expectCheckboxState(false);
+      await userEvent.click(checkbox);
 
       await waitFor(() => {
-        solrData.forEach(dataEntry => {
-          expect(post).toHaveBeenCalledWith(INSTALL_APP_EXAMPLES_API_URL, {
-            app_name: 'search',
-            data: dataEntry
-          });
+        expect(huePubSub.publish).toHaveBeenCalledWith('hue.global.info', {
+          message: 'Analytics have been activated.'
         });
       });
+    });
 
-      expect(post).toHaveBeenCalledTimes(solrData.length);
+    it('publishes success message when analytics are disabled', async () => {
+      (sendApiRequest as jest.Mock).mockResolvedValue({ collect_usage: false });
+      await renderAnalyticsAndWaitForLoad();
+
+      const checkbox = await expectCheckboxState(true);
+      await userEvent.click(checkbox);
+
+      await waitFor(() => {
+        expect(huePubSub.publish).toHaveBeenCalledWith('hue.global.info', {
+          message: 'Analytics have been deactivated.'
+        });
+      });
+    });
+
+    it('reloads data after successful save', async () => {
+      (sendApiRequest as jest.Mock).mockResolvedValue({ collect_usage: false });
+      await renderAnalyticsAndWaitForLoad();
+
+      const checkbox = await expectCheckboxState(true);
+
+      await userEvent.click(checkbox);
+
+      await waitFor(() => {
+        expect(get).toHaveBeenCalledTimes(2);
+        expect(get).toHaveBeenCalledWith(USAGE_ANALYTICS_API_URL, undefined, expectedFetchOptions);
+      });
+    });
+
+    it('handles undefined analytics data gracefully', async () => {
+      (get as jest.Mock).mockResolvedValue({});
+      await renderAnalyticsAndWaitForLoad();
+      await expectCheckboxState(false);
+    });
+
+    it('handles multiple error states simultaneously', async () => {
+      const fetchError = 'Fetch failed';
+      const saveError = 'Save failed';
+
+      (get as jest.Mock).mockRejectedValue(fetchError);
+      (sendApiRequest as jest.Mock).mockRejectedValue(saveError);
+
+      render(<Analytics />);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('loading-error-wrapper__errors')).toBeInTheDocument();
+      });
+
+      const checkbox = getCheckboxElement();
+      await userEvent.click(checkbox);
+
+      await waitFor(() => {
+        expect(screen.getByTestId('loading-error-wrapper__errors')).toBeInTheDocument();
+      });
     });
   });
 });