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-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/.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/.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 6d77b30767..531618621c 100644 --- a/packages/app-webdir-ui/.storybook/main.js +++ b/packages/app-webdir-ui/.storybook/main.js @@ -1,20 +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.production" : ".env.development"; + loadEnvFile(resolve(packageRoot, envFileName)); +} + const config = { 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 c0af51ca71..85dbf5398e 100644 --- a/packages/app-webdir-ui/.storybook/preview.js +++ b/packages/app-webdir-ui/.storybook/preview.js @@ -1,6 +1,6 @@ -import React, { useEffect} from "react"; +import React, { useEffect, useRef } from "react"; import { MemoryRouter, useLocation, useSearchParams } from "react-router-dom"; -import { useArgs } from 'storybook/preview-api'; +import { useArgs } from "storybook/preview-api"; import "@asu/unity-bootstrap-theme/src/scss/unity-bootstrap-theme.bundle.scss"; @@ -14,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 areObjectsEqual = (left = {}, right = {}) => + JSON.stringify(left) === JSON.stringify(right); -const Wrapper = ({ args, updateArgs,...props}) => { +const Wrapper = ({ args, updateArgs, ...props }) => { const location = useLocation(); - const [searchParams, setSearchParams] = useSearchParams(); + const [, setSearchParams] = useSearchParams(); + const seededFromArgsRef = useRef(false); - useEffect(()=>{ - updateArgs({ - ...args, - searchParams: getParamObject(new URLSearchParams(location.search)), - }) - },[location.search]) + useEffect(() => { + const nextSearchParams = getParamObject(new URLSearchParams(location.search)); + if (!areObjectsEqual(args.searchParams, nextSearchParams)) { + updateArgs({ + searchParams: nextSearchParams, + }); + } + }, [args.searchParams, location.search, updateArgs]); - useEffect(()=>{ - setSearchParams({ - ...getParamObject(searchParams), - ...args.searchParams, - }); - },[args.searchParams]) + useEffect(() => { + if (seededFromArgsRef.current) { + return; + } + + 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 ( + - + + ); }, ]; 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`. diff --git a/packages/app-webdir-ui/package.json b/packages/app-webdir-ui/package.json index fe3d1f565d..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", @@ -80,6 +83,7 @@ "jsdoc-to-markdown": "^9.0.0", "jsdoc-ts-utils": "^2.0.1", "jsdom-screenshot": "^4.0.0", + "msw": "^2.7.0", "postcss-loader": "^6.1.1", "raw-loader": "^4.0.2", "sass": "^1.39.2", @@ -98,5 +102,10 @@ }, "volta": { "extends": "../../package.json" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/packages/app-webdir-ui/server.js b/packages/app-webdir-ui/server.js new file mode 100644 index 0000000000..246e00d70c --- /dev/null +++ b/packages/app-webdir-ui/server.js @@ -0,0 +1,179 @@ +/** + * Development backend server for Web Directory API endpoints. + * Proxies requests to the real API for Storybook development. + * + * Endpoints: + * - GET /session/token + * - GET /webdir-profiles/* + * - POST /webdir-profiles/* + */ + +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/*"), 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/*"), 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 7767f4fb8e..6bf0d9d58c 100644 --- a/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js +++ b/packages/app-webdir-ui/src/FacultyRankComponent/index.stories.js @@ -3,6 +3,9 @@ import React from "react"; import { FullLayout } from "@asu/shared"; import { WebDirectory } from "../WebDirectoryComponent/index"; +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", decorators: [story => {story()}], @@ -35,8 +38,8 @@ export const FacultyRankWebDirectory = args => { { : ""; 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/SearchPage/index.stories.js b/packages/app-webdir-ui/src/SearchPage/index.stories.js index f20a2306d6..f11bababa5 100644 --- a/packages/app-webdir-ui/src/SearchPage/index.stories.js +++ b/packages/app-webdir-ui/src/SearchPage/index.stories.js @@ -4,6 +4,9 @@ import { SearchPage } from "./index"; import { FullLayout } from "@asu/shared"; +const API_URL = import.meta.env.VITE_API_URL; +const searchApiVersion = import.meta.env.VITE_SEARCH_API_VERSION; + export default { title: "Organisms/Search Page/Templates", decorators: [story => {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 4786c236bd..43e38d550e 100644 --- a/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js +++ b/packages/app-webdir-ui/src/WebDirectoryComponent/index.stories.js @@ -4,6 +4,9 @@ import { WebDirectory } from "./index"; import { FullLayout } from "@asu/shared"; +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", argTypes: { @@ -50,8 +53,8 @@ export const webDirectoryExampleDepartments = args => { {
); }; +webDirectoryExampleDepartments.args = { + alphaFilter: "true", +}; export const webDirectoryExamplePeople = args => { return ( @@ -71,8 +77,8 @@ export const webDirectoryExamplePeople = args => { { {
); }; +webDirectoryExampleDepartmentsAndPeople.args = { + alphaFilter: "true", +}; 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/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..9d9655b715 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" @@ -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" @@ -203,6 +204,7 @@ __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" 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 @@ -11921,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" @@ -11965,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" @@ -12721,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" @@ -27550,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" @@ -27559,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" @@ -28137,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" @@ -29432,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 @@ -30182,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: @@ -32382,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" @@ -32416,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"