mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
add editable domna survey section and cypress spec
Replaces the read-only Domna InfoRow block with a DomnaEditor that lets approvers set a free-text survey type and a survey date with optimistic update + error surfacing, mirroring the halted-state editor. Non-approvers keep the read-only InfoRow view. Cypress spec covers the approver flow end-to-end with a reload-persistence check. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1336d90c8d
commit
a69dac1fca
2 changed files with 288 additions and 15 deletions
100
cypress/e2e/live-tracking/domna-survey.cy.js
Normal file
100
cypress/e2e/live-tracking/domna-survey.cy.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Live Tracking — Domna Survey editor (issue #256)
|
||||
*
|
||||
* Verifies the approver flow on the Domna section of the property detail
|
||||
* drawer:
|
||||
* 1. an approver can set a Domna survey type (free text) and date and
|
||||
* save them,
|
||||
* 2. the drawer reflects the saved values immediately (optimistic
|
||||
* update),
|
||||
* 3. the values persist across a page reload (i.e. the deal-properties
|
||||
* endpoint wrote them server-side).
|
||||
*
|
||||
* Mirrors `halted-state.cy.js`. Assumes an authenticated approver session
|
||||
* is reusable by the test harness; the target portfolio + a deal whose
|
||||
* Domna section is editable by the current user are read from Cypress
|
||||
* env vars so the spec stays portable.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_DOMNA_DEAL_NAME");
|
||||
|
||||
const SURVEY_TYPE = "Standard";
|
||||
const SURVEY_DATE = "2025-07-15";
|
||||
|
||||
describe("Domna survey editor — approver flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerForTargetDeal() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
// Switch to the Measures tab — the easiest way into the drawer.
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-domna]").should("exist");
|
||||
}
|
||||
|
||||
it("lets an approver set domna survey type + date and persists them across reload", () => {
|
||||
openDrawerForTargetDeal();
|
||||
|
||||
// Approver sees editable inputs.
|
||||
cy.get("[data-testid=domna-survey-type-input]").should("be.visible");
|
||||
cy.get("[data-testid=domna-survey-date-input]").should("be.visible");
|
||||
|
||||
cy.get("[data-testid=domna-survey-type-input]")
|
||||
.clear()
|
||||
.type(SURVEY_TYPE);
|
||||
cy.get("[data-testid=domna-survey-date-input]")
|
||||
.clear()
|
||||
.type(SURVEY_DATE);
|
||||
|
||||
cy.get("[data-testid=domna-save-button]")
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
// Save completes — button label flips back, no error banner.
|
||||
cy.get("[data-testid=domna-save-button]").should(
|
||||
"contain.text",
|
||||
"Save Domna Survey",
|
||||
);
|
||||
cy.get("[data-testid=domna-error]").should("not.exist");
|
||||
|
||||
// Optimistic update — the inputs already reflect the new values.
|
||||
cy.get("[data-testid=domna-survey-type-input]").should(
|
||||
"have.value",
|
||||
SURVEY_TYPE,
|
||||
);
|
||||
cy.get("[data-testid=domna-survey-date-input]").should(
|
||||
"have.value",
|
||||
SURVEY_DATE,
|
||||
);
|
||||
|
||||
// Reload the page and reopen the drawer — the persisted values must
|
||||
// still be there.
|
||||
cy.reload();
|
||||
openDrawerForTargetDeal();
|
||||
|
||||
cy.get("[data-testid=domna-survey-type-input]").should(
|
||||
"have.value",
|
||||
SURVEY_TYPE,
|
||||
);
|
||||
cy.get("[data-testid=domna-survey-date-input]").should(
|
||||
"have.value",
|
||||
SURVEY_DATE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -930,6 +930,186 @@ function HaltedEditor({
|
|||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Domna section — editable for approvers (issue #256)
|
||||
// -----------------------------------------------------------------------
|
||||
interface DomnaEditorProps {
|
||||
dealId: string;
|
||||
portfolioId: string;
|
||||
initialSurveyType: string | null;
|
||||
initialSurveyDate: Date | string | null;
|
||||
/** True when the user has the approver capability on this portfolio. */
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
function DomnaEditor({
|
||||
dealId,
|
||||
portfolioId,
|
||||
initialSurveyType,
|
||||
initialSurveyDate,
|
||||
canEdit,
|
||||
}: DomnaEditorProps) {
|
||||
const initialType = initialSurveyType ?? "";
|
||||
const initialDate = useMemo(
|
||||
() => toDateInputValue(initialSurveyDate),
|
||||
[initialSurveyDate],
|
||||
);
|
||||
|
||||
const [typeValue, setTypeValue] = useState(initialType);
|
||||
const [dateValue, setDateValue] = useState(initialDate);
|
||||
const [savedType, setSavedType] = useState(initialType);
|
||||
const [savedDate, setSavedDate] = useState(initialDate);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when the drawer switches deals.
|
||||
useEffect(() => {
|
||||
setTypeValue(initialType);
|
||||
setSavedType(initialType);
|
||||
}, [initialType]);
|
||||
useEffect(() => {
|
||||
setDateValue(initialDate);
|
||||
setSavedDate(initialDate);
|
||||
}, [initialDate]);
|
||||
|
||||
const dirty = typeValue !== savedType || dateValue !== savedDate;
|
||||
|
||||
async function handleSave() {
|
||||
if (!dirty) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const fields: Record<string, string | null> = {};
|
||||
if (typeValue !== savedType) {
|
||||
fields.domna_survey_type = typeValue.trim() === "" ? null : typeValue;
|
||||
}
|
||||
if (dateValue !== savedDate) {
|
||||
fields.domna_survey_date = dateInputToIso(dateValue);
|
||||
}
|
||||
// Optimistic update.
|
||||
const prevType = savedType;
|
||||
const prevDate = savedDate;
|
||||
setSavedType(typeValue);
|
||||
setSavedDate(dateValue);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/deal-properties`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dealId, fields }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
setSavedType(prevType);
|
||||
setSavedDate(prevDate);
|
||||
setTypeValue(prevType);
|
||||
setDateValue(prevDate);
|
||||
const json = await res.json().catch(() => ({}));
|
||||
setError(
|
||||
typeof json.error === "string"
|
||||
? json.error
|
||||
: "Failed to update Domna survey",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
results: Record<string, { ok: boolean; error?: string }>;
|
||||
hubspotSync: "ok" | "failed" | "skipped";
|
||||
hubspotError?: string;
|
||||
};
|
||||
const fieldErrors = Object.entries(json.results ?? {})
|
||||
.filter(([, r]) => !r.ok)
|
||||
.map(([k, r]) => `${k}: ${r.error ?? "rejected"}`);
|
||||
if (fieldErrors.length > 0) {
|
||||
setError(fieldErrors.join("; "));
|
||||
} else if (json.hubspotSync === "failed") {
|
||||
setError(
|
||||
json.hubspotError
|
||||
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
|
||||
: "Saved locally — HubSpot sync failed",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setSavedType(prevType);
|
||||
setSavedDate(prevDate);
|
||||
setTypeValue(prevType);
|
||||
setDateValue(prevDate);
|
||||
setError(err instanceof Error ? err.message : "Failed to update Domna survey");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="divide-y divide-gray-50">
|
||||
<InfoRow label="Domna Survey Type" value={initialSurveyType} />
|
||||
<InfoRow
|
||||
label="Domna Survey Date"
|
||||
value={formatDate(initialSurveyDate)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
Domna Survey Type
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="domna-survey-type-input"
|
||||
value={typeValue}
|
||||
onChange={(e) => setTypeValue(e.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder="e.g. Standard, Detailed"
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
Domna Survey Date
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
data-testid="domna-survey-date-input"
|
||||
value={dateValue}
|
||||
onChange={(e) => setDateValue(e.target.value)}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Free text — leave blank to clear. Changes sync to HubSpot.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="domna-save-button"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || submitting}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? "Saving…" : "Save Domna Survey"}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
data-testid="domna-error"
|
||||
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PropertyDetailDrawer — main component
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -1008,16 +1188,6 @@ export default function PropertyDetailDrawer({
|
|||
deal?.technicalApprovedMeasuresForInstall ?? null,
|
||||
);
|
||||
|
||||
// Domna section: prefer the new text column when present, otherwise fall
|
||||
// back to the legacy boolean ("Required" / "Not required").
|
||||
const domnaSurveyTypeDisplay: string | null = (() => {
|
||||
if (!deal) return null;
|
||||
if (deal.domnaSurveyType) return deal.domnaSurveyType;
|
||||
if (deal.domnaSurveyRequired === true) return "Required";
|
||||
if (deal.domnaSurveyRequired === false) return "Not required";
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
|
||||
<DrawerContent
|
||||
|
|
@ -1166,13 +1336,16 @@ export default function PropertyDetailDrawer({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domna Survey section */}
|
||||
{/* Domna Survey section — editable for approvers (issue #256) */}
|
||||
<div ref={(el) => { sectionRefs.current.domna = el; }}>
|
||||
<SectionHeader id="domna" label={SECTION_TITLES.domna} />
|
||||
<div className="divide-y divide-gray-50">
|
||||
<InfoRow label="Domna Survey Type" value={domnaSurveyTypeDisplay} />
|
||||
<InfoRow label="Domna Survey Date" value={formatDate(deal.domnaSurveyDate)} />
|
||||
</div>
|
||||
<DomnaEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
initialSurveyType={deal.domnaSurveyType ?? null}
|
||||
initialSurveyDate={deal.domnaSurveyDate}
|
||||
canEdit={userCapability.includes("approver")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Halted section — editable for approvers (issue #255) */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue