diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 2de96b7..1953f84 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -21,14 +21,28 @@ jobs: matrix: include: - service: ner + path: backend/ner needs_shared: false - service: nel + path: backend/nel needs_shared: true - service: classify + path: backend/classify needs_shared: false - service: nel_v2 + path: backend/nel_v2 needs_shared: true - service: classify_v2 + path: backend/classify_v2 + needs_shared: false + - service: tabiya_plugin_contracts + path: backend/tabiya_plugin_contracts + needs_shared: false + - service: tabiya_core + path: backend/plugin_bundles/tabiya_core + needs_shared: false + - service: tabiya_io + path: backend/plugin_bundles/tabiya_io needs_shared: false steps: - uses: actions/checkout@v4 @@ -48,11 +62,11 @@ jobs: - name: Install dependencies run: poetry install --no-interaction ${{ matrix.service == 'nel_v2' && '--extras inference' || '' }} - working-directory: backend/${{ matrix.service }} + working-directory: ${{ matrix.path }} - name: Run tests run: poetry run pytest - working-directory: backend/${{ matrix.service }} + working-directory: ${{ matrix.path }} build-and-push: name: Build & push ${{ matrix.service }} @@ -62,7 +76,21 @@ jobs: environment: ${{ inputs.stack }} strategy: matrix: - service: [ner, nel, classify, nel_v2, classify_v2] + include: + - service: ner + dockerfile: backend/ner/Dockerfile + - service: nel + dockerfile: backend/nel/Dockerfile + - service: classify + dockerfile: backend/classify/Dockerfile + - service: nel_v2 + dockerfile: backend/nel_v2/Dockerfile + - service: classify_v2 + dockerfile: backend/classify_v2/Dockerfile + - service: tabiya_core + dockerfile: backend/plugin_bundles/tabiya_core/Dockerfile + - service: tabiya_io + dockerfile: backend/plugin_bundles/tabiya_io/Dockerfile permissions: contents: read id-token: write @@ -85,7 +113,7 @@ jobs: REGISTRY="${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/tabiya-classifier" echo "${{ secrets.HF_TOKEN }}" > /tmp/hf_token docker build \ - -f backend/${{ matrix.service }}/Dockerfile \ + -f ${{ matrix.dockerfile }} \ --secret id=HF_TOKEN,src=/tmp/hf_token \ -t ${REGISTRY}/${{ matrix.service }}:${{ github.sha }} \ -t ${REGISTRY}/${{ matrix.service }}:latest \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45057b0..289b0d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -86,7 +86,9 @@ jobs: --nel-image "${REGISTRY}/nel:${{ github.sha }}" \ --classify-image "${REGISTRY}/classify:${{ github.sha }}" \ --nel-v2-image "${REGISTRY}/nel_v2:${{ github.sha }}" \ - --classify-v2-image "${REGISTRY}/classify_v2:${{ github.sha }}" + --classify-v2-image "${REGISTRY}/classify_v2:${{ github.sha }}" \ + --tabiya-core-image "${REGISTRY}/tabiya_core:${{ github.sha }}" \ + --tabiya-io-image "${REGISTRY}/tabiya_io:${{ github.sha }}" # ── Phase 1: deploy everything except frontend ───────────────────────── - name: Deploy infrastructure (pre-frontend) diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs new file mode 100644 index 0000000..9a23d6b --- /dev/null +++ b/app/.eslintrc.cjs @@ -0,0 +1,36 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true, node: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:storybook/recommended", + ], + ignorePatterns: ["dist", ".eslintrc.cjs", "storybook-static", "coverage"], + parser: "@typescript-eslint/parser", + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + settings: { react: { version: "18.3" } }, + rules: { + // Disable the base rule — @typescript-eslint version is TS-aware. + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "after-used", + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + "@typescript-eslint/consistent-type-imports": [ + "error", + { prefer: "type-imports", fixStyle: "inline-type-imports" }, + ], + "react/prop-types": "off", + }, +}; diff --git a/app/.gitignore b/app/.gitignore index dafa699..9e91122 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,4 +1,16 @@ node_modules/ dist/ +storybook-static/ +coverage/ +.vite/ +*.log + +# Yarn Berry / PnP artifacts — this app uses classic Yarn 1.x (matches CI). +# Ignore these so a stray `yarn` (Berry) run can't re-commit a v2 lockfile or +# PnP files and break `yarn install --frozen-lockfile` on the classic CI runner. +.yarn/ +.pnp.* + .env .env.local +.DS_Store diff --git a/app/.storybook/main.ts b/app/.storybook/main.ts new file mode 100644 index 0000000..76ee3b8 --- /dev/null +++ b/app/.storybook/main.ts @@ -0,0 +1,21 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|tsx)"], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", + "msw-storybook-addon", + ], + framework: { + name: "@storybook/react-vite", + options: {}, + }, + staticDirs: ["../public"], + docs: { + autodocs: "tag", + }, +}; + +export default config; diff --git a/app/.storybook/preview.tsx b/app/.storybook/preview.tsx new file mode 100644 index 0000000..4a9c79b --- /dev/null +++ b/app/.storybook/preview.tsx @@ -0,0 +1,63 @@ +import { useEffect } from "react"; +import type { Preview, StoryFn } from "@storybook/react"; +import { I18nextProvider } from "react-i18next"; +import { initialize, mswLoader } from "msw-storybook-addon"; +import i18n from "../src/i18n/i18n"; +import { Locale, LocalesLabels } from "../src/i18n/constants"; +import { handlers } from "../src/mocks/handlers"; +import "../src/index.css"; + +initialize({ onUnhandledRequest: "bypass" }); + +const localeToolbarItems = Object.entries(LocalesLabels).map( + ([localeValue, label]) => ({ value: localeValue, title: label }), +); + +const preview: Preview = { + loaders: [mswLoader], + parameters: { + // Register the full MSW handler set for every story. Without this, MSW is + // initialized but has no handlers, so any story that hits the API (e.g. the + // pipelines pages) gets an unhandled request and fails to load. Individual + // stories still tune the backing store via seed/reset helpers. + msw: { handlers }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + globalTypes: { + locale: { + name: "Locale", + description: "Internationalization locale", + toolbar: { + icon: "globe", + items: localeToolbarItems, + defaultValue: Locale.EN_US, + showName: true, + }, + }, + }, + decorators: [ + function StoryWithLocale( + Story: StoryFn, + context: { globals: { locale?: string } }, + ) { + const selectedLocale = context.globals.locale ?? Locale.EN_US; + + useEffect(() => { + i18n.changeLanguage(selectedLocale); + }, [selectedLocale]); + + return ( + + + + ); + }, + ], +}; + +export default preview; diff --git a/app/index.html b/app/index.html index a3dd856..6d34683 100644 --- a/app/index.html +++ b/app/index.html @@ -5,7 +5,12 @@ Tabiya Classifier - + + +
diff --git a/app/knip.json b/app/knip.json new file mode 100644 index 0000000..3143af4 --- /dev/null +++ b/app/knip.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "src/mocks/browser.ts", + "src/mocks/server.ts", + "src/**/*.stories.tsx", + "src/**/*.test.{ts,tsx}" + ], + "project": ["src/**/*.{ts,tsx}"], + "ignoreDependencies": ["@storybook/blocks"], + "ignoreExportsUsedInFile": true, + "rules": { + "exports": "warn", + "types": "warn" + } +} diff --git a/app/package.json b/app/package.json index 67b65ee..a118147 100644 --- a/app/package.json +++ b/app/package.json @@ -2,23 +2,74 @@ "name": "tabiya-classifier-app", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint src --ext ts,tsx" + "lint": "eslint src --ext ts,tsx --max-warnings 0", + "lint:dead-code": "knip", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "dependencies": { + "@emotion/is-prop-valid": "^1.4.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", "firebase": "^10.12.0", + "framer-motion": "^12.40.0", + "i18next": "^26.3.1", + "i18next-browser-languagedetector": "^8.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.23.1" + "react-i18next": "^17.0.8", + "react-router-dom": "^6.23.1", + "reactflow": "^11", + "recharts": "^3.9.2", + "tailwind-merge": "^2.5.2" }, "devDependencies": { + "@storybook/addon-essentials": "^8.3.5", + "@storybook/addon-interactions": "^8.3.5", + "@storybook/addon-links": "^8.3.5", + "@storybook/blocks": "^8.3.5", + "@storybook/react": "^8.3.5", + "@storybook/react-vite": "^8.3.5", + "@storybook/test": "^8.3.5", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.3.0", + "@vitest/coverage-v8": "^2.1.2", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.37.1", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-storybook": "^0.9.0", + "jsdom": "^25.0.1", + "json-schema-to-typescript": "^15.0.4", + "knip": "^6.16.1", + "msw": "^2.4.9", + "msw-storybook-addon": "^2.0.4", + "postcss": "^8.4.47", + "storybook": "^8.3.5", + "tailwindcss": "^3.4.13", "typescript": "^5.4.5", - "vite": "^5.2.12" + "vite": "^5.2.12", + "vitest": "^2.1.2" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/app/postcss.config.js b/app/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/app/public/mockServiceWorker.js b/app/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/app/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx new file mode 100644 index 0000000..15b7c2b --- /dev/null +++ b/app/src/App.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +const GIVEN_APP_ROUTER_MARKER_TEST_ID = "given-app-router-marker"; + +// Stub the router's children so this test stays focused on App composition +// rather than the inner feature pages (those have their own tests). +vi.mock("./routes/AppRouter", () => ({ + AppRouter: () => ( +
router
+ ), +})); + +import { App } from "./App"; + +describe("App", () => { + it("renders the AppRouter", () => { + // GIVEN the App component + // WHEN it is rendered + render(); + + // THEN the AppRouter is mounted + expect( + screen.getByTestId(GIVEN_APP_ROUTER_MARKER_TEST_ID), + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/App.tsx b/app/src/App.tsx index 37d239e..9d70fce 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,43 +1,13 @@ -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import ProtectedRoute from "./auth/ProtectedRoute"; -import Login from "./pages/Login"; -import Dashboard from "./pages/Dashboard"; -import Configuration from "./pages/Configuration"; -import ApiKeys from "./pages/ApiKeys"; +import { ToastProvider } from "@/components"; +import { NavigationGuardProvider } from "@/lib/navigationGuard"; +import { AppRouter } from "./routes/AppRouter"; -export default function App() { +export function App() { return ( - - - } /> - - - - - } - /> - - - - } - /> - - - - } - /> - - } /> - - + + + + + ); } diff --git a/app/src/_test_utilities/VisualMock.tsx b/app/src/_test_utilities/VisualMock.tsx new file mode 100644 index 0000000..55a5058 --- /dev/null +++ b/app/src/_test_utilities/VisualMock.tsx @@ -0,0 +1,75 @@ +/** + * Placeholder component for stories that need to occupy a slot without + * dragging in a real feature page. Renders a dashed box with diagonal + * cross-lines and a centered label, so the surrounding layout is obviously + * the focus of the story. + * + * Lives in _test_utilities/ because it is only ever rendered by stories and + * tests — production code never imports it. + */ + +import { mergeClassNames } from "@/lib/mergeClassNames"; + +const uniqueId = "f7c8d9e0-1a2b-3c4d-5e6f-7a8b9c0d1e2f"; + +export const DATA_TEST_ID = { + CONTAINER: `visual-mock-container-${uniqueId}`, + LABEL: `visual-mock-label-${uniqueId}`, +}; + +export interface VisualMockProps { + /** Text shown centered inside the placeholder. */ + text: string; + /** Optional max-width cap; defaults to filling the parent. */ + maxWidth?: string | number; + className?: string; +} + +export function VisualMock({ text, maxWidth, className }: VisualMockProps) { + return ( +
+ + {text} + + + + + +
+ ); +} diff --git a/app/src/_test_utilities/index.ts b/app/src/_test_utilities/index.ts new file mode 100644 index 0000000..9254eaf --- /dev/null +++ b/app/src/_test_utilities/index.ts @@ -0,0 +1,17 @@ +export { VisualMock } from "./VisualMock"; +export type { VisualMockProps } from "./VisualMock"; + +export { renderWithRouter } from "./renderWithRouter"; +export type { + RenderWithRouterOptions, + AdditionalRouteEntry, +} from "./renderWithRouter"; + +export { renderWithRouterOutlet } from "./renderWithRouterOutlet"; +export type { RenderWithRouterOutletOptions } from "./renderWithRouterOutlet"; + +export { withFirebaseAuth, withRouter } from "./storybookDecorators"; +export type { + FirebaseAuthStoryOptions, + RouterStoryOptions, +} from "./storybookDecorators"; diff --git a/app/src/_test_utilities/renderWithRouter.tsx b/app/src/_test_utilities/renderWithRouter.tsx new file mode 100644 index 0000000..35fac7c --- /dev/null +++ b/app/src/_test_utilities/renderWithRouter.tsx @@ -0,0 +1,66 @@ +/** + * Renders a React element inside a MemoryRouter so router-aware components + * (NavLink, useNavigate, useLocation, etc.) can be exercised in unit tests + * without a full app shell. + * + * - `currentPath` is the initial pathname. + * - `routes` lets you register sibling routes (e.g. a fake `/login` page) so + * you can assert post-navigation state. + * - `routeState` is React Router's `location.state` for the initial entry — + * useful for testing redirect logic that reads `state.from`. + */ + +import type { ReactNode } from "react"; +import { render } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; + +export interface AdditionalRouteEntry { + /** Path pattern for the sibling route (e.g. "/login"). */ + path: string; + /** Element to render when the path matches. */ + element: ReactNode; +} + +export interface RenderWithRouterOptions { + /** Initial pathname rendered by the MemoryRouter. */ + currentPath: string; + /** Path pattern the element under test is mounted at. */ + elementPath: string; + /** Optional sibling routes for navigation assertions. */ + additionalRoutes?: AdditionalRouteEntry[]; + /** Optional `location.state` for the initial entry. */ + routeState?: Record; +} + +/** + * Render the given element behind a MemoryRouter at the configured path, + * alongside any extra routes the test needs (e.g. redirect targets). + */ +export function renderWithRouter( + element: ReactNode, + { + currentPath, + elementPath, + additionalRoutes = [], + routeState, + }: RenderWithRouterOptions, +) { + const initialEntry = routeState + ? { pathname: currentPath, state: routeState } + : currentPath; + + return render( + + + + {additionalRoutes.map((extraRoute) => ( + + ))} + + , + ); +} diff --git a/app/src/_test_utilities/renderWithRouterOutlet.tsx b/app/src/_test_utilities/renderWithRouterOutlet.tsx new file mode 100644 index 0000000..28682d1 --- /dev/null +++ b/app/src/_test_utilities/renderWithRouterOutlet.tsx @@ -0,0 +1,53 @@ +/** + * Variant of renderWithRouter for components that render an and + * therefore need a *nested* child route to fill it. + * + * Use this when the element under test (e.g. AppShell) is a layout route — + * its child renders via , not as a direct child of . + */ + +import type { ReactNode } from "react"; +import { render } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import type { AdditionalRouteEntry } from "./renderWithRouter"; + +export interface RenderWithRouterOutletOptions { + /** Initial pathname rendered by the MemoryRouter. */ + currentPath: string; + /** Path of the nested route (filled by the layout's ). */ + outletPath: string; + /** Element rendered inside the layout's outlet. */ + outletElement: ReactNode; + /** Optional sibling top-level routes (e.g. a fake /login marker). */ + additionalRoutes?: AdditionalRouteEntry[]; +} + +/** + * Render a layout component that uses with a nested child route. + */ +export function renderWithRouterOutlet( + layoutElement: ReactNode, + { + currentPath, + outletPath, + outletElement, + additionalRoutes = [], + }: RenderWithRouterOutletOptions, +) { + return render( + + + {additionalRoutes.map((extraRoute) => ( + + ))} + + + + + , + ); +} diff --git a/app/src/_test_utilities/storybookDecorators.tsx b/app/src/_test_utilities/storybookDecorators.tsx new file mode 100644 index 0000000..779e516 --- /dev/null +++ b/app/src/_test_utilities/storybookDecorators.tsx @@ -0,0 +1,69 @@ +/** + * Storybook decorators that wrap a story in the providers / router context + * production components depend on, with deterministic mock values. + * + * Why this lives in _test_utilities (not in a story file): multiple page + * stories need the same setup, and Storybook decorators are framework code, + * not test code — but the data they inject is the same flavor of mocking we + * use elsewhere, so co-locating with the test helpers keeps the + * "fake-data plumbing" in one place. + */ + +import type { ReactElement } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { + AuthOverrideProvider, + type UseFirebaseAuthValue, + type AuthenticatedUser, +} from "@/lib/auth/useFirebaseAuth"; + +type StoryRenderer = () => ReactElement; + +export interface RouterStoryOptions { + /** Initial pathname for MemoryRouter. Defaults to "/". */ + initialPath?: string; +} + +/** Wrap a story in a MemoryRouter at the given path. */ +export function withRouter({ initialPath = "/" }: RouterStoryOptions = {}) { + function StoryWithRouter(Story: StoryRenderer) { + return ( + + + + ); + } + return StoryWithRouter; +} + +export interface FirebaseAuthStoryOptions { + /** The signed-in user, or null for signed-out. Defaults to null. */ + user?: AuthenticatedUser | null; + /** Whether the hook should report initial auth-resolution loading. Defaults to false. */ + loading?: boolean; +} + +/** + * Wrap a story in an AuthOverrideProvider so useFirebaseAuth returns a + * deterministic value. Sign-in/up/out callbacks are no-ops by default. + */ +export function withFirebaseAuth({ + user = null, + loading = false, +}: FirebaseAuthStoryOptions = {}) { + const value: UseFirebaseAuthValue = { + user, + loading, + signInWithEmail: async () => undefined, + signUpWithEmail: async () => undefined, + signOut: async () => undefined, + }; + function StoryWithFirebaseAuth(Story: StoryRenderer) { + return ( + + + + ); + } + return StoryWithFirebaseAuth; +} diff --git a/app/src/auth/ProtectedRoute.tsx b/app/src/auth/ProtectedRoute.tsx deleted file mode 100644 index 0279eba..0000000 --- a/app/src/auth/ProtectedRoute.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import { Navigate } from "react-router-dom"; -import { useAuth } from "../hooks/useAuth"; - -export default function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { user, loading } = useAuth(); - - if (loading) { - return ( -
- Loading… -
- ); - } - - if (!user) { - return ; - } - - return <>{children}; -} diff --git a/app/src/components/AppLayout/AppLayout.stories.tsx b/app/src/components/AppLayout/AppLayout.stories.tsx new file mode 100644 index 0000000..ee259db --- /dev/null +++ b/app/src/components/AppLayout/AppLayout.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { AppLayout } from "./AppLayout"; +import { Sidebar } from "@/components"; +import { Topbar } from "@/components"; +import { StatusPill } from "@/components"; +import { Kbd } from "@/components"; + +const meta: Meta = { + title: "Primitives/AppLayout", + component: AppLayout, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +const groups = [ + { + label: "Workspace", + items: [ + { id: "classify", label: "Classifier", icon: "classify" as const }, + { id: "dashboard", label: "Dashboard", icon: "dashboard" as const }, + { id: "history", label: "History", icon: "history" as const }, + ], + }, + { + label: "Settings", + items: [ + { id: "config", label: "Configuration", icon: "config" as const }, + { id: "keys", label: "Keys", icon: "key" as const }, + { id: "docs", label: "Documentation", icon: "docs" as const }, + ], + }, +]; + +export const Shell: Story = { + render: () => { + const [active, setActive] = useState("classify"); + return ( + {}} + /> + } + topbar={ + {} }, + { label: active }, + ]} + right={ + <> + API healthy · v1.0.0 + ⌘ K + + } + /> + } + > +
+
Workspace · {active}
+

