assessment-model/cypress/e2e/projects/create-project.cy.js
Daniel Roth c96d964d24 feat(dates): restore a calendar picker behind DateInput
Dropping `<input type="date">` for the dd/mm/yyyy mask also dropped its
calendar, leaving typing as the only way to enter a date. ADR-0019 accepted
that cost but named the remedy: add a picker *behind* `DateInput`, writing the
same ISO value, rather than revert to the native control. That was felt
immediately in use, so this does it.

`DateInput` now renders a calendar button inside the field's right edge and a
Calendar in a Radix popover. Typing and picking are equal routes to the same
value — picking fills the text, typing moves the grid — so neither is
privileged and the two cannot disagree. The trigger is `type="button"` so it
cannot submit the form, and carries an aria-label, a bare icon having no
accessible name.

The calendar wrapper is written by hand rather than taken from the shadcn
registry: the published template targets react-day-picker v8/v9, whose
`classNames` keys differ from the `UI` enum v10 uses. It defaults to the enGB
locale, which carries `weekStartsOn: 1`, so the grid starts on Monday without
being told to.

The delicate part is the `Date` boundary. A calendar works in `Date`s, and
both obvious conversions are wrong: `new Date("2026-03-01")` is midnight UTC,
which is 29 February west of Greenwich, and `toISOString()` shifts the day back
the other way. So `isoToLocalDate` and `localDateToIso` build and read local
parts, and they are the only two functions in `src/utils/dates.ts` that touch
`Date` at all — everything else stays string-only. The round trip is tested
across a full month rather than at a sample day, because the failure only
appears on some days in some zones; the suite was also run under
TZ=America/Los_Angeles and TZ=Pacific/Auckland.

Dependencies: react-day-picker and date-fns, the latter already present
transitively and now direct at v4. `@tremor/react` bundles react-day-picker v8
nested and npm kept it isolated on date-fns v3, so nothing existing changed
version; no Tremor DatePicker is used anywhere in src, so there is no second
copy in the bundle. The alternative was a hand-rolled month grid with no
dependency, rejected because keyboard navigation and ARIA on a calendar are
easy to get subtly wrong and not worth owning.

ADR-0019's "the native calendar picker is gone" consequence was now false and
has been rewritten.

TESTS: tsc --noEmit and ESLint clean; vitest 582 passing (53 files), 24 of them
for the date helpers. NOT verified in a browser — this project has no jsdom or
testing-library, and `next build` was not run because it would statically
render pages against a DATABASE_URL still pointing at production. The new
Cypress case, which picks 17 March from the grid and expects 17/03/2026 in the
field, is unrun like the rest of that spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:39:11 +00:00

235 lines
9.3 KiB
JavaScript

