From 3a07dc9b2442281703e33aac4189f1097a6596ea Mon Sep 17 00:00:00 2001 From: Velang Date: Thu, 9 Apr 2026 06:32:22 -0700 Subject: [PATCH 01/45] enhancing the paymentflow --- .../cypress/regression/ApprovalFlow.cy.ts | 350 ++++++++++++++++-- .../cypress/utilities/PageFactory.ts | 41 +- .../Unity.AutoUI/cypress/utilities/index.ts | 3 + 3 files changed, 360 insertions(+), 34 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 5b480b2aab..fd9091b320 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -15,11 +15,13 @@ * - Post-approval status and date validation on the Payments table */ -import { ApplicationsListPage } from "../pages/ApplicationsListPage"; -import { ApplicationDetailsPage } from "../pages/ApplicationDetailsPage"; -import { ReviewAssessmentPage } from "../pages/ReviewAssessmentPage"; -import { ApplicationDetailsRightTabPage } from "../pages/ApplicationDetailsRightTabPage"; -import { NavigationPage } from "../pages/NavigationPage"; +import { + ApplicationDetailsPageInstance, + ApplicationDetailsRightTabPageInstance, + ApplicationsListPageInstance, + NavigationPageInstance, + ReviewAssessmentPageInstance, +} from "../utilities/PageFactory"; import { loginIfNeeded } from "../support/auth"; const isProd = @@ -35,6 +37,7 @@ const TEST_CONFIG = { submissionId: null as string | null, grantProgram: "Default Grants Program", approvedAmount: "5000", + assignOwner: "Unity User1", supplierNumber: Cypress.env("environment") === "TEST" ? "2002712" : "2009366", paymentGroup: "Cheque" as const, testComment: "Test comment from automated regression test", @@ -48,13 +51,29 @@ const TEST_CONFIG = { }, }; +const STATUS_ACTIONS = { + menuButton: "button.dropdown-toggle[data-bs-toggle='dropdown']", + startReview: "#Application_StartReviewButton", + completeReview: "#Application_CompleteReviewButton", + startAssessment: "#Application_StartAssessmentButton", + completeAssessment: "#Application_CompleteAssessmentButton", + approve: "#Application_ApproveButton", +}; + +const ADJUDICATION_ACTIONS = { + completeAssessment: "#AdjudicationTeamLeadActionBar #CompleteButton", +}; + +const BREADCRUMB_STATUS_SELECTOR = + ".application-details-breadcrumb .application-status"; + (isProd ? describe.skip : describe)("Approval Flow Regression Test", () => { // Page object instances reused across all tests - const listPage = new ApplicationsListPage(); - const detailsPage = new ApplicationDetailsPage(); - const reviewPage = new ReviewAssessmentPage(); - const rightTabPage = new ApplicationDetailsRightTabPage(); - const navPage = new NavigationPage(); + const listPage = ApplicationsListPageInstance(); + const detailsPage = ApplicationDetailsPageInstance(); + const reviewPage = ReviewAssessmentPageInstance(); + const rightTabPage = ApplicationDetailsRightTabPageInstance(); + const navPage = NavigationPageInstance(); // Resolved after "Fetch submission ID from API" — shared across all subsequent tests let submissionId: string; @@ -73,6 +92,218 @@ const TEST_CONFIG = { cy.contains("tr", submissionId, { timeout: 20000 }).should("exist"); } + function openStatusActionsMenu(): void { + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .click({ force: true }); + cy.get("ul.dropdown-menu", { timeout: 20000 }).should("be.visible"); + } + + function clickStatusAction(actionSelector: string): void { + openStatusActionsMenu(); + cy.get(actionSelector, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + } + + function clickStatusActionIfEnabled( + actionSelector: string, + actionName: string, + ): void { + openStatusActionsMenu(); + cy.get(actionSelector, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .then(($button) => { + if ($button.is(":disabled")) { + cy.log(`${actionName} is disabled; likely already progressed`); + return; + } + + cy.wrap($button).click({ force: true }); + confirmStatusActionIfNeeded(); + }); + } + + function confirmStatusActionIfNeeded(): void { + // Short grace period so whichever confirmation dialog appears has time to mount. + cy.wait(300); + cy.get("body").then(($body) => { + if ($body.find(".swal2-popup .swal2-confirm").length > 0) { + cy.get(".swal2-popup .swal2-confirm", { timeout: 20000 }) + .should("be.visible") + .click({ force: true }); + return; + } + + if ( + $body.find(".modal.show .modal-content:contains('Confirm Action')") + .length > 0 + ) { + cy.get(".modal.show", { timeout: 20000 }) + .should("be.visible") + .within(() => { + cy.contains("button", /^Confirm$/i, { timeout: 20000 }) + .should("be.visible") + .click({ force: true }); + }); + } + }); + } + + function dismissBlockingModalIfPresent(): void { + cy.get("body").then(($body) => { + if ($body.find(".swal2-container .swal2-confirm").length > 0) { + cy.get(".swal2-container .swal2-confirm", { timeout: 20000 }) + .should("be.visible") + .click({ force: true }); + } + }); + } + + function selectFirstAvailableOptionByLabel(labelText: string): void { + cy.get("body").then(($body) => { + if ($body.find(`label:contains('${labelText}'):visible`).length === 0) { + cy.log(`Label not found (skip): ${labelText}`); + return; + } + + cy.contains("label", labelText, { timeout: 10000 }) + .first() + .then(($label) => { + const fieldId = ($label.attr("for") || "").trim(); + if (!fieldId) { + cy.log(`No 'for' attribute on label (skip): ${labelText}`); + return; + } + + const selector = `#${fieldId}`; + if ($body.find(selector).length === 0) { + cy.log(`Field not found by id (skip): ${selector}`); + return; + } + + cy.get(selector, { timeout: 10000 }).then(($select) => { + const options = $select.find("option").toArray(); + const candidate = options.find((opt) => { + const value = (opt.getAttribute("value") || "").trim(); + const text = (opt.textContent || "").trim().toLowerCase(); + return value !== "" && text !== "please choose..."; + }); + + const value = candidate?.getAttribute("value"); + if (value) { + cy.wrap($select).select(value, { force: true }); + } + }); + }); + }); + } + + function populateReviewFieldsRequiredForCompleteReview(): void { + detailsPage.goToReviewAssessmentTab(); + + // The tab can render asynchronously; ensure form fields are ready before typing. + cy.get("#formio, #ApprovalView_ApprovedAmount", { timeout: 30000 }).should( + "exist", + ); + + cy.get("body").then(($body) => { + if ($body.find("#ApprovalView_ApprovedAmount").length > 0) { + reviewPage.enterApprovedAmount(TEST_CONFIG.approvedAmount); + } else { + cy.log("Approved amount field not present yet; skipping amount entry"); + } + + if ($body.find("#ApprovalView_FinalDecisionDate").length > 0) { + reviewPage.setDecisionDateToToday(); + } else { + cy.log("Decision date field not present yet; skipping date entry"); + } + }); + + selectFirstAvailableOptionByLabel("Likelihood of Funding"); + selectFirstAvailableOptionByLabel("Due Diligence Status"); + selectFirstAvailableOptionByLabel("Assessment Result"); + reviewPage.clickSave(); + } + + function assignSubmissionFromList(ownerName: string): void { + dismissBlockingModalIfPresent(); + + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId) + .selectRowByText(submissionId); + + cy.get("#assignApplication", { timeout: 20000 }) + .should("exist") + .and("not.have.class", "action-bar-btn-unavailable") + .and("be.visible") + .click({ force: true }); + + cy.contains(".modal-title", "Assessment Users", { timeout: 20000 }).should( + "be.visible", + ); + + cy.get("#AssigneeId", { timeout: 20000 }) + .should("be.visible") + .select(ownerName); + + cy.get("#user-tags-input", { timeout: 20000 }) + .should("be.visible") + .clear() + .type(ownerName, { delay: 0 }); + + cy.get("body").then(($body) => { + if ( + $body.find(".tags-suggestion-container .tags-suggestion-element") + .length > 0 + ) { + cy.get(".tags-suggestion-container .tags-suggestion-element", { + timeout: 10000, + }) + .contains(ownerName) + .click({ force: true }); + } else { + cy.get("#user-tags-input").type("{enter}"); + } + }); + + cy.contains(".modal-footer button", "Save", { timeout: 20000 }) + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + + cy.contains(".modal-title", "Assessment Users", { timeout: 20000 }).should( + "not.exist", + ); + } + + function clickAdjudicationActionIfEnabled(actionSelector: string): void { + cy.get("body").then(($body) => { + if ($body.find(actionSelector).length > 0) { + cy.get(actionSelector, { timeout: 20000 }).then(($button) => { + if (!$button.is(":disabled")) { + cy.wrap($button).click({ force: true }); + confirmStatusActionIfNeeded(); + } else { + cy.log(`Action is disabled: ${actionSelector}`); + } + }); + } else { + cy.log(`Action not present: ${actionSelector}`); + } + }); + } + /** Select the submissionId row and open the Approve Payments modal. */ function selectRowAndOpenApproveModal(): void { cy.contains("tr", submissionId, { timeout: 20000 }) @@ -168,54 +399,88 @@ const TEST_CONFIG = { .searchForSubmission(submissionId); }); + it("Assign submission on application list", () => { + assignSubmissionFromList(TEST_CONFIG.assignOwner); + }); + it("Select submission and open details", () => { - listPage.selectRowByText(submissionId).clickOpenButton(); + dismissBlockingModalIfPresent(); + + cy.location("pathname", { timeout: 20000 }).then((pathname) => { + if (pathname.includes("/GrantApplications/Details")) { + cy.log("Already on details page after assignment"); + } else { + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId) + .selectRowByText(submissionId) + .clickOpenButton(); + } + }); + + cy.get(BREADCRUMB_STATUS_SELECTOR, { timeout: 20000 }) + .should("be.visible") + .invoke("text") + .then((statusText) => { + const normalized = statusText.trim().toLowerCase(); + expect( + ["submitted", "assigned"], + "Expected initial status to be Submitted or Assigned", + ).to.include(normalized); + }); }); // ============ Review & Assessment ============ - it("Navigate to Review and Assessment tab", () => { - detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + it("Start review from Status Actions", () => { + clickStatusActionIfEnabled(STATUS_ACTIONS.startReview, "Start Review"); }); - it("Enter approval details and save", () => { - reviewPage - .verifyFormioLoaded() - .enterApprovedAmount(TEST_CONFIG.approvedAmount) - .setDecisionDateToToday() - .clickSave(); + it("Complete review from Status Actions", () => { + populateReviewFieldsRequiredForCompleteReview(); + clickStatusActionIfEnabled( + STATUS_ACTIONS.completeReview, + "Complete Review", + ); }); - it("Create and complete assessment", () => { + it("Start assessment from Status Actions", () => { + clickStatusActionIfEnabled( + STATUS_ACTIONS.startAssessment, + "Start Assessment", + ); + }); + + it("Navigate to Review and Assessment tab", () => { + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + }); + + it("Create assessment", () => { cy.wait(2000); // Allow assessment section to fully load reviewPage.scrollToAssessmentList(); cy.get("body").then(($body) => { if ($body.find("#CreateButton").length > 0) { cy.get("#CreateButton").click({ force: true }); - cy.wait(1000); + // Give the new assessment row time to render before subsequent actions. + cy.wait(1000); // Needed because row creation animation can delay DOM readiness. } else { cy.log("Create Assessment button not found - may already be created"); } }); + }); - cy.get("body").then(($body) => { - if ($body.find("#CompleteButton").length > 0) { - cy.get("#CompleteButton").click({ force: true }); - cy.wait(1000); - } else { - cy.log( - "Complete Assessment button not found - may already be completed", - ); - } - }); + it("Complete assessment from adjudication action bar", () => { + clickAdjudicationActionIfEnabled(ADJUDICATION_ACTIONS.completeAssessment); }); // ============ Payment Info ============ it("Configure payment info", () => { cy.reload(); // Reload to get fresh data and avoid concurrency issues - cy.wait(2000); + // Wait briefly for async payment tab dependencies to stabilize after reload. + cy.wait(2000); // Prevents save attempts before payment controls are initialized. detailsPage .goToPaymentInfoTab() .enterSupplierNumber(TEST_CONFIG.supplierNumber) @@ -269,12 +534,31 @@ const TEST_CONFIG = { }); }); + it("Enter approval details and save", () => { + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + reviewPage + .verifyFormioLoaded() + .enterApprovedAmount(TEST_CONFIG.approvedAmount) + .setDecisionDateToToday() + .clickSave(); + }); + // ============ Application Approval ============ it("Test approval workflow (confirm)", () => { cy.reload(); // Refresh to ensure all changes are reflected before approval detailsPage.dismissErrorModalIfPresent(); - detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); + clickStatusAction(STATUS_ACTIONS.completeAssessment); + confirmStatusActionIfNeeded(); + + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .and("not.contain.text", "Processing..."); + + clickStatusAction(STATUS_ACTIONS.approve); + detailsPage.waitForConfirmModal().clickConfirm(); }); // ============ Post-Approval Verification ============ diff --git a/applications/Unity.AutoUI/cypress/utilities/PageFactory.ts b/applications/Unity.AutoUI/cypress/utilities/PageFactory.ts index 404764e56a..f9be869c3d 100644 --- a/applications/Unity.AutoUI/cypress/utilities/PageFactory.ts +++ b/applications/Unity.AutoUI/cypress/utilities/PageFactory.ts @@ -6,6 +6,9 @@ import { LoginPage } from "../pages/LoginPage"; import { NavigationPage } from "../pages/NavigationPage"; import { DashboardPage } from "../pages/DashboardPage"; import { ApplicationDetailsPage } from "../pages/ApplicationDetailsPage"; +import { ApplicationsListPage } from "../pages/ApplicationsListPage"; +import { ReviewAssessmentPage } from "../pages/ReviewAssessmentPage"; +import { ApplicationDetailsRightTabPage } from "../pages/ApplicationDetailsRightTabPage"; import { ApplicationsPage, RolesPage, @@ -52,7 +55,37 @@ export class PageFactory { static getApplicationDetailsPage(): ApplicationDetailsPage { return this.getInstance( "ApplicationDetailsPage", - () => new ApplicationDetailsPage() + () => new ApplicationDetailsPage(), + ); + } + + /** + * Get or create ApplicationsListPage instance + */ + static getApplicationsListPage(): ApplicationsListPage { + return this.getInstance( + "ApplicationsListPage", + () => new ApplicationsListPage(), + ); + } + + /** + * Get or create ReviewAssessmentPage instance + */ + static getReviewAssessmentPage(): ReviewAssessmentPage { + return this.getInstance( + "ReviewAssessmentPage", + () => new ReviewAssessmentPage(), + ); + } + + /** + * Get or create ApplicationDetailsRightTabPage instance + */ + static getApplicationDetailsRightTabPage(): ApplicationDetailsRightTabPage { + return this.getInstance( + "ApplicationDetailsRightTabPage", + () => new ApplicationDetailsRightTabPage(), ); } @@ -125,6 +158,12 @@ export const DashboardPageInstance = () => PageFactory.getDashboardPage(); export const ApplicationsPageInstance = () => PageFactory.getApplicationsPage(); export const ApplicationDetailsPageInstance = () => PageFactory.getApplicationDetailsPage(); +export const ApplicationsListPageInstance = () => + PageFactory.getApplicationsListPage(); +export const ReviewAssessmentPageInstance = () => + PageFactory.getReviewAssessmentPage(); +export const ApplicationDetailsRightTabPageInstance = () => + PageFactory.getApplicationDetailsRightTabPage(); export const RolesPageInstance = () => PageFactory.getRolesPage(); export const UsersPageInstance = () => PageFactory.getUsersPage(); export const IntakesPageInstance = () => PageFactory.getIntakesPage(); diff --git a/applications/Unity.AutoUI/cypress/utilities/index.ts b/applications/Unity.AutoUI/cypress/utilities/index.ts index 9b53890069..7b897bd9da 100644 --- a/applications/Unity.AutoUI/cypress/utilities/index.ts +++ b/applications/Unity.AutoUI/cypress/utilities/index.ts @@ -26,6 +26,9 @@ export { DashboardPageInstance, ApplicationsPageInstance, ApplicationDetailsPageInstance, + ApplicationsListPageInstance, + ReviewAssessmentPageInstance, + ApplicationDetailsRightTabPageInstance, RolesPageInstance, UsersPageInstance, IntakesPageInstance, From 38dc06966a96b8160bb57dad922943cdd5321c56 Mon Sep 17 00:00:00 2001 From: Velang Date: Wed, 15 Apr 2026 08:56:48 -0700 Subject: [PATCH 02/45] fixing the tests --- .../Unity.AutoUI/cypress/e2e/basicEmail.cy.ts | 58 ++++--- .../cypress/pages/ApplicationsListPage.ts | 159 +++++++++++------- 2 files changed, 131 insertions(+), 86 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 6ae85878e2..19e1ec620a 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -6,6 +6,7 @@ describe("Send an email", () => { const TEST_EMAIL_BCC = Cypress.env("TEST_EMAIL_BCC") as string; const TEMPLATE_NAME = "Test Case 1"; const STANDARD_TIMEOUT = 20000; + const LONG_TIMEOUT = 60000; // Only suppress the noisy ResizeObserver error that Unity throws in TEST. // Everything else should still fail the test. @@ -41,9 +42,9 @@ describe("Send an email", () => { cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { // Already authenticated if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains("VIEW APPLICATIONS", { timeout: STANDARD_TIMEOUT }).click({ - force: true, - }); + cy.contains("VIEW APPLICATIONS", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .click(); return; } @@ -51,14 +52,15 @@ describe("Send an email", () => { if ($body.find('button:contains("LOGIN")').length > 0) { cy.contains("LOGIN", { timeout: STANDARD_TIMEOUT }) .should("exist") - .click({ force: true }); + .should("be.visible") + .click(); cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($loginBody) => { // IDIR chooser may or may not appear if ($loginBody.find(':contains("IDIR")').length > 0) { - cy.contains("IDIR", { timeout: STANDARD_TIMEOUT }).click({ - force: true, - }); + cy.contains("IDIR", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .click(); } cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($authBody) => { @@ -70,9 +72,9 @@ describe("Send an email", () => { cy.get("#password", { timeout: STANDARD_TIMEOUT }).type( Cypress.env("test1password"), ); - cy.contains("Continue", { timeout: STANDARD_TIMEOUT }).click({ - force: true, - }); + cy.contains("Continue", { timeout: STANDARD_TIMEOUT }) + .should("be.visible") + .click(); } else { cy.log("Already authenticated"); } @@ -158,14 +160,14 @@ describe("Send an email", () => { .scrollIntoView() .should("be.visible") .within(() => { - cy.contains("td", subject, { timeout: STANDARD_TIMEOUT }) + cy.contains("td", subject, { timeout: LONG_TIMEOUT }) .should("exist") .click(); }); return; } - cy.contains("td", subject, { timeout: STANDARD_TIMEOUT }) + cy.contains("td", subject, { timeout: LONG_TIMEOUT }) .should("exist") .click(); }); @@ -226,7 +228,8 @@ describe("Send an email", () => { cy.get("#btn-confirm-send", { timeout: STANDARD_TIMEOUT }) .should("exist") - .click({ force: true }); + .should("be.visible") + .click(); }); } @@ -368,21 +371,27 @@ describe("Send an email", () => { it("Save the email", () => { cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }) .should("exist") - .should("be.visible") - .click(); + .should("not.be.disabled") + .then(($btn) => { + ($btn[0] as HTMLButtonElement).click(); + }); - cy.get("#btn-new-email", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); + cy.get("#btn-new-email", { timeout: LONG_TIMEOUT }).should("exist"); }); it("Select saved email from Email History", () => { openSavedEmailFromHistoryBySubject(TEST_EMAIL_SUBJECT); - cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }).should("be.visible"); - cy.get("#EmailCC").should("be.visible"); - cy.get("#EmailBCC").should("be.visible"); - cy.get("#EmailSubject").should("be.visible"); + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }) + .should("exist") + .invoke("val") + .should("not.be.empty"); + cy.get("#EmailCC").should("exist"); + cy.get("#EmailBCC").should("exist"); + cy.get("#EmailSubject") + .should("exist") + .invoke("val") + .should("contain", TEST_EMAIL_SUBJECT); cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }).should("exist"); cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }).should("exist"); @@ -391,9 +400,10 @@ describe("Send an email", () => { it("Send the email", () => { cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }) .should("exist") - .should("be.visible") .should("not.be.disabled") - .click(); + .then(($btn) => { + ($btn[0] as HTMLButtonElement).click(); + }); }); it("Confirm send email in dialog", () => { diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index 108d6b0cfe..8992ec96df 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -37,7 +37,8 @@ export class ApplicationsListPage extends ApplicationsPage { private readonly extendedActionBar = { customButtons: "#app_custom_buttons", dynamicButtonContainer: "#dynamicButtonContainerId", - exportButton: "#dynamicButtonContainerId .dt-buttons button span", + exportButton: + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", saveViewButton: "button.grp-savedStates", }; @@ -94,7 +95,7 @@ export class ApplicationsListPage extends ApplicationsPage { | "last3months" | "last6months" | "alltime" - | "custom" + | "custom", ): this { cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) .should("be.visible") @@ -127,10 +128,11 @@ export class ApplicationsListPage extends ApplicationsPage { | "last3months" | "last6months" | "alltime" - | "custom" + | "custom", ): this { - cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.dateFilters.quickDateRange, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -139,12 +141,16 @@ export class ApplicationsListPage extends ApplicationsPage { * @deprecated Use selectQuickDateRange() instead. This method is for custom date ranges only. */ setSubmittedFromDate(date: string): this { - cy.get(this.dateFilters.submittedFromDate, { timeout: this.STANDARD_TIMEOUT }) - .click({ force: true }) - .clear({ force: true }) - .type(date, { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) + cy.get(this.dateFilters.submittedFromDate, { + timeout: this.STANDARD_TIMEOUT, + }) + .scrollIntoView() + .should("be.visible") + .click() + .clear() + .type(date) + .trigger("change") + .blur() .should("have.value", date); return this; } @@ -154,11 +160,13 @@ export class ApplicationsListPage extends ApplicationsPage { */ setSubmittedToDate(date: string): this { cy.get(this.dateFilters.submittedToDate, { timeout: this.STANDARD_TIMEOUT }) - .click({ force: true }) - .clear({ force: true }) - .type(date, { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) + .scrollIntoView() + .should("be.visible") + .click() + .clear() + .type(date) + .trigger("change") + .blur() .should("have.value", date); return this; } @@ -172,7 +180,7 @@ export class ApplicationsListPage extends ApplicationsPage { cy.wrap($s) .should("have.attr", "style") .and("contain", "display: none"); - } + }, ); return this; } @@ -205,7 +213,8 @@ export class ApplicationsListPage extends ApplicationsPage { selectRowByText(text: string): this { cy.contains("tr", text, { timeout: this.STANDARD_TIMEOUT }) .find(".checkbox-select") - .click({ force: true }); + .should("be.visible") + .click(); return this; } @@ -215,7 +224,8 @@ export class ApplicationsListPage extends ApplicationsPage { clickOpenButton(): this { cy.get("#externalLink", { timeout: this.STANDARD_TIMEOUT }) .should("exist") - .click({ force: true }); + .should("be.visible") + .click(); return this; } @@ -225,10 +235,9 @@ export class ApplicationsListPage extends ApplicationsPage { * Verify table has rows (using scroll body selector) */ verifyTableHasData(): this { - cy.get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT }).should( - "have.length.greaterThan", - 1 - ); + cy.get(this.scrollTable.tableRows, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.greaterThan", 1); return this; } @@ -241,7 +250,9 @@ export class ApplicationsListPage extends ApplicationsPage { .find("td") .not(":has(a)") .first() - .click({ force: true, ctrlKey: withCtrl }); + .scrollIntoView() + .should("be.visible") + .click({ ctrlKey: withCtrl }); return this; } @@ -274,7 +285,9 @@ export class ApplicationsListPage extends ApplicationsPage { .then(($els: JQuery) => { const titles: string[] = Cypress.$($els) .toArray() - .map((el: HTMLElement) => (el.textContent || "").replace(/\s+/g, " ").trim()) + .map((el: HTMLElement) => + (el.textContent || "").replace(/\s+/g, " ").trim(), + ) .filter((t: string) => t.length > 0); return titles; }); @@ -287,10 +300,9 @@ export class ApplicationsListPage extends ApplicationsPage { this.getVisibleHeaderTitles().then((titles: string[]) => { const titlesLower = titles.map((t: string) => t.toLowerCase()); expected.forEach((e: string) => { - expect( - titlesLower, - `visible headers should include "${e}"` - ).to.include(e.toLowerCase()); + expect(titlesLower, `visible headers should include "${e}"`).to.include( + e.toLowerCase(), + ); }); }); return this; @@ -302,7 +314,9 @@ export class ApplicationsListPage extends ApplicationsPage { * Scroll to and verify action bar exists */ verifyActionBarExists(): this { - cy.get(this.extendedActionBar.customButtons, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.extendedActionBar.customButtons, { + timeout: this.STANDARD_TIMEOUT, + }) .should("exist") .scrollIntoView(); return this; @@ -315,7 +329,7 @@ export class ApplicationsListPage extends ApplicationsPage { cy.get("#applicationPaymentRequest", { timeout: this.BUTTON_TIMEOUT }) .should("be.visible") .and("not.be.disabled") - .click({ force: true }); + .click(); return this; } @@ -325,7 +339,10 @@ export class ApplicationsListPage extends ApplicationsPage { verifyExportButtonVisible(): this { cy.contains(this.extendedActionBar.exportButton, "Export", { timeout: this.STANDARD_TIMEOUT, - }).should("be.visible"); + matchCase: false, + }) + .scrollIntoView() + .should("be.visible"); return this; } @@ -336,7 +353,7 @@ export class ApplicationsListPage extends ApplicationsPage { cy.contains( "#dynamicButtonContainerId button.grp-savedStates", "Save View", - { timeout: this.STANDARD_TIMEOUT } + { timeout: this.STANDARD_TIMEOUT }, ).should("be.visible"); return this; } @@ -346,10 +363,12 @@ export class ApplicationsListPage extends ApplicationsPage { */ verifyColumnsButtonVisible(): this { cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", "Columns", - { timeout: this.STANDARD_TIMEOUT } - ).should("be.visible"); + { timeout: this.STANDARD_TIMEOUT, matchCase: false }, + ) + .scrollIntoView() + .should("be.visible"); return this; } @@ -393,9 +412,11 @@ export class ApplicationsListPage extends ApplicationsPage { // Try Cancel button if available (check existence first to avoid timeout) cy.get("body").then(($body: JQuery) => { - const $cancelBtn = $body.find(this.paymentModal.cancelButton).filter( - (_: number, el: HTMLElement) => (el.textContent || "").includes("Cancel") - ); + const $cancelBtn = $body + .find(this.paymentModal.cancelButton) + .filter((_: number, el: HTMLElement) => + (el.textContent || "").includes("Cancel"), + ); if ($cancelBtn.length > 0) { cy.wrap($cancelBtn.first()).scrollIntoView().click({ force: true }); } else { @@ -441,11 +462,11 @@ export class ApplicationsListPage extends ApplicationsPage { ($m: JQuery) => { const isHidden = !$m.is(":visible") || !$m.hasClass("show"); expect(isHidden, "payment-modal hidden or not shown").to.eq(true); - } - ); - cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT }).should( - "not.exist" + }, ); + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); return this; } @@ -475,14 +496,13 @@ export class ApplicationsListPage extends ApplicationsPage { cy.contains(this.saveView.resetOption, "Reset to Default View", { timeout: this.STANDARD_TIMEOUT, }) - .should("exist") - .click({ force: true }); + .should("be.visible") + .click(); // Wait for table to rebuild - cy.get(this.scrollTable.columnTitles, { timeout: this.STANDARD_TIMEOUT }).should( - "have.length.gt", - 5 - ); + cy.get(this.scrollTable.columnTitles, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.gt", 5); return this; } @@ -490,15 +510,19 @@ export class ApplicationsListPage extends ApplicationsPage { * Open the Columns menu */ openColumnsMenu(): this { - cy.contains("span", "Columns", { timeout: this.STANDARD_TIMEOUT }) + cy.contains( + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: this.STANDARD_TIMEOUT, matchCase: false }, + ) + .scrollIntoView() .should("be.visible") .click(); // Wait for dropdown to be fully populated - cy.get(this.columnsMenu.dropdownItem, { timeout: this.STANDARD_TIMEOUT }).should( - "have.length.gt", - 50 - ); + cy.get(this.columnsMenu.dropdownItem, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.gt", 50); return this; } @@ -510,9 +534,16 @@ export class ApplicationsListPage extends ApplicationsPage { timeout: this.STANDARD_TIMEOUT, matchCase: false, }) - .should("exist") .scrollIntoView() - .click({ force: true }); + .should("exist") + .then(($item) => { + if (Cypress.dom.isVisible($item)) { + cy.wrap($item).click(); + } else { + // Some dropdown items are clipped by overflow containers; native click still triggers menu toggle reliably. + ($item[0] as HTMLElement).click(); + } + }); return this; } @@ -530,7 +561,9 @@ export class ApplicationsListPage extends ApplicationsPage { * Close the Columns menu */ closeColumnsMenu(): this { - cy.get(this.columnsMenu.buttonBackground, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.columnsMenu.buttonBackground, { + timeout: this.STANDARD_TIMEOUT, + }) .should("exist") .click({ force: true }); @@ -567,7 +600,7 @@ export class ApplicationsListPage extends ApplicationsPage { if (switchLink.length === 0) { cy.log( - 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session' + 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session', ); cy.get("body").click(0, 0); return; @@ -577,10 +610,12 @@ export class ApplicationsListPage extends ApplicationsPage { cy.url({ timeout: this.STANDARD_TIMEOUT }).should( "include", - "/GrantPrograms" + "/GrantPrograms", ); - cy.get(this.grantProgram.searchInput, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.grantProgram.searchInput, { + timeout: this.STANDARD_TIMEOUT, + }) .should("be.visible") .clear() .type(programName); @@ -596,9 +631,9 @@ export class ApplicationsListPage extends ApplicationsPage { cy.location("pathname", { timeout: this.STANDARD_TIMEOUT }).should( (p: string) => { expect( - p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0 + p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, ).to.eq(true); - } + }, ); }); }); From 4ef217767bd5587ab486fd0ba40541b7742e3201 Mon Sep 17 00:00:00 2001 From: Velang Date: Wed, 15 Apr 2026 10:25:57 -0700 Subject: [PATCH 03/45] tests OS agonistic --- .../cypress/pages/ApplicationsListPage.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index 8992ec96df..ad81918229 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -245,14 +245,29 @@ export class ApplicationsListPage extends ApplicationsPage { * Select a row by index (clicks on a non-link cell) */ selectRowByIndex(rowIndex: number, withCtrl = false): this { - cy.get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT }) - .eq(rowIndex) - .find("td") - .not(":has(a)") - .first() - .scrollIntoView() - .should("be.visible") - .click({ ctrlKey: withCtrl }); + const getTargetCell = () => + cy + .get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT }) + .eq(rowIndex) + .find("td") + .not(":has(a)") + .first(); + + cy.get(this.scrollTable.tableRows, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.greaterThan", rowIndex); + + getTargetCell().should("exist"); + + // Break the chain and re-query right before click to avoid stale element errors + // when the datatable re-renders asynchronously. + getTargetCell().then(($cell) => { + if (Cypress.dom.isAttached($cell) && Cypress.dom.isVisible($cell)) { + cy.wrap($cell).scrollIntoView().click({ ctrlKey: withCtrl }); + } else { + getTargetCell().click({ ctrlKey: withCtrl, force: true }); + } + }); return this; } From 6f8a41bbfcf6219f033c3d1a0ab89257a33822a5 Mon Sep 17 00:00:00 2001 From: Velang Date: Wed, 15 Apr 2026 15:40:55 -0700 Subject: [PATCH 04/45] making test more robust --- .../cypress/pages/ApplicationDetailsPage.ts | 75 ++++++--- .../cypress/pages/ApplicationsListPage.ts | 122 +++++++++----- .../cypress/pages/ReviewAssessmentPage.ts | 156 ++++++++++++------ .../cypress/regression/ApprovalFlow.cy.ts | 50 ++++-- 4 files changed, 285 insertions(+), 118 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 550c95ff22..30ff95248d 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -110,11 +110,19 @@ export class ApplicationDetailsPage extends BasePage { super(); } + private activateTab(tabSelector: string): void { + cy.get(tabSelector, { timeout: 20000 }) + .scrollIntoView() + .should("be.visible") + .click({ force: true }) + .should("have.class", "active"); + } + /** * Navigate to Submission tab */ goToSubmissionTab(): this { - this.clickElement(this.tabs.submission); + this.activateTab(this.tabs.submission); return this; } @@ -123,7 +131,7 @@ export class ApplicationDetailsPage extends BasePage { */ goToReviewAssessmentTab(): this { this.dismissErrorModalIfPresent(); - this.clickElement(this.tabs.reviewAssessment); + this.activateTab(this.tabs.reviewAssessment); return this; } @@ -131,7 +139,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Project Info tab */ goToProjectInfoTab(): this { - this.clickElement(this.tabs.projectInfo); + this.activateTab(this.tabs.projectInfo); return this; } @@ -139,7 +147,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Applicant Info tab */ goToApplicantInfoTab(): this { - this.clickElement(this.tabs.applicantInfo); + this.activateTab(this.tabs.applicantInfo); return this; } @@ -147,7 +155,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Funding Agreement tab */ goToFundingAgreementTab(): this { - this.clickElement(this.tabs.fundingAgreement); + this.activateTab(this.tabs.fundingAgreement); return this; } @@ -155,7 +163,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Payment Info tab */ goToPaymentInfoTab(): this { - this.clickElement(this.tabs.paymentInfo); + this.activateTab(this.tabs.paymentInfo); return this; } @@ -434,15 +442,13 @@ export class ApplicationDetailsPage extends BasePage { * Open the Status Actions dropdown */ openStatusActionsDropdown(): void { - cy.get(this.statusActions.dropdownMenu).then(($menu) => { - if (!$menu.is(":visible")) { - cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }) - .should("exist") - .scrollIntoView() - .click({ force: true }); - } - }); - cy.get(this.statusActions.dropdownMenu, { timeout: 10000 }).should( + this.dismissErrorModalIfPresent(); + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }) + .scrollIntoView() + .should("be.visible") + .and("not.contain.text", "Processing...") + .click({ force: true }); + cy.get(this.statusActions.dropdownMenu, { timeout: 20000 }).should( "be.visible", ); } @@ -565,7 +571,18 @@ export class ApplicationDetailsPage extends BasePage { * Wait for confirm action modal to appear (SweetAlert2) */ waitForConfirmModal(): this { - cy.get(this.confirmModal.modal, { timeout: 20000 }).should("be.visible"); + cy.get("body").then(($body) => { + if ($body.find(this.confirmModal.modal).length > 0) { + cy.get(this.confirmModal.modal, { timeout: 20000 }).should( + "be.visible", + ); + return; + } + + cy.contains(".modal.show .modal-content", "Confirm Action", { + timeout: 20000, + }).should("be.visible"); + }); return this; } @@ -573,10 +590,26 @@ export class ApplicationDetailsPage extends BasePage { * Click Confirm button in the modal (SweetAlert2) */ clickConfirm(): this { - cy.get(this.confirmModal.modal, { timeout: 20000 }) - .find(this.confirmModal.confirmButton) - .should("be.visible") - .click({ force: true }); + cy.get("body").then(($body) => { + if ($body.find(this.confirmModal.modal).length > 0) { + cy.get(this.confirmModal.modal, { timeout: 20000 }) + .find(this.confirmModal.confirmButton) + .should("be.visible") + .click({ force: true }); + cy.get(this.confirmModal.modal, { timeout: 20000 }).should("not.exist"); + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); + return; + } + + cy.get(".modal.show", { timeout: 20000 }) + .contains("button", /^Confirm$/i, { timeout: 20000 }) + .should("be.visible") + .click({ force: true }); + cy.contains(".modal.show .modal-content", "Confirm Action", { + timeout: 20000, + }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + }); return this; } @@ -603,7 +636,7 @@ export class ApplicationDetailsPage extends BasePage { .find(".swal2-confirm") .first() .click({ force: true }); - cy.wait(500); + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); } }); return this; diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index ad81918229..81fc241dd3 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -63,6 +63,11 @@ export class ApplicationsListPage extends ApplicationsPage { cancelButton: "#payment-modal .modal-footer button", }; + private readonly blockingUi = { + bootstrapModal: ".modal.show", + swalContainer: ".swal2-container", + }; + // Grant program selectors private readonly grantProgram = { userInitials: ".unity-user-initials", @@ -97,8 +102,11 @@ export class ApplicationsListPage extends ApplicationsPage { | "alltime" | "custom", ): this { + this.waitForNoBlockingOverlay(); cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView() .should("be.visible") + .and("not.be.disabled") .select(range); return this; } @@ -211,7 +219,9 @@ export class ApplicationsListPage extends ApplicationsPage { * Select a row by matching text content */ selectRowByText(text: string): this { + this.waitForNoBlockingOverlay(); cy.contains("tr", text, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView() .find(".checkbox-select") .should("be.visible") .click(); @@ -341,6 +351,7 @@ export class ApplicationsListPage extends ApplicationsPage { * Click the Payment button (extended with visibility checks) */ clickPaymentButtonWithWait(): this { + this.waitForNoBlockingOverlay(); cy.get("#applicationPaymentRequest", { timeout: this.BUTTON_TIMEOUT }) .should("be.visible") .and("not.be.disabled") @@ -408,6 +419,27 @@ export class ApplicationsListPage extends ApplicationsPage { cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }) .should("be.visible") .and("have.class", "show"); + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("exist"); + return this; + } + + /** + * Wait for blocking modal overlays to clear before interacting with list controls. + */ + waitForNoBlockingOverlay(): this { + cy.get(this.blockingUi.swalContainer, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + + cy.get(this.blockingUi.bootstrapModal, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); return this; } @@ -415,58 +447,71 @@ export class ApplicationsListPage extends ApplicationsPage { * Close payment modal using multiple strategies */ closePaymentModal(): this { - // Attempt ESC key - cy.get("body").type("{esc}", { force: true }); - - // Click backdrop if present (check existence first to avoid timeout) cy.get("body").then(($body: JQuery) => { - if ($body.find(this.paymentModal.backdrop).length > 0) { - cy.get(this.paymentModal.backdrop).click("topLeft", { force: true }); + if ($body.find(`${this.paymentModal.modal}.show`).length === 0) { + return; } - }); - // Try Cancel button if available (check existence first to avoid timeout) - cy.get("body").then(($body: JQuery) => { const $cancelBtn = $body .find(this.paymentModal.cancelButton) .filter((_: number, el: HTMLElement) => (el.textContent || "").includes("Cancel"), ); + if ($cancelBtn.length > 0) { cy.wrap($cancelBtn.first()).scrollIntoView().click({ force: true }); - } else { - cy.log("Cancel button not present, proceeding to hard-close fallback"); } }); - // Hard close fallback using jQuery - cy.window().then((win: Cypress.AUTWindow) => { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const windowWithModal = win as any; - if (typeof windowWithModal.closePaymentModal === "function") { - windowWithModal.closePaymentModal(); - } - } catch { - /* ignore */ + cy.get("body").then(($body: JQuery) => { + if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { + cy.get("body").type("{esc}", { force: true }); } + }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const $ = (win as any).jQuery || (win as any).$; - if ($) { - try { - $("#payment-modal") - .removeClass("show") - .attr("aria-hidden", "true") - .css("display", "none"); - $(".modal-backdrop").remove(); - $("body").removeClass("modal-open").css("overflow", ""); - } catch { - /* ignore */ - } + cy.get("body").then(($body: JQuery) => { + if ( + $body.find(`${this.paymentModal.modal}.show`).length > 0 && + $body.find(this.paymentModal.backdrop).length > 0 + ) { + cy.get(this.paymentModal.backdrop).first().click("topLeft", { + force: true, + }); } }); - return this; + + cy.get("body").then(($body: JQuery) => { + if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { + cy.window().then((win: Cypress.AUTWindow) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const windowWithModal = win as any; + if (typeof windowWithModal.closePaymentModal === "function") { + windowWithModal.closePaymentModal(); + } + } catch { + /* ignore */ + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $ = (win as any).jQuery || (win as any).$; + if ($) { + try { + $(this.paymentModal.modal) + .removeClass("show") + .attr("aria-hidden", "true") + .css("display", "none"); + $(this.paymentModal.backdrop).remove(); + $("body").removeClass("modal-open").css("overflow", ""); + } catch { + /* ignore */ + } + } + }); + } + }); + + return this.verifyPaymentModalClosed(); } /** @@ -479,14 +524,17 @@ export class ApplicationsListPage extends ApplicationsPage { expect(isHidden, "payment-modal hidden or not shown").to.eq(true); }, ); + cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT, }).should("not.exist"); + + cy.get(this.blockingUi.bootstrapModal, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); return this; } - // ============ Columns Menu Methods ============ - /** * Close any open dropdowns or modals */ diff --git a/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts index b5f2636ef9..07add7298b 100644 --- a/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts @@ -78,13 +78,13 @@ export class ReviewAssessmentPage extends BasePage { // Assessment selectors private readonly assessment = { - approvedAmount: '#ApprovalView_ApprovedAmount', - decisionDate: '#ApprovalView_FinalDecisionDate', + approvedAmount: "#ApprovalView_ApprovedAmount", + decisionDate: "#ApprovalView_FinalDecisionDate", saveButton: 'button:contains("Save")', // Assessment List view buttons - createAssessmentButton: '#CreateButton', - completeAssessmentButton: '#CompleteButton', - assessmentMainView: '#assessmentMainView', + createAssessmentButton: "#CreateButton", + completeAssessmentButton: "#CompleteButton", + assessmentMainView: "#assessmentMainView", }; constructor() { @@ -104,7 +104,7 @@ export class ReviewAssessmentPage extends BasePage { | "projectInfo" | "projectTimelines" | "projectBudget" - | "attestation" + | "attestation", ): this { cy.contains("h4.card-title", this.getSectionTitle(sectionName), { timeout: this.STANDARD_TIMEOUT, @@ -141,7 +141,7 @@ export class ReviewAssessmentPage extends BasePage { | "projectInfo" | "projectTimelines" | "projectBudget" - | "attestation" + | "attestation", ): this { cy.contains("h4.card-title", this.getSectionTitle(sectionName), { timeout: this.STANDARD_TIMEOUT, @@ -156,7 +156,9 @@ export class ReviewAssessmentPage extends BasePage { */ getApplicantName(): Cypress.Chainable { return cy - .get(this.organizationInfo.applicantName, { timeout: this.STANDARD_TIMEOUT }) + .get(this.organizationInfo.applicantName, { + timeout: this.STANDARD_TIMEOUT, + }) .invoke("val") .then((val) => String(val)); } @@ -165,8 +167,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify applicant name */ verifyApplicantName(expectedValue: string): this { - cy.get(this.organizationInfo.applicantName, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.organizationInfo.applicantName, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -175,7 +178,9 @@ export class ReviewAssessmentPage extends BasePage { */ getRegisteredBusinessName(): Cypress.Chainable { return cy - .get(this.organizationInfo.registeredBusinessName, { timeout: this.STANDARD_TIMEOUT }) + .get(this.organizationInfo.registeredBusinessName, { + timeout: this.STANDARD_TIMEOUT, + }) .invoke("val") .then((val) => String(val)); } @@ -184,8 +189,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify registered business name */ verifyRegisteredBusinessName(expectedValue: string): this { - cy.get(this.organizationInfo.registeredBusinessName, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.organizationInfo.registeredBusinessName, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -194,7 +200,9 @@ export class ReviewAssessmentPage extends BasePage { */ getRegisteredBusinessNumber(): Cypress.Chainable { return cy - .get(this.organizationInfo.registeredBusinessNumber, { timeout: this.STANDARD_TIMEOUT }) + .get(this.organizationInfo.registeredBusinessNumber, { + timeout: this.STANDARD_TIMEOUT, + }) .invoke("val") .then((val) => String(val)); } @@ -203,8 +211,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify registered business number */ verifyRegisteredBusinessNumber(expectedValue: string): this { - cy.get(this.organizationInfo.registeredBusinessNumber, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.organizationInfo.registeredBusinessNumber, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -214,8 +223,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify contact name */ verifyContactName(expectedValue: string): this { - cy.get(this.contactInfo.contactName, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.contactInfo.contactName, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -223,8 +233,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify contact title */ verifyContactTitle(expectedValue: string): this { - cy.get(this.contactInfo.contactTitle, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.contactInfo.contactTitle, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -232,8 +243,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify contact email */ verifyContactEmail(expectedValue: string): this { - cy.get(this.contactInfo.contactEmail, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.contactInfo.contactEmail, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -241,8 +253,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify contact phone primary */ verifyContactPhonePrimary(expectedValue: string): this { - cy.get(this.contactInfo.contactPhonePrimary, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.contactInfo.contactPhonePrimary, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -250,8 +263,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify contact phone secondary */ verifyContactPhoneSecondary(expectedValue: string): this { - cy.get(this.contactInfo.contactPhoneSecondary, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.contactInfo.contactPhoneSecondary, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -261,8 +275,10 @@ export class ReviewAssessmentPage extends BasePage { * Verify mailing address city */ verifyMailingCity(expectedValue: string): this { - cy.get(this.mailingAddress.city, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.mailingAddress.city, { timeout: this.STANDARD_TIMEOUT }).should( + "have.value", + expectedValue, + ); return this; } @@ -270,8 +286,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify mailing address street 1 */ verifyMailingStreet1(expectedValue: string): this { - cy.get(this.mailingAddress.street1, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.mailingAddress.street1, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -279,8 +296,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify mailing address postal code */ verifyMailingPostalCode(expectedValue: string): this { - cy.get(this.mailingAddress.postalCode, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(this.mailingAddress.postalCode, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -290,7 +308,9 @@ export class ReviewAssessmentPage extends BasePage { * Expand Organization Info panel */ expandOrganizationInfoPanel(): this { - cy.contains(".card-header", "Organization Info", { timeout: this.STANDARD_TIMEOUT }) + cy.contains(".card-header", "Organization Info", { + timeout: this.STANDARD_TIMEOUT, + }) .should("be.visible") .click({ force: true }); return this; @@ -300,7 +320,9 @@ export class ReviewAssessmentPage extends BasePage { * Expand Contact Info panel */ expandContactInfoPanel(): this { - cy.contains(".card-header", "Contact Info", { timeout: this.STANDARD_TIMEOUT }) + cy.contains(".card-header", "Contact Info", { + timeout: this.STANDARD_TIMEOUT, + }) .should("be.visible") .click({ force: true }); return this; @@ -310,7 +332,9 @@ export class ReviewAssessmentPage extends BasePage { * Expand Mailing Address panel */ expandMailingAddressPanel(): this { - cy.contains(".card-header", "Mailing Address", { timeout: this.STANDARD_TIMEOUT }) + cy.contains(".card-header", "Mailing Address", { + timeout: this.STANDARD_TIMEOUT, + }) .should("be.visible") .click({ force: true }); return this; @@ -336,8 +360,19 @@ export class ReviewAssessmentPage extends BasePage { * Verify formio container is loaded */ verifyFormioLoaded(): this { - cy.get(this.containers.formioContainer, { timeout: this.STANDARD_TIMEOUT }) - .should("exist"); + cy.get("body", { timeout: this.STANDARD_TIMEOUT }).should(($body) => { + const formioVisible = + $body.find(`${this.containers.formioContainer}:visible`).length > 0; + const approvalVisible = + $body.find(`${this.assessment.approvedAmount}:visible`).length > 0; + const assessmentListVisible = + $body.find(`${this.assessment.assessmentMainView}:visible`).length > 0; + + expect( + formioVisible || approvalVisible || assessmentListVisible, + "expected review and assessment content to be visible", + ).to.eq(true); + }); return this; } @@ -346,7 +381,9 @@ export class ReviewAssessmentPage extends BasePage { */ getFieldValue(fieldName: string): Cypress.Chainable { return cy - .get(`input[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + .get(`input[name="data[${fieldName}]"]`, { + timeout: this.STANDARD_TIMEOUT, + }) .invoke("val") .then((val) => String(val)); } @@ -355,8 +392,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify field value by name attribute */ verifyFieldValue(fieldName: string, expectedValue: string): this { - cy.get(`input[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) - .should("have.value", expectedValue); + cy.get(`input[name="data[${fieldName}]"]`, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); return this; } @@ -364,7 +402,9 @@ export class ReviewAssessmentPage extends BasePage { * Verify select field has expected text (for Choices.js dropdowns) */ verifySelectFieldText(fieldName: string, expectedText: string): this { - cy.get(`select[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + cy.get(`select[name="data[${fieldName}]"]`, { + timeout: this.STANDARD_TIMEOUT, + }) .parent() .find(".choices__item--selectable") .should("contain.text", expectedText); @@ -378,8 +418,11 @@ export class ReviewAssessmentPage extends BasePage { */ enterApprovedAmount(amount: string): this { cy.get(this.assessment.approvedAmount, { timeout: this.STANDARD_TIMEOUT }) - .clear() - .type(amount); + .scrollIntoView({ block: "center" }) + .should("exist") + .and("not.be.disabled") + .clear({ force: true }) + .type(amount, { force: true }); return this; } @@ -393,8 +436,11 @@ export class ReviewAssessmentPage extends BasePage { const dd = String(now.getDate()).padStart(2, "0"); const today = `${yyyy}-${mm}-${dd}`; cy.get(this.assessment.decisionDate, { timeout: this.STANDARD_TIMEOUT }) - .clear() - .type(today); + .scrollIntoView({ block: "center" }) + .should("exist") + .and("not.be.disabled") + .clear({ force: true }) + .type(today, { force: true }); return this; } @@ -403,8 +449,11 @@ export class ReviewAssessmentPage extends BasePage { */ setDecisionDate(date: string): this { cy.get(this.assessment.decisionDate, { timeout: this.STANDARD_TIMEOUT }) - .clear() - .type(date); + .scrollIntoView({ block: "center" }) + .should("exist") + .and("not.be.disabled") + .clear({ force: true }) + .type(date, { force: true }); return this; } @@ -422,7 +471,10 @@ export class ReviewAssessmentPage extends BasePage { * Scroll to Assessment List section */ scrollToAssessmentList(): this { - cy.get(this.assessment.assessmentMainView, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.assessment.assessmentMainView, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("be.visible") .scrollIntoView(); return this; } @@ -431,7 +483,9 @@ export class ReviewAssessmentPage extends BasePage { * Click Create Assessment button in Assessment List view */ clickCreateAssessment(): this { - cy.get(this.assessment.createAssessmentButton, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.assessment.createAssessmentButton, { + timeout: this.STANDARD_TIMEOUT, + }) .should("be.visible") .click({ force: true }); return this; @@ -441,7 +495,9 @@ export class ReviewAssessmentPage extends BasePage { * Click Complete Assessment button in Assessment List view */ clickCompleteAssessment(): this { - cy.get(this.assessment.completeAssessmentButton, { timeout: this.STANDARD_TIMEOUT }) + cy.get(this.assessment.completeAssessmentButton, { + timeout: this.STANDARD_TIMEOUT, + }) .should("not.be.disabled") .click({ force: true }); return this; diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index fd9091b320..95e70320cf 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -52,7 +52,8 @@ const TEST_CONFIG = { }; const STATUS_ACTIONS = { - menuButton: "button.dropdown-toggle[data-bs-toggle='dropdown']", + menuButton: "#ApplicationActionDropdown .dropdown-toggle", + menu: "#ApplicationActionDropdown .dropdown-menu", startReview: "#Application_StartReviewButton", completeReview: "#Application_CompleteReviewButton", startAssessment: "#Application_StartAssessmentButton", @@ -82,6 +83,7 @@ const BREADCRUMB_STATUS_SELECTOR = /** Navigate to the Payments tab and filter the table by submissionId. */ function navigateToPaymentsAndSearch(): void { + listPage.waitForNoBlockingOverlay(); cy.reload(); navPage.goToPayments(); cy.location("pathname", { timeout: 20000 }).should("include", "Payment"); @@ -92,13 +94,23 @@ const BREADCRUMB_STATUS_SELECTOR = cy.contains("tr", submissionId, { timeout: 20000 }).should("exist"); } + function waitForBlockingUiToClear(): void { + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); + cy.get(".modal.show", { timeout: 20000 }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + } + function openStatusActionsMenu(): void { + waitForBlockingUiToClear(); + detailsPage.dismissErrorModalIfPresent(); cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) .filter(":visible") .first() + .scrollIntoView() .should("be.visible") + .and("not.contain.text", "Processing...") .click({ force: true }); - cy.get("ul.dropdown-menu", { timeout: 20000 }).should("be.visible"); + cy.get(STATUS_ACTIONS.menu, { timeout: 20000 }).should("be.visible"); } function clickStatusAction(actionSelector: string): void { @@ -132,13 +144,13 @@ const BREADCRUMB_STATUS_SELECTOR = } function confirmStatusActionIfNeeded(): void { - // Short grace period so whichever confirmation dialog appears has time to mount. - cy.wait(300); + cy.wait(500); cy.get("body").then(($body) => { if ($body.find(".swal2-popup .swal2-confirm").length > 0) { cy.get(".swal2-popup .swal2-confirm", { timeout: 20000 }) .should("be.visible") .click({ force: true }); + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); return; } @@ -153,6 +165,10 @@ const BREADCRUMB_STATUS_SELECTOR = .should("be.visible") .click({ force: true }); }); + cy.contains(".modal.show .modal-content", "Confirm Action", { + timeout: 20000, + }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); } }); } @@ -163,6 +179,12 @@ const BREADCRUMB_STATUS_SELECTOR = cy.get(".swal2-container .swal2-confirm", { timeout: 20000 }) .should("be.visible") .click({ force: true }); + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); + } + + if ($body.find(".modal.show").length > 0) { + cy.get(".modal.show", { timeout: 20000 }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); } }); } @@ -209,10 +231,10 @@ const BREADCRUMB_STATUS_SELECTOR = function populateReviewFieldsRequiredForCompleteReview(): void { detailsPage.goToReviewAssessmentTab(); - // The tab can render asynchronously; ensure form fields are ready before typing. - cy.get("#formio, #ApprovalView_ApprovedAmount", { timeout: 30000 }).should( - "exist", - ); + reviewPage.verifyFormioLoaded(); + cy.get("#ApprovalView_ApprovedAmount", { timeout: 30000 }) + .should("be.visible") + .and("not.be.disabled"); cy.get("body").then(($body) => { if ($body.find("#ApprovalView_ApprovedAmount").length > 0) { @@ -238,6 +260,7 @@ const BREADCRUMB_STATUS_SELECTOR = dismissBlockingModalIfPresent(); listPage + .waitForNoBlockingOverlay() .selectQuickDateRange("alltime") .waitForTableRefresh() .searchForSubmission(submissionId) @@ -285,6 +308,7 @@ const BREADCRUMB_STATUS_SELECTOR = cy.contains(".modal-title", "Assessment Users", { timeout: 20000 }).should( "not.exist", ); + listPage.waitForNoBlockingOverlay(); } function clickAdjudicationActionIfEnabled(actionSelector: string): void { @@ -306,7 +330,9 @@ const BREADCRUMB_STATUS_SELECTOR = /** Select the submissionId row and open the Approve Payments modal. */ function selectRowAndOpenApproveModal(): void { + waitForBlockingUiToClear(); cy.contains("tr", submissionId, { timeout: 20000 }) + .scrollIntoView() .find(".checkbox-select") .click({ force: true }); cy.contains("button", "Approve", { timeout: 20000 }) @@ -405,6 +431,7 @@ const BREADCRUMB_STATUS_SELECTOR = it("Select submission and open details", () => { dismissBlockingModalIfPresent(); + listPage.waitForNoBlockingOverlay(); cy.location("pathname", { timeout: 20000 }).then((pathname) => { if (pathname.includes("/GrantApplications/Details")) { @@ -584,7 +611,10 @@ const BREADCRUMB_STATUS_SELECTOR = // ============ Payment Request ============ it("Select approved application and submit payment request", () => { - listPage.selectRowByText(submissionId).clickPaymentButtonWithWait(); + listPage + .waitForNoBlockingOverlay() + .selectRowByText(submissionId) + .clickPaymentButtonWithWait(); listPage.waitForPaymentModalVisible(); // Description field has a max length of 40 chars @@ -601,7 +631,7 @@ const BREADCRUMB_STATUS_SELECTOR = .and("not.be.disabled") .click(); - cy.get("#payment-modal", { timeout: 20000 }).should("not.be.visible"); + listPage.verifyPaymentModalClosed(); cy.log(`✅ Payment request submitted: ${paymentDescription}`); }); From bb37a59f1b8d98892576416c0e14436ded6887ce Mon Sep 17 00:00:00 2001 From: Stephan McColm Date: Thu, 16 Apr 2026 11:55:40 -0700 Subject: [PATCH 05/45] AB#32508 Fix detached row click in ApplicationsListPage during async table redraw --- .../cypress/pages/ApplicationsListPage.ts | 1391 +++++++++-------- 1 file changed, 709 insertions(+), 682 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index 81fc241dd3..e53b037ad0 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -11,695 +11,722 @@ import { ApplicationsPage } from "./ListPages"; * - Table horizontal scrolling and column visibility */ export class ApplicationsListPage extends ApplicationsPage { - private readonly STANDARD_TIMEOUT = 20000; - private readonly BUTTON_TIMEOUT = 60000; - - // Date filter selectors - private readonly dateFilters = { - quickDateRange: "select#quickDateRange", - submittedFromDate: "input#submittedFromDate", - submittedToDate: "input#submittedToDate", - spinner: 'div.spinner-grow[role="status"]', - }; - - // Quick date range option values - private readonly quickDateRangeOptions = { - today: "today", - last7Days: "last7days", - last30Days: "last30days", - last3Months: "last3months", - last6Months: "last6months", - allTime: "alltime", - custom: "custom", - }; - - // Extended action bar selectors (beyond ApplicationsPage) - private readonly extendedActionBar = { - customButtons: "#app_custom_buttons", - dynamicButtonContainer: "#dynamicButtonContainerId", - exportButton: - "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", - saveViewButton: "button.grp-savedStates", - }; - - // Table scrolling selectors - private readonly scrollTable = { - scrollBody: ".dt-scroll-body", - tableRows: ".dt-scroll-body tbody tr", - scrollHead: ".dt-scroll-head", - columnTitles: ".dt-scroll-head span.dt-column-title", - }; - - // Columns menu selectors - private readonly columnsMenu = { - dropdownItem: "a.dropdown-item", - buttonBackground: "div.dt-button-background", - }; - - // Payment modal selectors - private readonly paymentModal = { - modal: "#payment-modal", - backdrop: ".modal-backdrop", - cancelButton: "#payment-modal .modal-footer button", - }; - - private readonly blockingUi = { - bootstrapModal: ".modal.show", - swalContainer: ".swal2-container", - }; - - // Grant program selectors - private readonly grantProgram = { - userInitials: ".unity-user-initials", - userDropdown: "#user-dropdown a.dropdown-item", - searchInput: "#search-grant-programs", - programsTableRow: "#UserGrantProgramsTable tbody tr", - }; - - // Save view selectors - private readonly saveView = { - button: "button.grp-savedStates", - resetOption: "a.dropdown-item", - }; - - constructor() { - super(); - } - - // ============ Date Filter Methods ============ - - /** - * Select a quick date range from the dropdown - * @param range - One of: "today", "last7days", "last30days", "last3months", "last6months", "alltime", "custom" - */ - selectQuickDateRange( - range: - | "today" - | "last7days" - | "last30days" - | "last3months" - | "last6months" - | "alltime" - | "custom", - ): this { - this.waitForNoBlockingOverlay(); - cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) - .scrollIntoView() - .should("be.visible") - .and("not.be.disabled") - .select(range); - return this; - } - - /** - * Select "Last 6 months" from quick date range (default) - */ - selectLast6Months(): this { - return this.selectQuickDateRange("last6months"); - } - - /** - * Select "All time" from quick date range - */ - selectAllTime(): this { - return this.selectQuickDateRange("alltime"); - } - - /** - * Verify the quick date range dropdown has expected value - */ - verifyQuickDateRangeValue( - expectedValue: - | "today" - | "last7days" - | "last30days" - | "last3months" - | "last6months" - | "alltime" - | "custom", - ): this { - cy.get(this.dateFilters.quickDateRange, { - timeout: this.STANDARD_TIMEOUT, - }).should("have.value", expectedValue); - return this; - } - - /** - * Set the Submitted From Date filter (for custom date range) - * @deprecated Use selectQuickDateRange() instead. This method is for custom date ranges only. - */ - setSubmittedFromDate(date: string): this { - cy.get(this.dateFilters.submittedFromDate, { - timeout: this.STANDARD_TIMEOUT, - }) - .scrollIntoView() - .should("be.visible") - .click() - .clear() - .type(date) - .trigger("change") - .blur() - .should("have.value", date); - return this; - } - - /** - * Set the Submitted To Date filter - */ - setSubmittedToDate(date: string): this { - cy.get(this.dateFilters.submittedToDate, { timeout: this.STANDARD_TIMEOUT }) - .scrollIntoView() - .should("be.visible") - .click() - .clear() - .type(date) - .trigger("change") - .blur() - .should("have.value", date); - return this; - } - - /** - * Wait for table refresh (spinner to be hidden) - */ - waitForTableRefresh(): this { - cy.get(this.dateFilters.spinner, { timeout: this.STANDARD_TIMEOUT }).then( - ($s: JQuery) => { - cy.wrap($s) - .should("have.attr", "style") - .and("contain", "display: none"); - }, - ); - return this; - } - - /** - * Get today's date in ISO local format (YYYY-MM-DD) - */ - getTodayIsoLocal(): string { - const d = new Date(); - const pad2 = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; - } - - // ============ Search Methods ============ - - /** - * Search for a submission by ID - */ - searchForSubmission(submissionId: string): this { - cy.get("#search", { timeout: this.STANDARD_TIMEOUT }) - .clear() - .type(submissionId); - this.waitForTableRefresh(); - return this; - } - - /** - * Select a row by matching text content - */ - selectRowByText(text: string): this { - this.waitForNoBlockingOverlay(); - cy.contains("tr", text, { timeout: this.STANDARD_TIMEOUT }) - .scrollIntoView() - .find(".checkbox-select") - .should("be.visible") - .click(); - return this; - } - - /** - * Click the OPEN button (external link) - */ - clickOpenButton(): this { - cy.get("#externalLink", { timeout: this.STANDARD_TIMEOUT }) - .should("exist") - .should("be.visible") - .click(); - return this; - } - - // ============ Extended Table Methods ============ - - /** - * Verify table has rows (using scroll body selector) - */ - verifyTableHasData(): this { - cy.get(this.scrollTable.tableRows, { - timeout: this.STANDARD_TIMEOUT, - }).should("have.length.greaterThan", 1); - return this; - } - - /** - * Select a row by index (clicks on a non-link cell) - */ - selectRowByIndex(rowIndex: number, withCtrl = false): this { - const getTargetCell = () => - cy - .get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT }) - .eq(rowIndex) - .find("td") - .not(":has(a)") - .first(); - - cy.get(this.scrollTable.tableRows, { - timeout: this.STANDARD_TIMEOUT, - }).should("have.length.greaterThan", rowIndex); - - getTargetCell().should("exist"); - - // Break the chain and re-query right before click to avoid stale element errors - // when the datatable re-renders asynchronously. - getTargetCell().then(($cell) => { - if (Cypress.dom.isAttached($cell) && Cypress.dom.isVisible($cell)) { - cy.wrap($cell).scrollIntoView().click({ ctrlKey: withCtrl }); - } else { - getTargetCell().click({ ctrlKey: withCtrl, force: true }); - } - }); - return this; - } - - /** - * Select multiple rows by indices - */ - selectMultipleRows(indices: number[]): this { - indices.forEach((index, i) => { - this.selectRowByIndex(index, i > 0); - }); - return this; - } - - /** - * Scroll table horizontally to a specific position - */ - scrollTableHorizontally(x: number): this { - cy.get(this.scrollTable.scrollBody, { timeout: this.STANDARD_TIMEOUT }) - .should("exist") - .scrollTo(x, 0, { duration: 0, ensureScrollable: false }); - return this; - } - - /** - * Get visible header titles from the table - */ - getVisibleHeaderTitles(): Cypress.Chainable { - return cy - .get(this.scrollTable.columnTitles, { timeout: this.STANDARD_TIMEOUT }) - .then(($els: JQuery) => { - const titles: string[] = Cypress.$($els) - .toArray() - .map((el: HTMLElement) => - (el.textContent || "").replace(/\s+/g, " ").trim(), - ) - .filter((t: string) => t.length > 0); - return titles; - }); - } - - /** - * Assert that visible headers include expected columns (case-insensitive) - */ - assertVisibleHeadersInclude(expected: string[]): this { - this.getVisibleHeaderTitles().then((titles: string[]) => { - const titlesLower = titles.map((t: string) => t.toLowerCase()); - expected.forEach((e: string) => { - expect(titlesLower, `visible headers should include "${e}"`).to.include( - e.toLowerCase(), - ); - }); - }); - return this; - } - - // ============ Extended Action Bar Methods ============ - - /** - * Scroll to and verify action bar exists - */ - verifyActionBarExists(): this { - cy.get(this.extendedActionBar.customButtons, { - timeout: this.STANDARD_TIMEOUT, - }) - .should("exist") - .scrollIntoView(); - return this; - } - - /** - * Click the Payment button (extended with visibility checks) - */ - clickPaymentButtonWithWait(): this { - this.waitForNoBlockingOverlay(); - cy.get("#applicationPaymentRequest", { timeout: this.BUTTON_TIMEOUT }) - .should("be.visible") - .and("not.be.disabled") - .click(); - return this; - } - - /** - * Verify Export button is visible - */ - verifyExportButtonVisible(): this { - cy.contains(this.extendedActionBar.exportButton, "Export", { - timeout: this.STANDARD_TIMEOUT, - matchCase: false, - }) - .scrollIntoView() - .should("be.visible"); - return this; - } - - /** - * Verify Save View button is visible - */ - verifySaveViewButtonVisible(): this { - cy.contains( - "#dynamicButtonContainerId button.grp-savedStates", - "Save View", - { timeout: this.STANDARD_TIMEOUT }, - ).should("be.visible"); - return this; - } - - /** - * Verify Columns button is visible - */ - verifyColumnsButtonVisible(): this { - cy.contains( - "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", - "Columns", - { timeout: this.STANDARD_TIMEOUT, matchCase: false }, - ) - .scrollIntoView() - .should("be.visible"); - return this; - } - - /** - * Verify dynamic button container exists - */ - verifyDynamicButtonContainerExists(): this { - cy.get(this.extendedActionBar.dynamicButtonContainer, { - timeout: this.STANDARD_TIMEOUT, - }) - .should("exist") - .scrollIntoView(); - return this; - } - - // ============ Payment Modal Methods ============ - - /** - * Wait for payment modal to be visible - */ - waitForPaymentModalVisible(): this { - cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }) - .should("be.visible") - .and("have.class", "show"); - cy.get(this.paymentModal.backdrop, { - timeout: this.STANDARD_TIMEOUT, - }).should("exist"); - return this; - } - - /** - * Wait for blocking modal overlays to clear before interacting with list controls. - */ - waitForNoBlockingOverlay(): this { - cy.get(this.blockingUi.swalContainer, { - timeout: this.STANDARD_TIMEOUT, - }).should("not.exist"); - - cy.get(this.blockingUi.bootstrapModal, { - timeout: this.STANDARD_TIMEOUT, - }).should("not.exist"); - - cy.get(this.paymentModal.backdrop, { - timeout: this.STANDARD_TIMEOUT, - }).should("not.exist"); - return this; - } - - /** - * Close payment modal using multiple strategies - */ - closePaymentModal(): this { - cy.get("body").then(($body: JQuery) => { - if ($body.find(`${this.paymentModal.modal}.show`).length === 0) { - return; - } - - const $cancelBtn = $body - .find(this.paymentModal.cancelButton) - .filter((_: number, el: HTMLElement) => - (el.textContent || "").includes("Cancel"), + private readonly STANDARD_TIMEOUT = 20000; + private readonly BUTTON_TIMEOUT = 60000; + + // Date filter selectors + private readonly dateFilters = { + quickDateRange: "select#quickDateRange", + submittedFromDate: "input#submittedFromDate", + submittedToDate: "input#submittedToDate", + spinner: 'div.spinner-grow[role="status"]', + }; + + // Quick date range option values + private readonly quickDateRangeOptions = { + today: "today", + last7Days: "last7days", + last30Days: "last30days", + last3Months: "last3months", + last6Months: "last6months", + allTime: "alltime", + custom: "custom", + }; + + // Extended action bar selectors (beyond ApplicationsPage) + private readonly extendedActionBar = { + customButtons: "#app_custom_buttons", + dynamicButtonContainer: "#dynamicButtonContainerId", + exportButton: + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", + saveViewButton: "button.grp-savedStates", + }; + + // Table scrolling selectors + private readonly scrollTable = { + scrollBody: ".dt-scroll-body", + tableRows: ".dt-scroll-body tbody tr", + scrollHead: ".dt-scroll-head", + columnTitles: ".dt-scroll-head span.dt-column-title", + }; + + // Columns menu selectors + private readonly columnsMenu = { + dropdownItem: "a.dropdown-item", + buttonBackground: "div.dt-button-background", + }; + + // Payment modal selectors + private readonly paymentModal = { + modal: "#payment-modal", + backdrop: ".modal-backdrop", + cancelButton: "#payment-modal .modal-footer button", + }; + + private readonly blockingUi = { + bootstrapModal: ".modal.show", + swalContainer: ".swal2-container", + }; + + // Grant program selectors + private readonly grantProgram = { + userInitials: ".unity-user-initials", + userDropdown: "#user-dropdown a.dropdown-item", + searchInput: "#search-grant-programs", + programsTableRow: "#UserGrantProgramsTable tbody tr", + }; + + // Save view selectors + private readonly saveView = { + button: "button.grp-savedStates", + resetOption: "a.dropdown-item", + }; + + constructor() { + super(); + } + + // ============ Date Filter Methods ============ + + /** + * Select a quick date range from the dropdown + * @param range - One of: "today", "last7days", "last30days", "last3months", "last6months", "alltime", "custom" + */ + selectQuickDateRange( + range: + | "today" + | "last7days" + | "last30days" + | "last3months" + | "last6months" + | "alltime" + | "custom", + ): this { + this.waitForNoBlockingOverlay(); + cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView() + .should("be.visible") + .and("not.be.disabled") + .select(range); + return this; + } + + /** + * Select "Last 6 months" from quick date range (default) + */ + selectLast6Months(): this { + return this.selectQuickDateRange("last6months"); + } + + /** + * Select "All time" from quick date range + */ + selectAllTime(): this { + return this.selectQuickDateRange("alltime"); + } + + /** + * Verify the quick date range dropdown has expected value + */ + verifyQuickDateRangeValue( + expectedValue: + | "today" + | "last7days" + | "last30days" + | "last3months" + | "last6months" + | "alltime" + | "custom", + ): this { + cy.get(this.dateFilters.quickDateRange, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.value", expectedValue); + return this; + } + + /** + * Set the Submitted From Date filter (for custom date range) + * @deprecated Use selectQuickDateRange() instead. This method is for custom date ranges only. + */ + setSubmittedFromDate(date: string): this { + cy.get(this.dateFilters.submittedFromDate, { + timeout: this.STANDARD_TIMEOUT, + }) + .scrollIntoView() + .should("be.visible") + .click() + .clear() + .type(date) + .trigger("change") + .blur() + .should("have.value", date); + return this; + } + + /** + * Set the Submitted To Date filter + */ + setSubmittedToDate(date: string): this { + cy.get(this.dateFilters.submittedToDate, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView() + .should("be.visible") + .click() + .clear() + .type(date) + .trigger("change") + .blur() + .should("have.value", date); + return this; + } + + /** + * Wait for table refresh (spinner to be hidden) + */ + waitForTableRefresh(): this { + cy.get(this.dateFilters.spinner, { timeout: this.STANDARD_TIMEOUT }).then( + ($s: JQuery) => { + cy.wrap($s) + .should("have.attr", "style") + .and("contain", "display: none"); + }, ); + return this; + } + + /** + * Get today's date in ISO local format (YYYY-MM-DD) + */ + getTodayIsoLocal(): string { + const d = new Date(); + const pad2 = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + } + + // ============ Search Methods ============ + + /** + * Search for a submission by ID + */ + searchForSubmission(submissionId: string): this { + cy.get("#search", { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(submissionId); + this.waitForTableRefresh(); + return this; + } + + /** + * Select a row by matching text content + */ + selectRowByText(text: string): this { + this.waitForNoBlockingOverlay(); + cy.contains("tr", text, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView() + .find(".checkbox-select") + .should("be.visible") + .click(); + return this; + } + + /** + * Click the OPEN button (external link) + */ + clickOpenButton(): this { + cy.get("#externalLink", { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .should("be.visible") + .click(); + return this; + } + + // ============ Extended Table Methods ============ + + /** + * Verify table has rows (using scroll body selector) + */ + verifyTableHasData(): this { + cy.get(this.scrollTable.tableRows, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.greaterThan", 1); + return this; + } + + /** + * Dispatch a native mouse click sequence against the current target cell. + * This avoids Cypress holding a stale DOM reference while DataTables redraws. + */ + private clickRowCellNative(rowIndex: number, withCtrl = false): this { + this.waitForNoBlockingOverlay(); + + cy.window({ log: false }).then((win: Cypress.AUTWindow) => { + cy.get(this.scrollTable.tableRows, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("have.length.greaterThan", rowIndex) + .eq(rowIndex) + .should("exist") + .then(($row: JQuery) => { + const $cell = $row.find("td").not(":has(a)").first(); + + expect( + $cell.length, + `row ${rowIndex} should contain at least one non-link cell`, + ).to.be.greaterThan(0); + + const cell = $cell[0] as HTMLElement; + + cell.scrollIntoView({ + block: "center", + inline: "nearest", + }); + + const eventInit: MouseEventInit = { + bubbles: true, + cancelable: true, + view: win, + ctrlKey: withCtrl, + metaKey: withCtrl, + button: 0, + buttons: 1, + }; + + cell.dispatchEvent(new win.MouseEvent("mouseover", eventInit)); + cell.dispatchEvent(new win.MouseEvent("mousemove", eventInit)); + cell.dispatchEvent(new win.MouseEvent("mousedown", eventInit)); + cell.dispatchEvent(new win.MouseEvent("mouseup", eventInit)); + cell.dispatchEvent(new win.MouseEvent("click", eventInit)); + }); + }); + + return this; + } + + /** + * Select a row by index (clicks on a non-link cell) + */ + selectRowByIndex(rowIndex: number, withCtrl = false): this { + return this.clickRowCellNative(rowIndex, withCtrl); + } + + /** + * Select multiple rows by indices + */ + selectMultipleRows(indices: number[]): this { + indices.forEach((index, i) => { + this.selectRowByIndex(index, i > 0); + }); + return this; + } + + /** + * Scroll table horizontally to a specific position + */ + scrollTableHorizontally(x: number): this { + cy.get(this.scrollTable.scrollBody, { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .scrollTo(x, 0, { duration: 0, ensureScrollable: false }); + return this; + } + + /** + * Get visible header titles from the table + */ + getVisibleHeaderTitles(): Cypress.Chainable { + return cy + .get(this.scrollTable.columnTitles, { timeout: this.STANDARD_TIMEOUT }) + .then(($els: JQuery) => { + const titles: string[] = Cypress.$($els) + .toArray() + .map((el: HTMLElement) => + (el.textContent || "").replace(/\s+/g, " ").trim(), + ) + .filter((t: string) => t.length > 0); + return titles; + }); + } + + /** + * Assert that visible headers include expected columns (case-insensitive) + */ + assertVisibleHeadersInclude(expected: string[]): this { + this.getVisibleHeaderTitles().then((titles: string[]) => { + const titlesLower = titles.map((t: string) => t.toLowerCase()); + expected.forEach((e: string) => { + expect(titlesLower, `visible headers should include "${e}"`).to.include( + e.toLowerCase(), + ); + }); + }); + return this; + } + + // ============ Extended Action Bar Methods ============ + + /** + * Scroll to and verify action bar exists + */ + verifyActionBarExists(): this { + cy.get(this.extendedActionBar.customButtons, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .scrollIntoView(); + return this; + } + + /** + * Click the Payment button (extended with visibility checks) + */ + clickPaymentButtonWithWait(): this { + this.waitForNoBlockingOverlay(); + cy.get("#applicationPaymentRequest", { timeout: this.BUTTON_TIMEOUT }) + .should("be.visible") + .and("not.be.disabled") + .click(); + return this; + } + + /** + * Verify Export button is visible + */ + verifyExportButtonVisible(): this { + cy.contains(this.extendedActionBar.exportButton, "Export", { + timeout: this.STANDARD_TIMEOUT, + matchCase: false, + }) + .scrollIntoView() + .should("be.visible"); + return this; + } + + /** + * Verify Save View button is visible + */ + verifySaveViewButtonVisible(): this { + cy.contains( + "#dynamicButtonContainerId button.grp-savedStates", + "Save View", + { timeout: this.STANDARD_TIMEOUT }, + ).should("be.visible"); + return this; + } + + /** + * Verify Columns button is visible + */ + verifyColumnsButtonVisible(): this { + cy.contains( + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: this.STANDARD_TIMEOUT, matchCase: false }, + ) + .scrollIntoView() + .should("be.visible"); + return this; + } + + /** + * Verify dynamic button container exists + */ + verifyDynamicButtonContainerExists(): this { + cy.get(this.extendedActionBar.dynamicButtonContainer, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .scrollIntoView(); + return this; + } + + // ============ Payment Modal Methods ============ + + /** + * Wait for payment modal to be visible + */ + waitForPaymentModalVisible(): this { + cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .and("have.class", "show"); + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("exist"); + return this; + } + + /** + * Wait for blocking modal overlays to clear before interacting with list controls. + */ + waitForNoBlockingOverlay(): this { + cy.get(this.blockingUi.swalContainer, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + + cy.get(this.blockingUi.bootstrapModal, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + return this; + } + + /** + * Close payment modal using multiple strategies + */ + closePaymentModal(): this { + cy.get("body").then(($body: JQuery) => { + if ($body.find(`${this.paymentModal.modal}.show`).length === 0) { + return; + } + + const $cancelBtn = $body + .find(this.paymentModal.cancelButton) + .filter((_: number, el: HTMLElement) => + (el.textContent || "").includes("Cancel"), + ); + + if ($cancelBtn.length > 0) { + cy.wrap($cancelBtn.first()).scrollIntoView().click({ force: true }); + } + }); - if ($cancelBtn.length > 0) { - cy.wrap($cancelBtn.first()).scrollIntoView().click({ force: true }); - } - }); - - cy.get("body").then(($body: JQuery) => { - if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { - cy.get("body").type("{esc}", { force: true }); - } - }); - - cy.get("body").then(($body: JQuery) => { - if ( - $body.find(`${this.paymentModal.modal}.show`).length > 0 && - $body.find(this.paymentModal.backdrop).length > 0 - ) { - cy.get(this.paymentModal.backdrop).first().click("topLeft", { - force: true, + cy.get("body").then(($body: JQuery) => { + if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { + cy.get("body").type("{esc}", { force: true }); + } }); - } - }); - - cy.get("body").then(($body: JQuery) => { - if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { - cy.window().then((win: Cypress.AUTWindow) => { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const windowWithModal = win as any; - if (typeof windowWithModal.closePaymentModal === "function") { - windowWithModal.closePaymentModal(); + + cy.get("body").then(($body: JQuery) => { + if ( + $body.find(`${this.paymentModal.modal}.show`).length > 0 && + $body.find(this.paymentModal.backdrop).length > 0 + ) { + cy.get(this.paymentModal.backdrop).first().click("topLeft", { + force: true, + }); } - } catch { - /* ignore */ - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const $ = (win as any).jQuery || (win as any).$; - if ($) { - try { - $(this.paymentModal.modal) - .removeClass("show") - .attr("aria-hidden", "true") - .css("display", "none"); - $(this.paymentModal.backdrop).remove(); - $("body").removeClass("modal-open").css("overflow", ""); - } catch { - /* ignore */ + }); + + cy.get("body").then(($body: JQuery) => { + if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { + cy.window().then((win: Cypress.AUTWindow) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const windowWithModal = win as any; + if (typeof windowWithModal.closePaymentModal === "function") { + windowWithModal.closePaymentModal(); + } + } catch { + /* ignore */ + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $ = (win as any).jQuery || (win as any).$; + if ($) { + try { + $(this.paymentModal.modal) + .removeClass("show") + .attr("aria-hidden", "true") + .css("display", "none"); + $(this.paymentModal.backdrop).remove(); + $("body").removeClass("modal-open").css("overflow", ""); + } catch { + /* ignore */ + } + } + }); } - } }); - } - }); - - return this.verifyPaymentModalClosed(); - } - - /** - * Verify payment modal is closed - */ - verifyPaymentModalClosed(): this { - cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }).should( - ($m: JQuery) => { - const isHidden = !$m.is(":visible") || !$m.hasClass("show"); - expect(isHidden, "payment-modal hidden or not shown").to.eq(true); - }, - ); - - cy.get(this.paymentModal.backdrop, { - timeout: this.STANDARD_TIMEOUT, - }).should("not.exist"); - - cy.get(this.blockingUi.bootstrapModal, { - timeout: this.STANDARD_TIMEOUT, - }).should("not.exist"); - return this; - } - - /** - * Close any open dropdowns or modals - */ - closeOpenDropdowns(): this { - cy.get("body").then(($body: JQuery) => { - if ($body.find(this.columnsMenu.buttonBackground).length > 0) { - cy.get(this.columnsMenu.buttonBackground).click({ force: true }); - } - }); - return this; - } - - /** - * Open Save View dropdown and reset to default - */ - resetToDefaultView(): this { - cy.get(this.saveView.button, { timeout: this.STANDARD_TIMEOUT }) - .should("be.visible") - .and("contain.text", "Save View") - .click(); - - cy.contains(this.saveView.resetOption, "Reset to Default View", { - timeout: this.STANDARD_TIMEOUT, - }) - .should("be.visible") - .click(); - - // Wait for table to rebuild - cy.get(this.scrollTable.columnTitles, { - timeout: this.STANDARD_TIMEOUT, - }).should("have.length.gt", 5); - return this; - } - - /** - * Open the Columns menu - */ - openColumnsMenu(): this { - cy.contains( - "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", - "Columns", - { timeout: this.STANDARD_TIMEOUT, matchCase: false }, - ) - .scrollIntoView() - .should("be.visible") - .click(); - - // Wait for dropdown to be fully populated - cy.get(this.columnsMenu.dropdownItem, { - timeout: this.STANDARD_TIMEOUT, - }).should("have.length.gt", 50); - return this; - } - - /** - * Click a column item in the Columns menu (case-insensitive) - */ - clickColumnsItem(label: string): this { - cy.contains(this.columnsMenu.dropdownItem, label, { - timeout: this.STANDARD_TIMEOUT, - matchCase: false, - }) - .scrollIntoView() - .should("exist") - .then(($item) => { - if (Cypress.dom.isVisible($item)) { - cy.wrap($item).click(); - } else { - // Some dropdown items are clipped by overflow containers; native click still triggers menu toggle reliably. - ($item[0] as HTMLElement).click(); - } - }); - return this; - } - - /** - * Toggle multiple columns (click each one) - */ - toggleColumns(columns: string[]): this { - columns.forEach((column) => { - this.clickColumnsItem(column); - }); - return this; - } - - /** - * Close the Columns menu - */ - closeColumnsMenu(): this { - cy.get(this.columnsMenu.buttonBackground, { - timeout: this.STANDARD_TIMEOUT, - }) - .should("exist") - .click({ force: true }); - - cy.get(this.columnsMenu.buttonBackground, { - timeout: this.STANDARD_TIMEOUT, - }).should("not.exist"); - return this; - } - - // ============ Grant Program Methods ============ - - /** - * Switch to a specific grant program if available - * Note: Consider using NavigationPage.switchToTenantIfAvailable() for consistency - */ - switchToGrantProgram(programName: string): this { - cy.get("body").then(($body: JQuery) => { - const hasUserInitials = - $body.find(this.grantProgram.userInitials).length > 0; - - if (!hasUserInitials) { - cy.log("Skipping tenant switch: no user initials menu found"); - return; - } - - cy.get(this.grantProgram.userInitials).click(); - - cy.get("body").then(($body2: JQuery) => { - const switchLink = $body2 - .find(this.grantProgram.userDropdown) - .filter((_: number, el: HTMLElement) => { - return (el.textContent || "").trim() === "Switch Grant Programs"; - }); - - if (switchLink.length === 0) { - cy.log( - 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session', - ); - cy.get("body").click(0, 0); - return; - } - - cy.wrap(switchLink.first()).click(); - - cy.url({ timeout: this.STANDARD_TIMEOUT }).should( - "include", - "/GrantPrograms", + + return this.verifyPaymentModalClosed(); + } + + /** + * Verify payment modal is closed + */ + verifyPaymentModalClosed(): this { + cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }).should( + ($m: JQuery) => { + const isHidden = !$m.is(":visible") || !$m.hasClass("show"); + expect(isHidden, "payment-modal hidden or not shown").to.eq(true); + }, ); - cy.get(this.grantProgram.searchInput, { - timeout: this.STANDARD_TIMEOUT, + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + + cy.get(this.blockingUi.bootstrapModal, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + return this; + } + + /** + * Close any open dropdowns or modals + */ + closeOpenDropdowns(): this { + cy.get("body").then(($body: JQuery) => { + if ($body.find(this.columnsMenu.buttonBackground).length > 0) { + cy.get(this.columnsMenu.buttonBackground).click({ force: true }); + } + }); + return this; + } + + /** + * Open Save View dropdown and reset to default + */ + resetToDefaultView(): this { + cy.get(this.saveView.button, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .and("contain.text", "Save View") + .click(); + + cy.contains(this.saveView.resetOption, "Reset to Default View", { + timeout: this.STANDARD_TIMEOUT, }) - .should("be.visible") - .clear() - .type(programName); - - cy.contains(this.grantProgram.programsTableRow, programName, { - timeout: this.STANDARD_TIMEOUT, + .should("be.visible") + .click(); + + // Wait for table to rebuild + cy.get(this.scrollTable.columnTitles, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.gt", 5); + return this; + } + + /** + * Open the Columns menu + */ + openColumnsMenu(): this { + cy.contains( + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: this.STANDARD_TIMEOUT, matchCase: false }, + ) + .scrollIntoView() + .should("be.visible") + .click(); + + // Wait for dropdown to be fully populated + cy.get(this.columnsMenu.dropdownItem, { + timeout: this.STANDARD_TIMEOUT, + }).should("have.length.gt", 50); + return this; + } + + /** + * Click a column item in the Columns menu (case-insensitive) + */ + clickColumnsItem(label: string): this { + cy.contains(this.columnsMenu.dropdownItem, label, { + timeout: this.STANDARD_TIMEOUT, + matchCase: false, }) - .should("exist") - .within(() => { - cy.contains("button", "Select").should("be.enabled").click(); - }); - - cy.location("pathname", { timeout: this.STANDARD_TIMEOUT }).should( - (p: string) => { - expect( - p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, - ).to.eq(true); - }, - ); - }); - }); - return this; - } -} + .scrollIntoView() + .should("exist") + .then(($item) => { + if (Cypress.dom.isVisible($item)) { + cy.wrap($item).click(); + } else { + // Some dropdown items are clipped by overflow containers; native click still triggers menu toggle reliably. + ($item[0] as HTMLElement).click(); + } + }); + return this; + } + + /** + * Toggle multiple columns (click each one) + */ + toggleColumns(columns: string[]): this { + columns.forEach((column) => { + this.clickColumnsItem(column); + }); + return this; + } + + /** + * Close the Columns menu + */ + closeColumnsMenu(): this { + cy.get(this.columnsMenu.buttonBackground, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .click({ force: true }); + + cy.get(this.columnsMenu.buttonBackground, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + return this; + } + + // ============ Grant Program Methods ============ + + /** + * Switch to a specific grant program if available + * Note: Consider using NavigationPage.switchToTenantIfAvailable() for consistency + */ + switchToGrantProgram(programName: string): this { + cy.get("body").then(($body: JQuery) => { + const hasUserInitials = + $body.find(this.grantProgram.userInitials).length > 0; + + if (!hasUserInitials) { + cy.log("Skipping tenant switch: no user initials menu found"); + return; + } + + cy.get(this.grantProgram.userInitials).click(); + + cy.get("body").then(($body2: JQuery) => { + const switchLink = $body2 + .find(this.grantProgram.userDropdown) + .filter((_: number, el: HTMLElement) => { + return (el.textContent || "").trim() === "Switch Grant Programs"; + }); + + if (switchLink.length === 0) { + cy.log( + 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session', + ); + cy.get("body").click(0, 0); + return; + } + + cy.wrap(switchLink.first()).click(); + + cy.url({ timeout: this.STANDARD_TIMEOUT }).should( + "include", + "/GrantPrograms", + ); + + cy.get(this.grantProgram.searchInput, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("be.visible") + .clear() + .type(programName); + + cy.contains(this.grantProgram.programsTableRow, programName, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .within(() => { + cy.contains("button", "Select").should("be.enabled").click(); + }); + + cy.location("pathname", { timeout: this.STANDARD_TIMEOUT }).should( + (p: string) => { + expect( + p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, + ).to.eq(true); + }, + ); + }); + }); + return this; + } +} \ No newline at end of file From 53e7cddfc78d5b0ab6ef9faa7938813710555f5c Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 16 Apr 2026 16:48:57 -0700 Subject: [PATCH 06/45] AB#32583 refine chefs toolbar layout --- .../Localization/GrantManager/en.json | 18 ++- .../ChefsAttachments/ChefsAttachments.css | 114 ++++++------------ .../ChefsAttachments/Default.cshtml | 18 +-- 3 files changed, 58 insertions(+), 92 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index fe3e30cd2c..a8f134376e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -102,10 +102,20 @@ "ReviewerList:Subtotal": "Subtotal", "ReviewerList:CloneAssessment": "Clone Assessment", - "AssessmentResultAttachments:Id": "#", - "AssessmentResultAttachments:DocumentName": "Document Name", - "AssessmentResultAttachments:UploadedDate": "Date", - "AssessmentResultAttachments:AttachedBy": "Attached by", + "AssessmentResultAttachments:Id": "#", + "AssessmentResultAttachments:DocumentName": "Document Name", + "AssessmentResultAttachments:UploadedDate": "Date", + "AssessmentResultAttachments:AttachedBy": "Attached by", + "ChefsAttachments:Title": "Submission Attachments", + "ChefsAttachments:Filter": "Filter", + "ChefsAttachments:Download": "Download", + "ChefsAttachments:GenerateSummaries": "Generate AI Summaries", + "ChefsAttachments:GenerateSummary": "Generate Summary", + "ChefsAttachments:HideAISummaries": "Hide AI Summaries", + "ChefsAttachments:ShowAISummaries": "Show AI Summaries", + "ChefsAttachments:HideSummaries": "Hide Summaries", + "ChefsAttachments:ShowSummaries": "Show Summaries", + "ChefsAttachments:NoSummariesAvailable": "No summaries available", "Enum:AssessmentState.IN_PROGRESS": "In Progress", "Enum:AssessmentState.IN_REVIEW": "Under Review by Team Lead", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css index a1595e980c..6a9a913c3d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.css @@ -8,7 +8,9 @@ display: flex; flex-direction: row; justify-content: space-between; - align-items: flex-end; + align-items: flex-start; + flex-wrap: wrap; + gap: 8px 8px; margin-bottom: 8px; } @@ -18,11 +20,39 @@ } .submission-title { - display: inline-block; + display: flex; + align-items: center; + height: 36px; + padding-top: 0; } .submission-button-section { display: flex; + flex: 1 1 0; + margin-left: auto; + flex-wrap: wrap; + align-items: center; + align-content: center; + justify-content: flex-end; + gap: 6px; + min-width: 0; +} + +.submission-button-section .btn { + flex: 0 0 auto; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + white-space: nowrap; +} + +.submission-button-section .button-content { + display: inline-flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; } #ChefsAttachmentsTable_paginate { @@ -33,58 +63,26 @@ /* AI Summary Row Styles */ .ai-summary-row { border-left: 4px solid var(--bc-colors-blue-primary); - margin: -8px -10px 5px; background: #faf9f8; padding: 10px 15px; - opacity: 0; - transition: opacity 0.5s ease-in-out; -} - -.ai-summary-row.fade-in { - opacity: 1; - animation: fadeIn 0.5s ease-in forwards; } -.ai-summary-row.fade-out { - opacity: 0; - animation: fadeOut 0.5s ease-out forwards; +#ChefsAttachmentsTable td.ai-summary-cell { + padding: 0 !important; } -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fadeOut { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(-10px); - } -} - -.ai-summary-content { - font-size: 14px; - line-height: 1.6; +#ChefsAttachmentsTable .ai-summary-content { color: #292929; + padding: 8px 12px; } -.ai-summary-content strong { +#ChefsAttachmentsTable .ai-summary-content strong { color: var(--bc-colors-blue-primary); font-weight: 600; font-size: 13px; } -.ai-summary-content p { +#ChefsAttachmentsTable .ai-summary-content p { margin-bottom: 0; color: #495057; white-space: pre-wrap; @@ -94,39 +92,3 @@ #ChefsAttachmentsTable tr.shown { background-color: rgba(13, 110, 253, 0.05); } - -/* Disabled state for AI summary toggle button */ -#toggleAllAISummaries:disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -#toggleAllAISummaries:disabled i { - opacity: 0.5; -} - -/* Fix double scrollbar issue when AI summaries are expanded */ -#ChefsAttachmentsTable_wrapper .dt-scroll-body { - max-height: none !important; - overflow-y: visible !important; -} - -/* Ensure table wrapper uses full width */ -#ChefsAttachmentsTable_wrapper { - width: 100%; -} - -/* Ensure scroll container properly sizes */ -#ChefsAttachmentsTable_wrapper .dt-scroll { - overflow-x: auto; -} - -#ChefsAttachmentsTable_wrapper .dt-scroll-head .dt-scroll-headInner, -#ChefsAttachmentsTable_wrapper .dt-scroll-head .dt-scroll-headInner table { - width: 100% !important; -} - -#ChefsAttachmentsTable_wrapper .dt-scroll-body table { - width: 100% !important; -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 5b5a3f3698..39651c6cb0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -1,43 +1,37 @@ -@using Microsoft.Extensions.Localization +@using Microsoft.Extensions.Localization @using Unity.GrantManager.Localization @inject IStringLocalizer L
-
Submission Attachments
+
Submission Attachments
@if (ViewBag.IsAIAttachmentSummariesEnabled) { - - } - - - @* *@
From 414ef567c234c4f13aa4d7c419bb8e6e924f8838 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 16 Apr 2026 17:04:10 -0700 Subject: [PATCH 07/45] AB#32583 rerun branch automation From 960191b870d2b0b58f8334fd1a44c2c3c3a2c8e8 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 11:07:34 -0700 Subject: [PATCH 08/45] AB#30833 Update application analysis quick review UI --- .../AI/Models/AIJsonKeys.cs | 6 +- .../ApplicationAnalysisRecommendation.cs | 13 -- .../Responses/ApplicationAnalysisResponse.cs | 11 +- .../Versions/v0/application-analysis.user.txt | 15 +- .../v1/application-analysis.output.txt | 10 +- .../v1/application-analysis.rules.txt | 14 +- .../AI/Runtime/AIProviderPayloadValidator.cs | 8 +- .../AI/Runtime/OpenAIRuntimeService.cs | 43 +--- .../DataSeed/AIPromptDataSeeder.cs | 39 ++-- .../IGrantApplicationAppService.cs | 4 +- .../GrantApplicationAppService.cs | 34 +-- .../Pages/GrantApplications/Details.cshtml | 108 ++++----- .../Pages/GrantApplications/Details.js | 14 -- .../Pages/GrantApplications/ai-analysis.js | 208 +++++++----------- 14 files changed, 193 insertions(+), 334 deletions(-) delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs index 501de0c856..8b5de11fe5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs @@ -2,14 +2,12 @@ namespace Unity.AI.Models { public static class AIJsonKeys { - public const string Rating = "rating"; public const string Errors = "errors"; public const string Warnings = "warnings"; public const string Summaries = "summaries"; - public const string NextSteps = "nextSteps"; - public const string Hidden = "hidden"; - public const string Recommendation = "recommendation"; + public const string Recommendations = "recommendations"; public const string Decision = "decision"; + public const string Hidden = "hidden"; public const string Id = "id"; public const string Title = "title"; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs deleted file mode 100644 index 6b082f5295..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Unity.AI.Models -{ - public class ApplicationAnalysisRecommendation - { - [JsonPropertyName(AIJsonKeys.Decision)] - public string? Decision { get; set; } - - [JsonPropertyName(AIJsonKeys.Rationale)] - public string? Rationale { get; set; } - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs index 12f3532f4e..68231cd763 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs @@ -6,8 +6,8 @@ namespace Unity.AI.Responses { public class ApplicationAnalysisResponse { - [JsonPropertyName(AIJsonKeys.Rating)] - public string? Rating { get; set; } + [JsonPropertyName(AIJsonKeys.Decision)] + public string? Decision { get; set; } [JsonPropertyName(AIJsonKeys.Errors)] public List Errors { get; set; } = new(); @@ -18,10 +18,7 @@ public class ApplicationAnalysisResponse [JsonPropertyName(AIJsonKeys.Summaries)] public List Summaries { get; set; } = new(); - [JsonPropertyName(AIJsonKeys.NextSteps)] - public List NextSteps { get; set; } = new(); - - [JsonPropertyName(AIJsonKeys.Recommendation)] - public ApplicationAnalysisRecommendation? Recommendation { get; set; } + [JsonPropertyName(AIJsonKeys.Recommendations)] + public List Recommendations { get; set; } = new(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt index 67a6fde997..8d7c84c90e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt @@ -57,7 +57,7 @@ Analyze this grant application comprehensively across all five rubric categories OUTPUT { - "rating": "", + "decision": "", "warnings": [ { "title": "", @@ -76,23 +76,18 @@ OUTPUT "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } Important: - Use only APPLICATION CONTENT, ATTACHMENT SUMMARIES, FORM FIELD CONFIGURATION, and EVALUATION RUBRIC as evidence. +- decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. -- Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. -- recommendation.decision must be PROCEED or HOLD. -- recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. +- Use recommendations for reviewer-facing follow-up actions or considerations before scoring or decision-making. - Use "title" and "detail" keys for all finding objects. - Return valid plain JSON only in the exact OUTPUT shape. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt index a80671676b..44093f50be 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt @@ -1,5 +1,5 @@ { - "rating": "", + "decision": "", "errors": [ { "title": "", @@ -18,14 +18,10 @@ "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt index a250310372..5840d215c8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt @@ -19,17 +19,13 @@ - Use 3-6 words for title. - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. -- Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. +- Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. - If no findings exist, return empty arrays. -- Rating must be HIGH, MEDIUM, or LOW. +- Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. -- Use nextSteps for concrete reviewer-facing next actions based on the provided evidence. -- nextSteps may include proceeding with the normal review process when the application appears ready for that step. -- When evidence shows a meaningful gap, inconsistency, or uncertainty, use nextSteps for specific follow-up or verification actions. +- Use recommendations for concrete reviewer-facing next actions based on the provided evidence. +- Recommendations may include proceeding with the normal review process when the application appears ready for that step. +- When evidence shows a meaningful gap, inconsistency, or uncertainty, use recommendations for specific follow-up or verification actions. - Return an empty array only when no concrete next action would help the reviewer. -- recommendation.decision must be PROCEED or HOLD. -- Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. -- recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. -- recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index d317618f32..f34cb95092 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -19,16 +19,16 @@ public static bool IsValidApplicationAnalysisJson(string response) return false; } - return root.TryGetProperty(AIJsonKeys.Rating, out var rating) - && rating.ValueKind == JsonValueKind.String + return root.TryGetProperty(AIJsonKeys.Decision, out var decision) + && decision.ValueKind == JsonValueKind.String && root.TryGetProperty(AIJsonKeys.Errors, out var errors) && errors.ValueKind == JsonValueKind.Array && root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) && warnings.ValueKind == JsonValueKind.Array && root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) && summaries.ValueKind == JsonValueKind.Array - && root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps) - && nextSteps.ValueKind == JsonValueKind.Array; + && root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) + && recommendations.ValueKind == JsonValueKind.Array; } public static bool IsValidApplicationScoringJson(string response, string sectionJson) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 22137b9193..c206582081 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -362,7 +362,7 @@ private string AddIdsToAnalysisItems(string analysisJson) if (outputPropertyName == AIJsonKeys.Errors || outputPropertyName == AIJsonKeys.Warnings || outputPropertyName == AIJsonKeys.Summaries || - outputPropertyName == AIJsonKeys.NextSteps) + outputPropertyName == AIJsonKeys.Recommendations) { writer.WritePropertyName(outputPropertyName); writer.WriteStartArray(); @@ -874,9 +874,9 @@ private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(stri return response; } - if (TryGetStringProperty(root, AIJsonKeys.Rating, out var rating)) + if (TryGetStringProperty(root, AIJsonKeys.Decision, out var decision)) { - response.Rating = rating; + response.Decision = decision?.Trim().ToUpperInvariant(); } if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array) @@ -894,14 +894,9 @@ private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(stri response.Summaries = ParseFindings(summaries); } - if (root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps) && nextSteps.ValueKind == JsonValueKind.Array) + if (root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) && recommendations.ValueKind == JsonValueKind.Array) { - response.NextSteps = ParseFindings(nextSteps); - } - - if (root.TryGetProperty(AIJsonKeys.Recommendation, out var recommendation) && recommendation.ValueKind == JsonValueKind.Object) - { - response.Recommendation = ParseRecommendation(recommendation); + response.Recommendations = ParseFindings(recommendations); } return response; @@ -959,34 +954,6 @@ private static List ParseFindings(JsonElement array) return findings; } - private static ApplicationAnalysisRecommendation? ParseRecommendation(JsonElement recommendation) - { - string? decision = null; - if (recommendation.TryGetProperty(AIJsonKeys.Decision, out var decisionProp) && - decisionProp.ValueKind == JsonValueKind.String) - { - decision = decisionProp.GetString(); - } - - string? rationale = null; - if (recommendation.TryGetProperty(AIJsonKeys.Rationale, out var rationaleProp) && - rationaleProp.ValueKind == JsonValueKind.String) - { - rationale = rationaleProp.GetString(); - } - - if (string.IsNullOrWhiteSpace(decision) && string.IsNullOrWhiteSpace(rationale)) - { - return null; - } - - return new ApplicationAnalysisRecommendation - { - Decision = decision, - Rationale = rationale - }; - } - private static ApplicationScoringResponse ParseApplicationScoringResponse( string raw, IReadOnlyDictionary? questionIdAliasMap = null) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 05f5a784eb..58859a5e83 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -235,7 +235,7 @@ OPTIONAL FIELDS (may be left blank): OUTPUT { - "rating": "", + "decision": "", "warnings": [ { "title": "", @@ -254,24 +254,19 @@ OPTIONAL FIELDS (may be left blank): "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } Important: - Use only APPLICATION CONTENT, ATTACHMENT SUMMARIES, FORM FIELD CONFIGURATION, and EVALUATION RUBRIC as evidence. + - decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - - Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. - - recommendation.decision must be PROCEED or HOLD. - - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. + - Use recommendations for reviewer-facing follow-up actions or considerations before scoring or decision-making. - Use "title" and "detail" keys for all finding objects. - Return valid plain JSON only in the exact OUTPUT shape. """; @@ -333,7 +328,7 @@ 4. Return only the strongest evidence-backed reviewer conclusions. // ── v1/analysis.output.txt ─────────────────────────────────────────────── private const string AnalysisOutput = """ { - "rating": "", + "decision": "", "errors": [ { "title": "", @@ -352,16 +347,12 @@ 4. Return only the strongest evidence-backed reviewer conclusions. "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } """; @@ -388,20 +379,16 @@ 4. Return only the strongest evidence-backed reviewer conclusions. - Use 3-6 words for title. - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. - - Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. + - Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. - If no findings exist, return empty arrays. - - Rating must be HIGH, MEDIUM, or LOW. + - Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - - Use nextSteps for concrete reviewer-facing next actions based on the provided evidence. - - nextSteps may include proceeding with the normal review process when the application appears ready for that step. - - When evidence shows a meaningful gap, inconsistency, or uncertainty, use nextSteps for specific follow-up or verification actions. + - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. + - Recommendations may include proceeding with the normal review process when the application appears ready for that step. + - When evidence shows a meaningful gap, inconsistency, or uncertainty, use recommendations for specific follow-up or verification actions. - Return an empty array only when no concrete next action would help the reviewer. - - recommendation.decision must be PROCEED or HOLD. - - Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. - - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. - - recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. """; // ── v0/attachment.system.txt ───────────────────────────────────────────── diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index 2491deb1c9..953a10a65d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -19,8 +19,8 @@ public interface IGrantApplicationAppService Task GetAsync(Guid id); Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); Task GetAccountCodingIdFromFormIdAsync(Guid formId); - Task HideAIAnalysisItemAsync(Guid applicationId, string itemId); - Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId); + Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId); + Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId); Task> GetListAsync(GrantApplicationListInputDto input); Task IsApplicantRedStopAsync(Guid applicationId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 89761862d0..9e66d05039 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -1072,15 +1072,15 @@ private static Dictionary ExtractCustomFieldsForWorksheet(dynami return result; } - public async Task HideAIAnalysisItemAsync(Guid applicationId, string itemId) - { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: true); - } - - public async Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId) - { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: false); - } + public async Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId) + { + return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: true); + } + + public async Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId) + { + return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: false); + } private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applicationId, string itemId, bool isHidden) { @@ -1105,10 +1105,10 @@ private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applica } catch (Exception ex) { - var action = isHidden ? "hiding" : "showing"; - var userMessage = isHidden - ? "Failed to hide the AI item. Please try again." - : "Failed to show the AI item. Please try again."; + var action = isHidden ? "dismissing" : "restoring"; + var userMessage = isHidden + ? "Failed to dismiss the AI item. Please try again." + : "Failed to restore the AI item. Please try again."; Logger.LogError(ex, "Error {Action} AI analysis item {ItemId} for application {ApplicationId}", action, itemId, applicationId); throw new UserFriendlyException(userMessage); @@ -1130,10 +1130,10 @@ private static string SetAnalysisItemHiddenState(string analysisJson, string ite return analysisJson; } - UpdateFindingHiddenState(analysis.Errors, itemId, isHidden); - UpdateFindingHiddenState(analysis.Warnings, itemId, isHidden); - UpdateFindingHiddenState(analysis.Summaries, itemId, isHidden); - UpdateFindingHiddenState(analysis.NextSteps, itemId, isHidden); + UpdateFindingHiddenState(analysis.Errors, itemId, isHidden); + UpdateFindingHiddenState(analysis.Warnings, itemId, isHidden); + UpdateFindingHiddenState(analysis.Summaries, itemId, isHidden); + UpdateFindingHiddenState(analysis.Recommendations, itemId, isHidden); return System.Text.Json.JsonSerializer.Serialize(analysis, AiAnalysisWriteOptions); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 30ea386fc6..c73d194f71 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -265,15 +265,15 @@ - - @if (aiApplicationAnalysisEnabled) - { - - } + + @if (aiApplicationAnalysisEnabled) + { + + } @if (Model.IsDevPromptControlsEnabled) {
@*-------- History Tab Section END ---------*@ - @if (aiApplicationAnalysisEnabled) - { -
-
-
AI Application Analysis
-
@@ -438,39 +438,45 @@ @* Hidden HTML templates for AI analysis items *@
-
-
-
- - -
- - -
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 28a257b992..41ecd22540 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -1030,20 +1030,6 @@ $(function () { ); }); - PubSub.subscribe('update_ai_analysis_status', (msg, data) => { - const $indicator = $('#ai_analysis_status'); - const status = data?.status; - - $indicator.removeClass('proceed hold'); - - if (status === 'proceed' || status === 'hold') { - $indicator.addClass(status).show(); - return; - } - - $indicator.hide(); - }); - PubSub.subscribe('update_application_emails_count', (msg, data) => { if (data.itemCount || data.itemCount === 0) { tabCounters.emails = data.itemCount; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index dfd5d2abb1..f0853287bc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -3,11 +3,11 @@ * Renders a stable sectioned view for AI-generated analysis results. */ -const hiddenSectionVisibility = { +const dismissedSectionVisibility = { error: false, warning: false, summary: false, - nextStep: false + recommendation: false }; function bindTemplateAction($element, actionData) { @@ -20,16 +20,11 @@ function bindTemplateValue($item, key, value) { return; } - if (key === 'hide-btn' || key === 'show-btn') { + if (key === 'dismiss-btn' || key === 'restore-btn') { bindTemplateAction($element, value); return; } - if (key === 'icon') { - $element.addClass(value); - return; - } - $element.text(value); } @@ -59,15 +54,15 @@ function getFindingDetailText(item) { } } -function updateAnalysisTabStatus(recommendation) { - let status = ''; - if (recommendation) { - status = recommendation.decision === 'PROCEED' ? 'proceed' : 'hold'; +function normalizeDecision(decision) { + if (typeof decision !== 'string') { + return ''; } - PubSub.publish('update_ai_analysis_status', { - status: status - }); + const normalized = decision.trim().toUpperCase(); + return normalized === 'PROCEED' || normalized === 'HOLD' + ? normalized + : ''; } function normalizeFindings(items, fallbackType) { @@ -75,7 +70,7 @@ function normalizeFindings(items, fallbackType) { error: 'Error', warning: 'Warning', summary: 'Summary', - nextStep: 'Next step' + recommendation: 'Recommendation' }; return (items || []) @@ -89,31 +84,9 @@ function normalizeFindings(items, fallbackType) { })); } -function normalizeRecommendation(recommendation) { - if (!recommendation || typeof recommendation !== 'object') { - return null; - } - - const decision = typeof recommendation.decision === 'string' - ? recommendation.decision.trim().toUpperCase() - : ''; - const rationale = typeof recommendation.rationale === 'string' - ? recommendation.rationale.trim() - : ''; - - if (decision !== 'PROCEED' && decision !== 'HOLD') { - return null; - } - - return { - decision: decision, - rationale: rationale - }; -} - function createFindingItem(item, type, hidden) { const templateName = hidden ? 'hidden-item' : 'active-item'; - const actionKey = hidden ? 'show-btn' : 'hide-btn'; + const actionKey = hidden ? 'restore-btn' : 'dismiss-btn'; return createItemFromTemplate(templateName, { category: item.title, message: getFindingDetailText(item), @@ -123,7 +96,6 @@ function createFindingItem(item, type, hidden) { function renderSection(section) { const $section = createItemFromTemplate('section', { - icon: section.icon, title: section.title }); @@ -135,16 +107,39 @@ function renderSection(section) { const $items = $section.find('[data-element="items"]'); const $status = $section.find('[data-element="status-chip"]'); const $toggle = $section.find('[data-element="hidden-toggle"]'); + const $collapseToggle = $section.find('[data-element="collapse-toggle"]'); const hiddenCount = section.hiddenItems.length; - const isHiddenVisible = hiddenSectionVisibility[section.itemType] === true; + const isDismissedVisible = dismissedSectionVisibility[section.itemType] === true; - if (section.headerOnlyText) { - $section.addClass('header-only'); + if (section.decision) { $status .addClass('ai-analysis-status-chip') - .text(section.headerOnlyText) + .addClass(section.decision.toLowerCase()) + .text(section.decision === 'PROCEED' ? 'Proceed' : 'Hold') .show(); + } + + $collapseToggle + .off('click') + .on('click', function() { + const isCollapsed = $section.toggleClass('collapsed').hasClass('collapsed'); + $(this) + .attr('aria-expanded', (!isCollapsed).toString()) + .attr('title', isCollapsed ? 'Expand section' : 'Collapse section'); + }); + + if (section.headerOnlyText) { + $section.addClass('header-only'); + if (section.decision) { + $status.show(); + } else { + $status + .addClass('ai-analysis-status-chip') + .text(section.headerOnlyText) + .show(); + } $toggle.hide(); + $collapseToggle.hide(); return $section; } @@ -152,7 +147,7 @@ function renderSection(section) { section.allItems.forEach(item => { const isHidden = item.hidden === true; const $item = createFindingItem(item, section.itemType, isHidden); - if (isHidden && !isHiddenVisible) { + if (isHidden && !isDismissedVisible) { $item.hide(); } @@ -163,26 +158,26 @@ function renderSection(section) { if (hiddenCount > 0) { $toggle .css('visibility', 'visible') - .text(isHiddenVisible - ? 'Hide hidden items' - : 'Show hidden items') + .text(isDismissedVisible + ? 'Hide dismissed items' + : 'Show dismissed items') .prop('disabled', false) .show() .off('click') .on('click', function() { - const shouldShow = hiddenSectionVisibility[section.itemType] !== true; - hiddenSectionVisibility[section.itemType] = shouldShow; + const shouldShow = dismissedSectionVisibility[section.itemType] !== true; + dismissedSectionVisibility[section.itemType] = shouldShow; $items.find('.hidden-item').toggle(shouldShow); $toggle.text( shouldShow - ? 'Hide hidden items' - : 'Show hidden items' + ? 'Hide dismissed items' + : 'Show dismissed items' ); }); } else { - hiddenSectionVisibility[section.itemType] = false; + dismissedSectionVisibility[section.itemType] = false; $toggle - .text('Show hidden items') + .text('Show dismissed items') .css('visibility', 'hidden') .prop('disabled', true) .show(); @@ -191,32 +186,6 @@ function renderSection(section) { return $section; } -function renderRecommendationSection(recommendation) { - if (!recommendation) { - return null; - } - - const shouldProceed = recommendation.decision === 'PROCEED'; - const $section = createItemFromTemplate('section', { - icon: 'fl-info-circle', - title: 'Recommendation' - }); - - $section.addClass('recommendation compact'); - $section.find('[data-element="status-chip"]') - .addClass('ai-analysis-status-badge') - .addClass(shouldProceed ? 'proceed' : 'hold') - .text(shouldProceed ? 'Proceed' : 'Hold') - .show(); - $section.find('[data-element="hidden-toggle"]').remove(); - $section.find('[data-element="items"]').append( - $('
') - .text(recommendation.rationale || 'No rationale provided.') - ); - - return $section; -} - function splitFindingsByVisibility(items) { return { activeItems: items.filter(item => item.hidden !== true), @@ -225,42 +194,38 @@ function splitFindingsByVisibility(items) { } function buildAnalysisSections(analysisData) { - const recommendation = normalizeRecommendation(analysisData.recommendation); + const decision = normalizeDecision(analysisData.decision); const errors = normalizeFindings(analysisData.errors, 'error'); const warnings = normalizeFindings(analysisData.warnings, 'warning'); - const summaries = normalizeFindings(analysisData.summaries || analysisData.recommendations, 'summary'); - const nextSteps = normalizeFindings(analysisData.nextSteps, 'nextStep'); + const summaries = normalizeFindings(analysisData.summaries, 'summary'); + const recommendations = normalizeFindings(analysisData.recommendations, 'recommendation'); const errorGroups = splitFindingsByVisibility(errors); const warningGroups = splitFindingsByVisibility(warnings); const summaryGroups = splitFindingsByVisibility(summaries); - const nextStepGroups = splitFindingsByVisibility(nextSteps); + const recommendationGroups = splitFindingsByVisibility(recommendations); return { - recommendation, sections: [ { title: 'Errors', - icon: 'fl-times-circle', sectionClass: 'error', itemType: 'error', - headerOnlyText: errorGroups.activeItems.length === 0 && errorGroups.hiddenItems.length === 0 ? 'No errors' : null, + headerOnlyText: errorGroups.activeItems.length === 0 && errorGroups.hiddenItems.length === 0 ? '0 errors' : null, activeItems: errorGroups.activeItems, allItems: errors, hiddenItems: errorGroups.hiddenItems }, { title: 'Warnings', - icon: 'fl-exclamation-triangle', sectionClass: 'warning', itemType: 'warning', - headerOnlyText: warningGroups.activeItems.length === 0 && warningGroups.hiddenItems.length === 0 ? 'No warnings' : null, + headerOnlyText: warningGroups.activeItems.length === 0 && warningGroups.hiddenItems.length === 0 ? '0 warnings' : null, activeItems: warningGroups.activeItems, allItems: warnings, hiddenItems: warningGroups.hiddenItems }, { title: 'Summary', - icon: 'fl-info-circle', sectionClass: 'summary', itemType: 'summary', headerOnlyText: summaryGroups.activeItems.length === 0 && summaryGroups.hiddenItems.length === 0 ? 'No summary' : null, @@ -269,100 +234,79 @@ function buildAnalysisSections(analysisData) { hiddenItems: summaryGroups.hiddenItems }, { - title: 'Next Steps', - icon: 'fl-check-square', - sectionClass: 'next-steps', - itemType: 'nextStep', - headerOnlyText: nextStepGroups.activeItems.length === 0 && nextStepGroups.hiddenItems.length === 0 ? 'No next steps' : null, - activeItems: nextStepGroups.activeItems, - allItems: nextSteps, - hiddenItems: nextStepGroups.hiddenItems + title: 'Recommendations', + sectionClass: 'recommendations', + itemType: 'recommendation', + decision: decision, + headerOnlyText: recommendationGroups.activeItems.length === 0 && recommendationGroups.hiddenItems.length === 0 ? 'No recommendations' : null, + activeItems: recommendationGroups.activeItems, + allItems: recommendations, + hiddenItems: recommendationGroups.hiddenItems } ] }; } -function hasAnyAnalysisContent(recommendation, sections) { - if (recommendation) { - return true; - } - - return sections.some(section => section.allItems.length > 0); -} - function bindAnalysisItemActions($sections) { $sections.off('click'); - $sections.on('click', '[data-element="hide-btn"]', function(e) { + $sections.on('click', '[data-element="dismiss-btn"]', function(e) { e.preventDefault(); const itemId = $(this).data('id'); - hideAnalysisItem(itemId); + dismissAnalysisItem(itemId); }); - $sections.on('click', '[data-element="show-btn"]', function(e) { + $sections.on('click', '[data-element="restore-btn"]', function(e) { e.preventDefault(); const itemId = $(this).data('id'); - showAnalysisItem(itemId); + restoreAnalysisItem(itemId); }); } function renderRealAIAnalysis(analysisData) { - const { recommendation, sections } = buildAnalysisSections(analysisData); + const { sections } = buildAnalysisSections(analysisData); const $sections = $('#aiAnalysisSections'); $sections.empty(); - const $recommendationSection = renderRecommendationSection(recommendation); - const hasRecommendation = $recommendationSection !== null; - if ($recommendationSection) { - $sections.append($recommendationSection); - } sections.forEach(section => { $sections.append(renderSection(section)); }); - updateAnalysisTabStatus(recommendation); - const $noDataMessage = $('#aiAnalysisNoData'); - if (!hasRecommendation && !hasAnyAnalysisContent(recommendation, sections)) { - $noDataMessage.show(); - $sections.hide(); - } else { - $noDataMessage.hide(); - $sections.show(); - } + $noDataMessage.hide(); + $sections.show(); bindAnalysisItemActions($sections); } -globalThis.hideAnalysisItem = function(itemId) { +globalThis.dismissAnalysisItem = function(itemId) { const applicationId = $('#DetailsViewApplicationId').val(); unity.grantManager.grantApplications.grantApplication - .hideAIAnalysisItem(applicationId, itemId) + .dismissAIAnalysisItem(applicationId, itemId) .then(function() { loadAIAnalysis(); }) .catch(function() { - abp.message.error('Failed to hide the item. Please try again.'); + abp.message.error('Failed to dismiss the item. Please try again.'); }); } -globalThis.showAnalysisItem = function(itemId) { +globalThis.restoreAnalysisItem = function(itemId) { const applicationId = $('#DetailsViewApplicationId').val(); unity.grantManager.grantApplications.grantApplication - .showAIAnalysisItem(applicationId, itemId) + .restoreAIAnalysisItem(applicationId, itemId) .then(function() { loadAIAnalysis(); }) .catch(function() { - abp.message.error('Failed to show the item. Please try again.'); + abp.message.error('Failed to restore the item. Please try again.'); }); } function resetAnalysisView() { $('#aiAnalysisSections').empty().hide(); $('#aiAnalysisNoData').show(); - updateAnalysisTabStatus(null); } function tryParseRawAnalysis(analysisJson) { From d29e0ddfeaffb12bf67dd17348132bd57057b51b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 11:17:07 -0700 Subject: [PATCH 09/45] AB#30833 Align application analysis chevrons with page collapse pattern --- .../Pages/GrantApplications/ai-analysis.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index f0853287bc..07335f2c96 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -123,9 +123,13 @@ function renderSection(section) { .off('click') .on('click', function() { const isCollapsed = $section.toggleClass('collapsed').hasClass('collapsed'); + const $icon = $(this).find('i'); $(this) .attr('aria-expanded', (!isCollapsed).toString()) .attr('title', isCollapsed ? 'Expand section' : 'Collapse section'); + $icon + .toggleClass('fa-chevron-down', !isCollapsed) + .toggleClass('fa-chevron-up', isCollapsed); }); if (section.headerOnlyText) { From 644320c917cad3a118c8b0211835aface35d06d0 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 11:24:09 -0700 Subject: [PATCH 10/45] AB#30833 Adjust application analysis header controls --- .../Pages/GrantApplications/Details.cshtml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index c73d194f71..ac5ab98b62 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -442,9 +442,10 @@
+
- + @@ -452,9 +453,6 @@
-
From 4415dbf23109e3df210cf6dbd3ad863ec08156dc Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 11:30:29 -0700 Subject: [PATCH 11/45] AB#30833 Fix application analysis dismissed item separators --- .../Pages/GrantApplications/Details.css | 16 +++++++++++----- .../Pages/GrantApplications/ai-analysis.js | 11 +++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index bce040a95a..e84bcf7900 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -626,11 +626,17 @@ form label.error { border-bottom: 1px solid #e9ecef; } -.ai-analysis-detail-item:last-child { - margin-bottom: 0; - padding-bottom: 0; - border-bottom: none; -} +.ai-analysis-detail-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.ai-analysis-detail-item.last-visible { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} .ai-analysis-detail-category { font-weight: 700; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 07335f2c96..08664d3754 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -94,6 +94,14 @@ function createFindingItem(item, type, hidden) { }); } +function updateVisibleItemLayout($items) { + const $allItems = $items.children('.ai-analysis-detail-item'); + const $visibleItems = $allItems.filter(':visible'); + + $allItems.removeClass('last-visible'); + $visibleItems.last().addClass('last-visible'); +} + function renderSection(section) { const $section = createItemFromTemplate('section', { title: section.title @@ -157,6 +165,8 @@ function renderSection(section) { $items.append($item); }); + + updateVisibleItemLayout($items); } if (hiddenCount > 0) { @@ -172,6 +182,7 @@ function renderSection(section) { const shouldShow = dismissedSectionVisibility[section.itemType] !== true; dismissedSectionVisibility[section.itemType] = shouldShow; $items.find('.hidden-item').toggle(shouldShow); + updateVisibleItemLayout($items); $toggle.text( shouldShow ? 'Hide dismissed items' From 1c4b67215035405d2b793e265df882af9f1a1786 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 11:34:55 -0700 Subject: [PATCH 12/45] AB#30833 Fix application analysis visible item spacing --- .../Pages/GrantApplications/ai-analysis.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 08664d3754..098720363d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -96,7 +96,9 @@ function createFindingItem(item, type, hidden) { function updateVisibleItemLayout($items) { const $allItems = $items.children('.ai-analysis-detail-item'); - const $visibleItems = $allItems.filter(':visible'); + const $visibleItems = $allItems.filter(function() { + return this.style.display !== 'none'; + }); $allItems.removeClass('last-visible'); $visibleItems.last().addClass('last-visible'); From df9a2e7ef91bfeeb8f1023e6143efbcc2dc40ea9 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 15:03:28 -0700 Subject: [PATCH 13/45] AB#30833 Hide empty application analysis error and warning sections --- .../Pages/GrantApplications/ai-analysis.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 098720363d..d4d32a95f0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -227,7 +227,6 @@ function buildAnalysisSections(analysisData) { title: 'Errors', sectionClass: 'error', itemType: 'error', - headerOnlyText: errorGroups.activeItems.length === 0 && errorGroups.hiddenItems.length === 0 ? '0 errors' : null, activeItems: errorGroups.activeItems, allItems: errors, hiddenItems: errorGroups.hiddenItems @@ -236,7 +235,6 @@ function buildAnalysisSections(analysisData) { title: 'Warnings', sectionClass: 'warning', itemType: 'warning', - headerOnlyText: warningGroups.activeItems.length === 0 && warningGroups.hiddenItems.length === 0 ? '0 warnings' : null, activeItems: warningGroups.activeItems, allItems: warnings, hiddenItems: warningGroups.hiddenItems @@ -285,6 +283,10 @@ function renderRealAIAnalysis(analysisData) { $sections.empty(); sections.forEach(section => { + if ((section.itemType === 'error' || section.itemType === 'warning') && section.allItems.length === 0) { + return; + } + $sections.append(renderSection(section)); }); From a176ef3d1453bc85e3036cf13c8995485d948e5e Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 15:06:21 -0700 Subject: [PATCH 14/45] AB#30833 Simplify application analysis section rendering --- .../GrantApplicationAppService.cs | 122 +++++++------- .../Pages/GrantApplications/ai-analysis.js | 157 ++++++++++-------- 2 files changed, 151 insertions(+), 128 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 9e66d05039..4b1ed130b5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -1074,20 +1074,20 @@ private static Dictionary ExtractCustomFieldsForWorksheet(dynami public async Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId) { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: true); + return await UpdateAIAnalysisItemDismissedStateAsync(applicationId, itemId, isDismissed: true); } public async Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId) { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: false); + return await UpdateAIAnalysisItemDismissedStateAsync(applicationId, itemId, isDismissed: false); } - - private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applicationId, string itemId, bool isHidden) - { - if (string.IsNullOrWhiteSpace(itemId)) - { - throw new UserFriendlyException("AI analysis item id is required."); - } + + private async Task UpdateAIAnalysisItemDismissedStateAsync(Guid applicationId, string itemId, bool isDismissed) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + throw new UserFriendlyException("AI analysis item id is required."); + } var application = await applicationRepository.GetAsync(applicationId); @@ -1095,67 +1095,67 @@ private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applica { throw new UserFriendlyException("No AI analysis available for this application."); } - - try - { - var updatedAnalysis = SetAnalysisItemHiddenState(application.AIAnalysis, itemId, isHidden); - application.AIAnalysis = updatedAnalysis; - await applicationRepository.UpdateAsync(application); - return updatedAnalysis; - } - catch (Exception ex) - { - var action = isHidden ? "dismissing" : "restoring"; - var userMessage = isHidden + + try + { + var updatedAnalysis = SetAnalysisItemDismissedState(application.AIAnalysis, itemId, isDismissed); + application.AIAnalysis = updatedAnalysis; + await applicationRepository.UpdateAsync(application); + return updatedAnalysis; + } + catch (Exception ex) + { + var action = isDismissed ? "dismissing" : "restoring"; + var userMessage = isDismissed ? "Failed to dismiss the AI item. Please try again." : "Failed to restore the AI item. Please try again."; Logger.LogError(ex, "Error {Action} AI analysis item {ItemId} for application {ApplicationId}", action, itemId, applicationId); throw new UserFriendlyException(userMessage); - } - } - - private static string SetAnalysisItemHiddenState(string analysisJson, string itemId, bool isHidden) - { - if (string.IsNullOrWhiteSpace(analysisJson)) - { - return analysisJson; - } + } + } + + private static string SetAnalysisItemDismissedState(string analysisJson, string itemId, bool isDismissed) + { + if (string.IsNullOrWhiteSpace(analysisJson)) + { + return analysisJson; + } try { var analysis = System.Text.Json.JsonSerializer.Deserialize(analysisJson, AiAnalysisReadOptions); - if (analysis == null) - { - return analysisJson; - } - - UpdateFindingHiddenState(analysis.Errors, itemId, isHidden); - UpdateFindingHiddenState(analysis.Warnings, itemId, isHidden); - UpdateFindingHiddenState(analysis.Summaries, itemId, isHidden); - UpdateFindingHiddenState(analysis.Recommendations, itemId, isHidden); - - return System.Text.Json.JsonSerializer.Serialize(analysis, AiAnalysisWriteOptions); - } - catch (System.Text.Json.JsonException) - { - return analysisJson; - } - } - - private static void UpdateFindingHiddenState(IEnumerable findings, string itemId, bool isHidden) - { - foreach (var finding in findings) - { - if (!string.Equals(finding.Id, itemId, StringComparison.Ordinal)) - { - continue; - } - - finding.Hidden = isHidden; - return; - } - } + if (analysis == null) + { + return analysisJson; + } + + UpdateFindingDismissedState(analysis.Errors, itemId, isDismissed); + UpdateFindingDismissedState(analysis.Warnings, itemId, isDismissed); + UpdateFindingDismissedState(analysis.Summaries, itemId, isDismissed); + UpdateFindingDismissedState(analysis.Recommendations, itemId, isDismissed); + + return System.Text.Json.JsonSerializer.Serialize(analysis, AiAnalysisWriteOptions); + } + catch (System.Text.Json.JsonException) + { + return analysisJson; + } + } + + private static void UpdateFindingDismissedState(IEnumerable findings, string itemId, bool isDismissed) + { + foreach (var finding in findings) + { + if (!string.Equals(finding.Id, itemId, StringComparison.Ordinal)) + { + continue; + } + + finding.Hidden = isDismissed; + return; + } + } private static ApplicationAnalysisResponse? ParseAiAnalysisData(string? analysisJson) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index d4d32a95f0..0d50e54c3e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -104,102 +104,125 @@ function updateVisibleItemLayout($items) { $visibleItems.last().addClass('last-visible'); } -function renderSection(section) { - const $section = createItemFromTemplate('section', { - title: section.title - }); - - $section.addClass(section.sectionClass); - if (section.activeItems.length === 0) { - $section.addClass('compact'); +function configureSectionDecision($status, decision) { + if (!decision) { + return; } - const $items = $section.find('[data-element="items"]'); - const $status = $section.find('[data-element="status-chip"]'); - const $toggle = $section.find('[data-element="hidden-toggle"]'); - const $collapseToggle = $section.find('[data-element="collapse-toggle"]'); - const hiddenCount = section.hiddenItems.length; - const isDismissedVisible = dismissedSectionVisibility[section.itemType] === true; - - if (section.decision) { - $status - .addClass('ai-analysis-status-chip') - .addClass(section.decision.toLowerCase()) - .text(section.decision === 'PROCEED' ? 'Proceed' : 'Hold') - .show(); - } + $status + .addClass('ai-analysis-status-chip') + .addClass(decision.toLowerCase()) + .text(decision === 'PROCEED' ? 'Proceed' : 'Hold') + .show(); +} +function configureCollapseToggle($section, $collapseToggle) { $collapseToggle .off('click') .on('click', function() { const isCollapsed = $section.toggleClass('collapsed').hasClass('collapsed'); const $icon = $(this).find('i'); + $(this) .attr('aria-expanded', (!isCollapsed).toString()) .attr('title', isCollapsed ? 'Expand section' : 'Collapse section'); + $icon .toggleClass('fa-chevron-down', !isCollapsed) .toggleClass('fa-chevron-up', isCollapsed); }); +} - if (section.headerOnlyText) { - $section.addClass('header-only'); - if (section.decision) { - $status.show(); - } else { - $status - .addClass('ai-analysis-status-chip') - .text(section.headerOnlyText) - .show(); - } - $toggle.hide(); - $collapseToggle.hide(); - return $section; - } +function renderHeaderOnlySection($section, $status, $toggle, $collapseToggle, section) { + $section.addClass('header-only'); - if (section.activeItems.length > 0 || hiddenCount > 0) { - section.allItems.forEach(item => { - const isHidden = item.hidden === true; - const $item = createFindingItem(item, section.itemType, isHidden); - if (isHidden && !isDismissedVisible) { - $item.hide(); - } + if (section.decision) { + $status.show(); + } else { + $status + .addClass('ai-analysis-status-chip') + .text(section.headerOnlyText) + .show(); + } - $items.append($item); - }); + $toggle.hide(); + $collapseToggle.hide(); + return $section; +} - updateVisibleItemLayout($items); +function appendSectionItems($items, section, isDismissedVisible) { + if (section.activeItems.length === 0 && section.hiddenItems.length === 0) { + return; } - if (hiddenCount > 0) { - $toggle - .css('visibility', 'visible') - .text(isDismissedVisible - ? 'Hide dismissed items' - : 'Show dismissed items') - .prop('disabled', false) - .show() - .off('click') - .on('click', function() { - const shouldShow = dismissedSectionVisibility[section.itemType] !== true; - dismissedSectionVisibility[section.itemType] = shouldShow; - $items.find('.hidden-item').toggle(shouldShow); - updateVisibleItemLayout($items); - $toggle.text( - shouldShow - ? 'Hide dismissed items' - : 'Show dismissed items' - ); - }); - } else { + section.allItems.forEach(item => { + const isHidden = item.hidden === true; + const $item = createFindingItem(item, section.itemType, isHidden); + + if (isHidden && !isDismissedVisible) { + $item.hide(); + } + + $items.append($item); + }); + + updateVisibleItemLayout($items); +} + +function configureDismissedItemsToggle($items, $toggle, section, isDismissedVisible) { + const hiddenCount = section.hiddenItems.length; + + if (hiddenCount === 0) { dismissedSectionVisibility[section.itemType] = false; $toggle .text('Show dismissed items') .css('visibility', 'hidden') .prop('disabled', true) .show(); + return; + } + + $toggle + .css('visibility', 'visible') + .text(isDismissedVisible ? 'Hide dismissed items' : 'Show dismissed items') + .prop('disabled', false) + .show() + .off('click') + .on('click', function() { + const shouldShow = dismissedSectionVisibility[section.itemType] !== true; + dismissedSectionVisibility[section.itemType] = shouldShow; + $items.find('.hidden-item').toggle(shouldShow); + updateVisibleItemLayout($items); + $toggle.text(shouldShow ? 'Hide dismissed items' : 'Show dismissed items'); + }); +} + +function renderSection(section) { + const $section = createItemFromTemplate('section', { + title: section.title + }); + + $section.addClass(section.sectionClass); + if (section.activeItems.length === 0) { + $section.addClass('compact'); } + const $items = $section.find('[data-element="items"]'); + const $status = $section.find('[data-element="status-chip"]'); + const $toggle = $section.find('[data-element="hidden-toggle"]'); + const $collapseToggle = $section.find('[data-element="collapse-toggle"]'); + const isDismissedVisible = dismissedSectionVisibility[section.itemType] === true; + + configureSectionDecision($status, section.decision); + configureCollapseToggle($section, $collapseToggle); + + if (section.headerOnlyText) { + return renderHeaderOnlySection($section, $status, $toggle, $collapseToggle, section); + } + + appendSectionItems($items, section, isDismissedVisible); + configureDismissedItemsToggle($items, $toggle, section, isDismissedVisible); + return $section; } From f4eb752199750afb474da78e645b3f400b84c649 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 15:11:58 -0700 Subject: [PATCH 15/45] AB#30833 Align application analysis dismissed naming and labels --- .../AI/Models/AIJsonKeys.cs | 2 +- .../AI/Models/ApplicationAnalysisFinding.cs | 4 +- .../AI/Runtime/OpenAIRuntimeService.cs | 12 ++-- .../GrantApplicationAppService.cs | 2 +- .../Pages/GrantApplications/Details.cshtml | 45 ++++++++---- .../Pages/GrantApplications/ai-analysis.js | 68 +++++++++++++++---- 6 files changed, 94 insertions(+), 39 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs index 8b5de11fe5..6e83686ff0 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs @@ -7,7 +7,7 @@ public static class AIJsonKeys public const string Summaries = "summaries"; public const string Recommendations = "recommendations"; public const string Decision = "decision"; - public const string Hidden = "hidden"; + public const string Dismissed = "dismissed"; public const string Id = "id"; public const string Title = "title"; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs index 181168009e..f386f45d1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs @@ -7,8 +7,8 @@ public class ApplicationAnalysisFinding [JsonPropertyName(AIJsonKeys.Id)] public string? Id { get; set; } - [JsonPropertyName(AIJsonKeys.Hidden)] - public bool Hidden { get; set; } + [JsonPropertyName(AIJsonKeys.Dismissed)] + public bool Dismissed { get; set; } [JsonPropertyName(AIJsonKeys.Title)] public string? Title { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index c206582081..1a81024e9c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -373,12 +373,12 @@ private string AddIdsToAnalysisItems(string analysisJson) // Add unique ID first writer.WriteString("id", Guid.NewGuid().ToString()); - writer.WriteBoolean(AIJsonKeys.Hidden, false); + writer.WriteBoolean(AIJsonKeys.Dismissed, false); // Copy existing properties foreach (var itemProperty in item.EnumerateObject()) { - if (itemProperty.NameEquals(AIJsonKeys.Id) || itemProperty.NameEquals(AIJsonKeys.Hidden)) + if (itemProperty.NameEquals(AIJsonKeys.Id) || itemProperty.NameEquals(AIJsonKeys.Dismissed)) { continue; } @@ -927,9 +927,9 @@ private static List ParseFindings(JsonElement array) var id = item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String ? idProp.GetString() : null; - var hidden = item.TryGetProperty(AIJsonKeys.Hidden, out var hiddenProp) && - (hiddenProp.ValueKind == JsonValueKind.True || hiddenProp.ValueKind == JsonValueKind.False) && - hiddenProp.GetBoolean(); + var dismissed = item.TryGetProperty(AIJsonKeys.Dismissed, out var dismissedProp) && + (dismissedProp.ValueKind == JsonValueKind.True || dismissedProp.ValueKind == JsonValueKind.False) && + dismissedProp.GetBoolean(); string? title = null; if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) { @@ -945,7 +945,7 @@ private static List ParseFindings(JsonElement array) findings.Add(new ApplicationAnalysisFinding { Id = id, - Hidden = hidden, + Dismissed = dismissed, Title = title, Detail = detail }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 4b1ed130b5..b05dfa0bbf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -1152,7 +1152,7 @@ private static void UpdateFindingDismissedState(IEnumerable @* Default message when no analysis data is available *@
-
- -
No AI Analysis Available
-

AI analysis results will appear here when available.

-
-
-
- @* Analysis sections will be dynamically generated here *@ -
+
+ +
No AI Analysis Available
+

AI analysis results will appear here when available.

+
+ +
+
+ @* Analysis sections will be dynamically generated here *@ +
@* Hidden HTML templates for AI analysis items *@ @@ -446,7 +463,7 @@
-
@@ -459,8 +476,8 @@
-
@@ -469,8 +486,8 @@
-
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 0d50e54c3e..273fe7a8c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -10,6 +10,29 @@ const dismissedSectionVisibility = { recommendation: false }; +function getAnalysisLabels() { + const labels = document.getElementById('aiAnalysisLabels')?.dataset ?? {}; + + return { + errors: labels.errors || 'Errors', + warnings: labels.warnings || 'Warnings', + summary: labels.summary || 'Summary', + recommendations: labels.recommendations || 'Recommendations', + proceed: labels.proceed || 'Proceed', + hold: labels.hold || 'Hold', + noSummary: labels.noSummary || 'No summary', + noRecommendations: labels.noRecommendations || 'No recommendations', + showDismissed: labels.showDismissed || 'Show dismissed items', + hideDismissed: labels.hideDismissed || 'Hide dismissed items', + dismiss: labels.dismiss || 'Dismiss', + restore: labels.restore || 'Restore', + dismissTitle: labels.dismissTitle || 'Dismiss this item', + restoreTitle: labels.restoreTitle || 'Restore this item', + collapseTitle: labels.collapseTitle || 'Collapse section', + expandTitle: labels.expandTitle || 'Expand section' + }; +} + function bindTemplateAction($element, actionData) { $element.attr('data-id', actionData.id).attr('data-type', actionData.type); } @@ -78,20 +101,31 @@ function normalizeFindings(items, fallbackType) { .map((item, index) => ({ ...item, id: item.id || `${fallbackType}-${index}`, - hidden: item.hidden === true, + dismissed: item.dismissed === true, title: item.title || item.category || fallbackTitles[fallbackType] || 'Item', detail: item.detail || item.message || '' })); } function createFindingItem(item, type, hidden) { + const labels = getAnalysisLabels(); const templateName = hidden ? 'hidden-item' : 'active-item'; const actionKey = hidden ? 'restore-btn' : 'dismiss-btn'; - return createItemFromTemplate(templateName, { + const $item = createItemFromTemplate(templateName, { category: item.title, message: getFindingDetailText(item), [actionKey]: { id: item.id, type: type } }); + + if (hidden) { + $item.find('[data-element="restore-text"]').text(labels.restore); + $item.find('[data-element="restore-btn"]').attr('title', labels.restoreTitle); + } else { + $item.find('[data-element="dismiss-text"]').text(labels.dismiss); + $item.find('[data-element="dismiss-btn"]').attr('title', labels.dismissTitle); + } + + return $item; } function updateVisibleItemLayout($items) { @@ -105,6 +139,7 @@ function updateVisibleItemLayout($items) { } function configureSectionDecision($status, decision) { + const labels = getAnalysisLabels(); if (!decision) { return; } @@ -112,11 +147,12 @@ function configureSectionDecision($status, decision) { $status .addClass('ai-analysis-status-chip') .addClass(decision.toLowerCase()) - .text(decision === 'PROCEED' ? 'Proceed' : 'Hold') + .text(decision === 'PROCEED' ? labels.proceed : labels.hold) .show(); } function configureCollapseToggle($section, $collapseToggle) { + const labels = getAnalysisLabels(); $collapseToggle .off('click') .on('click', function() { @@ -125,7 +161,7 @@ function configureCollapseToggle($section, $collapseToggle) { $(this) .attr('aria-expanded', (!isCollapsed).toString()) - .attr('title', isCollapsed ? 'Expand section' : 'Collapse section'); + .attr('title', isCollapsed ? labels.expandTitle : labels.collapseTitle); $icon .toggleClass('fa-chevron-down', !isCollapsed) @@ -170,12 +206,13 @@ function appendSectionItems($items, section, isDismissedVisible) { } function configureDismissedItemsToggle($items, $toggle, section, isDismissedVisible) { + const labels = getAnalysisLabels(); const hiddenCount = section.hiddenItems.length; if (hiddenCount === 0) { dismissedSectionVisibility[section.itemType] = false; $toggle - .text('Show dismissed items') + .text(labels.showDismissed) .css('visibility', 'hidden') .prop('disabled', true) .show(); @@ -184,7 +221,7 @@ function configureDismissedItemsToggle($items, $toggle, section, isDismissedVisi $toggle .css('visibility', 'visible') - .text(isDismissedVisible ? 'Hide dismissed items' : 'Show dismissed items') + .text(isDismissedVisible ? labels.hideDismissed : labels.showDismissed) .prop('disabled', false) .show() .off('click') @@ -193,7 +230,7 @@ function configureDismissedItemsToggle($items, $toggle, section, isDismissedVisi dismissedSectionVisibility[section.itemType] = shouldShow; $items.find('.hidden-item').toggle(shouldShow); updateVisibleItemLayout($items); - $toggle.text(shouldShow ? 'Hide dismissed items' : 'Show dismissed items'); + $toggle.text(shouldShow ? labels.hideDismissed : labels.showDismissed); }); } @@ -228,12 +265,13 @@ function renderSection(section) { function splitFindingsByVisibility(items) { return { - activeItems: items.filter(item => item.hidden !== true), - hiddenItems: items.filter(item => item.hidden === true) + activeItems: items.filter(item => item.dismissed !== true), + hiddenItems: items.filter(item => item.dismissed === true) }; } function buildAnalysisSections(analysisData) { + const labels = getAnalysisLabels(); const decision = normalizeDecision(analysisData.decision); const errors = normalizeFindings(analysisData.errors, 'error'); const warnings = normalizeFindings(analysisData.warnings, 'warning'); @@ -247,7 +285,7 @@ function buildAnalysisSections(analysisData) { return { sections: [ { - title: 'Errors', + title: labels.errors, sectionClass: 'error', itemType: 'error', activeItems: errorGroups.activeItems, @@ -255,7 +293,7 @@ function buildAnalysisSections(analysisData) { hiddenItems: errorGroups.hiddenItems }, { - title: 'Warnings', + title: labels.warnings, sectionClass: 'warning', itemType: 'warning', activeItems: warningGroups.activeItems, @@ -263,20 +301,20 @@ function buildAnalysisSections(analysisData) { hiddenItems: warningGroups.hiddenItems }, { - title: 'Summary', + title: labels.summary, sectionClass: 'summary', itemType: 'summary', - headerOnlyText: summaryGroups.activeItems.length === 0 && summaryGroups.hiddenItems.length === 0 ? 'No summary' : null, + headerOnlyText: summaryGroups.activeItems.length === 0 && summaryGroups.hiddenItems.length === 0 ? labels.noSummary : null, activeItems: summaryGroups.activeItems, allItems: summaries, hiddenItems: summaryGroups.hiddenItems }, { - title: 'Recommendations', + title: labels.recommendations, sectionClass: 'recommendations', itemType: 'recommendation', decision: decision, - headerOnlyText: recommendationGroups.activeItems.length === 0 && recommendationGroups.hiddenItems.length === 0 ? 'No recommendations' : null, + headerOnlyText: recommendationGroups.activeItems.length === 0 && recommendationGroups.hiddenItems.length === 0 ? labels.noRecommendations : null, activeItems: recommendationGroups.activeItems, allItems: recommendations, hiddenItems: recommendationGroups.hiddenItems From 6368b315cbef88356f11268bc420534fdfb4313f Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 15:15:44 -0700 Subject: [PATCH 16/45] AB#30833 Hide empty application analysis sections --- .../Pages/GrantApplications/Details.cshtml | 2 -- .../Pages/GrantApplications/ai-analysis.js | 36 ++++++------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 7a22c505da..2d974dfe3a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -438,8 +438,6 @@ data-recommendations="Recommendations" data-proceed="Proceed" data-hold="Hold" - data-no-summary="No summary" - data-no-recommendations="No recommendations" data-show-dismissed="Show dismissed items" data-hide-dismissed="Hide dismissed items" data-dismiss="Dismiss" diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 273fe7a8c3..ef9a0cc8a8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -20,8 +20,6 @@ function getAnalysisLabels() { recommendations: labels.recommendations || 'Recommendations', proceed: labels.proceed || 'Proceed', hold: labels.hold || 'Hold', - noSummary: labels.noSummary || 'No summary', - noRecommendations: labels.noRecommendations || 'No recommendations', showDismissed: labels.showDismissed || 'Show dismissed items', hideDismissed: labels.hideDismissed || 'Hide dismissed items', dismiss: labels.dismiss || 'Dismiss', @@ -169,21 +167,8 @@ function configureCollapseToggle($section, $collapseToggle) { }); } -function renderHeaderOnlySection($section, $status, $toggle, $collapseToggle, section) { - $section.addClass('header-only'); - - if (section.decision) { - $status.show(); - } else { - $status - .addClass('ai-analysis-status-chip') - .text(section.headerOnlyText) - .show(); - } - - $toggle.hide(); - $collapseToggle.hide(); - return $section; +function shouldRenderSection(section) { + return section.allItems.length > 0; } function appendSectionItems($items, section, isDismissedVisible) { @@ -253,10 +238,6 @@ function renderSection(section) { configureSectionDecision($status, section.decision); configureCollapseToggle($section, $collapseToggle); - if (section.headerOnlyText) { - return renderHeaderOnlySection($section, $status, $toggle, $collapseToggle, section); - } - appendSectionItems($items, section, isDismissedVisible); configureDismissedItemsToggle($items, $toggle, section, isDismissedVisible); @@ -304,7 +285,6 @@ function buildAnalysisSections(analysisData) { title: labels.summary, sectionClass: 'summary', itemType: 'summary', - headerOnlyText: summaryGroups.activeItems.length === 0 && summaryGroups.hiddenItems.length === 0 ? labels.noSummary : null, activeItems: summaryGroups.activeItems, allItems: summaries, hiddenItems: summaryGroups.hiddenItems @@ -314,7 +294,6 @@ function buildAnalysisSections(analysisData) { sectionClass: 'recommendations', itemType: 'recommendation', decision: decision, - headerOnlyText: recommendationGroups.activeItems.length === 0 && recommendationGroups.hiddenItems.length === 0 ? labels.noRecommendations : null, activeItems: recommendationGroups.activeItems, allItems: recommendations, hiddenItems: recommendationGroups.hiddenItems @@ -344,7 +323,7 @@ function renderRealAIAnalysis(analysisData) { $sections.empty(); sections.forEach(section => { - if ((section.itemType === 'error' || section.itemType === 'warning') && section.allItems.length === 0) { + if (!shouldRenderSection(section)) { return; } @@ -352,8 +331,13 @@ function renderRealAIAnalysis(analysisData) { }); const $noDataMessage = $('#aiAnalysisNoData'); - $noDataMessage.hide(); - $sections.show(); + if ($sections.children().length === 0) { + $noDataMessage.show(); + $sections.hide(); + } else { + $noDataMessage.hide(); + $sections.show(); + } bindAnalysisItemActions($sections); } From 2e29e5ed4f84f4ede3e2c3d55419e3f5a48d5e73 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Apr 2026 15:20:59 -0700 Subject: [PATCH 17/45] AB#30833 Align application analysis summary labels with contract --- .../Pages/GrantApplications/Details.cshtml | 2 +- .../Pages/GrantApplications/ai-analysis.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 2d974dfe3a..f8ffa79efa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -434,7 +434,7 @@
Date: Fri, 10 Apr 2026 10:34:30 -0700 Subject: [PATCH 18/45] AB#30833 refine AI analysis status display --- .../Pages/GrantApplications/Details.cshtml | 7 +++- .../Pages/GrantApplications/ai-analysis.js | 37 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index f8ffa79efa..ad3a138684 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -414,7 +414,10 @@ {
-
Application Analysis
+
+
Application Analysis
+ +
-
+