From e9cfa4ad80b1b4d2f5d63ac64000442a857a74d1 Mon Sep 17 00:00:00 2001 From: Velang Date: Thu, 12 Feb 2026 15:54:38 -0800 Subject: [PATCH 01/25] Initial Commit --- .../cypress/e2e/ApplicationsActionBar.cy.ts | 629 ++++++------------ .../cypress/pages/ApplicationDetailsPage.ts | 506 ++++++++++++++ 2 files changed, 693 insertions(+), 442 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts index a6dc210c5..97fd8dcf8 100644 --- a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts @@ -1,426 +1,14 @@ /// import { loginIfNeeded } from "../support/auth"; +import { ApplicationsListPage } from "../pages/ApplicationDetailsPage"; describe("Unity Login and check data from CHEFS", () => { - const STANDARD_TIMEOUT = 20000; + const page = new ApplicationsListPage(); - function switchToDefaultGrantsProgramIfAvailable() { - cy.get("body").then(($body) => { - const hasUserInitials = $body.find(".unity-user-initials").length > 0; - - if (!hasUserInitials) { - cy.log("Skipping tenant switch: no user initials menu found"); - return; - } - - cy.get(".unity-user-initials").click(); - - cy.get("body").then(($body2) => { - const switchLink = $body2 - .find("#user-dropdown a.dropdown-item") - .filter((_, el) => { - 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: STANDARD_TIMEOUT }).should( - "include", - "/GrantPrograms", - ); - - cy.get("#search-grant-programs", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .clear() - .type("Default Grants Program"); - - // Flatten nested `within` usage to satisfy S2004 (limit nesting depth) - cy.contains( - "#UserGrantProgramsTable tbody tr", - "Default Grants Program", - { timeout: STANDARD_TIMEOUT }, - ) - .should("exist") - .within(() => { - cy.contains("button", "Select").should("be.enabled").click(); - }); - - cy.location("pathname", { timeout: STANDARD_TIMEOUT }).should((p) => { - expect( - p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, - ).to.eq(true); - }); - }); - }); - } - - // TEST renders the Submission tab inside an open shadow root (Form.io). - // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. - before(() => { - Cypress.config("includeShadowDom", true); - loginIfNeeded({ timeout: STANDARD_TIMEOUT }); - }); - - it("Switch to Default Grants Program if available", () => { - switchToDefaultGrantsProgramIfAvailable(); - }); - - it("Tests the existence and functionality of the Submitted Date From and Submitted Date To filters", () => { - const pad2 = (n: number) => String(n).padStart(2, "0"); - - const todayIsoLocal = () => { - const d = new Date(); - return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; - }; - - const waitForRefresh = () => { - // S3923 fix: remove identical branches; assert spinner is hidden when present. - cy.get('div.spinner-grow[role="status"]', { - timeout: STANDARD_TIMEOUT, - }).then(($s) => { - cy.wrap($s) - .should("have.attr", "style") - .and("contain", "display: none"); - }); - }; - - // --- Submitted Date From --- - cy.get("input#submittedFromDate", { timeout: STANDARD_TIMEOUT }) - .click({ force: true }) - .clear({ force: true }) - .type("2022-01-01", { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) - .should("have.value", "2022-01-01"); - - waitForRefresh(); - - // --- Submitted Date To --- - const today = todayIsoLocal(); - - cy.get("input#submittedToDate", { timeout: STANDARD_TIMEOUT }) - .click({ force: true }) - .clear({ force: true }) - .type(today, { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) - .should("have.value", today); - - waitForRefresh(); - }); - - // With no rows selected verify the visibility of Filter, Export, Save View, and Columns. - it("Verify the action buttons are visible with no rows selected", () => {}); - - // With one row selected verify the visibility of Filter, Export, Save View, and Columns. - it("Verify the action buttons are visible with one row selected", () => {}); - - it("Clicks Payment and force-closes the modal", () => { - const BUTTON_TIMEOUT = 60000; - - // Ensure table has rows - cy.get(".dt-scroll-body tbody tr", { timeout: STANDARD_TIMEOUT }).should( - "have.length.greaterThan", - 1, - ); - - // Select two rows using non-link cells - const clickSelectableCell = (rowIdx: number, withCtrl = false) => { - cy.get(".dt-scroll-body tbody tr", { timeout: STANDARD_TIMEOUT }) - .eq(rowIdx) - .find("td") - .not(":has(a)") - .first() - .click({ force: true, ctrlKey: withCtrl }); - }; - clickSelectableCell(0); - clickSelectableCell(1, true); - - // ActionBar - cy.get("#app_custom_buttons", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollIntoView(); - - // Click Payment - cy.get("#applicationPaymentRequest", { timeout: BUTTON_TIMEOUT }) - .should("be.visible") - .and("not.be.disabled") - .click({ force: true }); - - // Wait until modal is shown - cy.get("#payment-modal", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .and("have.class", "show"); - - // Attempt graceful closes first - cy.get("body").type("{esc}", { force: true }); // Bootstrap listens to ESC - cy.get(".modal-backdrop", { timeout: STANDARD_TIMEOUT }).then(($bd) => { - if ($bd.length) { - cy.wrap($bd).click("topLeft", { force: true }); - } - }); - - // Try footer Cancel if available (avoid .catch on Cypress chainable) - cy.contains("#payment-modal .modal-footer button", "Cancel", { - timeout: STANDARD_TIMEOUT, - }).then(($btn) => { - if ($btn && $btn.length > 0) { - cy.wrap($btn).scrollIntoView().click({ force: true }); - } else { - cy.log("Cancel button not present, proceeding to hard-close fallback"); - } - }); - - // Use window API (if present), then hard-close fallback - cy.window().then((win: any) => { - try { - if (typeof win.closePaymentModal === "function") { - win.closePaymentModal(); - } - } catch { - /* ignore */ - } - - // HARD CLOSE: forcibly hide modal and remove backdrop - 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", ""); // restore scroll - } catch { - /* ignore */ - } - } - }); - - // Verify modal/backdrop gone (be tolerant: assert non-interference instead of visibility only) - cy.get("#payment-modal", { timeout: STANDARD_TIMEOUT }).should(($m) => { - const isHidden = !$m.is(":visible") || !$m.hasClass("show"); - expect(isHidden, "payment-modal hidden or not shown").to.eq(true); - }); - cy.get(".modal-backdrop", { timeout: STANDARD_TIMEOUT }).should( - "not.exist", - ); - - // Right-side buttons usable - cy.get("#dynamicButtonContainerId", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollIntoView(); - - cy.contains("#dynamicButtonContainerId .dt-buttons button span", "Export", { - timeout: STANDARD_TIMEOUT, - }).should("be.visible"); - cy.contains( - "#dynamicButtonContainerId button.grp-savedStates", - "Save View", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); - cy.contains( - "#dynamicButtonContainerId .dt-buttons button span", - "Columns", - { timeout: STANDARD_TIMEOUT }, - ).should("be.visible"); - }); - - // Walk the Columns menu and toggle each column on, verifying the column is visible. - it("Verify all columns in the menu are visible when and toggled on.", () => { - const clickColumnsItem = (label: string) => { - cy.contains("a.dropdown-item", label, { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollIntoView() - .click({ force: true }); - }; - - const getVisibleHeaderTitles = () => { - return cy - .get(".dt-scroll-head span.dt-column-title", { - timeout: STANDARD_TIMEOUT, - }) - .then(($els) => { - const titles = Cypress.$($els) - .toArray() - .map((el) => (el.textContent || "").replace(/\s+/g, " ").trim()) - .filter((t) => t.length > 0); - return titles; - }); - }; - - const assertVisibleHeadersInclude = (expected: string[]) => { - getVisibleHeaderTitles().then((titles) => { - expected.forEach((e) => { - expect(titles, `visible headers should include "${e}"`).to.include(e); - }); - }); - }; - - const scrollX = (x: number) => { - cy.get(".dt-scroll-body", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .scrollTo(x, 0, { duration: 0, ensureScrollable: false }); - }; - - // Close any open dropdowns/modals first - cy.get("body").then(($body) => { - if ($body.find(".dt-button-background").length > 0) { - cy.get(".dt-button-background").click({ force: true }); - } - }); - - // Open the "Save View" dropdown - cy.get("button.grp-savedStates", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .and("contain.text", "Save View") - .click(); - - // Click "Reset to Default View" - cy.contains("a.dropdown-item", "Reset to Default View", { - timeout: STANDARD_TIMEOUT, - }) - .should("exist") - .click({ force: true }); - - // Wait for table to rebuild after reset - check for default columns - cy.get(".dt-scroll-head span.dt-column-title", { - timeout: STANDARD_TIMEOUT, - }).should("have.length.gt", 5); - - // Open Columns menu - cy.contains("span", "Columns", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .click(); - - // Wait for columns dropdown to be fully populated - cy.get("a.dropdown-item", { timeout: STANDARD_TIMEOUT }).should( - "have.length.gt", - 50, - ); - - clickColumnsItem("% of Total Project Budget"); - clickColumnsItem("Acquisition"); - clickColumnsItem("Applicant Electoral District"); - - clickColumnsItem("Applicant Id"); - clickColumnsItem("Applicant Id"); - - clickColumnsItem("Applicant Name"); - clickColumnsItem("Applicant Name"); - - clickColumnsItem("Approved Amount"); - clickColumnsItem("Approved Amount"); - - clickColumnsItem("Assessment Result"); - - clickColumnsItem("Assignee"); - clickColumnsItem("Assignee"); - - clickColumnsItem("Business Number"); - - clickColumnsItem("Category"); - clickColumnsItem("Category"); - - clickColumnsItem("City"); - - clickColumnsItem("Community"); - clickColumnsItem("Community"); - - clickColumnsItem("Community Population"); - clickColumnsItem("Contact Business Phone"); - clickColumnsItem("Contact Cell Phone"); - clickColumnsItem("Contact Email"); - clickColumnsItem("Contact Full Name"); - clickColumnsItem("Contact Title"); - clickColumnsItem("Decision Date"); - clickColumnsItem("Decline Rationale"); - clickColumnsItem("Due Date"); - clickColumnsItem("Due Diligence Status"); - clickColumnsItem("Economic Region"); - clickColumnsItem("Forestry Focus"); - clickColumnsItem("Forestry or Non-Forestry"); - clickColumnsItem("FYE Day"); - clickColumnsItem("FYE Month"); - clickColumnsItem("Indigenous"); - clickColumnsItem("Likelihood of Funding"); - clickColumnsItem("Non-Registered Organization Name"); - clickColumnsItem("Notes"); - clickColumnsItem("Org Book Status"); - clickColumnsItem("Organization Type"); - clickColumnsItem("Other Sector/Sub/Industry Description"); - clickColumnsItem("Owner"); - clickColumnsItem("Payout"); - clickColumnsItem("Place"); - clickColumnsItem("Project Electoral District"); - clickColumnsItem("Project End Date"); - - clickColumnsItem("Project Name"); - clickColumnsItem("Project Name"); - - clickColumnsItem("Project Start Date"); - clickColumnsItem("Project Summary"); - clickColumnsItem("Projected Funding Total"); - clickColumnsItem("Recommended Amount"); - clickColumnsItem("Red-Stop"); - clickColumnsItem("Regional District"); - clickColumnsItem("Registered Organization Name"); - clickColumnsItem("Registered Organization Number"); - - clickColumnsItem("Requested Amount"); - clickColumnsItem("Requested Amount"); - - clickColumnsItem("Risk Ranking"); - clickColumnsItem("Sector"); - clickColumnsItem("Signing Authority Business Phone"); - clickColumnsItem("Signing Authority Cell Phone"); - clickColumnsItem("Signing Authority Email"); - clickColumnsItem("Signing Authority Full Name"); - clickColumnsItem("Signing Authority Title"); - - clickColumnsItem("Status"); - clickColumnsItem("Status"); - - clickColumnsItem("Sub-Status"); - clickColumnsItem("Sub-Status"); - - clickColumnsItem("Submission #"); - clickColumnsItem("Submission #"); - - clickColumnsItem("Submission Date"); - clickColumnsItem("Submission Date"); - - clickColumnsItem("SubSector"); - - clickColumnsItem("Tags"); - clickColumnsItem("Tags"); - - clickColumnsItem("Total Paid Amount $"); - clickColumnsItem("Total Project Budget"); - clickColumnsItem("Total Score"); - clickColumnsItem("Unity Application Id"); - - // Close the menu and wait until the overlay is gone - cy.get("div.dt-button-background", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .click({ force: true }); - - cy.get("div.dt-button-background", { timeout: STANDARD_TIMEOUT }).should( - "not.exist", - ); - - // Assertions by horizontal scroll segments (human-style scan) - scrollX(0); - assertVisibleHeadersInclude([ + // Column visibility test data - organized by scroll position + const COLUMN_VISIBILITY_DATA = { + scrollPosition0: [ "Applicant Name", "Category", "Submission #", @@ -432,10 +20,8 @@ describe("Unity Login and check data from CHEFS", () => { "Approved Amount", "Project Name", "Applicant Id", - ]); - - scrollX(1500); - assertVisibleHeadersInclude([ + ], + scrollPosition1500: [ "Tags", "Assignee", "SubSector", @@ -443,20 +29,16 @@ describe("Unity Login and check data from CHEFS", () => { "Regional District", "Registered Organization Number", "Org Book Status", - ]); - - scrollX(3000); - assertVisibleHeadersInclude([ + ], + scrollPosition3000: [ "Project Start Date", "Project End Date", "Projected Funding Total", "Total Paid Amount $", "Project Electoral District", "Applicant Electoral District", - ]); - - scrollX(4500); - assertVisibleHeadersInclude([ + ], + scrollPosition4500: [ "Forestry or Non-Forestry", "Forestry Focus", "Acquisition", @@ -464,10 +46,8 @@ describe("Unity Login and check data from CHEFS", () => { "Community Population", "Likelihood of Funding", "Total Score", - ]); - - scrollX(6000); - assertVisibleHeadersInclude([ + ], + scrollPosition6000: [ "Assessment Result", "Recommended Amount", "Due Date", @@ -476,10 +56,8 @@ describe("Unity Login and check data from CHEFS", () => { "Project Summary", "Organization Type", "Business Number", - ]); - - scrollX(7500); - assertVisibleHeadersInclude([ + ], + scrollPosition7500: [ "Due Diligence Status", "Decline Rationale", "Contact Full Name", @@ -487,10 +65,8 @@ describe("Unity Login and check data from CHEFS", () => { "Contact Email", "Contact Business Phone", "Contact Cell Phone", - ]); - - scrollX(9000); - assertVisibleHeadersInclude([ + ], + scrollPosition9000: [ "Signing Authority Full Name", "Signing Authority Title", "Signing Authority Email", @@ -505,7 +81,176 @@ describe("Unity Login and check data from CHEFS", () => { "FYE Month", "Payout", "Unity Application Id", - ]); + ], + }; + + // Columns to toggle during the test - organized for maintainability + const COLUMNS_TO_TOGGLE = { + singleToggle: [ + "% of Total Project Budget", + "Acquisition", + "Applicant Electoral District", + "Assessment Result", + "Business Number", + "City", + "Community Population", + "Contact Business Phone", + "Contact Cell Phone", + "Contact Email", + "Contact Full Name", + "Contact Title", + "Decision Date", + "Decline Rationale", + "Due Date", + "Due Diligence Status", + "Economic Region", + "Forestry Focus", + "Forestry or Non-Forestry", + "FYE Day", + "FYE Month", + "Indigenous", + "Likelihood of Funding", + "Non-Registered Organization Name", + "Notes", + "Org Book Status", + "Organization Type", + "Other Sector/Sub/Industry Description", + "Owner", + "Payout", + "Place", + "Project Electoral District", + "Project End Date", + "Project Start Date", + "Project Summary", + "Projected Funding Total", + "Recommended Amount", + "Red-Stop", + "Regional District", + "Registered Organization Name", + "Registered Organization Number", + "Risk Ranking", + "Sector", + "Signing Authority Business Phone", + "Signing Authority Cell Phone", + "Signing Authority Email", + "Signing Authority Full Name", + "Signing Authority Title", + "SubSector", + "Total Paid Amount $", + "Total Project Budget", + "Total Score", + "Unity Application Id", + ], + doubleToggle: [ + "Applicant Id", + "Applicant Name", + "Approved Amount", + "Assignee", + "Category", + "Community", + "Project Name", + "Requested Amount", + "Status", + "Sub-Status", + "Submission #", + "Submission Date", + "Tags", + ], + }; + + // TEST renders the Submission tab inside an open shadow root (Form.io). + // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. + before(() => { + Cypress.config("includeShadowDom", true); + loginIfNeeded({ timeout: 20000 }); + }); + + it("Switch to Default Grants Program if available", () => { + page.switchToGrantProgram("Default Grants Program"); + }); + + it("Tests the existence and functionality of the Submitted Date From and Submitted Date To filters", () => { + // Set date filters and verify table refresh + page + .setSubmittedFromDate("2022-01-01") + .waitForTableRefresh() + .setSubmittedToDate(page.getTodayIsoLocal()) + .waitForTableRefresh(); + }); + + // With no rows selected verify the visibility of Filter, Export, Save View, and Columns. + it("Verify the action buttons are visible with no rows selected", () => { + // Placeholder for future implementation + }); + + // With one row selected verify the visibility of Filter, Export, Save View, and Columns. + it("Verify the action buttons are visible with one row selected", () => { + // Placeholder for future implementation + }); + + it("Clicks Payment and force-closes the modal", () => { + // Ensure table has data and select two rows + page + .verifyTableHasData() + .selectMultipleRows([0, 1]) + .verifyActionBarExists() + .clickPaymentButton() + .waitForPaymentModalVisible() + .closePaymentModal() + .verifyPaymentModalClosed(); + + // Verify right-side buttons are still usable + page + .verifyDynamicButtonContainerExists() + .verifyExportButtonVisible() + .verifySaveViewButtonVisible() + .verifyColumnsButtonVisible(); + }); + + // Walk the Columns menu and toggle each column on, verifying the column is visible. + it("Verify all columns in the menu are visible when and toggled on.", () => { + // Reset to default view and open columns menu + page.closeOpenDropdowns().resetToDefaultView().openColumnsMenu(); + + // Toggle all single-toggle columns + page.toggleColumns(COLUMNS_TO_TOGGLE.singleToggle); + + // Toggle all double-toggle columns (toggle twice to ensure visibility) + COLUMNS_TO_TOGGLE.doubleToggle.forEach((column) => { + page.clickColumnsItem(column).clickColumnsItem(column); + }); + + // Close the columns menu + page.closeColumnsMenu(); + + // Verify columns by scrolling through the table + page + .scrollTableHorizontally(0) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition0); + + page + .scrollTableHorizontally(1500) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition1500); + + page + .scrollTableHorizontally(3000) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition3000); + + page + .scrollTableHorizontally(4500) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition4500); + + page + .scrollTableHorizontally(6000) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition6000); + + page + .scrollTableHorizontally(7500) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition7500); + + page + .scrollTableHorizontally(9000) + .assertVisibleHeadersInclude(COLUMN_VISIBILITY_DATA.scrollPosition9000); }); it("Verify Logout", () => { diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 841506416..34eb15827 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -1,5 +1,511 @@ +/// + import { BasePage } from "./BasePage"; +/** + * ApplicationsListPage - Page Object for the Grant Applications List page + * Handles action bar, filters, table operations, columns menu, and modals + */ +export class ApplicationsListPage extends BasePage { + private readonly STANDARD_TIMEOUT = 20000; + private readonly BUTTON_TIMEOUT = 60000; + + // Date filter selectors + private readonly dateFilters = { + submittedFromDate: "input#submittedFromDate", + submittedToDate: "input#submittedToDate", + spinner: 'div.spinner-grow[role="status"]', + }; + + // Action bar selectors + private readonly actionBar = { + customButtons: "#app_custom_buttons", + dynamicButtonContainer: "#dynamicButtonContainerId", + paymentButton: "#applicationPaymentRequest", + exportButton: "#dynamicButtonContainerId .dt-buttons button span", + saveViewButton: "button.grp-savedStates", + columnsButton: "span", + }; + + // Table selectors + private readonly table = { + 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", + }; + + // Grant program selectors + private readonly grantProgram = { + userInitials: ".unity-user-initials", + userDropdown: "#user-dropdown a.dropdown-item", + searchInput: "#search-grant-programs", + programsTable: "#UserGrantProgramsTable", + programsTableRow: "#UserGrantProgramsTable tbody tr", + }; + + // Save view selectors + private readonly saveView = { + button: "button.grp-savedStates", + resetOption: "a.dropdown-item", + }; + + constructor() { + super(); + } + + // ============ Date Filter Methods ============ + + /** + * Set the Submitted From Date filter + */ + 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 }) + .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 }) + .click({ force: true }) + .clear({ force: true }) + .type(date, { force: true }) + .trigger("change", { force: true }) + .blur({ force: true }) + .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())}`; + } + + // ============ Table Methods ============ + + /** + * Verify table has rows + */ + verifyTableHasData(): this { + cy.get(this.table.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 { + cy.get(this.table.tableRows, { timeout: this.STANDARD_TIMEOUT }) + .eq(rowIndex) + .find("td") + .not(":has(a)") + .first() + .click({ force: true, ctrlKey: withCtrl }); + 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.table.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.table.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 + */ + assertVisibleHeadersInclude(expected: string[]): this { + this.getVisibleHeaderTitles().then((titles: string[]) => { + expected.forEach((e: string) => { + expect(titles, `visible headers should include "${e}"`).to.include(e); + }); + }); + return this; + } + + // ============ Action Bar Methods ============ + + /** + * Scroll to and verify action bar exists + */ + verifyActionBarExists(): this { + cy.get(this.actionBar.customButtons, { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .scrollIntoView(); + return this; + } + + /** + * Click the Payment button + */ + clickPaymentButton(): this { + cy.get(this.actionBar.paymentButton, { timeout: this.BUTTON_TIMEOUT }) + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + return this; + } + + /** + * Verify Export button is visible + */ + verifyExportButtonVisible(): this { + cy.contains(this.actionBar.exportButton, "Export", { + timeout: this.STANDARD_TIMEOUT, + }).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 span", + "Columns", + { timeout: this.STANDARD_TIMEOUT } + ).should("be.visible"); + return this; + } + + /** + * Verify dynamic button container exists + */ + verifyDynamicButtonContainerExists(): this { + cy.get(this.actionBar.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"); + return this; + } + + /** + * Close payment modal using multiple strategies + */ + closePaymentModal(): this { + // Attempt ESC key + cy.get("body").type("{esc}", { force: true }); + + // Click backdrop if present + cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT }).then( + ($bd: JQuery) => { + if ($bd.length) { + cy.wrap($bd).click("topLeft", { force: true }); + } + } + ); + + // Try Cancel button if available + cy.contains(this.paymentModal.cancelButton, "Cancel", { + timeout: this.STANDARD_TIMEOUT, + }).then(($btn: JQuery) => { + if ($btn && $btn.length > 0) { + cy.wrap($btn).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 */ + } + + // 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 */ + } + } + }); + return this; + } + + /** + * 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" + ); + return this; + } + + // ============ Columns Menu Methods ============ + + /** + * 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("exist") + .click({ force: true }); + + // Wait for table to rebuild + cy.get(this.table.columnTitles, { timeout: this.STANDARD_TIMEOUT }).should( + "have.length.gt", + 5 + ); + return this; + } + + /** + * Open the Columns menu + */ + openColumnsMenu(): this { + cy.contains("span", "Columns", { timeout: this.STANDARD_TIMEOUT }) + .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 + */ + clickColumnsItem(label: string): this { + cy.contains(this.columnsMenu.dropdownItem, label, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("exist") + .scrollIntoView() + .click({ force: true }); + 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 + */ + 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; + } +} + +/** + * ApplicationDetailsPage - Page Object for the Application Details page + * Handles tabs, status actions, and field verification + */ export class ApplicationDetailsPage extends BasePage { // Tab selectors private readonly tabs = { From 79329bb0ffbe18d0c1c60b2666b6a7fcec534ef6 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:06:56 -0800 Subject: [PATCH 02/25] AB#28769 - Refactor ApplicationContactsWidget to support reload --- .../EditContactModal.cshtml | 4 + .../ApplicationContact/EditContactModal.js | 46 ++++++ .../Components/ApplicantInfo/Default.cshtml | 13 +- .../ApplicationContactsWidgetController.cs | 4 +- .../ApplicationContactsWidgetViewComponent.cs | 9 +- .../ApplicationContactsWidgetViewModel.cs | 1 - .../ApplicationContactsWidget/Default.cshtml | 104 ++++++++----- .../ApplicationContactsWidget/Default.js | 143 +++++++++++------- .../Components/SummaryWidget/Default.js | 37 +---- 9 files changed, 208 insertions(+), 153 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.js diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml index 437084740..4e09aea7c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml @@ -10,6 +10,10 @@ Layout = null; } +@section scripts { + +} + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.js new file mode 100644 index 000000000..a0bdff0ab --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.js @@ -0,0 +1,46 @@ +(function ($) { + abp.modals.editOrDeleteContactModal = function () { + let initModal = function (publicApi, args) { + let modalManager = publicApi; + + $('#DeleteContactButton').click(handleDeleteContact); + + function handleDeleteContact(e) { + e.preventDefault(); + abp.message.confirm('Are you sure to delete this contact?') + .then(processDeleteConfirmation); + } + + function processDeleteConfirmation(confirmed) { + if (confirmed) { + deleteContact(); + } + } + + function deleteContact() { + try { + unity.grantManager.grantApplications.applicationContact + .delete(args.id) + .done(onContactDeleted) + .fail(onDeleteFailure); + } catch (error) { + onDeleteFailure(error); + } + } + + function onContactDeleted() { + modalManager.close(); + PubSub.publish("refresh_application_contacts"); + abp.notify.success('The contact has been deleted.'); + } + + function onDeleteFailure(error) { + abp.notify.error('Contact deletion failed.'); + if (error) { + console.log(error); + } + } + }; + return { initModal: initModal }; + } +})(jQuery); \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml index 7f30880c2..45dc00f84 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml @@ -369,18 +369,7 @@ form-id="@Model.ApplicationFormId" show-legend="false" editable-if="IsAdditionalContactEditable"> -
@L["Summary:ContactsTitle"].Value
-
- @await Component.InvokeAsync("ApplicationContactsWidget", new { applicationId = Model.ApplicationId, isReadOnly = !IsAdditionalContactEditable }) -
- - @if (IsAdditionalContactAddable) - { -
- -
- } + @await Component.InvokeAsync("ApplicationContactsWidget", new { applicationId = Model.ApplicationId })
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs index 3d1a62cd1..bdad15302 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs @@ -15,14 +15,14 @@ public class ApplicationContactsWidgetController : AbpController [HttpGet] [Route("RefreshApplicationContacts")] - public IActionResult ApplicationContacts(Guid applicationId, Boolean isReadOnly = false) + public IActionResult ApplicationContacts(Guid applicationId) { if (!ModelState.IsValid) { logger.LogWarning("Invalid model state for ApplicationContactsWidgetController: RefreshApplicationContacts"); return ViewComponent("ApplicationContactsWidget"); } - return ViewComponent("ApplicationContactsWidget", new { applicationId, isReadOnly }); + return ViewComponent("ApplicationContactsWidget", new { applicationId }); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs index 9022e29a1..d65bba4c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewComponent.cs @@ -13,7 +13,7 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicationContactsWidg RefreshUrl = "Widgets/ApplicationContacts/RefreshApplicationContacts", ScriptTypes = new[] { typeof(ApplicationContactsWidgetScriptBundleContributor) }, StyleTypes = new[] { typeof(ApplicationContactsWidgetStyleBundleContributor) }, - AutoInitialize = true)] + AutoInitialize = false)] public class ApplicationContactsWidgetViewComponent : AbpViewComponent { private readonly IApplicationContactService _applicationContactService; @@ -23,13 +23,12 @@ public ApplicationContactsWidgetViewComponent(IApplicationContactService applica _applicationContactService = applicationContactService; } - public async Task InvokeAsync(Guid applicationId, Boolean isReadOnly) + public async Task InvokeAsync(Guid applicationId) { List applicationContacts = await _applicationContactService.GetListByApplicationAsync(applicationId); ApplicationContactsWidgetViewModel model = new() { ApplicationContacts = applicationContacts, - ApplicationId = applicationId, - IsReadOnly = isReadOnly + ApplicationId = applicationId }; return View(model); @@ -52,7 +51,7 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files .AddIfNotContains("/Views/Shared/Components/ApplicationContactsWidget/Default.js"); context.Files - .AddIfNotContains("/libs/pubsub-js/src/pubsub.js"); + .AddIfNotContains("/Pages/ApplicationContact/EditContactModal.js"); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs index e76480ab1..13afa46c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetViewModel.cs @@ -14,7 +14,6 @@ public ApplicationContactsWidgetViewModel() public List ApplicationContacts { get; set; } public Guid ApplicationId { get; set; } - public Boolean IsReadOnly { get; set; } public static String ContactTypeValue(String contactType) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml index ae8f74799..cfa151662 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml @@ -1,52 +1,74 @@ +@using Microsoft.Extensions.Localization @using Unity.GrantManager.Web.Views.Shared.Components.ApplicationContactsWidget; +@using Unity.GrantManager.Localization; +@using Unity.Modules.Shared +@using Volo.Abp.Authorization.Permissions + +@inject IPermissionChecker PermissionChecker +@inject IStringLocalizer L @model ApplicationContactsWidgetViewModel @{ Layout = null; } -
- @if (Model.ApplicationContacts.Count > 0) { -

Info

-
- } - @foreach (var contact in Model.ApplicationContacts) - { -
-
-
-

@ApplicationContactsWidgetViewModel.ContactTypeValue(contact.ContactType)

-

@contact.ContactFullName, @contact.ContactTitle

- @if (!contact.ContactEmail.IsNullOrEmpty()) - { -
- -
@contact.ContactEmail
-
- } - @if (!contact.ContactMobilePhone.IsNullOrEmpty()) - { -
- -
@contact.ContactMobilePhone
-
- } - @if (!contact.ContactWorkPhone.IsNullOrEmpty()) - { -
- -
@contact.ContactWorkPhone
-
- } -
- @if(!(Model.IsReadOnly)) { -
- +@section scripts { + +} + +
@L["Summary:ContactsTitle"].Value
+ +@if (Model.ApplicationContacts.Count > 0) { +

Info

+
+} + +@foreach (var contact in Model.ApplicationContacts) +{ +
+
+
+

@ApplicationContactsWidgetViewModel.ContactTypeValue(contact.ContactType)

+

@contact.ContactFullName, @contact.ContactTitle

+ @if (!contact.ContactEmail.IsNullOrEmpty()) + { +
+ +
@contact.ContactEmail
+
+ } + @if (!contact.ContactMobilePhone.IsNullOrEmpty()) + { +
+ +
@contact.ContactMobilePhone
+
+ } + @if (!contact.ContactWorkPhone.IsNullOrEmpty()) + { +
+ +
@contact.ContactWorkPhone
}
-
+ @if (await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Update)) + { +
+ +
+ }
- } -
+
+
+} + +@if (await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Create)) +{ +
+ +
+} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js index 5e48b25eb..18aadf995 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js @@ -1,74 +1,103 @@ $(function () { - - let contactModal = new abp.ModalManager({ + let applicantContactsWidgetToken = null; + let _createContactModal = new abp.ModalManager(abp.appPath + 'ApplicationContact/CreateContactModal'); + let _editContactModal = new abp.ModalManager({ viewUrl: abp.appPath + 'ApplicationContact/EditContactModal', - modalClass: "editContactModal" + scriptUrl: '/Pages/ApplicationContact/EditContactModal.js', + modalClass: "editOrDeleteContactModal" }); - abp.modals.editContactModal = function () { - let initModal = function (publicApi, args) { - setupContactModal(args); - }; - return { initModal: initModal }; - } - - $('body').on('click','.contact-edit-btn',function(e){ - e.preventDefault(); - let itemId = $(this).data('id'); - contactModal.open({ - id: itemId - }); + // Handle modal result - refresh the widget after successful contact creation + _createContactModal.onResult(function () { + PubSub.publish("refresh_application_contacts"); + abp.notify.success( + 'The application contact have been successfully added.', + 'Application Contacts' + ); }); - contactModal.onResult(function () { + _editContactModal.onResult(function () { + PubSub.publish("refresh_application_contacts"); abp.notify.success( 'The application contact have been successfully updated.', 'Application Contacts' ); - PubSub.publish("refresh_application_contacts"); }); - let setupContactModal = function (args) { - $('#DeleteContactButton').click(handleDeleteContact); + abp.widgets.ApplicationContactsWidget = function ($wrapper) { - function handleDeleteContact(e) { - e.preventDefault(); - showDeleteConfirmation(); - } - - function showDeleteConfirmation() { - abp.message.confirm('Are you sure to delete this contact?') - .then(processDeleteConfirmation); - } - - function processDeleteConfirmation(confirmed) { - if (confirmed) { - deleteContact(); - } - } - - function deleteContact() { - try { - unity.grantManager.grantApplications.applicationContact - .delete(args.id) - .done(onContactDeleted) - .fail(onDeleteFailure); - } catch (error) { - onDeleteFailure(error); + let _widgetManager = $wrapper.data('abp-widget-manager'); + + let widgetApi = { + applicationId: null, // Cache the applicationId to prevent reading from stale DOM + + getFilters: function () { + const appId = this.applicationId || $wrapper.find('#ApplicationContactsWidget_ApplicationId').val(); + + return { + applicationId: appId + }; + }, + + init: function (filters) { + this.applicationId = $wrapper.find('#ApplicationContactsWidget_ApplicationId').val(); + this.setupEventHandlers(); + }, + + refresh: function () { + const currentFilters = this.getFilters(); + _widgetManager.refresh($wrapper, currentFilters); + }, + + setupEventHandlers: function() { + const self = this; + + // Unsubscribe from previous subscription if it exists + // This prevents duplicate event handlers after widget refresh + if (applicantContactsWidgetToken) { + PubSub.unsubscribe(applicantContactsWidgetToken); + applicantContactsWidgetToken = null; + } + + // Subscribe to the applicant_info_merged event and store the token + applicantContactsWidgetToken = PubSub.subscribe( + 'refresh_application_contacts', + () => { + self.refresh(); + } + ); + + // Handle Add Contact button click + $wrapper.on('click', '#CreateContactButton', function (e) { + e.preventDefault(); + _createContactModal.open({ + applicationId: $('#ApplicationContactsWidget_ApplicationId').val() + }); + }); + + $wrapper.on('click', '.contact-edit-btn', function (e) { + e.preventDefault(); + let itemId = $(this).data('id'); + _editContactModal.open({ + id: itemId + }); + }); } } - - function onContactDeleted() { - PubSub.publish("refresh_application_contacts"); - contactModal.close(); - abp.notify.success('The contact has been deleted.'); - } - - function onDeleteFailure(error) { - abp.notify.error('Contact deletion failed.'); - if (error) { - console.log(error); - } + + return widgetApi; + }; + + // Initialize the ApplicationContactsWidget manager with filter callback + let applicationContactsWidgetManager = new abp.WidgetManager({ + wrapper: '.abp-widget-wrapper[data-widget-name="ApplicationContactsWidget"]', + filterCallback: function () { + return { + 'applicationId': $('#ApplicationContactsWidget_ApplicationId').val() + }; } - } + }); + + // Initialize the widget + applicationContactsWidgetManager.init(); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js index 84d64f5e4..72d6b2e03 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/SummaryWidget/Default.js @@ -1,38 +1,5 @@ $(function () { - let applicationId = document.getElementById('SummaryWidgetApplicationId').value; - let isReadOnly = document.getElementById('SummaryWidgetIsReadOnly').value; - let contactModal = new abp.ModalManager(abp.appPath + 'ApplicationContact/CreateContactModal'); - - let applicationContactsWidgetManager = new abp.WidgetManager({ - wrapper: '#applicationContactsWidget', - filterCallback: function () { - return { - 'applicationId': applicationId, - 'isReadOnly': isReadOnly - }; - } - }); - - $('#AddContactButton').click(function (e) { - e.preventDefault(); - contactModal.open({ - applicationId: applicationId - }); - }); - - contactModal.onResult(function () { - abp.notify.success( - 'The application contact have been successfully added.', - 'Application Contacts' - ); - applicationContactsWidgetManager.refresh(); - }); - - PubSub.subscribe( - 'refresh_application_contacts', - (msg, data) => { - applicationContactsWidgetManager.refresh(); - } - ); + // SummaryWidget initialization + // Contact modal and widget management moved to ApplicationContactsWidget component }); From c5f13e47dfdc4e1adb9274b4f377fd822ab0d3d7 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:12:57 -0800 Subject: [PATCH 03/25] AB#28769 - Update ApplicationContactWidget unity tests --- .../Components/ApplicationContactWidgetTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs index abab7db3d..9b59458b0 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationContactWidgetTests.cs @@ -64,7 +64,7 @@ public async Task ApplicationContactWidgetReturnsStatus() }; //Act - var result = await viewComponent.InvokeAsync(applicationId, true) as ViewViewComponentResult; + var result = await viewComponent.InvokeAsync(applicationId) as ViewViewComponentResult; ApplicationContactsWidgetViewModel? resultModel; resultModel = result!.ViewData!.Model! as ApplicationContactsWidgetViewModel; From 295e5b45f487aba8b5e1a57e7b89a9ad7adfcca1 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:32:46 -0800 Subject: [PATCH 04/25] AB#28769 - Add contact refresh code quality improvements --- .../EditContactModal.cshtml | 6 ++--- .../Components/ApplicantInfo/Default.cshtml | 1 - .../ApplicationContactsWidget/Default.cshtml | 13 ++++++++--- .../ApplicationContactsWidget/Default.js | 23 +++++++++---------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml index 4e09aea7c..0ca46aa5d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationContact/EditContactModal.cshtml @@ -10,9 +10,9 @@ Layout = null; } -@section scripts { - -} + +@* NOTE: Dependency /Pages/ApplicationContact/EditContactModal.js is included through ApplicationContactsWidget *@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml index 45dc00f84..ad1d64a50 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml @@ -30,7 +30,6 @@ }); bool IsViewEditable = !updatePermissionResult.AllProhibited; - bool IsAdditionalContactAddable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Create); bool IsAdditionalContactEditable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Update); bool IsAssignApplicant = await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.AssignApplicant); bool IsLookupEnabled = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.Summary.Update); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml index cfa151662..44c688326 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml @@ -16,6 +16,11 @@ } +@{ + bool IsAdditionalContactAddable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Create); + bool IsAdditionalContactEditable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Update); +} +
@L["Summary:ContactsTitle"].Value
@if (Model.ApplicationContacts.Count > 0) { @@ -52,10 +57,12 @@
}
- @if (await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Update)) + @if (IsAdditionalContactEditable) {
-
} @@ -64,7 +71,7 @@
} -@if (await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Create)) +@if (IsAdditionalContactAddable) {
{ @@ -67,15 +66,20 @@ } ); + // Prevent duplicate delegated click handlers on re-init by removing any + // existing handlers in this widget's namespace before re-binding. + $wrapper.off('click.ApplicationContactsWidget', '#CreateContactButton'); + $wrapper.off('click.ApplicationContactsWidget', '.contact-edit-btn'); + // Handle Add Contact button click - $wrapper.on('click', '#CreateContactButton', function (e) { + $wrapper.on('click.ApplicationContactsWidget', '#CreateContactButton', function (e) { e.preventDefault(); _createContactModal.open({ - applicationId: $('#ApplicationContactsWidget_ApplicationId').val() + applicationId: self.applicationId || $wrapper.find('#ApplicationContactsWidget_ApplicationId').val() }); }); - $wrapper.on('click', '.contact-edit-btn', function (e) { + $wrapper.on('click.ApplicationContactsWidget', '.contact-edit-btn', function (e) { e.preventDefault(); let itemId = $(this).data('id'); _editContactModal.open({ @@ -90,12 +94,7 @@ // Initialize the ApplicationContactsWidget manager with filter callback let applicationContactsWidgetManager = new abp.WidgetManager({ - wrapper: '.abp-widget-wrapper[data-widget-name="ApplicationContactsWidget"]', - filterCallback: function () { - return { - 'applicationId': $('#ApplicationContactsWidget_ApplicationId').val() - }; - } + wrapper: '.abp-widget-wrapper[data-widget-name="ApplicationContactsWidget"]' }); // Initialize the widget From b545c6cf6319ea6ce91cebce340b141b735a1c5c Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:44:21 -0800 Subject: [PATCH 05/25] AB#28769 - Update applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Components/ApplicationContactsWidget/Default.cshtml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml index 44c688326..1f127697b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.cshtml @@ -12,10 +12,6 @@ Layout = null; } -@section scripts { - -} - @{ bool IsAdditionalContactAddable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Create); bool IsAdditionalContactEditable = await PermissionChecker.IsGrantedAsync(UnitySelector.Applicant.AdditionalContact.Update); From 82821c7e29db7a0f203f79a9984cbe3cf95a20bf Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:45:17 -0800 Subject: [PATCH 06/25] AB#28769 - Update applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ApplicationContactsWidgetController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs index bdad15302..bd396f328 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/ApplicationContactsWidgetController.cs @@ -20,7 +20,7 @@ public IActionResult ApplicationContacts(Guid applicationId) if (!ModelState.IsValid) { logger.LogWarning("Invalid model state for ApplicationContactsWidgetController: RefreshApplicationContacts"); - return ViewComponent("ApplicationContactsWidget"); + return ViewComponent("ApplicationContactsWidget", new { applicationId }); } return ViewComponent("ApplicationContactsWidget", new { applicationId }); } From 1d21a198d44458941829803c4225bfd90bcccc82 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:45:47 -0800 Subject: [PATCH 07/25] AB#28769 - Update applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Shared/Components/ApplicationContactsWidget/Default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js index 53e5a8d80..f1858be26 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationContactsWidget/Default.js @@ -3,7 +3,7 @@ let _createContactModal = new abp.ModalManager(abp.appPath + 'ApplicationContact/CreateContactModal'); let _editContactModal = new abp.ModalManager({ viewUrl: abp.appPath + 'ApplicationContact/EditContactModal', - scriptUrl: '/Pages/ApplicationContact/EditContactModal.js', + scriptUrl: abp.appPath + 'Pages/ApplicationContact/EditContactModal.js', modalClass: "editOrDeleteContactModal" }); From 8882dc5d778bcd9b74ed93c3daf1b749881aff25 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Tue, 17 Feb 2026 15:41:09 -0800 Subject: [PATCH 08/25] AB#31848: Compute Table Height Offset for Applicant Profile Details --- .../Pages/Applicants/Details.css | 2 +- .../Pages/Applicants/Details.js | 15 +++++++++++++++ .../Components/ApplicantSubmissions/Default.css | 6 +++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css index 0fb63a205..8d77b2fed 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.css @@ -21,7 +21,7 @@ /* Left panel tabs scrolling */ #detailsTab .tab-content { - overflow-y: scroll; + overflow-y: auto; overflow-x: hidden; height: calc(100vh - 220px); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js index b8b668979..6b9564063 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js @@ -31,6 +31,7 @@ function initializeApplicantDetailsPage() { setTimeout(function () { $('#main-loading').fadeOut(300, function () { $('.fade-in-load').addClass('visible'); + applyTabHeightOffset(); }); }, 500); @@ -82,6 +83,18 @@ function adjustVisibleTablesInContainer(containerId) { }); } +function applyTabHeightOffset() { + const detailsTab = document.getElementById('detailsTab'); + if (!detailsTab) return; + const tabNav = detailsTab.querySelector('ul.nav-tabs, ul.nav'); + const tabContent = detailsTab.querySelector('.tab-content'); + if (!tabNav || !tabContent) return; + const baseOffset = 175; + const totalOffset = baseOffset + tabNav.clientHeight; + tabContent.style.height = `calc(100vh - ${totalOffset}px)`; + tabContent.style.overflowY = 'auto'; +} + function initializeResizableDivider() { const divider = document.getElementById('main-divider'); const leftPanel = document.getElementById('main-left'); @@ -114,6 +127,7 @@ function initializeResizableDivider() { // Resize DataTables during panel resize debouncedResizeAwareDataTables(); + applyTabHeightOffset(); localStorage.setItem(storageKey, leftPercentage.toString()); } }; @@ -150,6 +164,7 @@ function initializeResizableDivider() { }); window.addEventListener('resize', restoreDividerPosition); + window.addEventListener('resize', applyTabHeightOffset); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css index 295de3ccd..7a017fac3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.css @@ -108,9 +108,9 @@ word-wrap: break-word; } -/* Override tab-content scrolling for Submissions tab - no scrolling */ -#detailsTab .tab-content:has(#SubmissionsWidget) { - overflow: hidden !important; +/* Submissions tab pane - handle overflow internally via dt-scroll-body */ +#nav-submissions { + overflow: hidden; } /* Make Submissions widget fill available space */ From d2af5276854bdf8ed59d42ec7ed8e041d72c5bab Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 17 Feb 2026 15:42:16 -0800 Subject: [PATCH 09/25] AB#31896 - generic contacts service start, and applicant profile read --- .../ApplicantProfileDto.cs | 4 +- .../ApplicantProfileRequest.cs | 2 +- .../IApplicantProfileAppService.cs | 3 +- .../IApplicantProfileContactService.cs | 29 + .../IApplicantProfileDataProvider.cs | 4 +- .../ProfileData/ApplicantAddressInfoDto.cs | 2 +- .../ProfileData/ApplicantContactInfoDto.cs | 13 + .../ProfileData/ApplicantOrgInfoDto.cs | 2 +- .../ProfileData/ApplicantPaymentInfoDto.cs | 2 +- .../ProfileData/ApplicantProfileDataDto.cs | 15 + .../ProfileData/ApplicantSubmissionInfoDto.cs | 2 +- .../ProfileData/ContactInfoItemDto.cs | 21 + .../ProfileData/ApplicantContactInfoDto.cs | 7 - .../ProfileData/ApplicantProfileDataDto.cs | 7 - .../Contacts/ContactDto.cs | 39 ++ .../Contacts/CreateContactLinkDto.cs | 42 ++ .../Contacts/IContactAppService.cs | 40 ++ ...rantManagerPermissionDefinitionProvider.cs | 5 + .../AddressInfoDataProvider.cs | 4 +- .../ApplicantProfileAppService.cs | 4 +- .../ApplicantProfileContactService.cs | 88 +++ .../ApplicantProfile/ApplicantProfileKeys.cs | 2 +- .../ContactInfoDataProvider.cs | 43 ++ .../ApplicantProfile/OrgInfoDataProvider.cs | 4 +- .../PaymentInfoDataProvider.cs | 4 +- .../SubmissionInfoDataProvider.cs | 4 +- .../ContactInfoDataProvider.cs | 17 - .../ApplicantTenantMapReconciliationWorker.cs | 1 + .../Contacts/ContactAppService.cs | 135 ++++ .../Permissions/GrantManagerPermissions.cs | 12 + .../Repositories/ContactLinkRepository.cs | 21 + .../Repositories/ContactRepository.cs | 21 + .../Controllers/ApplicantProfileController.cs | 18 +- .../GrantManagerWebModule.cs | 1 + .../ApplicantProfileDataSchemaFilter.cs | 50 ++ .../ApplicantProfileAppServiceTests.cs | 12 +- .../ApplicantProfileDataProviderTests.cs | 25 +- .../Contacts/ContactAppServiceTests.cs | 604 ++++++++++++++++++ .../Contacts/ContactInfoDataProviderTests.cs | 180 ++++++ .../Contacts/ContactInfoServiceTests.cs | 379 +++++++++++ .../TestAsyncEnumerableQueryable.cs | 61 ++ 41 files changed, 1864 insertions(+), 65 deletions(-) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/{Applicants => ApplicantProfile}/ApplicantProfileDto.cs (76%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/{Applicants => ApplicantProfile}/ApplicantProfileRequest.cs (89%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/{Applicants => ApplicantProfile}/IApplicantProfileAppService.cs (82%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs rename applications/Unity.GrantManager/src/{Unity.GrantManager.Application/Applicants => Unity.GrantManager.Application.Contracts}/ApplicantProfile/IApplicantProfileDataProvider.cs (90%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/{Applicants => ApplicantProfile}/ProfileData/ApplicantAddressInfoDto.cs (70%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/{Applicants => ApplicantProfile}/ProfileData/ApplicantOrgInfoDto.cs (69%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/{Applicants => ApplicantProfile}/ProfileData/ApplicantPaymentInfoDto.cs (70%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantProfileDataDto.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/{Applicants => ApplicantProfile}/ProfileData/ApplicantSubmissionInfoDto.cs (71%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantContactInfoDto.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantProfileDataDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/ContactDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/CreateContactLinkDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/{Applicants => }/ApplicantProfile/AddressInfoDataProvider.cs (82%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/{Applicants => ApplicantProfile}/ApplicantProfileAppService.cs (98%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/{Applicants => }/ApplicantProfile/ApplicantProfileKeys.cs (85%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/{Applicants => }/ApplicantProfile/OrgInfoDataProvider.cs (82%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/{Applicants => }/ApplicantProfile/PaymentInfoDataProvider.cs (82%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/{Applicants => }/ApplicantProfile/SubmissionInfoDataProvider.cs (83%) delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ContactInfoDataProvider.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs similarity index 76% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs index 9ec81114f..c36d91fe4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileDto.cs @@ -1,7 +1,7 @@ using System; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { public class ApplicantProfileDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs similarity index 89% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs index 9f65d31cd..5b6ef5b92 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ApplicantProfileRequest.cs @@ -1,6 +1,6 @@ using System; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { public class ApplicantProfileRequest { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs index 968cef47f..feb364582 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileAppService.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Unity.GrantManager.Applicants; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { public interface IApplicantProfileAppService { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs new file mode 100644 index 000000000..671c49736 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile.ProfileData; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Provides applicant-profile-specific contact retrieval operations. +/// This service aggregates contacts from two sources: profile-linked contacts +/// and application-level contacts matched by OIDC subject. +/// +public interface IApplicantProfileContactService +{ + /// + /// Retrieves contacts linked to the specified applicant profile. + /// + /// The unique identifier of the applicant profile. + /// A list of with IsEditable set to true. + Task> GetProfileContactsAsync(Guid profileId); + + /// + /// Retrieves application contacts associated with submissions matching the given OIDC subject. + /// The subject is normalized by stripping the domain portion (after @) and converting to upper case. + /// + /// The OIDC subject identifier (e.g. "user@idir"). + /// A list of with IsEditable set to false. + Task> GetApplicationContactsBySubjectAsync(string subject); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/IApplicantProfileDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs similarity index 90% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/IApplicantProfileDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs index 6a1b6b691..1dd840487 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/IApplicantProfileDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileDataProvider.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { /// /// Defines a contract for components that can provide applicant profile data diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs similarity index 70% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs index fde1734a0..a532be406 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantAddressInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs new file mode 100644 index 000000000..1da48226c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class ApplicantContactInfoDto : ApplicantProfileDataDto + { + public override string DataType => "CONTACTINFO"; + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Include)] + public List Contacts { get; set; } = []; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantOrgInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs similarity index 69% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantOrgInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs index c14ac0413..4a99135f3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantOrgInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantOrgInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantPaymentInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs similarity index 70% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantPaymentInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs index a6f7b77c3..c17c89eeb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantPaymentInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantPaymentInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantProfileDataDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantProfileDataDto.cs new file mode 100644 index 000000000..65da1a0fa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantProfileDataDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + [JsonPolymorphic(TypeDiscriminatorPropertyName = "dataType")] + [JsonDerivedType(typeof(ApplicantContactInfoDto), "CONTACTINFO")] + [JsonDerivedType(typeof(ApplicantOrgInfoDto), "ORGINFO")] + [JsonDerivedType(typeof(ApplicantAddressInfoDto), "ADDRESSINFO")] + [JsonDerivedType(typeof(ApplicantSubmissionInfoDto), "SUBMISSIONINFO")] + [JsonDerivedType(typeof(ApplicantPaymentInfoDto), "PAYMENTINFO")] + public class ApplicantProfileDataDto + { + public virtual string DataType { get; } = ""; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs similarity index 71% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs index 4c0a0ba60..5c3618c30 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantSubmissionInfoDto : ApplicantProfileDataDto { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs new file mode 100644 index 000000000..2be1b4ed0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs @@ -0,0 +1,21 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class ContactInfoItemDto + { + public Guid ContactId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Title { get; set; } + public string? Email { get; set; } + public string? HomePhoneNumber { get; set; } + public string? MobilePhoneNumber { get; set; } + public string? WorkPhoneNumber { get; set; } + public string? WorkPhoneExtension { get; set; } + public string? ContactType { get; set; } + public string? Role { get; set; } + public bool IsPrimary { get; set; } + public bool IsEditable { get; set; } + public Guid? ApplicationId { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantContactInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantContactInfoDto.cs deleted file mode 100644 index 74c15630b..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantContactInfoDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Unity.GrantManager.Applicants.ProfileData -{ - public class ApplicantContactInfoDto : ApplicantProfileDataDto - { - public override string DataType => "CONTACTINFO"; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantProfileDataDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantProfileDataDto.cs deleted file mode 100644 index 3c717b28b..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantProfileDataDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Unity.GrantManager.Applicants.ProfileData -{ - public class ApplicantProfileDataDto - { - public virtual string DataType { get; } = ""; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/ContactDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/ContactDto.cs new file mode 100644 index 000000000..263a54757 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/ContactDto.cs @@ -0,0 +1,39 @@ +using System; + +namespace Unity.GrantManager.Contacts; + +/// +/// Represents a contact linked to an entity, returned by the generic contacts service. +/// +public class ContactDto +{ + /// The unique identifier of the contact. + public Guid ContactId { get; set; } + + /// The full name of the contact. + public string Name { get; set; } = string.Empty; + + /// The job title of the contact. + public string? Title { get; set; } + + /// The email address of the contact. + public string? Email { get; set; } + + /// The home phone number of the contact. + public string? HomePhoneNumber { get; set; } + + /// The mobile phone number of the contact. + public string? MobilePhoneNumber { get; set; } + + /// The work phone number of the contact. + public string? WorkPhoneNumber { get; set; } + + /// The work phone extension of the contact. + public string? WorkPhoneExtension { get; set; } + + /// The role of the contact within the linked entity context. + public string? Role { get; set; } + + /// Whether this contact is the primary contact for the linked entity. + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/CreateContactLinkDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/CreateContactLinkDto.cs new file mode 100644 index 000000000..58e49b7ed --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/CreateContactLinkDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Unity.GrantManager.Contacts; + +/// +/// Input DTO for creating a new contact and linking it to a related entity. +/// +public class CreateContactLinkDto +{ + /// The full name of the contact. + public string Name { get; set; } = string.Empty; + + /// The job title of the contact. + public string? Title { get; set; } + + /// The email address of the contact. + public string? Email { get; set; } + + /// The home phone number of the contact. + public string? HomePhoneNumber { get; set; } + + /// The mobile phone number of the contact. + public string? MobilePhoneNumber { get; set; } + + /// The work phone number of the contact. + public string? WorkPhoneNumber { get; set; } + + /// The work phone extension of the contact. + public string? WorkPhoneExtension { get; set; } + + /// The role of the contact within the linked entity context. + public string? Role { get; set; } + + /// Whether this contact should be set as the primary contact. Only one primary is allowed per entity type and entity ID. + public bool IsPrimary { get; set; } + + /// The type of the entity to link the contact to (e.g. "ApplicantProfile"). + public string RelatedEntityType { get; set; } = string.Empty; + + /// The unique identifier of the related entity. + public Guid RelatedEntityId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs new file mode 100644 index 000000000..4a07057c5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Contacts/IContactAppService.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Contacts; + +/// +/// Generic contact management service. Provides operations for creating, retrieving, +/// and managing contacts linked to any entity type via . +/// +public interface IContactAppService +{ + /// + /// Retrieves all active contacts linked to the specified entity. + /// + /// The type of the related entity (e.g. "ApplicantProfile"). + /// The unique identifier of the related entity. + /// A list of for the matching entity. + Task> GetContactsByEntityAsync(string entityType, Guid entityId); + + /// + /// Creates a new contact and links it to the specified entity. + /// If is true, any existing primary + /// contact for the same entity type and ID will be cleared first. + /// + /// The contact and link details. + /// The created . + Task CreateContactAsync(CreateContactLinkDto input); + + /// + /// Sets the specified contact as the primary contact for the given entity. + /// Only one primary contact is allowed per entity type and entity ID; + /// any existing primary will be cleared before setting the new one. + /// + /// The type of the related entity. + /// The unique identifier of the related entity. + /// The unique identifier of the contact to set as primary. + /// Thrown when no active contact link is found for the given parameters. + Task SetPrimaryContactAsync(string entityType, Guid entityId, Guid contactId); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs index 06eefb139..52d3b5c62 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs @@ -19,6 +19,11 @@ public override void Define(IPermissionDefinitionContext context) grantManagerPermissionsGroup.AddPermission(GrantManagerPermissions.Intakes.Default, L("Permission:GrantManagerManagement.Intakes.Default")); grantManagerPermissionsGroup.AddPermission(GrantManagerPermissions.ApplicationForms.Default, L("Permission:GrantManagerManagement.ApplicationForms.Default")); + + var contactPermissions = grantManagerPermissionsGroup.AddPermission(GrantManagerPermissions.Contacts.Default, L("Permission:GrantManagerManagement.Contacts.Default")); + contactPermissions.AddChild(GrantManagerPermissions.Contacts.Create, L("Permission:GrantManagerManagement.Contacts.Create")); + contactPermissions.AddChild(GrantManagerPermissions.Contacts.Read, L("Permission:GrantManagerManagement.Contacts.Read")); + contactPermissions.AddChild(GrantManagerPermissions.Contacts.Update, L("Permission:GrantManagerManagement.Contacts.Update")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index 693a28994..f77a53000 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class AddressInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs similarity index 98% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs index 057b27d90..df8617813 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Unity.GrantManager.Applicants.ApplicantProfile; +using Unity.GrantManager.Applicants; using Unity.GrantManager.Applications; using Volo.Abp; using Volo.Abp.Application.Services; @@ -12,7 +12,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.TenantManagement; -namespace Unity.GrantManager.Applicants +namespace Unity.GrantManager.ApplicantProfile { [RemoteService(false)] public class ApplicantProfileAppService( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs new file mode 100644 index 000000000..476940ed3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Contacts; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Applicant-profile-specific contact service. Retrieves contacts linked to applicant profiles +/// and application-level contacts matched by OIDC subject. This service operates independently +/// from the generic and queries repositories directly. +/// +public class ApplicantProfileContactService( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + IRepository applicationFormSubmissionRepository, + IRepository applicationContactRepository) + : IApplicantProfileContactService, ITransientDependency +{ + private const string ApplicantProfileEntityType = "ApplicantProfile"; + + /// + public async Task> GetProfileContactsAsync(Guid profileId) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var contactsQuery = await contactRepository.GetQueryableAsync(); + + return await ( + from link in contactLinksQuery + join contact in contactsQuery on link.ContactId equals contact.Id + where link.RelatedEntityType == ApplicantProfileEntityType + && link.RelatedEntityId == profileId + && link.IsActive + select new ContactInfoItemDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + ContactType = link.RelatedEntityType, + Role = link.Role, + IsPrimary = link.IsPrimary, + IsEditable = true, + ApplicationId = null + }).ToListAsync(); + } + + /// + public async Task> GetApplicationContactsBySubjectAsync(string subject) + { + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); + + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicationContactsQuery = await applicationContactRepository.GetQueryableAsync(); + + var applicationContacts = await ( + from submission in submissionsQuery + join appContact in applicationContactsQuery on submission.ApplicationId equals appContact.ApplicationId + where submission.OidcSub == normalizedSubject + select new ContactInfoItemDto + { + ContactId = appContact.Id, + Name = appContact.ContactFullName, + Title = appContact.ContactTitle, + Email = appContact.ContactEmail, + MobilePhoneNumber = appContact.ContactMobilePhone, + WorkPhoneNumber = appContact.ContactWorkPhone, + ContactType = appContact.ContactType, + IsPrimary = false, + IsEditable = false, + ApplicationId = appContact.ApplicationId + }).ToListAsync(); + + return applicationContacts; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ApplicantProfileKeys.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileKeys.cs similarity index 85% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ApplicantProfileKeys.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileKeys.cs index 4b232c453..70bdfaaaa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ApplicantProfileKeys.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileKeys.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { public static class ApplicantProfileKeys { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs new file mode 100644 index 000000000..13bd414ee --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.ApplicantProfile +{ + /// + /// Provides contact information for the applicant profile by aggregating + /// profile-linked contacts and application-level contacts. + /// + [ExposeServices(typeof(IApplicantProfileDataProvider))] + public class ContactInfoDataProvider( + ICurrentTenant currentTenant, + IApplicantProfileContactService applicantProfileContactService) + : IApplicantProfileDataProvider, ITransientDependency + { + /// + public string Key => ApplicantProfileKeys.ContactInfo; + + /// + public async Task GetDataAsync(ApplicantProfileInfoRequest request) + { + var dto = new ApplicantContactInfoDto + { + Contacts = [] + }; + + var tenantId = request.TenantId; + + using (currentTenant.Change(tenantId)) + { + var profileContacts = await applicantProfileContactService.GetProfileContactsAsync(request.ProfileId); + dto.Contacts.AddRange(profileContacts); + + var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject); + dto.Contacts.AddRange(applicationContacts); + } + + return dto; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/OrgInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs index 6d7f3c7cc..2d1ced24c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/OrgInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class OrgInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/PaymentInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index 5684f158e..8e1ddde04 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class PaymentInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs similarity index 83% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index 7af7e641f..9bd91fcbd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Volo.Abp.DependencyInjection; -namespace Unity.GrantManager.Applicants.ApplicantProfile +namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] public class SubmissionInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ContactInfoDataProvider.cs deleted file mode 100644 index 71539ca65..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/ContactInfoDataProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ProfileData; -using Volo.Abp.DependencyInjection; - -namespace Unity.GrantManager.Applicants.ApplicantProfile -{ - [ExposeServices(typeof(IApplicantProfileDataProvider))] - public class ContactInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency - { - public string Key => ApplicantProfileKeys.ContactInfo; - - public Task GetDataAsync(ApplicantProfileInfoRequest request) - { - return Task.FromResult(new ApplicantContactInfoDto()); - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs index b93b81032..61d07b1ff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs @@ -2,6 +2,7 @@ using Quartz; using System; using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.Settings; using Unity.Modules.Shared.Utils; using Volo.Abp.BackgroundWorkers.Quartz; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs new file mode 100644 index 000000000..8a70946c5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Contacts; + +/// +/// Generic contact management service. Manages contacts and their links to arbitrary entity types. +/// Currently marked as [RemoteService(false)] not exposed as an HTTP endpoint. +/// Authorization roles to be configured before enabling remote access. +/// + +[Authorize] +[RemoteService(false)] +[ExposeServices(typeof(ContactAppService), typeof(IContactAppService))] +public class ContactAppService( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository) + : GrantManagerAppService, IContactAppService +{ + /// + public async Task> GetContactsByEntityAsync(string entityType, Guid entityId) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var contactsQuery = await contactRepository.GetQueryableAsync(); + + return await ( + from link in contactLinksQuery + join contact in contactsQuery on link.ContactId equals contact.Id + where link.RelatedEntityType == entityType + && link.RelatedEntityId == entityId + && link.IsActive + select new ContactDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + Role = link.Role, + IsPrimary = link.IsPrimary + }).ToListAsync(); + } + + /// + public async Task CreateContactAsync(CreateContactLinkDto input) + { + var contact = await contactRepository.InsertAsync(new Contact + { + Name = input.Name, + Title = input.Title, + Email = input.Email, + HomePhoneNumber = input.HomePhoneNumber, + MobilePhoneNumber = input.MobilePhoneNumber, + WorkPhoneNumber = input.WorkPhoneNumber, + WorkPhoneExtension = input.WorkPhoneExtension + }, autoSave: true); + + if (input.IsPrimary) + { + await ClearPrimaryAsync(input.RelatedEntityType, input.RelatedEntityId); + } + + await contactLinkRepository.InsertAsync(new ContactLink + { + ContactId = contact.Id, + RelatedEntityType = input.RelatedEntityType, + RelatedEntityId = input.RelatedEntityId, + Role = input.Role, + IsPrimary = input.IsPrimary, + IsActive = true + }, autoSave: true); + + return new ContactDto + { + ContactId = contact.Id, + Name = contact.Name, + Title = contact.Title, + Email = contact.Email, + HomePhoneNumber = contact.HomePhoneNumber, + MobilePhoneNumber = contact.MobilePhoneNumber, + WorkPhoneNumber = contact.WorkPhoneNumber, + WorkPhoneExtension = contact.WorkPhoneExtension, + Role = input.Role, + IsPrimary = input.IsPrimary + }; + } + + /// + public async Task SetPrimaryContactAsync(string entityType, Guid entityId, Guid contactId) + { + await ClearPrimaryAsync(entityType, entityId); + + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var link = await contactLinksQuery + .Where(l => l.RelatedEntityType == entityType + && l.RelatedEntityId == entityId + && l.ContactId == contactId + && l.IsActive) + .FirstOrDefaultAsync() ?? throw new BusinessException("Contacts:ContactLinkNotFound") + .WithData("contactId", contactId) + .WithData("entityType", entityType) + .WithData("entityId", entityId); + link.IsPrimary = true; + await contactLinkRepository.UpdateAsync(link, autoSave: true); + } + + /// + /// Clears the primary flag on all active contact links for the specified entity. + /// + private async Task ClearPrimaryAsync(string entityType, Guid entityId) + { + var contactLinksQuery = await contactLinkRepository.GetQueryableAsync(); + var currentPrimaryLinks = await contactLinksQuery + .Where(l => l.RelatedEntityType == entityType + && l.RelatedEntityId == entityId + && l.IsPrimary + && l.IsActive) + .ToListAsync(); + + foreach (var existing in currentPrimaryLinks) + { + existing.IsPrimary = false; + await contactLinkRepository.UpdateAsync(existing, autoSave: true); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs index 991948f97..169659b1d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantManagerPermissions.cs @@ -29,5 +29,17 @@ public static class Endpoints public const string Default = GroupName + ".Endpoints"; public const string ManageEndpoints = Default + ".ManageEndpoints"; } + + /// + /// Permission constants for the generic contacts service. + /// These are pre-wired for future HTTP endpoint exposure. + /// + public static class Contacts + { + public const string Default = GroupName + ".Contacts"; + public const string Create = Default + ".Create"; + public const string Read = Default + ".Read"; + public const string Update = Default + ".Update"; + } } #pragma warning restore S3218 // Inner class members should not shadow outer class "static" or type members \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs new file mode 100644 index 000000000..9319b7e25 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs @@ -0,0 +1,21 @@ +using System; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories +{ + [Dependency(ReplaceServices = true)] + [ExposeServices(typeof(IContactLinkRepository))] +#pragma warning disable CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. + // This pattern is an implementation ontop of ABP framework, will not change this + public class ContactLinkRepository : EfCoreRepository, IContactLinkRepository +#pragma warning restore CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. + { + public ContactLinkRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) + { + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs new file mode 100644 index 000000000..0d7222fc0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs @@ -0,0 +1,21 @@ +using System; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories +{ + [Dependency(ReplaceServices = true)] + [ExposeServices(typeof(IContactRepository))] +#pragma warning disable CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. + // This pattern is an implementation ontop of ABP framework, will not change this + public class ContactRepository : EfCoreRepository, IContactRepository +#pragma warning restore CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. + { + public ContactRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) + { + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs index bff4d0f6b..becf33e31 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -1,6 +1,8 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; -using Unity.GrantManager.Applicants; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Controllers.Authentication; using Volo.Abp.AspNetCore.Mvc; @@ -12,8 +14,20 @@ namespace Unity.GrantManager.Controllers public class ApplicantProfileController(IApplicantProfileAppService applicantProfileAppService) : AbpControllerBase { + /// + /// Retrieves applicant profile data based on the specified key. + /// The response data property is polymorphic and varies by key: + /// + /// CONTACTINFO — returns + /// ORGINFO — returns + /// ADDRESSINFO — returns + /// SUBMISSIONINFO — returns + /// PAYMENTINFO — returns + /// + /// [HttpGet] [Route("profile")] + [ProducesResponseType(typeof(ApplicantProfileDto), StatusCodes.Status200OK)] public async Task GetApplicantProfileAsync([FromQuery] ApplicantProfileInfoRequest applicantProfileRequest) { var profile = await applicantProfileAppService.GetApplicantProfileAsync(applicantProfileRequest); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 4a345330c..caf91fc16 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -460,6 +460,7 @@ private static void ConfigureSwaggerServices(IServiceCollection services) Type = SecuritySchemeType.ApiKey, Scheme = "ApiKeyScheme" }); + options.SchemaFilter(); } ); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs new file mode 100644 index 000000000..a4640af5d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs @@ -0,0 +1,50 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Collections.Generic; +using Unity.GrantManager.ApplicantProfile.ProfileData; + +namespace Unity.GrantManager.Swagger +{ + public class ApplicantProfileDataSchemaFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (context.Type != typeof(ApplicantProfileDataDto)) + return; + + var subTypes = new Dictionary + { + ["CONTACTINFO"] = typeof(ApplicantContactInfoDto), + ["ORGINFO"] = typeof(ApplicantOrgInfoDto), + ["ADDRESSINFO"] = typeof(ApplicantAddressInfoDto), + ["SUBMISSIONINFO"] = typeof(ApplicantSubmissionInfoDto), + ["PAYMENTINFO"] = typeof(ApplicantPaymentInfoDto) + }; + + var oneOfSchemas = new List(); + foreach (var (discriminatorValue, subType) in subTypes) + { + var subSchema = context.SchemaGenerator.GenerateSchema(subType, context.SchemaRepository); + oneOfSchemas.Add(subSchema); + } + + schema.OneOf = oneOfSchemas; + schema.Discriminator = new OpenApiDiscriminator + { + PropertyName = "dataType", + Mapping = new Dictionary() + }; + + foreach (var (discriminatorValue, subType) in subTypes) + { + var schemaId = context.SchemaRepository.Schemas.ContainsKey(subType.FullName!) + ? subType.FullName! + : subType.Name; + schema.Discriminator.Mapping[discriminatorValue] = $"#/components/schemas/{schemaId}"; + } + + schema.Description = "Polymorphic data payload. The shape depends on the 'dataType' discriminator (key parameter)."; + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs index 29cde4344..81f518089 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileAppServiceTests.cs @@ -1,8 +1,8 @@ using Shouldly; using System; using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ApplicantProfile; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; using Xunit; using Xunit.Abstractions; @@ -21,7 +21,7 @@ public ApplicantProfileAppServiceTests(ITestOutputHelper outputHelper) : base(ou { ProfileId = Guid.NewGuid(), Subject = "testuser@idir", - TenantId = Guid.NewGuid(), + TenantId = Guid.Empty, Key = key }; @@ -37,7 +37,7 @@ public async Task GetApplicantProfileAsync_WithValidKey_ShouldReturnData(string var request = CreateRequest(key); // Act - var result = await _service.GetApplicantProfileAsync(request); + var result = await WithUnitOfWorkAsync(() => _service.GetApplicantProfileAsync(request)); // Assert result.ShouldNotBeNull(); @@ -60,7 +60,7 @@ public async Task GetApplicantProfileAsync_WithValidKey_ShouldReturnCorrectDataT var request = CreateRequest(key); // Act - var result = await _service.GetApplicantProfileAsync(request); + var result = await WithUnitOfWorkAsync(() => _service.GetApplicantProfileAsync(request)); // Assert result.Data.ShouldNotBeNull(); @@ -89,7 +89,7 @@ public async Task GetApplicantProfileAsync_KeyLookupIsCaseInsensitive() var request = CreateRequest("contactinfo"); // Act - var result = await _service.GetApplicantProfileAsync(request); + var result = await WithUnitOfWorkAsync(() => _service.GetApplicantProfileAsync(request)); // Assert result.Data.ShouldNotBeNull(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index c9fa64a0f..74ccc3f9a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -1,9 +1,12 @@ +using NSubstitute; using Shouldly; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applicants.ApplicantProfile; -using Unity.GrantManager.Applicants.ProfileData; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Volo.Abp.MultiTenancy; using Xunit; namespace Unity.GrantManager.Applicants @@ -18,17 +21,29 @@ public class ApplicantProfileDataProviderTests Key = key }; + private static ContactInfoDataProvider CreateContactInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var applicantProfileContactService = Substitute.For(); + applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(Task.FromResult(new List())); + applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(Task.FromResult(new List())); + return new ContactInfoDataProvider(currentTenant, applicantProfileContactService); + } + [Fact] public void ContactInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new ContactInfoDataProvider(); + var provider = CreateContactInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.ContactInfo); } [Fact] public async Task ContactInfoDataProvider_GetDataAsync_ShouldReturnContactInfoDto() { - var provider = new ContactInfoDataProvider(); + var provider = CreateContactInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.ContactInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -103,7 +118,7 @@ public void AllProviders_ShouldHaveUniqueKeys() { IApplicantProfileDataProvider[] providers = [ - new ContactInfoDataProvider(), + CreateContactInfoDataProvider(), new OrgInfoDataProvider(), new AddressInfoDataProvider(), new SubmissionInfoDataProvider(), diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs new file mode 100644 index 000000000..e44be9fb4 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs @@ -0,0 +1,604 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.TestHelpers; +using Volo.Abp; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.Contacts +{ + public class ContactAppServiceTests + { + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly ContactAppService _service; + + public ContactAppServiceTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + + _service = new ContactAppService( + _contactRepository, + _contactLinkRepository); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + #region GetContactsByEntityAsync + + [Fact] + public async Task GetContactsByEntityAsync_WithMatchingLinks_ShouldReturnAllFields() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact + { + Name = "John Doe", + Title = "Manager", + Email = "john@example.com", + HomePhoneNumber = "111-1111", + MobilePhoneNumber = "222-2222", + WorkPhoneNumber = "333-3333", + WorkPhoneExtension = "101" + }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + Role = "Primary Contact", + IsPrimary = true, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(contactId); + contact.Name.ShouldBe("John Doe"); + contact.Title.ShouldBe("Manager"); + contact.Email.ShouldBe("john@example.com"); + contact.HomePhoneNumber.ShouldBe("111-1111"); + contact.MobilePhoneNumber.ShouldBe("222-2222"); + contact.WorkPhoneNumber.ShouldBe("333-3333"); + contact.WorkPhoneExtension.ShouldBe("101"); + contact.Role.ShouldBe("Primary Contact"); + contact.IsPrimary.ShouldBeTrue(); + } + + [Fact] + public async Task GetContactsByEntityAsync_WithMultipleContacts_ShouldReturnAll() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId1 = Guid.NewGuid(); + var contactId2 = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Contact One" }, contactId1), + WithId(new Contact { Name = "Contact Two" }, contactId2) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId1, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }, + new ContactLink + { + ContactId = contactId2, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.Count.ShouldBe(2); + result.ShouldContain(c => c.Name == "Contact One" && c.IsPrimary); + result.ShouldContain(c => c.Name == "Contact Two" && !c.IsPrimary); + } + + [Fact] + public async Task GetContactsByEntityAsync_ShouldExcludeInactiveLinks() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Inactive Contact" }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsActive = false + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetContactsByEntityAsync_ShouldExcludeDifferentEntityType() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Wrong Type" }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "OtherType", + RelatedEntityId = entityId, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetContactsByEntityAsync_ShouldExcludeDifferentEntityId() + { + // Arrange + var entityId = Guid.NewGuid(); + var otherEntityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact { Name = "Other Entity" }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = otherEntityId, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", entityId); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetContactsByEntityAsync_WithNoLinks_ShouldReturnEmpty() + { + // Arrange + _contactRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + _contactLinkRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetContactsByEntityAsync("TestEntity", Guid.NewGuid()); + + // Assert + result.ShouldBeEmpty(); + } + + #endregion + + #region CreateContactAsync + + [Fact] + public async Task CreateContactAsync_ShouldCreateContactAndLink() + { + // Arrange + var contactId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + + _contactRepository.InsertAsync(Arg.Any(), true, Arg.Any()) + .Returns(ci => + { + var c = ci.Arg(); + EntityHelper.TrySetId(c, () => contactId); + return c; + }); + + _contactLinkRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + + var input = new CreateContactLinkDto + { + Name = "New Contact", + Title = "Analyst", + Email = "new@example.com", + HomePhoneNumber = "111-1111", + MobilePhoneNumber = "222-2222", + WorkPhoneNumber = "333-3333", + WorkPhoneExtension = "101", + Role = "Reviewer", + IsPrimary = false, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId + }; + + // Act + var result = await _service.CreateContactAsync(input); + + // Assert + result.ContactId.ShouldBe(contactId); + result.Name.ShouldBe("New Contact"); + result.Title.ShouldBe("Analyst"); + result.Email.ShouldBe("new@example.com"); + result.HomePhoneNumber.ShouldBe("111-1111"); + result.MobilePhoneNumber.ShouldBe("222-2222"); + result.WorkPhoneNumber.ShouldBe("333-3333"); + result.WorkPhoneExtension.ShouldBe("101"); + result.Role.ShouldBe("Reviewer"); + result.IsPrimary.ShouldBeFalse(); + + await _contactRepository.Received(1).InsertAsync( + Arg.Is(c => + c.Name == "New Contact" + && c.Title == "Analyst" + && c.Email == "new@example.com" + && c.HomePhoneNumber == "111-1111" + && c.MobilePhoneNumber == "222-2222" + && c.WorkPhoneNumber == "333-3333" + && c.WorkPhoneExtension == "101"), + true, + Arg.Any()); + + await _contactLinkRepository.Received(1).InsertAsync( + Arg.Is(l => + l.ContactId == contactId + && l.RelatedEntityType == "TestEntity" + && l.RelatedEntityId == entityId + && l.Role == "Reviewer" + && !l.IsPrimary + && l.IsActive), + true, + Arg.Any()); + } + + [Fact] + public async Task CreateContactAsync_NonPrimary_ShouldNotClearExistingPrimary() + { + // Arrange + var contactId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + + _contactRepository.InsertAsync(Arg.Any(), true, Arg.Any()) + .Returns(ci => + { + var c = ci.Arg(); + EntityHelper.TrySetId(c, () => contactId); + return c; + }); + + var input = new CreateContactLinkDto + { + Name = "Non-Primary Contact", + IsPrimary = false, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId + }; + + // Act + await _service.CreateContactAsync(input); + + // Assert GetQueryableAsync should not be called (ClearPrimaryAsync not invoked) + await _contactLinkRepository.DidNotReceive().GetQueryableAsync(); + } + + [Fact] + public async Task CreateContactAsync_WhenPrimary_ShouldClearExistingPrimary() + { + // Arrange + var contactId = Guid.NewGuid(); + var entityId = Guid.NewGuid(); + var existingLinkId = Guid.NewGuid(); + + var existingLink = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(existingLink, () => existingLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + new[] { existingLink }.AsAsyncQueryable(), + Array.Empty().AsAsyncQueryable()); + + _contactRepository.InsertAsync(Arg.Any(), true, Arg.Any()) + .Returns(ci => + { + var c = ci.Arg(); + EntityHelper.TrySetId(c, () => contactId); + return c; + }); + + var input = new CreateContactLinkDto + { + Name = "Primary Contact", + IsPrimary = true, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId + }; + + // Act + var result = await _service.CreateContactAsync(input); + + // Assert + result.IsPrimary.ShouldBeTrue(); + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == existingLinkId && !l.IsPrimary), + true, + Arg.Any()); + } + + #endregion + + #region SetPrimaryContactAsync + + [Fact] + public async Task SetPrimaryContactAsync_ShouldClearExistingAndSetNew() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + var existingPrimaryLinkId = Guid.NewGuid(); + var targetLinkId = Guid.NewGuid(); + + var existingPrimaryLink = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(existingPrimaryLink, () => existingPrimaryLinkId); + + var targetLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + }; + EntityHelper.TrySetId(targetLink, () => targetLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + new[] { existingPrimaryLink }.AsAsyncQueryable(), + new[] { targetLink }.AsAsyncQueryable()); + + // Act + await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); + + // Assert + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == existingPrimaryLinkId && !l.IsPrimary), + true, + Arg.Any()); + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == targetLinkId && l.IsPrimary), + true, + Arg.Any()); + } + + [Fact] + public async Task SetPrimaryContactAsync_WithNoExistingPrimary_ShouldSetNew() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + var targetLinkId = Guid.NewGuid(); + + var targetLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + }; + EntityHelper.TrySetId(targetLink, () => targetLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + Array.Empty().AsAsyncQueryable(), + new[] { targetLink }.AsAsyncQueryable()); + + // Act + await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); + + // Assert only the target link should be updated (set to primary) + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == targetLinkId && l.IsPrimary), + true, + Arg.Any()); + } + + [Fact] + public async Task SetPrimaryContactAsync_WithMultipleExistingPrimaries_ShouldClearAll() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + var primaryLinkId1 = Guid.NewGuid(); + var primaryLinkId2 = Guid.NewGuid(); + var targetLinkId = Guid.NewGuid(); + + var primaryLink1 = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(primaryLink1, () => primaryLinkId1); + + var primaryLink2 = new ContactLink + { + ContactId = Guid.NewGuid(), + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = true, + IsActive = true + }; + EntityHelper.TrySetId(primaryLink2, () => primaryLinkId2); + + var targetLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = true + }; + EntityHelper.TrySetId(targetLink, () => targetLinkId); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + new[] { primaryLink1, primaryLink2 }.AsAsyncQueryable(), + new[] { targetLink }.AsAsyncQueryable()); + + // Act + await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); + + // Assert both existing primaries cleared + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == primaryLinkId1 && !l.IsPrimary), + true, + Arg.Any()); + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == primaryLinkId2 && !l.IsPrimary), + true, + Arg.Any()); + // Target set as primary + await _contactLinkRepository.Received(1).UpdateAsync( + Arg.Is(l => l.Id == targetLinkId && l.IsPrimary), + true, + Arg.Any()); + } + + [Fact] + public async Task SetPrimaryContactAsync_ShouldNotMatchInactiveLink() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var inactiveLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = "TestEntity", + RelatedEntityId = entityId, + IsPrimary = false, + IsActive = false + }; + + _contactLinkRepository.GetQueryableAsync() + .Returns( + Array.Empty().AsAsyncQueryable(), + new[] { inactiveLink }.AsAsyncQueryable()); + + // Act & Assert + await Should.ThrowAsync( + () => _service.SetPrimaryContactAsync("TestEntity", entityId, contactId)); + } + + [Fact] + public async Task SetPrimaryContactAsync_WhenContactLinkNotFound_ShouldThrow() + { + // Arrange + var entityId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + _contactLinkRepository.GetQueryableAsync() + .Returns( + Array.Empty().AsAsyncQueryable(), + Array.Empty().AsAsyncQueryable()); + + // Act & Assert + var ex = await Should.ThrowAsync( + () => _service.SetPrimaryContactAsync("TestEntity", entityId, contactId)); + ex.Code.ShouldBe("Contacts:ContactLinkNotFound"); + } + + #endregion + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs new file mode 100644 index 000000000..976ad574c --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs @@ -0,0 +1,180 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Contacts +{ + public class ContactInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IApplicantProfileContactService _applicantProfileContactService; + private readonly ContactInfoDataProvider _provider; + + public ContactInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _applicantProfileContactService = Substitute.For(); + _provider = new ContactInfoDataProvider(_currentTenant, _applicantProfileContactService); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.ContactInfo + }; + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + await _provider.GetDataAsync(request); + + // Assert + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldCallGetProfileContactsWithProfileId() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetProfileContactsAsync(request.ProfileId); + } + + [Fact] + public async Task GetDataAsync_ShouldCallGetApplicationContactsWithSubject() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetApplicationContactsBySubjectAsync(request.Subject); + } + + [Fact] + public async Task GetDataAsync_ShouldCombineBothContactSets() + { + // Arrange + var request = CreateRequest(); + var profileContacts = new List + { + new() { ContactId = Guid.NewGuid(), Name = "Profile Contact 1", IsEditable = true }, + new() { ContactId = Guid.NewGuid(), Name = "Profile Contact 2", IsEditable = true } + }; + var appContacts = new List + { + new() { ContactId = Guid.NewGuid(), Name = "App Contact 1", IsEditable = false } + }; + _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId).Returns(profileContacts); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject).Returns(appContacts); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts.Count.ShouldBe(3); + dto.Contacts.Count(c => c.IsEditable).ShouldBe(2); + dto.Contacts.Count(c => !c.IsEditable).ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_WithNoContacts_ShouldReturnEmptyList() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ProfileContactsShouldAppearBeforeApplicationContacts() + { + // Arrange + var request = CreateRequest(); + var profileContact = new ContactInfoItemDto + { + ContactId = Guid.NewGuid(), + Name = "Profile First", + IsEditable = true + }; + var appContact = new ContactInfoItemDto + { + ContactId = Guid.NewGuid(), + Name = "App Second", + IsEditable = false + }; + _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId) + .Returns(new List { profileContact }); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(request.Subject) + .Returns(new List { appContact }); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts[0].Name.ShouldBe("Profile First"); + dto.Contacts[1].Name.ShouldBe("App Second"); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + // Arrange + var request = CreateRequest(); + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + result.DataType.ShouldBe("CONTACTINFO"); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs new file mode 100644 index 000000000..8241c1a3b --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs @@ -0,0 +1,379 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Xunit; + +namespace Unity.GrantManager.Contacts +{ + public class ApplicantProfileContactServiceTests + { + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly IRepository _submissionRepository; + private readonly IRepository _applicationContactRepository; + private readonly ApplicantProfileContactService _service; + + public ApplicantProfileContactServiceTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + _submissionRepository = Substitute.For>(); + _applicationContactRepository = Substitute.For>(); + + _service = new ApplicantProfileContactService( + _contactRepository, + _contactLinkRepository, + _submissionRepository, + _applicationContactRepository); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + [Fact] + public async Task GetProfileContactsAsync_WithMatchingLinks_ShouldReturnContacts() + { + // Arrange + var profileId = Guid.NewGuid(); + var contactId = Guid.NewGuid(); + + var contacts = new[] + { + WithId(new Contact + { + Name = "John Doe", + Title = "Manager", + Email = "john@example.com", + HomePhoneNumber = "111-1111", + MobilePhoneNumber = "222-2222", + WorkPhoneNumber = "333-3333", + WorkPhoneExtension = "101" + }, contactId) + }.AsAsyncQueryable(); + + var contactLinks = new[] + { + new ContactLink + { + ContactId = contactId, + RelatedEntityType = "ApplicantProfile", + RelatedEntityId = profileId, + Role = "Primary Contact", + IsPrimary = true, + IsActive = true + } + }.AsAsyncQueryable(); + + _contactRepository.GetQueryableAsync().Returns(contacts); + _contactLinkRepository.GetQueryableAsync().Returns(contactLinks); + + // Act + var result = await _service.GetProfileContactsAsync(profileId); + + // Assert + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(contactId); + contact.Name.ShouldBe("John Doe"); + contact.Title.ShouldBe("Manager"); + contact.Email.ShouldBe("john@example.com"); + contact.HomePhoneNumber.ShouldBe("111-1111"); + contact.MobilePhoneNumber.ShouldBe("222-2222"); + contact.WorkPhoneNumber.ShouldBe("333-3333"); + contact.WorkPhoneExtension.ShouldBe("101"); + contact.Role.ShouldBe("Primary Contact"); + contact.IsPrimary.ShouldBeTrue(); + contact.IsEditable.ShouldBeTrue(); + contact.ApplicationId.ShouldBeNull(); + } + + [Fact] + public async Task GetProfileContactsAsync_WithNoLinks_ShouldReturnEmpty() + { + // Arrange + _contactRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + _contactLinkRepository.GetQueryableAsync().Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetProfileContactsAsync(Guid.NewGuid()); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithMatchingSubmission_ShouldReturnContacts() + { + // Arrange + var applicationId = Guid.NewGuid(); + var appContactId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Jane Smith", + ContactTitle = "Director", + ContactEmail = "jane@example.com", + ContactMobilePhone = "444-4444", + ContactWorkPhone = "555-5555", + ContactType = "Signing Authority" + }, appContactId) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + + // Assert + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(appContactId); + contact.Name.ShouldBe("Jane Smith"); + contact.Title.ShouldBe("Director"); + contact.Email.ShouldBe("jane@example.com"); + contact.MobilePhoneNumber.ShouldBe("444-4444"); + contact.WorkPhoneNumber.ShouldBe("555-5555"); + contact.ContactType.ShouldBe("Signing Authority"); + contact.IsPrimary.ShouldBeFalse(); + contact.IsEditable.ShouldBeFalse(); + contact.ApplicationId.ShouldBe(applicationId); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_ShouldMatchCaseInsensitively() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Case Test" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@IDIR"); + + // Assert + result.Count.ShouldBe(1); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_ShouldStripDomainFromSubject() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "MYUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Domain Strip Test" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("myuser@differentdomain"); + + // Assert + result.Count.ShouldBe(1); + result[0].Name.ShouldBe("Domain Strip Test"); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithSubjectWithoutAtSign_ShouldStillMatch() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "PLAINUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Plain User Contact" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("plainuser"); + + // Assert + result.Count.ShouldBe(1); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithNonMatchingSubject_ShouldReturnEmpty() + { + // Arrange + var applicationId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "OTHERUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = applicationId, + ContactFullName = "Should Not Match" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("differentuser@idir"); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithNoSubmissions_ShouldReturnEmpty() + { + // Arrange + _submissionRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationContactRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithMultipleSubmissions_ShouldReturnAllContacts() + { + // Arrange + var appId1 = Guid.NewGuid(); + var appId2 = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = appId1, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + }, + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = appId2, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var applicationContacts = new[] + { + WithId(new ApplicationContact + { + ApplicationId = appId1, + ContactFullName = "Contact App 1" + }, Guid.NewGuid()), + WithId(new ApplicationContact + { + ApplicationId = appId2, + ContactFullName = "Contact App 2" + }, Guid.NewGuid()) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("testuser@idir"); + + // Assert + result.Count.ShouldBe(2); + result.ShouldAllBe(c => !c.IsEditable); + result.ShouldAllBe(c => !c.IsPrimary); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs new file mode 100644 index 000000000..e0a27bbcb --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Query; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.GrantManager.TestHelpers +{ + internal class TestAsyncEnumerableQueryable : EnumerableQuery, IAsyncEnumerable, IQueryable + { + public TestAsyncEnumerableQueryable(IEnumerable enumerable) : base(enumerable) { } + public TestAsyncEnumerableQueryable(Expression expression) : base(expression) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + => new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); + } + + internal class TestAsyncEnumerator(IEnumerator inner) : IAsyncEnumerator + { + public T Current => inner.Current; + public ValueTask MoveNextAsync() => new(inner.MoveNext()); + public ValueTask DisposeAsync() + { + inner.Dispose(); + return default; + } + } + + internal class TestAsyncQueryProvider(IQueryProvider inner) : IQueryProvider, IAsyncQueryProvider + { + public IQueryable CreateQuery(Expression expression) + => new TestAsyncEnumerableQueryable(expression); + + public IQueryable CreateQuery(Expression expression) + => new TestAsyncEnumerableQueryable(expression); + + public object? Execute(Expression expression) + => inner.Execute(expression); + + public TResult Execute(Expression expression) + => inner.Execute(expression); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) + { + var resultType = typeof(TResult).GetGenericArguments()[0]; + var result = inner.Execute(expression); + return (TResult)typeof(Task).GetMethod(nameof(Task.FromResult))! + .MakeGenericMethod(resultType) + .Invoke(null, [result])!; + } + } + + internal static class TestQueryableExtensions + { + public static IQueryable AsAsyncQueryable(this IEnumerable source) + => new TestAsyncEnumerableQueryable(source); + } +} From c8cca0daecd2b0897e2ded982345c9a5c99f0727 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:44:28 -0800 Subject: [PATCH 10/25] AB#31284 - Allow copying of disabled input fields --- .../Unity.Theme.UX2/wwwroot/themes/ux2/layout.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css index 1de7a1885..e35ed9757 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css @@ -518,20 +518,28 @@ input.form-control-currency { input.form-control:disabled, textarea.form-control:disabled, .form-select:disabled, input.form-control-currency:disabled { color: var(--bc-colors-grey-text-200); - pointer-events: none; background-color: var(--bc-colors-grey-hover) !important; opacity: var(--bs-btn-disabled-opacity); background-blend-mode: difference; border: var(--bs-border-width) solid var(--bs-border-color); + + pointer-events: auto; + user-select: text; + -webkit-user-select: text; + cursor: text; } input.form-control-currency:disabled { color: var(--bc-colors-grey-text-200); - pointer-events: none; background-color: var(--bc-colors-grey-hover) !important; opacity: var(--bs-btn-disabled-opacity); background-blend-mode: difference; border: var(--bs-border-width) solid var(--bs-border-color); + + pointer-events: auto; + user-select: text; + -webkit-user-select: text; + cursor: text; } textarea.form-control:disabled { From e79c86ddcabc2d684fcf7e1797bf93998e09a4f4 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 17 Feb 2026 16:28:23 -0800 Subject: [PATCH 11/25] AB#31896 update role map on getcontacts profile --- .../ApplicantProfile/ApplicantProfileContactService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs index 476940ed3..a1de6bd35 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs @@ -77,7 +77,8 @@ join appContact in applicationContactsQuery on submission.ApplicationId equals a Email = appContact.ContactEmail, MobilePhoneNumber = appContact.ContactMobilePhone, WorkPhoneNumber = appContact.ContactWorkPhone, - ContactType = appContact.ContactType, + Role = appContact.ContactType, + ContactType = "Application", IsPrimary = false, IsEditable = false, ApplicationId = appContact.ApplicationId From 6bca7fe1d8b84452273411c9442f3daf48929790 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 17 Feb 2026 16:50:24 -0800 Subject: [PATCH 12/25] AB#31896 update the lookup for profile contacts --- .../ApplicantProfile/ApplicantProfileContactService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs index a1de6bd35..c9c5752cd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs @@ -1,11 +1,12 @@ +using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Applications; using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantApplications; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -77,7 +78,7 @@ join appContact in applicationContactsQuery on submission.ApplicationId equals a Email = appContact.ContactEmail, MobilePhoneNumber = appContact.ContactMobilePhone, WorkPhoneNumber = appContact.ContactWorkPhone, - Role = appContact.ContactType, + Role = ApplicationContactOptionList.ContactTypeList[appContact.ContactType], ContactType = "Application", IsPrimary = false, IsEditable = false, From 0a82052431fb707399a3ced6fa568031f86eab07 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:55:55 -0800 Subject: [PATCH 13/25] AB#31384 - Bugfix - Total Paid Amount - Update payment status checks to use status constant --- .../PaymentInfo/PaymentInfoViewComponent.cs | 3 ++- .../GrantApplications/GrantApplicationAppService.cs | 13 +++++++------ .../ApplicantSubmissionsViewComponent.cs | 5 +++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs index c3b195e30..063347227 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; +using Unity.Payments.Codes; using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; @@ -101,7 +102,7 @@ private static (decimal paidAmount, decimal pendingAmount) CalculatePaymentAmoun var paidAmount = requestsList .Where(e => !string.IsNullOrWhiteSpace(e.PaymentStatus) - && e.PaymentStatus.Trim().Equals("Fully Paid", StringComparison.OrdinalIgnoreCase)) + && e.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) .Sum(e => e.Amount); var pendingAmount = requestsList 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 66ddcf371..d590a5482 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -23,7 +23,7 @@ using Unity.GrantManager.Payments; using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; -using Unity.Payments.Enums; +using Unity.Payments.Codes; using Unity.Payments.PaymentRequests; using Volo.Abp; using Volo.Abp.Application.Dtos; @@ -52,7 +52,7 @@ public class GrantApplicationAppService( { public async Task> GetListAsync(GrantApplicationListInputDto input) { - // 1️⃣ Fetch applications with filters + paging in DB + // 1️ Fetch applications with filters + paging in DB var applications = await applicationRepository.WithFullDetailsAsync( input.SkipCount, input.MaxResultCount, @@ -72,13 +72,14 @@ public async Task> GetListAsync(GrantApplica paymentRequests = await paymentRequestService.GetListByApplicationIdsAsync(applicationIds); } - // 2️⃣ Pre-aggregate payment amounts for O(1) lookup + // 2️ Pre-aggregate payment amounts for O(1) lookup var paymentRequestsByApplication = paymentRequests - .Where(pr => pr.Status == PaymentRequestStatus.Submitted) + .Where(pr => !string.IsNullOrWhiteSpace(pr.PaymentStatus) + && pr.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) .GroupBy(pr => pr.CorrelationId) .ToDictionary(g => g.Key, g => g.Sum(pr => pr.Amount)); - // 3️⃣ Map applications to DTOs + // 3️ Map applications to DTOs var appDtos = applications.Select(app => { var appDto = ObjectMapper.Map(app); @@ -113,7 +114,7 @@ public async Task> GetListAsync(GrantApplica }).ToList(); - // 4️⃣ Get total count using same filters + // 4️ Get total count using same filters var totalCount = await applicationRepository.GetCountAsync( input.SubmittedFromDate, input.SubmittedToDate diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs index fe8ef8e38..355613cac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs @@ -6,7 +6,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Payments; -using Unity.Payments.Enums; +using Unity.Payments.Codes; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; @@ -62,7 +62,8 @@ public async Task InvokeAsync(Guid applicantId) { var paymentRequests = await _paymentRequestService.GetListByApplicationIdsAsync(applicationIds); paymentRequestsByApplication = paymentRequests - .Where(pr => pr.Status == PaymentRequestStatus.Submitted) + .Where(pr => !string.IsNullOrWhiteSpace(pr.PaymentStatus) + && pr.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) .GroupBy(pr => pr.CorrelationId) .ToDictionary(g => g.Key, g => g.Sum(pr => pr.Amount)); } From 88788297187ae813eab348422d4ba9a9477a33bc Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 18 Feb 2026 15:23:09 -0800 Subject: [PATCH 14/25] AB#31896 SonarQube cleanup --- .../Controllers/ApplicantProfileController.cs | 1 - .../Swagger/ApplicantProfileDataSchemaFilter.cs | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs index becf33e31..127a2861b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; using Unity.GrantManager.ApplicantProfile; -using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Controllers.Authentication; using Volo.Abp.AspNetCore.Mvc; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs index a4640af5d..46a7fedc9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Swagger/ApplicantProfileDataSchemaFilter.cs @@ -1,4 +1,3 @@ -using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; using System.Collections.Generic; @@ -23,7 +22,7 @@ public void Apply(OpenApiSchema schema, SchemaFilterContext context) }; var oneOfSchemas = new List(); - foreach (var (discriminatorValue, subType) in subTypes) + foreach (var (_, subType) in subTypes) { var subSchema = context.SchemaGenerator.GenerateSchema(subType, context.SchemaRepository); oneOfSchemas.Add(subSchema); From 51d7ee556e941eb45e81acd970de45e6771fc8c0 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 18 Feb 2026 18:30:57 -0800 Subject: [PATCH 15/25] AB#31413: Layout Adjustments on Applicant Profile - Applicant Info --- .../Components/ApplicantAddresses/Default.css | 1 - .../ApplicantOrganizationInfo/Default.cshtml | 33 ++++++++++--------- .../ApplicantOrganizationInfo/Default.css | 1 - 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css index 7ae7cc4f7..d6f31385e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.css @@ -24,7 +24,6 @@ } .applicant-organization-info { - background-color: #f8f9fa; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml index 0c7366d67..552b70232 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.cshtml @@ -40,26 +40,19 @@ - + - + - +
- -
-
- -
-
-
@@ -74,7 +67,7 @@ - + - - + @@ -128,7 +127,11 @@ - +
+
+ +
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css index a1c02309a..7f9d5e60a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantOrganizationInfo/Default.css @@ -31,7 +31,6 @@ } .applicant-organization-info { - background-color: #f8f9fa; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; From 8a7d12f92e86ce8720a833cb7b9042c694fe7912 Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 19 Feb 2026 10:06:22 -0800 Subject: [PATCH 16/25] Improve race condition handling in application links modal Enhances server-side validation by refreshing linked applications from the database before updates, preventing conflicts from stale client data. Improves client-side feedback and error handling, ensuring users are notified of conflicts or errors and the UI stays in sync with the latest data. --- .../ApplicationLinksModal.cshtml.cs | 169 +++++++++++++----- .../ApplicationLinksWidget/Default.js | 27 ++- 2 files changed, 150 insertions(+), 46 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs index 715b1af77..ece2fb7c7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs @@ -119,7 +119,33 @@ public async Task OnPostAsync() { List? selectedLinksWithTypes = JsonConvert.DeserializeObject>(LinksWithTypes); List? grantApplications = JsonConvert.DeserializeObject>(GrantApplicationsList!); - List? linkedApplications = JsonConvert.DeserializeObject>(LinkedApplicationsList!); + List? linkedApplications = JsonConvert.DeserializeObject>(LinkedApplicationsList!) ?? []; + + // Refresh from database instead of deserializing stale client data coming in to catch race conditions added. + var allLinks = await _applicationLinksService.GetListByApplicationAsync(CurrentApplicationId ?? Guid.Empty); + // Filter out the reverse links + var databaseLinkedApplications = allLinks.Where(item => item.ApplicationId != CurrentApplicationId).ToList(); + + // We only care if the data in the database is different to do the validation. + var listsAreEqual = new HashSet(linkedApplications, new ApplicationLinksInfoDtoComparer()).SetEquals(databaseLinkedApplications); + if (!listsAreEqual) + { + var linkValidationResult = await ValidateOnPostLinks( + selectedLinksWithTypes ?? [], + grantApplications ?? [], + databaseLinkedApplications); + + if (linkValidationResult.HasErrors) + { + return new JsonResult(new + { + success = false, + //Updates have occured while this window has been opened + message = string.Join(", ", linkValidationResult.ErrorMessages.Select(kvp => $"[{kvp.Key}]: {kvp.Value}")) + }); + } + } + if (selectedLinksWithTypes != null && grantApplications != null && linkedApplications != null) { @@ -127,49 +153,13 @@ public async Task OnPostAsync() foreach (var linkWithType in selectedLinksWithTypes) { var existingLink = linkedApplications.Find(app => app.ReferenceNumber == linkWithType.ReferenceNumber); - if (existingLink == null) { - // Add new link - var targetApplication = grantApplications.Find(app => app.ReferenceNo == linkWithType.ReferenceNumber); - if (targetApplication != null) - { - var linkedApplicationId = targetApplication.Id; - - // For CurrentApplication -> LinkedApplication - await _applicationLinksService.CreateAsync(new ApplicationLinksDto - { - ApplicationId = CurrentApplicationId ?? Guid.Empty, - LinkedApplicationId = linkedApplicationId, - LinkType = linkWithType.LinkType - }); - - // For LinkedApplication -> CurrentApplication (reverse link with appropriate type) - var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); - await _applicationLinksService.CreateAsync(new ApplicationLinksDto - { - ApplicationId = linkedApplicationId, - LinkedApplicationId = CurrentApplicationId ?? Guid.Empty, - LinkType = reverseLinkType - }); - } + await AddLink(linkWithType, grantApplications); } else { - // Check if the link type has changed - if (existingLink.LinkType != linkWithType.LinkType) - { - // Update the existing link's type - await _applicationLinksService.UpdateLinkTypeAsync(existingLink.Id, linkWithType.LinkType); - - // Also update the reverse link - var reverseLink = await _applicationLinksService.GetLinkedApplicationAsync(CurrentApplicationId ?? Guid.Empty, existingLink.ApplicationId); - var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); - await _applicationLinksService.UpdateLinkTypeAsync(reverseLink.Id, reverseLinkType); - - Logger.LogInformation("Updated link type for {ReferenceNumber} from {OldType} to {NewType}", - linkWithType.ReferenceNumber, existingLink.LinkType, linkWithType.LinkType); - } + await UpdateLink(linkWithType, existingLink); } } @@ -192,10 +182,109 @@ await _applicationLinksService.CreateAsync(new ApplicationLinksDto { Logger.LogError(ex, message: "Error updating application links"); } - return new JsonResult(new { success = true }); } + /// + /// Comparer to check for Application, LinkType and ProjectName when comparing data thats currently stored in the running + /// window versus what is stored in the database. Used to assist with race conditions prior to submitting from the modal. + /// + private sealed class ApplicationLinksInfoDtoComparer : IEqualityComparer + { + public bool Equals(ApplicationLinksInfoDto? x, ApplicationLinksInfoDto? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + return x.ApplicationId == y.ApplicationId && x.LinkType == y.LinkType && x.ProjectName == y.ProjectName; + } + + public int GetHashCode(ApplicationLinksInfoDto obj) => obj.ApplicationId.GetHashCode(); + } + + /// + /// If there is an inequality between what is in the application modal for links and the database, re-run the + /// validation checks to compare what is stored in the database rather than the local user window + /// + /// Link change the user is requesting + /// List of applications to retrieve their reference numbers for generating links + /// Existing links to compare against for validation + /// + /// List of ApplicationLinkValidationResult + private async Task ValidateOnPostLinks( + List newLinks, + List currentApplications, + List existingLinks) + { + var validateAllLinks = new List(); + + validateAllLinks.AddRange([.. newLinks.Select(link => + new ApplicationLinkValidationRequest + { + TargetApplicationId = currentApplications!.Single(app => app.ReferenceNo == link.ReferenceNumber).Id, + ReferenceNumber = link.ReferenceNumber, + LinkType = link.LinkType + })]); + + validateAllLinks.AddRange([.. existingLinks.Select(app => + new ApplicationLinkValidationRequest + { + TargetApplicationId = app.ApplicationId, + ReferenceNumber = app.ReferenceNumber, + LinkType = app.LinkType + } + )]); + + return await _applicationLinksService.ValidateApplicationLinksAsync(CurrentApplicationId ?? Guid.Empty, validateAllLinks); + } + + + private async Task AddLink(LinkWithType linkWithType, List grantApplications) + { + // Add new link + var targetApplication = grantApplications.Find(app => app.ReferenceNo == linkWithType.ReferenceNumber); + if (targetApplication != null) + { + var linkedApplicationId = targetApplication.Id; + + // For CurrentApplication -> LinkedApplication + await _applicationLinksService.CreateAsync(new ApplicationLinksDto + { + ApplicationId = CurrentApplicationId ?? Guid.Empty, + LinkedApplicationId = linkedApplicationId, + LinkType = linkWithType.LinkType + }); + + // For LinkedApplication -> CurrentApplication (reverse link with appropriate type) + var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); + await _applicationLinksService.CreateAsync(new ApplicationLinksDto + { + ApplicationId = linkedApplicationId, + LinkedApplicationId = CurrentApplicationId ?? Guid.Empty, + LinkType = reverseLinkType + }); + } + } + + + private async Task UpdateLink(LinkWithType linkWithType, ApplicationLinksInfoDto existingLink) + { + // Check if the link type has changed + if (existingLink.LinkType != linkWithType.LinkType) + { + // Update the existing link's type + await _applicationLinksService.UpdateLinkTypeAsync(existingLink.Id, linkWithType.LinkType); + + // Also update the reverse link + var reverseLink = await _applicationLinksService.GetLinkedApplicationAsync(CurrentApplicationId ?? Guid.Empty, existingLink.ApplicationId); + var reverseLinkType = GetReverseLinkType(linkWithType.LinkType); + await _applicationLinksService.UpdateLinkTypeAsync(reverseLink.Id, reverseLinkType); + + Logger.LogInformation("Updated link type for {ReferenceNumber} from {OldType} to {NewType}", + linkWithType.ReferenceNumber, existingLink.LinkType, linkWithType.LinkType); + } + } + + private static ApplicationLinkType GetReverseLinkType(ApplicationLinkType linkType) { return linkType switch diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js index 4e29d4aea..d183030c9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js @@ -136,6 +136,7 @@ $(function () { }) .catch(function (error) { abp.notify.error('Error deleting application link.'); + dataTable.ajax.reload(); }); } } @@ -163,6 +164,9 @@ $(function () { 'The application links have been successfully updated.', 'Application Links' ); + }); + + applicationLinksModal.onClose(function () { dataTable.ajax.reload(); }); @@ -921,17 +925,28 @@ $(function () { data: formData, processData: false, contentType: false, - success: () => { + success: (response) => { applicationLinksModal.close(); - abp.notify.success( - 'The application links have been successfully updated.', - 'Application Links' - ); + if (response.success) + { + abp.notify.success('The application links have been successfully updated.','Application Links'); + } + else + { // Display the error message from the server + abp.notify.error(response.message || 'Failed to update application links.','Application Links'); + } dataTable.ajax.reload(); }, error: (xhr, status, error) => { console.error('Error updating application links:', status, error); - abp.notify.error('Error updating application links: ' + error); + let errorMessage = 'Error updating application links.'; + + // Try to extract error message from response + if (xhr.responseJSON && xhr.responseJSON.message) { + errorMessage = xhr.responseJSON.message; + } + + abp.notify.error(errorMessage); } }); } From 9c2724db2ae93eb5e32684c0dec64378f171b43e Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 19 Feb 2026 10:27:12 -0800 Subject: [PATCH 17/25] Typo --- .../Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs index ece2fb7c7..ee360d4aa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs @@ -140,7 +140,7 @@ public async Task OnPostAsync() return new JsonResult(new { success = false, - //Updates have occured while this window has been opened + //Updates have occurred while this window has been opened message = string.Join(", ", linkValidationResult.ErrorMessages.Select(kvp => $"[{kvp.Key}]: {kvp.Value}")) }); } From 98933c4b9386b5d7c83eee5686012a13fbed1464 Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 19 Feb 2026 10:57:25 -0800 Subject: [PATCH 18/25] Making sonarqube happy --- .../Views/Shared/Components/ApplicationLinksWidget/Default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js index d183030c9..dcaeb4b1a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js @@ -942,7 +942,7 @@ $(function () { let errorMessage = 'Error updating application links.'; // Try to extract error message from response - if (xhr.responseJSON && xhr.responseJSON.message) { + if (xhr.responseJSON?.message) { errorMessage = xhr.responseJSON.message; } From 900a0d95d22c1ebc2832b94789bb58f24488ca81 Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 19 Feb 2026 11:20:50 -0800 Subject: [PATCH 19/25] Fixing a logic condition of assigning the refreshed database data post-verification --- .../Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs index ee360d4aa..4a4656c0e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml.cs @@ -142,8 +142,10 @@ public async Task OnPostAsync() success = false, //Updates have occurred while this window has been opened message = string.Join(", ", linkValidationResult.ErrorMessages.Select(kvp => $"[{kvp.Key}]: {kvp.Value}")) - }); + }); } + // Replace the links with what is currently in the database to ensure we are working with the most up to date data + linkedApplications = databaseLinkedApplications; } From 9332a667120c7cf23eef65c8fde759b407898512 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 19 Feb 2026 12:39:40 -0800 Subject: [PATCH 20/25] AB#31896 - fix unit tests --- .../Contacts/ContactInfoServiceTests.cs | 20 ++++++++++------ .../TestAsyncEnumerableQueryable.cs | 23 +++++++++++++++---- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs index 8241c1a3b..48fadd7b9 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs @@ -139,7 +139,7 @@ public async Task GetApplicationContactsBySubjectAsync_WithMatchingSubmission_Sh ContactEmail = "jane@example.com", ContactMobilePhone = "444-4444", ContactWorkPhone = "555-5555", - ContactType = "Signing Authority" + ContactType = "ADDITIONAL_SIGNING_AUTHORITY" }, appContactId) }.AsAsyncQueryable(); @@ -158,7 +158,8 @@ public async Task GetApplicationContactsBySubjectAsync_WithMatchingSubmission_Sh contact.Email.ShouldBe("jane@example.com"); contact.MobilePhoneNumber.ShouldBe("444-4444"); contact.WorkPhoneNumber.ShouldBe("555-5555"); - contact.ContactType.ShouldBe("Signing Authority"); + contact.ContactType.ShouldBe("Application"); + contact.Role.ShouldBe("Additional Signing Authority"); contact.IsPrimary.ShouldBeFalse(); contact.IsEditable.ShouldBeFalse(); contact.ApplicationId.ShouldBe(applicationId); @@ -186,7 +187,8 @@ public async Task GetApplicationContactsBySubjectAsync_ShouldMatchCaseInsensitiv WithId(new ApplicationContact { ApplicationId = applicationId, - ContactFullName = "Case Test" + ContactFullName = "Case Test", + ContactType = "ADDITIONAL_CONTACT" }, Guid.NewGuid()) }.AsAsyncQueryable(); @@ -222,7 +224,8 @@ public async Task GetApplicationContactsBySubjectAsync_ShouldStripDomainFromSubj WithId(new ApplicationContact { ApplicationId = applicationId, - ContactFullName = "Domain Strip Test" + ContactFullName = "Domain Strip Test", + ContactType = "ADDITIONAL_CONTACT" }, Guid.NewGuid()) }.AsAsyncQueryable(); @@ -259,7 +262,8 @@ public async Task GetApplicationContactsBySubjectAsync_WithSubjectWithoutAtSign_ WithId(new ApplicationContact { ApplicationId = applicationId, - ContactFullName = "Plain User Contact" + ContactFullName = "Plain User Contact", + ContactType = "ADDITIONAL_CONTACT" }, Guid.NewGuid()) }.AsAsyncQueryable(); @@ -355,12 +359,14 @@ public async Task GetApplicationContactsBySubjectAsync_WithMultipleSubmissions_S WithId(new ApplicationContact { ApplicationId = appId1, - ContactFullName = "Contact App 1" + ContactFullName = "Contact App 1", + ContactType = "ADDITIONAL_CONTACT" }, Guid.NewGuid()), WithId(new ApplicationContact { ApplicationId = appId2, - ContactFullName = "Contact App 2" + ContactFullName = "Contact App 2", + ContactType = "ADDITIONAL_SIGNING_AUTHORITY" }, Guid.NewGuid()) }.AsAsyncQueryable(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs index e0a27bbcb..26b6bcd97 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/TestHelpers/TestAsyncEnumerableQueryable.cs @@ -45,11 +45,24 @@ public TResult Execute(Expression expression) public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) { - var resultType = typeof(TResult).GetGenericArguments()[0]; - var result = inner.Execute(expression); - return (TResult)typeof(Task).GetMethod(nameof(Task.FromResult))! - .MakeGenericMethod(resultType) - .Invoke(null, [result])!; + // TResult is typically Task for async operations + var resultType = typeof(TResult); + + // Get the actual result synchronously + var syncResult = inner.Execute(expression); + + // If TResult is Task, extract T and wrap the result + if (resultType.IsGenericType && resultType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var taskResultType = resultType.GetGenericArguments()[0]; + var taskFromResult = typeof(Task) + .GetMethod(nameof(Task.FromResult))! + .MakeGenericMethod(taskResultType); + return (TResult)taskFromResult.Invoke(null, new[] { syncResult })!; + } + + // For non-generic Task or other types, just return as-is + return (TResult)(object)Task.CompletedTask; } } From 8fd71f042ad2d1037175b9ff3f77f145952f5756 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 19 Feb 2026 12:57:59 -0800 Subject: [PATCH 21/25] AB#31896 - codeQL suggestions and cleanup --- .../ProfileData/ApplicantContactInfoDto.cs | 3 +-- .../GrantManagerPermissionDefinitionProvider.cs | 5 ----- .../ApplicantProfile/ApplicantProfileContactService.cs | 8 +++++++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs index 1da48226c..f93d6dbf3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs @@ -6,8 +6,7 @@ namespace Unity.GrantManager.ApplicantProfile.ProfileData public class ApplicantContactInfoDto : ApplicantProfileDataDto { public override string DataType => "CONTACTINFO"; - - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Include)] + public List Contacts { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs index 52d3b5c62..06eefb139 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantManagerPermissionDefinitionProvider.cs @@ -19,11 +19,6 @@ public override void Define(IPermissionDefinitionContext context) grantManagerPermissionsGroup.AddPermission(GrantManagerPermissions.Intakes.Default, L("Permission:GrantManagerManagement.Intakes.Default")); grantManagerPermissionsGroup.AddPermission(GrantManagerPermissions.ApplicationForms.Default, L("Permission:GrantManagerManagement.ApplicationForms.Default")); - - var contactPermissions = grantManagerPermissionsGroup.AddPermission(GrantManagerPermissions.Contacts.Default, L("Permission:GrantManagerManagement.Contacts.Default")); - contactPermissions.AddChild(GrantManagerPermissions.Contacts.Create, L("Permission:GrantManagerManagement.Contacts.Create")); - contactPermissions.AddChild(GrantManagerPermissions.Contacts.Read, L("Permission:GrantManagerManagement.Contacts.Read")); - contactPermissions.AddChild(GrantManagerPermissions.Contacts.Update, L("Permission:GrantManagerManagement.Contacts.Update")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs index c9c5752cd..46ef6e66f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs @@ -78,7 +78,7 @@ join appContact in applicationContactsQuery on submission.ApplicationId equals a Email = appContact.ContactEmail, MobilePhoneNumber = appContact.ContactMobilePhone, WorkPhoneNumber = appContact.ContactWorkPhone, - Role = ApplicationContactOptionList.ContactTypeList[appContact.ContactType], + Role = GetMatchingRole(appContact.ContactType), ContactType = "Application", IsPrimary = false, IsEditable = false, @@ -87,4 +87,10 @@ join appContact in applicationContactsQuery on submission.ApplicationId equals a return applicationContacts; } + + private static string GetMatchingRole(string contactType) + { + return ApplicationContactOptionList.ContactTypeList.TryGetValue(contactType, out string? value) + ? value : contactType; + } } From d8a84fdeeddd7207c33e3e292437c0b8bda6af1d Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 19 Feb 2026 14:41:23 -0800 Subject: [PATCH 22/25] AB#31896 sonarQube cleanup --- .../ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs index f93d6dbf3..716f78928 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantContactInfoDto.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; using System.Collections.Generic; namespace Unity.GrantManager.ApplicantProfile.ProfileData From efa2706406c692cc939612a1be2685fb0caad88b Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 19 Feb 2026 15:21:43 -0800 Subject: [PATCH 23/25] AB#31896 remove unnecessary #pragma disables --- .../Repositories/ContactLinkRepository.cs | 7 +------ .../Repositories/ContactRepository.cs | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs index 9319b7e25..f8f7f5b95 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactLinkRepository.cs @@ -9,13 +9,8 @@ namespace Unity.GrantManager.Repositories { [Dependency(ReplaceServices = true)] [ExposeServices(typeof(IContactLinkRepository))] -#pragma warning disable CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. // This pattern is an implementation ontop of ABP framework, will not change this - public class ContactLinkRepository : EfCoreRepository, IContactLinkRepository -#pragma warning restore CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. + public class ContactLinkRepository(IDbContextProvider dbContextProvider) : EfCoreRepository(dbContextProvider), IContactLinkRepository { - public ContactLinkRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) - { - } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs index 0d7222fc0..e83324eae 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ContactRepository.cs @@ -9,13 +9,8 @@ namespace Unity.GrantManager.Repositories { [Dependency(ReplaceServices = true)] [ExposeServices(typeof(IContactRepository))] -#pragma warning disable CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. // This pattern is an implementation ontop of ABP framework, will not change this - public class ContactRepository : EfCoreRepository, IContactRepository -#pragma warning restore CS8613 // Nullability of reference types in return type doesn't match implicitly implemented member. + public class ContactRepository(IDbContextProvider dbContextProvider) : EfCoreRepository(dbContextProvider), IContactRepository { - public ContactRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) - { - } } } From 48bac3bd5a78665ecbd4d27f62ec12141cf9eb96 Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 19 Feb 2026 15:54:35 -0800 Subject: [PATCH 24/25] Added the quick date range select drop down next to search with predefined values. The previous "FromDate" and "ToDate" are hidden unless the quick date range selection is set to "Custom" Quick date range calculates the predefined values and sets "FromDate" and "ToDate" to utilize existing fuctions/logic Clearing all filters DOES NOT reset the quick date range filter (change in acceptance) Reviewing initial performance improvements --- .../Pages/GrantApplications/Index.js | 165 ++++++++++++++---- .../Components/ActionBar/Default.cshtml | 58 +++--- .../Shared/Components/ActionBar/Default.css | 6 + 3 files changed, 176 insertions(+), 53 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 3ebe15233..266a43543 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -7,6 +7,8 @@ const formatter = createNumberFormatter(); const l = abp.localization.getResource('GrantManager'); + const defaultQuickDateRange = 'last6months'; + let dt = $('#GrantApplicationsTable'); let dataTable; @@ -92,6 +94,22 @@ dt.search(''); dt.order(initialSortOrder).draw(); + // Reset date range filters + $('#quickDateRange').val(defaultQuickDateRange); + toggleCustomDateInputs(defaultQuickDateRange === 'custom'); + + const range = getDateRange(defaultQuickDateRange); + if (range) { + UIElements.submittedFromInput.val(range.fromDate); + UIElements.submittedToInput.val(range.toDate); + grantTableFilters.submittedFromDate = range.fromDate; + grantTableFilters.submittedToDate = range.toDate; + + localStorage.setItem('GrantApplications_FromDate', range.fromDate); + localStorage.setItem('GrantApplications_ToDate', range.toDate); + localStorage.setItem('GrantApplications_QuickRange', defaultQuickDateRange); + } + // Close the dropdown dt.buttons('.grp-savedStates') .container() @@ -164,50 +182,41 @@ const listColumns = getColumns(); } function initializeSubmittedFilterDates() { - const fromDate = localStorage.getItem('GrantApplications_FromDate'); const toDate = localStorage.getItem('GrantApplications_ToDate'); + const savedRange = localStorage.getItem('GrantApplications_QuickRange') || defaultQuickDateRange; - // Check if localStorage has values and use them + // Set the dropdown value + $('#quickDateRange').val(savedRange); + + // Show/hide custom date inputs based on saved selection + toggleCustomDateInputs(savedRange === 'custom'); + + // If we have saved dates, use them if (fromDate && toDate) { UIElements.submittedFromInput.val(fromDate); UIElements.submittedToInput.val(toDate); grantTableFilters.submittedFromDate = fromDate; grantTableFilters.submittedToDate = toDate; - return; + } else { + const range = getDateRange(defaultQuickDateRange); + if (range && range.fromDate && range.toDate) { + UIElements.submittedFromInput.val(range.fromDate); + UIElements.submittedToInput.val(range.toDate); + grantTableFilters.submittedFromDate = range.fromDate; + grantTableFilters.submittedToDate = range.toDate; + } } - let dtToday = new Date(); - let month = dtToday.getMonth() + 1; - let day = dtToday.getDate(); - let year = dtToday.getFullYear(); - if (month < 10) - month = '0' + month.toString(); - if (day < 10) - day = '0' + day.toString(); - let todayDate = year + '-' + month + '-' + day; - - let dtSixMonthsAgo = new Date(); - dtSixMonthsAgo.setMonth(dtSixMonthsAgo.getMonth() - 6); - let minMonth = dtSixMonthsAgo.getMonth() + 1; - let minDay = dtSixMonthsAgo.getDate(); - let minYear = dtSixMonthsAgo.getFullYear(); - if (minMonth < 10) - minMonth = '0' + minMonth.toString(); - if (minDay < 10) - minDay = '0' + minDay.toString(); - let suggestedMinDate = minYear + '-' + minMonth + '-' + minDay; - - UIElements.submittedToInput.attr({ 'max': todayDate }); - UIElements.submittedToInput.val(todayDate); - UIElements.submittedFromInput.attr({ 'max': todayDate }); - UIElements.submittedFromInput.val(suggestedMinDate); - grantTableFilters.submittedFromDate = suggestedMinDate; - grantTableFilters.submittedToDate = todayDate; + // Set max date to today for both inputs + const today = formatDate(new Date()); + UIElements.submittedToInput.attr({ 'max': today }); + UIElements.submittedFromInput.attr({ 'max': today }); } function bindUIEvents() { - UIElements.inputFilter.on('change', handleInputFilterChange); + UIElements.inputFilter.on('change', handleInputFilterChange); + $('#quickDateRange').on('change', handleQuickDateRangeChange); } function validateDate(dateValue, element) { @@ -243,6 +252,55 @@ const listColumns = getColumns(); return true; } + // Returns a formated { fromDate, toDate } for the filter fields. + // Null if 'custom' or no input provided (assumes custom is default break) + function getDateRange(rangeType) { + let today = new Date(); + const toDate = formatDate(new Date()); + let fromDate; + + switch (rangeType) { + case 'today': + fromDate = toDate; + break; + case 'last7days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 7))); + break; + case 'last30days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 30))); + break; + case 'last3months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 3))); + break; + case 'last6months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 6))); + break; + case 'alltime': + fromDate = null; + return { fromDate: null, toDate: null }; + case 'custom': + default: + return null; // Don't modify dates for custom + } + + return { fromDate, toDate }; + } + function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + function toggleCustomDateInputs(show) { + if (show) { + $('#customDateInputs').show(); + } else { + $('#customDateInputs').hide(); + } + } + + // ===================== // Input filter change handler // ===================== @@ -255,6 +313,11 @@ const listColumns = getColumns(); grantTableFilters.submittedFromDate = UIElements.submittedFromInput.val(); grantTableFilters.submittedToDate = UIElements.submittedToInput.val(); + //If the values for FromDate and ToDate are being set outside of the + //quick drop down handler, custom SHOULD be shown, but set just in case + $('#quickDateRange').val('custom'); + localStorage.setItem('GrantApplications_QuickRange', 'custom'); + const dtInstance = $('#GrantApplicationsTable').DataTable(); localStorage.setItem("GrantApplications_FromDate", grantTableFilters.submittedFromDate); @@ -262,6 +325,46 @@ const listColumns = getColumns(); dtInstance.ajax.reload(null, true); } + + function handleQuickDateRangeChange() { + const selectedRange = $(this).val(); + + localStorage.setItem('GrantApplications_QuickRange', selectedRange); + + if (selectedRange === 'custom') { + // Show the custom date inputs and don't modify their values + toggleCustomDateInputs(true); + return; + } + + // Hide custom date inputs for preset ranges + toggleCustomDateInputs(false); + + // Get the date range for the selected option + const range = getDateRange(selectedRange); + + if (range) { + // Populate the hidden date fields + UIElements.submittedFromInput.val(range.fromDate || ''); + UIElements.submittedToInput.val(range.toDate || ''); + grantTableFilters.submittedFromDate = range.fromDate; + grantTableFilters.submittedToDate = range.toDate; + + // Save to localStorage + if (range.fromDate && range.toDate) { + localStorage.setItem('GrantApplications_FromDate', range.fromDate); + localStorage.setItem('GrantApplications_ToDate', range.toDate); + } else { + // For "All time", clear the date filters + localStorage.removeItem('GrantApplications_FromDate'); + localStorage.removeItem('GrantApplications_ToDate'); + } + + // Reload the table with new filters + const dtInstance = $('#GrantApplicationsTable').DataTable(); + dtInstance.ajax.reload(null, true); + } + } function initializeDataTableAndEvents() { dataTable = initializeDataTable({ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml index eaf1c5bb6..bb425251d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml @@ -17,29 +17,43 @@
- - + + +
+ -
- - -
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css index 81685f369..cca218855 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css @@ -37,3 +37,9 @@ margin-bottom: -10px; padding-bottom: 0px !important; } + +.custom-date-range-container-div { + display: inline-block; + padding: 0px; + margin: 0px; +} From 3a9799a97283935d6878528079393f51bac32d64 Mon Sep 17 00:00:00 2001 From: David Bright Date: Thu, 19 Feb 2026 16:08:16 -0800 Subject: [PATCH 25/25] Fixed an option chaining flagged by Sonarqube --- .../src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 266a43543..050bfb23b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -200,7 +200,7 @@ const listColumns = getColumns(); grantTableFilters.submittedToDate = toDate; } else { const range = getDateRange(defaultQuickDateRange); - if (range && range.fromDate && range.toDate) { + if (range?.fromDate && range?.toDate) { UIElements.submittedFromInput.val(range.fromDate); UIElements.submittedToInput.val(range.toDate); grantTableFilters.submittedFromDate = range.fromDate;