diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f54345e6..2222ef4f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,9 +2,9 @@ name: Frontend checks on: pull_request: - branches: [main, dev] + branches: [main, dev, staging] push: - branches: [main, dev] + branches: [main, dev, staging] workflow_dispatch: {} permissions: @@ -32,18 +32,35 @@ jobs: # push-> the previous commit on the branch (github.event.before) # manual -> dev, for exact-head recovery runs runs-on: ubuntu-latest - env: - GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'origin/dev' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + - name: Resolve governance comparison ref + id: governance-ref + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_BEFORE: ${{ github.event.before }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + base="${PR_BASE_SHA}" + elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then + base="${EVENT_BEFORE}" + else + base="origin/dev" + fi + printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: npm - run: npm ci - run: npm run verify + env: + GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} visual-regression: name: Visual regression diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..eed6460e --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,140 @@ +name: Deploy staging + +# Standalone staging deployment (push to `staging` / manual dispatch), NOT a +# caller of the org reusable `cd-cdk.yaml` (that path is dev-only): staging +# trusts the exact GitHub-environment OIDC subject, which requires the deploy +# job to declare `environment: staging` and run in this repo, with the +# non-secret role ARN pinned below (created by the staging stack itself). +# +# Order is fixed: full `npm run verify` gates run BEFORE any deploy step. +# No secrets are used — OIDC + the static role ARN are the only credentials. + +on: + push: + branches: [staging] + workflow_dispatch: {} + +permissions: + id-token: write + contents: read + +concurrency: + group: deploy-staging + cancel-in-progress: false + +jobs: + deploy: + name: Deploy to staging + # Deploy only the exact staging branch ref, never a tag or other ref + # (workflow_dispatch can be invoked from arbitrary refs). + if: github.ref == 'refs/heads/staging' + runs-on: ubuntu-latest + environment: staging + env: + VITE_API_URL: https://api.staging.seahaven.com/api + AWS_REGION: us-east-1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Resolve governance comparison ref + id: governance-ref + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + if [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then + base="${EVENT_BEFORE}" + else + base="origin/dev" + fi + printf 'base=%s\n' "${base}" >> "${GITHUB_OUTPUT}" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + # Node 24 bundles npm 11 (lockfileVersion 3); the packageManager pin + # (npm@11.16.0) matches this CI environment. + - name: Quality gates (full verify before any deploy) + run: npm ci && npm run verify + env: + GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }} + + - name: Assume staging deploy role (OIDC) + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging + aws-region: us-east-1 + + # Builds the SPA with the staging VITE_API_URL (process env overrides the + # dev value committed in .env.production), syncs to the staging bucket, + # and invalidates CloudFront. + - name: Build and publish SPA + run: bash scripts/deploy-web.sh + env: + STACK_NAME: shoc-frontend-staging + WAIT_FOR_INVALIDATION: "true" + + - name: Verify deployment + run: | + set -euo pipefail + stack_output() { + aws cloudformation describe-stacks \ + --stack-name shoc-frontend-staging \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" \ + --output text + } + BUCKET="$(stack_output BucketName)" + DIST_ID="$(stack_output DistributionId)" + DIST_DOMAIN="$(stack_output DistributionDomainName)" + SITE_URL="$(stack_output SiteUrl)" + if [[ -z "${BUCKET}" || "${BUCKET}" == "None" || -z "${DIST_ID}" || "${DIST_ID}" == "None" || -z "${DIST_DOMAIN}" || "${DIST_DOMAIN}" == "None" ]]; then + echo "::error::Could not resolve bucket/distribution from stack outputs." >&2 + exit 1 + fi + echo "Bucket=${BUCKET} Distribution=${DIST_ID} (${DIST_DOMAIN}) SiteUrl=${SITE_URL}" + + aws s3api head-bucket --bucket "${BUCKET}" + echo "Bucket exists." + # The distribution is proven to exist and serve by the HTTPS check + # below: the custom domain is an alias to this distribution, and the + # deploy role deliberately carries no cloudfront:GetDistribution + # (least privilege; the dev template is shared and must not drift). + + if grep -Rq "api.dev.seahaven.com" dist/; then + echo "::error::Built assets contain the dev API URL (api.dev.seahaven.com)." >&2 + grep -Rl "api.dev.seahaven.com" dist/ >&2 || true + exit 1 + fi + echo "Built assets carry no dev API URL." + grep -Rq "api.staging.seahaven.com" dist/ + echo "Built assets reference the staging API URL." + + # Verify the actual post-invalidation HTML and its referenced assets, + # not only the local build or a generic endpoint response. + remote_dir="$(mktemp -d)" + trap 'rm -rf "${remote_dir}"' EXIT + for i in 1 2 3 4 5 6; do + if curl -fsS --max-time 30 "${SITE_URL}" -o "${remote_dir}/index.html"; then + break + fi + echo "Endpoint not ready (attempt ${i}); retrying in 20s..." + sleep 20 + done + test -s "${remote_dir}/index.html" + grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${remote_dir}/index.html" \ + | sed -E 's/^(src|href)="([^"]+)"$/\2/' \ + | sort -u > "${remote_dir}/asset-paths.txt" + test -s "${remote_dir}/asset-paths.txt" + while IFS= read -r asset_path; do + curl -fsS --max-time 30 "${SITE_URL%/}${asset_path}" \ + >> "${remote_dir}/assets.txt" + done < "${remote_dir}/asset-paths.txt" + if grep -q "api.dev.seahaven.com" "${remote_dir}/assets.txt"; then + echo "::error::Deployed assets contain the dev API URL." >&2 + exit 1 + fi + grep -q "api.staging.seahaven.com" "${remote_dir}/assets.txt" + echo "Deployed staging assets reference only the staging API URL." diff --git a/e2e/vendors/vendors.spec.ts b/e2e/vendors/vendors.spec.ts index 8c63f029..e8f54bef 100644 --- a/e2e/vendors/vendors.spec.ts +++ b/e2e/vendors/vendors.spec.ts @@ -61,6 +61,16 @@ const inactiveVendors = [ }, ]; +const LONG_TEXT_VENDOR = { + CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis", + ContactName: "Adam Christopher Whyte Senior Field Technician", + Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example", + Phone: "+1 (314) 555-0198 extension 123456", + Address: "New York, NY, Estados Unidos, 123 Long Address Avenue", + City: "", + State: "", +}; + interface MockState { listUrls: string[]; createdBody?: Record; @@ -88,10 +98,12 @@ async function mockVendorApi( deactivationBlocked?: boolean; deleteConflict?: boolean; legacyGateway?: boolean; + longText?: boolean; } = {}, ) { const state: MockState = { listUrls: [] }; const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor })); + if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR); if (options.legacyGateway) { vendorRecords[0].Phone = "555-1234 ext 9"; vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy"; @@ -437,6 +449,47 @@ test.describe("Vendor directory prototype parity", () => { await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); + test("truncates every non-abbreviated textual column", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await mockVendorApi(page, { longText: true }); + await page.goto("/vendors"); + + const row = page.locator("tbody tr").first(); + const fields = [ + { cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName }, + { cellIndex: 1, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName }, + { cellIndex: 2, testId: "vendor-text-location", value: LONG_TEXT_VENDOR.Address }, + { cellIndex: 4, testId: "vendor-text-phone", value: LONG_TEXT_VENDOR.Phone }, + { cellIndex: 5, testId: "vendor-text-email", value: LONG_TEXT_VENDOR.Email }, + ]; + + for (const expected of fields) { + const cell = row.locator("td").nth(expected.cellIndex); + const field = cell.getByTestId(expected.testId); + await expect(field).toBeVisible(); + + const metrics = await field.evaluate((element) => { + const fieldRect = element.getBoundingClientRect(); + const cellRect = element.closest("td")?.getBoundingClientRect(); + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + withinCell: + cellRect !== undefined && + fieldRect.left >= cellRect.left - 1 && + fieldRect.right <= cellRect.right + 1, + }; + }); + expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth); + expect(metrics.withinCell).toBe(true); + + await field.hover(); + await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toHaveCount(0); + } + }); + test("adds a technician to an existing company, then views and edits the aggregate", async ({ page, }) => { diff --git a/infra/cdk/README.md b/infra/cdk/README.md index e876a0c7..364780af 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -17,7 +17,8 @@ deployed through the org's **reusable** GitHub Actions workflow. (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). - **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is created by this stack, not added to the central `oidc-deploy-roles.yaml`. -- **Environments:** `dev` only today, deployed on push to the `dev` branch. +- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and + `staging` (push to `staging`, via the standalone `deploy-staging.yml`). ``` infra/cdk/ @@ -25,8 +26,9 @@ infra/cdk/ lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation .github/workflows/ - ci.yml quality gates (lint / build / test / e2e) + ci.yaml quality gates (lint / build / test / e2e) deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) + deploy-staging.yml standalone staging deploy (push to staging) ``` ## What the stack creates @@ -137,25 +139,83 @@ the `SiteUrl` output. --- -## Adding staging / prod later +## Staging environment (same account, exact OIDC subject) -Separate accounts: deploy this stack there with per-env `domainNames`, -`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's -`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`. +Staging lives in the same AWS account (396287094661) but deploys through its +own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org +reusable `cd-cdk.yaml`: -Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is -baked into `vite build`** — so each environment needs its own build with its own -API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment -in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than -relying on the committed `.env.production` (which carries the dev value). The -backend must also allow CORS from each frontend origin. +- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role + (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub + environment subject + `repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging` + (`StringEquals` on both `aud` and `sub`). The workflow declares + `environment: staging`, so only runs in that environment can assume the role. + Without `githubEnvironment`, the dev stack keeps its branch-ref trust + unchanged. +- **No secret:** the role ARN is static (the role name is deterministic), so + the workflow pins + `arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging` + directly — no `AWS_DEPLOY_ROLE_ARN`-style secret to set. +- **Gates first:** the workflow runs the full `npm run verify` before assuming + the staging role, then runs `scripts/deploy-web.sh` with + `STACK_NAME=shoc-frontend-staging`, + `VITE_API_URL=https://api.staging.seahaven.com/api`, and waits for the + CloudFront invalidation to complete. +- **Application-only role:** the recurring staging workflow can describe only + its exact stack, publish only to its exact bucket, and invalidate only its + exact distribution. It cannot assume the shared CDK bootstrap roles or + modify infrastructure. Staging infrastructure changes use the Administrator + command below. +- **Post-deploy checks:** bucket + distribution existence, HTTPS on + `https://staging.seahaven.com`, and the actual post-invalidation remote assets + contain the staging API URL and no dev API URL. (Not browser QA.) + +### One-time setup (run by a human with admin AWS creds + GitHub Admin) + +1. **GitHub Admin — create the `staging` environment** (Settings → + Environments → New environment → `staging`). Add protection rules as + appropriate (e.g. required reviewers, restrict to the `staging` branch). If + the environment does not exist, GitHub creates it unprotected on first use. +2. **AWS Admin — first deploy with admin creds** (same steps 1–3 as dev; the + OIDC provider and bootstrap already exist in this account): + + ```bash + cd infra/cdk + npx cdk deploy shoc-frontend-staging \ + -c envName=staging \ + -c deployBranch=staging \ + -c githubEnvironment=staging \ + -c domainNames=staging.seahaven.com \ + -c certificateArn=arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00 \ + -c hostedZoneId=Z02602739VQWBWCAGXP4 \ + -c hostedZoneName=staging.seahaven.com + ``` + + The `DeployRoleArn` output must match the ARN pinned in + `deploy-staging.yml` (it will — the role name is deterministic). + +3. **Backend CORS:** the staging API (`https://api.staging.seahaven.com`) must + allow the `https://staging.seahaven.com` origin. +4. Push to `staging` — `ci.yaml` runs the quality gates and + `deploy-staging.yml` deploys. + +### Adding prod later + +Same pattern: a prod account/stack with its own contexts and, ideally, its own +`githubEnvironment=prod` trust + workflow. Keep in mind `VITE_API_URL` is baked +into each environment's build, and the bucket's `RemovalPolicy.DESTROY` + +`autoDeleteObjects` defaults are dev/staging-friendly but should be revisited +for prod. ## Notes - **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. -- **CI and CD both fire on push to `dev`** in parallel; a red-CI commit still - deploys (matches the org's push-time-CD model). Gating deploy on CI is a - follow-up, not part of enabling CICD. +- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging + differs only in that its CD workflow also runs `npm run verify` itself + before deploying); a red-CI commit still deploys on `dev` (matches the + org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part + of enabling CICD. - **npm is pinned to v11.16.0**; the committed `package-lock.json` uses lockfileVersion 3, matching the Node 24 / npm 11 CI environment. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 9ef86c8e..876463fe 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -8,6 +8,10 @@ const app = new App(); const envName = app.node.tryGetContext("envName") ?? "dev"; const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new"; const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev"; +// When set (e.g. "staging"), the deploy role trusts the exact GitHub +// environment OIDC subject instead of a deploy-branch ref. Empty = dev-style +// branch-ref trust. +const githubEnvironment = app.node.tryGetContext("githubEnvironment") ?? ""; // Custom domain. Comma-separated, e.g. -c domainNames=dev.seahaven.com // The ACM cert MUST be in us-east-1 in the SAME account this stack deploys to. @@ -21,10 +25,17 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; +// Staging and beyond protect their stacks from accidental deletion; dev +// stays teardown-friendly (its artifacts are reproducible). CDK applies this +// at deploy time — it is not part of the synthesized template. +const terminationProtection = envName !== "dev"; + const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { envName, githubRepo, deployBranch, + githubEnvironment, + terminationProtection, domainNames, certificateArn, hostedZoneId, diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index be7a4681..dda5f67e 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -15,6 +15,13 @@ export interface FrontendStackProps extends StackProps { readonly githubRepo: string; /** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */ readonly deployBranch: string; + /** + * GitHub Actions environment name (e.g. "staging"). When set, the OIDC + * trust uses the EXACT environment subject + * `repo::environment:` (StringEquals) instead of the + * deploy-branch ref match below. Unset = dev-style branch-ref trust. + */ + readonly githubEnvironment?: string; /** * Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"]. * Empty = serve on the default *.cloudfront.net domain. @@ -57,6 +64,7 @@ export class FrontendStack extends Stack { envName, githubRepo, deployBranch, + githubEnvironment = "", domainNames, certificateArn, hostedZoneId, @@ -147,35 +155,52 @@ export class FrontendStack extends Stack { `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`, ); + // Trust conditions for the OIDC principal. With a GitHub environment + // (staging): exact StringEquals match on both aud and the environment + // subject — the staging workflow declares `environment: staging`, so only + // runs in that environment can assume the role. Without one (dev): keep + // the branch-ref trust, where StringLike scopes `sub` to pushes on the + // deploy branch (reusable-workflow runs still carry the caller-based sub). + const oidcConditions = githubEnvironment + ? { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, + }, + } + : { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + // Tightly scoped: only pushes to this repo's deploy branch. For a + // reusable-workflow run the OIDC `sub` is still caller-based, so this + // matches even though the deploy job lives in the `.github` repo. + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + }; + const deployRole = new iam.Role(this, "GithubDeployRole", { roleName: `githubdeploy-shoc-frontend-new-${envName}`, description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, maxSessionDuration: Duration.hours(1), - assumedBy: new iam.OpenIdConnectPrincipal(provider, { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - }, - StringLike: { - // Tightly scoped: only pushes to this repo's deploy branch. For a - // reusable-workflow run the OIDC `sub` is still caller-based, so this - // matches even though the deploy job lives in the `.github` repo. - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, - }, - }), + assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions), }); - // The whole `cd-cdk.yaml` job runs as this role. Permissions it needs: - // 1. assume the CDK bootstrap roles -> `cdk deploy` - // 2. describe the stack -> cd-cdk pre-flight / health-check / output reads - // 3. read/write the bucket -> post-deploy `aws s3 sync` - // 4. invalidate the distribution -> post-deploy cache bust - deployRole.addToPolicy( - new iam.PolicyStatement({ - sid: "AssumeCdkBootstrapRoles", - actions: ["sts:AssumeRole"], - resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], - }), - ); + // Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is + // intentionally narrower: its recurring promotion workflow only publishes + // application assets to this stack's bucket/distribution. Infrastructure + // changes remain an administrator-run CDK operation, so the staging OIDC + // role cannot inherit the bootstrap roles' account-wide deployment power. + if (!githubEnvironment) { + deployRole.addToPolicy( + new iam.PolicyStatement({ + sid: "AssumeCdkBootstrapRoles", + actions: ["sts:AssumeRole"], + resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`], + }), + ); + } deployRole.addToPolicy( new iam.PolicyStatement({ sid: "DescribeStack", diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 12c1b021..59b302ef 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -13,8 +13,9 @@ set -euo pipefail STACK_NAME="${STACK_NAME:-shoc-frontend-dev}" REGION="${AWS_REGION:-us-east-1}" +WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}" -echo "Building SPA (VITE_API_URL comes from .env.production)..." +echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..." npm ci npm run build @@ -48,8 +49,17 @@ aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \ --content-type "text/html" echo "Invalidating CloudFront ${DIST_ID}..." -aws cloudfront create-invalidation \ +INVALIDATION_ID="$(aws cloudfront create-invalidation \ --distribution-id "${DIST_ID}" \ - --paths "/*" + --paths "/*" \ + --query 'Invalidation.Id' \ + --output text)" + +if [[ "${WAIT_FOR_INVALIDATION}" == "true" ]]; then + echo "Waiting for CloudFront invalidation ${INVALIDATION_ID}..." + aws cloudfront wait invalidation-completed \ + --distribution-id "${DIST_ID}" \ + --id "${INVALIDATION_ID}" +fi echo "Web deploy complete." diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index a680c78b..e2651e39 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -51,6 +51,78 @@ function stopPropagation(event: MouseEvent): void { event.stopPropagation(); } +interface TruncatedTextProps { + value: string; + testId: string; + focusable?: boolean; + href?: string; + color?: string; + variant?: "description"; +} + +const truncatedTextSx = { + display: "block", + minWidth: 0, + maxWidth: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}; + +function TruncatedText({ + value, + testId, + focusable = false, + href, + color = "inherit", + variant, +}: TruncatedTextProps) { + const hasValue = value !== "—"; + const content = href ? ( + + {value} + + ) : variant ? ( + + {value} + + ) : ( + + {value} + + ); + + return ( + + {content} + + ); +} + +function getVendorDisplayValues(row: VendorListItem) { + const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", "); + return { + locationText: cityStateText || row.address.trim() || "—", + phoneText: row.phone.trim() || row.companyPhone.trim(), + }; +} + interface VendorTableRowProps { row: VendorListItem; onOpenDetail: (row: VendorListItem) => void; @@ -61,9 +133,7 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[0] ?? "—"; const extraTrades = trades.length > 1 ? trades.length - 1 : 0; - const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", "); - const locationText = cityStateText || row.address.trim() || "—"; - const phoneText = row.phone.trim() || row.companyPhone.trim(); + const { locationText, phoneText } = getVendorDisplayValues(row); const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`); const handleKeyDown = (event: KeyboardEvent) => { @@ -84,17 +154,25 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) onClick={() => onOpenDetail(row)} onKeyDown={handleKeyDown} > - - - {row.companyName || "—"} - + + - - + + {getInitials(row.contactName)} - - {row.contactName || "—"} - + - {locationText} + + + {primaryTrade === "—" ? ( "—" @@ -133,33 +221,21 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) )} - - {phoneText ? ( - - {phoneText} - - ) : ( - "—" - )} + + - - {row.email ? ( - - {row.email} - - ) : ( - "—" - )} + + {row.totalJobs ?? 0} diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index 8d51d3cf..b3572218 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -1,4 +1,4 @@ -import { screen, within } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import VendorsListPage from "@/app/(protected)/vendors/index"; @@ -181,6 +181,69 @@ describe("VendorsListPage", () => { expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument(); }); + it("truncates every textual table field and reveals each full value", async () => { + setupDefaults(); + const longCompany = "Gateway Plumbing and Water Systems of Greater Saint Louis"; + const longTechnician = "Adam Christopher Whyte Senior Field Technician"; + const longAddress = "New York, NY, Estados Unidos, 123 Long Address Avenue"; + const longPhone = "+1 (314) 555-0198 extension 123456"; + const longEmail = "dispatch-and-after-hours-coordination@gatewayplumbing.example"; + const fallbackVendor: VendorListItem = { + ...activeVendor, + id: 4, + companyName: longCompany, + contactName: longTechnician, + phone: longPhone, + email: longEmail, + address: longAddress, + city: "", + state: "", + }; + useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => { + if (params.pageSize === 1) return result([], 1); + return params.isActive ? result([fallbackVendor], 1) : result([], 0); + }); + + renderWithProviders(, { route: "/vendors", withAuth: false }); + + const fields = [ + { testId: "vendor-text-company", value: longCompany }, + { testId: "vendor-text-technician", value: longTechnician }, + { testId: "vendor-text-location", value: longAddress }, + { testId: "vendor-text-phone", value: longPhone }, + { testId: "vendor-text-email", value: longEmail }, + ]; + + for (const field of fields) { + const text = screen.getByTestId(field.testId); + const cell = text.closest("td"); + expect(cell).not.toBeNull(); + expect(cell).toHaveStyle({ overflow: "hidden" }); + expect(text).toHaveStyle({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }); + + await userEvent.hover(text); + expect(await screen.findByRole("tooltip")).toHaveTextContent(field.value); + await userEvent.unhover(text); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + } + + expect(screen.getByTestId("vendor-text-phone")).toHaveAttribute("href", `tel:${longPhone}`); + expect(screen.getByTestId("vendor-text-email")).toHaveAttribute("href", `mailto:${longEmail}`); + + const locationText = screen.getByTestId("vendor-text-location"); + fireEvent.keyDown(document, { key: "Tab" }); + locationText.focus(); + expect(locationText).toHaveFocus(); + expect(await screen.findByRole("tooltip")).toHaveTextContent(longAddress); + expect(within(screen.getByRole("cell", { name: longAddress })).getByText(longAddress)).toBe( + locationText, + ); + }); + it("reports the filtered totalCount in the footer when page rows are fewer than total", () => { setupDefaults(); const pageVendors = Array.from({ length: 3 }, (_, index) => ({