Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
VITE_APP_NAME="React Starter"
VITE_LOG_LEVEL=debug
DOMAIN=.localhost
APP_TABLE_NAME=starter-react
SECRET_APP_SIGNING_TOKEN=
LOCAL_DYNAMODB_ENDPOINT=http://localhost:8110
34 changes: 17 additions & 17 deletions .github/dependabot.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
---
version: 2
updates:
- package-ecosystem: npm # See documentation for possible values
directory: '/'
registries: '*' # Location of package manifests
schedule:
interval: monthly
groups:
prod:
patterns:
- '*'
- package-ecosystem: npm # See documentation for possible values
directory: '/'
registries: '*' # Location of package manifests
schedule:
interval: monthly
groups:
prod:
patterns:
- '*'

- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'monthly'
groups:
prod:
patterns:
- '*'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'monthly'
groups:
prod:
patterns:
- '*'
30 changes: 30 additions & 0 deletions .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: pull-request.yaml

on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:
- '.github/*'

jobs:
node:
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
REPO_URL: ${{ github.repository }}
steps:
- uses: actions/checkout@v5

- name: Setup node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npx playwright install
- run: npm run lint
- run: npm run test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ node_modules
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
coverage

# sst
.sst
Expand Down
144 changes: 135 additions & 9 deletions .junie/guidelines.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
You are an expert in TypeScript, Node.js, React Router v7, React, Shadcn UI, Radix UI and Tailwind.
You are an expert in TypeScript, Node.js, Vitest, React Router v7, React, Shadcn UI, Radix UI, and Tailwind.

Code Style and Structure

- Avoid useless comments that state the obvious.
- Use Vitest for tests
- avoid mocking unless absolutely necessary and prefer to mock the smallest possible unit of code.
- Unit Tests should be co-located to file they are testing.
- end to end tests should be in the `__E2E__`
- end-to-end tests should be in the `__E2E__`
- Write concise, technical TypeScript code with accurate examples.
- Use functional and declarative programming patterns; avoid classes.
- Prefer iteration and modularisation over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
- Use descriptive variable names with auxiliary verbs (e.g. isLoading, hasError).
- Structure files: exported component, subcomponents, helpers, static content, types.

Naming Conventions

- Use lowercase with dashes for directories (e.g., components/auth-wizard).
- Favor named exports for components.
- Favour named exports for components.
- Use kebab-case for component file names
- Use kebab-case for configuration file names
- Use kebab-case for directory names

TypeScript Usage

- Use TypeScript for all code; prefer interfaces over types.
- Use TypeScript for all code; prefer interfaces to types.
- Avoid enums; use maps instead.
- Use functional components with TypeScript interfaces.

Expand All @@ -40,7 +40,7 @@ UI and Styling

Performance Optimisation

- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC).
- Minimise , 'useEffect', and 'setState'.
- Wrap client components in Suspense with fallback.
- Use dynamic loading for non-critical components.
- Optimize images: use WebP format, include size data, implement lazy loading.
Expand All @@ -50,10 +50,136 @@ Key Conventions
- Use 'nuqs' for URL search parameter state management.
- Optimize Web Vitals (LCP, CLS, FID).
- Limit 'use client':
- Favor server components and Next.js SSR.
- Favour server components and SSR.
- Use only for Web API access in small components.
- Avoid for data fetching or state management.
- Avoid it for data fetching or state management.

Follow React router v7 docs for Data Fetching, Rendering, and Routing.


## Testing

### Example unit test for components

```tsx
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-react';
import { createRoutesStub } from 'react-router';
import { LoginForm } from './login-form';

describe('LoginForm', async () => {
describe('layout validation', () => {
const Stub = createRoutesStub([
{
path: '/login',
// @ts-expect-error - types will not match, react router docs mention using this.
Component: LoginForm
}
]);
it('should render email input form', async () => {
const { getByLabelText } = await render(<Stub initialEntries={['/login']} />);
await expect.element(getByLabelText('email')).toBeVisible();
});
it('should render password input form', async () => {
const { getByLabelText } = await render(<Stub initialEntries={['/login']} />);
await expect.element(getByLabelText('password')).toBeVisible();
});
it('should render login button', async () => {
const { getByRole } = await render(<Stub initialEntries={['/login']} />);
await expect.element(getByRole('button')).toBeVisible();
});
it('should render sign up link', async () => {
const { getByText } = await render(<Stub initialEntries={['/login']} />);
await expect.element(getByText('Sign up')).toBeVisible();
});
});
describe('form submit', () => {
const mockAction = vi.fn();

const Stub = createRoutesStub([
{
path: '/login',
// @ts-expect-error - types will not match, docs mention to use this.
Component: LoginForm,
action: mockAction
}
]);

afterEach(() => {
vi.clearAllMocks();
});

it('should submit form when data is entered correctly', async () => {
const { getByLabelText, getByRole } = await render(
<Stub initialEntries={['/login']} />
);

const emailEl = getByLabelText('email');
await emailEl.fill('user@mail.com');

const passwordEl = getByLabelText('password');
await passwordEl.fill('Password123$');

const submitBtn = getByRole('button');
await submitBtn.click();
expect(mockAction).toHaveBeenCalled();
});
it('should not submit form when data is incorrect', async () => {
const { getByLabelText, getByRole } = await render(
<Stub initialEntries={['/login']} />
);

const emailEl = getByLabelText('email');
await emailEl.fill('invalid-email');

const passwordEl = getByLabelText('password');
await passwordEl.fill('Password123$');

const submitBtn = getByRole('button');
await submitBtn.click();
expect(mockAction).not.toHaveBeenCalled();
});
it('should not submit form when data is empty', async () => {
const { getByRole } = await render(<Stub initialEntries={['/login']} />);

const submitBtn = getByRole('button');
await submitBtn.click();
expect(mockAction).not.toHaveBeenCalled();
});
});
describe('ui regression test', () => {
const Stub = createRoutesStub([
{
path: '/login',
// @ts-expect-error - types will not match, docs mention to use this.
Component: LoginForm
}
]);

it('should match snapshot', async () => {
const { container } = await render(<Stub initialEntries={['/login']} />);
expect(container).toMatchSnapshot();
});
});
});
```

