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", 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", 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..5a684e7733 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()}], }; @@ -70,7 +72,7 @@ export const webDirectoryExamplePeople = args => {
{
{ + 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..c6266dc1f2 --- /dev/null +++ b/packages/app-webdir-ui/src/helpers/webDirectoryMockProfiles.js @@ -0,0 +1,493 @@ +// @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: "jwhitfi", + eid: "454517", + deptId: "1350", + displayName: "Jordan Whitfield", + firstName: "Jordan", + lastName: "Whitfield", + titles: ["President"], + deptName: "Office of the President", + email: "jordan.whitfield@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 + }, + // Additional fictional profiles so pagination, the "More" tab overflow, + // and the "Filter by Last Initial" alpha filter all have enough volume + // and last-name diversity to exercise the full experience in Storybook. + { + asuriteId: "pnakamu", + eid: "1358010", + deptId: "1358", + displayName: "Priya Nakamura", + firstName: "Priya", + lastName: "Nakamura", + titles: ["Clinical Professor"], + deptName: "Ira A. Fulton Schools of Engineering", + email: "priya.nakamura@asu.edu", + phone: "480/965-3301", + rankGroup: "1", // Faculty + }, + { + asuriteId: "mdelgad2", + eid: "1344010", + deptId: "1344", + displayName: "Marcus Delgado", + firstName: "Marcus", + lastName: "Delgado", + titles: ["Distinguished Professor"], + deptName: "W. P. Carey School of Business", + email: "marcus.delgado@asu.edu", + phone: "480/965-3302", + rankGroup: "1", // Faculty + }, + { + asuriteId: "dokafor", + eid: "1353010", + deptId: "1353", + displayName: "Devon Okafor", + firstName: "Devon", + lastName: "Okafor", + titles: ["Professor"], + deptName: "College of Health Solutions", + email: "devon.okafor@asu.edu", + phone: "480/965-3303", + rankGroup: "1", // Faculty + }, + { + asuriteId: "hlindqv", + eid: "1350010", + deptId: "1350", + displayName: "Harper Lindqvist", + firstName: "Harper", + lastName: "Lindqvist", + titles: ["Associate Professor"], + deptName: "School of Life Sciences", + email: "harper.lindqvist@asu.edu", + phone: "480/965-3304", + rankGroup: "1", // Faculty + }, + { + asuriteId: "raberna", + eid: "1349010", + deptId: "1349", + displayName: "Reese Abernathy", + firstName: "Reese", + lastName: "Abernathy", + titles: ["Lecturer"], + deptName: "College of Global Futures", + email: "reese.abernathy@asu.edu", + phone: "480/965-3305", + rankGroup: "1", // Faculty + }, + { + asuriteId: "qostrow", + eid: "1358011", + deptId: "1358", + displayName: "Quinn Ostrowski", + firstName: "Quinn", + lastName: "Ostrowski", + titles: ["Program Manager"], + deptName: "Ira A. Fulton Schools of Engineering", + email: "quinn.ostrowski@asu.edu", + phone: "480/965-3306", + rankGroup: "2", // Academic Professionals + }, + { + asuriteId: "rdelacr", + eid: "1350011", + deptId: "1350", + displayName: "Rowan Delacroix", + firstName: "Rowan", + lastName: "Delacroix", + titles: ["Research Coordinator"], + deptName: "School of Life Sciences", + email: "rowan.delacroix@asu.edu", + phone: "480/965-3307", + rankGroup: "2", // Academic Professionals + }, + { + asuriteId: "bmontal", + eid: "1344011", + deptId: "1344", + displayName: "Blair Montalvo", + firstName: "Blair", + lastName: "Montalvo", + titles: ["Senior Academic Advisor"], + deptName: "W. P. Carey School of Business", + email: "blair.montalvo@asu.edu", + phone: "480/965-3308", + rankGroup: "2", // Academic Professionals + }, + { + asuriteId: "nsvensn", + eid: "1353011", + deptId: "1353", + displayName: "Noa Svensson", + firstName: "Noa", + lastName: "Svensson", + titles: ["Assistant Dean"], + deptName: "College of Health Solutions", + email: "noa.svensson@asu.edu", + phone: "480/965-3309", + rankGroup: "2", // Academic Professionals + }, + { + asuriteId: "ewhitak", + eid: "1349011", + deptId: "1349", + displayName: "Ellis Whitaker", + firstName: "Ellis", + lastName: "Whitaker", + titles: ["Adjunct Faculty"], + deptName: "College of Global Futures", + email: "ellis.whitaker@asu.edu", + phone: "480/965-3310", + rankGroup: "3", // Other Faculty and Academic Professionals + }, + { + asuriteId: "kfontai", + eid: "1358012", + deptId: "1358", + displayName: "Kai Fontaine", + firstName: "Kai", + lastName: "Fontaine", + titles: ["Emeritus Professor"], + deptName: "Ira A. Fulton Schools of Engineering", + email: "kai.fontaine@asu.edu", + phone: "480/965-3311", + rankGroup: "3", // Other Faculty and Academic Professionals + }, + { + asuriteId: "vsokolo", + eid: "1344012", + deptId: "1344", + displayName: "Vera Sokolova", + firstName: "Vera", + lastName: "Sokolova", + titles: ["Visiting Scholar"], + deptName: "W. P. Carey School of Business", + email: "vera.sokolova@asu.edu", + phone: "480/965-3312", + rankGroup: "3", // Other Faculty and Academic Professionals + }, + { + asuriteId: "tbranni", + eid: "1518010", + deptId: "1518", + displayName: "Toby Brannigan", + firstName: "Toby", + lastName: "Brannigan", + titles: ["IT Support Specialist"], + deptName: "Herberger Institute for Design and the Arts", + email: "toby.brannigan@asu.edu", + phone: "480/965-3313", + rankGroup: null, + }, + { + asuriteId: "ivasque", + eid: "1520010", + deptId: "1520", + displayName: "Ines Vasquez", + firstName: "Ines", + lastName: "Vasquez", + titles: ["Communications Manager"], + deptName: "Watts College of Public Service and Community Solutions", + email: "ines.vasquez@asu.edu", + phone: "480/965-3314", + rankGroup: null, + }, + { + asuriteId: "dmarsha", + eid: "3534010", + deptId: "3534", + displayName: "Drew Marshall", + firstName: "Drew", + lastName: "Marshall", + titles: ["Events Coordinator"], + deptName: "Mary Lou Fulton Teachers College", + email: "drew.marshall@asu.edu", + phone: "480/965-3315", + rankGroup: null, + }, + { + asuriteId: "ahuang2", + eid: "1353012", + deptId: "1353", + displayName: "Ash Huang", + firstName: "Ash", + lastName: "Huang", + titles: ["Budget Analyst"], + deptName: "College of Health Solutions", + email: "ash.huang@asu.edu", + phone: "480/965-3316", + rankGroup: null, + }, + { + asuriteId: "ncastil", + eid: "1344013", + deptId: "1344", + displayName: "Nico Castillo", + firstName: "Nico", + lastName: "Castillo", + titles: ["Facilities Manager"], + deptName: "W. P. Carey School of Business", + email: "nico.castillo@asu.edu", + phone: "480/965-3317", + rankGroup: null, + }, + { + asuriteId: "zwerner", + eid: "1349012", + deptId: "1349", + displayName: "Zara Werner", + firstName: "Zara", + lastName: "Werner", + titles: ["Office Manager"], + deptName: "College of Global Futures", + email: "zara.werner@asu.edu", + phone: "480/965-3318", + rankGroup: null, + }, +]; + +/** + * 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/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" }, 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" }, 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", 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 }; 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