hotfix(staging): deploy vendor table overflow fix (#155)
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy staging / Deploy to staging (push) Waiting to run

* 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:
Alexandre Brandizzi 2026-08-28 13:31:58 -03:00 • committed by GitHub
parent 40ce66fbb3
commit fcf2ce185f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 540 additions and 85 deletions

View file

@ -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

140
.github/workflows/deploy-staging.yml vendored Normal file
View 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."

View file

@ -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<string, unknown>;
@ -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,
}) => {

View file

@ -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.

View file

@ -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,

View file

@ -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:<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"].
* 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",

View file

@ -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."

View file

@ -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 ? (
<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 {
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}
>
<TableCell>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.companyName || "—"}
</Text>
<TableCell sx={{ overflow: "hidden" }}>
<TruncatedText
value={row.companyName || "—"}
testId="vendor-text-company"
variant="description"
color="var(--color-foreground)"
/>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<TableCell sx={{ overflow: "hidden" }}>
<Stack
direction="row"
spacing={1.25}
sx={{ alignItems: "center", minWidth: 0, overflow: "hidden" }}
>
<Avatar
sx={{
width: 28,
height: 28,
flexShrink: 0,
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-primary)",
fontSize: 10.5,
@ -103,12 +181,22 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
>
{getInitials(row.contactName)}
</Avatar>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.contactName || "—"}
</Text>
<TruncatedText
value={row.contactName || "—"}
testId="vendor-text-technician"
variant="description"
color="var(--color-foreground)"
/>
</Stack>
</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)" }}>
{primaryTrade === "—" ? (
"—"
@ -133,33 +221,21 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
</Stack>
)}
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
{phoneText ? (
<Link
href={`tel:${phoneText}`}
underline="hover"
color="inherit"
onClick={stopPropagation}
>
{phoneText}
</Link>
) : (
"—"
)}
<TableCell sx={{ color: "var(--color-muted-foreground)", overflow: "hidden" }}>
<TruncatedText
value={phoneText || "—"}
testId="vendor-text-phone"
href={phoneText ? `tel:${phoneText}` : undefined}
color="var(--color-muted-foreground)"
/>
</TableCell>
<TableCell>
{row.email ? (
<Link
href={`mailto:${row.email}`}
underline="hover"
sx={{ color: "#6b7a96" }}
onClick={stopPropagation}
>
{row.email}
</Link>
) : (
"—"
)}
<TableCell sx={{ overflow: "hidden" }}>
<TruncatedText
value={row.email || "—"}
testId="vendor-text-email"
href={row.email ? `mailto:${row.email}` : undefined}
color="#6b7a96"
/>
</TableCell>
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
<TableCell>

View file

@ -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(<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", () => {
setupDefaults();
const pageVendors = Array.from({ length: 3 }, (_, index) => ({