### Example unit test for server side code

```ts
import { describe, it, expect } from 'vitest';
import { loadServerEnv } from '~/lib/env/env.server';

describe('loadServerEnv()', () => {
it('should load server environment variables', () => {
const config = loadServerEnv();

expect(config).toEqual({
appTableName: 'delightable',
domain: '.localhost',
dynamoEndpoint: 'http://localhost:8110',
logLevel: 'debug',
secretAppSigningToken: 'some-secret-token'
});
});
});
```
17 changes: 17 additions & 0 deletions app/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
declare namespace NodeJS {
interface ProcessEnv {
DOMAIN: string;
APP_TABLE_NAME: string;
SECRET_APP_SIGNING_TOKEN: string;
LOCAL_DYNAMODB_ENDPOINT: string;
}
}

interface ImportMetaEnv {
readonly VITE_LOG_LEVEL: string;
readonly VITE_APP_NAME: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
2 changes: 1 addition & 1 deletion app/lib/auth/auth.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AUTH_COOKIE_NAME, type SessionFlashData, type UserSession } from '~/lib/auth/types';
import { loadServerEnv } from '~/lib/env.server';
import { loadServerEnv } from '~/lib/env/env.server';
import { createCookieSessionStorage } from 'react-router';

const appConfig = loadServerEnv();
Expand Down
2 changes: 1 addition & 1 deletion app/lib/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const userSessionSchema = z.object({
userId: z.string()
});

export const AUTH_COOKIE_NAME = '--delightable-auth';
export const AUTH_COOKIE_NAME = '--cookie-auth';
export type UserSession = z.infer<typeof userSessionSchema>;
export type SessionFlashData = {
error: string;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions app/lib/env/env.client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { loadClientEnv } from '~/lib/env/env.client';

describe('loadClientEnv()', () => {
it('should load client side env vars', () => {
expect(loadClientEnv()).toEqual({
appName: 'React Starter',
logLevel: 'debug'
});
});
});
23 changes: 23 additions & 0 deletions app/lib/env/env.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from 'zod/v4';

const clientEnv = z.object({
appName: z.string().min(1, 'App name is required'),
logLevel: z.string().optional()
});

/**
* Represents the client environment configuration derived from a predefined schema.
*/
export type ClientEnv = z.infer<typeof clientEnv>;

/**
* Loads and validates the client environment variables into a structured object.
*/
export function loadClientEnv() {
const env: ClientEnv = {
appName: import.meta.env.VITE_APP_NAME,
logLevel: import.meta.env.VITE_LOG_LEVEL
};

return clientEnv.parse(env);
}
16 changes: 16 additions & 0 deletions app/lib/env/env.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { loadServerEnv } from '~/lib/env/env.server';

describe('loadServerEnv()', () => {
it('should load server environment variables', () => {
const config = loadServerEnv();

expect(config).toEqual({
appTableName: 'react starter',
domain: '.localhost',
dynamoEndpoint: 'http://localhost:8110',
logLevel: 'debug',
secretAppSigningToken: 'some-secret-token'
});
});
});
8 changes: 4 additions & 4 deletions app/lib/env.server.ts → app/lib/env/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ export type EnvServer = z.infer<typeof envServerSchema>;
*/
export function loadServerEnv(): EnvServer {
const data: EnvServer = {
appTableName: process.env.VITE_APP_TABLE_NAME ?? '',
dynamoEndpoint: process.env.VITE_LOCAL_DYNAMODB_ENDPOINT ?? '',
domain: process.env.VITE_DOMAIN ?? '',
appTableName: process.env.APP_TABLE_NAME ?? '',
dynamoEndpoint: process.env.LOCAL_DYNAMODB_ENDPOINT ?? '',
domain: process.env.DOMAIN ?? '',
logLevel: process.env.VITE_LOG_LEVEL ?? '',
secretAppSigningToken: process.env.VITE_SECRET_APP_SIGNING_TOKEN ?? ''
secretAppSigningToken: process.env.SECRET_APP_SIGNING_TOKEN ?? ''
};

return envServerSchema.parse(data);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading