Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

66 Diagnostic page #67

Merged
merged 16 commits into from
Sep 1, 2024
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ This project's API integration uses the simulated REST endpoints made available

When running the application, you may sign in with any of the JSON Placeholder [Users](https://jsonplaceholder.typicode.com/users). Simply enter the _Username_ value from any user in the API and use any value for the _Password_. For example, try username `Bret` and password `abc123`.

### Diagnostics

Many applications, particularly mobile applications, have a hidden page which displays content useful for troubleshooting and support. To access the diagnostics page, go to the _Account_ page. Locate the _About_ section and click or tap the _Version_ item 7 times.

## About

This project was bootstrapped with the [Ionic CLI](https://ionicframework.com/docs/cli/commands/start).
Expand Down
8 changes: 8 additions & 0 deletions src/__fixtures__/appinfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { AppInfo } from '@capacitor/app';

export const appInfoFixture: AppInfo = {
name: 'app-info-name',
id: 'app-info-id',
build: 'app-info-build',
version: 'app-info-version',
};
11 changes: 11 additions & 0 deletions src/common/components/Badge/Badges.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.ls-badges {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
justify-content: end;
align-items: center;

ion-badge {
min-width: auto;
}
}
28 changes: 28 additions & 0 deletions src/common/components/Badge/Badges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PropsWithChildren } from 'react';
import classNames from 'classnames';

import './Badges.scss';
import { BaseComponentProps } from '../types';

/**
* Properties for the `Badges` component.
* @see {@link BaseComponentProps}
* @see {@link PropsWithChildren}
*/
interface BadgesProps extends BaseComponentProps, PropsWithChildren {}

/**
* The `Badges` component renders a collection of `IonBadge` components in a
* flexbox. The badges will wrap as needed.
* @param {BadgesProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const Badges = ({ children, className, testid = 'badges' }: BadgesProps): JSX.Element => {
return (
<div className={classNames('ls-badges', className)} data-testid={testid}>
{children}
</div>
);
};

export default Badges;
22 changes: 22 additions & 0 deletions src/common/components/Badge/__tests__/Badges.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';

import { render, screen } from 'test/test-utils';

import Badges from '../Badges';

describe('Badges', () => {
it('should render successfully', async () => {
// ARRANGE
render(
<Badges>
<div data-testid="child"></div>
</Badges>,
);
await screen.findByTestId('badges');

// ASSERT
expect(screen.getByTestId('badges')).toBeDefined();
expect(screen.getByTestId('badges')).toHaveClass('ls-badges');
expect(screen.getByTestId('child')).toBeDefined();
});
});
10 changes: 10 additions & 0 deletions src/common/components/List/List.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
ion-list.ls-list {
ion-list-header {
--border-width: 0 0 2px 0;

font-size: 1rem;
font-weight: 700;
line-height: 1.5rem;
text-transform: uppercase;
}
}
29 changes: 29 additions & 0 deletions src/common/components/List/List.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ComponentPropsWithoutRef } from 'react';
import { IonList } from '@ionic/react';
import classNames from 'classnames';

import './List.scss';
import { PropsWithTestId } from '../types';

/**
* Properties for the `List` component.
* @see {@link PropsWithTestId}
* @see {@link IonList}
*/
interface ListProps extends PropsWithTestId, ComponentPropsWithoutRef<typeof IonList> {}

/**
* The `List` component is a wrapper for the Ionic `IonList` component. Renders
* and standardized implementation of `IonList`.
*
* @param {ListProps} props - Component properties.
* @returns {JSX.Element} JSX
* @see {@link IonList}
*/
const List = ({ className, testid = 'list', ...listProps }: ListProps): JSX.Element => {
return (
<IonList className={classNames('ls-list', className)} data-testid={testid} {...listProps} />
);
};

export default List;
4 changes: 4 additions & 0 deletions src/common/components/Router/TabNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import UserEditPage from 'pages/Users/components/UserEdit/UserEditPage';
import UserAddPage from 'pages/Users/components/UserAdd/UserAddPage';
import AccountPage from 'pages/Account/AccountPage';
import ProfilePage from 'pages/Account/components/Profile/ProfilePage';
import DiagnosticsPage from 'pages/Account/components/Diagnostics/DiagnosticsPage';

/**
* The `TabNavigation` component provides a router outlet for all of the
Expand Down Expand Up @@ -56,6 +57,9 @@ const TabNavigation = (): JSX.Element => {
<Route exact path="/tabs/account/profile">
<ProfilePage />
</Route>
<Route exact path="/tabs/account/diagnostics">
<DiagnosticsPage />
</Route>
<Route exact path="/">
<Redirect to="/tabs/home" />
</Route>
Expand Down
2 changes: 0 additions & 2 deletions src/common/hooks/usePlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ type Platform = {
*/
export const usePlatform = (): Platform => {
const isNativePlatform = Capacitor.isNativePlatform();
console.log(`usePlatform::isNativePlatform::${isNativePlatform}`);
const platforms = getPlatforms();
console.log(`usePlatform::platforms::${platforms}`);

return {
isNativePlatform,
Expand Down
1 change: 1 addition & 0 deletions src/common/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Settings } from 'common/models/settings';
* React Query cache keys.
*/
export enum QueryKey {
AppInfo = 'AppInfo',
Settings = 'Settings',
Users = 'Users',
UserTokens = 'UserTokens',
Expand Down
19 changes: 16 additions & 3 deletions src/pages/Account/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
IonListHeader,
IonPage,
IonRow,
useIonRouter,
} from '@ionic/react';
import { useState } from 'react';
import dayjs from 'dayjs';

import './AccountPage.scss';
Expand All @@ -25,12 +27,23 @@ import SettingsForm from './components/Settings/SettingsForm';
* @returns {JSX.Element} JSX
*/
const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element => {
const [diagnosticsCount, setDiagnosticsCount] = useState<number>(0);
const config = useConfig();
const router = useIonRouter();

const versionTs = dayjs(config.VITE_BUILD_TS).format('YY.MM.DD.hhmm');
const sha = config.VITE_BUILD_COMMIT_SHA.substring(0, 7);
const version = `${versionTs}.${sha}`;

const onDiagnosticsClick = () => {
if (diagnosticsCount >= 6) {
setDiagnosticsCount(0);
router.push('/tabs/account/diagnostics');
} else {
setDiagnosticsCount((value) => value + 1);
}
};

return (
<IonPage className="page-account" data-testid={testid}>
<ProgressProvider>
Expand All @@ -44,10 +57,10 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element
<IonListHeader>
<IonLabel>Account</IonLabel>
</IonListHeader>
<IonItem lines="full" routerLink="/tabs/account/profile">
<IonItem detail lines="full" routerLink="/tabs/account/profile">
<IonLabel>Profile</IonLabel>
</IonItem>
<IonItem lines="full" routerLink="/auth/signout">
<IonItem detail lines="full" routerLink="/auth/signout">
<IonLabel>Sign Out</IonLabel>
</IonItem>
</IonList>
Expand Down Expand Up @@ -76,7 +89,7 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element
<IonListHeader>
<IonLabel>About</IonLabel>
</IonListHeader>
<IonItem lines="full">
<IonItem lines="full" onClick={() => onDiagnosticsClick()}>
<IonLabel>Version {version}</IonLabel>
</IonItem>
</IonList>
Expand Down
20 changes: 20 additions & 0 deletions src/pages/Account/api/useGetAppInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { App } from '@capacitor/app';

import { QueryKey } from 'common/utils/constants';

/**
* A query hook which returns the `AppInfo` value from the `App` capacitor.
*
* Note: The capacitor only returns a value when the app is running as a
* native mobile application. Otherwise returns an error.
*
* @returns Returns a `UseQueryResult` whose data is an `AppInfo` object.
* @see {@link https://capacitorjs.com/docs/apis/app}
*/
export const useGetAppInfo = () => {
return useQuery({
queryKey: [QueryKey.AppInfo],
queryFn: () => App.getInfo(),
});
};
82 changes: 82 additions & 0 deletions src/pages/Account/components/Diagnostics/AppDiagnostics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { IonItem, IonLabel, IonListHeader } from '@ionic/react';
import classNames from 'classnames';

import { BaseComponentProps } from 'common/components/types';
import { usePlatform } from 'common/hooks/usePlatform';
import { useGetAppInfo } from 'pages/Account/api/useGetAppInfo';
import List from 'common/components/List/List';
import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton';

/**
* The `AppDiagnostics` component displays application diagnostic information
* as a list of key/value pairs. The attributes are obtained from the `App`
* capacitor.
*
* @param {BaseComponentProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const AppDiagnostics = ({
className,
testid = 'diagnostics-app',
}: BaseComponentProps): JSX.Element => {
const { isNativePlatform } = usePlatform();
const { data: appInfo, isLoading } = useGetAppInfo();

if (isNativePlatform) {
return (
<List className={classNames('app-diagnostics', className)} data-testid={testid}>
<IonListHeader lines="full">
<IonLabel>App</IonLabel>
</IonListHeader>
{isLoading && (
<IonItem data-testid={`${testid}-loading`}>
<LoaderSkeleton animated heightStyle="1.5rem" />
</IonItem>
)}
{appInfo && (
<>
<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">Name</IonLabel>
<IonLabel className="ion-text-end" data-testid={`${testid}-name`}>
{appInfo.name}
</IonLabel>
</IonItem>
<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">ID</IonLabel>
<IonLabel className="ion-text-end" data-testid={`${testid}-id`}>
{appInfo.id}
</IonLabel>
</IonItem>
<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">Build</IonLabel>
<IonLabel className="ion-text-end" data-testid={`${testid}-build`}>
{appInfo.build}
</IonLabel>
</IonItem>
<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">Version</IonLabel>
<IonLabel className="ion-text-end" data-testid={`${testid}-version`}>
{appInfo.version}
</IonLabel>
</IonItem>
</>
)}
</List>
);
} else {
return (
<List className={classNames('app-diagnostics', className)} data-testid={testid}>
<IonListHeader lines="full">
<IonLabel>App</IonLabel>
</IonListHeader>
<IonItem className="text-sm">
<IonLabel color="medium" className="font-medium" data-testid={`${testid}-not-native`}>
Information available on mobile devices.
</IonLabel>
</IonItem>
</List>
);
}
};

export default AppDiagnostics;
56 changes: 56 additions & 0 deletions src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { IonItem, IonLabel, IonListHeader, IonText } from '@ionic/react';
import classNames from 'classnames';
import dayjs from 'dayjs';

import { BaseComponentProps } from 'common/components/types';
import List from 'common/components/List/List';
import { useConfig } from 'common/hooks/useConfig';

/**
* The `BuildDiagnostics` component displays application diagnostic information
* as a list of key/value pairs. The attributes are obtained from the application
* configuration with values obtained from the DevOps automation pipeline.
*
* @param {BaseComponentProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const BuildDiagnostics = ({
className,
testid = 'diagnostics-build',
}: BaseComponentProps): JSX.Element => {
const config = useConfig();

return (
<List className={classNames('build-diagnostics', className)} data-testid={testid}>
<IonListHeader lines="full">
<IonLabel>Build</IonLabel>
</IonListHeader>

<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">Environment</IonLabel>
<IonText data-testid={`${testid}-env`}>{config.VITE_BUILD_ENV_CODE}</IonText>
</IonItem>
<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">Time</IonLabel>
<IonText data-testid={`${testid}-time`}>
{dayjs(config.VITE_BUILD_TS).format('YYYY-MM-DD HH:mm:ss Z')}
</IonText>
</IonItem>
<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">SHA</IonLabel>
<IonText className="break-all" data-testid={`${testid}-sha`}>
{config.VITE_BUILD_COMMIT_SHA}
</IonText>
</IonItem>
<IonItem className="text-sm">
<IonLabel className="font-medium ion-margin-end">Workflow</IonLabel>
<IonText data-testid={`${testid}-workflow`}>
{config.VITE_BUILD_WORKFLOW_NAME} {config.VITE_BUILD_WORKFLOW_RUN_NUMBER}.
{config.VITE_BUILD_WORKFLOW_RUN_ATTEMPT}
</IonText>
</IonItem>
</List>
);
};

export default BuildDiagnostics;
Loading