mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 19:32:06 +00:00
hotfix(staging): deploy vendor table overflow fix (#155)
* ci: add protected staging frontend deployment lane (#151) * ci: add protected staging deployment lane * fix: constrain staging publisher permissions * fix: handle first-push governance baseline --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid> * fix(vendors): truncate long textual table values (#154) * fix(vendors): truncate long locations in table * fix(vendors): truncate textual table cells --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid> --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
This commit is contained in:
parent
40ce66fbb3
commit
fcf2ce185f
9 changed files with 540 additions and 85 deletions
25
.github/workflows/ci.yaml
vendored
25
.github/workflows/ci.yaml
vendored
|
|
@ -2,9 +2,9 @@ name: Frontend checks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev, staging]
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [main, dev, staging]
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -32,18 +32,35 @@ jobs:
|
||||||
# push-> the previous commit on the branch (github.event.before)
|
# push-> the previous commit on the branch (github.event.before)
|
||||||
# manual -> dev, for exact-head recovery runs
|
# manual -> dev, for exact-head recovery runs
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
|
env:
|
||||||
|
GOVERNANCE_BASE: ${{ steps.governance-ref.outputs.base }}
|
||||||
|
|
||||||
visual-regression:
|
visual-regression:
|
||||||
name: Visual regression
|
name: Visual regression
|
||||||
|
|
|
||||||
140
.github/workflows/deploy-staging.yml
vendored
Normal file
140
.github/workflows/deploy-staging.yml
vendored
Normal file
|
|
@ -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."
|
||||||
53
e2e/vendors/vendors.spec.ts
vendored
53
e2e/vendors/vendors.spec.ts
vendored
|
|
@ -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 {
|
interface MockState {
|
||||||
listUrls: string[];
|
listUrls: string[];
|
||||||
createdBody?: Record<string, unknown>;
|
createdBody?: Record<string, unknown>;
|
||||||
|
|
@ -88,10 +98,12 @@ async function mockVendorApi(
|
||||||
deactivationBlocked?: boolean;
|
deactivationBlocked?: boolean;
|
||||||
deleteConflict?: boolean;
|
deleteConflict?: boolean;
|
||||||
legacyGateway?: boolean;
|
legacyGateway?: boolean;
|
||||||
|
longText?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const state: MockState = { listUrls: [] };
|
const state: MockState = { listUrls: [] };
|
||||||
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
|
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
|
||||||
|
if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_VENDOR);
|
||||||
if (options.legacyGateway) {
|
if (options.legacyGateway) {
|
||||||
vendorRecords[0].Phone = "555-1234 ext 9";
|
vendorRecords[0].Phone = "555-1234 ext 9";
|
||||||
vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy";
|
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);
|
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 ({
|
test("adds a technician to an existing company, then views and edits the aggregate", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ deployed through the org's **reusable** GitHub Actions workflow.
|
||||||
(provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA).
|
(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
|
- **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`.
|
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/
|
infra/cdk/
|
||||||
|
|
@ -25,8 +26,9 @@ infra/cdk/
|
||||||
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role
|
||||||
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation
|
||||||
.github/workflows/
|
.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.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
|
## 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`,
|
Staging lives in the same AWS account (396287094661) but deploys through its
|
||||||
`certificateArn`, `hostedZoneId`/`hostedZoneName` context; set that repo's
|
own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org
|
||||||
`AWS_DEPLOY_ROLE_ARN` secret; and add a job to `deploy.yml`.
|
reusable `cd-cdk.yaml`:
|
||||||
|
|
||||||
Because the SPA calls the API directly at an absolute URL, **`VITE_API_URL` is
|
- **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role
|
||||||
baked into `vite build`** — so each environment needs its own build with its own
|
(`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub
|
||||||
API host (e.g. `https://api.staging.seahaven.com/api`). Set it per environment
|
environment subject
|
||||||
in the deploy job (e.g. export `VITE_API_URL` before the build step) rather than
|
`repo:Sea-Haven-Industries/shoc-frontend-new:environment:staging`
|
||||||
relying on the committed `.env.production` (which carries the dev value). The
|
(`StringEquals` on both `aud` and `sub`). The workflow declares
|
||||||
backend must also allow CORS from each frontend origin.
|
`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
|
## Notes
|
||||||
|
|
||||||
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
- **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` +
|
||||||
`autoDeleteObjects` (dev artifacts are reproducible) — change this for prod.
|
`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
|
- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging
|
||||||
deploys (matches the org's push-time-CD model). Gating deploy on CI is a
|
differs only in that its CD workflow also runs `npm run verify` itself
|
||||||
follow-up, not part of enabling CICD.
|
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
|
- **npm is pinned to v11.16.0**; the committed `package-lock.json` uses
|
||||||
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
lockfileVersion 3, matching the Node 24 / npm 11 CI environment.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ const app = new App();
|
||||||
const envName = app.node.tryGetContext("envName") ?? "dev";
|
const envName = app.node.tryGetContext("envName") ?? "dev";
|
||||||
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
const githubRepo = app.node.tryGetContext("githubRepo") ?? "Sea-Haven-Industries/shoc-frontend-new";
|
||||||
const deployBranch = app.node.tryGetContext("deployBranch") ?? "dev";
|
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
|
// 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.
|
// 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 hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? "";
|
||||||
const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? "";
|
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}`, {
|
const stack = new FrontendStack(app, `shoc-frontend-${envName}`, {
|
||||||
envName,
|
envName,
|
||||||
githubRepo,
|
githubRepo,
|
||||||
deployBranch,
|
deployBranch,
|
||||||
|
githubEnvironment,
|
||||||
|
terminationProtection,
|
||||||
domainNames,
|
domainNames,
|
||||||
certificateArn,
|
certificateArn,
|
||||||
hostedZoneId,
|
hostedZoneId,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ export interface FrontendStackProps extends StackProps {
|
||||||
readonly githubRepo: string;
|
readonly githubRepo: string;
|
||||||
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
||||||
readonly deployBranch: string;
|
readonly deployBranch: string;
|
||||||
|
/**
|
||||||
|
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
|
||||||
|
* trust uses the EXACT environment subject
|
||||||
|
* `repo:<owner/name>:environment:<env>` (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"].
|
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
|
||||||
* Empty = serve on the default *.cloudfront.net domain.
|
* Empty = serve on the default *.cloudfront.net domain.
|
||||||
|
|
@ -57,6 +64,7 @@ export class FrontendStack extends Stack {
|
||||||
envName,
|
envName,
|
||||||
githubRepo,
|
githubRepo,
|
||||||
deployBranch,
|
deployBranch,
|
||||||
|
githubEnvironment = "",
|
||||||
domainNames,
|
domainNames,
|
||||||
certificateArn,
|
certificateArn,
|
||||||
hostedZoneId,
|
hostedZoneId,
|
||||||
|
|
@ -147,35 +155,52 @@ export class FrontendStack extends Stack {
|
||||||
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
|
`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", {
|
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
||||||
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
||||||
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
||||||
maxSessionDuration: Duration.hours(1),
|
maxSessionDuration: Duration.hours(1),
|
||||||
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
||||||
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}`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The whole `cd-cdk.yaml` job runs as this role. Permissions it needs:
|
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
||||||
// 1. assume the CDK bootstrap roles -> `cdk deploy`
|
// intentionally narrower: its recurring promotion workflow only publishes
|
||||||
// 2. describe the stack -> cd-cdk pre-flight / health-check / output reads
|
// application assets to this stack's bucket/distribution. Infrastructure
|
||||||
// 3. read/write the bucket -> post-deploy `aws s3 sync`
|
// changes remain an administrator-run CDK operation, so the staging OIDC
|
||||||
// 4. invalidate the distribution -> post-deploy cache bust
|
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
||||||
deployRole.addToPolicy(
|
if (!githubEnvironment) {
|
||||||
new iam.PolicyStatement({
|
deployRole.addToPolicy(
|
||||||
sid: "AssumeCdkBootstrapRoles",
|
new iam.PolicyStatement({
|
||||||
actions: ["sts:AssumeRole"],
|
sid: "AssumeCdkBootstrapRoles",
|
||||||
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
|
actions: ["sts:AssumeRole"],
|
||||||
}),
|
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
deployRole.addToPolicy(
|
deployRole.addToPolicy(
|
||||||
new iam.PolicyStatement({
|
new iam.PolicyStatement({
|
||||||
sid: "DescribeStack",
|
sid: "DescribeStack",
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ set -euo pipefail
|
||||||
|
|
||||||
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
STACK_NAME="${STACK_NAME:-shoc-frontend-dev}"
|
||||||
REGION="${AWS_REGION:-us-east-1}"
|
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 ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
|
@ -48,8 +49,17 @@ aws s3 cp dist/index.html "s3://${BUCKET}/index.html" \
|
||||||
--content-type "text/html"
|
--content-type "text/html"
|
||||||
|
|
||||||
echo "Invalidating CloudFront ${DIST_ID}..."
|
echo "Invalidating CloudFront ${DIST_ID}..."
|
||||||
aws cloudfront create-invalidation \
|
INVALIDATION_ID="$(aws cloudfront create-invalidation \
|
||||||
--distribution-id "${DIST_ID}" \
|
--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."
|
echo "Web deploy complete."
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,78 @@ function stopPropagation(event: MouseEvent): void {
|
||||||
event.stopPropagation();
|
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 ? (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
underline="hover"
|
||||||
|
color="inherit"
|
||||||
|
data-testid={testId}
|
||||||
|
sx={{ ...truncatedTextSx, color }}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Link>
|
||||||
|
) : variant ? (
|
||||||
|
<Text
|
||||||
|
variant={variant}
|
||||||
|
data-testid={testId}
|
||||||
|
sx={{ ...truncatedTextSx, color, flex: "1 1 auto" }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
data-testid={testId}
|
||||||
|
tabIndex={focusable && hasValue ? 0 : undefined}
|
||||||
|
sx={{ ...truncatedTextSx, color }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={hasValue ? value : ""} placement="top" arrow describeChild>
|
||||||
|
{content}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
interface VendorTableRowProps {
|
||||||
row: VendorListItem;
|
row: VendorListItem;
|
||||||
onOpenDetail: (row: VendorListItem) => void;
|
onOpenDetail: (row: VendorListItem) => void;
|
||||||
|
|
@ -61,9 +133,7 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
const trades = splitTrades(row.tradeSpecialties);
|
const trades = splitTrades(row.tradeSpecialties);
|
||||||
const primaryTrade = trades[0] ?? "—";
|
const primaryTrade = trades[0] ?? "—";
|
||||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", ");
|
const { locationText, phoneText } = getVendorDisplayValues(row);
|
||||||
const locationText = cityStateText || row.address.trim() || "—";
|
|
||||||
const phoneText = row.phone.trim() || row.companyPhone.trim();
|
|
||||||
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
@ -84,17 +154,25 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
onClick={() => onOpenDetail(row)}
|
onClick={() => onOpenDetail(row)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell sx={{ overflow: "hidden" }}>
|
||||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
<TruncatedText
|
||||||
{row.companyName || "—"}
|
value={row.companyName || "—"}
|
||||||
</Text>
|
testId="vendor-text-company"
|
||||||
|
variant="description"
|
||||||
|
color="var(--color-foreground)"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell sx={{ overflow: "hidden" }}>
|
||||||
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1.25}
|
||||||
|
sx={{ alignItems: "center", minWidth: 0, overflow: "hidden" }}
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
|
flexShrink: 0,
|
||||||
backgroundColor: "var(--color-bg-muted)",
|
backgroundColor: "var(--color-bg-muted)",
|
||||||
color: "var(--color-primary)",
|
color: "var(--color-primary)",
|
||||||
fontSize: 10.5,
|
fontSize: 10.5,
|
||||||
|
|
@ -103,12 +181,22 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
>
|
>
|
||||||
{getInitials(row.contactName)}
|
{getInitials(row.contactName)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
|
<TruncatedText
|
||||||
{row.contactName || "—"}
|
value={row.contactName || "—"}
|
||||||
</Text>
|
testId="vendor-text-technician"
|
||||||
|
variant="description"
|
||||||
|
color="var(--color-foreground)"
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
|
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
|
||||||
|
<TruncatedText
|
||||||
|
value={locationText}
|
||||||
|
testId="vendor-text-location"
|
||||||
|
focusable
|
||||||
|
color="var(--color-muted-foreground)"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||||
{primaryTrade === "—" ? (
|
{primaryTrade === "—" ? (
|
||||||
"—"
|
"—"
|
||||||
|
|
@ -133,33 +221,21 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
|
||||||
{phoneText ? (
|
<TruncatedText
|
||||||
<Link
|
value={phoneText || "—"}
|
||||||
href={`tel:${phoneText}`}
|
testId="vendor-text-phone"
|
||||||
underline="hover"
|
href={phoneText ? `tel:${phoneText}` : undefined}
|
||||||
color="inherit"
|
color="var(--color-muted-foreground)"
|
||||||
onClick={stopPropagation}
|
/>
|
||||||
>
|
|
||||||
{phoneText}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell sx={{ overflow: "hidden" }}>
|
||||||
{row.email ? (
|
<TruncatedText
|
||||||
<Link
|
value={row.email || "—"}
|
||||||
href={`mailto:${row.email}`}
|
testId="vendor-text-email"
|
||||||
underline="hover"
|
href={row.email ? `mailto:${row.email}` : undefined}
|
||||||
sx={{ color: "#6b7a96" }}
|
color="#6b7a96"
|
||||||
onClick={stopPropagation}
|
/>
|
||||||
>
|
|
||||||
{row.email}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
|
||||||
|
|
@ -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 userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import VendorsListPage from "@/app/(protected)/vendors/index";
|
import VendorsListPage from "@/app/(protected)/vendors/index";
|
||||||
|
|
@ -181,6 +181,69 @@ describe("VendorsListPage", () => {
|
||||||
expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument();
|
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(<VendorsListPage />, { 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", () => {
|
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue