feat(waiter-web): add web baseline
Why: establish baseline web runtime scaffold before wave implementation. What: add react app structure, docker runtime assets, docs runbooks, and ignore policy updates. Rule: keep technical intent and repository workflow compliance.
This commit is contained in:
parent
c6974494ea
commit
124e043a3b
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.repo-tasks/
|
||||
.repo-context/
|
||||
.tasks/
|
||||
.agile/
|
||||
.git/
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@ -2,3 +2,14 @@
|
||||
.repo-context/
|
||||
.tasks/
|
||||
.agile/
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_API_BASE_URL=http://localhost:8080
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
COPY --from=build /app/dist ./
|
||||
EXPOSE 8080
|
||||
8
docker/40-runtime-config.sh
Normal file
8
docker/40-runtime-config.sh
Normal file
@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cat > /usr/share/nginx/html/runtime-config.js <<EOT
|
||||
window.__APP_CONFIG__ = {
|
||||
API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}"
|
||||
};
|
||||
EOT
|
||||
9
docker/nginx.conf
Normal file
9
docker/nginx.conf
Normal file
@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
6
docs/architecture/frontend-boundary.md
Normal file
6
docs/architecture/frontend-boundary.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Frontend Boundary
|
||||
|
||||
- This repository hosts a React edge application for a single BFF.
|
||||
- Frontend data access flows through `src/api/*` adapter modules.
|
||||
- The UI does not access DAL or internal services directly.
|
||||
- API base URL is configured with `VITE_API_BASE_URL`.
|
||||
27
docs/runbooks/containerization.md
Normal file
27
docs/runbooks/containerization.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Containerization Runbook
|
||||
|
||||
## Image Build
|
||||
|
||||
```bash
|
||||
docker build -t agilewebs/waiter-floor-web:dev .
|
||||
```
|
||||
|
||||
## Local Run
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 \
|
||||
-e API_BASE_URL=http://host.docker.internal:8080 \
|
||||
--name waiter-floor-web agilewebs/waiter-floor-web:dev
|
||||
```
|
||||
|
||||
## Runtime Configuration Strategy
|
||||
|
||||
- Build-time fallback: `VITE_API_BASE_URL`
|
||||
- Runtime override: container env `API_BASE_URL`
|
||||
- Runtime file generated at startup: `/runtime-config.js`
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl -f http://localhost:8080/
|
||||
```
|
||||
27
docs/runbooks/local-development.md
Normal file
27
docs/runbooks/local-development.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Local Development
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:8080 npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm run test:ci
|
||||
```
|
||||
|
||||
See also: `docs/runbooks/testing.md`
|
||||
23
docs/runbooks/testing.md
Normal file
23
docs/runbooks/testing.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Testing Runbook
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Execute Tests
|
||||
|
||||
```bash
|
||||
npm run test:ci
|
||||
```
|
||||
|
||||
## Coverage Scope
|
||||
|
||||
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior
|
||||
- `src/api/dashboardApi.test.ts`: endpoint path/query contract generation
|
||||
- `src/App.test.tsx`: render baseline and mocked load flow
|
||||
|
||||
## Notes
|
||||
|
||||
- Use containerized Node execution when host `npm` is unavailable.
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Waiter Floor Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4224
package-lock.json
generated
Normal file
4224
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "waiter-floor-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest run --coverage=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.6",
|
||||
"vitest": "^2.1.8",
|
||||
"jsdom": "^25.0.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/jest-dom": "^6.6.3"
|
||||
}
|
||||
}
|
||||
3
public/runtime-config.js
Normal file
3
public/runtime-config.js
Normal file
@ -0,0 +1,3 @@
|
||||
window.__APP_CONFIG__ = {
|
||||
API_BASE_URL: 'http://localhost:8080'
|
||||
};
|
||||
31
src/App.test.tsx
Normal file
31
src/App.test.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./api/dashboardApi', () => ({
|
||||
loadDashboard: vi.fn()
|
||||
}));
|
||||
|
||||
import { loadDashboard } from './api/dashboardApi';
|
||||
import App from './App';
|
||||
|
||||
describe('App', () => {
|
||||
it('renders baseline page', () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Waiter Floor Web' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Load' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads dashboard data when user clicks load', async () => {
|
||||
vi.mocked(loadDashboard).mockResolvedValue({ summary: 'ok' });
|
||||
render(<App />);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ctx stage28' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadDashboard).toHaveBeenCalledWith('ctx stage28');
|
||||
expect(screen.getByText(/summary/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
41
src/App.tsx
Normal file
41
src/App.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { loadDashboard } from './api/dashboardApi';
|
||||
|
||||
function App() {
|
||||
const [contextId, setContextId] = useState('demo-context');
|
||||
const [payload, setPayload] = useState<unknown>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onLoad = async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await loadDashboard(contextId);
|
||||
setPayload(response);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown request error';
|
||||
setError(message);
|
||||
setPayload(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<h1>Waiter Floor Web</h1>
|
||||
<p>React baseline wired to its corresponding BFF via an API adapter module.</p>
|
||||
<div className="row">
|
||||
<input value={contextId} onChange={(event) => setContextId(event.target.value)} />
|
||||
<button type="button" onClick={onLoad} disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Load'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p>{error}</p>}
|
||||
<pre>{JSON.stringify(payload, null, 2)}</pre>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
26
src/api/client.test.ts
Normal file
26
src/api/client.test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getApiBaseUrl } from './client';
|
||||
|
||||
describe('getApiBaseUrl', () => {
|
||||
afterEach(() => {
|
||||
delete window.__APP_CONFIG__;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('uses runtime-config API base URL when present', () => {
|
||||
window.__APP_CONFIG__ = { API_BASE_URL: 'http://runtime.example' };
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'http://env.example');
|
||||
|
||||
expect(getApiBaseUrl()).toBe('http://runtime.example');
|
||||
});
|
||||
|
||||
it('falls back to VITE_API_BASE_URL when runtime-config is missing', () => {
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'http://env.example');
|
||||
|
||||
expect(getApiBaseUrl()).toBe('http://env.example');
|
||||
});
|
||||
|
||||
it('falls back to localhost default when both runtime and env are missing', () => {
|
||||
expect(getApiBaseUrl()).toBe('http://localhost:8080');
|
||||
});
|
||||
});
|
||||
25
src/api/client.ts
Normal file
25
src/api/client.ts
Normal file
@ -0,0 +1,25 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: {
|
||||
API_BASE_URL?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
const runtimeValue = window.__APP_CONFIG__?.API_BASE_URL;
|
||||
if (runtimeValue && runtimeValue.length > 0) {
|
||||
return runtimeValue;
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
|
||||
}
|
||||
|
||||
export async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${getApiBaseUrl()}${path}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET ${path} failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
18
src/api/dashboardApi.test.ts
Normal file
18
src/api/dashboardApi.test.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getJson: vi.fn()
|
||||
}));
|
||||
|
||||
import { getJson } from './client';
|
||||
import { loadDashboard } from './dashboardApi';
|
||||
|
||||
describe('loadDashboard', () => {
|
||||
it('builds encoded endpoint path and delegates to getJson', async () => {
|
||||
vi.mocked(getJson).mockResolvedValue({ ok: true });
|
||||
|
||||
await loadDashboard('demo context/1');
|
||||
|
||||
expect(getJson).toHaveBeenCalledWith('/api/waiter/floor/assignments?contextId=demo%20context%2F1');
|
||||
});
|
||||
});
|
||||
5
src/api/dashboardApi.ts
Normal file
5
src/api/dashboardApi.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { getJson } from './client';
|
||||
|
||||
export async function loadDashboard(contextId: string): Promise<unknown> {
|
||||
return getJson(`/api/waiter/floor/assignments?contextId=${encodeURIComponent(contextId)}`);
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
56
src/styles.css
Normal file
56
src/styles.css
Normal file
@ -0,0 +1,56 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(140deg, #f5f8ff, #eef3f0);
|
||||
color: #1d2230;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(760px, 100%);
|
||||
background: #ffffff;
|
||||
border: 1px solid #dce4f2;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 30px rgba(37, 52, 92, 0.08);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #c7d4ee;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #2158d6;
|
||||
color: #fff;
|
||||
border-color: #2158d6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
tsconfig.app.json
Normal file
17
tsconfig.app.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
tsconfig.app.tsbuildinfo
Normal file
1
tsconfig.app.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./src/App.test.tsx","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/dashboardApi.test.ts","./src/api/dashboardApi.ts","./src/test/setup.ts"],"version":"5.9.3"}
|
||||
6
tsconfig.json
Normal file
6
tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" }
|
||||
]
|
||||
}
|
||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user