From b2b5dde7167fca1af727e6339d3c55416b533140 Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Wed, 23 Sep 2026 11:07:19 -0700 Subject: [PATCH] VPR-62 feat(student): Add frontend Career Selection code --- .../__tests__/career-columns.test.ts | 109 +++++ .../__tests__/career-completeness.test.ts | 115 ++++++ .../__tests__/career-fields.test.ts | 107 +++++ .../career-option-form-dialog.test.ts | 119 ++++++ .../career-option-manager-component.test.ts | 186 +++++++++ .../__tests__/career-option-manager.test.ts | 198 +++++++++ .../career-record-components.test.ts | 156 ++++++++ .../__tests__/career-selection-form.test.ts | 77 ++++ .../__tests__/career-selection-guards.test.ts | 175 ++++++++ .../__tests__/career-selection-report.test.ts | 83 ++++ ...career-selection-select-with-other.test.ts | 138 +++++++ .../career-selection-service.test.ts | 284 +++++++++++++ .../__tests__/career-selection-table.test.ts | 101 +++++ .../__tests__/career-selection-view.test.ts | 146 +++++++ .../__tests__/completeness-icon.test.ts | 84 ++++ .../__tests__/use-career-selection.test.ts | 236 +++++++++++ .../components/CareerOptionFormDialog.vue | 104 +++++ .../components/CareerOptionManager.vue | 207 ++++++++++ .../components/CareerRecordLink.vue | 20 + .../components/CareerSelectionPageHeading.vue | 32 ++ .../components/CareerSelectionRowCard.vue | 71 ++++ .../CareerSelectionSelectWithOther.vue | 60 +++ .../components/CareerSelectionTable.vue | 136 +++++++ .../components/CompletenessIcon.vue | 55 +++ .../components/MentorSelector.vue | 32 ++ .../composables/use-career-option-manager.ts | 100 +++++ .../composables/use-career-selection.ts | 85 ++++ .../CareerSelection/constants/permissions.ts | 15 + .../CareerSelection/constants/record-page.ts | 11 + .../pages/CareerSelectionForm.vue | 376 ++++++++++++++++++ .../pages/CareerSelectionList.vue | 92 +++++ .../pages/CareerSelectionManageOptions.vue | 53 +++ .../pages/CareerSelectionReport.vue | 81 ++++ .../pages/CareerSelectionView.vue | 140 +++++++ .../router/career-selection-guards.ts | 113 ++++++ .../services/career-selection-service.ts | 131 ++++++ .../Students/CareerSelection/types/index.ts | 119 ++++++ .../CareerSelection/utils/career-columns.ts | 57 +++ .../utils/career-completeness.ts | 28 ++ .../CareerSelection/utils/career-fields.ts | 66 +++ .../components/AppAccessControls.vue | 113 ------ .../components/EmergencyContactPageShell.vue | 39 -- .../EmergencyContact/constants/record-page.ts | 11 + .../pages/EmergencyContactForm.vue | 36 +- .../pages/EmergencyContactList.vue | 105 ++--- .../pages/EmergencyContactReport.vue | 38 +- .../pages/EmergencyContactView.vue | 9 +- .../services/emergency-contact-service.ts | 56 +-- .../__tests__/app-access-controls.test.ts | 229 +++++++++++ .../__tests__/ensure-permissions.test.ts | 125 ++++++ .../student-record-components.test.ts | 161 ++++++++ .../__tests__/use-report-exports.test.ts | 116 ++++++ .../Students/components/AppAccessControls.vue | 188 +++++++++ .../src/Students/components/StudentEmail.vue | 16 + .../Students/components/StudentRecordLink.vue | 59 +++ .../components/StudentRecordPageShell.vue | 47 +++ .../composables/use-report-exports.ts | 45 +++ .../src/Students/router/ensure-permissions.ts | 41 ++ VueApp/src/Students/router/index.ts | 37 +- VueApp/src/Students/router/routes.ts | 53 +++ .../Students/services/student-app-service.ts | 77 ++++ VueApp/src/components/ColumnToggle.vue | 68 ++++ VueApp/src/components/ExportToolbar.vue | 164 ++++---- .../__tests__/column-toggle.test.ts | 71 ++++ .../__tests__/export-toolbar.test.ts | 81 ++++ .../__tests__/use-confirm-leave.test.ts | 83 ++++ .../use-scrollable-table-region.test.ts | 69 ++++ VueApp/src/composables/use-confirm-leave.ts | 26 ++ .../use-scrollable-table-region.ts | 32 ++ .../src/composables/use-select-aria-label.ts | 29 ++ VueApp/src/store/UserStore.ts | 17 +- 71 files changed, 6293 insertions(+), 446 deletions(-) create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-columns.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-completeness.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-fields.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-option-form-dialog.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-option-manager-component.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-option-manager.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-record-components.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-selection-form.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-selection-guards.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-selection-report.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-selection-select-with-other.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-selection-service.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-selection-table.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/career-selection-view.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/completeness-icon.test.ts create mode 100644 VueApp/src/Students/CareerSelection/__tests__/use-career-selection.test.ts create mode 100644 VueApp/src/Students/CareerSelection/components/CareerOptionFormDialog.vue create mode 100644 VueApp/src/Students/CareerSelection/components/CareerOptionManager.vue create mode 100644 VueApp/src/Students/CareerSelection/components/CareerRecordLink.vue create mode 100644 VueApp/src/Students/CareerSelection/components/CareerSelectionPageHeading.vue create mode 100644 VueApp/src/Students/CareerSelection/components/CareerSelectionRowCard.vue create mode 100644 VueApp/src/Students/CareerSelection/components/CareerSelectionSelectWithOther.vue create mode 100644 VueApp/src/Students/CareerSelection/components/CareerSelectionTable.vue create mode 100644 VueApp/src/Students/CareerSelection/components/CompletenessIcon.vue create mode 100644 VueApp/src/Students/CareerSelection/components/MentorSelector.vue create mode 100644 VueApp/src/Students/CareerSelection/composables/use-career-option-manager.ts create mode 100644 VueApp/src/Students/CareerSelection/composables/use-career-selection.ts create mode 100644 VueApp/src/Students/CareerSelection/constants/permissions.ts create mode 100644 VueApp/src/Students/CareerSelection/constants/record-page.ts create mode 100644 VueApp/src/Students/CareerSelection/pages/CareerSelectionForm.vue create mode 100644 VueApp/src/Students/CareerSelection/pages/CareerSelectionList.vue create mode 100644 VueApp/src/Students/CareerSelection/pages/CareerSelectionManageOptions.vue create mode 100644 VueApp/src/Students/CareerSelection/pages/CareerSelectionReport.vue create mode 100644 VueApp/src/Students/CareerSelection/pages/CareerSelectionView.vue create mode 100644 VueApp/src/Students/CareerSelection/router/career-selection-guards.ts create mode 100644 VueApp/src/Students/CareerSelection/services/career-selection-service.ts create mode 100644 VueApp/src/Students/CareerSelection/types/index.ts create mode 100644 VueApp/src/Students/CareerSelection/utils/career-columns.ts create mode 100644 VueApp/src/Students/CareerSelection/utils/career-completeness.ts create mode 100644 VueApp/src/Students/CareerSelection/utils/career-fields.ts delete mode 100644 VueApp/src/Students/EmergencyContact/components/AppAccessControls.vue delete mode 100644 VueApp/src/Students/EmergencyContact/components/EmergencyContactPageShell.vue create mode 100644 VueApp/src/Students/EmergencyContact/constants/record-page.ts create mode 100644 VueApp/src/Students/__tests__/app-access-controls.test.ts create mode 100644 VueApp/src/Students/__tests__/ensure-permissions.test.ts create mode 100644 VueApp/src/Students/__tests__/student-record-components.test.ts create mode 100644 VueApp/src/Students/__tests__/use-report-exports.test.ts create mode 100644 VueApp/src/Students/components/AppAccessControls.vue create mode 100644 VueApp/src/Students/components/StudentEmail.vue create mode 100644 VueApp/src/Students/components/StudentRecordLink.vue create mode 100644 VueApp/src/Students/components/StudentRecordPageShell.vue create mode 100644 VueApp/src/Students/composables/use-report-exports.ts create mode 100644 VueApp/src/Students/router/ensure-permissions.ts create mode 100644 VueApp/src/Students/services/student-app-service.ts create mode 100644 VueApp/src/components/ColumnToggle.vue create mode 100644 VueApp/src/components/__tests__/column-toggle.test.ts create mode 100644 VueApp/src/components/__tests__/export-toolbar.test.ts create mode 100644 VueApp/src/composables/__tests__/use-confirm-leave.test.ts create mode 100644 VueApp/src/composables/__tests__/use-scrollable-table-region.test.ts create mode 100644 VueApp/src/composables/use-confirm-leave.ts create mode 100644 VueApp/src/composables/use-scrollable-table-region.ts create mode 100644 VueApp/src/composables/use-select-aria-label.ts diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-columns.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-columns.test.ts new file mode 100644 index 000000000..35d2cd218 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-columns.test.ts @@ -0,0 +1,109 @@ +import { OVERVIEW_COLUMNS, REPORT_COLUMNS, previewStatement, STATEMENT_PREVIEW_LENGTH } from "../utils/career-columns" + +/** + * Tests for the grid columns the roster and report are built from, and the statement excerpt the + * report shows in place of a 5000-character plan. + */ + +function column(columns: typeof OVERVIEW_COLUMNS, name: string) { + return columns.find((c) => c.name === name) +} + +describe("statement excerpt", () => { + it("shows a short statement in full", () => { + expect.hasAssertions() + expect(previewStatement("Internship")).toBe("Internship") + }) + + it("trims the surrounding whitespace", () => { + expect.hasAssertions() + expect(previewStatement(" Internship \n")).toBe("Internship") + }) + + it("reads an absent statement as empty", () => { + expect.hasAssertions() + expect(previewStatement(null)).toBe("") + }) + + it("cuts a long statement to the preview length, ellipsis included", () => { + expect.hasAssertions() + const excerpt = previewStatement("x".repeat(500)) + + expect(excerpt).toHaveLength(STATEMENT_PREVIEW_LENGTH) + expect(excerpt.endsWith("…")).toBeTruthy() + }) + + it("does not cut a statement that just fits", () => { + expect.hasAssertions() + const exact = "x".repeat(STATEMENT_PREVIEW_LENGTH) + + expect(previewStatement(exact)).toBe(exact) + }) + + it("does not leave a dangling space before the ellipsis", () => { + expect.hasAssertions() + const statement = `${"x".repeat(STATEMENT_PREVIEW_LENGTH - 2)} more words` + + expect(previewStatement(statement)).toBe(`${"x".repeat(STATEMENT_PREVIEW_LENGTH - 2)}…`) + }) +}) + +describe("overview columns", () => { + it("opens with the student's identity", () => { + expect.hasAssertions() + expect(OVERVIEW_COLUMNS.slice(0, 3).map((c) => c.name)).toStrictEqual(["classLevel", "fullName", "email"]) + }) + + it("sorts a flagged field on its completeness, not its value", () => { + expect.hasAssertions() + expect(column(OVERVIEW_COLUMNS, "direction")?.field).toBe("directionCompleted") + }) + + it("sorts the mentor on its value, having no completeness flag", () => { + expect.hasAssertions() + expect(column(OVERVIEW_COLUMNS, "mentor")?.field).toBe("mentorName") + }) + + it("centres the completeness icons and leaves the mentor ranged left", () => { + expect.hasAssertions() + expect(column(OVERVIEW_COLUMNS, "direction")?.align).toBe("center") + expect(column(OVERVIEW_COLUMNS, "mentor")?.align).toBe("left") + }) + + it("ends with a formatted last-updated date", () => { + expect.hasAssertions() + const lastUpdated = OVERVIEW_COLUMNS.at(-1) + + expect(lastUpdated?.name).toBe("lastUpdated") + expect(lastUpdated?.format?.("2026-04-17T10:00:00", {})).toBe( + new Date("2026-04-17T10:00:00").toLocaleDateString(), + ) + expect(lastUpdated?.format?.(null, {})).toBe("") + }) +}) + +describe("report columns", () => { + it("reads each field's selected value", () => { + expect.hasAssertions() + expect(column(REPORT_COLUMNS, "direction")?.field).toBe("direction") + expect(column(REPORT_COLUMNS, "postGrad")?.field).toBe("postGrad") + }) + + it("leaves statements unformatted, so search reaches past the excerpt", () => { + expect.hasAssertions() + // QTable's search matches the formatted value; the excerpt is the report's cell slot. + expect(column(REPORT_COLUMNS, "shortTerm")?.format).toBeUndefined() + expect(column(REPORT_COLUMNS, "longTerm")?.format).toBeUndefined() + }) + + it("does not offer to sort on a statement", () => { + expect.hasAssertions() + expect(column(REPORT_COLUMNS, "shortTerm")?.sortable).toBeFalsy() + expect(column(REPORT_COLUMNS, "direction")?.sortable).toBeTruthy() + }) + + it("carries the same fields in the same order as the roster", () => { + expect.hasAssertions() + expect(REPORT_COLUMNS.map((c) => c.name)).toStrictEqual(OVERVIEW_COLUMNS.map((c) => c.name)) + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-completeness.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-completeness.test.ts new file mode 100644 index 000000000..f3c8f2124 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-completeness.test.ts @@ -0,0 +1,115 @@ +import { isSelectionComplete, missingFieldLabels } from "../utils/career-completeness" +import type { CareerDropdownOption, StudentInfo } from "../types" + +/** + * Tests for the form's missing-fields warning. isSelectionComplete mirrors IsSelectionComplete in + * CareerSelectionService.cs, which drives the roster icons and the exports, so the cases here are + * the ones the C# tests cover too. + */ + +function option(label: string, value: number, isOther = false): CareerDropdownOption { + return { label, value, isOther } +} + +function answered(overrides: Partial = {}): StudentInfo { + return { + direction: option("Academia", 1), + directionOther: "", + primaryFocus: option("Equine", 2), + primaryFocusOther: "", + secondaryFocus: option("Bovine", 3), + secondaryFocusOther: "", + postGrad: option("Residency", 4), + shortTermPlans: "Internship", + longTermPlans: "Practice ownership", + ...overrides, + } +} + +describe("is selection complete", () => { + it("counts an ordinary choice as answered", () => { + expect.hasAssertions() + expect(isSelectionComplete(option("Academia", 1), null)).toBeTruthy() + }) + + it("counts nothing selected as unanswered", () => { + expect.hasAssertions() + expect(isSelectionComplete(null, "Wildlife")).toBeFalsy() + }) + + it("wants the free text before the catch-all counts as answered", () => { + expect.hasAssertions() + const other = option("Other", 9, true) + expect(isSelectionComplete(other, null)).toBeFalsy() + expect(isSelectionComplete(other, "Wildlife rehabilitation")).toBeTruthy() + }) + + it("does not accept whitespace as the catch-all's free text", () => { + expect.hasAssertions() + expect(isSelectionComplete(option("Other", 9, true), " ")).toBeFalsy() + }) + + it("ignores free text for an ordinary choice", () => { + expect.hasAssertions() + expect(isSelectionComplete(option("Academia", 1), "")).toBeTruthy() + }) +}) + +describe("missing field labels", () => { + it("wants nothing when every field is answered", () => { + expect.hasAssertions() + expect(missingFieldLabels(answered())).toStrictEqual([]) + }) + + it("lists every field of an empty form, in page order", () => { + expect.hasAssertions() + const empty = answered({ + direction: null, + primaryFocus: null, + secondaryFocus: null, + postGrad: null, + shortTermPlans: "", + longTermPlans: "", + }) + + expect(missingFieldLabels(empty)).toStrictEqual([ + "Career Direction", + "Primary Focus", + "Secondary Focus", + "Post-Graduation Plans", + "Short Term Plans", + "Long Term Plans", + ]) + }) + + it("prompts for the secondary focus even though the roster treats it as optional", () => { + expect.hasAssertions() + expect(missingFieldLabels(answered({ secondaryFocus: null }))).toStrictEqual(["Secondary Focus"]) + }) + + it("reads the short term plans as the post-grad catch-all's explanation", () => { + expect.hasAssertions() + // Post-graduation plans have no free-text field of their own. + const other = option("Other", 9, true) + + expect(missingFieldLabels(answered({ postGrad: other, shortTermPlans: "Research fellowship" }))).toStrictEqual( + [], + ) + expect(missingFieldLabels(answered({ postGrad: other, shortTermPlans: "" }))).toStrictEqual([ + "Post-Graduation Plans", + "Short Term Plans", + ]) + }) + + it("treats a whitespace-only statement as unanswered", () => { + expect.hasAssertions() + expect(missingFieldLabels(answered({ longTermPlans: " " }))).toStrictEqual(["Long Term Plans"]) + }) + + it("wants the catch-all's free text before the field counts as answered", () => { + expect.hasAssertions() + const withOther = answered({ direction: option("Other", 9, true), directionOther: "" }) + + expect(missingFieldLabels(withOther)).toStrictEqual(["Career Direction"]) + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-fields.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-fields.test.ts new file mode 100644 index 000000000..86be36f2c --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-fields.test.ts @@ -0,0 +1,107 @@ +import { CAREER_FIELDS } from "../utils/career-fields" +import type { StudentCareerListItem, StudentCareerReport } from "../types" + +/** + * Tests for the career field metadata. The overview, report, mobile cards and exports all read + * their columns from this one list, so a wrong key here breaks several views at once. + */ + +function listItem(): StudentCareerListItem { + return { + personId: 100, + rowKey: "100", + hasDetailRoute: true, + fullName: "Student, Test", + classLevel: "V1", + email: "tstudent@ucdavis.edu", + directionCompleted: true, + primaryFocusCompleted: true, + secondaryFocusCompleted: false, + postGradCompleted: true, + shortTermPlansCompleted: true, + longTermPlansCompleted: false, + mentorName: "Vet, Ann", + lastUpdated: "2026-04-17T10:00:00", + } +} + +function report(): StudentCareerReport { + return { + personId: 100, + rowKey: "100", + hasDetailRoute: true, + fullName: "Student, Test", + classLevel: "V1", + email: "tstudent@ucdavis.edu", + direction: "Academia", + primaryFocus: "Equine", + secondaryFocus: "", + postGrad: "Residency", + shortTermPlans: "Internship", + longTermPlans: "", + mentorName: "Vet, Ann", + lastUpdated: "2026-04-17T10:00:00", + } +} + +describe("career fields", () => { + it("names each field once", () => { + expect.hasAssertions() + const names = CAREER_FIELDS.map((f) => f.name) + expect(new Set(names).size).toBe(names.length) + }) + + it("reads a value from the report for every field", () => { + expect.hasAssertions() + const row = report() + for (const field of CAREER_FIELDS) { + expect(row).toHaveProperty(field.valueField) + } + }) + + it("reads a completeness flag from the overview for every flagged field", () => { + expect.hasAssertions() + const row = listItem() + for (const field of CAREER_FIELDS.filter((f) => f.completedField)) { + expect(row).toHaveProperty(field.completedField!) + } + }) + + it("labels every completeness icon for screen readers", () => { + expect.hasAssertions() + for (const field of CAREER_FIELDS.filter((f) => f.completedField)) { + expect(field.tooltipLabel).toBeTruthy() + } + }) + + it("shows the mentor as plain text on both pages", () => { + expect.hasAssertions() + // The mentor is admin-managed, so it is reported rather than flagged as complete. + const mentor = CAREER_FIELDS.find((f) => f.name === "mentor") + expect(mentor?.completedField).toBeUndefined() + expect(mentor?.valueField).toBe("mentorName") + }) + + it("treats only the second species focus as optional", () => { + expect.hasAssertions() + expect(CAREER_FIELDS.filter((f) => f.optional).map((f) => f.name)).toStrictEqual(["secondarySpecies"]) + }) + + it("treats only the two plan statements as free text", () => { + expect.hasAssertions() + expect(CAREER_FIELDS.filter((f) => f.statement).map((f) => f.name)).toStrictEqual(["shortTerm", "longTerm"]) + }) + + it("keeps the column order the grids and exports share", () => { + expect.hasAssertions() + expect(CAREER_FIELDS.map((f) => f.label)).toStrictEqual([ + "Career", + "Species 1", + "Species 2", + "Post Grad", + "Mentor", + "Short Term", + "Long Term", + ]) + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-option-form-dialog.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-option-form-dialog.test.ts new file mode 100644 index 000000000..cff1ff906 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-option-form-dialog.test.ts @@ -0,0 +1,119 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import CareerOptionFormDialog from "../components/CareerOptionFormDialog.vue" +import { CAREER_OPTION_TYPES } from "../composables/use-career-option-manager" +import type { CareerOptionSaveResult, CareerSelectionOption } from "../types" + +/** + * Tests for the add/edit option dialog: the title it takes from the list it manages, the label + * rule it applies before saving, and what it does with a refused save. + */ + +function option(id: number, label: string, overrides: Partial = {}): CareerSelectionOption { + return { id, label, isOther: false, usageCount: 0, ...overrides } +} + +function mountDialog(editing: CareerSelectionOption | null, saveResult: CareerOptionSaveResult) { + const saveOption = vi.fn<(id: number | null, label: string) => Promise>() + saveOption.mockResolvedValue(saveResult) + const wrapper = mount(CareerOptionFormDialog, { + props: { + modelValue: true, + titleId: "career-options-species", + config: CAREER_OPTION_TYPES.species, + option: editing, + existingOptions: [option(1, "Equine"), option(9, "Other", { isOther: true })], + saveOption, + }, + global: { + plugins: [[Quasar, {}]], + // The dialog shell renders in a portal; its contents are what these tests are about. + stubs: { RecordFormDialog: { template: "
" } }, + }, + }) + return { wrapper, saveOption } +} + +/** Runs the dialog's submit handler, as its save button does. */ +async function submit(wrapper: ReturnType["wrapper"]): Promise { + await (wrapper.vm as unknown as { submit: () => Promise }).submit() + await flushPromises() +} + +describe("career option form dialog", () => { + it("titles itself for adding to the list it manages", () => { + expect.hasAssertions() + const { wrapper } = mountDialog(null, { success: true, errors: [] }) + + expect((wrapper.vm as unknown as { title: string }).title).toBe("Add Species Option") + }) + + it("titles itself for editing an existing option", () => { + expect.hasAssertions() + const { wrapper } = mountDialog(option(1, "Equine"), { success: true, errors: [] }) + + expect((wrapper.vm as unknown as { title: string }).title).toBe("Edit Species Option") + }) + + it("saves the trimmed label and reports it", async () => { + expect.hasAssertions() + const { wrapper, saveOption } = mountDialog(null, { success: true, errors: [] }) + ;(wrapper.vm as unknown as { form: { label: string } }).form.label = " Exotics " + + await submit(wrapper) + + expect(saveOption).toHaveBeenCalledWith(null, "Exotics") + expect(wrapper.emitted("saved")).toStrictEqual([["Exotics"]]) + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("saves an edit against the option's id", async () => { + expect.hasAssertions() + const { wrapper, saveOption } = mountDialog(option(1, "Equine"), { success: true, errors: [] }) + ;(wrapper.vm as unknown as { form: { label: string } }).form.label = "Equine and camelid" + + await submit(wrapper) + + expect(saveOption).toHaveBeenCalledWith(1, "Equine and camelid") + }) + + it("shows the server's refusal and stays open", async () => { + expect.hasAssertions() + const { wrapper } = mountDialog(null, { success: false, errors: ["That option already exists."] }) + ;(wrapper.vm as unknown as { form: { label: string } }).form.label = "Equine" + + await submit(wrapper) + + expect((wrapper.vm as unknown as { formError: string }).formError).toBe("That option already exists.") + expect(wrapper.emitted("update:modelValue")).toBeUndefined() + }) + + it("falls back to a general message when the server sends no reason", async () => { + expect.hasAssertions() + const { wrapper } = mountDialog(null, { success: false, errors: [] }) + ;(wrapper.vm as unknown as { form: { label: string } }).form.label = "Exotics" + + await submit(wrapper) + + expect((wrapper.vm as unknown as { formError: string }).formError).toBe( + "Unable to save the option. Please try again.", + ) + }) + + it("refuses a duplicate name before asking the server", () => { + expect.hasAssertions() + const { wrapper } = mountDialog(null, { success: true, errors: [] }) + const rule = (wrapper.vm as unknown as { labelRule: (v: string) => true | string }).labelRule + + expect(rule("equine")).toBeTypeOf("string") + expect(rule("Exotics")).toBeTruthy() + }) + + it("lets an option keep its own name while being edited", () => { + expect.hasAssertions() + const { wrapper } = mountDialog(option(1, "Equine"), { success: true, errors: [] }) + const rule = (wrapper.vm as unknown as { labelRule: (v: string) => true | string }).labelRule + + expect(rule("Equine")).toBeTruthy() + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-option-manager-component.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-option-manager-component.test.ts new file mode 100644 index 000000000..f21e78150 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-option-manager-component.test.ts @@ -0,0 +1,186 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import { ref } from "vue" +import CareerOptionManager from "../components/CareerOptionManager.vue" +import type { CareerOptionSaveResult, CareerSelectionOption } from "../types" + +/** + * Tests for the option manager: the admin table behind one dropdown's list. The composable it + * drives is tested separately, so it is stubbed here and only the component's own rules are + * exercised — which options may be edited or deleted, and what a refused delete reports. + */ + +const mockNotify = vi.fn<(...args: unknown[]) => unknown>() +/** Captures the confirm dialog's handler so a test can accept the prompt. */ +const dialogCallbacks: { onOk?: () => Promise | void } = {} + +vi.mock("quasar", async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + useQuasar: () => ({ + notify: (...args: unknown[]) => mockNotify(...args), + dialog: () => { + const chain = { + onOk(handler: () => Promise | void) { + dialogCallbacks.onOk = handler + return chain + }, + } + return chain + }, + }), + } +}) + +const managerState = { + options: ref([]), + loading: ref(false), + loadFailed: ref(false), + deletingId: ref(null), + load: vi.fn<() => Promise>(), + save: vi.fn<() => Promise>(), + remove: vi.fn<(id: number) => Promise>(), +} + +vi.mock("../composables/use-career-option-manager", async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, useCareerOptionManager: () => managerState } +}) + +function option(id: number, label: string, overrides: Partial = {}): CareerSelectionOption { + return { id, label, isOther: false, usageCount: 0, ...overrides } +} + +function mountManager(options: CareerSelectionOption[], loadFailed = false) { + vi.clearAllMocks() + delete dialogCallbacks.onOk + managerState.options.value = options + managerState.loadFailed.value = loadFailed + managerState.deletingId.value = null + managerState.remove.mockResolvedValue({ success: true, errors: [] }) + return mount(CareerOptionManager, { + props: { type: "species" }, + global: { + plugins: [[Quasar, {}]], + stubs: { + CareerOptionFormDialog: true, + StatusBanner: { template: "" }, + }, + }, + }) +} + +function buttonWithLabel(wrapper: ReturnType, label: string) { + return wrapper.findAll("button").find((b) => b.attributes("aria-label") === label) +} + +describe("career option manager", () => { + it("wraps a long option name rather than pushing the actions off a narrow screen", () => { + expect.hasAssertions() + const wrapper = mountManager([option(1, "A very long species focus name ".repeat(3))]) + + // jsdom does no layout, so this checks the wrapping rules rather than the fit itself. + expect(wrapper.find(".q-table__container").classes()).not.toContain("q-table--no-wrap") + const cells = wrapper.findAll("tbody td") + expect(cells[0].classes()).not.toContain("text-no-wrap") + // Breaks a single unbroken word too, which wrapping at spaces alone cannot. + expect(cells[0].classes()).toContain("option-label") + expect(cells[1].classes()).toContain("text-no-wrap") + expect(cells[2].classes()).toContain("text-no-wrap") + }) + + it("heads the section with the list it manages", () => { + expect.hasAssertions() + const wrapper = mountManager([option(1, "Equine")]) + + expect(wrapper.find("h2").text()).toBe("Species Focus") + expect(wrapper.text()).toContain("Choices shared by the Primary and Secondary Species Focus dropdowns.") + }) + + it("counts how many students chose each option", () => { + expect.hasAssertions() + const wrapper = mountManager([option(1, "Equine", { usageCount: 2 }), option(2, "Bovine", { usageCount: 1 })]) + + expect(wrapper.text()).toContain("2 students") + expect(wrapper.text()).toContain("1 student") + }) + + it("offers no edit or delete for the catch-all", () => { + expect.hasAssertions() + // "Other" is what tells the form to collect free text, so it cannot be renamed or removed. + const wrapper = mountManager([option(9, "Other", { isOther: true })]) + + expect(buttonWithLabel(wrapper, "Edit Other")).toBeUndefined() + }) + + it("explains why an option in use cannot be deleted", async () => { + expect.hasAssertions() + const wrapper = mountManager([option(1, "Equine", { usageCount: 2 })]) + + // aria-disabled, not disabled: a disabled button leaves the tab order and never opens + // its tooltip, so a keyboard user could not reach the explanation. + const deleteButton = buttonWithLabel(wrapper, "Cannot delete Equine: selected by 2 students") + expect(deleteButton).toBeDefined() + expect(deleteButton?.attributes("aria-disabled")).toBe("true") + expect(deleteButton?.attributes("disabled")).toBeUndefined() + + // Still reachable, so the handler is what has to refuse it: no dialog is opened, which + // shows as no onOk handler having been captured. + await deleteButton?.trigger("click") + expect(dialogCallbacks.onOk).toBeUndefined() + }) + + it("allows deleting an option nobody has chosen", () => { + expect.hasAssertions() + const wrapper = mountManager([option(1, "Equine")]) + + const deleteButton = buttonWithLabel(wrapper, "Delete Equine") + expect(deleteButton).toBeDefined() + expect(deleteButton?.attributes("aria-disabled")).toBeUndefined() + }) + + it("deletes on confirmation and says so", async () => { + expect.hasAssertions() + const wrapper = mountManager([option(1, "Equine")]) + + await buttonWithLabel(wrapper, "Delete Equine")?.trigger("click") + await dialogCallbacks.onOk?.() + await flushPromises() + + expect(managerState.remove).toHaveBeenCalledWith(1) + expect(mockNotify).toHaveBeenCalledWith({ type: "positive", message: 'Deleted "Equine".' }) + }) + + it("reports the server's reason when a delete is refused", async () => { + expect.hasAssertions() + const wrapper = mountManager([option(1, "Equine")]) + managerState.remove.mockResolvedValue({ success: false, errors: ["2 students have selected this option."] }) + + await buttonWithLabel(wrapper, "Delete Equine")?.trigger("click") + await dialogCallbacks.onOk?.() + await flushPromises() + + expect(mockNotify).toHaveBeenCalledWith({ + type: "negative", + message: "2 students have selected this option.", + }) + }) + + it("offers a retry when the options cannot be loaded", async () => { + expect.hasAssertions() + const wrapper = mountManager([], true) + + expect(wrapper.find(".banner").text()).toContain("Unable to load the Species Focus options.") + await wrapper.find(".banner button").trigger("click") + + expect(managerState.load).toHaveBeenCalledWith() + }) + + it("does not offer to add an option it could not load the list for", () => { + expect.hasAssertions() + const wrapper = mountManager([], true) + + expect(buttonWithLabel(wrapper, "Add Species option")?.attributes("disabled")).toBeDefined() + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-option-manager.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-option-manager.test.ts new file mode 100644 index 000000000..b24e7b70a --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-option-manager.test.ts @@ -0,0 +1,198 @@ +import { careerSelectionService } from "../services/career-selection-service" +import { useCareerOptionManager, validateOptionLabel } from "../composables/use-career-option-manager" +import type { CareerSelectionOption } from "../types" + +/** + * Tests for managing career selection dropdown options: the client-side label check, the + * service's option endpoints, and the list state the manage page is built on. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +const mockDel = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + del: (...args: unknown[]) => mockDel(...args), + }), + postForBlob: vi.fn<(...args: unknown[]) => unknown>(), + downloadBlob: vi.fn<(...args: unknown[]) => unknown>(), +})) + +function option(id: number, label: string, overrides: Partial = {}): CareerSelectionOption { + return { id, label, isOther: false, usageCount: 0, ...overrides } +} + +const EXISTING = [option(1, "Equine"), option(2, "Small Animal"), option(3, "Other", { isOther: true })] +const DUPLICATE = "An option with this name already exists." + +function validate(label: string, editingId: number | null = null, maxLength = 100): string | null { + return validateOptionLabel(label, { existing: EXISTING, editingId, maxLength }) +} + +describe("option label validation", () => { + it("rejects a blank name", () => { + expect.hasAssertions() + expect(validate(" ")).toBe("Please enter a name.") + }) + + it("rejects a name over the maximum length once trimmed", () => { + expect.hasAssertions() + expect(validate("a".repeat(101))).toBe("Name must be 100 characters or fewer.") + expect(validate(` ${"a".repeat(100)} `)).toBeNull() + }) + + it("rejects a duplicate that differs only in case or surrounding spaces", () => { + expect.hasAssertions() + expect(validate(" equine ")).toBe(DUPLICATE) + }) + + it("rejects a new option named after the catch-all", () => { + expect.hasAssertions() + expect(validate("other")).toBe(DUPLICATE) + }) + + it("allows an option to keep its own name or change only its case", () => { + expect.hasAssertions() + expect(validate("Equine", 1)).toBeNull() + expect(validate("EQUINE", 1)).toBeNull() + }) + + it("rejects renaming an option to another option's name", () => { + expect.hasAssertions() + expect(validate("Small Animal", 1)).toBe(DUPLICATE) + }) + + it("accepts a new unique name", () => { + expect.hasAssertions() + expect(validate("Exotics")).toBeNull() + }) +}) + +describe("career selection service option endpoints", () => { + it("maps each option type to its URL slug", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: [] }) + + await careerSelectionService.getOptions("career") + await careerSelectionService.getOptions("species") + await careerSelectionService.getOptions("postGrad") + + expect(mockGet).toHaveBeenNthCalledWith(1, expect.stringMatching(/\/career-selection\/options\/career$/u)) + expect(mockGet).toHaveBeenNthCalledWith(2, expect.stringMatching(/\/career-selection\/options\/species$/u)) + expect(mockGet).toHaveBeenNthCalledWith(3, expect.stringMatching(/\/career-selection\/options\/post-grad$/u)) + }) + + it("returns null when the options fail to load", async () => { + expect.hasAssertions() + mockGet.mockResolvedValue({ success: false, result: null, errors: ["Server error"] }) + await expect(careerSelectionService.getOptions("species")).resolves.toBeNull() + }) + + it("returns an empty list, not null, when there are no options", async () => { + expect.hasAssertions() + mockGet.mockResolvedValue({ success: true, result: [] }) + await expect(careerSelectionService.getOptions("species")).resolves.toStrictEqual([]) + }) + + it("maps options to the form's dropdown shape", async () => { + expect.hasAssertions() + mockGet.mockResolvedValue({ + success: true, + result: [option(1, "Equine"), option(3, "Other", { isOther: true })], + }) + + await expect(careerSelectionService.getDropdownOptions("species")).resolves.toStrictEqual([ + { label: "Equine", value: 1, isOther: false }, + { label: "Other", value: 3, isOther: true }, + ]) + }) + + it("gives the form an empty dropdown when the options fail to load", async () => { + expect.hasAssertions() + mockGet.mockResolvedValue({ success: false, result: null, errors: ["Forbidden"] }) + await expect(careerSelectionService.getDropdownOptions("career")).resolves.toStrictEqual([]) + }) + + it("posts a new option's label", async () => { + expect.hasAssertions() + mockPost.mockResolvedValue({ success: true, result: option(4, "Exotics"), errors: [] }) + + const result = await careerSelectionService.createOption("species", "Exotics") + + expect(mockPost).toHaveBeenCalledWith(expect.stringMatching(/\/options\/species$/u), { label: "Exotics" }) + expect(result).toStrictEqual({ success: true, errors: [] }) + }) + + it("puts a renamed option to its own URL", async () => { + expect.hasAssertions() + mockPut.mockResolvedValue({ success: true, result: option(1, "Horses"), errors: [] }) + + await careerSelectionService.updateOption("species", 1, "Horses") + + expect(mockPut).toHaveBeenCalledWith(expect.stringMatching(/\/options\/species\/1$/u), { label: "Horses" }) + }) + + it("passes a refused delete's errors through", async () => { + expect.hasAssertions() + mockDel.mockResolvedValue({ success: false, result: null, errors: ["Option is in use."] }) + + const result = await careerSelectionService.deleteOption("career", 7) + + expect(mockDel).toHaveBeenCalledWith(expect.stringMatching(/\/options\/career\/7$/u)) + expect(result).toStrictEqual({ success: false, errors: ["Option is in use."] }) + }) +}) + +describe("career option list state", () => { + it("flags a failed load so the page can offer a retry", async () => { + expect.hasAssertions() + mockGet.mockResolvedValue({ success: false, result: null, errors: ["Server error"] }) + const manager = useCareerOptionManager("career") + + await manager.load() + + expect(manager.loadFailed.value).toBeTruthy() + expect(manager.options.value).toStrictEqual([]) + }) + + it("trims the label and reloads after a successful save", async () => { + expect.hasAssertions() + mockPost.mockResolvedValue({ success: true, result: option(4, "Exotics"), errors: [] }) + mockGet.mockResolvedValue({ success: true, result: [...EXISTING, option(4, "Exotics")] }) + const manager = useCareerOptionManager("species") + + const result = await manager.save(null, " Exotics ") + + expect(result.success).toBeTruthy() + expect(mockPost).toHaveBeenLastCalledWith(expect.any(String), { label: "Exotics" }) + expect(manager.options.value).toHaveLength(4) + }) + + it("does not reload after a failed save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPut.mockResolvedValue({ success: false, result: null, errors: ["Duplicate"] }) + const manager = useCareerOptionManager("species") + + await manager.save(1, "Small Animal") + + expect(mockGet).not.toHaveBeenCalled() + }) + + it("reloads after a refused delete so the usage count explains the refusal", async () => { + expect.hasAssertions() + mockDel.mockResolvedValue({ success: false, result: null, errors: ["Option is in use."] }) + mockGet.mockResolvedValue({ success: true, result: [option(1, "Equine", { usageCount: 2 })] }) + const manager = useCareerOptionManager("species") + + await manager.remove(1) + + expect(manager.options.value[0]?.usageCount).toBe(2) + expect(manager.deletingId.value).toBeNull() + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-record-components.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-record-components.test.ts new file mode 100644 index 000000000..b18319f2a --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-record-components.test.ts @@ -0,0 +1,156 @@ +import { mount } from "@vue/test-utils" +import type { GlobalMountOptions } from "@vue/test-utils" +import { Quasar } from "quasar" +import CareerRecordLink from "../components/CareerRecordLink.vue" +import CareerSelectionRowCard from "../components/CareerSelectionRowCard.vue" +import MentorSelector from "../components/MentorSelector.vue" +import StudentRecordLink from "@/Students/components/StudentRecordLink.vue" +import { careerSelectionService } from "../services/career-selection-service" + +/** + * Tests for the career selection roster's building blocks: the name link, the narrow-screen card, + * and the admin-only mentor picker. + */ + +const quasar: GlobalMountOptions = { plugins: [[Quasar, {}]] } + +function student(overrides = {}) { + return { personId: 100, fullName: "Student, Test", hasDetailRoute: true, classLevel: "V1", ...overrides } +} + +describe("career record link", () => { + function mountLink(canEdit: boolean, emphasized = false) { + return mount(CareerRecordLink, { + props: { student: student(), canEdit, emphasized }, + global: { ...quasar, stubs: { StudentRecordLink: true } }, + }) + } + + it("routes an editor to the career selection edit page", () => { + expect.hasAssertions() + const link = mountLink(true).findComponent(StudentRecordLink) + + expect(link.props("editRoute")).toBe("CareerSelectionEdit") + expect(link.props("viewRoute")).toBe("CareerSelectionView") + expect(link.props("canEdit")).toBeTruthy() + }) + + it("passes the reader's own flag through", () => { + expect.hasAssertions() + expect(mountLink(false).findComponent(StudentRecordLink).props("canEdit")).toBeFalsy() + }) + + it("passes the card's emphasis through", () => { + expect.hasAssertions() + expect(mountLink(true, true).findComponent(StudentRecordLink).props("emphasized")).toBeTruthy() + }) +}) + +describe("career selection row card", () => { + function mountCard(overrides = {}, visibleColumns?: string[]) { + return mount(CareerSelectionRowCard, { + props: { student: student(overrides), canEdit: true, visibleColumns }, + slots: { default: "

Field lines

" }, + global: { + ...quasar, + stubs: { CareerRecordLink: { template: "{{ student.fullName }}", props: ["student"] } }, + }, + }) + } + + it("heads the card with the student and their class", () => { + expect.hasAssertions() + const wrapper = mountCard() + + expect(wrapper.text()).toContain("Student, Test") + expect(wrapper.text()).toContain("V1") + }) + + it("shows the field lines it is given", () => { + expect.hasAssertions() + expect(mountCard().text()).toContain("Field lines") + }) + + it("dates the card when the record has been saved", () => { + expect.hasAssertions() + const wrapper = mountCard({ lastUpdated: "2026-04-17T10:00:00" }) + + expect(wrapper.text()).toContain(`Updated ${new Date("2026-04-17T10:00:00").toLocaleDateString()}`) + }) + + it("says nothing about a record that has never been saved", () => { + expect.hasAssertions() + expect(mountCard({ lastUpdated: null }).text()).not.toContain("Updated") + }) + + it("links the student's email", () => { + expect.hasAssertions() + const link = mountCard({ email: "tstudent@ucdavis.edu" }).find("a[href='mailto:tstudent@ucdavis.edu']") + + expect(link.exists()).toBeTruthy() + expect(link.text()).toBe("tstudent@ucdavis.edu") + }) + + it("drops the name, class, email and date when their columns are hidden", () => { + expect.hasAssertions() + const wrapper = mountCard({ email: "tstudent@ucdavis.edu", lastUpdated: "2026-04-17T10:00:00" }, ["direction"]) + + expect(wrapper.text()).not.toContain("Student, Test") + expect(wrapper.text()).not.toContain("V1") + expect(wrapper.text()).not.toContain("tstudent@ucdavis.edu") + expect(wrapper.text()).not.toContain("Updated") + }) + + it("keeps the columns still shown alongside the hidden ones", () => { + expect.hasAssertions() + const wrapper = mountCard({ lastUpdated: "2026-04-17T10:00:00" }, ["fullName", "lastUpdated"]) + + expect(wrapper.text()).toContain("Student, Test") + expect(wrapper.text()).not.toContain("V1") + expect(wrapper.text()).toContain("Updated") + }) +}) + +describe("mentor selector", () => { + const mentor = { personId: 500, iamId: "IAM500", fullName: "Vet, Ann", loginId: "avet", mailId: "avet" } + + function mountSelector(modelValue: typeof mentor | null) { + return mount(MentorSelector, { + props: { modelValue, label: "Mentor" }, + global: { ...quasar, stubs: { PersonSearchSelect: true } }, + }) + } + + it("searches affiliates through the career selection service", () => { + expect.hasAssertions() + const picker = mountSelector(null).findComponent({ name: "PersonSearchSelect" }) + + expect(picker.props("search")).toBe(careerSelectionService.searchMentors) + expect(picker.props("label")).toBe("Mentor") + }) + + it("shows the saved mentor", () => { + expect.hasAssertions() + const picker = mountSelector(mentor).findComponent({ name: "PersonSearchSelect" }) + + expect(picker.props("modelValue")).toStrictEqual(mentor) + }) + + it("reports a chosen mentor to the form", async () => { + expect.hasAssertions() + const wrapper = mountSelector(null) + + await wrapper.findComponent({ name: "PersonSearchSelect" }).vm.$emit("update:modelValue", mentor) + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[mentor]]) + }) + + it("reports a cleared mentor as nothing selected", () => { + expect.hasAssertions() + const wrapper = mountSelector(mentor) + + wrapper.findComponent({ name: "PersonSearchSelect" }).vm.$emit("update:modelValue", null) + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[null]]) + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-selection-form.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-selection-form.test.ts new file mode 100644 index 000000000..5e116c022 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-selection-form.test.ts @@ -0,0 +1,77 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import CareerSelectionForm from "../pages/CareerSelectionForm.vue" +import { careerSelectionService } from "../services/career-selection-service" +import type { CareerDropdownOption, StudentCareerDetail } from "../types" + +/** + * Tests for the career selection form's loading. The record and the dropdown options load side by + * side, and the form must not open until all of them have, or a saved choice could be cleared and + * saved against an empty option list. + */ + +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: { pidm: "42" } }), + useRouter: () => ({ push: vi.fn<(to: unknown) => void>(), replace: vi.fn<(to: unknown) => void>() }), +})) +vi.mock("@/composables/CheckPagePermission", () => ({ checkHasOnePermission: () => false })) +vi.mock("@/composables/use-confirm-leave", () => ({ useConfirmLeave: () => {} })) + +const detail: StudentCareerDetail = { + personId: 42, + fullName: "Student, Test", + classLevel: "V2", + studentInfo: { + direction: null, + directionOther: "", + primaryFocus: null, + primaryFocusOther: "", + secondaryFocus: null, + secondaryFocusOther: "", + postGrad: null, + mentorId: null, + mentorName: "", + mentorIamId: null, + shortTermPlans: "", + longTermPlans: "", + }, + canEdit: true, + canViewStudentList: true, + lastUpdated: null, +} + +function mountForm() { + return mount(CareerSelectionForm, { + global: { + plugins: [[Quasar, {}]], + stubs: { + StudentRecordPageShell: { name: "StudentRecordPageShell", template: "
", props: ["loading"] }, + }, + }, + }) +} + +describe("career selection form", () => { + it("stays loading until the dropdown options arrive, not just the record", async () => { + expect.hasAssertions() + let resolveOptions: (options: CareerDropdownOption[]) => void = () => {} + const pendingOptions = new Promise((resolve) => { + resolveOptions = resolve + }) + vi.spyOn(careerSelectionService, "getDetail").mockResolvedValue(detail) + vi.spyOn(careerSelectionService, "getDropdownOptions").mockImplementation((kind) => + kind === "postGrad" ? pendingOptions : Promise.resolve([]), + ) + + const wrapper = mountForm() + await flushPromises() + const shell = wrapper.findComponent({ name: "StudentRecordPageShell" }) + + expect(shell.props("loading")).toBeTruthy() + + resolveOptions([]) + await flushPromises() + + expect(shell.props("loading")).toBeFalsy() + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-selection-guards.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-selection-guards.test.ts new file mode 100644 index 000000000..305aac4dc --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-selection-guards.test.ts @@ -0,0 +1,175 @@ +import { setActivePinia, createPinia } from "pinia" +import { useUserStore } from "@/store/UserStore" +import { + requireCareerViewAccess, + requireCareerEditAccess, + requireCareerListAccess, +} from "../router/career-selection-guards" + +/** + * Tests for the Career Selection route guards. + * Faculty (SVMSecure.CareerSelection.Faculty) is deliberately absent: whether a given + * student is one of a faculty member's mentees is not answerable from the permission + * array, so it is enforced by the API rather than here. + */ + +const ADMIN = "SVMSecure.CareerSelection.Admin" +const READ_ONLY = "SVMSecure.CareerSelection.ReadOnly" +const STUDENT = "SVMSecure.CareerSelection.Student" +const VIEW_OWN = "SVMSecure.CareerSelection.ViewOwn" + +const OWN_ID = 100 +const OTHER_ID = 200 +const HOME = { name: "StudentsHome" } + +// A guard returns true to admit, or a route location to redirect. Both are truthy, so +// compare against true rather than asserting truthiness on the raw result. +function admits(result: unknown): boolean { + return result === true +} + +function ownView(pidm: number = OWN_ID) { + return { name: "CareerSelectionView", params: { pidm } } +} + +function setUser(permissions: string[], userId: number | null = OWN_ID): void { + setActivePinia(createPinia()) + const userStore = useUserStore() + userStore.userInfo.userId = userId + userStore.setPermissions(permissions) +} + +describe("career selection route guards", () => { + describe("view access", () => { + it("allows an admin to view any record", () => { + expect.hasAssertions() + setUser([ADMIN]) + expect(admits(requireCareerViewAccess(OTHER_ID))).toBeTruthy() + }) + + it("allows a read-only user to view any record", () => { + expect.hasAssertions() + setUser([READ_ONLY]) + expect(admits(requireCareerViewAccess(OTHER_ID))).toBeTruthy() + }) + + it("allows a student to view their own record while the app is open", () => { + expect.hasAssertions() + setUser([VIEW_OWN, STUDENT]) + expect(admits(requireCareerViewAccess(OWN_ID))).toBeTruthy() + }) + + it("allows a student to view their own record while the app is closed", () => { + expect.hasAssertions() + // Closing the app strips Student from the role but leaves ViewOwn in place. + setUser([VIEW_OWN]) + expect(admits(requireCareerViewAccess(OWN_ID))).toBeTruthy() + }) + + it("allows a student holding only an individual grant to view their own record", () => { + expect.hasAssertions() + // An individual grant surfaces as Student without the ViewOwn role membership. + setUser([STUDENT]) + expect(admits(requireCareerViewAccess(OWN_ID))).toBeTruthy() + }) + + it("redirects a student away from another student's record", () => { + expect.hasAssertions() + setUser([VIEW_OWN, STUDENT]) + expect(requireCareerViewAccess(OTHER_ID)).toStrictEqual(ownView()) + }) + + it("sends a user with no career selection access home", () => { + expect.hasAssertions() + setUser(["SVMSecure.Students"]) + expect(requireCareerViewAccess(OWN_ID)).toStrictEqual(HOME) + }) + + it("sends a user with no resolved id home", () => { + expect.hasAssertions() + setUser([VIEW_OWN], null) + expect(requireCareerViewAccess(OWN_ID)).toStrictEqual(HOME) + }) + }) + + describe("edit access", () => { + it("allows an admin to edit any record", () => { + expect.hasAssertions() + setUser([ADMIN]) + expect(admits(requireCareerEditAccess(OTHER_ID))).toBeTruthy() + }) + + // canEditOwnRecord no longer lists ADMIN, so the early return in requireCareerEditAccess + // is the only thing admitting an admin to their own record. + it("allows an admin to edit their own record", () => { + expect.hasAssertions() + setUser([ADMIN]) + expect(admits(requireCareerEditAccess(OWN_ID))).toBeTruthy() + }) + + it("allows a student to edit their own record while the app is open", () => { + expect.hasAssertions() + setUser([VIEW_OWN, STUDENT]) + expect(admits(requireCareerEditAccess(OWN_ID))).toBeTruthy() + }) + + it("redirects a student to their own view while the app is closed", () => { + expect.hasAssertions() + setUser([VIEW_OWN]) + expect(requireCareerEditAccess(OWN_ID)).toStrictEqual(ownView()) + }) + + it("redirects a student away from another student's edit page", () => { + expect.hasAssertions() + setUser([VIEW_OWN, STUDENT]) + expect(requireCareerEditAccess(OTHER_ID)).toStrictEqual(ownView()) + }) + + it("redirects a read-only user to the record they asked for, not their own", () => { + expect.hasAssertions() + setUser([READ_ONLY]) + expect(requireCareerEditAccess(OTHER_ID)).toStrictEqual(ownView(OTHER_ID)) + }) + + it("does not let a read-only user edit", () => { + expect.hasAssertions() + setUser([READ_ONLY]) + expect(admits(requireCareerEditAccess(OTHER_ID))).toBeFalsy() + }) + + it("sends a user with no career selection access home", () => { + expect.hasAssertions() + setUser(["SVMSecure.Students"]) + expect(requireCareerEditAccess(OWN_ID)).toStrictEqual(HOME) + }) + }) + + describe("list access", () => { + it("allows an admin onto the list", () => { + expect.hasAssertions() + setUser([ADMIN]) + expect(admits(requireCareerListAccess())).toBeTruthy() + }) + + it("allows a read-only user onto the list", () => { + expect.hasAssertions() + setUser([READ_ONLY]) + expect(admits(requireCareerListAccess())).toBeTruthy() + }) + + it("redirects a student to their own record", () => { + expect.hasAssertions() + setUser([VIEW_OWN, STUDENT]) + expect(requireCareerListAccess()).toStrictEqual({ + name: "CareerSelectionEdit", + params: { pidm: OWN_ID }, + }) + }) + + it("sends a user with no career selection access home", () => { + expect.hasAssertions() + setUser(["SVMSecure.Students"]) + expect(requireCareerListAccess()).toStrictEqual(HOME) + }) + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-selection-report.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-selection-report.test.ts new file mode 100644 index 000000000..4fab6c185 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-selection-report.test.ts @@ -0,0 +1,83 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import CareerSelectionReport from "../pages/CareerSelectionReport.vue" +import { careerSelectionService } from "../services/career-selection-service" +import { STATEMENT_PREVIEW_LENGTH } from "../utils/career-columns" +import type { StudentCareerReport } from "../types" + +/** + * Tests for the career selection report's statement columns: each shows an excerpt, yet the + * table search still reaches the whole statement. + */ + +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: {}, query: {} }), + useRouter: () => ({ push: vi.fn<(to: unknown) => void>() }), +})) +vi.mock("@/composables/CheckPagePermission", () => ({ checkHasOnePermission: () => false })) +vi.mock("@/composables/use-scrollable-table-region", () => ({ useScrollableTableRegion: () => {} })) + +// Long enough that the search term sits well past the excerpt. +const LONG_PLAN = `${"Start in a mixed practice and build up surgical skills. ".repeat(3)}Then pursue aquaculture.` + +function reportRow(overrides: Partial = {}): StudentCareerReport { + return { + personId: 1, + rowKey: "1", + hasDetailRoute: true, + fullName: "Student, Test", + classLevel: "V2", + email: "tstudent@ucdavis.edu", + direction: "Private Practice", + primaryFocus: "Equine", + secondaryFocus: "", + postGrad: "Internship", + shortTermPlans: "", + longTermPlans: LONG_PLAN, + mentorName: "", + lastUpdated: null, + ...overrides, + } +} + +async function mountReport(rows: StudentCareerReport[]) { + vi.spyOn(careerSelectionService, "getReport").mockResolvedValue(rows) + const wrapper = mount(CareerSelectionReport, { + global: { + plugins: [[Quasar, {}]], + stubs: { + ExportToolbar: { name: "ExportToolbar", template: "
", props: ["filter"] }, + ColumnToggle: true, + CareerRecordLink: { template: "{{ student.fullName }}", props: ["student"] }, + RouterLink: true, + }, + }, + }) + await flushPromises() + return wrapper +} + +describe("career selection report", () => { + it("shows a long statement as its excerpt", async () => { + expect.hasAssertions() + const wrapper = await mountReport([reportRow()]) + + expect(LONG_PLAN.length).toBeGreaterThan(STATEMENT_PREVIEW_LENGTH) + expect(wrapper.text()).toContain(LONG_PLAN.slice(0, 20)) + expect(wrapper.text()).not.toContain("aquaculture") + }) + + it("finds a student by a word past the excerpt", async () => { + expect.hasAssertions() + const wrapper = await mountReport([ + reportRow(), + reportRow({ personId: 2, rowKey: "2", fullName: "Other, Student", longTermPlans: "Small animal" }), + ]) + + await wrapper.findComponent({ name: "ExportToolbar" }).vm.$emit("update:filter", "aquaculture") + await flushPromises() + + expect(wrapper.text()).toContain("Student, Test") + expect(wrapper.text()).not.toContain("Other, Student") + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-selection-select-with-other.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-selection-select-with-other.test.ts new file mode 100644 index 000000000..0b79f7112 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-selection-select-with-other.test.ts @@ -0,0 +1,138 @@ +import { mount } from "@vue/test-utils" +import { Quasar, QInput, QSelect } from "quasar" +import CareerSelectionSelectWithOther from "../components/CareerSelectionSelectWithOther.vue" +import type { CareerDropdownOption } from "../types" + +/** + * Tests for the dropdown that pairs with a free-text field: the text field belongs to the + * catch-all option, and appears only while that option is selected. + */ + +const OPTIONS: CareerDropdownOption[] = [ + { label: "Academia", value: 1, isOther: false }, + { label: "Other", value: 9, isOther: true }, +] + +function mountSelect(selectModel: CareerDropdownOption | null, otherModel = "", readOnly = false) { + return mount(CareerSelectionSelectWithOther, { + props: { + label: "Career Direction", + options: OPTIONS, + readOnly, + selectModel, + otherModel, + emptyLabel: "Not selected", + }, + global: { plugins: [[Quasar, {}]] }, + }) +} + +/** The standalone label element above the field; its id is generated per instance. */ +function standaloneLabel(wrapper: ReturnType) { + return wrapper.get("div[id]") +} + +describe("career selection select with other", () => { + it("shows the question above the field rather than as a floating label", () => { + expect.hasAssertions() + const wrapper = mountSelect(OPTIONS[0]) + + // A floating label is clipped to one ellipsized line, which cannot carry a full question, + // so the dropdown is named by the visible text above it instead. + expect(wrapper.findComponent(QSelect).props("label")).toBeUndefined() + expect(standaloneLabel(wrapper).text()).toBe("Career Direction") + }) + + it("names the combobox from that question", () => { + expect.hasAssertions() + // Guards the one fragile part of this: QSelect only ever puts Quasar's own attributes on + // the hidden focus target carrying role="combobox", so useSelectAriaLabel sets the name + // there by hand. If Quasar changes that internal, this is what catches it. + const wrapper = mountSelect(OPTIONS[0]) + + expect(wrapper.get('[role="combobox"]').attributes("aria-labelledby")).toBe( + standaloneLabel(wrapper).attributes("id"), + ) + }) + + it("keeps naming the combobox once the field becomes editable", async () => { + expect.hasAssertions() + // Quasar rebuilds the focus target across the readonly boundary, dropping the attribute + // with it, so the composable has to re-apply it on update rather than only on mount. + const wrapper = mountSelect(OPTIONS[0], "", true) + await wrapper.setProps({ readOnly: false }) + + expect(wrapper.get('[role="combobox"]').attributes("aria-labelledby")).toBe( + standaloneLabel(wrapper).attributes("id"), + ) + }) + + it("shows the question once, not as both a standalone label and a floating one", () => { + expect.hasAssertions() + const wrapper = mountSelect(OPTIONS[0]) + + expect(wrapper.text().split("Career Direction").length - 1).toBe(1) + }) + + it("hides the free-text field for an ordinary choice", () => { + expect.hasAssertions() + const wrapper = mountSelect(OPTIONS[0]) + + expect(wrapper.findComponent(QInput).exists()).toBeFalsy() + }) + + it("hides the free-text field while nothing is selected", () => { + expect.hasAssertions() + const wrapper = mountSelect(null) + + expect(wrapper.findComponent(QInput).exists()).toBeFalsy() + }) + + it("shows the free-text field for the catch-all", () => { + expect.hasAssertions() + const wrapper = mountSelect(OPTIONS[1], "Wildlife rehabilitation") + + const input = wrapper.findComponent(QInput) + expect(input.exists()).toBeTruthy() + expect(input.props("modelValue")).toBe("Wildlife rehabilitation") + }) + + it("holds the free text to the column's length", () => { + expect.hasAssertions() + // The entity caps career free text at 200 characters. + const wrapper = mountSelect(OPTIONS[1]) + + expect(wrapper.findComponent(QInput).props("maxlength")).toBe("200") + }) + + it("shows the empty label in place of no selection", () => { + expect.hasAssertions() + const wrapper = mountSelect(null) + + expect(wrapper.findComponent(QSelect).props("displayValue")).toBe("Not selected") + }) + + it("shows the chosen option's label once one is selected", () => { + expect.hasAssertions() + const wrapper = mountSelect(OPTIONS[0]) + + expect(wrapper.findComponent(QSelect).props("displayValue")).toBe("Academia") + }) + + it("locks both fields when the record is read-only", () => { + expect.hasAssertions() + const wrapper = mountSelect(OPTIONS[1], "Wildlife rehabilitation", true) + + expect(wrapper.findComponent(QSelect).props("readonly")).toBeTruthy() + expect(wrapper.findComponent(QInput).props("readonly")).toBeTruthy() + }) + + it("reports a changed selection to the form", async () => { + expect.hasAssertions() + const wrapper = mountSelect(OPTIONS[0]) + + await wrapper.findComponent(QSelect).setValue(OPTIONS[1]) + + expect(wrapper.emitted("update:selectModel")).toStrictEqual([[OPTIONS[1]]]) + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-selection-service.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-selection-service.test.ts new file mode 100644 index 000000000..43b75e58a --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-selection-service.test.ts @@ -0,0 +1,284 @@ +import { careerSelectionService } from "../services/career-selection-service" +import type { StudentInfo } from "../types" + +/** + * Tests for the career selection service beyond its option endpoints: the roster, report, record + * and mentor lookups, and the app access and export calls it inherits from StudentAppService. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +const mockDel = vi.fn<(...args: unknown[]) => unknown>() +const mockPostForBlob = vi.fn<(...args: unknown[]) => unknown>() +const mockDownloadBlob = vi.fn<(...args: unknown[]) => unknown>() + +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + del: (...args: unknown[]) => mockDel(...args), + createUrlSearchParams: (params: Record) => new URLSearchParams(params).toString(), + }), + postForBlob: (...args: unknown[]) => mockPostForBlob(...args), + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +function emptyStudentInfo(): StudentInfo { + return { + direction: null, + directionOther: "", + primaryFocus: null, + primaryFocusOther: "", + secondaryFocus: null, + secondaryFocusOther: "", + postGrad: null, + shortTermPlans: "", + longTermPlans: "", + } +} + +describe("roster and report", () => { + it("reads the roster from the app's base url", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: [{ personId: 100 }] }) + + const result = await careerSelectionService.getList() + + expect(mockGet).toHaveBeenCalledWith(expect.stringMatching(/\/students\/career-selection$/u)) + expect(result).toHaveLength(1) + }) + + it("returns an empty roster when the request fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + await expect(careerSelectionService.getList()).resolves.toStrictEqual([]) + }) + + it("reads the report from its own endpoint", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: [] }) + + await careerSelectionService.getReport() + + expect(mockGet).toHaveBeenCalledWith(expect.stringMatching(/\/career-selection\/report$/u)) + }) +}) + +describe("one student's record", () => { + it("requests the record by person id", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: { personId: 100, studentInfo: emptyStudentInfo() } }) + + await careerSelectionService.getDetail(100) + + expect(mockGet).toHaveBeenCalledWith(expect.stringMatching(/\/career-selection\/100$/u)) + }) + + it("returns null when the record cannot be read", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + await expect(careerSelectionService.getDetail(100)).resolves.toBeNull() + }) + + it("turns absent plans into empty strings for the form", async () => { + expect.hasAssertions() + vi.clearAllMocks() + // The textareas bind to strings; null would render as "null" and count as a change. + mockGet.mockResolvedValue({ + success: true, + result: { + personId: 100, + studentInfo: { ...emptyStudentInfo(), shortTermPlans: null, longTermPlans: null }, + }, + }) + + const detail = await careerSelectionService.getDetail(100) + + expect(detail?.studentInfo.shortTermPlans).toBe("") + expect(detail?.studentInfo.longTermPlans).toBe("") + }) + + it("leaves written plans alone", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ + success: true, + result: { + personId: 100, + studentInfo: { ...emptyStudentInfo(), shortTermPlans: "Internship", longTermPlans: "Ownership" }, + }, + }) + + const detail = await careerSelectionService.getDetail(100) + + expect(detail?.studentInfo.shortTermPlans).toBe("Internship") + }) +}) + +describe("saving a record", () => { + it("puts the form to the student's endpoint and returns the refreshed record", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const info = emptyStudentInfo() + mockPut.mockResolvedValue({ success: true, result: { personId: 100, studentInfo: info }, errors: [] }) + + const response = await careerSelectionService.updateCareerSelection(100, info) + + expect(mockPut).toHaveBeenCalledWith(expect.stringMatching(/\/career-selection\/100$/u), info) + expect(response.success).toBeTruthy() + expect(response.result?.personId).toBe(100) + }) + + it("reports the server's errors and no record when the save is refused", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPut.mockResolvedValue({ success: false, result: null, errors: ["Select a career direction."] }) + + const response = await careerSelectionService.updateCareerSelection(100, emptyStudentInfo()) + + expect(response.result).toBeNull() + expect(response.errors).toStrictEqual(["Select a career direction."]) + }) + + it("reports an empty error list when the server sends none", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPut.mockResolvedValue({ success: false, result: null }) + + const response = await careerSelectionService.updateCareerSelection(100, emptyStudentInfo()) + + expect(response.errors).toStrictEqual([]) + }) +}) + +describe("mentor search", () => { + it("passes the search text as a query parameter", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: [] }) + + await careerSelectionService.searchMentors("smith") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("/career-selection/mentors?search=smith")) + }) + + it("returns null on a failed search so the picker can tell it from no matches", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + await expect(careerSelectionService.searchMentors("smith")).resolves.toBeNull() + }) +}) + +describe("app access", () => { + it("reads whether the app is open", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: true }) + + const result = await careerSelectionService.getAccessStatus() + + expect(mockGet).toHaveBeenCalledWith(expect.stringMatching(/\/career-selection\/access\/status$/u)) + expect(result).toBeTruthy() + }) + + it("reports a closed app as false, not as a failed read", async () => { + expect.hasAssertions() + vi.clearAllMocks() + // A bare boolean status makes "closed" falsy, so only null may mean failure. + mockGet.mockResolvedValue({ success: true, result: false }) + + await expect(careerSelectionService.getAccessStatus()).resolves.toBeFalsy() + await expect(careerSelectionService.getAccessStatus()).resolves.not.toBeNull() + }) + + it("returns null when the status cannot be read", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + await expect(careerSelectionService.getAccessStatus()).resolves.toBeNull() + }) + + it("posts the toggle and returns the new state", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: true, result: false }) + + const result = await careerSelectionService.toggleAppAccess() + + expect(mockPost).toHaveBeenCalledWith(expect.stringMatching(/\/access\/toggle-app$/u)) + expect(result).toBeFalsy() + }) + + it("returns null when the toggle fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: false }) + + await expect(careerSelectionService.toggleAppAccess()).resolves.toBeNull() + }) +}) + +describe("exports", () => { + it("downloads the overview workbook under a career selection filename", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPostForBlob.mockResolvedValue({ blob: new Blob(["x"]), filename: null }) + + const downloaded = await careerSelectionService.downloadOverviewExcel() + + expect(mockPostForBlob).toHaveBeenCalledWith(expect.stringMatching(/\/export\/overview\/excel$/u), {}) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.any(Blob), "career-selection-overview.xlsx") + expect(downloaded).toBeTruthy() + }) + + it("prefers the filename the server sends", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPostForBlob.mockResolvedValue({ blob: new Blob(["x"]), filename: "CareerSelection_20260417.xlsx" }) + + await careerSelectionService.downloadExcel() + + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.any(Blob), "CareerSelection_20260417.xlsx") + }) + + it("reports nothing to download when the export comes back empty", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPostForBlob.mockResolvedValue({ blob: new Blob([]), filename: null }) + + const downloaded = await careerSelectionService.downloadExcel() + + expect(downloaded).toBeFalsy() + expect(mockDownloadBlob).not.toHaveBeenCalled() + }) + + it("opens each pdf in a new tab", () => { + expect.hasAssertions() + vi.clearAllMocks() + const open = vi.spyOn(globalThis, "open").mockReturnValue(null) + + careerSelectionService.openOverviewPdf() + careerSelectionService.openPdf() + + expect(open).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/\/export\/overview\/pdf$/u), + "_blank", + "noopener", + ) + expect(open).toHaveBeenNthCalledWith(2, expect.stringMatching(/\/export\/pdf$/u), "_blank", "noopener") + open.mockRestore() + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-selection-table.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-selection-table.test.ts new file mode 100644 index 000000000..53fcdb611 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-selection-table.test.ts @@ -0,0 +1,101 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Screen } from "quasar" +import { h } from "vue" +import CareerSelectionTable from "../components/CareerSelectionTable.vue" +import { CAREER_FIELDS, type CareerField } from "../utils/career-fields" +import { REPORT_COLUMNS } from "../utils/career-columns" +import type { StudentCareerReport } from "../types" + +/** + * Tests for the roster table the overview and report share. It owns the student's name and email + * and the phone card, and hands every career field to the page through its two slots. + */ + +vi.mock("@/composables/use-scrollable-table-region", () => ({ useScrollableTableRegion: () => {} })) + +function reportRow(overrides: Partial = {}): StudentCareerReport { + return { + personId: 1, + rowKey: "1", + hasDetailRoute: true, + fullName: "Student, Test", + classLevel: "V2", + email: "tstudent@ucdavis.edu", + direction: "Private Practice", + primaryFocus: "Equine", + secondaryFocus: "", + postGrad: "Internship", + shortTermPlans: "Rural practice", + longTermPlans: "Teach", + mentorName: "", + lastUpdated: null, + ...overrides, + } +} + +const statementFields = CAREER_FIELDS.filter((f) => f.statement) + +function mountTable(xs = false) { + Screen.xs = xs + return mount(CareerSelectionTable, { + props: { + rows: [reportRow()], + columns: REPORT_COLUMNS, + loading: false, + rowsPerPage: 25, + canEdit: true, + label: "Test table", + cellFields: statementFields, + excelExport: vi.fn<() => Promise>(), + pdfExport: vi.fn<() => void>(), + }, + slots: { + "field-cell": ({ field }: { field: CareerField }) => h("td", { class: "page-cell" }, `cell:${field.name}`), + "card-field": ({ field, value }: { field: CareerField; value: string | null | undefined }) => + h("p", { class: "page-card-line" }, `${field.name}=${value ?? ""}`), + }, + global: { + plugins: [[Quasar, {}]], + stubs: { + ExportToolbar: true, + CareerRecordLink: { template: "{{ student.fullName }}", props: ["student"] }, + }, + }, + }) +} + +describe("career selection table", () => { + afterEach(() => { + Screen.xs = false + }) + + it("names and emails each student itself", async () => { + expect.hasAssertions() + const wrapper = mountTable() + await flushPromises() + + expect(wrapper.text()).toContain("Student, Test") + expect(wrapper.find("a[href='mailto:tstudent@ucdavis.edu']").exists()).toBeTruthy() + }) + + it("hands the page only the cells it asked to render", async () => { + expect.hasAssertions() + const wrapper = mountTable() + await flushPromises() + + expect(wrapper.findAll(".page-cell").map((c) => c.text())).toStrictEqual(["cell:shortTerm", "cell:longTerm"]) + // A field the page did not claim keeps the table's own cell. + expect(wrapper.text()).toContain("Private Practice") + }) + + it("gives the page a card line per career field, with its value, on a phone", async () => { + expect.hasAssertions() + const wrapper = mountTable(true) + await flushPromises() + + const lines = wrapper.findAll(".page-card-line").map((l) => l.text()) + expect(lines).toHaveLength(CAREER_FIELDS.length) + expect(lines).toContain("direction=Private Practice") + expect(lines).toContain("longTerm=Teach") + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/career-selection-view.test.ts b/VueApp/src/Students/CareerSelection/__tests__/career-selection-view.test.ts new file mode 100644 index 000000000..f38b71c96 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/career-selection-view.test.ts @@ -0,0 +1,146 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import CareerSelectionView from "../pages/CareerSelectionView.vue" +import { careerSelectionService } from "../services/career-selection-service" +import type { StudentCareerDetail, StudentInfo } from "../types" + +/** + * Tests for the read-only career selection record. Its work is turning the stored answers into + * the label/value pairs the page lists: a choice normally reads as its own label, but the + * catch-all reads as the free text the student typed instead. + */ + +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: { pidm: "42" } }), + useRouter: () => ({ push: vi.fn<(to: unknown) => void>() }), +})) + +function option(label: string, isOther = false) { + return { label, value: 1, isOther } +} + +function studentInfo(overrides: Partial = {}): StudentInfo { + return { + direction: option("Private Practice"), + directionOther: "", + primaryFocus: option("Equine"), + primaryFocusOther: "", + secondaryFocus: option("Small Animal"), + secondaryFocusOther: "", + postGrad: option("Internship"), + mentorId: 7, + mentorName: "Mentor, Alex", + mentorIamId: "iam-7", + shortTermPlans: "Build a rural practice.", + longTermPlans: "Teach eventually.", + ...overrides, + } +} + +async function mountView(info: Partial = {}) { + const detail: StudentCareerDetail = { + personId: 42, + fullName: "Student, Test", + classLevel: "V2", + studentInfo: studentInfo(info), + canEdit: false, + canViewStudentList: false, + lastUpdated: null, + } + vi.spyOn(careerSelectionService, "getDetail").mockResolvedValue(detail) + + const wrapper = mount(CareerSelectionView, { + global: { + plugins: [[Quasar, {}]], + stubs: { StudentRecordPageShell: { template: "
" } }, + }, + }) + await flushPromises() + return wrapper +} + +/** The rendered pairs, as { label: value }, read straight off the description list. */ +function pairs(wrapper: Awaited>): Record { + const terms = wrapper.findAll("dt") + const values = wrapper.findAll("dd") + return Object.fromEntries(terms.map((term, i) => [term.text(), values[i].text()])) +} + +describe("career selection view", () => { + it("pairs every answer with the question it answers", async () => { + expect.hasAssertions() + const wrapper = await mountView() + + // A dt/dd pair per answer is what makes the association programmatic rather than visual. + expect(wrapper.findAll("dt")).toHaveLength(7) + expect(wrapper.findAll("dd")).toHaveLength(7) + }) + + it("reads each choice as its own label", async () => { + expect.hasAssertions() + const wrapper = await mountView() + + expect(pairs(wrapper)).toStrictEqual({ + "Career Direction": "Private Practice", + "Primary Focus": "Equine", + "Secondary Focus": "Small Animal", + "Post-Graduation Plans": "Internship", + "Mentoring Faculty": "Mentor, Alex", + "Short Term Plans": "Build a rural practice.", + "Long Term Plans": "Teach eventually.", + }) + }) + + it("reads a catch-all choice as the free text it belongs to", async () => { + expect.hasAssertions() + // Each catch-all has its own free-text field, so this also catches them being crossed. + const wrapper = await mountView({ + direction: option("Other", true), + directionOther: "Zoo medicine", + primaryFocus: option("Other", true), + primaryFocusOther: "Reptiles", + secondaryFocus: option("Other", true), + secondaryFocusOther: "Raptors", + }) + + const shown = pairs(wrapper) + expect(shown["Career Direction"]).toBe("Zoo medicine") + expect(shown["Primary Focus"]).toBe("Reptiles") + expect(shown["Secondary Focus"]).toBe("Raptors") + }) + + it("sends a catch-all post-graduation plan to the short term plans", async () => { + expect.hasAssertions() + // Post-graduation is the one choice with no free-text field of its own. + const wrapper = await mountView({ postGrad: option("Other", true) }) + + expect(pairs(wrapper)["Post-Graduation Plans"]).toBe("Other - See short-term plans") + }) + + it("shows a dash for an answer the student has not given", async () => { + expect.hasAssertions() + const wrapper = await mountView({ direction: null, mentorName: "", longTermPlans: "" }) + + const shown = pairs(wrapper) + expect(shown["Career Direction"]).toBe("—") + expect(shown["Mentoring Faculty"]).toBe("—") + expect(shown["Long Term Plans"]).toBe("—") + }) + + it("keeps the paragraphs of a plan", async () => { + expect.hasAssertions() + const wrapper = await mountView({ longTermPlans: "Build a practice.\n\nThen teach." }) + + const longTerm = wrapper.findAll("dd").at(-1) + // The newlines survive to the DOM, and the class is what renders them as line breaks. + expect(longTerm?.element.textContent).toBe("Build a practice.\n\nThen teach.") + expect(longTerm?.classes()).toContain("detail-value") + }) + + it("shows the catch-all's dash when its free text was never filled in", async () => { + expect.hasAssertions() + const wrapper = await mountView({ direction: option("Other", true), directionOther: "" }) + + expect(pairs(wrapper)["Career Direction"]).toBe("—") + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/completeness-icon.test.ts b/VueApp/src/Students/CareerSelection/__tests__/completeness-icon.test.ts new file mode 100644 index 000000000..67ba4dbdd --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/completeness-icon.test.ts @@ -0,0 +1,84 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import CompletenessIcon from "../components/CompletenessIcon.vue" + +/** + * Tests for the career selection CompletenessIcon: one field is either answered or not, unlike + * the emergency contact icon, which counts fields and has a partial state. + */ + +interface Props { + complete: boolean + label?: string + showMissing?: boolean +} + +function mountIcon(props: Props) { + const wrapper = mount(CompletenessIcon, { + props, + global: { plugins: [[Quasar, {}]] }, + }) + const icon = wrapper.findComponent({ name: "QIcon" }) + // Read the name off the sr-only text, not the icon: Quasar sets aria-hidden on every + // q-icon, so anything named there is dropped from the accessibility tree. + const srOnly = wrapper.find(".sr-only") + return { + rendered: icon.exists(), + iconName: icon.exists() ? (icon.props("name") as string) : "", + iconColor: icon.exists() ? (icon.props("color") as string) : "", + tooltipText: srOnly.exists() ? srOnly.text() : undefined, + srOnlyHidden: srOnly.exists() ? srOnly.attributes("aria-hidden") : undefined, + } +} + +describe("completeness icon", () => { + describe("answered", () => { + it("shows a green check", () => { + expect.hasAssertions() + const { iconName, iconColor, tooltipText, srOnlyHidden } = mountIcon({ complete: true }) + expect(iconName).toBe("check_circle") + expect(iconColor).toBe("positive") + expect(tooltipText).toBe("Complete") + // The whole point of the sibling: it must not inherit the icon's aria-hidden. + expect(srOnlyHidden).toBeUndefined() + }) + + it("keeps the tooltip generic even when a label is given", () => { + expect.hasAssertions() + const { tooltipText } = mountIcon({ complete: true, label: "Direction" }) + expect(tooltipText).toBe("Complete") + }) + }) + + describe("unanswered", () => { + it("shows a red cross", () => { + expect.hasAssertions() + const { iconName, iconColor, tooltipText } = mountIcon({ complete: false }) + expect(iconName).toBe("cancel") + expect(iconColor).toBe("negative") + expect(tooltipText).toBe("Missing") + }) + + it("names the field in the tooltip when a label is given", () => { + expect.hasAssertions() + const { tooltipText } = mountIcon({ complete: false, label: "Direction" }) + expect(tooltipText).toBe("Missing Direction") + }) + }) + + describe("optional fields (showMissing false)", () => { + it("renders nothing when unanswered", () => { + expect.hasAssertions() + // A second species focus is optional, so an unset one is left blank rather than flagged. + const { rendered } = mountIcon({ complete: false, showMissing: false }) + expect(rendered).toBeFalsy() + }) + + it("still shows the green check when answered", () => { + expect.hasAssertions() + const { iconName, iconColor } = mountIcon({ complete: true, showMissing: false }) + expect(iconName).toBe("check_circle") + expect(iconColor).toBe("positive") + }) + }) +}) diff --git a/VueApp/src/Students/CareerSelection/__tests__/use-career-selection.test.ts b/VueApp/src/Students/CareerSelection/__tests__/use-career-selection.test.ts new file mode 100644 index 000000000..0295ba829 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/__tests__/use-career-selection.test.ts @@ -0,0 +1,236 @@ +import { useCareerSelection } from "../composables/use-career-selection" +import { careerSelectionService } from "../services/career-selection-service" +import type { StudentCareerDetail, StudentInfo } from "../types" + +/** + * Tests for useCareerSelection: the form state behind the career selection edit page, including + * the dirty tracking the unsaved-changes prompt depends on. + */ + +vi.mock("../services/career-selection-service", () => ({ + careerSelectionService: { + getDetail: vi.fn<(...args: unknown[]) => unknown>(), + updateCareerSelection: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +const mockGetDetail = vi.mocked(careerSelectionService.getDetail) +const mockUpdate = vi.mocked(careerSelectionService.updateCareerSelection) + +function studentInfo(overrides: Partial = {}): StudentInfo { + return { + direction: { label: "Academia", value: 1, isOther: false }, + directionOther: "", + primaryFocus: null, + primaryFocusOther: "", + secondaryFocus: null, + secondaryFocusOther: "", + postGrad: null, + mentorId: 500, + mentorName: "Vet, Ann", + mentorIamId: "IAM500", + shortTermPlans: "Internship", + longTermPlans: "", + ...overrides, + } +} + +function detail(overrides: Partial = {}): StudentCareerDetail { + return { + personId: 100, + fullName: "Student, Test", + classLevel: "V1", + canEdit: true, + canViewStudentList: false, + studentInfo: studentInfo(), + lastUpdated: "2026-04-17T10:00:00", + ...overrides, + } +} + +describe("loading a record", () => { + it("fills the form from the loaded record", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, studentInfo: form, detail: loaded } = useCareerSelection() + + await loadDetail(100) + + expect(mockGetDetail).toHaveBeenCalledWith(100) + expect(loaded.value?.fullName).toBe("Student, Test") + expect(form.value.shortTermPlans).toBe("Internship") + }) + + it("leaves the form empty when the record cannot be read", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(null) + const { loadDetail, studentInfo: form, detail: loaded } = useCareerSelection() + + await loadDetail(100) + + expect(loaded.value).toBeNull() + expect(form.value.shortTermPlans).toBe("") + }) + + it("clears the loading flag once the record is in", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, loading } = useCareerSelection() + + const pending = loadDetail(100) + expect(loading.value).toBeTruthy() + await pending + + expect(loading.value).toBeFalsy() + }) + + it("copies the record rather than editing it in place", async () => { + expect.hasAssertions() + vi.clearAllMocks() + // The form must not mutate the loaded record, which the page also reads from. + const loadedRecord = detail() + mockGetDetail.mockResolvedValue(loadedRecord) + const { loadDetail, studentInfo: form } = useCareerSelection() + + await loadDetail(100) + form.value.shortTermPlans = "Changed" + + expect(loadedRecord.studentInfo.shortTermPlans).toBe("Internship") + }) +}) + +describe("dirty tracking", () => { + it("starts clean after loading", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, isDirty } = useCareerSelection() + + await loadDetail(100) + + expect(isDirty.value).toBeFalsy() + }) + + it("notices an edited field", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, studentInfo: form, isDirty } = useCareerSelection() + await loadDetail(100) + + form.value.longTermPlans = "Practice ownership" + + expect(isDirty.value).toBeTruthy() + }) + + it("notices a changed dropdown", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, studentInfo: form, isDirty } = useCareerSelection() + await loadDetail(100) + + form.value.direction = { label: "Industry", value: 2, isOther: false } + + expect(isDirty.value).toBeTruthy() + }) + + it("reads as clean again when the edit is undone", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, studentInfo: form, isDirty } = useCareerSelection() + await loadDetail(100) + + form.value.longTermPlans = "Practice ownership" + form.value.longTermPlans = "" + + expect(isDirty.value).toBeFalsy() + }) +}) + +describe("saving", () => { + it("sends the form and keeps the refreshed record", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const saved = detail({ lastUpdated: "2026-04-18T09:00:00" }) + mockUpdate.mockResolvedValue({ success: true, result: saved, errors: [] }) + const { loadDetail, save, detail: loaded, studentInfo: form } = useCareerSelection() + await loadDetail(100) + form.value.longTermPlans = "Practice ownership" + + const succeeded = await save(100) + + expect(succeeded).toBeTruthy() + // The form is repopulated from the response, so assert on what was sent, not on form.value. + expect(mockUpdate).toHaveBeenCalledWith(100, expect.objectContaining({ longTermPlans: "Practice ownership" })) + expect(loaded.value?.lastUpdated).toBe("2026-04-18T09:00:00") + }) + + it("reads as clean after a successful save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, save, studentInfo: form, isDirty } = useCareerSelection() + await loadDetail(100) + form.value.longTermPlans = "Practice ownership" + mockUpdate.mockResolvedValue({ + success: true, + result: detail({ studentInfo: studentInfo({ longTermPlans: "Practice ownership" }) }), + errors: [], + }) + + await save(100) + + expect(isDirty.value).toBeFalsy() + }) + + it("keeps the edits and reports the errors when the save is refused", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, save, studentInfo: form, saveErrors, isDirty } = useCareerSelection() + await loadDetail(100) + form.value.longTermPlans = "Practice ownership" + mockUpdate.mockResolvedValue({ success: false, result: null, errors: ["Select a career direction."] }) + + const succeeded = await save(100) + + expect(succeeded).toBeFalsy() + expect(saveErrors.value).toStrictEqual(["Select a career direction."]) + expect(form.value.longTermPlans).toBe("Practice ownership") + expect(isDirty.value).toBeTruthy() + }) + + it("clears earlier errors when a later save succeeds", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGetDetail.mockResolvedValue(detail()) + const { loadDetail, save, saveErrors } = useCareerSelection() + await loadDetail(100) + mockUpdate.mockResolvedValue({ success: false, result: null, errors: ["Select a career direction."] }) + await save(100) + mockUpdate.mockResolvedValue({ success: true, result: detail(), errors: [] }) + + await save(100) + + expect(saveErrors.value).toStrictEqual([]) + }) + + it("clears the saving flag once the save is done", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockUpdate.mockResolvedValue({ success: true, result: detail(), errors: [] }) + const { save, saving } = useCareerSelection() + + const pending = save(100) + expect(saving.value).toBeTruthy() + await pending + + expect(saving.value).toBeFalsy() + }) +}) diff --git a/VueApp/src/Students/CareerSelection/components/CareerOptionFormDialog.vue b/VueApp/src/Students/CareerSelection/components/CareerOptionFormDialog.vue new file mode 100644 index 000000000..5591831af --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CareerOptionFormDialog.vue @@ -0,0 +1,104 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/components/CareerOptionManager.vue b/VueApp/src/Students/CareerSelection/components/CareerOptionManager.vue new file mode 100644 index 000000000..299098b6e --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CareerOptionManager.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/VueApp/src/Students/CareerSelection/components/CareerRecordLink.vue b/VueApp/src/Students/CareerSelection/components/CareerRecordLink.vue new file mode 100644 index 000000000..be417f954 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CareerRecordLink.vue @@ -0,0 +1,20 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/components/CareerSelectionPageHeading.vue b/VueApp/src/Students/CareerSelection/components/CareerSelectionPageHeading.vue new file mode 100644 index 000000000..92a3db1f6 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CareerSelectionPageHeading.vue @@ -0,0 +1,32 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/components/CareerSelectionRowCard.vue b/VueApp/src/Students/CareerSelection/components/CareerSelectionRowCard.vue new file mode 100644 index 000000000..11bf840cf --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CareerSelectionRowCard.vue @@ -0,0 +1,71 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/components/CareerSelectionSelectWithOther.vue b/VueApp/src/Students/CareerSelection/components/CareerSelectionSelectWithOther.vue new file mode 100644 index 000000000..bf4bc0f4f --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CareerSelectionSelectWithOther.vue @@ -0,0 +1,60 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/components/CareerSelectionTable.vue b/VueApp/src/Students/CareerSelection/components/CareerSelectionTable.vue new file mode 100644 index 000000000..baf452885 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CareerSelectionTable.vue @@ -0,0 +1,136 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/components/CompletenessIcon.vue b/VueApp/src/Students/CareerSelection/components/CompletenessIcon.vue new file mode 100644 index 000000000..d29ea3195 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/CompletenessIcon.vue @@ -0,0 +1,55 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/components/MentorSelector.vue b/VueApp/src/Students/CareerSelection/components/MentorSelector.vue new file mode 100644 index 000000000..da39f458a --- /dev/null +++ b/VueApp/src/Students/CareerSelection/components/MentorSelector.vue @@ -0,0 +1,32 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/composables/use-career-option-manager.ts b/VueApp/src/Students/CareerSelection/composables/use-career-option-manager.ts new file mode 100644 index 000000000..e6e5f7979 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/composables/use-career-option-manager.ts @@ -0,0 +1,100 @@ +import { ref } from "vue" +import type { CareerOptionSaveResult, CareerOptionType, CareerSelectionOption } from "../types" +import { careerSelectionService } from "../services/career-selection-service" + +type CareerOptionTypeConfig = { + title: string + // Singular name used in dialog titles, e.g. "Add Species Option". + singular: string + description: string + maxLength: number +} + +const CAREER_OPTION_TYPES: Record = { + career: { + title: "Career Direction", + singular: "Career Direction", + description: "Choices for the Career Direction dropdown.", + maxLength: 100, + }, + species: { + title: "Species Focus", + singular: "Species", + description: "Choices shared by the Primary and Secondary Species Focus dropdowns.", + maxLength: 100, + }, + postGrad: { + title: "Post-Graduation Plans", + singular: "Post-Graduation Plan", + description: "Choices for the Post-Graduation Plans dropdown.", + maxLength: 200, + }, +} + +function normalizeLabel(label: string): string { + return label.trim().toLocaleLowerCase() +} + +type OptionLabelContext = { + existing: CareerSelectionOption[] + editingId: number | null // The option being renamed, or null when adding. + maxLength: number +} + +/** + * Client-side check of a proposed option label. The server is the authority on duplicates, + * but this catches obvious issues. Returns an error message, or null. + */ +function validateOptionLabel(label: string, { existing, editingId, maxLength }: OptionLabelContext): string | null { + const trimmed = label.trim() + if (!trimmed) { + return "Please enter a name." + } + if (trimmed.length > maxLength) { + return `Name must be ${maxLength} characters or fewer.` + } + const normalized = normalizeLabel(trimmed) + // Excluding the option being edited lets an unchanged name, or a change of case only, save. + const duplicate = existing.some((o) => o.id !== editingId && normalizeLabel(o.label) === normalized) + return duplicate ? "An option with this name already exists." : null +} + +function useCareerOptionManager(type: CareerOptionType) { + const options = ref([]) + const loading = ref(false) + const loadFailed = ref(false) + const deletingId = ref(null) + + async function load(): Promise { + loading.value = true + const result = await careerSelectionService.getOptions(type) + loadFailed.value = result === null + options.value = result ?? [] + loading.value = false + } + + async function save(id: number | null, label: string): Promise { + const trimmed = label.trim() + const result = + id === null + ? await careerSelectionService.createOption(type, trimmed) + : await careerSelectionService.updateOption(type, id, trimmed) + if (result.success) { + await load() + } + return result + } + + async function remove(id: number): Promise { + deletingId.value = id + const result = await careerSelectionService.deleteOption(type, id) + await load() + deletingId.value = null + return result + } + + return { options, loading, loadFailed, deletingId, load, save, remove } +} + +export { CAREER_OPTION_TYPES, useCareerOptionManager, validateOptionLabel } +export type { CareerOptionTypeConfig } diff --git a/VueApp/src/Students/CareerSelection/composables/use-career-selection.ts b/VueApp/src/Students/CareerSelection/composables/use-career-selection.ts new file mode 100644 index 000000000..745e976f3 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/composables/use-career-selection.ts @@ -0,0 +1,85 @@ +import { ref, computed } from "vue" +import type { StudentCareerDetail, StudentInfo } from "../types" +import { careerSelectionService } from "../services/career-selection-service" + +function emptyStudentInfo(): StudentInfo { + return { + direction: null, + directionOther: "", + primaryFocus: null, + primaryFocusOther: "", + secondaryFocus: null, + secondaryFocusOther: "", + mentorId: null, + mentorName: "", + mentorIamId: null, + postGrad: null, + shortTermPlans: "", + longTermPlans: "", + } +} + +function useCareerSelection() { + const loading = ref(false) + const saving = ref(false) + const detail = ref(null) + const saveErrors = ref([]) + + const studentInfo = ref(emptyStudentInfo()) // Form state + + const initialSnapshot = ref("") // Snapshot for dirty tracking + + function takeSnapshot(): string { + return JSON.stringify({ + studentInfo: studentInfo.value, + }) + } + + const currentSnapshot = computed(() => takeSnapshot()) + const isDirty = computed(() => initialSnapshot.value !== currentSnapshot.value) + + function populateForm(data: StudentCareerDetail): void { + studentInfo.value = { ...data.studentInfo } + initialSnapshot.value = takeSnapshot() + } + + async function loadDetail(personId: number): Promise { + loading.value = true + saveErrors.value = [] + const result = await careerSelectionService.getDetail(personId) + detail.value = result + if (result) { + populateForm(result) + } + loading.value = false + } + + async function save(personId: number): Promise { + saving.value = true + saveErrors.value = [] + const response = await careerSelectionService.updateCareerSelection(personId, studentInfo.value) + saving.value = false + + if (response.success && response.result) { + detail.value = response.result + populateForm(response.result) + return true + } + + saveErrors.value = response.errors + return false + } + + return { + loading, + saving, + detail, + saveErrors, + studentInfo, + isDirty, + loadDetail, + save, + } +} + +export { useCareerSelection } diff --git a/VueApp/src/Students/CareerSelection/constants/permissions.ts b/VueApp/src/Students/CareerSelection/constants/permissions.ts new file mode 100644 index 000000000..10e3ff771 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/constants/permissions.ts @@ -0,0 +1,15 @@ +/** + * RAPS permissions for the Career Selection app. Mirrors CareerSelectionPermissions.cs. + */ + +const CAREER_SELECTION_PERMISSION_PREFIX = "SVMSecure.CareerSelection" + +const CAREER_SELECTION_PERMISSIONS = { + ADMIN: `${CAREER_SELECTION_PERMISSION_PREFIX}.Admin`, + READ_ONLY: `${CAREER_SELECTION_PERMISSION_PREFIX}.ReadOnly`, + STUDENT: `${CAREER_SELECTION_PERMISSION_PREFIX}.Student`, + FACULTY: `${CAREER_SELECTION_PERMISSION_PREFIX}.Faculty`, + VIEW_OWN: `${CAREER_SELECTION_PERMISSION_PREFIX}.ViewOwn`, +} as const + +export { CAREER_SELECTION_PERMISSION_PREFIX, CAREER_SELECTION_PERMISSIONS } diff --git a/VueApp/src/Students/CareerSelection/constants/record-page.ts b/VueApp/src/Students/CareerSelection/constants/record-page.ts new file mode 100644 index 000000000..2241731b0 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/constants/record-page.ts @@ -0,0 +1,11 @@ +/** + * Labels for StudentRecordPageShell on the career selection view and edit pages. + */ + +const CAREER_SELECTION_RECORD_PAGE = { + appLabel: "Career Selection", + listRouteName: "CareerSelectionList", + recordLabel: "career selection", +} as const + +export { CAREER_SELECTION_RECORD_PAGE } diff --git a/VueApp/src/Students/CareerSelection/pages/CareerSelectionForm.vue b/VueApp/src/Students/CareerSelection/pages/CareerSelectionForm.vue new file mode 100644 index 000000000..690c04265 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/pages/CareerSelectionForm.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/VueApp/src/Students/CareerSelection/pages/CareerSelectionList.vue b/VueApp/src/Students/CareerSelection/pages/CareerSelectionList.vue new file mode 100644 index 000000000..7a5f3c6d5 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/pages/CareerSelectionList.vue @@ -0,0 +1,92 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/pages/CareerSelectionManageOptions.vue b/VueApp/src/Students/CareerSelection/pages/CareerSelectionManageOptions.vue new file mode 100644 index 000000000..8030de7f6 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/pages/CareerSelectionManageOptions.vue @@ -0,0 +1,53 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/pages/CareerSelectionReport.vue b/VueApp/src/Students/CareerSelection/pages/CareerSelectionReport.vue new file mode 100644 index 000000000..9b7c0d9a4 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/pages/CareerSelectionReport.vue @@ -0,0 +1,81 @@ + + + diff --git a/VueApp/src/Students/CareerSelection/pages/CareerSelectionView.vue b/VueApp/src/Students/CareerSelection/pages/CareerSelectionView.vue new file mode 100644 index 000000000..7d204af53 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/pages/CareerSelectionView.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/VueApp/src/Students/CareerSelection/router/career-selection-guards.ts b/VueApp/src/Students/CareerSelection/router/career-selection-guards.ts new file mode 100644 index 000000000..def336f30 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/router/career-selection-guards.ts @@ -0,0 +1,113 @@ +import type { RouteLocationNormalized } from "vue-router" +import { checkHasOnePermission } from "@/composables/CheckPagePermission" +import { useUserStore } from "@/store/UserStore" +import { CAREER_SELECTION_PERMISSIONS } from "../constants/permissions" + +const { ADMIN, READ_ONLY, STUDENT } = CAREER_SELECTION_PERMISSIONS +// A mentor sees a roster, but only of the students they mentor. Which students those are is +// data the client does not hold, so the guards let faculty through and the API decides. +const { FACULTY } = CAREER_SELECTION_PERMISSIONS +// Those in the STUDENTS_DVM role have this permission. +// The app open/close switch flips only STUDENT, so read access survives a closed app. +const { VIEW_OWN } = CAREER_SELECTION_PERMISSIONS + +const HOME = { name: "StudentsHome" } + +function viewRoute(pidm: string | number) { + return { name: "CareerSelectionView", params: { pidm } } +} + +function editRoute(pidm: string | number) { + return { name: "CareerSelectionEdit", params: { pidm } } +} + +function isAdmin(): boolean { + return checkHasOnePermission([ADMIN]) +} + +function canViewAllRecords(): boolean { + return checkHasOnePermission([ADMIN, READ_ONLY]) +} + +function isFaculty(): boolean { + return checkHasOnePermission([FACULTY]) +} + +function canEditOwnRecord(): boolean { + return checkHasOnePermission([STUDENT]) +} + +function hasOwnRecord(): boolean { + return checkHasOnePermission([VIEW_OWN, STUDENT]) +} + +function ownRecordId(): number | null { + const userStore = useUserStore() + return userStore.userInfo.userId ?? null +} + +// Viewing your own record is not gated on the edit permission, so the ownership check has +// to come before any permission check rather than after one. +function requireCareerViewAccess(pidm: string | number) { + if (canViewAllRecords()) { + return true + } + + // Whether this student is one of their mentees is a question only the server can answer, so + // a mentor is let through and the record request is left to decide it. A record that is not + // theirs is refused there, which the page surfaces as its record-not-found notice. + if (isFaculty()) { + return true + } + + const ownId = ownRecordId() + if (ownId === null || !hasOwnRecord()) { + return HOME + } + + return Number(pidm) === ownId ? true : viewRoute(ownId) +} + +function requireCareerEditAccess(pidm: string | number) { + if (isAdmin()) { + return true + } + + const ownId = ownRecordId() + if (Number(pidm) !== ownId) { + // Do not disclose the existence of the record to unauthorized users. + if (canViewAllRecords() || isFaculty()) { + return viewRoute(pidm) + } + return ownId !== null && hasOwnRecord() ? viewRoute(ownId) : HOME + } + + if (canEditOwnRecord()) { + return true + } + // App closed: the record stays readable even though it can no longer be edited. + return hasOwnRecord() ? viewRoute(ownId) : HOME +} + +function requireCareerListAccess() { + if (canViewAllRecords() || isFaculty()) { + return true + } + + const ownId = ownRecordId() + if (ownId === null || !hasOwnRecord()) { + return HOME + } + + // Edit rather than View, because the edit guard downgrades to View when the app is + // closed and a student who can edit should not have to click through a read-only page. + return editRoute(ownId) +} + +const careerSelectionGuards = { + list: requireCareerListAccess, + view: (to: RouteLocationNormalized) => requireCareerViewAccess(to.params.pidm as string), + edit: (to: RouteLocationNormalized) => requireCareerEditAccess(to.params.pidm as string), +} + +export { careerSelectionGuards, requireCareerViewAccess, requireCareerEditAccess, requireCareerListAccess } diff --git a/VueApp/src/Students/CareerSelection/services/career-selection-service.ts b/VueApp/src/Students/CareerSelection/services/career-selection-service.ts new file mode 100644 index 000000000..99b10359d --- /dev/null +++ b/VueApp/src/Students/CareerSelection/services/career-selection-service.ts @@ -0,0 +1,131 @@ +import { useFetch } from "@/composables/ViperFetch" +import { StudentAppService } from "@/Students/services/student-app-service" +import type { + StudentCareerListItem, + StudentCareerDetail, + StudentCareerReport, + StudentInfo, + CareerDropdownOption, + CareerOptionType, + CareerOptionSaveResult, + CareerSelectionOption, + MentorOption, +} from "../types" + +const OPTION_TYPE_SLUGS: Record = { + career: "career", + species: "species", + postGrad: "post-grad", +} + +class CareerSelectionService extends StudentAppService { + constructor() { + super("students/career-selection", { + overviewExcel: "career-selection-overview.xlsx", + excel: "career-selection.xlsx", + }) + } + + // Dropdown options, keyed on the option type slug. GET serves admins and students (only + // admins get usage counts); the writes are admin-only. + private optionsUrl(type: CareerOptionType, id?: number): string { + const url = `${this.baseUrl}/options/${OPTION_TYPE_SLUGS[type]}` + return id === undefined ? url : `${url}/${id}` + } + + downloadOverviewCsv = (): Promise => + this.downloadExportFile("export/overview/csv", "career-selection-overview.csv") + + downloadCsv = (): Promise => this.downloadExportFile("export/csv", "career-selection.csv") + + // Returns null on a failed request so the page can tell an empty list from a load failure. + getOptions = async (type: CareerOptionType): Promise => { + const { get } = useFetch() + const response = await get(this.optionsUrl(type)) + if (!response.success || !Array.isArray(response.result)) { + return null + } + return response.result as CareerSelectionOption[] + } + + createOption = async (type: CareerOptionType, label: string): Promise => { + const { post } = useFetch() + const response = await post(this.optionsUrl(type), { label }) + return { success: response.success, errors: response.errors ?? [] } + } + + updateOption = async (type: CareerOptionType, id: number, label: string): Promise => { + const { put } = useFetch() + const response = await put(this.optionsUrl(type, id), { label }) + return { success: response.success, errors: response.errors ?? [] } + } + + deleteOption = async (type: CareerOptionType, id: number): Promise => { + const { del } = useFetch() + const response = await del(this.optionsUrl(type, id)) + return { success: response.success, errors: response.errors ?? [] } + } + + // The form's choices for one dropdown, in the server's order. Empty if the request fails. + getDropdownOptions = async (type: CareerOptionType): Promise => { + const options = await this.getOptions(type) + return (options ?? []).map((o) => ({ label: o.label, value: o.id, isOther: o.isOther })) + } + + // Mentor picker lookup. Returns null on a failed request (vs an empty array) so the picker + // can tell "no matches" from "the fetch failed". + searchMentors = async (search: string): Promise => { + const { get, createUrlSearchParams } = useFetch() + const response = await get(`${this.baseUrl}/mentors?${createUrlSearchParams({ search })}`) + return response.success ? (response.result as MentorOption[]) : null + } + + getList = async (): Promise => { + const { get } = useFetch() + const response = await get(this.baseUrl) + if (!response.success || !response.result) { + return [] + } + return response.result as StudentCareerListItem[] + } + + getDetail = async (personId: number): Promise => { + const { get } = useFetch() + const response = await get(`${this.baseUrl}/${personId}`) + if (!response.success || !response.result) { + return null + } + if (response.result?.studentInfo?.shortTermPlans === null) { + response.result.studentInfo.shortTermPlans = "" + } + if (response.result?.studentInfo?.longTermPlans === null) { + response.result.studentInfo.longTermPlans = "" + } + return response.result as StudentCareerDetail + } + + updateCareerSelection = async ( + personId: number, + data: StudentInfo, + ): Promise<{ success: boolean; result: StudentCareerDetail | null; errors: string[] }> => { + const { put } = useFetch() + const response = await put(`${this.baseUrl}/${personId}`, data) + return { + success: response.success, + result: response.success ? (response.result as StudentCareerDetail) : null, + errors: response.errors ?? [], + } + } + + getReport = async (): Promise => { + const { get } = useFetch() + const response = await get(`${this.baseUrl}/report`) + if (!response.success || !response.result) { + return [] + } + return response.result as StudentCareerReport[] + } +} + +const careerSelectionService = new CareerSelectionService() +export { careerSelectionService } diff --git a/VueApp/src/Students/CareerSelection/types/index.ts b/VueApp/src/Students/CareerSelection/types/index.ts new file mode 100644 index 000000000..d79ce5ce9 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/types/index.ts @@ -0,0 +1,119 @@ +type CareerDropdownOption = { + label: string + value: number | null + // The catch-all option. Set by the server so a renamed "Other" still behaves like one. + isOther: boolean +} + +/** + * One SVM affiliate offered by the mentor picker. `iamId` is what the shared person picker keys + * its options on; `personId` is what a career selection is saved with. + */ +type MentorOption = { + personId: number + iamId: string + fullName: string + loginId: string | null + mailId: string | null +} + +type StudentInfo = { + direction: CareerDropdownOption | null + directionOther: string | null + primaryFocus: CareerDropdownOption | null + primaryFocusOther: string | null + secondaryFocus: CareerDropdownOption | null + secondaryFocusOther: string | null + postGrad: CareerDropdownOption | null + mentorId?: number | null + mentorName?: string | null + // The picker keys its options on the IAM id, so a saved mentor needs one to + // render. The server saves the mentor from mentorId and ignores this. + mentorIamId?: string | null + shortTermPlans: string + longTermPlans: string +} + +type StudentCareerListItem = { + personId: number + rowKey: string + hasDetailRoute: boolean + fullName: string + classLevel: string + email: string + directionCompleted: boolean + primaryFocusCompleted: boolean + secondaryFocusCompleted: boolean + postGradCompleted: boolean + shortTermPlansCompleted: boolean + longTermPlansCompleted: boolean + mentorName?: string + lastUpdated: string | null +} + +type StudentCareerDetail = { + personId: number + fullName: string + classLevel: string + studentInfo: StudentInfo + canEdit: boolean + canViewStudentList: boolean + lastUpdated: string | null +} + +type StudentCareerReport = { + personId: number + rowKey: string + hasDetailRoute: boolean + fullName: string + classLevel: string + email: string + direction: string + primaryFocus: string + secondaryFocus: string + postGrad: string + shortTermPlans: string + longTermPlans: string + mentorName: string + lastUpdated: string | null +} + +// What the shared roster table needs of a row, which the overview and report rows both carry. +type CareerTableRow = { + personId: number + rowKey: string + hasDetailRoute: boolean + fullName: string + classLevel: string + email?: string | null + lastUpdated?: string | null +} + +// The three option tables behind the form's dropdowns. +type CareerOptionType = "career" | "species" | "postGrad" + +// One dropdown option as the server sends it, to the form and the admin page alike. +type CareerSelectionOption = { + id: number + label: string + isOther: boolean + usageCount: number // Only admins get a real count; it is 0 for everyone else. +} + +type CareerOptionSaveResult = { + success: boolean + errors: string[] +} + +export type { + CareerDropdownOption, + CareerOptionType, + CareerSelectionOption, + CareerOptionSaveResult, + CareerTableRow, + MentorOption, + StudentInfo, + StudentCareerListItem, + StudentCareerDetail, + StudentCareerReport, +} diff --git a/VueApp/src/Students/CareerSelection/utils/career-columns.ts b/VueApp/src/Students/CareerSelection/utils/career-columns.ts new file mode 100644 index 000000000..fdd938609 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/utils/career-columns.ts @@ -0,0 +1,57 @@ +import type { QTableProps } from "quasar" +import { CAREER_FIELDS } from "./career-fields" + +// Career statements run to 5000 characters, which makes the row unreadable. The report shows +// an opening excerpt; the full text is on the detail page and in the Excel export. The excerpt is +// applied in the report's cell slots, not as a column format: QTable's search matches the +// formatted value, so a format would hide everything past the excerpt from search. +const STATEMENT_PREVIEW_LENGTH = 50 + +function previewStatement(value: string | null | undefined): string { + const text = value?.trim() ?? "" + return text.length <= STATEMENT_PREVIEW_LENGTH ? text : `${text.slice(0, STATEMENT_PREVIEW_LENGTH - 1).trimEnd()}…` +} + +function formatDate(value: string | null): string { + return value ? new Date(value).toLocaleDateString() : "" +} + +const LAST_UPDATED_COLUMN = { + name: "lastUpdated", + label: "Last Updated", + field: "lastUpdated", + align: "left" as const, + sortable: true, + format: formatDate, +} + +const OVERVIEW_COLUMNS: NonNullable = [ + { name: "classLevel", label: "Class", field: "classLevel", align: "center", sortable: true }, + { name: "fullName", label: "Name", field: "fullName", align: "left", sortable: true }, + { name: "email", label: "Email", field: "email", align: "left", sortable: true }, + ...CAREER_FIELDS.map((f) => ({ + name: f.name, + label: f.label, + field: f.completedField ?? f.valueField, + align: f.completedField ? ("center" as const) : ("left" as const), + sortable: true, + })), + LAST_UPDATED_COLUMN, +] + +const REPORT_COLUMNS: NonNullable = [ + { name: "classLevel", label: "Class", field: "classLevel", align: "left", sortable: true }, + { name: "fullName", label: "Name", field: "fullName", align: "left", sortable: true }, + { name: "email", label: "Email", field: "email", align: "left", sortable: true }, + ...CAREER_FIELDS.map((f) => ({ + name: f.name, + label: f.label, + field: f.valueField, + align: "left" as const, + // Paragraphs of free text have no useful order to sort by. + sortable: !f.statement, + })), + LAST_UPDATED_COLUMN, +] + +export { OVERVIEW_COLUMNS, REPORT_COLUMNS, previewStatement, STATEMENT_PREVIEW_LENGTH } diff --git a/VueApp/src/Students/CareerSelection/utils/career-completeness.ts b/VueApp/src/Students/CareerSelection/utils/career-completeness.ts new file mode 100644 index 000000000..2a131a32e --- /dev/null +++ b/VueApp/src/Students/CareerSelection/utils/career-completeness.ts @@ -0,0 +1,28 @@ +import type { CareerDropdownOption, StudentInfo } from "../types" + +/** + * A choice counts as answered unless it is the catch-all, which also needs its free text. + * Mirrors IsSelectionComplete in CareerSelectionService.cs. + */ +function isSelectionComplete(option: CareerDropdownOption | null, otherText: string | null): boolean { + return option !== null && (!option.isOther || Boolean(otherText?.trim())) +} + +/** + * The fields the form still wants an answer for, in the order they appear on the page. + */ +function missingFieldLabels(studentInfo: StudentInfo): string[] { + const checks: [complete: boolean, label: string][] = [ + [isSelectionComplete(studentInfo.direction, studentInfo.directionOther), "Career Direction"], + [isSelectionComplete(studentInfo.primaryFocus, studentInfo.primaryFocusOther), "Primary Focus"], + // Optional field, but still flag in the warning. + [isSelectionComplete(studentInfo.secondaryFocus, studentInfo.secondaryFocusOther), "Secondary Focus"], + // Post-graduation plans lack their own Other field and use short term plans instead. + [isSelectionComplete(studentInfo.postGrad, studentInfo.shortTermPlans), "Post-Graduation Plans"], + [Boolean(studentInfo.shortTermPlans?.trim()), "Short Term Plans"], + [Boolean(studentInfo.longTermPlans?.trim()), "Long Term Plans"], + ] + return checks.filter(([complete]) => !complete).map(([, label]) => label) +} + +export { isSelectionComplete, missingFieldLabels } diff --git a/VueApp/src/Students/CareerSelection/utils/career-fields.ts b/VueApp/src/Students/CareerSelection/utils/career-fields.ts new file mode 100644 index 000000000..a578d1126 --- /dev/null +++ b/VueApp/src/Students/CareerSelection/utils/career-fields.ts @@ -0,0 +1,66 @@ +import type { StudentCareerListItem, StudentCareerReport } from "../types" + +/** + * The career selection fields the overview and report show, in column order. + */ +type CareerField = { + name: string // Column name, which is also the table's body-cell slot name. + label: string // Column header and mobile card caption. + valueField: keyof StudentCareerReport // The report's value for this field. + completedField?: keyof StudentCareerListItem // The overview's completeness flag, for non-text fields. + tooltipLabel?: string // Fuller name for the overview's completeness icon, which is also its accessible label. + optional?: boolean // Unanswered is not flagged as missing on the overview. + statement?: boolean // Free-text statement, shown as an excerpt on the report. +} + +const CAREER_FIELDS: CareerField[] = [ + { + name: "direction", + label: "Career", + valueField: "direction", + completedField: "directionCompleted", + tooltipLabel: "Career Direction", + }, + { + name: "primarySpecies", + label: "Species 1", + valueField: "primaryFocus", + completedField: "primaryFocusCompleted", + tooltipLabel: "Primary Focus", + }, + { + name: "secondarySpecies", + label: "Species 2", + valueField: "secondaryFocus", + completedField: "secondaryFocusCompleted", + tooltipLabel: "Secondary Focus", + optional: true, + }, + { + name: "postGrad", + label: "Post Grad", + valueField: "postGrad", + completedField: "postGradCompleted", + tooltipLabel: "Post-Graduation Plans", + }, + { name: "mentor", label: "Mentor", valueField: "mentorName" }, + { + name: "shortTerm", + label: "Short Term", + valueField: "shortTermPlans", + completedField: "shortTermPlansCompleted", + tooltipLabel: "Short Term Plans", + statement: true, + }, + { + name: "longTerm", + label: "Long Term", + valueField: "longTermPlans", + completedField: "longTermPlansCompleted", + tooltipLabel: "Long Term Plans", + statement: true, + }, +] + +export { CAREER_FIELDS } +export type { CareerField } diff --git a/VueApp/src/Students/EmergencyContact/components/AppAccessControls.vue b/VueApp/src/Students/EmergencyContact/components/AppAccessControls.vue deleted file mode 100644 index 3d4134642..000000000 --- a/VueApp/src/Students/EmergencyContact/components/AppAccessControls.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - diff --git a/VueApp/src/Students/EmergencyContact/components/EmergencyContactPageShell.vue b/VueApp/src/Students/EmergencyContact/components/EmergencyContactPageShell.vue deleted file mode 100644 index ca0002d44..000000000 --- a/VueApp/src/Students/EmergencyContact/components/EmergencyContactPageShell.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/VueApp/src/Students/EmergencyContact/constants/record-page.ts b/VueApp/src/Students/EmergencyContact/constants/record-page.ts new file mode 100644 index 000000000..c5aeaf9bf --- /dev/null +++ b/VueApp/src/Students/EmergencyContact/constants/record-page.ts @@ -0,0 +1,11 @@ +/** + * Labels for StudentRecordPageShell on the emergency contact view and edit pages. + */ + +const EMERGENCY_CONTACT_RECORD_PAGE = { + appLabel: "Emergency Contacts", + listRouteName: "EmergencyContactList", + recordLabel: "contact", +} as const + +export { EMERGENCY_CONTACT_RECORD_PAGE } diff --git a/VueApp/src/Students/EmergencyContact/pages/EmergencyContactForm.vue b/VueApp/src/Students/EmergencyContact/pages/EmergencyContactForm.vue index 64bf4096a..9f871ef3f 100644 --- a/VueApp/src/Students/EmergencyContact/pages/EmergencyContactForm.vue +++ b/VueApp/src/Students/EmergencyContact/pages/EmergencyContactForm.vue @@ -1,15 +1,17 @@ diff --git a/VueApp/src/Students/components/StudentEmail.vue b/VueApp/src/Students/components/StudentEmail.vue new file mode 100644 index 000000000..b1e322d90 --- /dev/null +++ b/VueApp/src/Students/components/StudentEmail.vue @@ -0,0 +1,16 @@ + + + diff --git a/VueApp/src/Students/components/StudentRecordLink.vue b/VueApp/src/Students/components/StudentRecordLink.vue new file mode 100644 index 000000000..d959f3226 --- /dev/null +++ b/VueApp/src/Students/components/StudentRecordLink.vue @@ -0,0 +1,59 @@ + + + diff --git a/VueApp/src/Students/components/StudentRecordPageShell.vue b/VueApp/src/Students/components/StudentRecordPageShell.vue new file mode 100644 index 000000000..bb548b829 --- /dev/null +++ b/VueApp/src/Students/components/StudentRecordPageShell.vue @@ -0,0 +1,47 @@ + + + diff --git a/VueApp/src/Students/composables/use-report-exports.ts b/VueApp/src/Students/composables/use-report-exports.ts new file mode 100644 index 000000000..417f5583d --- /dev/null +++ b/VueApp/src/Students/composables/use-report-exports.ts @@ -0,0 +1,45 @@ +import type { Ref } from "vue" +import { useQuasar } from "quasar" + +type ReportExports = { + /** Downloads the workbook, returning false when the server had nothing to export. */ + downloadExcel: () => Promise + /** Opens the PDF in a new tab. */ + openPdf: () => void + /** Downloads the CSV. Left out by a report that serves no CSV, which then shows no CSV button. */ + downloadCsv?: () => Promise +} + +/** + * Export toolbar handlers for a student report or overview grid. The PDF opens in a new tab, so + * an empty grid is caught here rather than leaving the reader with a blank tab. + */ +export function useReportExports(rows: Ref, exports: ReportExports) { + const $q = useQuasar() + + async function download(fetchFile: () => Promise, label: string): Promise { + const success = await fetchFile() + if (success) { + $q.notify({ type: "positive", message: `${label} report downloaded.` }) + } else { + $q.notify({ type: "warning", message: "No data to export." }) + } + } + + async function handleExcelExport(): Promise { + await download(exports.downloadExcel, "Excel") + } + + const { downloadCsv } = exports + const handleCsvExport = downloadCsv === undefined ? undefined : () => download(downloadCsv, "CSV") + + function handlePdfExport(): void { + if (rows.value.length === 0) { + $q.notify({ type: "warning", message: "No data to export." }) + return + } + exports.openPdf() + } + + return { handleExcelExport, handlePdfExport, handleCsvExport } +} diff --git a/VueApp/src/Students/router/ensure-permissions.ts b/VueApp/src/Students/router/ensure-permissions.ts new file mode 100644 index 000000000..61cf0993c --- /dev/null +++ b/VueApp/src/Students/router/ensure-permissions.ts @@ -0,0 +1,41 @@ +import { useFetch } from "@/composables/ViperFetch" +import { useUserStore } from "@/store/UserStore" + +// In-flight latch: dedups concurrent navigations but resets after each attempt so later +// sessions (e.g. re-auth into an SIS role) can re-fetch instead of reusing a stale resolution. +const permissionLoads = new Map>() + +async function loadPermissions(prefix: string) { + try { + const userStore = useUserStore() + const { get } = useFetch() + const apiUrl = import.meta.env.VITE_API_URL + const perms = await get(`${apiUrl}loggedInUser/permissions?prefix=${prefix}`) + if (perms.success && Array.isArray(perms.result)) { + userStore.addPermissions(perms.result) + } + } finally { + permissionLoads.delete(prefix) + } +} + +/** + * Loads the permissions of one area, which requireLogin does not cover, unless the user already + * holds some. Concurrent navigations share the one request. + */ +async function ensurePermissions(prefix: string): Promise { + const userStore = useUserStore() + const existingPermissions = userStore.userInfo?.permissions ?? [] + if (existingPermissions.some((p: string) => p.startsWith(prefix))) { + return + } + + let load = permissionLoads.get(prefix) + if (!load) { + load = loadPermissions(prefix) + permissionLoads.set(prefix, load) + } + await load +} + +export { ensurePermissions } diff --git a/VueApp/src/Students/router/index.ts b/VueApp/src/Students/router/index.ts index f7d428856..d3dc0b217 100644 --- a/VueApp/src/Students/router/index.ts +++ b/VueApp/src/Students/router/index.ts @@ -2,30 +2,12 @@ import { createSpaRouter } from "@/shared/create-spa-router" import { routes } from "./routes" import { useRequireLogin } from "@/composables/RequireLogin" import { checkHasOnePermission } from "@/composables/CheckPagePermission" -import { useFetch } from "@/composables/ViperFetch" import { useUserStore } from "@/store/UserStore" +import { ensurePermissions } from "./ensure-permissions" +import { CAREER_SELECTION_PERMISSION_PREFIX } from "@/Students/CareerSelection/constants/permissions" const router = createSpaRouter(routes) -// In-flight latch: dedups concurrent navigations but resets after each attempt so later -// sessions (e.g. re-auth into an SIS role) can re-fetch instead of reusing a stale resolution. -let sisPermissionsPromise: Promise | null = null - -async function loadSisPermissions() { - try { - const userStore = useUserStore() - const { get } = useFetch() - const apiUrl = import.meta.env.VITE_API_URL - const sisPerms = await get(`${apiUrl}loggedInUser/permissions?prefix=SVMSecure.SIS`) - if (sisPerms.success && Array.isArray(sisPerms.result)) { - const currentPermissions = userStore.userInfo?.permissions ?? [] - userStore.setPermissions([...currentPermissions, ...sisPerms.result]) - } - } finally { - sisPermissionsPromise = null - } -} - router.beforeEach(async (to, from) => { const userStore = useUserStore() @@ -40,16 +22,11 @@ router.beforeEach(async (to, from) => { return false } - // SIS permissions are in a separate area, so they aren't loaded by requireLogin. - // Emergency Contact routes grant access via SVMSecure.SIS.AllStudents. - const existingPermissions = userStore.userInfo?.permissions ?? [] - const hasSisPermissions = existingPermissions.some((p: string) => p.startsWith("SVMSecure.SIS")) - if (!hasSisPermissions) { - if (!sisPermissionsPromise) { - sisPermissionsPromise = loadSisPermissions() - } - await sisPermissionsPromise - } + // Emergency Contact routes grant access via SVMSecure.SIS.AllStudents, and Career + // Selection routes via SVMSecure.CareerSelection; both areas are outside requireLogin. + // Fetched together rather than in turn, to save a round trip: the store merges each + // area's result into what is already held, so the order they arrive in does not matter. + await Promise.all([ensurePermissions("SVMSecure.SIS"), ensurePermissions(CAREER_SELECTION_PERMISSION_PREFIX)]) } if (to.meta.permissions !== undefined) { diff --git a/VueApp/src/Students/router/routes.ts b/VueApp/src/Students/router/routes.ts index ea3f1b1cb..a1c8894b5 100644 --- a/VueApp/src/Students/router/routes.ts +++ b/VueApp/src/Students/router/routes.ts @@ -2,6 +2,8 @@ import type { RouteLocationNormalized } from "vue-router" import ViperLayout from "@/layouts/ViperLayout.vue" import { checkHasOnePermission } from "@/composables/CheckPagePermission" import { useUserStore } from "@/store/UserStore" +import { careerSelectionGuards } from "@/Students/CareerSelection/router/career-selection-guards" +import { CAREER_SELECTION_PERMISSIONS } from "@/Students/CareerSelection/constants/permissions" const adminPermissions = ["SVMSecure.Students.EmergencyContactAdmin", "SVMSecure.SIS.AllStudents"] const editPermissions = ["SVMSecure.Students.EmergencyContactAdmin", "SVMSecure.Students.EmergencyContactStudent"] @@ -118,6 +120,57 @@ const routes = [ }, ], }, + { + path: "/Students/CareerSelection/", + meta: { + layout: ViperLayout, + }, + children: [ + { + path: "", + name: "CareerSelectionList", + meta: { layout: ViperLayout }, + beforeEnter: careerSelectionGuards.list, + component: () => import("@/Students/CareerSelection/pages/CareerSelectionList.vue"), + }, + { + path: "edit/:pidm", + name: "CareerSelectionEdit", + beforeEnter: careerSelectionGuards.edit, + meta: { layout: ViperLayout }, + component: () => import("@/Students/CareerSelection/pages/CareerSelectionForm.vue"), + }, + { + path: "view/:pidm", + name: "CareerSelectionView", + beforeEnter: careerSelectionGuards.view, + meta: { layout: ViperLayout }, + component: () => import("@/Students/CareerSelection/pages/CareerSelectionView.vue"), + }, + { + path: "report", + name: "CareerSelectionReport", + meta: { + layout: ViperLayout, + permissions: [ + CAREER_SELECTION_PERMISSIONS.ADMIN, + CAREER_SELECTION_PERMISSIONS.READ_ONLY, + CAREER_SELECTION_PERMISSIONS.FACULTY, + ], + }, + component: () => import("@/Students/CareerSelection/pages/CareerSelectionReport.vue"), + }, + { + path: "options", + name: "CareerSelectionManageOptions", + meta: { + layout: ViperLayout, + permissions: [CAREER_SELECTION_PERMISSIONS.ADMIN], + }, + component: () => import("@/Students/CareerSelection/pages/CareerSelectionManageOptions.vue"), + }, + ], + }, { path: "/:catchAll(.*)*", meta: { layout: ViperLayout }, diff --git a/VueApp/src/Students/services/student-app-service.ts b/VueApp/src/Students/services/student-app-service.ts new file mode 100644 index 000000000..6b41f7ec5 --- /dev/null +++ b/VueApp/src/Students/services/student-app-service.ts @@ -0,0 +1,77 @@ +import { useFetch, postForBlob, downloadBlob } from "@/composables/ViperFetch" + +// Every member below is called from the pages, but always through a subclass instance +// (`careerSelectionService.downloadExcel`, `emergencyContactService.getAccessStatus`). fallow +// resolves those against the subclass without linking them back to the declaration on this +// abstract base, so it reports the whole shared surface as unused. That surface is all this +// file holds, which leaves the rule nothing useful to say about it. +// fallow-ignore-file unused-class-member + +/** Filenames to save the exports under when the response does not name the file. */ +type StudentAppExportFilenames = { + overviewExcel: string + excel: string +} + +/** + * The endpoints every student self-service app (emergency contacts, career selection) serves under + * its own base URL: app-wide student access, and the overview and full-detail exports. + */ +abstract class StudentAppService { + protected readonly baseUrl: string + private readonly exportFilenames: StudentAppExportFilenames + + /** @param apiPath The app's path below the API root, e.g. "students/career-selection". */ + constructor(apiPath: string, exportFilenames: StudentAppExportFilenames) { + this.baseUrl = `${import.meta.env.VITE_API_URL}${apiPath}` + this.exportFilenames = exportFilenames + } + + /** + * Returns null on a failed request. The result is checked against null rather than for + * truthiness, because an app whose status is a bare boolean reports "closed" as false. + */ + getAccessStatus = async (): Promise => { + const { get } = useFetch() + const response = await get(`${this.baseUrl}/access/status`) + if (!response.success || response.result === null || response.result === undefined) { + return null + } + return response.result as TAccessStatus + } + + /** Returns whether the app is now open, or null on a failed request. */ + toggleAppAccess = async (): Promise => { + const { post } = useFetch() + const response = await post(`${this.baseUrl}/access/toggle-app`) + if (!response.success || response.result === null || response.result === undefined) { + return null + } + return response.result as boolean + } + + downloadOverviewExcel = (): Promise => + this.downloadExportFile("export/overview/excel", this.exportFilenames.overviewExcel) + + openOverviewPdf = (): void => { + globalThis.open(`${this.baseUrl}/export/overview/pdf`, "_blank", "noopener") + } + + downloadExcel = (): Promise => this.downloadExportFile("export/excel", this.exportFilenames.excel) + + openPdf = (): void => { + globalThis.open(`${this.baseUrl}/export/pdf`, "_blank", "noopener") + } + + /** POSTs for an export file and saves it, or returns false when the server had nothing to export. */ + protected async downloadExportFile(path: string, fallbackFilename: string): Promise { + const { blob, filename } = await postForBlob(`${this.baseUrl}/${path}`, {}) + if (blob.size === 0) { + return false + } + downloadBlob(blob, filename ?? fallbackFilename) + return true + } +} + +export { StudentAppService } diff --git a/VueApp/src/components/ColumnToggle.vue b/VueApp/src/components/ColumnToggle.vue new file mode 100644 index 000000000..9a5a5e818 --- /dev/null +++ b/VueApp/src/components/ColumnToggle.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/VueApp/src/components/ExportToolbar.vue b/VueApp/src/components/ExportToolbar.vue index 7fe4c8e15..da7d11657 100644 --- a/VueApp/src/components/ExportToolbar.vue +++ b/VueApp/src/components/ExportToolbar.vue @@ -1,6 +1,7 @@