From 0e680469219ebb656f5e4da74130d0e9e1bc1b04 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 27 Jul 2026 15:50:18 -0700 Subject: [PATCH 01/11] fix(app-webdir-ui): fix nav controls and anon image for webdir --- packages/app-webdir-ui/.storybook/main.js | 1 + packages/app-webdir-ui/.storybook/preview.js | 10 + packages/app-webdir-ui/package.json | 7 + .../app-webdir-ui/public/mockServiceWorker.js | 349 ++++++++++++++++++ .../src/FacultyRankComponent/index.stories.js | 2 + .../app-webdir-ui/src/ProfileCard/index.js | 12 +- .../src/ProfileCard/index.test.js | 58 +++ .../WebDirectoryComponent/index.stories.js | 2 + .../src/helpers/Filter/index.test.js | 97 +++++ .../src/helpers/webDirectoryMockHandlers.js | 97 +++++ .../src/helpers/webDirectoryMockProfiles.js | 256 +++++++++++++ yarn.lock | 45 +-- 12 files changed, 900 insertions(+), 36 deletions(-) create mode 100644 packages/app-webdir-ui/public/mockServiceWorker.js create mode 100644 packages/app-webdir-ui/src/ProfileCard/index.test.js create mode 100644 packages/app-webdir-ui/src/helpers/Filter/index.test.js create mode 100644 packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js create mode 100644 packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js diff --git a/packages/app-webdir-ui/.storybook/main.js b/packages/app-webdir-ui/.storybook/main.js index 6d77b30767..1b98b1bf90 100644 --- a/packages/app-webdir-ui/.storybook/main.js +++ b/packages/app-webdir-ui/.storybook/main.js @@ -6,6 +6,7 @@ function getAbsolutePath(value) { } const config = { + staticDirs: ["../public"], addons: [ fileURLToPath(import.meta.resolve("../../../.storybook-config/index.js")), fileURLToPath(import.meta.resolve("../../../.storybook-config/dataLayerListener/index.js")), diff --git a/packages/app-webdir-ui/.storybook/preview.js b/packages/app-webdir-ui/.storybook/preview.js index c0af51ca71..75e76f263b 100644 --- a/packages/app-webdir-ui/.storybook/preview.js +++ b/packages/app-webdir-ui/.storybook/preview.js @@ -1,9 +1,18 @@ import React, { useEffect} from "react"; import { MemoryRouter, useLocation, useSearchParams } from "react-router-dom"; +import { initialize, mswLoader } from "msw-storybook-addon"; import { useArgs } from 'storybook/preview-api'; import "@asu/unity-bootstrap-theme/src/scss/unity-bootstrap-theme.bundle.scss"; +// The live Web Directory API blocks requests from localhost, so msw mocks +// those endpoints in Storybook. See src/helpers/webDirectoryMockHandlers.js. +initialize({ + serviceWorker: { + url: "./mockServiceWorker.js", + }, +}); + const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, }; @@ -67,6 +76,7 @@ const preview = { argTypes, args, decorators, + loaders: [mswLoader], }; export default preview; diff --git a/packages/app-webdir-ui/package.json b/packages/app-webdir-ui/package.json index fe3d1f565d..b001abda16 100644 --- a/packages/app-webdir-ui/package.json +++ b/packages/app-webdir-ui/package.json @@ -80,6 +80,8 @@ "jsdoc-to-markdown": "^9.0.0", "jsdoc-ts-utils": "^2.0.1", "jsdom-screenshot": "^4.0.0", + "msw": "^2.7.0", + "msw-storybook-addon": "^2.0.0", "postcss-loader": "^6.1.1", "raw-loader": "^4.0.2", "sass": "^1.39.2", @@ -98,5 +100,10 @@ }, "volta": { "extends": "../../package.json" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/packages/app-webdir-ui/public/mockServiceWorker.js b/packages/app-webdir-ui/public/mockServiceWorker.js new file mode 100644 index 0000000000..33dde9e770 --- /dev/null +++ b/packages/app-webdir-ui/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js b/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js index 7767f4fb8e..1ac6a36a0f 100644 --- a/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js +++ b/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js @@ -2,9 +2,11 @@ import React from "react"; import { FullLayout } from "@asu/shared"; import { WebDirectory } from "../WebDirectoryComponent/index"; +import { webDirectoryHandlers } from "../helpers/webDirectoryMockHandlers"; export default { title: "Organisms/Web Directory/Templates", + parameters: { msw: { handlers: webDirectoryHandlers } }, decorators: [story => {story()}], }; diff --git a/packages/app-webdir-ui/src/ProfileCard/index.js b/packages/app-webdir-ui/src/ProfileCard/index.js index 77b30066fc..8642543315 100644 --- a/packages/app-webdir-ui/src/ProfileCard/index.js +++ b/packages/app-webdir-ui/src/ProfileCard/index.js @@ -12,6 +12,7 @@ import { profileCardType } from "./models"; * @param {string} [props.matchedAffiliationTitle] - The matched affiliation title of the user. * @param {string} [props.matchedAffiliationDept] - The matched affiliation department of the user. * @param {string} [props.imgURL] - The URL of the user's profile image. + * @param {string} [props.anonImgURL] - Fallback placeholder image URL used when `imgURL` is empty or fails to load. * @param {string} [props.profileURL] - The URL of the user's profile page. * @param {string} [props.email] - The email address of the user. * @param {string} [props.telephone] - The telephone number of the user. @@ -45,7 +46,14 @@ const ProfileCard = ({ ...props }) => { : ""; const hideNonExistantImages = e => { - e.target.style.display = "none"; + // Fall back to the anon placeholder image instead of hiding the image + // entirely. Guard against the anon image itself failing to load so we + // don't loop indefinitely + if (props.anonImgURL && e.target.src !== props.anonImgURL) { + e.target.src = props.anonImgURL; + } else { + e.target.style.display = "none"; + } }; let formattedTelephone = props.telephone; if (formattedTelephone) { @@ -76,7 +84,7 @@ const ProfileCard = ({ ...props }) => {
tag would be rendered without its "src" attribute, which is not a good practice and should be avoided. + src={props.imgURL || props.anonImgURL} alt={props.name} onError={hideNonExistantImages} /> diff --git a/packages/app-webdir-ui/src/ProfileCard/index.test.js b/packages/app-webdir-ui/src/ProfileCard/index.test.js new file mode 100644 index 0000000000..7ad0c4c1ca --- /dev/null +++ b/packages/app-webdir-ui/src/ProfileCard/index.test.js @@ -0,0 +1,58 @@ +import React from "react"; + +import { fireEvent, render, screen } from "@testing-library/react"; + +import { ProfileCard } from "./index"; + +const IMG_URL = "https://example.com/photo.jpg"; +const ANON_IMG_URL = "https://example.com/anon.png"; + +describe("ProfileCard", () => { + it("uses anonImgURL as a fallback when imgURL is empty", () => { + render( + + ); + + expect(screen.getByAltText("Morgan Denke")).toHaveAttribute( + "src", + ANON_IMG_URL + ); + }); + + it("swaps to anonImgURL when the profile image fails to load", () => { + render( + + ); + + const img = screen.getByAltText("Morgan Denke"); + expect(img).toHaveAttribute("src", IMG_URL); + + fireEvent.error(img); + + expect(img).toHaveAttribute("src", ANON_IMG_URL); + expect(img).toBeVisible(); + }); + + it("hides the image instead of looping when the anon image itself fails to load", () => { + render( + + ); + + const img = screen.getByAltText("Morgan Denke"); + fireEvent.error(img); + expect(img).toHaveAttribute("src", ANON_IMG_URL); + + fireEvent.error(img); + + expect(img).toHaveAttribute("src", ANON_IMG_URL); + expect(img).not.toBeVisible(); + }); +}); diff --git a/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js b/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js index 4786c236bd..85f9df48b6 100644 --- a/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js +++ b/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js @@ -3,6 +3,7 @@ import React from "react"; import { WebDirectory } from "./index"; import { FullLayout } from "@asu/shared"; +import { webDirectoryHandlers } from "../helpers/webDirectoryMockHandlers"; export default { title: "Organisms/Web Directory/Templates", @@ -18,6 +19,7 @@ export default { }, }, args: { alphaFilter: "false" }, + parameters: { msw: { handlers: webDirectoryHandlers } }, decorators: [story => {story()}], }; diff --git a/packages/app-webdir-ui/src/helpers/Filter/index.test.js b/packages/app-webdir-ui/src/helpers/Filter/index.test.js new file mode 100644 index 0000000000..a4bab990b8 --- /dev/null +++ b/packages/app-webdir-ui/src/helpers/Filter/index.test.js @@ -0,0 +1,97 @@ +import React from "react"; + +import { fireEvent, render, screen } from "@testing-library/react"; + +import { FilterComponent } from "./index"; + +const CHOICES = ["A", "B", "C", "D", "E", "F", "G"]; + +/** + * jsdom never actually lays out content, so `scrollWidth`/`offsetWidth` + * default to 0. Mock them on the rendered `.choices-container` and fire the + * events `FilterComponent` listens for, mirroring what a real overflowing + * browser layout would report. + */ +const mockScrollableContainer = ({ scrollWidth, offsetWidth }) => { + const container = screen.getByRole("radiogroup"); + Object.defineProperty(container, "scrollWidth", { + configurable: true, + value: scrollWidth, + }); + Object.defineProperty(container, "offsetWidth", { + configurable: true, + value: offsetWidth, + }); + Object.defineProperty(container, "clientWidth", { + configurable: true, + value: offsetWidth, + }); + container.scrollTo = jest.fn(); + fireEvent(container, new Event("resize")); + return container; +}; + +describe("FilterComponent nav controls", () => { + it("renders the prev/next controls with the classes their CSS depends on", () => { + render( + + ); + const container = mockScrollableContainer({ + scrollWidth: 600, + offsetWidth: 300, + }); + + // Nothing scrolled yet: only the "next" control should be visible. + expect( + document.querySelector(".scroll-control-next .carousel-control-next-icon") + ).toBeInTheDocument(); + expect(document.querySelector(".scroll-control-prev")).not.toBeInTheDocument(); + + // Simulate having scrolled all the way over: "prev" appears, "next" hides. + Object.defineProperty(container, "scrollLeft", { + configurable: true, + value: 300, + }); + fireEvent.scroll(container); + + expect( + document.querySelector(".scroll-control-prev .carousel-control-prev-icon") + ).toBeInTheDocument(); + expect(document.querySelector(".scroll-control-next")).not.toBeInTheDocument(); + }); + + it("scrolls the choices container when the next control is clicked", () => { + render( + + ); + const container = mockScrollableContainer({ + scrollWidth: 600, + offsetWidth: 300, + }); + + fireEvent.click(document.querySelector(".scroll-control-next")); + + expect(container.scrollTo).toHaveBeenCalledWith({ + left: 200, + behavior: "smooth", + }); + }); + + it("clamps the scroll position to the container's max scrollable width", () => { + render( + + ); + const container = mockScrollableContainer({ + scrollWidth: 350, + offsetWidth: 300, + }); + + fireEvent.click(document.querySelector(".scroll-control-next")); + + // maxScrollLeft is only 50, even though a "next" click nominally asks for +200. + expect(container.scrollTo).toHaveBeenCalledWith({ + left: 50, + behavior: "smooth", + }); + }); +}); diff --git a/packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js b/packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js new file mode 100644 index 0000000000..e4f75758d9 --- /dev/null +++ b/packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js @@ -0,0 +1,97 @@ +// @ts-check +import { http, HttpResponse } from "msw"; + +import { + getMockProfileByAsuriteId, + mockDirectoryProfiles, +} from "./webDirectoryMockProfiles"; + +const MOCK_CSRF_TOKEN = "mock-csrf-token"; + +/** + * Paginates the mock profile pool the same way the real + * `webdir-profiles/faculty-staff/filtered` endpoint would, and wraps it in + * the `{ meta: { page }, results }` envelope expected by `helpers/search.js`. + * When a `rank_group` param is present (used by `FacultyRankComponent`'s + * tabs), only profiles seeded with a matching `rankGroup` are included, so + * each tab shows distinct data instead of the same full pool. + * @param {URL} url + */ +function buildFilteredProfilesResponse(url) { + const page = Number(url.searchParams.get("page")) || 1; + const size = Number(url.searchParams.get("size")) || 6; + const rankGroup = url.searchParams.get("rank_group"); + const pool = rankGroup + ? mockDirectoryProfiles.filter( + profile => profile.rank_group.raw === rankGroup + ) + : mockDirectoryProfiles; + const start = (page - 1) * size; + const results = pool.slice(start, start + size); + + return { + meta: { + request_id: "mock-request-id", + page: { + current: page, + total_pages: Math.ceil(pool.length / size), + total_results: pool.length, + size, + }, + }, + results, + }; +} + +/** + * Handles the POST `webdir-profiles/department` endpoint, which is used both + * for the "people"/"people_departments" Web Directory search (`full_records: + * true`) and for enriching GET results with title/department info + * (`full_records: false`). + * @param {{ full_records?: boolean, profiles?: { asurite_id: string, dept_id?: string }[] }} body + */ +function buildDepartmentProfilesResponse(body) { + const profiles = body.profiles || []; + + if (!body.full_records) { + return profiles.map(profile => { + const mock = getMockProfileByAsuriteId(profile.asurite_id); + return { + title: mock.titles.raw, + dept_name: mock.departments.raw[0], + }; + }); + } + + return profiles.map((profile, index) => { + const fullRecord = getMockProfileByAsuriteId(profile.asurite_id, index); + return { + asurite_id: profile.asurite_id, + dept_id: profile.dept_id, + ...(index === 0 ? { total_results: profiles.length } : {}), + full_record: fullRecord, + }; + }); +} + +/** + * MSW request handlers for the Web Directory endpoints hit by + * `WebDirectoryComponent` and `FacultyRankComponent` stories. Registered via + * each story's `parameters.msw.handlers`. + */ +export const webDirectoryHandlers = [ + http.get("*/session/token", () => HttpResponse.text(MOCK_CSRF_TOKEN)), + + http.get("*/webdir-profiles/faculty-staff/filtered", ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json(buildFilteredProfilesResponse(url)); + }), + + http.post("*/webdir-profiles/department", async ({ request }) => { + const body = + /** @type {{ full_records?: boolean, profiles?: { asurite_id: string, dept_id?: string }[] }} */ ( + await request.json() + ); + return HttpResponse.json(buildDepartmentProfilesResponse(body)); + }), +]; diff --git a/packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js b/packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js new file mode 100644 index 0000000000..99720010c5 --- /dev/null +++ b/packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js @@ -0,0 +1,256 @@ +// @ts-check +/** + * Mock profile "seed" data used to build fake Web Directory API responses for + * Storybook (via msw). The real `webdir-profiles/*` endpoints block requests + * coming from localhost, so these fixtures let the Web Directory stories keep + * working without hitting the live API. + */ + +/** + * @typedef {Object} MockProfileSeed + * @property {string} asuriteId + * @property {string} eid + * @property {string} deptId + * @property {string} displayName + * @property {string} firstName + * @property {string} lastName + * @property {string[]} titles + * @property {string} deptName + * @property {string} email + * @property {string} phone + * @property {"1"|"2"|"3"|null} [rankGroup] - Matches `FacultyRankComponent`'s + * `rank_group` filter (1=Faculty, 2=Academic Professionals, 3=Other), or + * `null`/omitted for profiles outside those groups. + */ + +/** @type {MockProfileSeed[]} */ +const mockProfileSeeds = [ + { + asuriteId: "mcrow", + eid: "454517", + deptId: "1350", + displayName: "Michael Crow", + firstName: "Michael", + lastName: "Crow", + titles: ["President"], + deptName: "Office of the President", + email: "michael.crow@asu.edu", + phone: "480/965-1234", + // Not a Faculty/Academic Professional rank group; excluded from Faculty Rank tabs. + rankGroup: null, + }, + { + asuriteId: "mdenke", + eid: "1350001", + deptId: "1350", + displayName: "Morgan Denke", + firstName: "Morgan", + lastName: "Denke", + titles: ["Associate Professor"], + deptName: "School of Life Sciences", + email: "morgan.denke@asu.edu", + phone: "480/965-2345", + rankGroup: "1", // Faculty + }, + { + asuriteId: "jagarc50", + eid: "1350002", + deptId: "1350", + displayName: "Javier Garcia", + firstName: "Javier", + lastName: "Garcia", + titles: ["Lecturer"], + deptName: "School of Life Sciences", + email: "javier.garcia@asu.edu", + phone: "480/965-3456", + rankGroup: "1", // Faculty + }, + { + asuriteId: "lhillzev", + eid: "1353001", + deptId: "1353", + displayName: "Lena Hillzev", + firstName: "Lena", + lastName: "Hillzev", + titles: ["Administrative Assistant"], + deptName: "College of Health Solutions", + email: "lena.hillzev@asu.edu", + phone: "480/965-4567", + rankGroup: "3", // Other Faculty and Academic Professionals + }, + { + asuriteId: "tgrandli", + eid: "1344001", + deptId: "1344", + displayName: "Taylor Grandli", + firstName: "Taylor", + lastName: "Grandli", + titles: ["Academic Advisor"], + deptName: "W. P. Carey School of Business", + email: "taylor.grandli@asu.edu", + phone: "480/965-5678", + rankGroup: "2", // Academic Professionals + }, + { + asuriteId: "jcunnin8", + eid: "1358001", + deptId: "1358", + displayName: "Jordan Cunningham", + firstName: "Jordan", + lastName: "Cunningham", + titles: ["Professor"], + deptName: "Ira A. Fulton Schools of Engineering", + email: "jordan.cunningham@asu.edu", + phone: "480/965-6789", + rankGroup: "1", // Faculty + }, + { + asuriteId: "ccherrer", + eid: "1358002", + deptId: "1358", + displayName: "Casey Cherrer", + firstName: "Casey", + lastName: "Cherrer", + titles: ["Assistant Professor"], + deptName: "Ira A. Fulton Schools of Engineering", + email: "casey.cherrer@asu.edu", + phone: "480/965-7890", + rankGroup: "1", // Faculty + }, + { + asuriteId: "csmudde", + eid: "1358003", + deptId: "1358", + displayName: "Charlie Smudde", + firstName: "Charlie", + lastName: "Smudde", + titles: ["Research Scientist"], + deptName: "Ira A. Fulton Schools of Engineering", + email: "charlie.smudde@asu.edu", + phone: "480/965-8901", + rankGroup: "2", // Academic Professionals + }, + { + asuriteId: "abarnett", + eid: "1349001", + deptId: "1349", + displayName: "Alex Barnett", + firstName: "Alex", + lastName: "Barnett", + titles: ["Faculty"], + deptName: "College of Global Futures", + email: "alex.barnett@asu.edu", + phone: "480/965-9012", + rankGroup: "3", // Other Faculty and Academic Professionals + }, + { + asuriteId: "rjmoreno", + eid: "1518001", + deptId: "1518", + displayName: "Riley Moreno", + firstName: "Riley", + lastName: "Moreno", + titles: ["Staff"], + deptName: "Herberger Institute for Design and the Arts", + email: "riley.moreno@asu.edu", + phone: "480/965-0123", + rankGroup: null, + }, + { + asuriteId: "spatel12", + eid: "1520001", + deptId: "1520", + displayName: "Sam Patel", + firstName: "Sam", + lastName: "Patel", + titles: ["Professor"], + deptName: "Watts College of Public Service and Community Solutions", + email: "sam.patel@asu.edu", + phone: "480/965-1122", + rankGroup: "1", // Faculty + }, + { + asuriteId: "ekowalsk", + eid: "3534001", + deptId: "3534", + displayName: "Emerson Kowalski", + firstName: "Emerson", + lastName: "Kowalski", + titles: ["Associate Dean"], + deptName: "Mary Lou Fulton Teachers College", + email: "emerson.kowalski@asu.edu", + phone: "480/965-2233", + rankGroup: "2", // Academic Professionals + }, +]; + +/** + * Builds a Web Directory search-API-shaped raw record from a mock profile seed. + * @param {MockProfileSeed} seed + * @param {number} index + * @returns {Record} + */ +function toRawProfile(seed, index) { + return { + id: { raw: `mock-${seed.asuriteId}` }, + asurite_id: { raw: seed.asuriteId }, + eid: { raw: seed.eid }, + deptids: { raw: [seed.deptId] }, + display_name: { raw: seed.displayName }, + first_name: { raw: seed.firstName }, + last_name: { raw: seed.lastName }, + titles: { raw: seed.titles }, + departments: { raw: [seed.deptName] }, + email_address: { raw: seed.email }, + phone: { raw: seed.phone }, + rank_group: { raw: seed.rankGroup ?? null }, + campus_address: { raw: "ASU Tempe Campus" }, + city: { raw: "Tempe" }, + state: { raw: "AZ" }, + photo_url: { + raw: `https://source.unsplash.com/random/400x400?sig=${index}`, + }, + bio: { raw: `${seed.displayName} is a member of ${seed.deptName}.` }, + short_bio: { raw: `${seed.titles[0]} in ${seed.deptName}` }, + facebook: { raw: null }, + linkedin: { raw: null }, + twitter: { raw: null }, + website: { raw: "" }, + _meta: { + engine: "web-dir-faculty-staff", + score: 5, + id: `mock-${seed.asuriteId}`, + }, + }; +} + +export const mockDirectoryProfiles = mockProfileSeeds.map(toRawProfile); + +/** + * Looks up (or falls back to generating) a mock raw profile for the given + * asurite ID, so POST payloads referencing arbitrary IDs still resolve. + * @param {string} asuriteId + * @param {number} index + * @returns {Record} + */ +export function getMockProfileByAsuriteId(asuriteId, index = 0) { + const found = mockProfileSeeds.find(seed => seed.asuriteId === asuriteId); + if (found) { + return toRawProfile(found, index); + } + return toRawProfile( + { + asuriteId, + eid: "0", + deptId: "1350", + displayName: asuriteId, + firstName: asuriteId, + lastName: "", + titles: ["Staff"], + deptName: "Arizona State University", + email: `${asuriteId}@asu.edu`, + phone: "480/965-0000", + }, + index + ); +} diff --git a/yarn.lock b/yarn.lock index ac5ddc5da8..9758a3d106 100644 --- a/yarn.lock +++ b/yarn.lock @@ -66,7 +66,7 @@ __metadata: resolution: "@asu/app-degree-pages@workspace:packages/app-degree-pages" dependencies: "@asu/shared": "npm:*" - "@asu/unity-react-core": "npm:^1.0.0" + "@asu/unity-react-core": "npm:^2.0.0" "@babel/core": "npm:^7.13.14" "@babel/plugin-syntax-jsx": "npm:^7.14.5" "@babel/plugin-transform-react-jsx": "npm:^7.13.12" @@ -118,8 +118,8 @@ __metadata: resolution: "@asu/app-rfi@workspace:packages/app-rfi" dependencies: "@asu/shared": "npm:*" - "@asu/unity-bootstrap-theme": "npm:^1.0.0" - "@asu/unity-react-core": "npm:^1.0.0" + "@asu/unity-bootstrap-theme": "npm:*" + "@asu/unity-react-core": "npm:^2.0.0" "@babel/core": "npm:^7.13.14" "@babel/eslint-parser": "npm:^7.13.14" "@babel/plugin-proposal-class-properties": "npm:^7.13.0" @@ -203,6 +203,8 @@ __metadata: jsdoc-to-markdown: "npm:^9.0.0" jsdoc-ts-utils: "npm:^2.0.1" jsdom-screenshot: "npm:^4.0.0" + msw: "npm:^2.7.0" + msw-storybook-addon: "npm:^2.0.0" postcss-loader: "npm:^6.1.1" prop-types: "npm:^15.7.2" raw-loader: "npm:^4.0.2" @@ -227,7 +229,7 @@ __metadata: version: 0.0.0-use.local resolution: "@asu/component-events@workspace:packages/component-events" dependencies: - "@asu/unity-react-core": "npm:^1.0.0" + "@asu/unity-react-core": "npm:^2.0.0" "@babel/core": "npm:^7.13.14" "@babel/plugin-syntax-jsx": "npm:^7.14.5" "@babel/plugin-transform-runtime": "npm:^7.14.5" @@ -335,7 +337,7 @@ __metadata: version: 0.0.0-use.local resolution: "@asu/component-news@workspace:packages/component-news" dependencies: - "@asu/unity-react-core": "npm:^1.0.0" + "@asu/unity-react-core": "npm:^2.0.0" "@babel/core": "npm:^7.13.14" "@babel/plugin-syntax-jsx": "npm:^7.14.5" "@babel/plugin-transform-react-jsx": "npm:^7.13.12" @@ -406,7 +408,7 @@ __metadata: resolution: "@asu/static-site@workspace:packages/static-site" dependencies: "@asu/component-header-footer": "npm:^1.0.0" - "@asu/unity-bootstrap-theme": "npm:^1.20" + "@asu/unity-bootstrap-theme": "npm:^2.0.0" "@asu/unity-react-core": "npm:^2.0.0" "@fortawesome/fontawesome-svg-core": "npm:^6.4.2" "@fortawesome/free-brands-svg-icons": "npm:^6.4.2" @@ -428,16 +430,7 @@ __metadata: languageName: unknown linkType: soft -"@asu/unity-bootstrap-theme@npm:^1.0.0, @asu/unity-bootstrap-theme@npm:^1.20, @asu/unity-bootstrap-theme@npm:^1.21.3": - version: 1.39.7 - resolution: "@asu/unity-bootstrap-theme@npm:1.39.7::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40asu%2Funity-bootstrap-theme%2F1.39.7%2F3cd9a99aa2e5a872bf867247a19e19cb6ee73fab" - peerDependencies: - "@fortawesome/fontawesome-free": ^5.15.3 - checksum: 10c0/9eb5e02a944026c0206412778ecfaffb18ab9b566be957bb179f908a4268129823b4695104ab74a89da163d061694d07140910dd2075852da96052f69edc77f2 - languageName: node - linkType: hard - -"@asu/unity-bootstrap-theme@workspace:packages/unity-bootstrap-theme": +"@asu/unity-bootstrap-theme@npm:*, @asu/unity-bootstrap-theme@npm:^2.0.0, @asu/unity-bootstrap-theme@workspace:packages/unity-bootstrap-theme": version: 0.0.0-use.local resolution: "@asu/unity-bootstrap-theme@workspace:packages/unity-bootstrap-theme" dependencies: @@ -491,29 +484,13 @@ __metadata: languageName: unknown linkType: soft -"@asu/unity-react-core@npm:^1.0.0": - version: 1.11.4 - resolution: "@asu/unity-react-core@npm:1.11.4::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40asu%2Funity-react-core%2F1.11.4%2Fb75273d3986c565d7ac8fb20de6659accd130f3d" - dependencies: - "@glidejs/glide": "npm:^3.6.0" - react-share: "npm:^5.3" - peerDependencies: - "@asu/unity-bootstrap-theme": ^1.21.3 - "@fortawesome/fontawesome-free": ^5.15.3 - react: ^19.0.0 - react-dom: ^19.0.0 - react-router-dom: ">= 5.2.0 < 7" - checksum: 10c0/0116ad0df26c1ff7f003e8e0134dba074390428d1a23469f946316b635955c1c8f35a34adcb74b27f8721960c14de5176939f1a8587aa262f41b55825f2d9ca4 - languageName: node - linkType: hard - "@asu/unity-react-core@npm:^2.0.0, @asu/unity-react-core@workspace:packages/unity-react-core": version: 0.0.0-use.local resolution: "@asu/unity-react-core@workspace:packages/unity-react-core" dependencies: "@asu/component-header-footer": "npm:*" "@asu/shared": "npm:*" - "@asu/unity-bootstrap-theme": "npm:^1.21.3" + "@asu/unity-bootstrap-theme": "npm:*" "@babel/cli": "npm:^7.19.3" "@babel/core": "npm:^7.21.3" "@babel/plugin-transform-runtime": "npm:^7.19.6" @@ -566,7 +543,7 @@ __metadata: typescript: "npm:5.6.2" vite: "npm:^6.0.0" peerDependencies: - "@asu/unity-bootstrap-theme": ^1.21.3 + "@asu/unity-bootstrap-theme": ^2.0.0 "@fortawesome/fontawesome-free": ^5.15.3 react: ^19.0.0 react-dom: ^19.0.0 From cb41952b835e1da077d6e39333e410105a7ac352 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 27 Jul 2026 15:59:03 -0700 Subject: [PATCH 02/11] fix(unity-react-core): restore nav-control styles and stop bundling react-dom/server - Restore the .scroll-control-prev/-next and carousel-control-*-icon styles into NavControls.styles.js (co-located with the component instead of unity-bootstrap-theme). PR #1698's TabbedPanels redesign removed this CSS assuming it was dead code from the old carousel-based tabs, but NavControls is still used standalone by app-webdir-ui's Filter component ("Filter by Last Initial"), which regressed to unstyled buttons as a result. - Split getBootstrapHTML (react-dom/server, Storybook/dev-tooling only) out of useBaseSpecificFramework.js into its own file. Every component imports useBaseSpecificFramework at runtime, so bundling react-dom/server there was pulling server-rendering internals (MessageChannel, TextEncoder, etc.) into every consumer's published dist for no runtime benefit, and broke jsdom-based tests in app-webdir-ui. --- .../.storybook/decorators.tsx | 2 +- .../.storybook/renderToHTML.jsx | 2 +- packages/unity-react-core/package.json | 4 +- .../scripts/check-changes.tsx | 2 +- .../GaEventWrapper/getBootstrapHTML.js | 12 +++ .../useBaseSpecificFramework.js | 4 - .../components/NavControls.styles.js | 80 ++++++++++++++++++- 7 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 packages/unity-react-core/src/components/GaEventWrapper/getBootstrapHTML.js diff --git a/packages/unity-react-core/.storybook/decorators.tsx b/packages/unity-react-core/.storybook/decorators.tsx index 8308b03839..22d67aa1a6 100644 --- a/packages/unity-react-core/.storybook/decorators.tsx +++ b/packages/unity-react-core/.storybook/decorators.tsx @@ -4,7 +4,7 @@ import { Decorator } from "@storybook/react"; import React, { ReactNode, StrictMode, useEffect } from "react"; -import { getBootstrapHTML } from "../src/components/GaEventWrapper/useBaseSpecificFramework"; +import { getBootstrapHTML } from "../src/components/GaEventWrapper/getBootstrapHTML"; import { useChannel } from "storybook/preview-api"; declare interface ContainerProps { diff --git a/packages/unity-react-core/.storybook/renderToHTML.jsx b/packages/unity-react-core/.storybook/renderToHTML.jsx index 7ebbd94919..9797b2e6fd 100644 --- a/packages/unity-react-core/.storybook/renderToHTML.jsx +++ b/packages/unity-react-core/.storybook/renderToHTML.jsx @@ -1,5 +1,5 @@ -import { getBootstrapHTML } from '../src/components/GaEventWrapper/useBaseSpecificFramework.js'; +import { getBootstrapHTML } from '../src/components/GaEventWrapper/getBootstrapHTML.js'; import { formatCode } from './formatCode.js'; export { formatCode }; diff --git a/packages/unity-react-core/package.json b/packages/unity-react-core/package.json index c187db18e3..aa7c62da20 100644 --- a/packages/unity-react-core/package.json +++ b/packages/unity-react-core/package.json @@ -51,7 +51,7 @@ "devDependencies": { "@asu/component-header-footer": "*", "@asu/shared": "*", - "@asu/unity-bootstrap-theme": "^1.21.3", + "@asu/unity-bootstrap-theme": "*", "@babel/cli": "^7.19.3", "@babel/core": "^7.21.3", "@babel/plugin-transform-runtime": "^7.19.6", @@ -107,7 +107,7 @@ "react-share": "^5.3" }, "peerDependencies": { - "@asu/unity-bootstrap-theme": "^1.21.3", + "@asu/unity-bootstrap-theme": "^2.0.0", "@fortawesome/fontawesome-free": "^5.15.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/unity-react-core/scripts/check-changes.tsx b/packages/unity-react-core/scripts/check-changes.tsx index e1c21e9d66..0cddb90767 100644 --- a/packages/unity-react-core/scripts/check-changes.tsx +++ b/packages/unity-react-core/scripts/check-changes.tsx @@ -31,7 +31,7 @@ import { SidebarMenu } from "../src/components/SidebarMenu/SidebarMenu"; import { SystemAlert } from "../src/components/SystemAlert/SystemAlert"; import { Table } from "../src/components/Tables/Tables"; import { Loader } from "../src/components/Loader/Loader"; -import { getBootstrapHTML } from "../src/components/GaEventWrapper/useBaseSpecificFramework.js"; +import { getBootstrapHTML } from "../src/components/GaEventWrapper/getBootstrapHTML.js"; import { initializeServerEnvironment, cleanupServerEnvironment } from "./server-utils.js"; import fs from "fs"; import path from "path"; diff --git a/packages/unity-react-core/src/components/GaEventWrapper/getBootstrapHTML.js b/packages/unity-react-core/src/components/GaEventWrapper/getBootstrapHTML.js new file mode 100644 index 0000000000..427fa36c2d --- /dev/null +++ b/packages/unity-react-core/src/components/GaEventWrapper/getBootstrapHTML.js @@ -0,0 +1,12 @@ +// Storybook/dev-tooling only: renders a component to static HTML to preview +// its "Bootstrap" (non-React, static markup + vanilla JS) consumption path. +// Deliberately kept out of useBaseSpecificFramework.js, which every +// component imports at runtime for the isBootstrap/isReact hook — importing +// react-dom/server there would bundle server-rendering internals into every +// consuming app's dist output for no benefit. +import { renderToStaticMarkup } from "react-dom/server"; + +import { identifierPrefix } from "./useBaseSpecificFramework"; + +export const getBootstrapHTML = jsx => + renderToStaticMarkup(jsx, { identifierPrefix }); diff --git a/packages/unity-react-core/src/components/GaEventWrapper/useBaseSpecificFramework.js b/packages/unity-react-core/src/components/GaEventWrapper/useBaseSpecificFramework.js index 62ee346f46..c7f44f2af9 100644 --- a/packages/unity-react-core/src/components/GaEventWrapper/useBaseSpecificFramework.js +++ b/packages/unity-react-core/src/components/GaEventWrapper/useBaseSpecificFramework.js @@ -1,13 +1,9 @@ // where does this hook belong? There might be a better location for it. import { useId } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; // used inside the StaticStory component in packages/unity-react-core/.storybook/decorators.tsx export const identifierPrefix = "staticMarkup"; -export const getBootstrapHTML = jsx => - renderToStaticMarkup(jsx, { identifierPrefix }); - export function useBaseSpecificFramework() { const id = useId(); /** diff --git a/packages/unity-react-core/src/components/TabbedPanels/components/NavControls.styles.js b/packages/unity-react-core/src/components/TabbedPanels/components/NavControls.styles.js index 374446b17d..6efbd3129f 100644 --- a/packages/unity-react-core/src/components/TabbedPanels/components/NavControls.styles.js +++ b/packages/unity-react-core/src/components/TabbedPanels/components/NavControls.styles.js @@ -1,14 +1,90 @@ // @ts-check import styled from "styled-components"; -// TODO Why is this not in unity-bootstrap-theme? -// TODO move to unity-bootstrap-theme or update comment explaining why it's here +// Co-located here (rather than in unity-bootstrap-theme) so `NavControls` +// renders correctly for any consumer out of the box, without depending on a +// shared/global stylesheet that may not know this component still needs it. const NavControlButtons = styled.div` button { padding: 16px 0; border: none; outline: none; } + + .scroll-control-prev, + .scroll-control-next { + outline: none; + border: none; + width: 80px; + position: absolute; + height: 100%; + top: 0; + } + + .scroll-control-prev { + background: linear-gradient( + 90deg, + rgba(25, 25, 25, 0.25) 0%, + rgba(25, 25, 25, 0) 100% + ); + left: 0; + } + + .scroll-control-next { + right: 0; + background: linear-gradient( + 90deg, + rgba(25, 25, 25, 0) 0%, + rgba(25, 25, 25, 0.25) 100% + ); + + .carousel-control-next-icon { + margin: 0 12px 0 42px; + } + } + + .scroll-control-prev .carousel-control-prev-icon, + .scroll-control-next .carousel-control-next-icon { + background-size: 60% 60%; + display: block; + opacity: 1; + padding: 12px; + position: relative; + top: 50%; + left: 0; + transform: translate(0, -50%); + background-color: #fafafa; // $asu-gray-7 + border: solid 1px #d0d0d0; // $asu-gray-5 + border-radius: 100%; + color: #000; + } + + .carousel-control-next-icon { + background-image: url("data:image/svg+xml; utf8, "); + background-position: 80% 50%; + } + + .carousel-control-prev-icon { + background-image: url("data:image/svg+xml; utf8, "); + background-position: 60% 50%; + } + + @media screen and (max-width: 768px) { + // $uds-breakpoint-md + .scroll-control-prev, + .scroll-control-next { + width: 48px; + } + + .scroll-control-next .carousel-control-next-icon, + .scroll-control-prev .carousel-control-prev-icon { + margin: 0px 12px 0px 8px; + } + + .scroll-control-prev .carousel-control-prev-icon { + margin-left: 0px; + } + } `; export { NavControlButtons }; From 7214d478609d54a14d9ff30e10363d9015f04a84 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 27 Jul 2026 16:19:12 -0700 Subject: [PATCH 03/11] fix(app-rfi): correct stale @asu/unity-bootstrap-theme and @asu/unity-react-core dependency ranges Both were pinned to ^1.x ranges that no longer match the workspace's current major versions (unity-bootstrap-theme 2.x, unity-react-core 2.x), causing yarn to resolve a stale published copy from the registry instead of symlinking the local workspace package. --- packages/app-rfi/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-rfi/package.json b/packages/app-rfi/package.json index 5da4e88477..8804ff8be1 100644 --- a/packages/app-rfi/package.json +++ b/packages/app-rfi/package.json @@ -44,7 +44,7 @@ }, "devDependencies": { "@asu/shared": "*", - "@asu/unity-bootstrap-theme": "^1.0.0", + "@asu/unity-bootstrap-theme": "*", "@babel/core": "^7.13.14", "@babel/eslint-parser": "^7.13.14", "@babel/plugin-proposal-class-properties": "^7.13.0", @@ -79,7 +79,7 @@ "webpack-merge": "^5.8.0" }, "dependencies": { - "@asu/unity-react-core": "^1.0.0", + "@asu/unity-react-core": "^2.0.0", "formik": "^2.1.4", "prop-types": "^15.7.2", "react-phone-input-2": "2.15.1", From 8b660693d5304e76ed98121dd390001352e5e390 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 27 Jul 2026 16:19:23 -0700 Subject: [PATCH 04/11] fix(component-events): correct stale @asu/unity-react-core dependency range Was pinned to ^1.0.0, which no longer matches the workspace's current 2.x version, causing yarn to resolve a stale published copy from the registry instead of symlinking the local workspace package. --- packages/component-events/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/component-events/package.json b/packages/component-events/package.json index 3402038070..c341e375a7 100644 --- a/packages/component-events/package.json +++ b/packages/component-events/package.json @@ -39,7 +39,7 @@ "postdocs": "node ../../scripts/process-readme-props.js" }, "dependencies": { - "@asu/unity-react-core": "^1.0.0", + "@asu/unity-react-core": "^2.0.0", "prop-types": "^15.7.2", "styled-components": "^6.0.0" }, From d5f3869740f833f8c35139e5af12fbea4449fb90 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 27 Jul 2026 16:19:35 -0700 Subject: [PATCH 05/11] fix(component-news): correct stale @asu/unity-react-core dependency range Was pinned to ^1.0.0, which no longer matches the workspace's current 2.x version, causing yarn to resolve a stale published copy from the registry instead of symlinking the local workspace package. --- packages/component-news/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/component-news/package.json b/packages/component-news/package.json index d363f6d395..bc0690cedc 100644 --- a/packages/component-news/package.json +++ b/packages/component-news/package.json @@ -39,7 +39,7 @@ "postdocs": "node ../../scripts/process-readme-props.js" }, "dependencies": { - "@asu/unity-react-core": "^1.0.0", + "@asu/unity-react-core": "^2.0.0", "prop-types": "^15.7.2", "styled-components": "^6.0.0" }, From fc1aa8fddac24d3a3b0668eeb6410609f2432f73 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 27 Jul 2026 16:19:44 -0700 Subject: [PATCH 06/11] fix(static-site): correct stale @asu/unity-bootstrap-theme dependency range Was pinned to ^1.20, which no longer matches the workspace's current 2.x version, causing yarn to resolve a stale published copy from the registry instead of symlinking the local workspace package. --- packages/static-site/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/static-site/package.json b/packages/static-site/package.json index 693845b713..f5647e601b 100644 --- a/packages/static-site/package.json +++ b/packages/static-site/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@asu/component-header-footer": "^1.0.0", - "@asu/unity-bootstrap-theme": "^1.20", + "@asu/unity-bootstrap-theme": "^2.0.0", "@asu/unity-react-core": "^2.0.0", "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-brands-svg-icons": "^6.4.2", From 81ce102c79ab8f5e299d1bb4d50e38af2cd71a58 Mon Sep 17 00:00:00 2001 From: david ornelas Date: Mon, 27 Jul 2026 16:20:07 -0700 Subject: [PATCH 07/11] fix(app-degree-pages): correct stale @asu/unity-react-core dependency range Was pinned to ^1.0.0, which no longer matches the workspace's current 2.x version, causing yarn to resolve a stale published copy from the registry instead of symlinking the local workspace package. --- packages/app-degree-pages/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-degree-pages/package.json b/packages/app-degree-pages/package.json index 242f95c8de..a9fdd9b61f 100644 --- a/packages/app-degree-pages/package.json +++ b/packages/app-degree-pages/package.json @@ -43,7 +43,7 @@ "postdocs": "node ../../scripts/process-readme-props.js" }, "dependencies": { - "@asu/unity-react-core": "^1.0.0", + "@asu/unity-react-core": "^2.0.0", "@popperjs/core": "^2.9.2", "classnames": "^2.3.1", "prop-types": "^15.7.2", From c98f619afb73e3eeef4d01773caef88ac2de6598 Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:16:05 -0700 Subject: [PATCH 08/11] chore(app-webdir-ui): add proxy server to allow api with local dev --- packages/app-webdir-ui/.gitignore | 4 + packages/app-webdir-ui/.storybook/main.js | 98 ++++- packages/app-webdir-ui/.storybook/preview.js | 75 ++-- packages/app-webdir-ui/package.json | 6 +- .../app-webdir-ui/public/mockServiceWorker.js | 349 ------------------ packages/app-webdir-ui/server.js | 218 +++++++++++ .../src/FacultyRankComponent/index.stories.js | 9 +- .../src/SearchPage/index.stories.js | 7 +- .../WebDirectoryComponent/index.stories.js | 23 +- .../src/helpers/webDirectoryMockHandlers.js | 97 ----- .../src/helpers/webDirectoryMockProfiles.js | 256 ------------- yarn.lock | 90 +++-- 12 files changed, 444 insertions(+), 788 deletions(-) delete mode 100644 packages/app-webdir-ui/public/mockServiceWorker.js create mode 100644 packages/app-webdir-ui/server.js delete mode 100644 packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js delete mode 100644 packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js diff --git a/packages/app-webdir-ui/.gitignore b/packages/app-webdir-ui/.gitignore index 905cf52a77..bcbf6faaac 100644 --- a/packages/app-webdir-ui/.gitignore +++ b/packages/app-webdir-ui/.gitignore @@ -5,3 +5,7 @@ package-lock.json yarn-lock.json docs/** !docs/README.props.md + +# Environment overrides (personal/local config) +.env.local +.env*.local diff --git a/packages/app-webdir-ui/.storybook/main.js b/packages/app-webdir-ui/.storybook/main.js index 1b98b1bf90..23e70f33a1 100644 --- a/packages/app-webdir-ui/.storybook/main.js +++ b/packages/app-webdir-ui/.storybook/main.js @@ -1,21 +1,113 @@ -import { dirname } from "path"; +import { existsSync, readFileSync } from "fs"; +import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; function getAbsolutePath(value) { return dirname(fileURLToPath(import.meta.resolve(value))); } +const storybookDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(storybookDir, ".."); + +function loadEnvFile(filePath) { + if (!existsSync(filePath)) { + return; + } + + const fileContent = readFileSync(filePath, "utf8"); + for (const rawLine of fileContent.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const separatorIndex = line.indexOf("="); + if (separatorIndex < 0) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + if (!key || process.env[key] !== undefined) { + continue; + } + + const value = line.slice(separatorIndex + 1).trim(); + process.env[key] = value.replace(/^['"]|['"]$/g, ""); + } +} + +function loadStorybookEnv(configType) { + const envFileName = + configType === "PRODUCTION" ? ".env.storybook-build" : ".env.development"; + loadEnvFile(resolve(packageRoot, envFileName)); +} + const config = { - staticDirs: ["../public"], addons: [ fileURLToPath(import.meta.resolve("../../../.storybook-config/index.js")), - fileURLToPath(import.meta.resolve("../../../.storybook-config/dataLayerListener/index.js")), + fileURLToPath( + import.meta + .resolve("../../../.storybook-config/dataLayerListener/index.js") + ), getAbsolutePath("@storybook/addon-a11y"), ], stories: ["../src/**/*.stories.js"], framework: { name: getAbsolutePath("@storybook/react-vite"), }, + /** + * Configure Vite proxy for real API data in dev environment. + * Proxy target is read from env as VITE_API_URL + VITE_SEARCH_API_VERSION. + * Proxy only API routes to avoid intercepting Storybook/Vite internal assets. + */ + viteFinal: async (config, { configType }) => { + loadStorybookEnv(configType); + + const apiUrl = process.env.VITE_API_URL; + const searchApiVersion = process.env.VITE_SEARCH_API_VERSION; + const target = `${apiUrl || ""}${searchApiVersion || ""}`; + const apiPath = `/${(searchApiVersion || "") + .replace(/^\/+/, "") + .replace(/\/+$/, "")}`; + + if (!target) { + throw new Error( + "Missing Storybook proxy target. Set VITE_API_URL and VITE_SEARCH_API_VERSION in env files." + ); + } + + config.server = config.server || {}; + config.server.proxy = { + [apiPath]: { + target, + changeOrigin: true, + secure: false, + logLevel: "info", + }, + "/session/token": { + target, + changeOrigin: true, + secure: false, + logLevel: "info", + }, + "/webdir-profiles": { + target, + changeOrigin: true, + secure: false, + logLevel: "info", + }, + }; + + config.optimizeDeps = config.optimizeDeps || {}; + config.optimizeDeps.esbuildOptions = + config.optimizeDeps.esbuildOptions || {}; + config.optimizeDeps.esbuildOptions.loader = { + ...(config.optimizeDeps.esbuildOptions.loader || {}), + ".js": "jsx", + }; + + return config; + }, }; export default config; diff --git a/packages/app-webdir-ui/.storybook/preview.js b/packages/app-webdir-ui/.storybook/preview.js index 75e76f263b..85dbf5398e 100644 --- a/packages/app-webdir-ui/.storybook/preview.js +++ b/packages/app-webdir-ui/.storybook/preview.js @@ -1,18 +1,9 @@ -import React, { useEffect} from "react"; +import React, { useEffect, useRef } from "react"; import { MemoryRouter, useLocation, useSearchParams } from "react-router-dom"; -import { initialize, mswLoader } from "msw-storybook-addon"; -import { useArgs } from 'storybook/preview-api'; +import { useArgs } from "storybook/preview-api"; import "@asu/unity-bootstrap-theme/src/scss/unity-bootstrap-theme.bundle.scss"; -// The live Web Directory API blocks requests from localhost, so msw mocks -// those endpoints in Storybook. See src/helpers/webDirectoryMockHandlers.js. -initialize({ - serviceWorker: { - url: "./mockServiceWorker.js", - }, -}); - const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, }; @@ -23,50 +14,67 @@ const argTypes = { control: { type: "object", }, - } + }, }; const args = { - searchParams: {} + searchParams: {}, }; const getParamObject = (paramArray = []) => { const result = {}; - for (const entry of (paramArray.entries())) { + for (const entry of paramArray.entries()) { result[entry[0]] = entry[1]; } return result; -} +}; -const Wrapper = ({ args, updateArgs,...props}) => { +const areObjectsEqual = (left = {}, right = {}) => + JSON.stringify(left) === JSON.stringify(right); + +const Wrapper = ({ args, updateArgs, ...props }) => { const location = useLocation(); - const [searchParams, setSearchParams] = useSearchParams(); + const [, setSearchParams] = useSearchParams(); + const seededFromArgsRef = useRef(false); + + useEffect(() => { + const nextSearchParams = getParamObject(new URLSearchParams(location.search)); + if (!areObjectsEqual(args.searchParams, nextSearchParams)) { + updateArgs({ + searchParams: nextSearchParams, + }); + } + }, [args.searchParams, location.search, updateArgs]); - useEffect(()=>{ - updateArgs({ - ...args, - searchParams: getParamObject(new URLSearchParams(location.search)), - }) - },[location.search]) + useEffect(() => { + if (seededFromArgsRef.current) { + return; + } - useEffect(()=>{ - setSearchParams({ - ...getParamObject(searchParams), - ...args.searchParams, - }); - },[args.searchParams]) + const currentSearchParams = getParamObject(new URLSearchParams(location.search)); + if ( + Object.keys(currentSearchParams).length === 0 && + Object.keys(args.searchParams || {}).length > 0 + ) { + setSearchParams(args.searchParams, { replace: true }); + } + + seededFromArgsRef.current = true; + }, [args.searchParams, location.search, setSearchParams]); return props.children; -} +}; const decorators = [ - (Story) => { + Story => { const [args, updateArgs] = useArgs(); - return + return ( + - + + ); }, ]; @@ -76,7 +84,6 @@ const preview = { argTypes, args, decorators, - loaders: [mswLoader], }; export default preview; diff --git a/packages/app-webdir-ui/package.json b/packages/app-webdir-ui/package.json index b001abda16..28021f3cc3 100644 --- a/packages/app-webdir-ui/package.json +++ b/packages/app-webdir-ui/package.json @@ -31,7 +31,9 @@ "build": "vite build && cp -r src/assets dist/", "build:stats": "webpack -c webpack/webpack.prod.js --profile --json=compilation-stats.json", "start:dev": "webpack-dashboard -- webpack serve -c webpack/webpack.dev.js", - "storybook": "storybook dev -p 9030", + "server": "node server.js", + "storybook:ui": "storybook dev -p 9030", + "storybook": "concurrently \"npm run server\" \"npm run storybook:ui\" --names \"server,storybook\" --prefix \"[{name}]\" --kill-others-on-exit", "build-storybook": "storybook build -o ../../build/$npm_package_name", "jsdoc": "jsdoc -c jsdoc.config.js", "predocs": "mkdir -p ./docs", @@ -63,6 +65,7 @@ "@testing-library/react": "^16.0.0", "@vitejs/plugin-react": "^4.3.1", "babel-loader": "^8.2.2", + "concurrently": "^10.0.3", "copy-webpack-plugin": "^9.0.1", "css-loader": "^5.2.4", "dotenv-webpack": "^7.0.3", @@ -81,7 +84,6 @@ "jsdoc-ts-utils": "^2.0.1", "jsdom-screenshot": "^4.0.0", "msw": "^2.7.0", - "msw-storybook-addon": "^2.0.0", "postcss-loader": "^6.1.1", "raw-loader": "^4.0.2", "sass": "^1.39.2", diff --git a/packages/app-webdir-ui/public/mockServiceWorker.js b/packages/app-webdir-ui/public/mockServiceWorker.js deleted file mode 100644 index 33dde9e770..0000000000 --- a/packages/app-webdir-ui/public/mockServiceWorker.js +++ /dev/null @@ -1,349 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - */ - -const PACKAGE_VERSION = '2.14.6' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -addEventListener('install', function () { - self.skipWaiting() -}) - -addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -addEventListener('message', async function (event) { - const clientId = Reflect.get(event.source || {}, 'id') - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -addEventListener('fetch', function (event) { - const requestInterceptedAt = Date.now() - - // Bypass navigation requests. - if (event.request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === 'only-if-cached' && - event.request.mode !== 'same-origin' - ) { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been terminated (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) -}) - -/** - * @param {FetchEvent} event - * @param {string} requestId - * @param {number} requestInterceptedAt - */ -async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event) - const requestCloneForEvents = event.request.clone() - const response = await getResponse( - event, - client, - requestId, - requestInterceptedAt, - ) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents) - - // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - isMockedResponse: IS_MOCKED_RESPONSE in response, - request: { - id: requestId, - ...serializedRequest, - }, - response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, - }, - }, - }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ) - } - - return response -} - -/** - * Resolve the main client for the given event. - * Client that issues a request doesn't necessarily equal the client - * that registered the worker. It's with the latter the worker should - * communicate with during the response resolving phase. - * @param {FetchEvent} event - * @returns {Promise} - */ -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (activeClientIds.has(event.clientId)) { - return client - } - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -/** - * @param {FetchEvent} event - * @param {Client | undefined} client - * @param {string} requestId - * @param {number} requestInterceptedAt - * @returns {Promise} - */ -async function getResponse(event, client, requestId, requestInterceptedAt) { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone() - - function passthrough() { - // Cast the request headers to a new Headers instance - // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers) - - // Remove the "accept" header value that marked this request as passthrough. - // This prevents request alteration and also keeps it compliant with the - // user-defined CORS policies. - const acceptHeader = headers.get('accept') - if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()) - const filteredValues = values.filter( - (value) => value !== 'msw/passthrough', - ) - - if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')) - } else { - headers.delete('accept') - } - } - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request) - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - interceptedAt: requestInterceptedAt, - ...serializedRequest, - }, - }, - [serializedRequest.body], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -/** - * @param {Client} client - * @param {any} message - * @param {Array} transferrables - * @returns {Promise} - */ -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]) - }) -} - -/** - * @param {Response} response - * @returns {Response} - */ -function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} - -/** - * @param {Request} request - */ -async function serializeRequest(request) { - return { - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.arrayBuffer(), - keepalive: request.keepalive, - } -} diff --git a/packages/app-webdir-ui/server.js b/packages/app-webdir-ui/server.js new file mode 100644 index 0000000000..4d2e8e8060 --- /dev/null +++ b/packages/app-webdir-ui/server.js @@ -0,0 +1,218 @@ +/** + * Development backend server for Web Directory API endpoints. + * Proxies requests to the real API for Storybook development. + * + * Endpoints: + * - GET /session/token + * - GET /webdir-profiles/faculty-staff/filtered + * - POST /webdir-profiles/department + */ + +const express = require("express"); +const app = express(); +const PORT = Number(process.env.PORT || 3000); +const API_ORIGIN = "https://asuapp2dev.prod.acquia-sites.com"; +// API_PATH_PREFIX is for local routing within this server +const API_PATH_PREFIX = "/api/v1"; +// SEARCH_API_PATH is for upstream URL construction +const SEARCH_API_PATH = `${API_PATH_PREFIX}/`; + +app.use(express.json()); + +app.use((req, res, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, X-CSRF-Token, Authorization" + ); + + if (req.method === "OPTIONS") { + res.sendStatus(204); + return; + } + + next(); +}); + +function getTargetUrl(path, queryParams, useSearchApiPath = true) { + const baseUrl = useSearchApiPath + ? new URL(SEARCH_API_PATH, API_ORIGIN) + : new URL("/", API_ORIGIN); + const normalizedPath = path.startsWith("/") ? path.slice(1) : path; + const url = new URL(normalizedPath, baseUrl); + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined) { + url.searchParams.append(key, value); + } + } + } + return url; +} + +async function proxyRequest({ + path, + method = "GET", + query, + body, + headers = {}, + useSearchApiPath = true, +}) { + const targetUrl = getTargetUrl(path, query, useSearchApiPath); + const requestOptions = { + method, + headers, + }; + + if (body !== undefined) { + requestOptions.body = JSON.stringify(body); + requestOptions.headers = { + "Content-Type": "application/json", + ...headers, + }; + } + + return fetch(targetUrl, requestOptions); +} + +function withApiPrefix(path) { + return [path, `${API_PATH_PREFIX}${path}`]; +} + +function toUpstreamPath(requestPath) { + if (requestPath.startsWith(`${API_PATH_PREFIX}/`)) { + return requestPath.slice(API_PATH_PREFIX.length + 1); + } + if (requestPath.startsWith("/")) { + return requestPath.slice(1); + } + return requestPath; +} + +async function relayUpstreamResponse(upstreamResponse, res) { + const contentType = upstreamResponse.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const payload = await upstreamResponse.json(); + res.status(upstreamResponse.status).json(payload); + return; + } + + const payload = await upstreamResponse.text(); + res + .status(upstreamResponse.status) + .type(contentType || "text/plain") + .send(payload); +} + +// Routes +app.get(withApiPrefix("/session/token"), async (req, res) => { + console.log("[API] GET /session/token"); + try { + const upstreamResponse = await proxyRequest({ + path: "/session/token", + method: "GET", + useSearchApiPath: false, + }); + const token = await upstreamResponse.text(); + res.status(upstreamResponse.status).send(token); + } catch (error) { + console.error("[API] Error:", error.message); + res.status(500).json({ error: error.message }); + } +}); + +app.get( + withApiPrefix("/webdir-profiles/faculty-staff/filtered"), + async (req, res) => { + console.log("[API] GET /webdir-profiles/faculty-staff/filtered", req.query); + try { + const upstreamResponse = await proxyRequest({ + path: "webdir-profiles/faculty-staff/filtered", + method: "GET", + query: req.query, + }); + const response = await upstreamResponse.json(); + res.status(upstreamResponse.status).json(response); + } catch (error) { + console.error("[API] Error:", error.message); + res.status(500).json({ error: error.message }); + } + } +); + +app.get(withApiPrefix("/webdir-profiles/*"), async (req, res) => { + console.log("[API] GET", req.path, req.query); + try { + const upstreamResponse = await proxyRequest({ + path: toUpstreamPath(req.path), + method: "GET", + query: req.query, + }); + await relayUpstreamResponse(upstreamResponse, res); + } catch (error) { + console.error("[API] Error:", error.message); + res.status(500).json({ error: error.message }); + } +}); + +app.post(withApiPrefix("/webdir-profiles/department"), async (req, res) => { + console.log("[API] POST /webdir-profiles/department", req.body); + try { + const upstreamResponse = await proxyRequest({ + path: "webdir-profiles/department", + method: "POST", + query: req.query, + body: req.body, + headers: { + "X-CSRF-Token": req.headers["x-csrf-token"] || "", + }, + }); + const response = await upstreamResponse.json(); + res.status(upstreamResponse.status).json(response); + } catch (error) { + console.error("[API] Error:", error.message); + res.status(500).json({ error: error.message }); + } +}); + +app.post(withApiPrefix("/webdir-profiles/*"), async (req, res) => { + console.log("[API] POST", req.path, req.body); + try { + const upstreamResponse = await proxyRequest({ + path: toUpstreamPath(req.path), + method: "POST", + query: req.query, + body: req.body, + headers: { + "X-CSRF-Token": req.headers["x-csrf-token"] || "", + }, + }); + await relayUpstreamResponse(upstreamResponse, res); + } catch (error) { + console.error("[API] Error:", error.message); + res.status(500).json({ error: error.message }); + } +}); + +// Health check endpoint +app.get("/health", (req, res) => { + res.json({ status: "ok", timestamp: new Date().toISOString() }); +}); + +// Start server +app.listen(PORT, () => { + console.log( + `\n[Web Directory API Server] Running on http://localhost:${PORT}` + ); + console.log( + `[Web Directory API Server] Proxy target: ${API_ORIGIN}${SEARCH_API_PATH}\n` + ); +}); + +// Graceful shutdown +process.on("SIGTERM", () => { + console.log("\n[Web Directory API Server] Shutting down...\n"); + process.exit(0); +}); diff --git a/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js b/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js index 1ac6a36a0f..6bf0d9d58c 100644 --- a/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js +++ b/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js @@ -2,11 +2,12 @@ import React from "react"; import { FullLayout } from "@asu/shared"; import { WebDirectory } from "../WebDirectoryComponent/index"; -import { webDirectoryHandlers } from "../helpers/webDirectoryMockHandlers"; + +const API_URL = import.meta.env.VITE_API_URL; +const searchApiVersion = import.meta.env.VITE_SEARCH_API_VERSION; export default { title: "Organisms/Web Directory/Templates", - parameters: { msw: { handlers: webDirectoryHandlers } }, decorators: [story => {story()}], }; @@ -37,8 +38,8 @@ export const FacultyRankWebDirectory = args => { {story()}], @@ -12,8 +15,8 @@ export default { export const searchPageExample = () => (
diff --git a/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js b/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js index 85f9df48b6..43e38d550e 100644 --- a/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js +++ b/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js @@ -3,7 +3,9 @@ import React from "react"; import { WebDirectory } from "./index"; import { FullLayout } from "@asu/shared"; -import { webDirectoryHandlers } from "../helpers/webDirectoryMockHandlers"; + +const API_URL = import.meta.env.VITE_API_URL; +const searchApiVersion = import.meta.env.VITE_SEARCH_API_VERSION; export default { title: "Organisms/Web Directory/Templates", @@ -19,7 +21,6 @@ export default { }, }, args: { alphaFilter: "false" }, - parameters: { msw: { handlers: webDirectoryHandlers } }, decorators: [story => {story()}], }; @@ -52,8 +53,8 @@ export const webDirectoryExampleDepartments = args => { {
); }; +webDirectoryExampleDepartments.args = { + alphaFilter: "true", +}; export const webDirectoryExamplePeople = args => { return ( @@ -73,8 +77,8 @@ export const webDirectoryExamplePeople = args => { { {
); }; +webDirectoryExampleDepartmentsAndPeople.args = { + alphaFilter: "true", +}; diff --git a/packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js b/packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js deleted file mode 100644 index e4f75758d9..0000000000 --- a/packages/app-webdir-ui/src/helpers/webDirectoryMockHandlers.js +++ /dev/null @@ -1,97 +0,0 @@ -// @ts-check -import { http, HttpResponse } from "msw"; - -import { - getMockProfileByAsuriteId, - mockDirectoryProfiles, -} from "./webDirectoryMockProfiles"; - -const MOCK_CSRF_TOKEN = "mock-csrf-token"; - -/** - * Paginates the mock profile pool the same way the real - * `webdir-profiles/faculty-staff/filtered` endpoint would, and wraps it in - * the `{ meta: { page }, results }` envelope expected by `helpers/search.js`. - * When a `rank_group` param is present (used by `FacultyRankComponent`'s - * tabs), only profiles seeded with a matching `rankGroup` are included, so - * each tab shows distinct data instead of the same full pool. - * @param {URL} url - */ -function buildFilteredProfilesResponse(url) { - const page = Number(url.searchParams.get("page")) || 1; - const size = Number(url.searchParams.get("size")) || 6; - const rankGroup = url.searchParams.get("rank_group"); - const pool = rankGroup - ? mockDirectoryProfiles.filter( - profile => profile.rank_group.raw === rankGroup - ) - : mockDirectoryProfiles; - const start = (page - 1) * size; - const results = pool.slice(start, start + size); - - return { - meta: { - request_id: "mock-request-id", - page: { - current: page, - total_pages: Math.ceil(pool.length / size), - total_results: pool.length, - size, - }, - }, - results, - }; -} - -/** - * Handles the POST `webdir-profiles/department` endpoint, which is used both - * for the "people"/"people_departments" Web Directory search (`full_records: - * true`) and for enriching GET results with title/department info - * (`full_records: false`). - * @param {{ full_records?: boolean, profiles?: { asurite_id: string, dept_id?: string }[] }} body - */ -function buildDepartmentProfilesResponse(body) { - const profiles = body.profiles || []; - - if (!body.full_records) { - return profiles.map(profile => { - const mock = getMockProfileByAsuriteId(profile.asurite_id); - return { - title: mock.titles.raw, - dept_name: mock.departments.raw[0], - }; - }); - } - - return profiles.map((profile, index) => { - const fullRecord = getMockProfileByAsuriteId(profile.asurite_id, index); - return { - asurite_id: profile.asurite_id, - dept_id: profile.dept_id, - ...(index === 0 ? { total_results: profiles.length } : {}), - full_record: fullRecord, - }; - }); -} - -/** - * MSW request handlers for the Web Directory endpoints hit by - * `WebDirectoryComponent` and `FacultyRankComponent` stories. Registered via - * each story's `parameters.msw.handlers`. - */ -export const webDirectoryHandlers = [ - http.get("*/session/token", () => HttpResponse.text(MOCK_CSRF_TOKEN)), - - http.get("*/webdir-profiles/faculty-staff/filtered", ({ request }) => { - const url = new URL(request.url); - return HttpResponse.json(buildFilteredProfilesResponse(url)); - }), - - http.post("*/webdir-profiles/department", async ({ request }) => { - const body = - /** @type {{ full_records?: boolean, profiles?: { asurite_id: string, dept_id?: string }[] }} */ ( - await request.json() - ); - return HttpResponse.json(buildDepartmentProfilesResponse(body)); - }), -]; diff --git a/packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js b/packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js deleted file mode 100644 index 99720010c5..0000000000 --- a/packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js +++ /dev/null @@ -1,256 +0,0 @@ -// @ts-check -/** - * Mock profile "seed" data used to build fake Web Directory API responses for - * Storybook (via msw). The real `webdir-profiles/*` endpoints block requests - * coming from localhost, so these fixtures let the Web Directory stories keep - * working without hitting the live API. - */ - -/** - * @typedef {Object} MockProfileSeed - * @property {string} asuriteId - * @property {string} eid - * @property {string} deptId - * @property {string} displayName - * @property {string} firstName - * @property {string} lastName - * @property {string[]} titles - * @property {string} deptName - * @property {string} email - * @property {string} phone - * @property {"1"|"2"|"3"|null} [rankGroup] - Matches `FacultyRankComponent`'s - * `rank_group` filter (1=Faculty, 2=Academic Professionals, 3=Other), or - * `null`/omitted for profiles outside those groups. - */ - -/** @type {MockProfileSeed[]} */ -const mockProfileSeeds = [ - { - asuriteId: "mcrow", - eid: "454517", - deptId: "1350", - displayName: "Michael Crow", - firstName: "Michael", - lastName: "Crow", - titles: ["President"], - deptName: "Office of the President", - email: "michael.crow@asu.edu", - phone: "480/965-1234", - // Not a Faculty/Academic Professional rank group; excluded from Faculty Rank tabs. - rankGroup: null, - }, - { - asuriteId: "mdenke", - eid: "1350001", - deptId: "1350", - displayName: "Morgan Denke", - firstName: "Morgan", - lastName: "Denke", - titles: ["Associate Professor"], - deptName: "School of Life Sciences", - email: "morgan.denke@asu.edu", - phone: "480/965-2345", - rankGroup: "1", // Faculty - }, - { - asuriteId: "jagarc50", - eid: "1350002", - deptId: "1350", - displayName: "Javier Garcia", - firstName: "Javier", - lastName: "Garcia", - titles: ["Lecturer"], - deptName: "School of Life Sciences", - email: "javier.garcia@asu.edu", - phone: "480/965-3456", - rankGroup: "1", // Faculty - }, - { - asuriteId: "lhillzev", - eid: "1353001", - deptId: "1353", - displayName: "Lena Hillzev", - firstName: "Lena", - lastName: "Hillzev", - titles: ["Administrative Assistant"], - deptName: "College of Health Solutions", - email: "lena.hillzev@asu.edu", - phone: "480/965-4567", - rankGroup: "3", // Other Faculty and Academic Professionals - }, - { - asuriteId: "tgrandli", - eid: "1344001", - deptId: "1344", - displayName: "Taylor Grandli", - firstName: "Taylor", - lastName: "Grandli", - titles: ["Academic Advisor"], - deptName: "W. P. Carey School of Business", - email: "taylor.grandli@asu.edu", - phone: "480/965-5678", - rankGroup: "2", // Academic Professionals - }, - { - asuriteId: "jcunnin8", - eid: "1358001", - deptId: "1358", - displayName: "Jordan Cunningham", - firstName: "Jordan", - lastName: "Cunningham", - titles: ["Professor"], - deptName: "Ira A. Fulton Schools of Engineering", - email: "jordan.cunningham@asu.edu", - phone: "480/965-6789", - rankGroup: "1", // Faculty - }, - { - asuriteId: "ccherrer", - eid: "1358002", - deptId: "1358", - displayName: "Casey Cherrer", - firstName: "Casey", - lastName: "Cherrer", - titles: ["Assistant Professor"], - deptName: "Ira A. Fulton Schools of Engineering", - email: "casey.cherrer@asu.edu", - phone: "480/965-7890", - rankGroup: "1", // Faculty - }, - { - asuriteId: "csmudde", - eid: "1358003", - deptId: "1358", - displayName: "Charlie Smudde", - firstName: "Charlie", - lastName: "Smudde", - titles: ["Research Scientist"], - deptName: "Ira A. Fulton Schools of Engineering", - email: "charlie.smudde@asu.edu", - phone: "480/965-8901", - rankGroup: "2", // Academic Professionals - }, - { - asuriteId: "abarnett", - eid: "1349001", - deptId: "1349", - displayName: "Alex Barnett", - firstName: "Alex", - lastName: "Barnett", - titles: ["Faculty"], - deptName: "College of Global Futures", - email: "alex.barnett@asu.edu", - phone: "480/965-9012", - rankGroup: "3", // Other Faculty and Academic Professionals - }, - { - asuriteId: "rjmoreno", - eid: "1518001", - deptId: "1518", - displayName: "Riley Moreno", - firstName: "Riley", - lastName: "Moreno", - titles: ["Staff"], - deptName: "Herberger Institute for Design and the Arts", - email: "riley.moreno@asu.edu", - phone: "480/965-0123", - rankGroup: null, - }, - { - asuriteId: "spatel12", - eid: "1520001", - deptId: "1520", - displayName: "Sam Patel", - firstName: "Sam", - lastName: "Patel", - titles: ["Professor"], - deptName: "Watts College of Public Service and Community Solutions", - email: "sam.patel@asu.edu", - phone: "480/965-1122", - rankGroup: "1", // Faculty - }, - { - asuriteId: "ekowalsk", - eid: "3534001", - deptId: "3534", - displayName: "Emerson Kowalski", - firstName: "Emerson", - lastName: "Kowalski", - titles: ["Associate Dean"], - deptName: "Mary Lou Fulton Teachers College", - email: "emerson.kowalski@asu.edu", - phone: "480/965-2233", - rankGroup: "2", // Academic Professionals - }, -]; - -/** - * Builds a Web Directory search-API-shaped raw record from a mock profile seed. - * @param {MockProfileSeed} seed - * @param {number} index - * @returns {Record} - */ -function toRawProfile(seed, index) { - return { - id: { raw: `mock-${seed.asuriteId}` }, - asurite_id: { raw: seed.asuriteId }, - eid: { raw: seed.eid }, - deptids: { raw: [seed.deptId] }, - display_name: { raw: seed.displayName }, - first_name: { raw: seed.firstName }, - last_name: { raw: seed.lastName }, - titles: { raw: seed.titles }, - departments: { raw: [seed.deptName] }, - email_address: { raw: seed.email }, - phone: { raw: seed.phone }, - rank_group: { raw: seed.rankGroup ?? null }, - campus_address: { raw: "ASU Tempe Campus" }, - city: { raw: "Tempe" }, - state: { raw: "AZ" }, - photo_url: { - raw: `https://source.unsplash.com/random/400x400?sig=${index}`, - }, - bio: { raw: `${seed.displayName} is a member of ${seed.deptName}.` }, - short_bio: { raw: `${seed.titles[0]} in ${seed.deptName}` }, - facebook: { raw: null }, - linkedin: { raw: null }, - twitter: { raw: null }, - website: { raw: "" }, - _meta: { - engine: "web-dir-faculty-staff", - score: 5, - id: `mock-${seed.asuriteId}`, - }, - }; -} - -export const mockDirectoryProfiles = mockProfileSeeds.map(toRawProfile); - -/** - * Looks up (or falls back to generating) a mock raw profile for the given - * asurite ID, so POST payloads referencing arbitrary IDs still resolve. - * @param {string} asuriteId - * @param {number} index - * @returns {Record} - */ -export function getMockProfileByAsuriteId(asuriteId, index = 0) { - const found = mockProfileSeeds.find(seed => seed.asuriteId === asuriteId); - if (found) { - return toRawProfile(found, index); - } - return toRawProfile( - { - asuriteId, - eid: "0", - deptId: "1350", - displayName: asuriteId, - firstName: asuriteId, - lastName: "", - titles: ["Staff"], - deptName: "Arizona State University", - email: `${asuriteId}@asu.edu`, - phone: "480/965-0000", - }, - index - ); -} diff --git a/yarn.lock b/yarn.lock index 9758a3d106..9d9655b715 100644 --- a/yarn.lock +++ b/yarn.lock @@ -186,6 +186,7 @@ __metadata: "@vitejs/plugin-react": "npm:^4.3.1" axios: "npm:~1.16.0" babel-loader: "npm:^8.2.2" + concurrently: "npm:^10.0.3" copy-webpack-plugin: "npm:^9.0.1" css-loader: "npm:^5.2.4" dotenv-webpack: "npm:^7.0.3" @@ -204,7 +205,6 @@ __metadata: jsdoc-ts-utils: "npm:^2.0.1" jsdom-screenshot: "npm:^4.0.0" msw: "npm:^2.7.0" - msw-storybook-addon: "npm:^2.0.0" postcss-loader: "npm:^6.1.1" prop-types: "npm:^15.7.2" raw-loader: "npm:^4.0.2" @@ -11898,6 +11898,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:5.6.2, chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1, chalk@npm:^5.6.2": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 + languageName: node + linkType: hard + "chalk@npm:^1.1.3": version: 1.1.3 resolution: "chalk@npm:1.1.3" @@ -11942,13 +11949,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.4.1, chalk@npm:^5.6.2": - version: 5.6.2 - resolution: "chalk@npm:5.6.2" - checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 - languageName: node - linkType: hard - "change-case@npm:^3.1.0": version: 3.1.0 resolution: "change-case@npm:3.1.0" @@ -12698,6 +12698,23 @@ __metadata: languageName: node linkType: hard +"concurrently@npm:^10.0.3": + version: 10.0.3 + resolution: "concurrently@npm:10.0.3" + dependencies: + chalk: "npm:5.6.2" + rxjs: "npm:7.8.2" + shell-quote: "npm:1.8.4" + supports-color: "npm:10.2.2" + tree-kill: "npm:1.2.2" + yargs: "npm:18.0.0" + bin: + conc: dist/bin/index.js + concurrently: dist/bin/index.js + checksum: 10c0/59a4d9a7946fdbfbfa380543e5e5a40eb5b993cde18862126e8c7fe117813ebd26d67aa67b64781543c0e357eb23fb551f303b622e439354398ff0d7d71735b6 + languageName: node + linkType: hard + "concurrently@npm:^6.4.0": version: 6.5.1 resolution: "concurrently@npm:6.5.1" @@ -27527,6 +27544,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:7.8.2, rxjs@npm:^7.5.5, rxjs@npm:^7.8.1": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 + languageName: node + linkType: hard + "rxjs@npm:^6.4.0, rxjs@npm:^6.6.0, rxjs@npm:^6.6.2, rxjs@npm:^6.6.3": version: 6.6.7 resolution: "rxjs@npm:6.6.7" @@ -27536,15 +27562,6 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.5.5, rxjs@npm:^7.8.1": - version: 7.8.2 - resolution: "rxjs@npm:7.8.2" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 - languageName: node - linkType: hard - "safe-array-concat@npm:^1.1.3": version: 1.1.4 resolution: "safe-array-concat@npm:1.1.4" @@ -28114,6 +28131,13 @@ __metadata: languageName: node linkType: hard +"shell-quote@npm:1.8.4": + version: 1.8.4 + resolution: "shell-quote@npm:1.8.4" + checksum: 10c0/86c93678bc394cb81f5ddcdc87df9c95d279ef9652775cd1cd1eed361404169a8d8cbaacaeed232ab09919e36ee1e5363863570390d78571f8c22b7f6312fb40 + languageName: node + linkType: hard + "shell-quote@npm:^1.6.1": version: 1.8.3 resolution: "shell-quote@npm:1.8.3" @@ -29409,7 +29433,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^10.2.2": +"supports-color@npm:10.2.2, supports-color@npm:^10.2.2": version: 10.2.2 resolution: "supports-color@npm:10.2.2" checksum: 10c0/fb28dd7e0cdf80afb3f2a41df5e068d60c8b4f97f7140de2eaed5b42e075d82a0e980b20a2c0efd2b6d73cfacb55555285d8cc719fa0472220715aefeaa1da7c @@ -30159,7 +30183,7 @@ __metadata: languageName: node linkType: hard -"tree-kill@npm:^1.2.2": +"tree-kill@npm:1.2.2, tree-kill@npm:^1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2" bin: @@ -32359,6 +32383,20 @@ __metadata: languageName: node linkType: hard +"yargs@npm:18.0.0, yargs@npm:^18.0.0": + version: 18.0.0 + resolution: "yargs@npm:18.0.0" + dependencies: + cliui: "npm:^9.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + string-width: "npm:^7.2.0" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^22.0.0" + checksum: 10c0/bf290e4723876ea9c638c786a5c42ac28e03c9ca2325e1424bf43b94e5876456292d3ed905b853ebbba6daf43ed29e772ac2a6b3c5fb1b16533245d6211778f3 + languageName: node + linkType: hard + "yargs@npm:^15.0.2, yargs@npm:^15.4.1": version: 15.4.1 resolution: "yargs@npm:15.4.1" @@ -32393,20 +32431,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^18.0.0": - version: 18.0.0 - resolution: "yargs@npm:18.0.0" - dependencies: - cliui: "npm:^9.0.1" - escalade: "npm:^3.1.1" - get-caller-file: "npm:^2.0.5" - string-width: "npm:^7.2.0" - y18n: "npm:^5.0.5" - yargs-parser: "npm:^22.0.0" - checksum: 10c0/bf290e4723876ea9c638c786a5c42ac28e03c9ca2325e1424bf43b94e5876456292d3ed905b853ebbba6daf43ed29e772ac2a6b3c5fb1b16533245d6211778f3 - languageName: node - linkType: hard - "yauzl@npm:^2.10.0": version: 2.10.0 resolution: "yauzl@npm:2.10.0" From 890219798ba882fd4a500f9e7683304902556416 Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:05:21 -0700 Subject: [PATCH 09/11] chore(app-webdir-ui): environment files were not included --- .gitignore | 3 +++ packages/app-webdir-ui/.env.development | 11 +++++++++++ packages/app-webdir-ui/.env.example | 13 +++++++++++++ packages/app-webdir-ui/.env.production | 5 +++++ packages/app-webdir-ui/.storybook/main.js | 2 +- 5 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/app-webdir-ui/.env.development create mode 100644 packages/app-webdir-ui/.env.example create mode 100644 packages/app-webdir-ui/.env.production diff --git a/.gitignore b/.gitignore index 73e7161059..dde73f6e9f 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,9 @@ tests/__image_snapshots__/__diff_output__ !.yarn/versions *.env.* !.env.yarn.example +!.env.example +!.env.development +!.env.production # --- Adversarial coding pipeline (project-local .kiro) --- # Committed: .kiro/agents, .kiro/skills, .kiro/hooks, .kiro/scripts, diff --git a/packages/app-webdir-ui/.env.development b/packages/app-webdir-ui/.env.development new file mode 100644 index 0000000000..46db4d9b82 --- /dev/null +++ b/packages/app-webdir-ui/.env.development @@ -0,0 +1,11 @@ +# Web Directory UI - Development Environment Configuration +# +# This file is loaded by Vite when running `yarn storybook:dev` +# It enables the backend API proxy for real data testing in Storybook + +# Local proxy server port used by `node server.js` +PORT=3000 + +# Search engine request base URL + version used by Storybook stories +VITE_API_URL=http://localhost:${PORT}/ +VITE_SEARCH_API_VERSION=api/v1/ diff --git a/packages/app-webdir-ui/.env.example b/packages/app-webdir-ui/.env.example new file mode 100644 index 0000000000..88fd025020 --- /dev/null +++ b/packages/app-webdir-ui/.env.example @@ -0,0 +1,13 @@ +# Web Directory UI - Environment Configuration Template +# +# Copy this file to: +# - .env.development (git-tracked, shared dev defaults) +# - .env.local (git-ignored, personal overrides) + +# Local proxy server port used by `node server.js` +# PORT=3000 + +# API_URL + searchApiVersion used by stories/components +# Default base URL + version pair: +# VITE_API_URL=http://localhost:${PORT}/ +# VITE_SEARCH_API_VERSION=/api/v1/ diff --git a/packages/app-webdir-ui/.env.production b/packages/app-webdir-ui/.env.production new file mode 100644 index 0000000000..f34ae92331 --- /dev/null +++ b/packages/app-webdir-ui/.env.production @@ -0,0 +1,5 @@ +# Web Directory UI - Storybook build environment +# Loaded by the build-storybook script for deterministic static build config. + +VITE_API_URL=https://asuapp2dev.prod.acquia-sites.com/ +VITE_SEARCH_API_VERSION=api/v1/ diff --git a/packages/app-webdir-ui/.storybook/main.js b/packages/app-webdir-ui/.storybook/main.js index 23e70f33a1..531618621c 100644 --- a/packages/app-webdir-ui/.storybook/main.js +++ b/packages/app-webdir-ui/.storybook/main.js @@ -38,7 +38,7 @@ function loadEnvFile(filePath) { function loadStorybookEnv(configType) { const envFileName = - configType === "PRODUCTION" ? ".env.storybook-build" : ".env.development"; + configType === "PRODUCTION" ? ".env.production" : ".env.development"; loadEnvFile(resolve(packageRoot, envFileName)); } From 79e7835f07a15f8b49f2fbe9631a1275fda3b66d Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:45:55 -0700 Subject: [PATCH 10/11] chore(app-webdir-ui): removed long paths in favor of wildcard paths --- packages/app-webdir-ui/server.js | 43 ++------------------------------ 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/packages/app-webdir-ui/server.js b/packages/app-webdir-ui/server.js index 4d2e8e8060..246e00d70c 100644 --- a/packages/app-webdir-ui/server.js +++ b/packages/app-webdir-ui/server.js @@ -4,8 +4,8 @@ * * Endpoints: * - GET /session/token - * - GET /webdir-profiles/faculty-staff/filtered - * - POST /webdir-profiles/department + * - GET /webdir-profiles/* + * - POST /webdir-profiles/* */ const express = require("express"); @@ -123,25 +123,6 @@ app.get(withApiPrefix("/session/token"), async (req, res) => { } }); -app.get( - withApiPrefix("/webdir-profiles/faculty-staff/filtered"), - async (req, res) => { - console.log("[API] GET /webdir-profiles/faculty-staff/filtered", req.query); - try { - const upstreamResponse = await proxyRequest({ - path: "webdir-profiles/faculty-staff/filtered", - method: "GET", - query: req.query, - }); - const response = await upstreamResponse.json(); - res.status(upstreamResponse.status).json(response); - } catch (error) { - console.error("[API] Error:", error.message); - res.status(500).json({ error: error.message }); - } - } -); - app.get(withApiPrefix("/webdir-profiles/*"), async (req, res) => { console.log("[API] GET", req.path, req.query); try { @@ -157,26 +138,6 @@ app.get(withApiPrefix("/webdir-profiles/*"), async (req, res) => { } }); -app.post(withApiPrefix("/webdir-profiles/department"), async (req, res) => { - console.log("[API] POST /webdir-profiles/department", req.body); - try { - const upstreamResponse = await proxyRequest({ - path: "webdir-profiles/department", - method: "POST", - query: req.query, - body: req.body, - headers: { - "X-CSRF-Token": req.headers["x-csrf-token"] || "", - }, - }); - const response = await upstreamResponse.json(); - res.status(upstreamResponse.status).json(response); - } catch (error) { - console.error("[API] Error:", error.message); - res.status(500).json({ error: error.message }); - } -}); - app.post(withApiPrefix("/webdir-profiles/*"), async (req, res) => { console.log("[API] POST", req.path, req.body); try { From 6f4fce2085899bb4543deda168d2dd37789a0327 Mon Sep 17 00:00:00 2001 From: Scott Williams <5209283+scott-williams-az@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:08:47 -0700 Subject: [PATCH 11/11] chore(app-webdir-ui): update readme --- packages/app-webdir-ui/README.md | 175 +++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 53 deletions(-) diff --git a/packages/app-webdir-ui/README.md b/packages/app-webdir-ui/README.md index 11a51c2a9d..ba70946532 100644 --- a/packages/app-webdir-ui/README.md +++ b/packages/app-webdir-ui/README.md @@ -2,70 +2,139 @@ This package is intended to provde the components needed for Search, the designs for which can be found [here](https://xd.adobe.com/view/41641639-f009-41e2-802c-6859906edb2c-1437/grid/). -In practice, only two components will ever be used, to wit `SearchPage` and `WebDirectoryComponent`, the rest are to be used within those components. -Let's take them one at a time. +In practice, only two components of this package are intended for use `SearchPage` and `WebDirectoryComponent`, the rest are to be used within those components. + +## Shared Props +Used by `SearchPage` and `WebDirectoryComponent` + +| Prop Name | Type | Description | Default Value | +|-----------|------|-------------|---------------| +| `API_URL` | string | URL endpoint for searching. | - | +| `searchApiVersion` | string | API version string for constructing the API URL. | - | false | + +**Full API URL format:** + +`{{API_URL}}{{searchApiVersion}}endpoint/path/with?parameters`. + +**Example result:** + +`https://asuapp2dev.prod.acquia-sites.com/api/v1/webdir-profiles/faculty-staff` + ## `SearchPage` -The `SearchPage` component can take two props, `searchURL` and `loggedIn`. -- `searchURL` is a string that tells the component where to search. The endpoint names -will be appended to this. For example, by providing the URL "https://dev-asu-isearch.ws.asu.edu/api/v1/, the staff data will be found at https://dev-asu-isearch.ws.asu.edu/api/v1/webdir-profiles/faculty-staff. -- `loggedIn` is a boolean that tells the component if the user is currently logged in. In -practice this simply controls whether the admin buttons are displayed. +The `SearchPage` component has 3 props: `API_URL`, `searchApiVersion`, and `loggedIn`. + +| Prop Name | Type | Description | Default Value | +|-----------|------|-------------|---------------| +| `API_URL` | string | See [Shared Props](#shared-props) section| - | +| `searchApiVersion` | string | See [Shared Props](#shared-props) section| - | +| `loggedIn` | boolean | Indicates whether the user is currently logged in. Controls whether admin buttons are displayed. | false | -To see an example of how to use this page, take a look at `examples/index.html`. -You can also see the storybook example at `src/SearchPage/index.stories.js`. +To see an example of how to use this page, take a look at the storybook example at `src/SearchPage/index.stories.js`. ## `WebDirectoryComponent` -This component is a bit more complicated. It's props, `searchType`, `ids`, and `searchURL`, dictate which of three different scenarios is active. -- `searchType` can be one of four different values: - - departments - - people - - people_departments - - faculty_rank -- `ids` is either a lit of comma-separated ids (when `searchType` is 'departments) or -a list of objects containing `asuriteid` and `dept` pairs (when `searchType` is 'people' or 'people_departments') -- `searchURL` is a string that tells the component where to search. The endpoint names -will be appended to this. For example, by providing the URL "https://dev-asu-isearch.ws.asu.edu/api/v1/, the data will be found at https://dev-asu-isearch.ws.asu.edu/api/v1/webdir-departments/profiles. +The `WebDirectoryComponent` supports multiple display scenarios based on `searchType` and ID inputs. -Here's some examples of the props: +| Prop Name | Type | Description | Default Value | +|-----------|------|-------------|---------------| +| `API_URL` | string | See [Shared Props](#shared-props) section| - | +| `searchApiVersion` | string | See [Shared Props](#shared-props) section| - | +| `searchType` | string | Search mode. Supported values: `departments`, `faculty_rank`, `people`, `people_departments`. | - | +| `ids` | string | Comma-separated IDs. For `people` and `people_departments`, pass pairs in `asurite:dept` format (example: `jdoe:1234,asmith:5678`). | - | +| `deptIds` | string | Comma-separated department IDs. Used by `departments` and `faculty_rank`. | - | +| `profileURLBase` | string | Base URL used for profile links. | `https://search.asu.edu` | +| `appPathFolder` | string | Optional app path folder used in generated links. | - | +| `display` | object | Display config (pager, sort, profiles per page, grid mode, profile exclusions). | - | +| `filters` | object | Filters sent with requests (employee, expertise, title, campuses). | - | +| `alphaFilter` | string | Enables last-initial filter UI when set to `"true"`. | `"false"` | +Examples of props: + +```javascript +// Scenario 1 - Display Web Directory for departments +{ + searchType: "departments", + deptIds: "1457,1374", + ids: "", + API_URL: "https://asuapp2dev.prod.acquia-sites.com/", + searchApiVersion: "api/v1/", + filters: {}, +} ``` -Scenario 1 - Display Web Directory for departments: - webdirUI.initWebDirectory({ - targetSelector: "#searchPageContainer", - props: { - searchType: 'departments', - ids: ['1457', '1374'], - searchURL="https://dev-asu-isearch.ws.asu.edu/api/v1/" - filters: {...}, - }, - }); -``` + +```javascript +// Scenario 2 - Display Web Directory for people +{ + searchType: "people", + ids: "jdoe:1457,asmith:1374", + deptIds: "", + API_URL: "https://asuapp2dev.prod.acquia-sites.com/", + searchApiVersion: "api/v1/", + filters: {}, +} ``` -Scenario 2 - Display Web Directory for people: - webdirUI.initWebDirectory({ - targetSelector: "#searchPageContainer", - props: { - searchType: 'people', - ids: [{asuriteid: 123, dept: 1457}, {asuriteid: 456, dept: 1374}], - searchURL="https://dev-asu-isearch.ws.asu.edu/api/v1/" - filters: {...}, - }, - }); + +```javascript +// Scenario 3 - Display Web Directory for people and departments +{ + searchType: "people_departments", + ids: "jdoe:1457,asmith:1374", + deptIds: "", + API_URL: "https://asuapp2dev.prod.acquia-sites.com/", + searchApiVersion: "api/v1/", + filters: {}, +} ``` + +```javascript +// Scenario 4 - Departments view with custom display options and employee/title filters +{ + searchType: "departments", + deptIds: "1457,1374", + ids: "", + API_URL: "https://asuapp2dev.prod.acquia-sites.com/", + searchApiVersion: "api/v1/", + display: { + defaultSort: "last_name", + profilesPerPage: "12", + usePager: "1", + grid: "true", + doNotDisplayProfiles: "jdoe,asmith", + }, + filters: { + employee: "1", + title: "Professor", + }, + alphaFilter: "true", +} ``` -Scenario 3 - Display Web Directory for people and departments: - webdirUI.initWebDirectory({ - targetSelector: "#searchPageContainer", - props: { - searchType: 'people_departments', - ids: [{asuriteid: 123, dept: 1457}, {asuriteid: 456, dept: 1374}], - searchURL="https://dev-asu-isearch.ws.asu.edu/api/v1/" - filters: {...}, - }, - }); + +```javascript +// Scenario 5 - Faculty rank view with expertise/campus filters and compact list display +{ + searchType: "faculty_rank", + deptIds: "1457", + ids: "", + API_URL: "https://asuapp2dev.prod.acquia-sites.com/", + searchApiVersion: "api/v1/", + display: { + defaultSort: "people_order", + profilesPerPage: "6", + usePager: "0", + grid: "false", + }, + filters: { + expertise: "Data Science", + campuses: "Tempe", + }, + alphaFilter: "false", +} ``` -To see an example of how to use this page, take a look at `examples/web-directory.html`. -You can also see the storybook example at `src/WebDirectoryComponent/index.stories.js`. +Display behavior note: + +- For `departments`, `people`, and `people_departments`, rendering goes through `ASUSearchResultsList`, so options like `profilesPerPage`, `usePager`, `defaultSort`, and `doNotDisplayProfiles` are applied there. +- For `faculty_rank`, rendering goes through `FacultyRankTabPanels`. Grid mode and filter inputs still apply, but list-specific behavior may differ from non-`faculty_rank` modes. + +To see an example of how to use this page, take a look at the storybook example at `src/WebDirectoryComponent/index.stories.js`.