mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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>
235 lines
9.3 KiB
JavaScript
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();
|
|
}
|