/**
* Ara Projects — projects list, empty state and create-project modal (#409)
*
* Two groups, because one of them writes:
*
* 1. The UI group stubs POST /api/projects with cy.intercept. It exercises
* the empty state, the modal, client-side validation and the navigation
* to workstream selection without touching the database, so it is safe to
* run anywhere.
*
* 2. The end-to-end group performs a real create and is the acceptance
* criterion's "create a project end-to-end". It INSERTs, so it is gated
* behind CYPRESS_ARA_PROJECTS_E2E_WRITES and must only ever be pointed at
* a non-production database. It also needs the #407 reference-data
* migration (0274) applied, or the project-type select will be empty and
* the page will not offer the CTA at all.
*
* Like the shell spec, this uses `cy.login` (cypress/support/commands.ts) to
* mint a real next-auth JWE cookie, so NEXTAUTH_JWT_SECRET must be set.
*
* NOT YET RUN. The environment this was written in has DATABASE_URL pointing at
* production, so Cypress was deliberately not executed. Treat the selectors and
* assertions below as unverified until someone runs them against a scratch DB.
*/
const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET");
const E2E_WRITES = Cypress.env("ARA_PROJECTS_E2E_WRITES");
const INTERNAL_USER = {
email: "dev@domna.homes",
name: "Internal User",
onboarded: true,
sub: "cypress-internal",
};
describe("Ara Projects — create project (stubbed)", function () {
beforeEach(function () {
if (!JWT_SECRET) {
cy.log("NEXTAUTH_JWT_SECRET not set — skipping");
this.skip();
}
cy.login(INTERNAL_USER);
});
it("offers the Create project CTA from the list", function () {
cy.visit("/projects");
// The CTA sits inside the welcome card when there are no projects, and in
// the page header once there are — either way there is exactly one.
cy.get("[data-testid=create-project-trigger]").should("be.visible").click();
cy.get("[data-testid=create-project-form]").should("be.visible");
cy.contains("Project details").should("be.visible");
});
it("shows the welcome empty state when the user has no visible projects", function () {
// Only meaningful on an instance with no projects visible to this user;
// where there are some, the list renders instead and this is skipped.
cy.visit("/projects");
cy.get("body").then(($body) => {
if ($body.find("[data-testid=projects-list-empty]").length === 0) {
cy.log("user has visible projects — empty state not applicable");
return;
}
cy.contains("Welcome to Ara Projects").should("be.visible");
cy.contains("How setup works").should("be.visible");
cy.get("[data-testid=create-project-trigger]").should("be.visible");
});
});
it("refuses to submit without a name and a project type", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
cy.get("[data-testid=create-project-submit]").click();
cy.contains("Enter a project name").should("be.visible");
cy.get("@createCall").should("not.have.been.called");
});
it("rejects an end date before the start date", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type("Backwards dates");
cy.get("[data-testid=create-project-start-date]").type("30/09/2026");
cy.get("[data-testid=create-project-end-date]").type("01/03/2026");
cy.get("[data-testid=create-project-submit]").click();
cy.contains("The end date cannot be before the start date").should(
"be.visible",
);
});
it("fills the field day-first from the calendar picker", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
// Type a date first so the grid opens on a known month rather than
// whatever month the test happens to run in.
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
cy.get("[data-testid=create-project-start-date-picker]").click();
// The grid is en-GB, so the week starts on Monday.
cy.get("[role=dialog]").within(() => {
cy.contains("Mo").should("be.visible");
cy.contains("March 2026").should("be.visible");
cy.get("[role=gridcell] button").contains(/^17$/).click();
});
cy.get("[data-testid=create-project-start-date]").should(
"have.value",
"17/03/2026",
);
});
it("formats the date as it is typed and rejects a day that does not exist", function () {
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
// The separators are supplied by the field, so eight digits are enough.
cy.get("[data-testid=create-project-start-date]")
.type("31022026")
.should("have.value", "31/02/2026");
cy.get("[data-testid=create-project-name]").type("Impossible date");
cy.intercept("POST", "/api/projects", cy.spy().as("createCall"));
cy.get("[data-testid=create-project-submit]").click();
// A finished-but-impossible date must not read as an empty optional field.
cy.contains("Enter a valid date").should("be.visible");
cy.get("@createCall").should("not.have.been.called");
});
it("navigates to workstream selection on success", function () {
cy.intercept("POST", "/api/projects", {
statusCode: 201,
body: { id: "4242" },
}).as("createProject");
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type("Riverside retrofit 2026");
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
cy.get("[data-testid=create-project-domna-admin-access]").click();
cy.get("[data-testid=create-project-submit]").click();
cy.wait("@createProject").then(({ request }) => {
expect(request.body.name).to.eq("Riverside retrofit 2026");
expect(request.body.domnaAdminAccess).to.eq(true);
// Typed day-first, sent ISO: the display format stops at the input, and
// 01/03 is 1 March rather than 3 January.
expect(request.body.startDate).to.eq("2026-03-01");
});
cy.location("pathname").should("eq", "/projects/4242/setup/workstreams");
});
it("surfaces the server's error message on a refusal", function () {
cy.intercept("POST", "/api/projects", {
statusCode: 403,
body: { error: "You cannot create a project for that organisation." },
}).as("createProject");
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type("Not mine");
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-submit]").click();
cy.wait("@createProject");
cy.get("[data-testid=create-project-error]")
.should("be.visible")
.and("contain", "cannot create a project for that organisation");
cy.location("pathname").should("eq", "/projects");
});
});
describe("Ara Projects — create project (end to end, writes)", function () {
beforeEach(function () {
if (!JWT_SECRET || !E2E_WRITES) {
cy.log(
"ARA_PROJECTS_E2E_WRITES not set — skipping the writing happy path",
);
this.skip();
}
cy.login(INTERNAL_USER);
});
it("creates a project and lands on workstream selection", function () {
// Unique per run so repeated runs do not collide on a name a human is
// eyeballing later. `project.name` has no unique constraint; this is for
// legibility, not correctness.
const name = `Cypress project ${Date.now()}`;
cy.visit("/projects");
cy.get("[data-testid=create-project-trigger]").click();
cy.get("[data-testid=create-project-name]").type(name);
selectFirstOption("create-project-organisation");
selectFirstOption("create-project-type");
cy.get("[data-testid=create-project-description]").type(
"Created by the Cypress happy path.",
);
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
cy.get("[data-testid=create-project-end-date]").type("30/09/2026");
// Required for an internal user creating on behalf of an organisation they
// are not a member of — without it the guard refuses, by design.
cy.get("[data-testid=create-project-domna-admin-access]").click();
cy.get("[data-testid=create-project-submit]").click();
cy.location("pathname").should("match", /^\/projects\/\d+\/setup\/workstreams$/);
// The new project is now visible in the list, which is the visibility rule
// agreeing with the write that just happened.
cy.visit("/projects");
cy.get("[data-testid=projects-list]").should("exist");
cy.contains("[data-testid=projects-list-item]", name).should("be.visible");
});
});
/**
* Pick the first item in a shadcn/Radix `Select`. The options live in a portal
* outside the trigger, so this opens the trigger and then reaches for the
* listbox at the document root.
*/
function selectFirstOption(testId) {
cy.get(`[data-testid=${testId}]`).click();
cy.get("[role=option]").first().click();
}