diff --git a/README.md b/README.md index 85b489c..f6c6a77 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/__fixtures__/appinfo.ts b/src/__fixtures__/appinfo.ts new file mode 100644 index 0000000..600afed --- /dev/null +++ b/src/__fixtures__/appinfo.ts @@ -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', +}; diff --git a/src/common/components/Badge/Badges.scss b/src/common/components/Badge/Badges.scss new file mode 100644 index 0000000..20e4c3f --- /dev/null +++ b/src/common/components/Badge/Badges.scss @@ -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; + } +} diff --git a/src/common/components/Badge/Badges.tsx b/src/common/components/Badge/Badges.tsx new file mode 100644 index 0000000..610d87e --- /dev/null +++ b/src/common/components/Badge/Badges.tsx @@ -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 ( +
+ {children} +
+ ); +}; + +export default Badges; diff --git a/src/common/components/Badge/__tests__/Badges.test.tsx b/src/common/components/Badge/__tests__/Badges.test.tsx new file mode 100644 index 0000000..59e6bd3 --- /dev/null +++ b/src/common/components/Badge/__tests__/Badges.test.tsx @@ -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( + +
+
, + ); + await screen.findByTestId('badges'); + + // ASSERT + expect(screen.getByTestId('badges')).toBeDefined(); + expect(screen.getByTestId('badges')).toHaveClass('ls-badges'); + expect(screen.getByTestId('child')).toBeDefined(); + }); +}); diff --git a/src/common/components/List/List.scss b/src/common/components/List/List.scss new file mode 100644 index 0000000..99cd68a --- /dev/null +++ b/src/common/components/List/List.scss @@ -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; + } +} diff --git a/src/common/components/List/List.tsx b/src/common/components/List/List.tsx new file mode 100644 index 0000000..7b5edba --- /dev/null +++ b/src/common/components/List/List.tsx @@ -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 {} + +/** + * 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 ( + + ); +}; + +export default List; diff --git a/src/common/components/Router/TabNavigation.tsx b/src/common/components/Router/TabNavigation.tsx index bcf3f91..8f810d6 100644 --- a/src/common/components/Router/TabNavigation.tsx +++ b/src/common/components/Router/TabNavigation.tsx @@ -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 @@ -56,6 +57,9 @@ const TabNavigation = (): JSX.Element => { + + + diff --git a/src/common/hooks/usePlatform.ts b/src/common/hooks/usePlatform.ts index 9535361..b9bfdc5 100644 --- a/src/common/hooks/usePlatform.ts +++ b/src/common/hooks/usePlatform.ts @@ -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, diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts index ed9b22d..bf73340 100644 --- a/src/common/utils/constants.ts +++ b/src/common/utils/constants.ts @@ -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', diff --git a/src/pages/Account/AccountPage.tsx b/src/pages/Account/AccountPage.tsx index 315bb90..df2ce21 100644 --- a/src/pages/Account/AccountPage.tsx +++ b/src/pages/Account/AccountPage.tsx @@ -8,7 +8,9 @@ import { IonListHeader, IonPage, IonRow, + useIonRouter, } from '@ionic/react'; +import { useState } from 'react'; import dayjs from 'dayjs'; import './AccountPage.scss'; @@ -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(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 ( @@ -44,10 +57,10 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element Account - + Profile - + Sign Out @@ -76,7 +89,7 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element About - + onDiagnosticsClick()}> Version {version} diff --git a/src/pages/Account/api/useGetAppInfo.ts b/src/pages/Account/api/useGetAppInfo.ts new file mode 100644 index 0000000..06153bf --- /dev/null +++ b/src/pages/Account/api/useGetAppInfo.ts @@ -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(), + }); +}; diff --git a/src/pages/Account/components/Diagnostics/AppDiagnostics.tsx b/src/pages/Account/components/Diagnostics/AppDiagnostics.tsx new file mode 100644 index 0000000..daee753 --- /dev/null +++ b/src/pages/Account/components/Diagnostics/AppDiagnostics.tsx @@ -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 ( + + + App + + {isLoading && ( + + + + )} + {appInfo && ( + <> + + Name + + {appInfo.name} + + + + ID + + {appInfo.id} + + + + Build + + {appInfo.build} + + + + Version + + {appInfo.version} + + + + )} + + ); + } else { + return ( + + + App + + + + Information available on mobile devices. + + + + ); + } +}; + +export default AppDiagnostics; diff --git a/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx b/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx new file mode 100644 index 0000000..1629114 --- /dev/null +++ b/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx @@ -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 ( + + + Build + + + + Environment + {config.VITE_BUILD_ENV_CODE} + + + Time + + {dayjs(config.VITE_BUILD_TS).format('YYYY-MM-DD HH:mm:ss Z')} + + + + SHA + + {config.VITE_BUILD_COMMIT_SHA} + + + + Workflow + + {config.VITE_BUILD_WORKFLOW_NAME} {config.VITE_BUILD_WORKFLOW_RUN_NUMBER}. + {config.VITE_BUILD_WORKFLOW_RUN_ATTEMPT} + + + + ); +}; + +export default BuildDiagnostics; diff --git a/src/pages/Account/components/Diagnostics/DiagnosticsPage.tsx b/src/pages/Account/components/Diagnostics/DiagnosticsPage.tsx new file mode 100644 index 0000000..b354d44 --- /dev/null +++ b/src/pages/Account/components/Diagnostics/DiagnosticsPage.tsx @@ -0,0 +1,51 @@ +import { IonCol, IonContent, IonGrid, IonPage, IonRow } from '@ionic/react'; + +import { PropsWithTestId } from 'common/components/types'; +import Header from 'common/components/Header/Header'; +import PageHeader from 'common/components/Content/PageHeader'; +import AppDiagnostics from './AppDiagnostics'; +import PlatformDiagnostics from './PlatformDiagnostics'; +import BuildDiagnostics from './BuildDiagnostics'; + +/** + * The `DiagnosticsPage` renders a layout of components that display information + * about the application to aid in support and problem resolution. + * + * @param {PropsWithTestId} props - Component properties. + * @returns {JSX.Element} JSX + */ +const DiagnosticsPage = ({ testid = 'page-diagnostics' }: PropsWithTestId): JSX.Element => { + return ( + +
+ + + + + + + Diagnostics + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default DiagnosticsPage; diff --git a/src/pages/Account/components/Diagnostics/PlatformDiagnostics.tsx b/src/pages/Account/components/Diagnostics/PlatformDiagnostics.tsx new file mode 100644 index 0000000..8b53d74 --- /dev/null +++ b/src/pages/Account/components/Diagnostics/PlatformDiagnostics.tsx @@ -0,0 +1,52 @@ +import { IonBadge, IonItem, IonLabel, IonListHeader } from '@ionic/react'; +import classNames from 'classnames'; + +import { BaseComponentProps } from 'common/components/types'; +import { usePlatform } from 'common/hooks/usePlatform'; +import List from 'common/components/List/List'; +import Badges from 'common/components/Badge/Badges'; + +/** + * The `PlatformDiagnostics` component displays application diagnostic information + * as a list of key/value pairs. The attributes are obtained from the Ionic + * framework. + * + * @param {BaseComponentProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const PlatformDiagnostics = ({ + className, + testid = 'diagnostics-platform', +}: BaseComponentProps): JSX.Element => { + const { isNativePlatform, platforms } = usePlatform(); + + return ( + + + Platform + + + Native + {isNativePlatform ? ( + YES + ) : ( + + NO + + )} + + + Platforms + + {platforms.map((platform) => ( + + {platform} + + ))} + + + + ); +}; + +export default PlatformDiagnostics; diff --git a/src/pages/Account/components/Diagnostics/__tests__/AppDiagnostics.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/AppDiagnostics.test.tsx new file mode 100644 index 0000000..3912c0c --- /dev/null +++ b/src/pages/Account/components/Diagnostics/__tests__/AppDiagnostics.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; +import { AppInfo } from '@capacitor/app'; + +import { render, screen } from 'test/test-utils'; +import * as UsePlatform from 'common/hooks/usePlatform'; +import * as UseGetAppInfo from 'pages/Account/api/useGetAppInfo'; +import { appInfoFixture } from '__fixtures__/appinfo'; + +import AppDiagnostics from '../AppDiagnostics'; + +describe('AppDiagnostics', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('diagnostics-app'); + + // ASSERT + expect(screen.getByTestId('diagnostics-app')).toBeDefined(); + }); + + it('should render non-native values', async () => { + // ARRANGE + const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform'); + usePlatformSpy.mockReturnValue({ isNativePlatform: false, platforms: [] }); + render(); + await screen.findByTestId('diagnostics-app'); + + // ASSERT + expect(screen.getByTestId('diagnostics-app')).toBeDefined(); + expect(screen.getByTestId('diagnostics-app-not-native')).toBeDefined(); + expect(screen.getByTestId('diagnostics-app-not-native')).toHaveTextContent( + /Information available on mobile devices./, + ); + }); + + it('should display app info', async () => { + // ARRANGE + const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform'); + usePlatformSpy.mockReturnValue({ isNativePlatform: true, platforms: [] }); + const useGetAppInfoSpy = vi.spyOn(UseGetAppInfo, 'useGetAppInfo'); + useGetAppInfoSpy.mockReturnValue({ + data: appInfoFixture, + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('diagnostics-app-name'); + + // ASSERT + expect(screen.getByTestId('diagnostics-app-name')).toHaveTextContent(appInfoFixture.name); + expect(screen.getByTestId('diagnostics-app-id')).toHaveTextContent(appInfoFixture.id); + expect(screen.getByTestId('diagnostics-app-build')).toHaveTextContent(appInfoFixture.build); + expect(screen.getByTestId('diagnostics-app-version')).toHaveTextContent(appInfoFixture.version); + }); + + it('should display loading state', async () => { + // ARRANGE + const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform'); + usePlatformSpy.mockReturnValue({ isNativePlatform: true, platforms: [] }); + const useGetAppInfoSpy = vi.spyOn(UseGetAppInfo, 'useGetAppInfo'); + useGetAppInfoSpy.mockReturnValue({ + data: undefined, + isLoading: true, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('diagnostics-app-loading'); + + // ASSERT + expect(screen.getByTestId('diagnostics-app-loading')).toBeDefined(); + }); +}); diff --git a/src/pages/Account/components/Diagnostics/__tests__/BuildDiagnostics.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/BuildDiagnostics.test.tsx new file mode 100644 index 0000000..dfd7f13 --- /dev/null +++ b/src/pages/Account/components/Diagnostics/__tests__/BuildDiagnostics.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import BuildDiagnostics from '../BuildDiagnostics'; + +describe('BuildDiagnostics', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('diagnostics-build'); + + // ASSERT + expect(screen.getByTestId('diagnostics-build')).toBeDefined(); + }); + + it('should display build values', async () => { + // ARRANGE + render(); + await screen.findByTestId('build'); + + // ASSERT + expect(screen.getByTestId('build')).toBeDefined(); + expect(screen.getByTestId('build-env')).toHaveTextContent('test'); + expect(screen.getByTestId('build-time')).toHaveTextContent('1970-01-01 00:00:00 +00:00'); + expect(screen.getByTestId('build-sha')).toHaveTextContent('test'); + expect(screen.getByTestId('build-workflow')).toHaveTextContent('test 1.1'); + }); +}); diff --git a/src/pages/Account/components/Diagnostics/__tests__/DiagnosticsPage.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/DiagnosticsPage.test.tsx new file mode 100644 index 0000000..4b80379 --- /dev/null +++ b/src/pages/Account/components/Diagnostics/__tests__/DiagnosticsPage.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import DiagnosticsPage from '../DiagnosticsPage'; + +describe('DiagnosticsPage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-diagnostics'); + + // ASSERT + expect(screen.getByTestId('page-diagnostics')).toBeDefined(); + }); +}); diff --git a/src/pages/Account/components/Diagnostics/__tests__/PlatformDiagnostics.test.tsx b/src/pages/Account/components/Diagnostics/__tests__/PlatformDiagnostics.test.tsx new file mode 100644 index 0000000..1e476f6 --- /dev/null +++ b/src/pages/Account/components/Diagnostics/__tests__/PlatformDiagnostics.test.tsx @@ -0,0 +1,49 @@ +import { expect, it, vi } from 'vitest'; + +import { render, screen } from 'test/test-utils'; +import * as UsePlatform from 'common/hooks/usePlatform'; + +import PlatformDiagnostics from '../PlatformDiagnostics'; + +describe('PlatformDiagnostics', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('diagnostics-platform'); + + // ASSERT + expect(screen.getByTestId('diagnostics-platform')).toBeDefined(); + }); + + it('should display platform values', async () => { + // ARRANGE + const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform'); + usePlatformSpy.mockReturnValue({ isNativePlatform: true, platforms: ['test'] }); + render(); + await screen.findByTestId('diagnostics-platform'); + + // ASSERT + expect(screen.getByTestId('diagnostics-platform')).toBeDefined(); + expect(screen.getByTestId('diagnostics-platform-is-native')).toBeDefined(); + expect(screen.queryByTestId('diagnostics-platform-not-native')).toBeNull(); + expect(screen.getAllByTestId('diagnostics-platform-platform').length).toBe(1); + }); + + it('should display non-native badge', async () => { + // ARRANGE + const usePlatformSpy = vi.spyOn(UsePlatform, 'usePlatform'); + usePlatformSpy.mockReturnValue({ isNativePlatform: false, platforms: ['test', 'test2'] }); + render(); + await screen.findByTestId('diagnostics-platform'); + + // ASSERT + expect(screen.getByTestId('diagnostics-platform')).toBeDefined(); + expect(screen.getByTestId('diagnostics-platform-not-native')).toBeDefined(); + expect(screen.queryByTestId('diagnostics-platform-is-native')).toBeNull(); + expect(screen.getAllByTestId('diagnostics-platform-platform').length).toBe(2); + }); +}); diff --git a/src/setupTests.ts b/src/setupTests.ts index a4624ce..70ff073 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -8,6 +8,9 @@ import { afterAll, afterEach, beforeAll } from 'vitest'; import { server } from 'test/mocks/server'; import { queryClient } from 'test/query-client'; +// Tests always run in this timezone +process.env.TZ = 'UTC'; + // Mock matchmedia window.matchMedia = window.matchMedia || diff --git a/src/theme/main.scss b/src/theme/main.scss index 52442ea..50a3c37 100644 --- a/src/theme/main.scss +++ b/src/theme/main.scss @@ -30,3 +30,6 @@ /* Fonts */ @import './fonts.scss'; + +/* Typography */ +@import './typography.scss'; diff --git a/src/theme/typography.scss b/src/theme/typography.scss new file mode 100644 index 0000000..28f5c2f --- /dev/null +++ b/src/theme/typography.scss @@ -0,0 +1,80 @@ +// Typography styles inspired by Tailwind +:root { + // font size + .text-xs { + font-size: 0.75rem; + line-height: 1rem; + } + .text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } + .text-base { + font-size: 1rem; + line-height: 1.5rem; + } + .text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + .text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + .text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + .text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + .text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + // font weight + .font-thin { + font-weight: 100; + } + .font-extralight { + font-weight: 200; + } + .font-light { + font-weight: 300; + } + .font-normal { + font-weight: 400; + } + .font-medium { + font-weight: 500; + } + .font-semibold { + font-weight: 600; + } + .font-bold { + font-weight: 700; + } + .font-extrabold { + font-weight: 800; + } + .font-black { + font-weight: 900; + } + + // word break + .break-normal { + overflow-wrap: normal; + word-break: normal; + } + .break-words { + overflow-wrap: break-word; + } + .break-all { + word-break: break-all; + } + .break-keep { + word-break: keep-all; + } +}