Page content goes here

+
+
+ ); + }, +}; diff --git a/app/src/components/AppLayout/AppLayout.test.tsx b/app/src/components/AppLayout/AppLayout.test.tsx new file mode 100644 index 0000000..73baca3 --- /dev/null +++ b/app/src/components/AppLayout/AppLayout.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AppLayout, DATA_TEST_ID } from "./AppLayout"; + +const GIVEN_SIDEBAR_TEST_ID = "given-sidebar-slot"; +const GIVEN_TOPBAR_TEST_ID = "given-topbar-slot"; + +describe("AppLayout", () => { + it("renders sidebar, topbar, and children content", () => { + // GIVEN main content for the layout + const givenMainContent = "main content"; + + // WHEN we render the AppLayout composed with three slots + render( + sidebar} + topbar={
topbar
} + > +
{givenMainContent}
+
, + ); + + // THEN every slot is in the document with the given content + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toBeInTheDocument(); + expect(screen.getByTestId(GIVEN_SIDEBAR_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(GIVEN_TOPBAR_TEST_ID)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.MAIN)).toHaveTextContent( + givenMainContent, + ); + }); +}); diff --git a/app/src/components/AppLayout/AppLayout.tsx b/app/src/components/AppLayout/AppLayout.tsx new file mode 100644 index 0000000..db0db66 --- /dev/null +++ b/app/src/components/AppLayout/AppLayout.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; + +const uniqueId = "b8cabb2d-d140-48e5-b5bd-62d5dfa46628"; + +export const DATA_TEST_ID = { + CONTAINER: `app-layout-container-${uniqueId}`, + MAIN: `app-layout-main-${uniqueId}`, +}; + +export interface AppLayoutProps { + sidebar: ReactNode; + topbar?: ReactNode; + children: ReactNode; + className?: string; +} + +/** + * The page chrome: persistent sidebar on the left, sticky topbar, scrolling + * content area. Page components decide their own inner padding. + */ +export function AppLayout({ + sidebar, + topbar, + children, + className, +}: AppLayoutProps) { + return ( +
+ {sidebar} +
+ {topbar} +
+ {children} +
+
+
+ ); +} diff --git a/app/src/components/BottomTabBar/BottomTabBar.tsx b/app/src/components/BottomTabBar/BottomTabBar.tsx new file mode 100644 index 0000000..de2f9f6 --- /dev/null +++ b/app/src/components/BottomTabBar/BottomTabBar.tsx @@ -0,0 +1,62 @@ +import { Icon } from "@/components/Icon/Icon"; +import { mergeClassNames } from "@/lib/mergeClassNames"; +import type { IconName } from "@/components/Icon/Icon.types"; + +const uniqueId = "f3e2d1c0-b9a8-4f7e-8d6c-5b4a3c2d1e0f"; + +export const DATA_TEST_ID = { + CONTAINER: `bottom-tab-bar-container-${uniqueId}`, + TAB: `bottom-tab-bar-tab-${uniqueId}`, +}; + +export interface BottomTabItem { + id: string; + label: string; + /** Shorter label for narrow viewports. Falls back to `label` if omitted. */ + shortLabel?: string; + icon: IconName; +} + +export interface BottomTabBarProps { + items: BottomTabItem[]; + activeId: string; + onNavigate: (id: string) => void; + className?: string; +} + +export function BottomTabBar({ + items, + activeId, + onNavigate, + className, +}: BottomTabBarProps) { + return ( + + ); +} diff --git a/app/src/components/Breadcrumbs/Breadcrumbs.stories.tsx b/app/src/components/Breadcrumbs/Breadcrumbs.stories.tsx new file mode 100644 index 0000000..e3efa84 --- /dev/null +++ b/app/src/components/Breadcrumbs/Breadcrumbs.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { Breadcrumbs } from "@/components"; + +const meta: Meta = { + title: "Primitives/Breadcrumbs", + component: Breadcrumbs, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + items: [ + { label: "Workspace", onClick: fn() }, + { label: "Classifier" }, + ], + }, +}; + +export const ThreeLevel: Story = { + args: { + items: [ + { label: "Documentation", onClick: fn() }, + { label: "Endpoints", onClick: fn() }, + { label: "POST /v1/classify" }, + ], + }, +}; diff --git a/app/src/components/Breadcrumbs/Breadcrumbs.test.tsx b/app/src/components/Breadcrumbs/Breadcrumbs.test.tsx new file mode 100644 index 0000000..dc16028 --- /dev/null +++ b/app/src/components/Breadcrumbs/Breadcrumbs.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Breadcrumbs, DATA_TEST_ID } from "./Breadcrumbs"; + +describe("Breadcrumbs", () => { + it("renders one list item per breadcrumb", () => { + // GIVEN three breadcrumb labels + const givenBreadcrumbLabels = ["Docs", "Endpoints", "POST /classify"]; + + // WHEN we render Breadcrumbs with those labels + render( + ({ label }))} + />, + ); + + // THEN exactly that many items render, with the right text in each position + const renderedItems = screen.getAllByTestId(DATA_TEST_ID.ITEM); + expect(renderedItems).toHaveLength(givenBreadcrumbLabels.length); + givenBreadcrumbLabels.forEach((label, index) => { + expect(renderedItems[index]).toHaveTextContent(label); + }); + }); + + it("renders intermediate items as anchors when onClick is provided", () => { + // GIVEN a clickable intermediate label and a terminal label + const givenClickableLabel = "Docs"; + const givenTerminalLabel = "Endpoints"; + + // WHEN we render Breadcrumbs with the clickable label first + render( + {} }, + { label: givenTerminalLabel }, + ]} + />, + ); + + // THEN the clickable item renders as a single link with that label + const renderedLinks = screen.getAllByTestId(DATA_TEST_ID.LINK); + expect(renderedLinks).toHaveLength(1); + expect(renderedLinks[0]).toHaveTextContent(givenClickableLabel); + }); + + it("calls onClick when an item is activated", async () => { + // GIVEN an onClick spy + const onClick = vi.fn(); + + // AND a rendered breadcrumb bound to that handler + render( + , + ); + + // WHEN the user clicks the breadcrumb link + await userEvent.click(screen.getByTestId(DATA_TEST_ID.LINK)); + + // THEN onClick is invoked exactly once + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/Breadcrumbs/Breadcrumbs.tsx b/app/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000..86d91f3 --- /dev/null +++ b/app/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; + +const uniqueId = "92dfd837-3b3c-463b-9aa0-fed5dfefe5ff"; + +export const DATA_TEST_ID = { + NAV: `breadcrumbs-nav-${uniqueId}`, + ITEM: `breadcrumbs-item-${uniqueId}`, + LINK: `breadcrumbs-link-${uniqueId}`, +}; + +export interface BreadcrumbItem { + label: ReactNode; + href?: string; + onClick?: () => void; +} + +export interface BreadcrumbsProps { + items: BreadcrumbItem[]; + className?: string; + /** Separator character between items. Defaults to /. */ + separator?: string; +} + +export function Breadcrumbs({ + items, + className, + separator = "/", +}: BreadcrumbsProps) { + return ( + + ); +} diff --git a/app/src/components/Button/Button.stories.tsx b/app/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000..e64eef2 --- /dev/null +++ b/app/src/components/Button/Button.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { Button } from "./Button"; +import { Icon } from "@/components"; + +const meta: Meta = { + title: "Primitives/Button", + component: Button, + parameters: { layout: "centered" }, + argTypes: { + variant: { + control: "select", + options: ["default", "primary", "lime", "ghost", "danger"], + }, + size: { control: "select", options: ["sm", "md", "lg"] }, + }, + args: { children: "Run classify", onClick: fn() }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Primary: Story = { args: { variant: "primary" } }; +export const Lime: Story = { args: { variant: "lime", children: "Open the Classifier" } }; +export const Ghost: Story = { args: { variant: "ghost" } }; +export const Danger: Story = { args: { variant: "danger", children: "Revoke" } }; + +export const Small: Story = { args: { size: "sm" } }; +export const Large: Story = { args: { size: "lg", variant: "primary" } }; + +export const WithIcons: Story = { + args: { + variant: "primary", + leading: , + trailing: , + }, +}; + +export const Loading: Story = { + args: { variant: "primary", loading: true, children: "Running" }, +}; + +export const Disabled: Story = { + args: { variant: "primary", disabled: true }, +}; + +export const AllVariants: Story = { + args: { onClick: fn() }, + render: (args) => ( +
+ + + + + +
+ ), +}; diff --git a/app/src/components/Button/Button.test.tsx b/app/src/components/Button/Button.test.tsx new file mode 100644 index 0000000..ed360ee --- /dev/null +++ b/app/src/components/Button/Button.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button, DATA_TEST_ID } from "./Button"; + +describe("Button", () => { + it("renders its children as the label", () => { + // GIVEN an expected label text + const givenLabelText = "Save"; + + // WHEN we render a Button with that label + render(); + + // THEN the label node carries the given text + expect(screen.getByTestId(DATA_TEST_ID.LABEL)).toHaveTextContent(givenLabelText); + }); + + it("fires onClick when activated", async () => { + // GIVEN an onClick spy + const onClick = vi.fn(); + + // AND a rendered Button bound to that spy + render(); + + // WHEN the user clicks the button + await userEvent.click(screen.getByTestId(DATA_TEST_ID.CONTAINER)); + + // THEN the spy is called exactly once + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("is disabled and skips clicks while loading", async () => { + // GIVEN an onClick spy + const onClick = vi.fn(); + + // AND a Button rendered with loading=true + render( + , + ); + + // WHEN the user clicks the loading button + await userEvent.click(screen.getByTestId(DATA_TEST_ID.CONTAINER)); + + // THEN the button is marked disabled, the spinner is shown, and onClick is never called + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toBeDisabled(); + expect(screen.getByTestId(DATA_TEST_ID.SPINNER)).toBeInTheDocument(); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("does not render the trailing slot while loading", () => { + // GIVEN a Button with a trailing icon-like marker and loading=true + // WHEN it renders + render( + , + ); + + // THEN the trailing slot is suppressed in favor of the spinner + expect(screen.queryByTestId(DATA_TEST_ID.TRAILING)).not.toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.SPINNER)).toBeInTheDocument(); + }); + + it("renders leading and trailing slots when provided and not loading", () => { + // GIVEN a Button with both leading and trailing slots + // WHEN it renders + render( + , + ); + + // THEN both slot containers are present in the DOM + expect(screen.getByTestId(DATA_TEST_ID.LEADING)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.TRAILING)).toBeInTheDocument(); + }); + + it("applies the primary variant classes", () => { + // GIVEN a primary Button + // WHEN it renders + render(); + + // THEN the button element carries the primary background utility + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).toMatch(/bg-navy/); + }); +}); diff --git a/app/src/components/Button/Button.tsx b/app/src/components/Button/Button.tsx new file mode 100644 index 0000000..f9f702a --- /dev/null +++ b/app/src/components/Button/Button.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; +import type { ButtonProps } from "@/components"; +import { buttonVariants } from "@/components"; + +const uniqueId = "01f9e13d-269a-41e4-ab18-f6a108050c29"; + +export const DATA_TEST_ID = { + CONTAINER: `button-container-${uniqueId}`, + SPINNER: `button-spinner-${uniqueId}`, + LEADING: `button-leading-${uniqueId}`, + TRAILING: `button-trailing-${uniqueId}`, + LABEL: `button-label-${uniqueId}`, +}; + +export const Button = forwardRef(function Button( + { + variant, + size, + className, + leading, + trailing, + loading, + disabled, + children, + type = "button", + ...rest + }, + ref, +) { + const isDisabled = disabled || loading; + return ( + + ); +}); diff --git a/app/src/components/Button/Button.types.ts b/app/src/components/Button/Button.types.ts new file mode 100644 index 0000000..f066c8d --- /dev/null +++ b/app/src/components/Button/Button.types.ts @@ -0,0 +1,14 @@ +import type { ButtonHTMLAttributes } from "react"; +import type { VariantProps } from "class-variance-authority"; +import type { buttonVariants } from "./Button.variants"; + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + /** Renders a leading 14px slot — typically an Icon. */ + leading?: React.ReactNode; + /** Renders a trailing 14px slot — typically an Icon. */ + trailing?: React.ReactNode; + /** Shows a spinner and disables interaction while running. */ + loading?: boolean; +} diff --git a/app/src/components/Button/Button.variants.ts b/app/src/components/Button/Button.variants.ts new file mode 100644 index 0000000..5779904 --- /dev/null +++ b/app/src/components/Button/Button.variants.ts @@ -0,0 +1,34 @@ +import { cva } from "class-variance-authority"; + +export const buttonVariants = cva( + // base: monospace label, rounded-md corners, smooth transitions, focus ring + "inline-flex items-center justify-center gap-2 font-mono font-medium tracking-tight " + + "border transition-colors duration-100 outline-none " + + "focus-visible:ring-2 focus-visible:ring-navy/30 focus-visible:ring-offset-1 focus-visible:ring-offset-cream " + + "disabled:opacity-45 disabled:cursor-not-allowed", + { + variants: { + variant: { + default: + "bg-paper text-navy border-line-strong hover:bg-cream-200 hover:border-navy", + primary: + "bg-navy text-lime border-navy hover:bg-navy-700", + lime: + "bg-lime text-navy border-lime font-semibold hover:bg-lime-600 hover:border-lime-600", + ghost: + "bg-transparent text-navy border-line hover:bg-paper", + danger: + "bg-paper text-error border-line hover:bg-[#fcecea] hover:border-error", + }, + size: { + sm: "px-2.5 py-1 text-[11px] rounded", + md: "px-3.5 py-2 text-xs rounded", + lg: "px-4 py-2.5 text-[13px] rounded", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + }, + }, +); diff --git a/app/src/components/Card/Card.stories.tsx b/app/src/components/Card/Card.stories.tsx new file mode 100644 index 0000000..de4fae9 --- /dev/null +++ b/app/src/components/Card/Card.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { Card, CardHead } from "./Card"; +import { Button } from "@/components"; +import { Eyebrow } from "@/components"; + +const meta: Meta = { + title: "Primitives/Card", + component: Card, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const Plain: Story = { + render: () => ( + + Calls this month +
12,348
+
+ ), +}; + +export const WithHead: Story = { + render: () => ( + + + View all + + } + /> +

Table content goes here.

+
+ ), +}; + +export const Flush: Story = { + render: () => ( + +
Flush card – host owns padding
+
+ ), +}; + +export const Elevated: Story = { + render: () => ( + + Elevated +

Used for overlays or featured surfaces.

+
+ ), +}; diff --git a/app/src/components/Card/Card.test.tsx b/app/src/components/Card/Card.test.tsx new file mode 100644 index 0000000..0c3fe1d --- /dev/null +++ b/app/src/components/Card/Card.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Card, CardHead, DATA_TEST_ID } from "./Card"; + +describe("Card", () => { + it("renders children inside a styled container", () => { + // GIVEN expected card contents + const givenCardContents = "contents"; + + // WHEN we render a Card with those contents + render({givenCardContents}); + + // THEN the container carries the given contents + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveTextContent( + givenCardContents, + ); + }); + + it("omits inner padding when flush", () => { + // GIVEN a Card with flush=true + // WHEN we render it + render(x); + + // THEN the rendered element does not carry the p-5 utility + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).not.toMatch(/p-5/); + }); + + it("applies the elevated shadow class when elevated", () => { + // GIVEN a Card with elevated=true + // WHEN we render it + render(x); + + // THEN the container carries the elevated shadow utility + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).toMatch( + /shadow-card-2/, + ); + }); +}); + +describe("CardHead", () => { + it("renders title and action slots", () => { + // GIVEN a title text and action label + const givenCardTitle = "Title"; + const givenCardActionLabel = "act"; + + // WHEN we render a CardHead with that title and action + render( + {givenCardActionLabel}} + />, + ); + + // THEN both slots carry their given content + expect(screen.getByTestId(DATA_TEST_ID.HEAD_TITLE)).toHaveTextContent( + givenCardTitle, + ); + expect(screen.getByTestId(DATA_TEST_ID.HEAD_ACTION)).toHaveTextContent( + givenCardActionLabel, + ); + }); +}); diff --git a/app/src/components/Card/Card.tsx b/app/src/components/Card/Card.tsx new file mode 100644 index 0000000..d6128cb --- /dev/null +++ b/app/src/components/Card/Card.tsx @@ -0,0 +1,77 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; + +const uniqueId = "c2f2fc50-0164-42f5-a4d6-6c2d6f16450f"; + +export const DATA_TEST_ID = { + CONTAINER: `card-container-${uniqueId}`, + HEAD_CONTAINER: `card-head-container-${uniqueId}`, + HEAD_TITLE: `card-head-title-${uniqueId}`, + HEAD_ACTION: `card-head-action-${uniqueId}`, +}; + +export interface CardProps extends HTMLAttributes { + /** Remove the default padding — use when the card hosts its own layout. */ + flush?: boolean; + /** Use the elevated shadow style instead of flat borders. */ + elevated?: boolean; +} + +export const Card = forwardRef(function Card( + { flush, elevated, className, children, ...rest }, + ref, +) { + return ( +
+ {children} +
+ ); +}); + +export interface CardHeadProps + extends Omit, "title"> { + title?: React.ReactNode; + action?: React.ReactNode; +} + +export function CardHead({ + title, + action, + className, + children, + ...rest +}: CardHeadProps) { + return ( +
+ {title && ( +

+ {title} +

+ )} + {children} + {action && ( + {action} + )} +
+ ); +} diff --git a/app/src/components/CodeBlock/CodeBlock.stories.tsx b/app/src/components/CodeBlock/CodeBlock.stories.tsx new file mode 100644 index 0000000..accc442 --- /dev/null +++ b/app/src/components/CodeBlock/CodeBlock.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CodeBlock } from "./CodeBlock"; + +const meta: Meta = { + title: "Primitives/CodeBlock", + component: CodeBlock, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +const curl = `curl -X POST https://api.classifier.tabiya.tech/v1/classify \\ + -H "x-api-key: $TABIYA_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{"text":"Senior Data Engineer with Python."}'`; + +const response = `{ + "status": "healthy", + "service": "classify-api", + "dependencies": { "ner_api": "healthy", "nel_api": "healthy" } +}`; + +export const Default: Story = { + args: { code: curl }, +}; + +export const Muted: Story = { + args: { code: response, muted: true }, +}; + +export const Copyable: Story = { + args: { code: curl, copyable: true }, +}; + +export const Scrolling: Story = { + args: { + copyable: true, + maxHeight: 180, + code: Array.from({ length: 30 }, (_, i) => `line ${i + 1}: payload data ${i}`).join("\n"), + }, +}; diff --git a/app/src/components/CodeBlock/CodeBlock.test.tsx b/app/src/components/CodeBlock/CodeBlock.test.tsx new file mode 100644 index 0000000..92f8b60 --- /dev/null +++ b/app/src/components/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CodeBlock, DATA_TEST_ID } from "./CodeBlock"; + +describe("CodeBlock", () => { + it("renders the code prop as the body", () => { + // GIVEN an expected code snippet + const givenCodeSnippet = "curl https://example.com"; + + // WHEN we render a CodeBlock with that snippet + render(); + + // THEN the rendered code carries the given snippet + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveTextContent( + givenCodeSnippet, + ); + }); + + it("applies the muted variant class when muted", () => { + // GIVEN a CodeBlock with muted=true + // WHEN we render it + render(); + + // THEN the pre element carries the muted utility class + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER).className).toMatch(/muted/); + }); + + it("copies text to clipboard when the copy button is clicked", async () => { + // GIVEN a clipboard spy and the expected code snippet + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const givenCodeSnippet = "hello world"; + + // AND a copyable CodeBlock with that snippet + render(); + + // WHEN the user clicks the copy button + await userEvent.click(screen.getByTestId(DATA_TEST_ID.COPY_BUTTON)); + + // THEN the clipboard is called with the given snippet + expect(writeText).toHaveBeenCalledWith(givenCodeSnippet); + }); +}); diff --git a/app/src/components/CodeBlock/CodeBlock.tsx b/app/src/components/CodeBlock/CodeBlock.tsx new file mode 100644 index 0000000..eba8b24 --- /dev/null +++ b/app/src/components/CodeBlock/CodeBlock.tsx @@ -0,0 +1,73 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { useState } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; + +const uniqueId = "69e59609-0d9a-4928-80d6-9c727815def9"; + +export const DATA_TEST_ID = { + CONTAINER: `code-block-container-${uniqueId}`, + COPY_BUTTON: `code-block-copy-button-${uniqueId}`, +}; + +export interface CodeBlockProps extends HTMLAttributes { + /** Dim the background — used for response examples in Docs. */ + muted?: boolean; + /** Show a copy button in the top-right that copies the rendered text. */ + copyable?: boolean; + /** The text content. Children are rendered if provided; otherwise `code` is shown. */ + code?: string; + children?: ReactNode; + /** Max height before the block scrolls vertically. */ + maxHeight?: number | string; +} + +export function CodeBlock({ + muted, + copyable, + code, + children, + className, + maxHeight, + ...rest +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + const text = code ?? (typeof children === "string" ? children : ""); + + async function onCopy() { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // navigator.clipboard may be unavailable in some test envs; swallow. + } + } + + return ( +
+      {copyable && (
+        
+ +
+ )} + {children ?? code} +
+ ); +} diff --git a/app/src/components/Divider/Divider.stories.tsx b/app/src/components/Divider/Divider.stories.tsx new file mode 100644 index 0000000..3a84af0 --- /dev/null +++ b/app/src/components/Divider/Divider.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Divider } from "./Divider"; + +const meta: Meta = { + title: "Primitives/Divider", + component: Divider, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const Solid: Story = { + render: () => ( +
+

Above

+ +

Below

+
+ ), +}; + +export const Dashed: Story = { + render: () => ( +
+

Above

+ +

Below

+
+ ), +}; diff --git a/app/src/components/Divider/Divider.test.tsx b/app/src/components/Divider/Divider.test.tsx new file mode 100644 index 0000000..20857c9 --- /dev/null +++ b/app/src/components/Divider/Divider.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Divider, DATA_TEST_ID } from "./Divider"; + +describe("Divider", () => { + it("renders as a separator with horizontal orientation by default", () => { + // GIVEN the default expected orientation + const expectedDefaultOrientation = "horizontal"; + + // WHEN we render the default Divider + render(); + + // THEN it exposes the separator role with horizontal orientation + const dividerNode = screen.getByTestId(DATA_TEST_ID.CONTAINER); + expect(dividerNode.getAttribute("role")).toBe("separator"); + expect(dividerNode.getAttribute("aria-orientation")).toBe( + expectedDefaultOrientation, + ); + }); + + it("uses vertical orientation when requested", () => { + // GIVEN a vertical orientation + const givenOrientation = "vertical" as const; + + // WHEN we render the Divider with that orientation + render(); + + // THEN the separator reports the given orientation + expect( + screen.getByTestId(DATA_TEST_ID.CONTAINER).getAttribute("aria-orientation"), + ).toBe(givenOrientation); + }); +}); diff --git a/app/src/components/Divider/Divider.tsx b/app/src/components/Divider/Divider.tsx new file mode 100644 index 0000000..f241ab6 --- /dev/null +++ b/app/src/components/Divider/Divider.tsx @@ -0,0 +1,41 @@ +import type { HTMLAttributes } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; + +const uniqueId = "a7e3b610-5d29-4f48-91c8-6b04e8d2a1f5"; + +export const DATA_TEST_ID = { + CONTAINER: `divider-container-${uniqueId}`, +}; + +export interface DividerProps extends HTMLAttributes { + /** Use a dashed line instead of solid (often signals a soft section break). */ + dashed?: boolean; + /** Vertical orientation for inline use. Defaults to horizontal. */ + orientation?: "horizontal" | "vertical"; +} + +export function Divider({ + dashed, + orientation = "horizontal", + className, + ...rest +}: DividerProps) { + const vertical = orientation === "vertical"; + return ( +
+ ); +} diff --git a/app/src/components/Drawer/Drawer.stories.tsx b/app/src/components/Drawer/Drawer.stories.tsx new file mode 100644 index 0000000..2d000f8 --- /dev/null +++ b/app/src/components/Drawer/Drawer.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { fn } from "@storybook/test"; +import { Drawer } from "./Drawer"; +import { Button } from "@/components"; + +const meta: Meta = { + title: "Primitives/Drawer", + component: Drawer, + parameters: { layout: "centered" }, +}; +export default meta; + +type Story = StoryObj; + +export const EntityDetail: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + const onOpen = fn(() => setIsOpen(true)); + const onClose = fn(() => setIsOpen(false)); + const onViewInEsco = fn(); + return ( + <> + + + + +
+ } + > +
+

Top ESCO match: data engineer (94%)

+

Alt match: big data engineer (88%)

+
+ + + ); + }, +}; diff --git a/app/src/components/Drawer/Drawer.test.tsx b/app/src/components/Drawer/Drawer.test.tsx new file mode 100644 index 0000000..7677269 --- /dev/null +++ b/app/src/components/Drawer/Drawer.test.tsx @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Drawer, DATA_TEST_ID } from "./Drawer"; + +describe("Drawer", () => { + it("does not render when closed", () => { + // GIVEN a Drawer with open=false + // WHEN we render it + render( + {}} title="Hidden"> + body + , + ); + + // THEN no panel is in the DOM + expect(screen.queryByTestId(DATA_TEST_ID.PANEL)).not.toBeInTheDocument(); + }); + + it("renders eyebrow, title, body and footer when open", () => { + // GIVEN content for each slot + const givenEyebrowText = "OCCUPATION"; + const givenTitleText = "Senior Engineer"; + const givenBodyText = "details body"; + const givenFooterText = "footer-actions"; + + // WHEN we render the open Drawer with all four slots populated + render( + {}} + eyebrow={givenEyebrowText} + title={givenTitleText} + footer={{givenFooterText}} + > + {givenBodyText} + , + ); + + // THEN each slot's node carries the given text + expect(screen.getByTestId(DATA_TEST_ID.EYEBROW)).toHaveTextContent(givenEyebrowText); + expect(screen.getByTestId(DATA_TEST_ID.TITLE)).toHaveTextContent(givenTitleText); + expect(screen.getByTestId(DATA_TEST_ID.BODY)).toHaveTextContent(givenBodyText); + expect(screen.getByTestId(DATA_TEST_ID.FOOTER)).toHaveTextContent(givenFooterText); + }); + + it("invokes onClose on Escape", async () => { + // GIVEN an onClose spy + const onClose = vi.fn(); + + // AND an open Drawer wired to that spy + render( + + body + , + ); + + // WHEN the user presses Escape + await userEvent.keyboard("{Escape}"); + + // THEN onClose is invoked exactly once + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("invokes onClose when the backdrop is clicked", async () => { + // GIVEN an onClose spy + const onClose = vi.fn(); + + // AND an open Drawer wired to that spy + render( + + body + , + ); + + // WHEN the user mousedowns on the backdrop directly + const backdrop = screen.getByTestId(DATA_TEST_ID.BACKDROP); + await userEvent.pointer({ keys: "[MouseLeft>]", target: backdrop }); + + // THEN onClose is invoked exactly once + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not invoke onClose when closeOnBackdropClick is false", async () => { + // GIVEN an onClose spy + const onClose = vi.fn(); + + // AND a Drawer that ignores backdrop clicks + render( + + body + , + ); + + // WHEN the user mousedowns on the backdrop + const backdrop = screen.getByTestId(DATA_TEST_ID.BACKDROP); + await userEvent.pointer({ keys: "[MouseLeft>]", target: backdrop }); + + // THEN onClose stays untouched + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/Drawer/Drawer.tsx b/app/src/components/Drawer/Drawer.tsx new file mode 100644 index 0000000..3ceb214 --- /dev/null +++ b/app/src/components/Drawer/Drawer.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; + +const uniqueId = "3b992191-34cd-406b-bf12-5ab1b1729d2b"; + +export const DATA_TEST_ID = { + BACKDROP: `drawer-backdrop-${uniqueId}`, + PANEL: `drawer-panel-${uniqueId}`, + HEADER: `drawer-header-${uniqueId}`, + EYEBROW: `drawer-eyebrow-${uniqueId}`, + TITLE: `drawer-title-${uniqueId}`, + DESCRIPTION: `drawer-description-${uniqueId}`, + BODY: `drawer-body-${uniqueId}`, + FOOTER: `drawer-footer-${uniqueId}`, +}; + +export interface DrawerProps { + open: boolean; + onClose: () => void; + title?: ReactNode; + description?: ReactNode; + /** Visual eyebrow above the title (e.g. entity type name). */ + eyebrow?: ReactNode; + /** Sticky footer slot. */ + footer?: ReactNode; + /** Width of the slide-in panel. Defaults to 480px. */ + width?: number | string; + /** Closes the drawer when the backdrop is clicked. Defaults to true. */ + closeOnBackdropClick?: boolean; + className?: string; + children?: ReactNode; +} + +const backdropVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +}; + +const panelVariants = { + hidden: { x: 24, opacity: 0 }, + visible: { x: 0, opacity: 1 }, +}; + +export function Drawer({ + open, + onClose, + title, + description, + eyebrow, + footer, + width = 480, + closeOnBackdropClick = true, + className, + children, +}: DrawerProps) { + const panelRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + panelRef.current?.focus(); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + return createPortal( + + {open && ( + { + if (closeOnBackdropClick && event.target === event.currentTarget) { + onClose(); + } + }} + className="fixed inset-0 z-[150] flex items-end justify-end bg-ink/35" + variants={backdropVariants} + initial="hidden" + animate="visible" + exit="hidden" + transition={{ duration: 0.16, ease: "easeOut" }} + > + + {(title || eyebrow || description) && ( +
+ {eyebrow && ( +
+ {eyebrow} +
+ )} + {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ )} +
+ {children} +
+ {footer && ( +
+ {footer} +
+ )} +
+
+ )} +
, + document.body, + ); +} diff --git a/app/src/components/EmptyState/EmptyState.stories.tsx b/app/src/components/EmptyState/EmptyState.stories.tsx new file mode 100644 index 0000000..48c1aa3 --- /dev/null +++ b/app/src/components/EmptyState/EmptyState.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { EmptyState } from "./EmptyState"; +import { Button } from "@/components"; + +const meta: Meta = { + title: "Primitives/EmptyState", + component: EmptyState, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: "history", + title: "No classifications yet", + description: "Run your first classification to see it here.", + action: ( + + ), + }, +}; + +export const Minimal: Story = { + args: { title: "Nothing matches your filters" }, +}; diff --git a/app/src/components/EmptyState/EmptyState.test.tsx b/app/src/components/EmptyState/EmptyState.test.tsx new file mode 100644 index 0000000..dc356c3 --- /dev/null +++ b/app/src/components/EmptyState/EmptyState.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { EmptyState, DATA_TEST_ID } from "./EmptyState"; + +describe("EmptyState", () => { + it("renders title and optional description and action", () => { + // GIVEN title, description, and action content + const givenEmptyStateTitle = "No keys yet"; + const givenEmptyStateDescription = "Create one above."; + const givenEmptyStateActionLabel = "create-cta"; + + // WHEN we render an EmptyState with all three slots + render( + {givenEmptyStateActionLabel}} + />, + ); + + // THEN each slot carries the given content + expect(screen.getByTestId(DATA_TEST_ID.TITLE)).toHaveTextContent( + givenEmptyStateTitle, + ); + expect(screen.getByTestId(DATA_TEST_ID.DESCRIPTION)).toHaveTextContent( + givenEmptyStateDescription, + ); + expect(screen.getByTestId(DATA_TEST_ID.ACTION)).toHaveTextContent( + givenEmptyStateActionLabel, + ); + }); + + it("renders without a description or action", () => { + // GIVEN an EmptyState with just a title + const givenEmptyStateTitle = "Nothing here"; + + // WHEN we render it + render(); + + // THEN only the title is visible and the optional slots are absent + expect(screen.getByTestId(DATA_TEST_ID.TITLE)).toHaveTextContent( + givenEmptyStateTitle, + ); + expect(screen.queryByTestId(DATA_TEST_ID.DESCRIPTION)).not.toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.ACTION)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/EmptyState/EmptyState.tsx b/app/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..4897e39 --- /dev/null +++ b/app/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; +import { Icon, type IconName } from "@/components"; + +const uniqueId = "a9bb5fab-439b-43c0-be79-70a2e2ad06ce"; + +export const DATA_TEST_ID = { + CONTAINER: `empty-state-container-${uniqueId}`, + ICON_WRAP: `empty-state-icon-wrap-${uniqueId}`, + TITLE: `empty-state-title-${uniqueId}`, + DESCRIPTION: `empty-state-description-${uniqueId}`, + ACTION: `empty-state-action-${uniqueId}`, +}; + +export interface EmptyStateProps { + icon?: IconName; + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + className?: string; +} + +export function EmptyState({ + icon, + title, + description, + action, + className, +}: EmptyStateProps) { + return ( +
+ {icon && ( +
+ +
+ )} +
+ {title} +
+ {description && ( +

+ {description} +

+ )} + {action && ( +
+ {action} +
+ )} +
+ ); +} diff --git a/app/src/components/Eyebrow/Eyebrow.stories.tsx b/app/src/components/Eyebrow/Eyebrow.stories.tsx new file mode 100644 index 0000000..8682b47 --- /dev/null +++ b/app/src/components/Eyebrow/Eyebrow.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Eyebrow } from "./Eyebrow"; + +const meta: Meta = { + title: "Primitives/Eyebrow", + component: Eyebrow, + parameters: { layout: "padded" }, + args: { children: "Workspace · Classifier" }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const AboveHeading: Story = { + render: () => ( +
+ Pipeline · Stage 01 +

Named Entity Recognition

+
+ ), +}; diff --git a/app/src/components/Eyebrow/Eyebrow.test.tsx b/app/src/components/Eyebrow/Eyebrow.test.tsx new file mode 100644 index 0000000..fcd21be --- /dev/null +++ b/app/src/components/Eyebrow/Eyebrow.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Eyebrow, DATA_TEST_ID } from "./Eyebrow"; + +describe("Eyebrow", () => { + it("renders children inside an .eyebrow container", () => { + // GIVEN an expected eyebrow text + const givenEyebrowText = "Settings · Pipeline"; + + // WHEN we render an Eyebrow with that text + render({givenEyebrowText}); + + // THEN the eyebrow container is in the DOM, carries the given text, and the editorial class + const eyebrowNode = screen.getByTestId(DATA_TEST_ID.CONTAINER); + expect(eyebrowNode).toBeInTheDocument(); + expect(eyebrowNode).toHaveTextContent(givenEyebrowText); + expect(eyebrowNode.className).toMatch(/eyebrow/); + }); +}); diff --git a/app/src/components/Eyebrow/Eyebrow.tsx b/app/src/components/Eyebrow/Eyebrow.tsx new file mode 100644 index 0000000..4590738 --- /dev/null +++ b/app/src/components/Eyebrow/Eyebrow.tsx @@ -0,0 +1,23 @@ +import type { HTMLAttributes } from "react"; +import { mergeClassNames } from "@/lib/mergeClassNames.ts"; + +const uniqueId = "3c4d9a1b-7e62-4a85-9f0c-2d8b6e7a4310"; + +export const DATA_TEST_ID = { + CONTAINER: `eyebrow-container-${uniqueId}`, +}; + +export type EyebrowProps = HTMLAttributes; + +/** Mono uppercase micro-label that sits above a heading. */ +export function Eyebrow({ className, children, ...rest }: EyebrowProps) { + return ( +
+ {children} +
+ ); +} diff --git a/app/src/components/FormField/FormField.stories.tsx b/app/src/components/FormField/FormField.stories.tsx new file mode 100644 index 0000000..f007361 --- /dev/null +++ b/app/src/components/FormField/FormField.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; +import { FormField } from "./FormField"; +import { Input } from "@/components"; +import { Textarea } from "@/components"; + +const meta: Meta = { + title: "Primitives/FormField", + component: FormField, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + ), +}; + +export const Required: Story = { + render: () => ( + + + + ), +}; + +export const WithError: Story = { + render: () => ( + + + + ), +}; + +export const Textareaish: Story = { + render: () => ( + +