This commit is contained in:
hamid
2026-06-16 01:32:43 +03:30
commit 69bbd28bb0
298 changed files with 24728 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(rm -rf server/.template.config)",
"Bash(rm -f server/CleanArcTemplate.nuspec)",
"Bash(rm -f server/.github/workflows/package.yml)",
"Bash(rmdir server/.github/workflows)",
"Bash(rmdir server/.github)"
]
}
}
+40
View File
@@ -0,0 +1,40 @@
# AGENTS.md — Balinyaar
Guidance for AI coding agents working in this repository. Read this first, then the project-specific `AGENTS.md` for whichever side you are editing.
## What this repository is
Balinyaar is a full-stack application split into two independent projects:
| Path | Project | Stack | Detail |
| -------------------------- | -------------- | ----------------------------------------------------------- | ------------------------------- |
| [`client/`](client/) | Web frontend | Next.js (App Router), React, TypeScript, Material UI (MUI) | [client/AGENTS.md](client/AGENTS.md) |
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10), Clean Architecture, CQRS, EF Core | [server/AGENTS.md](server/AGENTS.md) |
The two communicate over HTTP/JSON (and optionally gRPC). The client reads the API base URL from `NEXT_PUBLIC_API_URL`; the server runs on `https://localhost:5002` by default.
There is **no root-level build** — each project is built and run on its own. `client/` and `server/` are not part of the same package/solution.
## Working agreements
- **Stay within one project per change** unless the task explicitly spans both. A frontend change rarely needs server files and vice-versa.
- **Match the surrounding style.** Each project has its own conventions (see its `AGENTS.md`). Mirror the existing patterns rather than introducing new ones.
- **Run the project's own checks** before declaring work done:
- client: `npm run lint` and `npm run type` (and `npm run test:ci` if tests are touched)
- server: `dotnet build` and `dotnet test`
- **Do not reintroduce template/starter scaffolding.** This repo was derived from two open-source starters; their branding, demo/showcase pages, NuGet template packaging, and `_TITLE_`/`_DESCRIPTION_` placeholders have been intentionally removed. Don't add them back.
- **Secrets**: never commit real connection strings, keys, or tokens. Use `.env` (client) and `appsettings.*.json` / user-secrets (server).
## Quick start
```bash
# Frontend
cd client && npm install && npm run dev # http://localhost:3000
# Backend
cd server && dotnet run --project src/API/CleanArc.Web.Api/CleanArc.Web.Api.csproj # https://localhost:5002/swagger
```
## Naming note
The server's C# namespaces and solution file still use the `CleanArc*` prefix (e.g. `CleanArc.Web.Api`, `CleanArcTemplate.sln`). This is the internal project naming and is **not** template branding — do not mass-rename it unless explicitly asked, as it touches every file, the `.sln`, and EF migrations.
+22
View File
@@ -0,0 +1,22 @@
# Environment similar to NODE_ENV.
# Analytics and public resources are enabled only in "production"
# How to use: set value to "production" to get fully functional application.
NEXT_PUBLIC_ENV = development
# NEXT_PUBLIC_ENV = preview
# NEXT_PUBLIC_ENV = production
# Enables additional debug features, no additional debug information if the variable is not set
# How to use: set value to "true" to get more debugging information, but don't do it on production.
NEXT_PUBLIC_DEBUG = true
# Public URL of the application/website.
# How to use: Do not set any value until you need custom domain for your application.
# NEXT_PUBLIC_PUBLIC_URL = https://xxx.com
# NEXT_PUBLIC_PUBLIC_URL = https://xxx.web.app
NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
# API/Backend basic URL (the CleanArc server)
NEXT_PUBLIC_API_URL = https://localhost:5002
# NEXT_PUBLIC_API_URL = https://dev-api.domain.com
# NEXT_PUBLIC_API_URL = https://api.domain.com
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"import/no-cycle": "error"
}
}
+40
View File
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+4
View File
@@ -0,0 +1,4 @@
.next
node_modules
out
styles
+13
View File
@@ -0,0 +1,13 @@
module.exports = {
printWidth: 120, // max 120 chars in line, code is easy to read
useTabs: false, // use spaces instead of tabs
tabWidth: 2, // "visual width" of of the "tab"
trailingComma: 'es5', // add trailing commas in objects, arrays, etc.
semi: true, // add ; when needed
singleQuote: true, // '' for stings instead of ""
bracketSpacing: true, // import { some } ... instead of import {some} ...
arrowParens: 'always', // braces even for single param in arrow functions (a) => { }
jsxSingleQuote: false, // "" for react props, like in html
jsxBracketSameLine: false, // pretty JSX
endOfLine: 'lf', // 'lf' for linux, 'crlf' for windows, we need to use 'lf' for git
};
+66
View File
@@ -0,0 +1,66 @@
# AGENTS.md — Balinyaar Web Client
Agent-oriented guide to the frontend. For human setup/run instructions see [README.md](README.md).
## Stack
- **Next.js** with the **App Router** (`src/app/`), statically exported (`output: 'export'` in `next.config.mjs` → builds to `out/`)
- **React** + **TypeScript** (`strict` mode)
- **Material UI (MUI)** for components and theming (Emotion under the hood)
- **Jest** + **Testing Library** for unit tests
- ESLint (`eslint-config-next`) + Prettier
## Commands
| Task | Command |
| ------------ | ------------------ |
| Dev server | `npm run dev` |
| Build | `npm run build` |
| Lint | `npm run lint` |
| Type-check | `npm run type` |
| Format | `npm run format` |
| Test (watch) | `npm test` |
| Test (CI) | `npm run test:ci` |
Always run `npm run lint` and `npm run type` after a change. Run `npm run test:ci` when you touch a component that has a `*.test.tsx`.
## Directory map
```
src/
├── app/ Routes (App Router). Each folder = a route; page.tsx = the page.
│ ├── layout.tsx Root layout: store + theme providers, global <metadata>
│ ├── page.tsx "/" entry (delegates to home)
│ ├── home/ about/ Content pages
│ ├── auth/login|signup Auth pages (LoginForm is currently a stub)
│ └── me/ Authenticated user page
├── components/
│ ├── common/ Reusable App* primitives (see below) + ErrorBoundary
│ └── UserInfo/ Logged-in user summary
├── hooks/ useIsAuthenticated, useIsMobile, event hooks, useWindowSize
├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar, config
├── store/ Global state: React context + reducer (AppStore, AppReducer)
├── theme/ ThemeProvider, light/dark palettes, colors, MUI-for-Next bridge
└── utils/ storage (local/session), navigation, environment, text, types
```
## Conventions (follow these)
- **Imports**: use the `@/*` alias for anything under `src/` (e.g. `import { AppButton } from '@/components'`). The alias is defined in `tsconfig.json`.
- **Barrel files**: most folders export through an `index.ts(x)`. Add new public exports there (e.g. a new common component is re-exported from `src/components/common/index.tsx`, which `src/components/index.tsx` re-exports).
- **Common components** live in `src/components/common/<Name>/` as a folder containing `<Name>.tsx`, `index.tsx` (re-export), and an optional `<Name>.test.tsx`. Existing ones: `AppAlert`, `AppButton`, `AppIcon`, `AppIconButton`, `AppImage`, `AppLink`, `AppLoading`. Prefer reusing these over raw MUI where one exists.
- **Icons**: reference icons by string name through `AppIcon`. The name→icon map is in `src/components/common/AppIcon/config.ts`; add new icons there (custom SVGs go in `AppIcon/icons/`).
- **Navigation**: sidebar/bottom-bar items are arrays of `LinkToPage` defined inside `PublicLayout.tsx` / `PrivateLayout.tsx`. Add a route → add an entry there to surface it in the nav.
- **Two layouts**: `PrivateLayout` (after auth) and `PublicLayout` (before auth); `CurrentLayout` picks between them based on auth state. The app name lives in the `TITLE_PRIVATE` / `TITLE_PUBLIC` constants in those files.
- **Auth is a stub.** `src/hooks/auth.ts` and `src/app/auth/login/LoginForm.tsx` fake login by writing a placeholder token to session storage (`// TODO: AUTH:`). When implementing real auth, replace those spots and point requests at `NEXT_PUBLIC_API_URL` (the `server` project's JWT endpoints).
- **Theming**: use the theme/palette via MUI's `sx` / `useTheme`; don't hardcode colors. Light/dark values are in `src/theme/light.ts` and `dark.ts`; dark-mode toggle flows through the store.
- **Client vs server components**: files needing hooks/browser APIs start with `'use client';` (see `LoginForm.tsx`). Pages that only render markup can stay server components.
## Environment
Browser-exposed config comes from `NEXT_PUBLIC_*` variables (copy `.env.sample``.env`). The ones actually read in code: `NEXT_PUBLIC_ENV`, `NEXT_PUBLIC_DEBUG`, `NEXT_PUBLIC_PUBLIC_URL` (see `src/config.ts`) and `NEXT_PUBLIC_VERSION` (see `src/utils/environment.ts`). `NEXT_PUBLIC_API_URL` is the backend base URL.
## Gotchas
- Static export (`output: 'export'`) means **no server-side runtime** — no API routes, no SSR-only features, `images.unoptimized` is on.
- Don't reintroduce the removed demo/showcase route (`src/app/dev`) or `Demo*` components — they were template content.
+76
View File
@@ -0,0 +1,76 @@
# Balinyaar — Web Client
Frontend for the Balinyaar application, built with **Next.js (App Router)**, **React**, **TypeScript**, and **Material UI (MUI)**.
It ships with a small library of reusable `App*` components, a theming system (light/dark), a lightweight global store, and public/private layouts wired for an authentication flow.
## Requirements
- Node.js 18+ and npm
- The backend API (see [`../server`](../server)) running for authenticated features
## Getting started
1. Install dependencies:
```bash
npm install
```
2. Create your local environment file from the sample and adjust values:
```bash
cp .env.sample .env
```
Key variables (all `NEXT_PUBLIC_*` are exposed to the browser):
| Variable | Purpose |
| ------------------------ | ---------------------------------------------------- |
| `NEXT_PUBLIC_ENV` | `development` \| `preview` \| `production` |
| `NEXT_PUBLIC_DEBUG` | `true` enables extra console logging |
| `NEXT_PUBLIC_PUBLIC_URL` | Public URL of the site |
| `NEXT_PUBLIC_API_URL` | Base URL of the backend API (the `server` project) |
3. Run the dev server:
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
## Available scripts
| Script | Description |
| ------------------ | ------------------------------------------------------- |
| `npm run dev` | Start the development server with hot reload |
| `npm run build` | Production build (static export to `out/`) |
| `npm run start` | Serve a production build |
| `npm run lint` | Run ESLint (`eslint-config-next`) |
| `npm run format` | Format the codebase with Prettier |
| `npm test` | Run Jest in watch mode |
| `npm run test:ci` | Run Jest once (CI) |
| `npm run type` | Type-check with `tsc` |
## Project structure
```
src/
├── app/ Next.js App Router routes (home, about, auth, me) + root layout
├── components/ Reusable UI: common App* components (AppButton, AppIcon, ...) + UserInfo
├── hooks/ Custom hooks (auth, layout, events, window size)
├── layout/ PublicLayout / PrivateLayout + TopBar, SideBar, BottomBar
├── store/ Global app store (React context + reducer)
├── theme/ MUI theme provider, light/dark palettes, colors
└── utils/ Helpers (storage, navigation, env, text, types)
```
The `@/*` import alias maps to `src/*` (see `tsconfig.json`).
> For a deeper, agent-oriented map of conventions and where to make changes, see [AGENTS.md](AGENTS.md).
## Notes
- `next.config.mjs` uses `output: 'export'`, producing a static site in `out/`.
- Authentication in `src/hooks/auth.ts` and `src/app/auth/login/LoginForm.tsx` is currently a stub (look for `// TODO: AUTH:`); wire it to the backend's JWT endpoints when implementing real auth.
+27
View File
@@ -0,0 +1,27 @@
import type { Config } from 'jest';
import nextJest from 'next/jest.js';
// const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.* and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig: Config = {
coverageProvider: 'v8',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
// Handle module aliases
'^@/(.*)$': '<rootDir>/$1',
},
// testEnvironment: 'jest-environment-jsdom',
testEnvironment: 'jsdom',
// transform: {
// '.+\\.(css|styl|less|sass|scss)$': 'jest-css-modules-transform',
// },
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);
+9
View File
@@ -0,0 +1,9 @@
// Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.*`
// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// To get 'next/router' working with tests
jest.mock('next/router', () => require('next-router-mock'));
+16
View File
@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export', // Use this if you want to create "static generated website" (SSG), result in "/out" folder
trailingSlash: true,
images: { unoptimized: true },
env: {
// Add custom build-time env variables here, also check .env.* files
},
reactStrictMode: true,
// reactStrictMode: false,
};
export default nextConfig;
+11872
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "balinyaar-client",
"version": "0.1.0",
"description": "Balinyaar web application",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"format": "prettier ./ --write",
"lint": "next lint",
"start": "next start",
"test": "jest --watch",
"test:ci": "jest --ci",
"type": "tsc"
},
"dependencies": {
"@emotion/cache": "latest",
"@emotion/react": "latest",
"@emotion/server": "latest",
"@emotion/styled": "latest",
"@mui/icons-material": "latest",
"@mui/material": "latest",
"@mui/material-nextjs": "latest",
"clsx": "latest",
"copy-to-clipboard": "latest",
"next": "latest",
"react": "latest",
"react-dom": "latest"
},
"devDependencies": {
"@testing-library/jest-dom": "latest",
"@testing-library/react": "latest",
"@testing-library/user-event": "latest",
"@types/jest": "latest",
"@types/node": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"eslint": "latest",
"eslint-config-next": "latest",
"jest": "latest",
"jest-environment-jsdom": "latest",
"next-router-mock": "latest",
"prettier": "latest",
"ts-node": "latest",
"typescript": "^5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+2
View File
@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#D99E82" d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"/><path fill="#EA596E" d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"/><path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z"/><path fill="#292F33" d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"/><path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z"/><path fill="#99AAB5" d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Disallow: /private/
User-agent: *
Allow: /
+22
View File
@@ -0,0 +1,22 @@
{
"name": "Balinyaar",
"short_name": "Balinyaar",
"description": "Balinyaar web application",
"start_url": ".",
"theme_color": "#000000",
"background_color": "#ffffff",
"display": "standalone",
"orientation": "portrait",
"icons": [
{
"src": "favicon.ico?v=1.0",
"sizes": "48x48 32x32 16x16",
"type": "image/x-icon"
},
{ "src": "img/favicon/16x16.png?v=1.0", "sizes": "16x16", "type": "image/png" },
{ "src": "img/favicon/32x32.png?v=1.0", "sizes": "32x32", "type": "image/png" },
{ "src": "img/favicon/180x180.png?v=1.0", "sizes": "180x180", "type": "image/png" },
{ "src": "img/favicon/192x192.png?v=1.0", "sizes": "192x192", "type": "image/png" },
{ "src": "img/favicon/512x512.png?v=1.0", "sizes": "512x512", "type": "image/png" }
]
}
+19
View File
@@ -0,0 +1,19 @@
import { Stack, Typography } from '@mui/material';
import { NextPage } from 'next';
/**
* Renders About Application page
* @page About
*/
const AboutPage: NextPage = () => {
return (
<Stack spacing={2} padding={2}>
<Stack>
<Typography variant="h3">About application</Typography>
<Typography variant="body1">Balinyaar is a Next.js (App Router) application built with Material UI.</Typography>
</Stack>
</Stack>
);
};
export default AboutPage;
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { Stack } from '@mui/material';
import { useRouter } from 'next/navigation';
import { AppButton } from '@/components';
import { useAppStore } from '@/store';
import { useEventLogout } from '@/hooks';
import { sessionStorageSet } from '@/utils';
/**
* Renders login form for user to authenticate
* @component LoginForm
*/
const LoginForm = () => {
const router = useRouter();
const [, dispatch] = useAppStore();
const onLogout = useEventLogout();
const onLogin = () => {
// TODO: AUTH: Sample of access token store, replace next line in real application
sessionStorageSet('access_token', 'TODO:_save-real-access-token-here');
dispatch({ type: 'LOG_IN' });
router.replace('/'); // Redirect to home page without ability to go back
};
return (
<Stack alignItems="center" spacing={2} padding={2}>
<Stack>Put form controls or add social login buttons here...</Stack>
<Stack direction="row">
<AppButton color="success" onClick={onLogin}>
Emulate User Login
</AppButton>
<AppButton color="warning" onClick={onLogout}>
Logout User
</AppButton>
</Stack>
</Stack>
);
};
export default LoginForm;
+21
View File
@@ -0,0 +1,21 @@
import { Metadata, NextPage } from 'next';
import LoginForm from './LoginForm';
/**
* User Login page
* @page Login
*/
const LoginPage: NextPage = () => {
return (
<>
<LoginForm />
</>
);
};
export const metadata: Metadata = {
title: 'Login - Balinyaar',
description: 'Balinyaar web application',
};
export default LoginPage;
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
/**
* Redirects to default Auth page
* @page Auth
* @redirect /auth
*/
const AuthPage = () => {
redirect('/auth/login');
// return <div>Auth Page</div>;
};
export default AuthPage;
+9
View File
@@ -0,0 +1,9 @@
import { Metadata } from 'next';
import LoginPage from '../login/page';
export const metadata: Metadata = {
title: 'Signup - Balinyaar',
description: 'Balinyaar web application',
};
export default LoginPage; // Reuses the Login page for now
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+18
View File
@@ -0,0 +1,18 @@
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
/* required for sticky elements: HeaderMobile, and so on */
max-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
+24
View File
@@ -0,0 +1,24 @@
import { Metadata, NextPage } from 'next';
import { Stack, Typography } from '@mui/material';
export const metadata: Metadata = {
title: 'Balinyaar',
description: 'Balinyaar web application',
};
/**
* Main page of the Application
* @page Home
*/
const Home: NextPage = () => {
return (
<Stack spacing={2} padding={2}>
<Stack>
<Typography variant="h3">Welcome to Balinyaar</Typography>
<Typography variant="body1">This is the home page of the application.</Typography>
</Stack>
</Stack>
);
};
export default Home;
+35
View File
@@ -0,0 +1,35 @@
import { FunctionComponent, PropsWithChildren } from 'react';
import { Metadata, Viewport } from 'next';
import { SimplePaletteColorOptions } from '@mui/material';
import { AppStoreProvider } from '@/store';
import defaultTheme, { ThemeProvider } from '@/theme';
import CurrentLayout from '@/layout';
import './globals.css';
const THEME_COLOR = (defaultTheme.palette?.primary as SimplePaletteColorOptions)?.main || '#FFFFFF';
export const viewport: Viewport = {
themeColor: THEME_COLOR,
};
export const metadata: Metadata = {
title: 'Balinyaar',
description: 'Balinyaar web application',
manifest: '/site.webmanifest',
};
const RootLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
return (
<html lang="en">
<body>
<AppStoreProvider>
<ThemeProvider>
<CurrentLayout>{children}</CurrentLayout>
</ThemeProvider>
</AppStoreProvider>
</body>
</html>
);
};
export default RootLayout;
+18
View File
@@ -0,0 +1,18 @@
import { Stack } from '@mui/material';
import { NextPage } from 'next';
import { AppAlert, UserInfo } from '../../components';
/**
* Renders User Profile Page
* @page Me
*/
const MeAkaProfilePage: NextPage = () => {
return (
<Stack spacing={2} padding={2}>
<AppAlert severity="warning">This page is under construction</AppAlert>
<UserInfo showAvatar />
</Stack>
);
};
export default MeAkaProfilePage;
+3
View File
@@ -0,0 +1,3 @@
import HomePage from './home/page';
export default HomePage;
@@ -0,0 +1,44 @@
import { Avatar, Stack, Typography } from '@mui/material';
import { AppLink } from '../common';
interface UserInfoProps {
className?: string;
showAvatar?: boolean;
user?: any;
}
/**
* Renders User info with Avatar
* @component UserInfo
* @param {boolean} [showAvatar] - user's avatar picture is shown when true
* @param {object} [user] - logged user data {name, email, avatar...}
*/
const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
const srcAvatar = user?.avatar ? user?.avatar : undefined;
const userPhoneOrEmail = user?.phone || (user?.email as string);
return (
<Stack alignItems="center" minHeight="fit-content" marginBottom={2} {...restOfProps}>
{showAvatar ? (
<AppLink to="/me" underline="none">
<Avatar
sx={{
width: 64,
height: 64,
fontSize: '3rem',
}}
alt={fullName || 'User Avatar'}
src={srcAvatar}
/>
</AppLink>
) : null}
<Typography sx={{ mt: 1 }} variant="h6">
{fullName || 'Current User'}
</Typography>
<Typography variant="body2">{userPhoneOrEmail || 'Loading...'}</Typography>
</Stack>
);
};
export default UserInfo;
+4
View File
@@ -0,0 +1,4 @@
import UserInfo from './UserInfo';
export { UserInfo };
export default UserInfo;
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import AppAlert from './AppAlert';
import { capitalize, randomText } from '@/utils';
import { AlertProps } from '@mui/material';
const ComponentToTest = AppAlert;
/**
* Tests for <AppAlert/> component
*/
describe('<AppAlert/> component', () => {
it('renders itself', () => {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} />);
const alert = screen.getByTestId(testId);
expect(alert).toBeDefined();
expect(alert).toHaveAttribute('role', 'alert');
expect(alert).toHaveClass('MuiAlert-root');
});
it('supports .severity property', () => {
const SEVERITIES = ['error', 'info', 'success', 'warning'];
for (const severity of SEVERITIES) {
const testId = randomText(8);
const severity = 'success';
render(
<ComponentToTest
data-testid={testId}
severity={severity}
variant="filled" // Needed to verify exact MUI classes
/>
);
const alert = screen.getByTestId(testId);
expect(alert).toBeDefined();
expect(alert).toHaveClass(`MuiAlert-filled${capitalize(severity)}`);
}
});
it('supports .variant property', () => {
const VARIANTS = ['filled', 'outlined', 'standard'];
for (const variant of VARIANTS) {
const testId = randomText(8);
render(
<ComponentToTest
data-testid={testId}
variant={variant as AlertProps['variant']}
severity="warning" // Needed to verify exact MUI classes
/>
);
const alert = screen.getByTestId(testId);
expect(alert).toBeDefined();
expect(alert).toHaveClass(`MuiAlert-${variant}Warning`);
}
});
});
@@ -0,0 +1,18 @@
import MuiAlert, { AlertProps as MuiAlertProps } from '@mui/material/Alert';
import { FunctionComponent } from 'react';
import { APP_ALERT_SEVERITY, APP_ALERT_VARIANT } from '../../config';
/**
* Application styled Alert component
* @component AppAlert
*/
const AppAlert: FunctionComponent<MuiAlertProps> = ({
severity = APP_ALERT_SEVERITY,
variant = APP_ALERT_VARIANT,
onClose,
...restOfProps
}) => {
return <MuiAlert severity={severity} sx={{ marginY: 1 }} variant={variant} onClose={onClose} {...restOfProps} />;
};
export default AppAlert;
@@ -0,0 +1,3 @@
import AppAlert from './AppAlert';
export { AppAlert as default, AppAlert };
@@ -0,0 +1,155 @@
import { FunctionComponent } from 'react';
import { render, screen, within } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import AppButton, { AppButtonProps } from './AppButton';
import DefaultIcon from '@mui/icons-material/MoreHoriz';
import { randomText, capitalize } from '@/utils';
/**
* AppButton wrapped with Theme Provider
*/
const ComponentToTest: FunctionComponent<AppButtonProps> = (props) => (
<ThemeProvider>
<AppButton {...props} />
</ThemeProvider>
);
/**
* Test specific color for AppButton
* @param {string} colorName - name of the color, one of ColorName type
* @param {string} [expectedClassName] - optional value to be found in className (color "true" may use "success" class name)
* @param {boolean} [ignoreClassName] - optional flag to ignore className (color "inherit" doesn't use any class name)
*/
function testButtonColor(colorName: string, ignoreClassName = false, expectedClassName = colorName) {
it(`supports "${colorName}" color`, () => {
const testId = randomText(8);
let text = `${colorName} button`;
render(
<ComponentToTest
color={colorName}
data-testid={testId}
variant="contained" // Required to get correct CSS class name
>
{text}
</ComponentToTest>
);
let button = screen.getByTestId(testId);
expect(button).toBeDefined();
// console.log('button.className:', button?.className);
if (!ignoreClassName) {
expect(button?.className?.includes('MuiButton-root')).toBeTruthy();
expect(button?.className?.includes('MuiButton-contained')).toBeTruthy();
expect(button?.className?.includes(`MuiButton-contained${capitalize(expectedClassName)}`)).toBeTruthy(); // Check for "MuiButton-contained[Primary| Secondary |...]" class
}
});
}
describe('<AppButton/> component', () => {
// beforeEach(() => {});
it('renders itself', () => {
let text = 'sample button';
const testId = randomText(8);
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
expect(button).toHaveAttribute('role', 'button');
expect(button).toHaveAttribute('type', 'button'); // not "submit" or "input" by default
});
it('has .margin style by default', () => {
let text = 'button with default margin';
const testId = randomText(8);
render(<ComponentToTest data-testid={testId}>{text}</ComponentToTest>);
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
expect(button).toHaveStyle('margin: 8px'); // Actually it is theme.spacing(1) value
});
it('supports .className property', () => {
let text = 'button with specific class';
let className = 'someClassName';
const testId = randomText(8);
render(
<ComponentToTest data-testid={testId} className={className}>
{text}
</ComponentToTest>
);
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
expect(button).toHaveClass(className);
});
it('supports .label property', () => {
let text = 'button with label';
render(<ComponentToTest label={text} />);
let span = screen.getByText(text);
expect(span).toBeDefined();
let button = span.closest('button'); // parent <button> element
expect(button).toBeDefined();
});
it('supports .text property', () => {
let text = 'button with text';
render(<ComponentToTest text={text} />);
let span = screen.getByText(text);
expect(span).toBeDefined();
let button = span.closest('button'); // parent <button> element
expect(button).toBeDefined();
});
it('supports .startIcon property as <svg/>', () => {
let text = 'button with start icon';
render(<ComponentToTest text={text} startIcon={<DefaultIcon data-testid="startIcon" />} />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('startIcon');
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-startIcon');
});
it('supports .endIcon property', () => {
let text = 'button with end icon as <svg/>';
render(<ComponentToTest text={text} endIcon={<DefaultIcon data-testid="endIcon" />} />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('endIcon');
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-endIcon');
});
it('supports .startIcon property as string', () => {
let text = 'button with start icon';
render(<ComponentToTest text={text} startIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizIcon'); //Note: this is valid only when "default" icon is <MoreHorizIcon />
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-startIcon');
});
it('supports .endIcon property', () => {
let text = 'button with end icon as string';
render(<ComponentToTest text={text} endIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizIcon'); //Note: this is valid only when "default" icon is <MoreHorizIcon />
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-endIcon');
});
// MUI colors
testButtonColor('inherit');
testButtonColor('primary');
testButtonColor('secondary');
testButtonColor('error');
testButtonColor('warning');
testButtonColor('info');
testButtonColor('success');
// Non-MUI colors
testButtonColor('green', true);
testButtonColor('#FF00FF', true);
testButtonColor('rgba(255, 0, 0, 0.5)', true);
});
@@ -0,0 +1,92 @@
import { ElementType, FunctionComponent, ReactNode, useMemo } from 'react';
import Button, { ButtonProps } from '@mui/material/Button';
import AppIcon from '../AppIcon';
import AppLink from '../AppLink';
import { APP_BUTTON_VARIANT } from '../../config';
const MUI_BUTTON_COLORS = ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'];
const DEFAULT_SX_VALUES = {
margin: 1, // By default the AppButton has theme.spacing(1) margin on all sides
};
export interface AppButtonProps extends Omit<ButtonProps, 'color' | 'endIcon' | 'startIcon'> {
color?: string; // Not only 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning',
endIcon?: string | ReactNode;
label?: string; // Alternate to .text
text?: string; // Alternate to .label
startIcon?: string | ReactNode;
// Missing props
component?: ElementType; // Could be RouterLink, AppLink, <a>, etc.
to?: string; // Link prop
href?: string; // Link prop
openInNewTab?: boolean; // Link prop
underline?: 'none' | 'hover' | 'always'; // Link prop
}
/**
* Application styled Material UI Button with Box around to specify margins using props
* @component AppButton
* @param {string} [color] - when passing MUI value ('primary', 'secondary', and so on), it is color of the button body, otherwise it is color of text and icons
* @param {string} [children] - content to render, overrides .label and .text props
* @param {string | ReactNode} [endIcon] - name of AppIcon or ReactNode to show after the button label
* @param {string} [href] - external link URI
* @param {string} [label] - text to render, alternate to .text
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
* @param {string | ReactNode} [startIcon] - name of AppIcon or ReactNode to show before the button label
* @param {Array<func| object| bool> | func | object} [sx] - additional CSS styles to apply to the button
* @param {string} [text] - text to render, alternate to .label
* @param {string} [to] - internal link URI
* @param {string} [underline] - controls underline style when button used as link, one of 'none', 'hover', or 'always'
* @param {string} [variant] - MUI variant of the button, one of 'text', 'outlined', or 'contained'
*/
const AppButton: FunctionComponent<AppButtonProps> = ({
children,
color: propColor = 'inherit',
component: propComponent,
endIcon,
label,
startIcon,
sx: propSx = DEFAULT_SX_VALUES,
text,
underline = 'none',
variant = APP_BUTTON_VARIANT,
...restOfProps
}) => {
const iconStart: ReactNode = useMemo(
() => (!startIcon ? undefined : typeof startIcon === 'string' ? <AppIcon icon={String(startIcon)} /> : startIcon),
[startIcon]
);
const iconEnd: ReactNode = useMemo(
() => (!endIcon ? undefined : typeof endIcon === 'string' ? <AppIcon icon={String(endIcon)} /> : endIcon),
[endIcon]
);
const isMuiColor = useMemo(() => MUI_BUTTON_COLORS.includes(propColor), [propColor]);
const componentToRender =
!propComponent && (restOfProps?.href || restOfProps?.to) ? AppLink : propComponent ?? Button;
const colorToRender = isMuiColor ? (propColor as ButtonProps['color']) : 'inherit';
const sxToRender = {
...propSx,
...(isMuiColor ? {} : { color: propColor }),
};
return (
<Button
component={componentToRender}
color={colorToRender}
endIcon={iconEnd}
startIcon={iconStart}
sx={sxToRender}
variant={variant}
{...{ ...restOfProps, underline }}
>
{children || label || text}
</Button>
);
};
export default AppButton;
@@ -0,0 +1,3 @@
import AppButton from './AppButton';
export { AppButton as default, AppButton };
@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import AppIcon from './AppIcon';
import { APP_ICON_SIZE } from '../../config';
import { randomColor, randomText } from '@/utils';
import { ICONS } from './config';
const ComponentToTest = AppIcon;
/**
* Tests for <AppIcon/> component
*/
describe('<AppIcon/> component', () => {
it('renders itself', () => {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} />);
const svg = screen.getByTestId(testId);
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('data-icon', 'default');
expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
});
it('supports .color property', () => {
const testId = randomText(8);
const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail
render(<ComponentToTest data-testid={testId} color={color} />);
const svg = screen.getByTestId(testId);
expect(svg).toHaveAttribute('data-icon', 'default');
// expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from <svg> rendering
expect(svg).toHaveStyle(`color: ${color}`);
expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set
});
it('supports .icon property', () => {
// Verify that all icons are supported
for (const icon of Object.keys(ICONS)) {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} icon={icon} />);
const svg = screen.getByTestId(testId);
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('data-icon', icon.toLowerCase());
}
});
it('supports .size property', () => {
const testId = randomText(8);
const size = Math.floor(Math.random() * 128) + 1;
render(<ComponentToTest data-testid={testId} size={size} />);
const svg = screen.getByTestId(testId);
expect(svg).toHaveAttribute('size', String(size));
expect(svg).toHaveAttribute('height', String(size));
expect(svg).toHaveAttribute('width', String(size));
});
it('supports .title property', () => {
const testId = randomText(8);
const title = randomText(16);
render(<ComponentToTest data-testid={testId} title={title} />);
const svg = screen.getByTestId(testId);
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('title', title);
});
});
@@ -0,0 +1,51 @@
import { ComponentType, FunctionComponent, SVGAttributes } from 'react';
import { APP_ICON_SIZE } from '../../config';
import { IconName, ICONS } from './config';
/**
* Props of the AppIcon component, also can be used for SVG icons
*/
export interface Props extends SVGAttributes<SVGElement> {
color?: string;
icon?: IconName | string;
size?: string | number;
title?: string;
}
/**
* Renders SVG icon by given Icon name
* @component AppIcon
* @param {string} [color] - color of the icon as a CSS color value
* @param {string} [icon] - name of the Icon to render
* @param {string} [title] - title/hint to show when the cursor hovers the icon
* @param {string | number} [size] - size of the icon, default is ICON_SIZE
*/
const AppIcon: FunctionComponent<Props> = ({
color,
icon = 'default',
size = APP_ICON_SIZE,
style,
...restOfProps
}) => {
const iconName = (icon || 'default').trim().toLowerCase() as IconName;
let ComponentToRender: ComponentType = ICONS[iconName];
if (!ComponentToRender) {
console.warn(`AppIcon: icon "${iconName}" is not found!`);
ComponentToRender = ICONS.default; // ICONS['default'];
}
const propsToRender = {
height: size,
color,
fill: color && 'currentColor',
size,
style: { ...style, color },
width: size,
...restOfProps,
};
return <ComponentToRender data-icon={iconName} {...propsToRender} />;
};
export default AppIcon;
@@ -0,0 +1,56 @@
// SVG assets
import PencilIcon from './icons/PencilIcon';
// MUI Icons
import DefaultIcon from '@mui/icons-material/MoreHoriz';
import SettingsIcon from '@mui/icons-material/Settings';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import DayNightIcon from '@mui/icons-material/Brightness4';
import NightIcon from '@mui/icons-material/Brightness3';
import DayIcon from '@mui/icons-material/Brightness5';
import SearchIcon from '@mui/icons-material/Search';
import InfoIcon from '@mui/icons-material/Info';
import HomeIcon from '@mui/icons-material/Home';
import AccountCircle from '@mui/icons-material/AccountCircle';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import PersonIcon from '@mui/icons-material/Person';
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
import NotificationsIcon from '@mui/icons-material/NotificationsOutlined';
import DangerousIcon from '@mui/icons-material/Dangerous';
/**
* List of all available Icon names
*/
export type IconName = keyof typeof ICONS;
/**
* How to use:
* 1. Import all required React, MUI or other SVG icons into this file.
* 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must!
* 3. Use icons everywhere in the App by their names in <Icon icon="xxx" /> component
* Important: properties of ICONS object MUST be lowercase!
* Note: You can use camelCase or UPPERCASE in the <Icon icon="someIconByName" /> component
*/
export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record<string, ComponentType> */ = {
default: DefaultIcon,
logo: PencilIcon,
close: CloseIcon,
menu: MenuIcon,
settings: SettingsIcon,
visibilityon: VisibilityIcon,
visibilityoff: VisibilityOffIcon,
daynight: DayNightIcon,
night: NightIcon,
day: DayIcon,
search: SearchIcon,
info: InfoIcon,
home: HomeIcon,
account: AccountCircle,
signup: PersonAddIcon,
login: PersonIcon,
logout: ExitToAppIcon,
notifications: NotificationsIcon,
error: DangerousIcon,
};
@@ -0,0 +1,29 @@
import { FunctionComponent } from 'react';
import { IconProps } from '../utils';
const CurrencyIcon: FunctionComponent<IconProps> = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 36 36" {...props}>
<path
fill="#D99E82"
d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"
/>
<path
fill="#EA596E"
d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"
/>
<path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z" />
<path
fill="#292F33"
d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"
/>
<path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z" />
<path
fill="#99AAB5"
d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"
/>
</svg>
);
};
export default CurrencyIcon;
@@ -0,0 +1,29 @@
import { FunctionComponent } from 'react';
import { IconProps } from '../utils';
const PencilIcon: FunctionComponent<IconProps> = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 36 36" {...props}>
<path
fill="#D99E82"
d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"
/>
<path
fill="#EA596E"
d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"
/>
<path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z" />
<path
fill="#292F33"
d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"
/>
<path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z" />
<path
fill="#99AAB5"
d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"
/>
</svg>
);
};
export default PencilIcon;
@@ -0,0 +1,125 @@
import { FunctionComponent } from 'react';
import { IconProps } from '../utils';
const YellowPlaneIcon: FunctionComponent<IconProps> = (props) => {
const styleOpacityAndEnableBackground = {
opacity: 0.2,
// enableBackground: 'new'
};
return (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" xmlSpace="preserve" {...props}>
<path
style={{ fill: '#FFCE00' }}
d="M142.21,493.991c12.991,12.991,34.057,12.991,47.05,0c12.991-12.991,12.991-34.057,0-47.048
L65.057,322.742c-12.993-12.992-34.059-12.992-47.05,0s-12.989,34.055,0,47.048L142.21,493.991z"
/>
<circle style={{ fill: '#7D868C' }} cx="386.857" cy="125.141" r="35.932" />
<path
style={styleOpacityAndEnableBackground}
d="M391.846,120.154c-9.556-9.556-18.727-17.187-27.59-22.948
c-0.969,0.785-1.908,1.627-2.807,2.527c-14.033,14.034-14.033,36.786,0,50.816c14.031,14.034,36.786,14.034,50.816,0
c0.908-0.907,1.754-1.853,2.546-2.829C409.07,138.902,401.453,129.759,391.846,120.154z"
/>
<path
style={styleOpacityAndEnableBackground}
d="M75.949,333.636c-15.914,18.935-28.002,33.038-32.3,37.337
c-4.221,4.221-7.777,8.86-10.672,13.785l94.264,94.266c4.925-2.894,9.565-6.449,13.789-10.672
c4.298-4.301,18.399-16.384,37.334-32.303L75.949,333.636z"
/>
<path
style={{ fill: '#333E48' }}
d="M384.037,127.964c-30.663-30.663-63.439-47.604-94.099-16.941
C254.182,146.782,79.164,363.201,57.52,384.842c-19.227,19.231-19.23,50.409,0,69.636c19.23,19.233,50.409,19.228,69.636,0
c21.644-21.641,238.063-196.657,273.82-232.416C431.639,191.4,414.698,158.626,384.037,127.964z"
/>
<circle style={{ fill: '#7D868C' }} cx="218.905" cy="293.104" r="35.93" />
<path
style={styleOpacityAndEnableBackground}
d="M278.708,123.019c-25.32,28.083-74.009,86.548-119.486,141.296
l88.463,88.463c54.748-45.477,113.215-94.162,141.296-119.487L278.708,123.019z"
/>
<g>
<path
style={{ fill: '#FFCE00' }}
d="M384.46,458.665c27.283,27.281,71.513,27.279,98.795-0.003c27.28-27.279,27.283-71.511,0-98.793
L152.13,28.746c-27.283-27.283-71.513-27.283-98.793,0c-27.283,27.281-27.285,71.514-0.002,98.793L384.46,458.665z"
/>
<path
style={{ fill: '#FFCE00' }}
d="M84.341,435.945c-2.121,0-4.241-0.809-5.857-2.426c-3.236-3.235-3.236-8.48-0.002-11.716
l50.812-50.814c3.236-3.236,8.483-3.235,11.716-0.001c3.236,3.235,3.236,8.48,0,11.714l-50.812,50.814
C88.58,435.136,86.459,435.945,84.341,435.945z"
/>
</g>
<rect
x="174.379"
y="88.222"
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 200.0443 399.0237)"
style={styleOpacityAndEnableBackground}
width="16.568"
height="139.719"
/>
<rect
x="117.601"
y="31.432"
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 143.2742 261.929)"
style={styleOpacityAndEnableBackground}
width="16.568"
height="139.719"
/>
<rect
x="345.637"
y="259.464"
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 371.313 812.4501)"
style={styleOpacityAndEnableBackground}
width="16.568"
height="139.719"
/>
<rect
x="402.427"
y="316.259"
transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 428.1006 949.5629)"
style={styleOpacityAndEnableBackground}
width="16.568"
height="139.719"
/>
<path
style={{ fill: '#1E252B' }}
d="M489.114,354.011L383.944,248.841c10.747-9.425,18.276-16.308,22.89-20.921
c16.43-16.43,22.038-34.95,16.673-55.044c-1.363-5.108-3.454-10.308-6.267-15.628c0.296-0.281,0.596-0.553,0.885-0.842
c2.167-2.167,4.076-4.523,5.724-7.024l41.209,41.209c1.618,1.617,3.739,2.426,5.858,2.426c2.12,0,4.24-0.808,5.858-2.426
c3.235-3.236,3.235-8.48,0-11.716l-46.323-46.323c0.406-2.427,0.626-4.902,0.626-7.411c0-11.811-4.599-22.914-12.95-31.265
c-8.351-8.352-19.454-12.953-31.265-12.953c-2.511,0-4.987,0.22-7.415,0.627L333.126,35.23c-3.235-3.235-8.48-3.236-11.716-0.001
c-3.236,3.235-3.236,8.48-0.001,11.714l41.209,41.21c-2.503,1.647-4.858,3.555-7.025,5.722c-0.288,0.288-0.562,0.59-0.843,0.886
c-5.319-2.812-10.518-4.902-15.627-6.267c-20.099-5.369-38.615,0.243-55.044,16.673c-4.61,4.611-11.492,12.142-20.921,22.893
L157.989,22.89C143.229,8.129,123.605,0,102.733,0S62.237,8.129,47.48,22.888c-14.76,14.76-22.89,34.383-22.89,55.254
c-0.001,20.873,8.127,40.497,22.887,55.254l114.564,114.564c-6.337,7.624-12.648,15.223-18.86,22.703
c-19.346,23.293-38.189,45.983-53.839,64.649l-18.428-18.429c-7.848-7.848-18.284-12.17-29.383-12.17s-21.534,4.322-29.382,12.172
c-16.198,16.199-16.198,42.559,0,58.761l25.683,25.684c-6.704,20.045-2.103,43.073,13.83,59.004
c10.865,10.867,25.311,16.85,40.677,16.85c6.339,0,12.515-1.034,18.354-2.994l25.658,25.658c8.102,8.101,18.74,12.15,29.381,12.15
c10.64,0,21.284-4.051,29.384-12.15c16.201-16.201,16.201-42.562-0.001-58.763l-18.43-18.43
c18.659-15.643,41.335-34.476,64.615-53.811c7.49-6.221,15.101-12.541,22.736-18.887l114.566,114.564
c14.757,14.756,34.379,22.885,55.251,22.887c0.002,0,0.002,0,0.004,0c20.87,0,40.495-8.129,55.254-22.89
c14.758-14.759,22.887-34.381,22.888-55.253C512.002,388.394,503.872,368.77,489.114,354.011z M386.86,97.491
c7.385,0,14.328,2.876,19.55,8.099c5.222,5.222,8.097,12.165,8.097,19.55c0,6.56-2.273,12.766-6.438,17.731
c-4.96-6.686-10.995-13.587-18.174-20.765c-7.179-7.179-14.08-13.215-20.767-18.176C374.093,99.765,380.301,97.491,386.86,97.491z
M295.795,116.881c12.28-12.282,24.689-16.218,39.053-12.38c12.86,3.434,27.034,13.026,43.329,29.323
c16.296,16.295,25.886,30.468,29.32,43.329c3.836,14.362-0.098,26.772-12.382,39.054c-4.413,4.414-12.113,11.434-22.915,20.895
l-97.303-97.303C284.365,128.993,291.385,121.29,295.795,116.881z M23.866,363.932c-9.74-9.742-9.74-25.593,0-35.333
c4.717-4.718,10.992-7.317,17.666-7.317c6.675,0,12.949,2.599,17.668,7.317l19.438,19.441C65.414,363.702,55.648,375,51.662,378.986
c-2.168,2.168-4.12,4.47-5.868,6.876L23.866,363.932z M183.401,452.801c9.742,9.741,9.742,25.59,0.001,35.331
c-9.742,9.741-25.594,9.742-35.336,0l-21.927-21.926c2.417-1.763,4.717-3.717,6.873-5.872c3.985-3.985,15.285-13.751,30.947-26.976
L183.401,452.801z M230.718,356.101c-53.485,44.418-99.676,82.779-109.419,92.521c-7.735,7.735-18.02,11.995-28.96,11.996
c-10.939,0-21.225-4.26-28.961-11.997c-15.968-15.966-15.968-41.949,0-57.92c9.744-9.743,48.118-55.949,92.55-109.452
c5.891-7.094,11.87-14.293,17.879-21.523l78.468,78.468C245.033,344.21,237.822,350.2,230.718,356.101z M477.397,452.804
c-11.631,11.632-27.093,18.038-43.539,18.038h-0.003c-16.447-0.001-31.909-6.406-43.538-18.034L59.192,121.681
c-11.631-11.628-18.036-27.09-18.034-43.538c0-16.447,6.406-31.91,18.037-43.541c11.631-11.629,27.093-18.034,43.539-18.034
c16.447,0,31.91,6.405,43.54,18.036l331.124,331.123c11.63,11.629,18.036,27.093,18.036,43.54
C495.434,425.713,489.029,441.175,477.397,452.804z"
/>
</svg>
);
};
export default YellowPlaneIcon;
@@ -0,0 +1,3 @@
import AppIcon from './AppIcon';
export { AppIcon as default, AppIcon };
@@ -0,0 +1,11 @@
import { SVGAttributes } from 'react';
/**
* Props to use with custom SVG icons, similar to AppIcon's Props
*/
export interface IconProps extends SVGAttributes<SVGElement> {
color?: string;
icon?: string;
size?: string | number;
title?: string;
}
@@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react';
import AppIconButton, { MUI_ICON_BUTTON_COLORS } from './AppIconButton';
import { APP_ICON_SIZE } from '../../config';
import { capitalize, randomColor, randomText } from '@/utils';
import { ICONS } from '../AppIcon/config';
const ComponentToTest = AppIconButton;
function randomPropertyName(obj: object): string {
const objectProperties = Object.keys(obj);
const propertyName = objectProperties[Math.floor(Math.random() * objectProperties.length)];
return propertyName;
}
// function randomPropertyValue(obj: object): unknown {
// const propertyName = randomPropertyName(obj);
// return (obj as ObjectPropByName)[propertyName];
// }
/**
* Tests for <AppIconButton/> component
*/
describe('<AppIconButton/> component', () => {
it('renders itself', () => {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} />);
// Button
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
expect(button).toHaveAttribute('role', 'button');
expect(button).toHaveAttribute('type', 'button');
// Icon
const svg = button.querySelector('svg');
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('data-icon', 'default'); // default icon
expect(svg).toHaveAttribute('size', String(APP_ICON_SIZE)); // default size
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
});
it('supports .color property', () => {
for (const color of [...MUI_ICON_BUTTON_COLORS, randomColor(), randomColor(), randomColor()]) {
const testId = randomText(8);
const icon = randomPropertyName(ICONS) as string;
render(<ComponentToTest data-testid={testId} color={color} icon={icon} />);
// Button
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
if (color == 'default') {
return; // Nothing to test for default color
}
if (MUI_ICON_BUTTON_COLORS.includes(color)) {
expect(button).toHaveClass(`MuiIconButton-color${capitalize(color)}`);
} else {
expect(button).toHaveStyle({ color: color });
}
}
});
it('supports .disable property', () => {
const testId = randomText(8);
const title = randomText(16);
render(<ComponentToTest data-testid={testId} disabled />);
// Button
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button).toHaveClass('Mui-disabled');
});
it('supports .icon property', () => {
// Verify that all icons are supported
for (const icon of Object.keys(ICONS)) {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} icon={icon} />);
// Button
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
// Icon
const svg = button.querySelector('svg');
expect(button).toBeDefined();
expect(svg).toHaveAttribute('data-icon', icon.toLowerCase());
}
});
it('supports .size property', () => {
const sizes = ['small', 'medium', 'large'] as const; // as IconButtonProps['size'][];
for (const size of sizes) {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} size={size} />);
// Button
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
expect(button).toHaveClass(`MuiIconButton-size${capitalize(size)}`); // MuiIconButton-sizeSmall | MuiIconButton-sizeMedium | MuiIconButton-sizeLarge
}
});
it('supports .title property', async () => {
const testId = randomText(8);
const title = randomText(16);
render(<ComponentToTest data-testid={testId} title={title} />);
// Button
const button = screen.getByTestId(testId);
expect(button).toBeDefined();
expect(button).toHaveAttribute('aria-label', title);
// Emulate mouseover event to show tooltip
await fireEvent(button, new MouseEvent('mouseover', { bubbles: true }));
// Tooltip is rendered in a separate div, so we need to find it by role
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeDefined();
expect(tooltip).toHaveTextContent(title);
expect(tooltip).toHaveClass('MuiTooltip-popper');
});
});
@@ -0,0 +1,98 @@
import { ElementType, FunctionComponent, useMemo } from 'react';
import { Tooltip, IconButton, IconButtonProps, TooltipProps } from '@mui/material';
import AppIcon from '../AppIcon';
import AppLink from '../AppLink';
import { alpha } from '@mui/material';
import { Props } from '../AppIcon/AppIcon';
import { IconName } from '../AppIcon/config';
export const MUI_ICON_BUTTON_COLORS = [
'inherit',
'default',
'primary',
'secondary',
'success',
'error',
'info',
'warning',
];
export interface AppIconButtonProps extends Omit<IconButtonProps, 'color'> {
color?: string; // Not only 'inherit' | 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning',
icon?: IconName | string;
iconProps?: Partial<Props>;
// Missing props
component?: ElementType; // Could be RouterLink, AppLink, <a>, etc.
to?: string; // Link prop
href?: string; // Link prop
openInNewTab?: boolean; // Link prop
tooltipProps?: Partial<TooltipProps>;
}
/**
* Renders MUI IconButton with SVG image by given Icon name
* @param {string} [color] - color of background and hover effect. Non MUI values is also accepted.
* @param {boolean} [disabled] - the IconButton is not active when true, also the Tooltip is not rendered.
* @param {string} [href] - external link URI
* @param {string} [icon] - name of Icon to render inside the IconButton
* @param {object} [iconProps] - additional props to pass into the AppIcon component
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
* @param {string} [size] - size of the button: 'small', 'medium' or 'large'
* @param {Array<func| object| bool> | func | object} [sx] - additional CSS styles to apply to the button
* @param {string} [title] - when set, the IconButton is rendered inside Tooltip with this text
* @param {string} [to] - internal link URI
* @param {object} [tooltipProps] - additional props to pass into the Tooltip component
*/
const AppIconButton: FunctionComponent<AppIconButtonProps> = ({
color = 'default',
component,
children,
disabled,
icon,
iconProps,
sx,
title,
tooltipProps,
...restOfProps
}) => {
const componentToRender = !component && (restOfProps?.href || restOfProps?.to) ? AppLink : component ?? IconButton;
const isMuiColor = useMemo(() => MUI_ICON_BUTTON_COLORS.includes(color), [color]);
const iconButtonToRender = useMemo(() => {
const colorToRender = isMuiColor ? (color as IconButtonProps['color']) : 'default';
const sxToRender = {
...sx,
...(!isMuiColor && {
color: color,
':hover': {
backgroundColor: alpha(color, 0.04),
},
}),
};
return (
<IconButton
component={componentToRender}
color={colorToRender}
disabled={disabled}
sx={sxToRender}
{...restOfProps}
>
<AppIcon icon={icon} {...iconProps} />
{children}
</IconButton>
);
}, [color, componentToRender, children, disabled, icon, isMuiColor, sx, iconProps, restOfProps]);
// When title is set, wrap the IconButton with Tooltip.
// Note: when IconButton is disabled the Tooltip is not working, so we don't need it
return title && !disabled ? (
<Tooltip title={title} {...tooltipProps}>
{iconButtonToRender}
</Tooltip>
) : (
iconButtonToRender
);
};
export default AppIconButton;
@@ -0,0 +1,3 @@
import AppIconButton from './AppIconButton';
export { AppIconButton as default, AppIconButton };
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import { randomText } from '@/utils';
import AppImage from './AppImage';
const ComponentToTest = AppImage;
/**
* Tests for <AppImage/> component
*/
describe('<AppImage/> component', () => {
const src = 'https:/domain.com/image.jpg';
it('renders itself', () => {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} src={src} />);
const image = screen.getByTestId(testId);
expect(image).toBeDefined();
expect(image).toHaveAttribute('src', src);
expect(image).toHaveAttribute('alt', 'Image'); // Default prop value
expect(image).toHaveAttribute('height', '256'); // Default prop value
expect(image).toHaveAttribute('width', '256'); // Default prop value
});
it('supports .width and .height props', () => {
const testId = randomText(8);
const height = 345;
const width = 123;
render(<ComponentToTest data-testid={testId} height={height} src={src} width={width} />);
const image = screen.getByTestId(testId);
expect(image).toBeDefined();
expect(image).toHaveAttribute('height', String(height));
expect(image).toHaveAttribute('width', String(width));
});
it('supports .title property', () => {
const testId = randomText(8);
const title = randomText(16);
render(<ComponentToTest data-testid={testId} src={src} title={title} />);
const image = screen.getByTestId(testId);
expect(image).toBeDefined();
expect(image).toHaveAttribute('title', title);
expect(image).toHaveAttribute('alt', title); // When title is provided, it is used as alt
});
it('supports .alt property even when .title is provided', () => {
const testId = randomText(8);
const title = randomText(16);
const alt = randomText(32);
render(<ComponentToTest alt={alt} data-testid={testId} src={src} title={title} />);
const image = screen.getByTestId(testId);
expect(image).toBeDefined();
expect(image).toHaveAttribute('alt', alt);
expect(image).toHaveAttribute('title', title);
});
});
@@ -0,0 +1,23 @@
import { FunctionComponent } from 'react';
import NextImage, { ImageProps } from 'next/image';
interface AppImageProps extends Omit<ImageProps, 'alt'> {
alt?: string; // Make property optional as it was before NextJs v13
}
/**
* Application wrapper around NextJS image with some default props
* @component AppImage
*/
const AppImage: FunctionComponent<AppImageProps> = ({
title, // Note: value has be destructed before usage as default value for other property
alt = title ?? 'Image',
height = 256,
width = 256,
...restOfProps
}) => {
// Uses custom loader + unoptimized="true" to avoid NextImage warning https://nextjs.org/docs/api-reference/next/image#unoptimized
return <NextImage alt={alt} height={height} title={title} unoptimized={true} width={width} {...restOfProps} />;
};
export default AppImage;
@@ -0,0 +1,3 @@
import AppImage from './AppImage';
export { AppImage as default, AppImage };
@@ -0,0 +1,229 @@
import { render, screen } from '@testing-library/react';
import mockRouter from 'next-router-mock';
/* IMPORTANT! To get 'next/router' working with tests, add into "jest.setup.js" file following:
---
jest.mock('next/router', () => require('next-router-mock'));
---
*/
import AppLink from '.';
import { capitalize, randomColor } from '@/utils';
jest.mock('next/navigation', () => {
const result = {
...require('next-router-mock'),
// useSearchParams: () => jest.fn(),
usePathname: () => {
const router = mockRouter;
return router.asPath;
},
};
return result;
});
/**
* AppLink wrapped with Mocked Router
*/
const ComponentToTest = AppLink;
/**
* Tests for <AppLink/> component
*/
describe('<AppLink/> component', () => {
it('renders itself', () => {
const text = 'sample text';
const url = 'https://example.com/';
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
const link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).toHaveAttribute('href', url);
expect(link).toHaveTextContent(text);
});
it('supports external link', () => {
const text = 'external link';
const url = 'https://example.com/';
render(<ComponentToTest href={url}>{text}</ComponentToTest>);
const link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).toHaveAttribute('href', url);
expect(link).toHaveTextContent(text);
expect(link).toHaveAttribute('target', '_blank'); // Open external links in new Tab by default
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
const rel = (link as any)?.rel;
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
});
it('supports internal link', () => {
const text = 'internal link';
const url = '/internal-link';
render(<ComponentToTest to={url}>{text}</ComponentToTest>);
const link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).toHaveAttribute('href', url);
expect(link).toHaveTextContent(text);
expect(link).not.toHaveAttribute('target');
expect(link).not.toHaveAttribute('rel');
});
it('supports .openInNewTab property', () => {
// External link with openInNewTab={false}
let text = 'external link in same tab';
let url = 'https://example.com/';
render(
<ComponentToTest href={url} openInNewTab={false}>
{text}
</ComponentToTest>
);
let link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).toHaveAttribute('href', url);
expect(link).toHaveTextContent(text);
expect(link).not.toHaveAttribute('target');
expect(link).not.toHaveAttribute('rel');
// Internal link with openInNewTab={true}
text = 'internal link in new tab';
url = '/internal-link-in-new-tab';
render(
<ComponentToTest to={url} openInNewTab>
{text}
</ComponentToTest>
);
link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).toHaveAttribute('href', url);
expect(link).toHaveTextContent(text);
expect(link).toHaveAttribute('target', '_blank'); // Open links in new Tab
expect(link).toHaveAttribute('rel'); // For links opened in new Tab rel="noreferrer noopener" is required
const rel = (link as any)?.rel;
expect(rel.includes('noreferrer')).toBeTruthy(); // ref="noreferrer" check
expect(rel.includes('noopener')).toBeTruthy(); // rel="noreferrer check
});
it('supports .className property', () => {
let text = 'internal link with specific class';
let url = '/internal-link-with-class';
let className = 'someClassName';
render(
<ComponentToTest to={url} className={className}>
{text}
</ComponentToTest>
);
let link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).toHaveClass(className);
});
it('supports .activeClassName property in pair with .to property', () => {
let link;
let textActive = 'internal link with activeClassName';
let textPassive = 'internal link without activeClassName';
let url = '/internal-link';
let activeClassName = 'someClassName';
// router.pathhname doesn't match .to prop
mockRouter.push('not-' + url);
render(
<ComponentToTest to={url} activeClassName={activeClassName}>
{textPassive}
</ComponentToTest>
);
link = screen.getByText(textPassive);
expect(link).toBeDefined();
expect(link).not.toHaveClass(activeClassName);
// router.pathhname matches .to prop
mockRouter.push(url);
render(
<ComponentToTest to={url} activeClassName={activeClassName}>
{textActive}
</ComponentToTest>
);
link = screen.getByText(textActive);
expect(link).toBeDefined();
expect(link).toHaveClass(activeClassName);
});
it('supports .activeClassName property in pair with .href property', () => {
let link;
let textActive = 'external link with activeClassName';
let textPassive = 'external link without activeClassName';
let url = '/external-link.com';
let activeClassName = 'someClassName';
// router.pathhname doesn't match .href prop
mockRouter.push('not-' + url);
render(
<ComponentToTest href={url} activeClassName={activeClassName}>
{textPassive}
</ComponentToTest>
);
link = screen.getByText(textPassive);
expect(link).toBeDefined();
expect(link).not.toHaveClass(activeClassName);
// router.pathhname matches .href prop
mockRouter.push(url);
render(
<ComponentToTest href={url} activeClassName={activeClassName}>
{textActive}
</ComponentToTest>
);
link = screen.getByText(textActive);
expect(link).toBeDefined();
expect(link).toHaveClass(activeClassName);
});
it('supports .color property', () => {
// Check several times with random colors
for (let i = 1; i < 5; i++) {
let text = `link #${i} with .color property`;
let url = '/internal-link-with-color';
let color = randomColor();
render(
<ComponentToTest to={url} color={color}>
{text}
</ComponentToTest>
);
let link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).toHaveStyle(`color: ${color}`);
}
});
it('supports .underline property', () => {
// Enumerate all possible values
['hover', 'always', 'none'].forEach((underline) => {
let text = `link with .underline == "${underline}"`;
let url = '/internal-link-with-underline';
render(
<ComponentToTest to={url} underline={underline as any}>
{text}
</ComponentToTest>
);
let link = screen.getByText(text);
expect(link).toBeDefined();
underline === 'none'
? expect(link).toHaveStyle('text-decoration: none')
: expect(link).toHaveStyle('text-decoration: underline');
// TODO: make "hover" test with "mouse moving"
expect(link).toHaveClass(`MuiLink-underline${capitalize(underline)}`);
});
});
it('supports .noLinkStyle property', () => {
let text = 'internal link noLinkStyle';
let url = '/internal-link-no-style';
let noLinkStyle = true;
render(
<ComponentToTest to={url} noLinkStyle={noLinkStyle}>
{text}
</ComponentToTest>
);
let link = screen.getByText(text);
expect(link).toBeDefined();
expect(link).not.toHaveClass('MuiLink-root');
});
});
@@ -0,0 +1,136 @@
'use client';
// See: https://github.com/mui-org/material-ui/blob/6b18675c7e6204b77f4c469e113f62ee8be39178/examples/nextjs-with-typescript/src/Link.tsx
/* eslint-disable jsx-a11y/anchor-has-content */
import { AnchorHTMLAttributes, forwardRef } from 'react';
import clsx from 'clsx';
import { usePathname } from 'next/navigation';
import NextLink, { LinkProps as NextLinkProps } from 'next/link';
import MuiLink, { LinkProps as MuiLinkProps } from '@mui/material/Link';
import { APP_LINK_COLOR, APP_LINK_UNDERLINE } from '../../config';
export const EXTERNAL_LINK_PROPS = {
target: '_blank',
rel: 'noopener noreferrer',
};
/**
* Props for NextLinkComposed component
*/
interface NextLinkComposedProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,
Omit<NextLinkProps, 'href' | 'as' | 'onClick' | 'onMouseEnter'> {
to: NextLinkProps['href'];
linkAs?: NextLinkProps['as'];
href?: NextLinkProps['href'];
}
/**
* NextJS composed link to use with Material UI
* @NextLinkComposed NextLinkComposed
*/
const NextLinkComposed = forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
{ to, linkAs, href, replace, scroll, passHref, shallow, prefetch, ...restOfProps },
ref
) {
return (
<NextLink
legacyBehavior={true} // TODO: Remove when MUI become compatible with NextJs 13+
href={to}
prefetch={prefetch}
as={linkAs}
replace={replace}
scroll={scroll}
shallow={shallow}
passHref={passHref}
>
<a ref={ref} {...restOfProps} />
</NextLink>
);
});
/**
* Props for AppLinkForNext component
*/
export type AppLinkForNextProps = {
activeClassName?: string;
as?: NextLinkProps['as'];
href?: string | NextLinkProps['href'];
noLinkStyle?: boolean;
to?: string | NextLinkProps['href'];
openInNewTab?: boolean;
} & Omit<NextLinkComposedProps, 'to' | 'linkAs' | 'href'> &
Omit<MuiLinkProps, 'href'>;
/**
* Material UI link for NextJS
* A styled version of the Next.js Link component: https://nextjs.org/docs/#with-link
* @component AppLinkForNext
* @param {string} [activeClassName] - class name for active link, applied when the router.pathname matches .href or .to props
* @param {string} [as] - passed to NextJS Link component in .as prop
* @param {string} [className] - class name for <a> tag or NextJS Link component
* @param {object|function} children - content to wrap with <a> tag
* @param {string} [color] - color of the link
* @param {boolean} [noLinkStyle] - when true, link will not have MUI styles
* @param {string} [to] - internal link URI
* @param {string} [href] - external link URI
* @param {boolean} [openInNewTab] - link will be opened in new tab when true
* @param {string} [underline] - controls "underline" style of the MUI link: 'hover' | 'always' | 'none'
*/
const AppLinkForNext = forwardRef<HTMLAnchorElement, AppLinkForNextProps>(function Link(props, ref) {
const {
activeClassName = 'active', // This class is applied to the Link component when the router.pathname matches the href/to prop
as: linkAs,
className: classNameProps,
href,
noLinkStyle,
role, // Link don't have roles, so just exclude it from ...restOfProps
color = APP_LINK_COLOR,
underline = APP_LINK_UNDERLINE,
to,
sx,
openInNewTab = Boolean(href), // Open external links in new Tab by default
...restOfProps
} = props;
const currentPath = usePathname();
const destination = to ?? href ?? '';
const pathname = typeof destination === 'string' ? destination : destination.pathname;
const className = clsx(classNameProps, {
[activeClassName]: pathname == currentPath && activeClassName,
});
const isExternal =
typeof destination === 'string' && (destination.startsWith('http') || destination.startsWith('mailto:'));
const propsToRender = {
color,
underline, // 'hover' | 'always' | 'none'
...(openInNewTab && EXTERNAL_LINK_PROPS),
...restOfProps,
};
if (isExternal) {
if (noLinkStyle) {
return <a className={className} href={destination as string} ref={ref as any} {...propsToRender} />;
}
return <MuiLink className={className} href={destination as string} ref={ref} sx={sx} {...propsToRender} />;
}
if (noLinkStyle) {
return <NextLinkComposed className={className} ref={ref as any} to={destination} {...propsToRender} />;
}
return (
<MuiLink
component={NextLinkComposed}
linkAs={linkAs}
className={className}
ref={ref}
to={destination}
sx={sx}
{...propsToRender}
/>
);
});
export default AppLinkForNext;
@@ -0,0 +1,4 @@
import AppLink, { AppLinkForNextProps as AppLinkProps } from './AppLinkNextNavigation';
export type { AppLinkProps };
export { AppLink as default, AppLink };
@@ -0,0 +1,36 @@
import { FunctionComponent } from 'react';
import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material';
import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '@/components/config';
interface Props extends StackProps {
color?: CircularProgressProps['color'];
size?: number | string;
type?: 'circular' | 'linear';
value?: number;
}
/**
* Renders MI circular progress centered inside Stack
* @component AppLoading
* @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem'
*/
const AppLoading: FunctionComponent<Props> = ({
color = APP_LOADING_COLOR,
size = APP_LOADING_SIZE,
type = APP_LOADING_TYPE,
value,
...restOfProps
}) => {
const alignItems = type === 'linear' ? undefined : 'center';
return (
<Stack my={2} alignItems={alignItems} {...restOfProps}>
{type === 'linear' ? (
<LinearProgress color={color} value={value} />
) : (
<CircularProgress color={color} size={size} value={value} />
)}
</Stack>
);
};
export default AppLoading;
@@ -0,0 +1,4 @@
import AppLoading from './AppLoading';
export { AppLoading };
export default AppLoading;
@@ -0,0 +1,61 @@
'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
name: string;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
/**
* Error boundary wrapper to save Application parts from falling
* @component ErrorBoundary
* @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default
*/
class ErrorBoundary extends Component<Props, State> {
static defaultProps = {
name: 'Error Boundary',
};
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
// The next render will show the Error UI
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Save information to help render Error UI
this.setState({ error, errorInfo });
// TODO: Add log error messages to an error reporting service here
}
render() {
if (this.state.hasError) {
// Error UI rendering
return (
<div>
<h2>{this.props.name} - Something went wrong</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state?.error?.toString()}
<br />
{this.state?.errorInfo?.componentStack}
</details>
</div>
);
}
// Normal UI rendering
return this.props.children;
}
}
export default ErrorBoundary;
+10
View File
@@ -0,0 +1,10 @@
import AppAlert from './AppAlert';
import AppButton from './AppButton';
import AppIcon from './AppIcon';
import AppIconButton from './AppIconButton';
import AppImage from './AppImage';
import AppLink from './AppLink';
import AppLoading from './AppLoading';
import ErrorBoundary from './ErrorBoundary';
export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppImage, AppLink, AppLoading };
+35
View File
@@ -0,0 +1,35 @@
/**
* Components configuration
*/
export const CONTENT_MAX_WIDTH = 800;
export const CONTENT_MIN_WIDTH = 320; // CONTENT_MAX_WIDTH - Sidebar width
/**
* AppAlert and AppSnackBarAlert components
*/
export const APP_ALERT_SEVERITY = 'error'; // 'error' | 'info'| 'success' | 'warning'
export const APP_ALERT_VARIANT = 'filled'; // 'filled' | 'outlined' | 'standard'
/**
* AppButton component
*/
export const APP_BUTTON_VARIANT = 'contained'; // | 'text' | 'outlined'
export const APP_BUTTON_MARGIN = 1;
/**
* AppIcon component
*/
export const APP_ICON_SIZE = 24;
/**
* AppLink component
*/
export const APP_LINK_COLOR = 'textSecondary'; // 'primary' // 'secondary'
export const APP_LINK_UNDERLINE = 'hover'; // 'always
/**
* AppLoading component
*/
export const APP_LOADING_COLOR = 'primary'; // 'secondary'
export const APP_LOADING_SIZE = '3rem'; // 40
export const APP_LOADING_TYPE = 'circular'; // 'linear'; // 'circular'
+5
View File
@@ -0,0 +1,5 @@
export * from './common';
import UserInfo from './UserInfo';
export { UserInfo };
+15
View File
@@ -0,0 +1,15 @@
import { envRequired, getCurrentEnvironment } from '@/utils/environment';
export const IS_DEBUG = process.env.NEXT_PUBLIC_DEBUG === 'true'; // Enables logging, etc.
export const IS_PRODUCTION = getCurrentEnvironment() === 'production'; // Enables analytics, etc.
// export const PUBLIC_URL = envRequired(process.env.NEXT_PUBLIC_PUBLIC_URL); // Variant 1: .env variable is required
export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env variable is optional
IS_DEBUG &&
console.log('@/config', {
IS_DEBUG,
IS_PRODUCTION,
PUBLIC_URL,
});
+32
View File
@@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { sessionStorageGet, sessionStorageDelete } from '@/utils/sessionStorage';
import { useAppStore } from '../store';
/**
* Hook to detect is current user authenticated or not
* @returns {boolean} true if user is authenticated, false otherwise
*/
export function useIsAuthenticated() {
const [state] = useAppStore();
let result = state.isAuthenticated;
// TODO: AUTH: replace next line with access token verification
result = Boolean(sessionStorageGet('access_token', ''));
return result;
}
/**
* Returns event handler to Logout current user
* @returns {function} calling this event logs out current user
*/
export function useEventLogout() {
const [, dispatch] = useAppStore();
return useCallback(() => {
// TODO: AUTH: replace next line with access token saving
sessionStorageDelete('access_token');
dispatch({ type: 'LOG_OUT' });
}, [dispatch]);
}
+17
View File
@@ -0,0 +1,17 @@
import { useCallback } from 'react';
import { useAppStore } from '../store';
/**
* Returns event handler to toggle Dark/Light modes
* @returns {function} calling this event toggles dark/light mode
*/
export function useEventSwitchDarkMode() {
const [state, dispatch] = useAppStore();
return useCallback(() => {
dispatch({
type: 'DARK_MODE',
payload: !state.darkMode,
});
}, [state, dispatch]);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './auth';
export * from './event';
export * from './layout';
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import useWindowsSize from './useWindowSize';
import { useMediaQuery, useTheme } from '@mui/material';
import { IS_SERVER } from '@/utils';
export const MOBILE_SCREEN_MAX_WIDTH = 600; // Sync with https://mui.com/material-ui/customization/breakpoints/
export const SERVER_SIDE_MOBILE_FIRST = true; // true - for mobile, false - for desktop
/**
* Hook to detect onMobile vs. onDesktop using "resize" event listener
* @returns {boolean} true when on onMobile, false when on onDesktop
*/
export function useIsMobileByWindowsResizing() {
const theme = useTheme();
const { width } = useWindowsSize();
const onMobile = width <= theme.breakpoints?.values?.sm ?? MOBILE_SCREEN_MAX_WIDTH;
return onMobile;
}
/**
* Hook to detect onMobile vs. onDesktop using Media Query
* @returns {boolean} true when on onMobile, false when on onDesktop
*/
function useIsMobileByMediaQuery() {
// const onMobile = useMediaQuery({ maxWidth: MOBILE_SCREEN_MAX_WIDTH });
const theme = useTheme();
const onMobile = useMediaQuery(theme.breakpoints.down('sm'));
return onMobile;
}
/**
* Hook to detect onMobile vs. onDesktop with Next.js workaround
* @returns {boolean} true when on onMobile, false when on onDesktop
*/
function useIsMobileForNextJs() {
// const onMobile = useOnMobileByWindowsResizing();
const onMobile = useIsMobileByMediaQuery();
const [onMobileDelayed, setOnMobileDelayed] = useState(SERVER_SIDE_MOBILE_FIRST);
useEffect(() => {
setOnMobileDelayed(onMobile); // Next.js don't allow to use useOnMobileXxx() directly, so we need to use this workaround
}, [onMobile]);
return onMobileDelayed;
}
/**
* Hook to apply "onMobile" vs. "onDesktop" class to document.body depending on screen size.
* Due to SSR/SSG we can not set 'app-layout onMobile' or 'app-layout onDesktop' on the server
* If we modify className using JS, we will got Warning: Prop `className` did not match. Server: "app-layout" Client: "app-layout onDesktop"
* So we have to apply document.body.class using the hook :)
* Note: Use this hook one time only! In main App or Layout component
*/
function useMobileOrDesktopByChangingBodyClass() {
// const onMobile = useOnMobileByWindowsResizing();
const onMobile = useIsMobileByMediaQuery();
useEffect(() => {
if (onMobile) {
document.body.classList.remove('onDesktop');
document.body.classList.add('onMobile');
} else {
document.body.classList.remove('onMobile');
document.body.classList.add('onDesktop');
}
}, [onMobile]);
}
/**
* We need a "smart export wrappers", because we can not use hooks on the server side
*/
// export const useOnMobile = IS_SERVER ? () => SERVER_SIDE_IS_MOBILE_VALUE : useOnMobileByWindowsResizing;
// export const useOnMobile = IS_SERVER ? () => SERVER_SIDE_IS_MOBILE_VALUE : useOnMobileByMediaQuery;
export const useIsMobile = IS_SERVER ? () => SERVER_SIDE_MOBILE_FIRST : useIsMobileForNextJs;
export const useBodyClassForMobileOrDesktop = IS_SERVER ? () => undefined : useMobileOrDesktopByChangingBodyClass;
+40
View File
@@ -0,0 +1,40 @@
import { useLayoutEffect, useState } from 'react';
import { IS_SERVER } from '@/utils';
const MOBILE_WINDOWS_SIZE = { width: 720, height: 1280 };
const DESKTOP_WINDOWS_SIZE = { width: 1920, height: 1080 };
const DEFAULT_WINDOWS_SIZE = MOBILE_WINDOWS_SIZE ?? DESKTOP_WINDOWS_SIZE; // Mobile-First by default
type WindowSize = {
width: number;
height: number;
};
/**
* Hook to monitor Window (actually Browser) Size using "resize" event listener
* @returns {WindowSize} current window size as {width, height} object
*/
const useWindowSize = (): WindowSize => {
const [windowSize, setWindowSize] = useState<WindowSize>(DEFAULT_WINDOWS_SIZE);
useLayoutEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener('resize', handleResize);
handleResize(); // Get initial/current window size
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowSize;
};
/**
* The hook will really work in Browser only, so or Server Side Rendering (SSR) we just return DEFAULT_WINDOWS_SIZE
*/
export default IS_SERVER ? () => DEFAULT_WINDOWS_SIZE : useWindowSize;
+15
View File
@@ -0,0 +1,15 @@
'use client';
import React, { FunctionComponent, PropsWithChildren } from 'react';
import { useIsAuthenticated } from '@/hooks';
import PrivateLayout from './PrivateLayout';
import PublicLayout from './PublicLayout';
/**
* Returns the current Layout component depending on different circumstances.
* @layout CurrentLayout
*/
const CurrentLayout: FunctionComponent<PropsWithChildren> = (props) => {
return useIsAuthenticated() ? <PrivateLayout {...props} /> : <PublicLayout {...props} />;
};
export default CurrentLayout;
+49
View File
@@ -0,0 +1,49 @@
import { FunctionComponent, PropsWithChildren } from 'react';
import { LinkToPage } from '@/utils';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
const TITLE_PRIVATE = 'Balinyaar'; // Title for pages after authentication
/**
* SideBar navigation items with links for Private Layout
*/
const SIDE_BAR_ITEMS: Array<LinkToPage> = [
{
title: 'Home',
path: '/',
icon: 'home',
},
{
title: 'My Profile',
path: '/me',
icon: 'account',
},
{
title: '404',
path: '/wrong-url',
icon: 'error',
},
{
title: 'About',
path: '/about',
icon: 'info',
},
];
/**
* Renders "Private Layout" composition
* @layout PrivateLayout
*/
const PrivateLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const title = TITLE_PRIVATE;
document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
return (
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarPersistentOnDesktop">
{children}
{/* <Stack component="footer">Copyright &copy; </Stack> */}
</TopBarAndSideBarLayout>
);
};
export default PrivateLayout;
+72
View File
@@ -0,0 +1,72 @@
import { FunctionComponent, PropsWithChildren } from 'react';
import { Stack } from '@mui/material';
import { LinkToPage } from '@/utils';
import { useIsMobile } from '@/hooks';
import { BottomBar } from './components';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
import { BOTTOM_BAR_DESKTOP_VISIBLE } from './config';
const TITLE_PUBLIC = 'Unauthorized - Balinyaar'; // Title for pages without/before authentication
/**
* SideBar navigation items with links for Public Layout
*/
const SIDE_BAR_ITEMS: Array<LinkToPage> = [
{
title: 'Log In',
path: '/auth/login',
icon: 'login',
},
{
title: 'Sign Up',
path: '/auth/signup',
icon: 'signup',
},
{
title: 'About',
path: '/about',
icon: 'info',
},
];
/**
* BottomBar navigation items with links for Public Layout
*/
const BOTTOM_BAR_ITEMS: Array<LinkToPage> = [
{
title: 'Log In',
path: '/auth/login',
icon: 'login',
},
{
title: 'Sign Up',
path: '/auth/signup',
icon: 'signup',
},
{
title: 'About',
path: '/about',
icon: 'info',
},
];
/**
* Renders "Public Layout" composition
* @layout PublicLayout
*/
const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const onMobile = useIsMobile();
const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE;
const title = TITLE_PUBLIC;
document.title = title; // Also Update Tab Title // TODO: Do we need this? Move it to useEffect()?
return (
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarAlwaysTemporary">
{children}
<Stack component="footer">{bottomBarVisible && <BottomBar items={BOTTOM_BAR_ITEMS} />}</Stack>
</TopBarAndSideBarLayout>
);
};
export default PublicLayout;
@@ -0,0 +1,129 @@
'use client';
import { FunctionComponent, useMemo, useState } from 'react';
import { Stack, StackProps } from '@mui/material';
import { IS_DEBUG } from '@/config';
import { AppIconButton, ErrorBoundary } from '@/components';
import { useAppStore } from '@/store';
import { LinkToPage } from '@/utils';
import { useEventSwitchDarkMode, useIsMobile } from '@/hooks';
import { TopBar } from './components';
import SideBar, { SideBarProps } from './components/SideBar';
import {
SIDE_BAR_DESKTOP_ANCHOR,
SIDE_BAR_MOBILE_ANCHOR,
SIDE_BAR_WIDTH,
TOP_BAR_DESKTOP_HEIGHT,
TOP_BAR_MOBILE_HEIGHT,
} from './config';
interface Props extends StackProps {
sidebarItems: Array<LinkToPage>;
title: string;
variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent';
}
/**
* Renders "TopBar and SideBar" composition
* @layout TopBarAndSideBarLayout
*/
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant }) => {
const [state] = useAppStore();
const [sidebarVisible, setSidebarVisible] = useState(false); // TODO: Verify is default value is correct
const onMobile = useIsMobile();
const onSwitchDarkMode = useEventSwitchDarkMode();
const sidebarProps = useMemo((): Partial<SideBarProps> => {
const anchor = onMobile ? SIDE_BAR_MOBILE_ANCHOR : SIDE_BAR_DESKTOP_ANCHOR;
let open = sidebarVisible;
let sidebarVariant: SideBarProps['variant'] = 'temporary';
switch (variant) {
case 'sidebarAlwaysTemporary':
break;
case 'sidebarPersistentOnDesktop':
open = onMobile ? sidebarVisible : true;
sidebarVariant = onMobile ? 'temporary' : 'persistent';
break;
case 'sidebarAlwaysPersistent':
open = true;
sidebarVariant = 'persistent';
break;
}
return { anchor, open, variant: sidebarVariant };
}, [onMobile, sidebarVisible, variant]);
const stackStyles = useMemo(
() => ({
minHeight: '100vh', // Full screen height
paddingTop: onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT,
paddingLeft:
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('left')
? SIDE_BAR_WIDTH
: undefined,
paddingRight:
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('right')
? SIDE_BAR_WIDTH
: undefined,
}),
[onMobile, sidebarProps]
);
const onSideBarOpen = () => {
if (!sidebarVisible) setSidebarVisible(true); // Don't re-render Layout when SideBar is already open
};
const onSideBarClose = () => {
if (sidebarVisible) setSidebarVisible(false); // Don't re-render Layout when SideBar is already closed
};
const LogoButton = (
<AppIconButton
icon="logo"
title={sidebarProps.open ? undefined : 'Open Sidebar'}
to={sidebarProps.open ? '/' : undefined} // Navigate to Home only when SideBar is closed
onClick={sidebarProps.open ? undefined : onSideBarOpen} // Open SideBar only when it's closed
/>
);
const DarkModeButton = (
<AppIconButton
icon={state.darkMode ? 'day' : 'night'} // Variant 1
// icon="daynight" // Variant 2
title={state.darkMode ? 'Switch to Light mode' : 'Switch to Dark mode'}
onClick={onSwitchDarkMode}
/>
);
// Note: useMemo() is not needed for startNode, endNode. We need respect store.darkMode and so on.
const { startNode, endNode } = sidebarProps?.anchor?.includes('left')
? { startNode: LogoButton, endNode: DarkModeButton }
: { startNode: DarkModeButton, endNode: LogoButton };
IS_DEBUG &&
console.log('Render <TopbarAndSidebarLayout/>', {
onMobile,
darkMode: state.darkMode,
sidebarProps,
});
return (
<Stack sx={stackStyles}>
<Stack component="header">
<TopBar startNode={startNode} title={title} endNode={endNode} />
<SideBar items={sidebarItems} onClose={onSideBarClose} {...sidebarProps} />
</Stack>
<Stack
component="main"
flexGrow={1} // Takes all possible space
justifyContent="space-between" // Push children content (Footer, StatusBar, etc.) to the bottom
paddingLeft={1}
paddingRight={1}
paddingTop={1}
>
<ErrorBoundary name="Content">{children}</ErrorBoundary>
</Stack>
</Stack>
);
};
export default TopBarAndSideBarLayout;
@@ -0,0 +1,39 @@
'use client';
import { FunctionComponent, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { BottomNavigation, BottomNavigationAction } from '@mui/material';
import { LinkToPage } from '@/utils';
import { AppIcon } from '@/components';
interface Props {
items: Array<LinkToPage>;
}
/**
* Renders horizontal Navigation Bar using MUI BottomNavigation component
* @component BottomBar
*/
const BottomBar: FunctionComponent<Props> = ({ items }) => {
const router = useRouter();
const onNavigationChange = useCallback(
(_event: unknown, newValue: string) => {
router.push(newValue);
},
[router]
);
return (
<BottomNavigation
value={location.pathname} // Automatically highlights bottom navigation for current page
showLabels // Always show labels on bottom navigation, otherwise label visible only for active page
onChange={onNavigationChange}
>
{items.map(({ title, path, icon }) => (
<BottomNavigationAction key={`${title}-${path}`} label={title} value={path} icon={<AppIcon icon={icon} />} />
))}
</BottomNavigation>
);
};
export default BottomBar;
+97
View File
@@ -0,0 +1,97 @@
import { FunctionComponent, useCallback, MouseEvent } from 'react';
import { Stack, Divider, Drawer, DrawerProps, FormControlLabel, Switch, Tooltip } from '@mui/material';
import { useAppStore } from '@/store';
import { LinkToPage } from '@/utils';
import { useEventLogout, useEventSwitchDarkMode, useIsAuthenticated, useIsMobile } from '@/hooks';
import { AppIconButton, UserInfo } from '@/components';
import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config';
import SideBarNavList from './SideBarNavList';
export interface SideBarProps extends Pick<DrawerProps, 'anchor' | 'className' | 'open' | 'variant' | 'onClose'> {
items: Array<LinkToPage>;
}
/**
* Renders SideBar with Menu and User details
* Actually for Authenticated users only, rendered in "Private Layout"
* @component SideBar
* @param {string} anchor - 'left' or 'right'
* @param {boolean} open - the Drawer is visible when true
* @param {string} variant - variant of the Drawer, one of 'permanent', 'persistent', 'temporary'
* @param {function} onClose - called when the Drawer is closing
*/
const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items, onClose, ...restOfProps }) => {
const [state] = useAppStore();
// const isAuthenticated = state.isAuthenticated; // Variant 1
const isAuthenticated = useIsAuthenticated(); // Variant 2
const onMobile = useIsMobile();
const onSwitchDarkMode = useEventSwitchDarkMode();
const onLogout = useEventLogout();
const handleAfterLinkClick = useCallback(
(event: MouseEvent) => {
if (variant === 'temporary' && typeof onClose === 'function') {
onClose(event, 'backdropClick');
}
},
[variant, onClose]
);
return (
<Drawer
anchor={anchor}
open={open}
variant={variant}
PaperProps={{
sx: {
width: SIDE_BAR_WIDTH,
marginTop: onMobile ? 0 : variant === 'temporary' ? 0 : TOP_BAR_DESKTOP_HEIGHT,
height: onMobile ? '100%' : variant === 'temporary' ? '100%' : `calc(100% - ${TOP_BAR_DESKTOP_HEIGHT})`,
},
}}
onClose={onClose}
>
<Stack
sx={{
height: '100%',
padding: 2,
}}
{...restOfProps}
onClick={handleAfterLinkClick}
>
{isAuthenticated && (
<>
<UserInfo showAvatar />
<Divider />
</>
)}
<SideBarNavList items={items} showIcons />
<Divider />
<Stack
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-evenly',
alignItems: 'center',
marginTop: 2,
}}
>
<Tooltip title={state.darkMode ? 'Switch to Light mode' : 'Switch to Dark mode'}>
<FormControlLabel
label={!state.darkMode ? 'Light mode' : 'Dark mode'}
control={<Switch checked={state.darkMode} onChange={onSwitchDarkMode} />}
/>
</Tooltip>
{isAuthenticated && <AppIconButton icon="logout" title="Logout Current User" onClick={onLogout} />}
</Stack>
</Stack>
</Drawer>
);
};
export default SideBar;
@@ -0,0 +1,45 @@
'use client';
import { FunctionComponent, MouseEventHandler } from 'react';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { AppIcon, AppLink } from '@/components';
import { LinkToPage } from '@/utils';
import { usePathname } from 'next/navigation';
interface Props extends LinkToPage {
openInNewTab?: boolean;
selected?: boolean;
onClick?: MouseEventHandler;
}
/**
* Renders Navigation Item for SideBar, detects current url and sets selected state if needed
* @component SideBarNavItem
*/
const SideBarNavItem: FunctionComponent<Props> = ({
openInNewTab,
icon,
path,
selected: propSelected = false,
subtitle,
title,
onClick,
}) => {
const pathname = usePathname();
const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false;
return (
<ListItemButton
component={AppLink}
selected={selected}
to={path}
href="" // Hard reset for .href property, otherwise links are always opened in new tab :(
openInNewTab={openInNewTab}
onClick={onClick}
>
<ListItemIcon>{icon && <AppIcon icon={icon} />}</ListItemIcon>
<ListItemText primary={title} secondary={subtitle} />
</ListItemButton>
);
};
export default SideBarNavItem;
@@ -0,0 +1,35 @@
import { FunctionComponent, MouseEventHandler } from 'react';
import List from '@mui/material/List';
import { LinkToPage } from '@/utils';
import SideBarNavItem from './SideBarNavItem';
interface Props {
items: Array<LinkToPage>;
showIcons?: boolean;
onClick?: MouseEventHandler;
}
/**
* Renders list of Navigation Items inside SideBar
* @component SideBarNavList
* @param {array} items - list of objects to render as navigation items
* @param {boolean} [showIcons] - icons in navigation items are visible when true
* @param {function} [onAfterLinkClick] - optional callback called when some navigation item was clicked
*/
const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick, ...restOfProps }) => {
return (
<List component="nav" {...restOfProps}>
{items.map(({ icon, path, title }) => (
<SideBarNavItem
key={`${title}-${path}`}
icon={showIcons ? icon : undefined}
path={path}
title={title}
onClick={onClick}
/>
))}
</List>
);
};
export default SideBarNavList;
+46
View File
@@ -0,0 +1,46 @@
import { FunctionComponent, ReactNode } from 'react';
import { AppBar, Toolbar, Typography } from '@mui/material';
interface Props {
endNode?: ReactNode;
startNode?: ReactNode;
title?: string;
}
/**
* Renders TopBar composition
* @component TopBar
*/
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', ...restOfProps }) => {
return (
<AppBar
component="div"
sx={
{
// boxShadow: 'none', // Uncomment to hide shadow
}
}
{...restOfProps}
>
<Toolbar disableGutters sx={{ paddingX: 1 }}>
{startNode}
<Typography
variant="h6"
sx={{
marginX: 1,
flexGrow: 1,
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
{title}
</Typography>
{endNode}
</Toolbar>
</AppBar>
);
};
export default TopBar;
+5
View File
@@ -0,0 +1,5 @@
import BottomBar from './BottomBar';
import SideBar from './SideBar';
import TopBar from './TopBar';
export { BottomBar, SideBar, TopBar };
+21
View File
@@ -0,0 +1,21 @@
/**
* Layout configuration
*/
/**
* SideBar configuration
*/
export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right';
export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';
export const SIDE_BAR_WIDTH = '240px';
/**
* TopBar configuration
*/
export const TOP_BAR_MOBILE_HEIGHT = '56px';
export const TOP_BAR_DESKTOP_HEIGHT = '64px';
/**
* BottomBar configuration
*/
export const BOTTOM_BAR_DESKTOP_VISIBLE = false; // true;
+6
View File
@@ -0,0 +1,6 @@
import CurrentLayout from './CurrentLayout';
import PrivateLayout from './PrivateLayout';
import PublicLayout from './PublicLayout';
export { PublicLayout, PrivateLayout };
export default CurrentLayout;
+46
View File
@@ -0,0 +1,46 @@
import { Reducer } from 'react';
import { localStorageSet } from '../utils/localStorage';
import { AppStoreState } from './config';
/**
* Reducer for global AppStore using "Redux styled" actions
* @function AppReducer
* @param {object} state - current/default state
* @param {string} action.type - unique name of the action
* @param {string} action.action - alternate to action.type property, unique name of the action
* @param {*} [action.payload] - optional data object or the function to get data object
*/
const AppReducer: Reducer<AppStoreState, any> = (state, action) => {
// console.log('AppReducer() - action:', action);
switch (action.type || action.action) {
case 'CURRENT_USER':
return {
...state,
currentUser: action?.currentUser || action?.payload,
};
case 'SIGN_UP':
case 'LOG_IN':
return {
...state,
isAuthenticated: true,
};
case 'LOG_OUT':
return {
...state,
isAuthenticated: false,
currentUser: undefined, // Also reset previous user data
};
case 'DARK_MODE': {
const darkMode = action?.darkMode ?? action?.payload;
localStorageSet('darkMode', darkMode);
return {
...state,
darkMode,
};
}
default:
return state;
}
};
export default AppReducer;
+81
View File
@@ -0,0 +1,81 @@
'use client';
import {
createContext,
useReducer,
useContext,
FunctionComponent,
PropsWithChildren,
Dispatch,
ComponentType,
} from 'react';
// import useMediaQuery from '@mui/material/useMediaQuery';
import AppReducer from './AppReducer';
import { localStorageGet } from '../utils/localStorage';
import { IS_SERVER } from '../utils/environment';
import { APP_STORE_INITIAL_STATE, AppStoreState } from './config';
/**
* Instance of React Context for global AppStore
*/
export type AppContextReturningType = [AppStoreState, Dispatch<any>];
const AppContext = createContext<AppContextReturningType>([APP_STORE_INITIAL_STATE, () => null]);
/**
* Main global Store as HOC with React Context API
* @component AppStoreProvider
* import {AppStoreProvider} from './store'
* ...
* <AppStoreProvider>
* <App/>
* </AppStoreProvider>
*/
const AppStoreProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
// const prefersDarkMode = IS_SERVER ? false : useMediaQuery('(prefers-color-scheme: dark)'); // Note: Conditional hook is bad idea :(
const prefersDarkMode = IS_SERVER ? false : window.matchMedia('(prefers-color-scheme: dark)').matches;
const previousDarkMode = IS_SERVER ? false : Boolean(localStorageGet('darkMode', false));
// const tokenExists = Boolean(loadToken());
const initialState: AppStoreState = {
...APP_STORE_INITIAL_STATE,
darkMode: previousDarkMode || prefersDarkMode,
// isAuthenticated: tokenExists,
};
const value: AppContextReturningType = useReducer(AppReducer, initialState);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};
/**
* Hook to use the AppStore in functional components
* @hook useAppStore
* import {useAppStore} from './store'
* ...
* const [state, dispatch] = useAppStore();
* OR
* const [state] = useAppStore();
*/
const useAppStore = (): AppContextReturningType => useContext(AppContext);
/**
* HOC to inject the ApStore to class component, also works for functional components
* @hok withAppStore
* import {withAppStore} from './store'
* ...
* class MyComponent
*
* render () {
* const [state, dispatch] = this.props.appStore;
* ...
* }
* ...
* export default withAppStore(MyComponent)
*/
interface WithAppStoreProps {
appStore: AppContextReturningType;
}
const withAppStore = (Component: ComponentType<WithAppStoreProps>): FunctionComponent =>
function ComponentWithAppStore(props) {
return <Component {...props} appStore={useAppStore()} />;
};
export { AppStoreProvider, useAppStore, withAppStore };
+16
View File
@@ -0,0 +1,16 @@
/**
* Data structure of the AppStore state
*/
export interface AppStoreState {
darkMode: boolean;
isAuthenticated: boolean;
currentUser?: object | undefined;
}
/**
* Initial values for the AppStore state
*/
export const APP_STORE_INITIAL_STATE: AppStoreState = {
darkMode: false, // Overridden by useMediaQuery('(prefers-color-scheme: dark)') in AppStore
isAuthenticated: false, // Overridden in AppStore by checking auth token
};
+3
View File
@@ -0,0 +1,3 @@
import { AppStoreProvider, useAppStore, withAppStore } from './AppStore';
export { AppStoreProvider, useAppStore, withAppStore };
@@ -0,0 +1,12 @@
import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter';
import { FunctionComponent, PropsWithChildren } from 'react';
/**
* Platform-specific ThemeProvider for Next.js
* @component MuiThemeProviderForNextJs
*/
const MuiThemeProviderForNextJs: FunctionComponent<PropsWithChildren> = ({ children }) => {
return <AppRouterCacheProvider>{children}</AppRouterCacheProvider>;
};
export default MuiThemeProviderForNextJs;
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { FunctionComponent, PropsWithChildren, useEffect, useMemo, useState } from 'react';
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material/styles';
import { useAppStore } from '../store';
import DARK_THEME from './dark';
import LIGHT_THEME from './light';
import MuiThemeProviderForNextJs from './MuiThemeProviderForNextJs';
import CssBaseline from '@mui/material/CssBaseline';
function getThemeByDarkMode(darkMode: boolean) {
return darkMode ? createTheme(DARK_THEME) : createTheme(LIGHT_THEME);
}
/**
* Renders composition of Emotion's CacheProvider + MUI's ThemeProvider to wrap content of entire App
* The Light or Dark themes applied depending on global .darkMode state
* @component AppThemeProvider
*/
const AppThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
const [state] = useAppStore();
const [loading, setLoading] = useState(true);
const currentTheme = useMemo(
() => getThemeByDarkMode(state.darkMode),
[state.darkMode] // Observe AppStore and re-create the theme when .darkMode changes
);
useEffect(() => setLoading(false), []); // Set .loading to false when the component is mounted
if (loading) return null; // Don't render anything until the component is mounted
return (
<MuiThemeProviderForNextJs>
<MuiThemeProvider theme={currentTheme}>
<CssBaseline /* MUI Styles */ />
{children}
</MuiThemeProvider>
</MuiThemeProviderForNextJs>
);
};
export default AppThemeProvider;
+27
View File
@@ -0,0 +1,27 @@
import { PaletteOptions, SimplePaletteColorOptions } from '@mui/material';
const COLOR_PRIMARY: SimplePaletteColorOptions = {
main: '#64B5F6',
contrastText: '#000000',
// light: '#64B5F6',
// dark: '#64B5F6',
};
const COLOR_SECONDARY: SimplePaletteColorOptions = {
main: '#EF9A9A',
contrastText: '#000000',
// light: '#EF9A9A',
// dark: '#EF9A9A',
};
/**
* MUI colors set to use in theme.palette
*/
export const PALETTE_COLORS: Partial<PaletteOptions> = {
primary: COLOR_PRIMARY,
secondary: COLOR_SECONDARY,
// error: COLOR_ERROR,
// warning: COLOR_WARNING;
// info: COLOR_INFO;
// success: COLOR_SUCCESS;
};
+18
View File
@@ -0,0 +1,18 @@
import { ThemeOptions } from '@mui/material';
import { PALETTE_COLORS } from './colors';
/**
* MUI theme options for "Dark Mode"
*/
export const DARK_THEME: ThemeOptions = {
palette: {
mode: 'dark',
// background: {
// paper: '#424242', // Gray 800 - Background of "Paper" based component
// default: '#121212',
// },
...PALETTE_COLORS,
},
};
export default DARK_THEME;
+10
View File
@@ -0,0 +1,10 @@
import AppThemeProvider from './ThemeProvider';
import DARK_THEME from './dark';
import LIGHT_THEME from './light';
export {
LIGHT_THEME as default, // Change to DARK_THEME if you want to use dark theme as default
DARK_THEME,
LIGHT_THEME,
AppThemeProvider as ThemeProvider,
};
+18
View File
@@ -0,0 +1,18 @@
import { ThemeOptions } from '@mui/material';
import { PALETTE_COLORS } from './colors';
/**
* MUI theme options for "Light Mode"
*/
export const LIGHT_THEME: ThemeOptions = {
palette: {
mode: 'light',
// background: {
// paper: '#f5f5f5', // Gray 100 - Background of "Paper" based component
// default: '#FFFFFF',
// },
...PALETTE_COLORS,
},
};
export default LIGHT_THEME;
+53
View File
@@ -0,0 +1,53 @@
export const IS_SERVER = typeof window === 'undefined';
export const IS_BROWSER = typeof window !== 'undefined' && typeof window?.document !== 'undefined';
/* eslint-disable no-restricted-globals */
export const IS_WEBWORKER =
typeof self === 'object' && self.constructor && self.constructor.name === 'DedicatedWorkerGlobalScope';
/* eslint-enable no-restricted-globals */
/**
* Returns the value of the environment variable with the given name, raises an error if it is required and not set.
* Note: My not work with Next.js on client-side code.
* @param {string} name - The name of the environment variable to get: e.g. XXX_YYY_PUBLIC_URL
* @param {boolean} [isRequired] - Whether the environment variable is required or not.
* @param {string} [defaultValue] - The default value to return if the environment variable is not set.
* @returns {string} The value of the environment variable with the given name.
*/
export function envGet(
name: string,
isRequired = false,
defaultValue: string | undefined = undefined
): string | undefined {
let variable = process.env[name]; // Classic way
// let variable = import.meta.env[name]; // Vite way
if (typeof variable === 'undefined') {
if (isRequired) {
throw new Error(`Missing process.env.${name} variable`);
}
variable = defaultValue;
}
return variable;
}
/**
* Verifies existence of environment variables, raises an error if it is required and not set.
* @example const MY_VARIABLE = requireEnv(process.env.MY_VARIABLE);
* @param {string} [passProcessDotEnvDotValueNameHere] - Pass a value of process.env.MY_VARIABLE here, not just a name!
* @returns {string} The value of incoming parameter.
* @throws Error "Missing .env variable!"
*/
export function envRequired(passProcessDotEnvDotValueNameHere: string | undefined): string {
if (typeof passProcessDotEnvDotValueNameHere === 'undefined') {
throw new Error('Missing .env variable!');
}
return passProcessDotEnvDotValueNameHere;
}
export function getCurrentVersion(): string {
return process.env.npm_package_version ?? process.env.NEXT_PUBLIC_VERSION ?? 'unknown';
}
export function getCurrentEnvironment(): string {
return process.env.NEXT_PUBLIC_ENV ?? process.env?.NODE_ENV ?? 'development';
}
+7
View File
@@ -0,0 +1,7 @@
export * from './environment';
export * from './localStorage';
export * from './navigation';
export * from './sessionStorage';
export * from './sleep';
export * from './type';
export * from './text';
+56
View File
@@ -0,0 +1,56 @@
import { IS_SERVER } from './environment';
/**
* Smartly reads value from localStorage
*/
export function localStorageGet(name: string, defaultValue: any = ''): string {
if (IS_SERVER) {
return defaultValue; // We don't have access to localStorage on the server
}
const valueFromStore = localStorage.getItem(name);
if (valueFromStore === null) return defaultValue; // No value in store, return default one
try {
const jsonParsed = JSON.parse(valueFromStore);
if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
return jsonParsed; // We successfully parse JS value from the store
}
} catch (error) {}
return valueFromStore; // Return string value as it is
}
/**
* Smartly writes value into localStorage
*/
export function localStorageSet(name: string, value: any) {
if (IS_SERVER) {
return; // Do nothing on server side
}
if (typeof value === 'undefined') {
return; // Do not store undefined values
}
let valueAsString: string;
if (typeof value === 'object') {
valueAsString = JSON.stringify(value);
} else {
valueAsString = String(value);
}
localStorage.setItem(name, valueAsString);
}
/**
* Deletes value by name from localStorage, if specified name is empty entire localStorage is cleared.
*/
export function localStorageDelete(name: string) {
if (IS_SERVER) {
return; // Do nothing on server side
}
if (name) {
localStorage.removeItem(name);
} else {
localStorage.clear();
}
}
+55
View File
@@ -0,0 +1,55 @@
import { IS_BROWSER } from './environment';
export const EXTERNAL_LINK_PROPS = {
target: '_blank',
rel: 'noopener noreferrer',
};
/**
* Disables "Back" button for current page
* Usage: Call function in useEffect( ,[]) or directly
*/
export function disableBackNavigation() {
window.history.pushState(null, '', window.location.href);
window.onpopstate = function () {
window.history.go(1);
};
}
/**
* Navigates to the specified URL with options
*/
export function navigateTo(url: string, replaceInsteadOfPush = false, optionalTitle = '') {
if (replaceInsteadOfPush) {
window.history.replaceState(null, optionalTitle, url);
} else {
window.history.pushState(null, optionalTitle, url);
}
}
/**
* For smooth scrolling to the specified element with optional offset
* @param {object} destinationElement - DOM element to scroll to
* @param {number} [verticalOffset] - optional vertical offset
* @param {object} [scrollingElement] - optional scrolling element, defaults to .window
* @param {string} [behavior] - optional scroll behavior, defaults to 'smooth'
*/
export function scrollIntoViewAdjusted(
destinationElement: Element | HTMLElement | null,
verticalOffset = 0,
scrollingElement?: Element | HTMLElement | null,
behavior: 'auto' | 'instant' | 'smooth' | undefined = 'smooth'
) {
if (!IS_BROWSER || !destinationElement) {
return;
}
const rect = destinationElement.getBoundingClientRect();
if (!rect || typeof rect.top === 'undefined') {
return;
}
const top = rect.top - verticalOffset;
const elementToScroll = scrollingElement ?? window;
elementToScroll.scrollBy({ top, behavior });
}
+56
View File
@@ -0,0 +1,56 @@
import { IS_SERVER } from './environment';
/**
* Smartly reads value from sessionStorage
*/
export function sessionStorageGet(name: string, defaultValue: any = ''): string {
if (IS_SERVER) {
return defaultValue; // We don't have access to sessionStorage on the server
}
const valueFromStore = sessionStorage.getItem(name);
if (valueFromStore === null) return defaultValue; // No value in store, return default one
try {
const jsonParsed = JSON.parse(valueFromStore);
if (['boolean', 'number', 'bigint', 'string', 'object'].includes(typeof jsonParsed)) {
return jsonParsed; // We successfully parse JS value from the store
}
} catch (error) {}
return valueFromStore; // Return string value as it is
}
/**
* Smartly writes value into sessionStorage
*/
export function sessionStorageSet(name: string, value: any) {
if (IS_SERVER) {
return; // Do nothing on server side
}
if (typeof value === 'undefined') {
return; // Do not store undefined values
}
let valueAsString: string;
if (typeof value === 'object') {
valueAsString = JSON.stringify(value);
} else {
valueAsString = String(value);
}
sessionStorage.setItem(name, valueAsString);
}
/**
* Deletes value by name from sessionStorage, if specified name is empty entire sessionStorage is cleared.
*/
export function sessionStorageDelete(name: string) {
if (IS_SERVER) {
return; // Do nothing on server side
}
if (name) {
sessionStorage.removeItem(name);
} else {
sessionStorage.clear();
}
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Delays code executions for specific amount of time. Must be called with await!
* @param {number} interval - number of milliseconds to wait for
*/
export async function sleep(interval = 1000) {
return new Promise((resolve) => setTimeout(resolve, interval));
}
export default sleep;
+49
View File
@@ -0,0 +1,49 @@
export const CHARS_NUMERIC = '0123456789';
export const CHARS_ALPHA_LOWER = 'abcdefghijklmnopqrstuvwxyz';
export const CHARS_ALPHA_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export const CHARS_ALPHA_NUMERIC = CHARS_NUMERIC + CHARS_ALPHA_LOWER + CHARS_ALPHA_UPPER;
/**
* Generate a random string of a given length using a given set of characters
* @param {number} length - the length of the string to generate
* @param {string} [allowedChars] - the set of characters to use in the string, defaults to all alphanumeric characters in upper and lower case + numbers
* @returns {string} - the generated string
*/
export function randomText(length: number, allowedChars = CHARS_ALPHA_NUMERIC) {
let result = '';
const charLength = allowedChars.length;
let counter = 0;
while (counter < length) {
result += allowedChars.charAt(Math.floor(Math.random() * charLength));
counter += 1;
}
return result;
}
/**
* Compare two strings including null and undefined values
* @param {string} a - the first string to compare
* @param {string} b - the second string to compare
* @returns {boolean} - true if the strings are the same or both null or undefined, false otherwise
*/
export function compareTexts(a: string | null | undefined, b: string | null | undefined) {
if (a === undefined || a === null || a === '') {
return b === undefined || b === null || b === '';
}
return a === b;
}
/**
* Capitalize the first letter of a string
* @param {string} s - the string to capitalize
* @returns {string} - the capitalized string
*/
export const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.substring(1);
/**
* Generate a random color as #RRGGBB value
* @returns {string} - the generated color
*/
export function randomColor() {
const color = Math.floor(Math.random() * 16777215).toString(16);
return '#' + color;
}
+12
View File
@@ -0,0 +1,12 @@
// Helper to read object's properties as obj['name']
export type ObjectPropByName = Record<string, any>;
/**
* Data for "Page Link" in SideBar adn other UI elements
*/
export type LinkToPage = {
icon?: string; // Icon name to use as <AppIcon icon={icon} />
path?: string; // URL to navigate to
title?: string; // Title or primary text to display
subtitle?: string; // Sub-title or secondary text to display
};

Some files were not shown because too many files have changed in this diff Show More