diff --git a/RELEASE.rst b/RELEASE.rst index 961fd6384f..4c17537b64 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,20 @@ Release Notes ============= +Version 0.25.0 +-------------- + +- Mechanism to sync server prefetch with client API calls (#1798) +- show more button for v2 drawer dates (#1809) +- Add data-ph- elements to CTA buttons (#1821) +- Endpoints for userlist/learningpath memberships (#1808) +- Clear resource_type filter when leaving Learning Materials search tab (#1780) +- Update opensearchproject/opensearch Docker tag to v2.18.0 (#1812) +- Update dependency postcss-styled-syntax to ^0.7.0 (#1811) +- Update dependency ruff to v0.7.3 (#1810) +- Update dependency @chromatic-com/storybook to v3 (#1764) +- learning resource drawer v2 run comparison table (#1782) + Version 0.24.3 (Released November 14, 2024) -------------- diff --git a/docker-compose.opensearch.base.yml b/docker-compose.opensearch.base.yml index 05741b9bfa..f722ca7a47 100644 --- a/docker-compose.opensearch.base.yml +++ b/docker-compose.opensearch.base.yml @@ -1,6 +1,6 @@ services: opensearch: - image: opensearchproject/opensearch:2.17.1 + image: opensearchproject/opensearch:2.18.0 environment: - "cluster.name=opensearch-cluster" - "bootstrap.memory_lock=true" # along with the memlock settings below, disables swapping diff --git a/frontends/api/package.json b/frontends/api/package.json index ecaa7c61c2..12acd63404 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -10,6 +10,7 @@ "./v1": "./src/generated/v1/api.ts", "./hooks/*": "./src/hooks/*/index.ts", "./constants": "./src/common/constants.ts", + "./ssr/*": "./src/ssr/*.ts", "./test-utils/factories": "./src/test-utils/factories/index.ts", "./test-utils": "./src/test-utils/index.ts" }, diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index 99e09952af..c49bbf18d3 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -18233,6 +18233,45 @@ export const LearningpathsApiAxiosParamCreator = function ( options: localVarRequestOptions, } }, + /** + * Get a list of all learning path items + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + learningpathsMembershipList: async ( + options: RawAxiosRequestConfig = {}, + ): Promise => { + const localVarPath = `/api/v1/learningpaths/membership/` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, /** * Update individual fields of a learning path * @summary Update @@ -18672,6 +18711,35 @@ export const LearningpathsApiFp = function (configuration?: Configuration) { configuration, )(axios, operationBasePath || basePath) }, + /** + * Get a list of all learning path items + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async learningpathsMembershipList( + options?: RawAxiosRequestConfig, + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string, + ) => AxiosPromise> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.learningpathsMembershipList(options) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["LearningpathsApi.learningpathsMembershipList"]?.[ + index + ]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, /** * Update individual fields of a learning path * @summary Update @@ -18917,6 +18985,19 @@ export const LearningpathsApiFactory = function ( ) .then((request) => request(axios, basePath)) }, + /** + * Get a list of all learning path items + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + learningpathsMembershipList( + options?: RawAxiosRequestConfig, + ): AxiosPromise> { + return localVarFp + .learningpathsMembershipList(options) + .then((request) => request(axios, basePath)) + }, /** * Update individual fields of a learning path * @summary Update @@ -19456,6 +19537,19 @@ export class LearningpathsApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)) } + /** + * Get a list of all learning path items + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LearningpathsApi + */ + public learningpathsMembershipList(options?: RawAxiosRequestConfig) { + return LearningpathsApiFp(this.configuration) + .learningpathsMembershipList(options) + .then((request) => request(this.axios, this.basePath)) + } + /** * Update individual fields of a learning path * @summary Update @@ -24126,6 +24220,45 @@ export const UserlistsApiAxiosParamCreator = function ( options: localVarRequestOptions, } }, + /** + * Get a list of all userlist items for a user + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + userlistsMembershipList: async ( + options: RawAxiosRequestConfig = {}, + ): Promise => { + const localVarPath = `/api/v1/userlists/membership/` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, /** * Viewset for UserLists * @summary Update @@ -24504,6 +24637,33 @@ export const UserlistsApiFp = function (configuration?: Configuration) { configuration, )(axios, operationBasePath || basePath) }, + /** + * Get a list of all userlist items for a user + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async userlistsMembershipList( + options?: RawAxiosRequestConfig, + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string, + ) => AxiosPromise> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.userlistsMembershipList(options) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["UserlistsApi.userlistsMembershipList"]?.[index]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, /** * Viewset for UserLists * @summary Update @@ -24722,6 +24882,19 @@ export const UserlistsApiFactory = function ( ) .then((request) => request(axios, basePath)) }, + /** + * Get a list of all userlist items for a user + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + userlistsMembershipList( + options?: RawAxiosRequestConfig, + ): AxiosPromise> { + return localVarFp + .userlistsMembershipList(options) + .then((request) => request(axios, basePath)) + }, /** * Viewset for UserLists * @summary Update @@ -25127,6 +25300,19 @@ export class UserlistsApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)) } + /** + * Get a list of all userlist items for a user + * @summary List + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UserlistsApi + */ + public userlistsMembershipList(options?: RawAxiosRequestConfig) { + return UserlistsApiFp(this.configuration) + .userlistsMembershipList(options) + .then((request) => request(this.axios, this.basePath)) + } + /** * Viewset for UserLists * @summary Update diff --git a/frontends/api/src/hooks/channels/index.ts b/frontends/api/src/hooks/channels/index.ts index b86f798c58..549e77778b 100644 --- a/frontends/api/src/hooks/channels/index.ts +++ b/frontends/api/src/hooks/channels/index.ts @@ -27,6 +27,7 @@ const useChannelDetail = (channelType: string, channelName: string) => { ...channels.detailByType(channelType, channelName), }) } + const useChannelCounts = (channelType: string) => { return useQuery({ ...channels.countsByType(channelType), @@ -54,4 +55,5 @@ export { useChannelsList, useChannelPartialUpdate, useChannelCounts, + channels as channelsKeyFactory, } diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index 7633c806f8..6c2ea60ef3 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -500,13 +500,6 @@ const useSchoolsList = () => { return useQuery(learningResources.schools()) } -/* - * Not intended to be imported except for special cases. - * It's used in the ResourceCarousel to dynamically build a single useQueries hook - * from config because a React component cannot conditionally call hooks during renders. - */ -export { default as learningResourcesKeyFactory } from "./keyFactory" - export { useLearningResourcesList, useFeaturedLearningResourcesList, @@ -538,4 +531,5 @@ export { useListItemMove, usePlatformsList, useSchoolsList, + learningResources as learningResourcesKeyFactory, } diff --git a/frontends/api/src/hooks/newsEvents/index.ts b/frontends/api/src/hooks/newsEvents/index.ts index 0e14708dba..b6f0d9b26f 100644 --- a/frontends/api/src/hooks/newsEvents/index.ts +++ b/frontends/api/src/hooks/newsEvents/index.ts @@ -15,4 +15,9 @@ const useNewsEventsDetail = (id: number) => { return useQuery(newsEvents.detail(id)) } -export { useNewsEventsList, useNewsEventsDetail, NewsEventsListFeedTypeEnum } +export { + useNewsEventsList, + useNewsEventsDetail, + NewsEventsListFeedTypeEnum, + newsEvents as newsEventsKeyFactory, +} diff --git a/frontends/api/src/hooks/testimonials/index.ts b/frontends/api/src/hooks/testimonials/index.ts index 2ee1c6f4c5..da98b49afc 100644 --- a/frontends/api/src/hooks/testimonials/index.ts +++ b/frontends/api/src/hooks/testimonials/index.ts @@ -23,4 +23,8 @@ const useTestimonialDetail = (id: number | undefined) => { }) } -export { useTestimonialDetail, useTestimonialList } +export { + useTestimonialDetail, + useTestimonialList, + testimonials as testimonialsKeyFactory, +} diff --git a/frontends/api/src/hooks/widget_lists/index.ts b/frontends/api/src/hooks/widget_lists/index.ts index 2ac9aff1f4..1c8c2c42d2 100644 --- a/frontends/api/src/hooks/widget_lists/index.ts +++ b/frontends/api/src/hooks/widget_lists/index.ts @@ -3,8 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { widgetListsApi } from "../../clients" import widgetLists from "./keyFactory" import { WidgetInstance } from "api/v0" + /** - * Query is diabled if id is undefined. + * Query is disabled if id is undefined. */ const useWidgetList = (id: number | undefined) => { return useQuery({ diff --git a/frontends/api/src/ssr/prefetch.ts b/frontends/api/src/ssr/prefetch.ts new file mode 100644 index 0000000000..6078fcafec --- /dev/null +++ b/frontends/api/src/ssr/prefetch.ts @@ -0,0 +1,13 @@ +import { QueryClient, dehydrate } from "@tanstack/react-query" +import type { Query } from "@tanstack/react-query" + +// Utility to avoid repetition in server components +export const prefetch = async (queries: (Query | unknown)[]) => { + const queryClient = new QueryClient() + + await Promise.all( + queries.map((query) => queryClient.prefetchQuery(query as Query)), + ) + + return dehydrate(queryClient) +} diff --git a/frontends/api/src/ssr/usePrefetchWarnings.test.ts b/frontends/api/src/ssr/usePrefetchWarnings.test.ts new file mode 100644 index 0000000000..ab34473ba8 --- /dev/null +++ b/frontends/api/src/ssr/usePrefetchWarnings.test.ts @@ -0,0 +1,118 @@ +import { renderHook } from "@testing-library/react" +import { useQuery } from "@tanstack/react-query" +import { usePrefetchWarnings } from "./usePrefetchWarnings" +import { setupReactQueryTest } from "../hooks/test-utils" +import { urls, factories, setMockResponse } from "../test-utils" +import { + learningResourcesKeyFactory, + useLearningResourcesDetail, +} from "../hooks/learningResources" + +jest.mock("./usePrefetchWarnings", () => { + const originalModule = jest.requireActual("./usePrefetchWarnings") + return { + ...originalModule, + logQueries: jest.fn(), + } +}) + +describe("SSR prefetch warnings", () => { + beforeEach(() => { + jest.spyOn(console, "info").mockImplementation(() => {}) + jest.spyOn(console, "table").mockImplementation(() => {}) + }) + + it("Warns if a query is requested on the client that has not been prefetched", async () => { + const { wrapper, queryClient } = setupReactQueryTest() + + const data = factories.learningResources.resource() + setMockResponse.get(urls.learningResources.details({ id: 1 }), data) + + renderHook(() => useLearningResourcesDetail(1), { wrapper }) + + renderHook(usePrefetchWarnings, { + wrapper, + initialProps: { queryClient }, + }) + + expect(console.info).toHaveBeenCalledWith( + "The following queries were requested in first render but not prefetched.", + "If these queries are user-specific, they cannot be prefetched - responses are cached on public CDN.", + "Otherwise, consider fetching on the server with prefetch:", + ) + expect(console.table).toHaveBeenCalledWith( + [ + expect.objectContaining({ + disabled: false, + initialStatus: "loading", + key: learningResourcesKeyFactory.detail(1).queryKey, + observerCount: 1, + }), + ], + ["hash", "initialStatus", "status", "observerCount", "disabled"], + ) + }) + + it("Ignores exempted queries requested on the client that have not been prefetched", async () => { + const { wrapper, queryClient } = setupReactQueryTest() + + const data = factories.learningResources.resource() + setMockResponse.get(urls.learningResources.details({ id: 1 }), data) + + renderHook(() => useLearningResourcesDetail(1), { wrapper }) + + renderHook(usePrefetchWarnings, { + wrapper, + initialProps: { + queryClient, + exemptions: [learningResourcesKeyFactory.detail(1).queryKey], + }, + }) + + expect(console.info).not.toHaveBeenCalled() + expect(console.table).not.toHaveBeenCalled() + }) + + it("Warns for queries prefetched on the server but not requested on the client", async () => { + const { wrapper, queryClient } = setupReactQueryTest() + + const data = factories.learningResources.resource() + setMockResponse.get(urls.learningResources.details({ id: 1 }), data) + + // Emulate server prefetch + const { unmount } = renderHook( + () => + useQuery({ + ...learningResourcesKeyFactory.detail(1), + initialData: data, + }), + { wrapper }, + ) + + // Removes observer + unmount() + + renderHook(usePrefetchWarnings, { + wrapper, + initialProps: { queryClient }, + }) + + expect(console.info).toHaveBeenCalledWith( + "The following queries were prefetched on the server but not accessed during initial render.", + "If these queries are no longer in use they should removed from prefetch:", + ) + expect(console.table).toHaveBeenCalledWith( + [ + { + disabled: false, + hash: JSON.stringify(learningResourcesKeyFactory.detail(1).queryKey), + initialStatus: "success", + key: learningResourcesKeyFactory.detail(1).queryKey, + observerCount: 0, + status: "success", + }, + ], + ["hash", "initialStatus", "status", "observerCount", "disabled"], + ) + }) +}) diff --git a/frontends/api/src/ssr/usePrefetchWarnings.ts b/frontends/api/src/ssr/usePrefetchWarnings.ts new file mode 100644 index 0000000000..000d80261c --- /dev/null +++ b/frontends/api/src/ssr/usePrefetchWarnings.ts @@ -0,0 +1,96 @@ +import { useEffect } from "react" +import type { Query, QueryClient, QueryKey } from "@tanstack/react-query" + +const logQueries = (...args: [...string[], Query[]]) => { + const queries = args.pop() as Query[] + console.info(...args) + console.table( + queries.map((query) => ({ + key: query.queryKey, + hash: query.queryHash, + disabled: query.isDisabled(), + initialStatus: query.initialState.status, + status: query.state.status, + observerCount: query.getObserversCount(), + })), + ["hash", "initialStatus", "status", "observerCount", "disabled"], + ) +} + +const PREFETCH_EXEMPT_QUERIES = [["userMe"]] + +/** + * Call this as high as possible in render tree to detect query usage on + * first render. + */ +export const usePrefetchWarnings = ({ + queryClient, + exemptions = [], +}: { + queryClient: QueryClient + /** + * A list of query keys that should be exempted. + * + * NOTE: This uses react-query's hierarchical key matching, so exempting + * ["a", { x: 1 }] will exempt + * - ["a", { x: 1 }] + * - ["a", { x: 1, y: 2 }] + * - ["a", { x: 1, y: 2 }, ...any_other_entries] + */ + exemptions?: QueryKey[] +}) => { + /** + * NOTE: React renders components top-down, but effects run bottom-up, so + * this effect will run after all child effects. + */ + useEffect( + () => { + if (process.env.NODE_ENV === "production") { + return + } + + const cache = queryClient.getQueryCache() + const queries = cache.getAll() + + const exempted = [...exemptions, ...PREFETCH_EXEMPT_QUERIES].map((key) => + cache.find(key), + ) + + const potentialPrefetches = queries.filter( + (query) => + !exempted.includes(query) && + query.initialState.status !== "success" && + !query.isDisabled(), + ) + + if (potentialPrefetches.length > 0) { + logQueries( + "The following queries were requested in first render but not prefetched.", + "If these queries are user-specific, they cannot be prefetched - responses are cached on public CDN.", + "Otherwise, consider fetching on the server with prefetch:", + potentialPrefetches, + ) + } + + const unusedPrefetches = queries.filter( + (query) => + !exempted.includes(query) && + query.initialState.status === "success" && + query.getObserversCount() === 0 && + !query.isDisabled(), + ) + + if (unusedPrefetches.length > 0) { + logQueries( + "The following queries were prefetched on the server but not accessed during initial render.", + "If these queries are no longer in use they should removed from prefetch:", + unusedPrefetches, + ) + } + }, + // We only want to run this on initial render. + // (Aside: queryClient should be a singleton anyway) + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) +} diff --git a/frontends/main/src/app-pages/HomePage/HomePage.tsx b/frontends/main/src/app-pages/HomePage/HomePage.tsx index 475e53efd7..ac4e7891a0 100644 --- a/frontends/main/src/app-pages/HomePage/HomePage.tsx +++ b/frontends/main/src/app-pages/HomePage/HomePage.tsx @@ -1,6 +1,6 @@ "use client" -import React, { Suspense } from "react" +import React from "react" import { Container, styled, theme } from "ol-components" import HeroSearch from "@/page-components/HeroSearch/HeroSearch" import BrowseTopicsSection from "./BrowseTopicsSection" @@ -52,25 +52,21 @@ const HomePage: React.FC = () => {
- - - +
- - - + diff --git a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx index 20a743990c..d5c926cb09 100644 --- a/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx +++ b/frontends/main/src/app-pages/SearchPage/SearchPage.test.tsx @@ -491,6 +491,32 @@ describe("Search Page Tabs", () => { expect(params2.get("department")).toBe("8") // should preserve other params }) + test("Switching from learning materials tab clears resource type only", async () => { + setMockApiResponses({ + search: { + count: 1000, + metadata: { + aggregations: { + resource_type: [{ key: "video", doc_count: 100 }], + }, + suggestions: [], + }, + }, + }) + const { location } = renderWithProviders(, { + url: "?resource_category=learning_material&resource_type=video&topic=Biology", + }) + const tabLM = screen.getByRole("tab", { name: /Learning Materials/ }) + const tabCourses = screen.getByRole("tab", { name: /Courses/ }) + expect(tabLM).toHaveAttribute("aria-selected") + + // Click "Courses" + await user.click(tabCourses) + expect(location.current.search).toBe( + "?resource_category=course&topic=Biology", + ) + }) + test("Tab titles show corret result counts", async () => { setMockApiResponses({ search: { diff --git a/frontends/main/src/app/departments/page.tsx b/frontends/main/src/app/departments/page.tsx index 41364044ee..a9b5133e9f 100644 --- a/frontends/main/src/app/departments/page.tsx +++ b/frontends/main/src/app/departments/page.tsx @@ -1,15 +1,27 @@ import React from "react" import { Metadata } from "next" - +import DepartmentListingPage from "@/app-pages/DepartmentListingPage/DepartmentListingPage" import { standardizeMetadata } from "@/common/metadata" +import { Hydrate } from "@tanstack/react-query" +import { learningResourcesKeyFactory } from "api/hooks/learningResources" +import { channelsKeyFactory } from "api/hooks/channels" +import { prefetch } from "api/ssr/prefetch" + export const metadata: Metadata = standardizeMetadata({ title: "Departments", }) -import DepartmentListingPage from "@/app-pages/DepartmentListingPage/DepartmentListingPage" +const Page: React.FC = async () => { + const dehydratedState = await prefetch([ + channelsKeyFactory.countsByType("department"), + learningResourcesKeyFactory.schools(), + ]) -const Page: React.FC = () => { - return + return ( + + + + ) } export default Page diff --git a/frontends/main/src/app/page.tsx b/frontends/main/src/app/page.tsx index 34dd27b718..ae920e6784 100644 --- a/frontends/main/src/app/page.tsx +++ b/frontends/main/src/app/page.tsx @@ -2,6 +2,13 @@ import React from "react" import type { Metadata } from "next" import HomePage from "@/app-pages/HomePage/HomePage" import { getMetadataAsync } from "@/common/metadata" +import { Hydrate } from "@tanstack/react-query" +import { testimonialsKeyFactory } from "api/hooks/testimonials" +import { + NewsEventsListFeedTypeEnum, + newsEventsKeyFactory, +} from "api/hooks/newsEvents" +import { prefetch } from "api/ssr/prefetch" type SearchParams = { [key: string]: string | string[] | undefined @@ -19,7 +26,25 @@ export async function generateMetadata({ } const Page: React.FC = async () => { - return + const dehydratedState = await prefetch([ + testimonialsKeyFactory.list({ position: 1 }), + newsEventsKeyFactory.list({ + feed_type: [NewsEventsListFeedTypeEnum.News], + limit: 6, + sortby: "-news_date", + }), + newsEventsKeyFactory.list({ + feed_type: [NewsEventsListFeedTypeEnum.Events], + limit: 5, + sortby: "event_date", + }), + ]) + + return ( + + + + ) } export default Page diff --git a/frontends/main/src/app/providers.tsx b/frontends/main/src/app/providers.tsx index 7c0506a702..ddd3a29456 100644 --- a/frontends/main/src/app/providers.tsx +++ b/frontends/main/src/app/providers.tsx @@ -6,10 +6,13 @@ import { QueryClientProvider } from "@tanstack/react-query" import { ThemeProvider, NextJsAppRouterCacheProvider } from "ol-components" import { Provider as NiceModalProvider } from "@ebay/nice-modal-react" import ConfiguredPostHogProvider from "@/page-components/ConfiguredPostHogProvider/ConfiguredPostHogProvider" +import { usePrefetchWarnings } from "api/ssr/usePrefetchWarnings" export default function Providers({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient() + usePrefetchWarnings({ queryClient }) + return ( diff --git a/frontends/main/src/page-components/SearchDisplay/ResourceCategoryTabs.tsx b/frontends/main/src/page-components/SearchDisplay/ResourceCategoryTabs.tsx index 3edd248b96..cefd652e24 100644 --- a/frontends/main/src/page-components/SearchDisplay/ResourceCategoryTabs.tsx +++ b/frontends/main/src/page-components/SearchDisplay/ResourceCategoryTabs.tsx @@ -94,6 +94,9 @@ const ResourceCategoryTabList: React.FC = ({ const tab = tabs.find((t) => t.name === value) setSearchParams((prev) => { const next = new URLSearchParams(prev) + if (prev.get("resource_category") === "learning_material") { + next.delete("resource_type") + } if (tab?.resource_category) { next.set("resource_category", tab.resource_category) } else { diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index 78045e5752..82c0ee2a74 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -45,7 +45,7 @@ "validator": "^13.11.0" }, "devDependencies": { - "@chromatic-com/storybook": "^1.9.0", + "@chromatic-com/storybook": "^3.0.0", "@faker-js/faker": "^9.0.0", "@storybook/addon-actions": "^8.2.9", "@storybook/addon-essentials": "^8.2.9", diff --git a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts index 79eacb61b3..c1dfaf11c1 100644 --- a/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts +++ b/frontends/ol-components/src/components/LearningResourceCard/testUtils.ts @@ -1,4 +1,4 @@ -import { ResourceTypeEnum } from "api" +import { DeliveryEnum, DeliveryEnumDescriptions, ResourceTypeEnum } from "api" import { factories } from "api/test-utils" const _makeResource = factories.learningResources.resource @@ -41,6 +41,18 @@ const resources = { }), } +const sameDataRun = factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.Online, + name: DeliveryEnumDescriptions.online, + }, + ], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "100", currency: "USD" }, + ], +}) const courses = { free: { noCertificate: makeResource({ @@ -152,6 +164,83 @@ const courses = { availability: "dated", }), }, + multipleRuns: { + sameData: makeResource({ + resource_type: ResourceTypeEnum.Course, + free: true, + runs: [ + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + factories.learningResources.run({ + delivery: sameDataRun.delivery, + resource_prices: sameDataRun.resource_prices, + location: sameDataRun.location, + }), + ], + }), + differentData: makeResource({ + resource_type: ResourceTypeEnum.Course, + runs: [ + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.Online, + name: DeliveryEnumDescriptions.online, + }, + ], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "100", currency: "USD" }, + ], + }), + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.Online, + name: DeliveryEnumDescriptions.online, + }, + ], + resource_prices: [ + { amount: "0", currency: "USD" }, + { amount: "100", currency: "USD" }, + ], + }), + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.InPerson, + name: DeliveryEnumDescriptions.in_person, + }, + ], + resource_prices: [{ amount: "150", currency: "USD" }], + location: "Earth", + }), + factories.learningResources.run({ + delivery: [ + { + code: DeliveryEnum.InPerson, + name: DeliveryEnumDescriptions.in_person, + }, + ], + resource_prices: [{ amount: "150", currency: "USD" }], + location: "Earth", + }), + ], + }), + }, } const resourceArgType = { diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.test.tsx new file mode 100644 index 0000000000..99a832b9f0 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.test.tsx @@ -0,0 +1,39 @@ +import React from "react" +import { render, screen, within } from "@testing-library/react" +import { courses } from "../LearningResourceCard/testUtils" +import InfoSectionV2 from "./InfoSectionV2" +import { ThemeProvider } from "../ThemeProvider/ThemeProvider" +import { DeliveryEnumDescriptions } from "api" + +describe("Differing runs comparison table", () => { + test("Does not appear if data is the same", () => { + const course = courses.multipleRuns.sameData + render(, { + wrapper: ThemeProvider, + }) + expect(screen.queryByTestId("differing-runs-table")).toBeNull() + }) + + test("Appears if data is different", () => { + const course = courses.multipleRuns.differentData + render(, { + wrapper: ThemeProvider, + }) + const differingRunsTable = screen.getByTestId("differing-runs-table") + expect(differingRunsTable).toBeInTheDocument() + const onlineLabels = within(differingRunsTable).getAllByText( + DeliveryEnumDescriptions.online, + ) + const inPersonLabels = within(differingRunsTable).getAllByText( + DeliveryEnumDescriptions.in_person, + ) + const onlinePriceLabels = within(differingRunsTable).getAllByText("$100") + const inPersonPriceLabels = within(differingRunsTable).getAllByText("$150") + const earthLocationLabels = within(differingRunsTable).getAllByText("Earth") + expect(onlineLabels).toHaveLength(2) + expect(inPersonLabels).toHaveLength(2) + expect(onlinePriceLabels).toHaveLength(2) + expect(inPersonPriceLabels).toHaveLength(2) + expect(earthLocationLabels).toHaveLength(2) + }) +}) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx new file mode 100644 index 0000000000..57bdaf0843 --- /dev/null +++ b/frontends/ol-components/src/components/LearningResourceExpanded/DifferingRunsTable.tsx @@ -0,0 +1,132 @@ +import React from "react" +import styled from "@emotion/styled" +import { theme } from "../ThemeProvider/ThemeProvider" +import { LearningResource } from "api" +import { + allRunsAreIdentical, + formatRunDate, + getDisplayPrice, + getRunPrices, + showStartAnytime, +} from "ol-utilities" + +const DifferingRuns = styled.div({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + alignSelf: "stretch", + borderRadius: "4px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderBottom: "none", +}) + +const DifferingRun = styled.div({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "16px", + padding: "12px", + alignSelf: "stretch", + borderBottom: `1px solid ${theme.custom.colors.lightGray2}`, +}) + +const DifferingRunHeader = styled.div({ + display: "flex", + alignSelf: "stretch", + alignItems: "center", + gap: "16px", + padding: "12px", + color: theme.custom.colors.darkGray2, + backgroundColor: theme.custom.colors.lightGray1, + ...theme.typography.subtitle3, +}) + +const DifferingRunData = styled.div({ + display: "flex", + color: theme.custom.colors.darkGray2, + ...theme.typography.body3, +}) + +const DifferingRunLabel = styled.strong({ + display: "flex", +}) + +const dateColumnStyle = { + width: "130px", + [theme.breakpoints.down("sm")]: { + width: "auto", + flex: "2 0 0", + }, +} + +const priceColumnStyle = { + width: "110px", + [theme.breakpoints.down("sm")]: { + width: "auto", + flex: "1 0 0", + }, +} + +const formatStyle = { + flex: "1 0 0", +} + +const DateLabel = styled(DifferingRunLabel)(dateColumnStyle) + +const PriceLabel = styled(DifferingRunLabel)(priceColumnStyle) + +const FormatLabel = styled(DifferingRunLabel)(formatStyle) + +const DateData = styled(DifferingRunData)(dateColumnStyle) + +const PriceData = styled(DifferingRunData)(priceColumnStyle) + +const FormatData = styled(DifferingRunData)(formatStyle) + +const DifferingRunLocation = styled(DifferingRunData)({ + flex: "1 0 100%", + flexDirection: "column", + alignSelf: "stretch", +}) + +const DifferingRunsTable: React.FC<{ resource: LearningResource }> = ({ + resource, +}) => { + const asTaughtIn = resource ? showStartAnytime(resource) : false + if (!allRunsAreIdentical(resource)) { + return ( + + + Date + Price + Format + + {resource.runs?.map((run, index) => ( + + {formatRunDate(run, asTaughtIn)} + {run.resource_prices && ( + + {getDisplayPrice(getRunPrices(run)["course"])} + + )} + {run.delivery && ( + + {run.delivery?.map((dm) => dm?.name).join(", ")} + + )} + {run.delivery.filter((d) => d.code === "in_person").length > 0 && + run.location && ( + + Location + {run.location} + + )} + + ))} + + ) + } + return null +} + +export default DifferingRunsTable diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx index d14bac2176..2ae5b4a370 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.test.tsx @@ -5,6 +5,7 @@ import InfoSectionV2 from "./InfoSectionV2" import { ThemeProvider } from "../ThemeProvider/ThemeProvider" import { formatRunDate } from "ol-utilities" import invariant from "tiny-invariant" +import user from "@testing-library/user-event" // This is a pipe followed by a zero-width space const SEPARATOR = "|​" @@ -134,9 +135,9 @@ describe("Learning resource info section start date", () => { within(section).getByText(runDate) }) - test("Multiple Runs", () => { - const course = courses.free.multipleRuns - const expectedDateText = course.runs + test("Multiple run dates", () => { + const course = courses.multipleRuns.sameData + const expectedDateText = `${course.runs ?.sort((a, b) => { if (a?.start_date && b?.start_date) { return Date.parse(a.start_date) - Date.parse(b.start_date) @@ -144,15 +145,39 @@ describe("Learning resource info section start date", () => { return 0 }) .map((run) => formatRunDate(run, false)) - .join(SEPARATOR) + .slice(0, 2) + .join(SEPARATOR)}Show more` invariant(expectedDateText) render(, { wrapper: ThemeProvider, }) const section = screen.getByTestId("drawer-info-items") - within(section).getByText((_content, node) => { + within(section).getAllByText((_content, node) => { return node?.textContent === expectedDateText || false }) }) + + test("If data is different, dates are not shown", () => { + const course = courses.multipleRuns.differentData + render(, { + wrapper: ThemeProvider, + }) + const section = screen.getByTestId("drawer-info-items") + expect(within(section).queryByText("Start Date:")).toBeNull() + }) + + test("Clicking the show more button should show more dates", async () => { + const course = courses.multipleRuns.sameData + const totalRuns = course.runs?.length ? course.runs.length : 0 + render(, { + wrapper: ThemeProvider, + }) + + const runDates = screen.getByTestId("drawer-run-dates") + expect(runDates.children.length).toBe(3) + const showMoreLink = within(runDates).getByText("Show more") + await user.click(showMoreLink) + expect(runDates.children.length).toBe(totalRuns + 1) + }) }) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx index 6c27cf378a..70615e470b 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/InfoSectionV2.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useState } from "react" import styled from "@emotion/styled" import ISO6391 from "iso-639-1" import { @@ -16,12 +16,15 @@ import { } from "@remixicon/react" import { LearningResource, ResourceTypeEnum } from "api" import { + allRunsAreIdentical, formatDurationClockTime, formatRunDate, getLearningResourcePrices, showStartAnytime, } from "ol-utilities" import { theme } from "../ThemeProvider/ThemeProvider" +import DifferingRunsTable from "./DifferingRunsTable" +import { Link } from "../Link/Link" const SeparatorContainer = styled.span({ padding: "0 8px", @@ -86,6 +89,19 @@ const InfoValue = styled.div({ ...theme.typography.body3, }) +const NoWrap = styled.span({ + whiteSpace: "nowrap", +}) + +const ShowMoreLink = styled(Link)({ + paddingLeft: "12px", +}) + +const ShowLessLink = styled(Link)({ + display: "flex", + paddingTop: "4px", +}) + const PriceDisplay = styled.div({ display: "flex", alignItems: "center", @@ -144,6 +160,77 @@ const InfoItemValue: React.FC = ({ ) } +const RunDates: React.FC<{ resource: LearningResource }> = ({ resource }) => { + const [showingMore, setShowingMore] = useState(false) + const asTaughtIn = showStartAnytime(resource) + const sortedDates = resource.runs + ?.sort((a, b) => { + if (a?.start_date && b?.start_date) { + return Date.parse(a.start_date) - Date.parse(b.start_date) + } + return 0 + }) + .map((run) => formatRunDate(run, asTaughtIn)) + const totalDates = sortedDates?.length || 0 + const showMore = totalDates > 2 + if (showMore) { + const ShowHideLink = showingMore ? ShowLessLink : ShowMoreLink + const showMoreLink = ( + + setShowingMore(!showingMore)} + > + {showingMore ? "Show less" : "Show more"} + + + ) + return ( + + {sortedDates?.slice(0, 2).map((runDate, index) => { + return ( + + + + ) + })} + {!showingMore && showMoreLink} + {showingMore && + sortedDates?.slice(2).map((runDate, index) => { + return ( + + + + ) + })} + {showingMore && showMoreLink} + + ) + } else { + const runDates = sortedDates?.map((runDate, index) => { + return ( + + + + ) + }) + return {runDates} + } +} + const INFO_ITEMS: InfoItemConfig = [ { label: (resource: LearningResource) => { @@ -153,33 +240,10 @@ const INFO_ITEMS: InfoItemConfig = [ }, Icon: RiCalendarLine, selector: (resource: LearningResource) => { - const asTaughtIn = resource ? showStartAnytime(resource) : false - if ( - [ResourceTypeEnum.Course, ResourceTypeEnum.Program].includes( - resource.resource_type as "course" | "program", - ) - ) { - const sortedDates = - resource.runs - ?.sort((a, b) => { - if (a?.start_date && b?.start_date) { - return Date.parse(a.start_date) - Date.parse(b.start_date) - } - return 0 - }) - .map((run) => formatRunDate(run, asTaughtIn)) ?? [] - const runDates = - sortedDates.map((runDate, index) => { - return ( - - ) - }) ?? [] - return runDates + const totalDatesWithRuns = + resource.runs?.filter((run) => run.start_date !== null).length || 0 + if (allRunsAreIdentical(resource) && totalDatesWithRuns > 0) { + return } else return null }, }, @@ -392,11 +456,16 @@ const InfoSectionV2 = ({ resource }: { resource?: LearningResource }) => { } return ( - - {infoItems.map((props, index) => ( - - ))} - + <> + + + {infoItems + .filter((props) => props.value !== null) + .map((props, index) => ( + + ))} + + ) } diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx index 8a1a54df9d..122e7f49ea 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.test.tsx @@ -104,6 +104,7 @@ describe("Learning Resource Expanded", () => { }) as HTMLAnchorElement expect(link.href).toMatch(new RegExp(`^${resource.url}/?$`)) + expect(link.getAttribute("data-ph-action")).toBe("click-cta") } }, ) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx index a0155829db..09005f595a 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV1.tsx @@ -244,6 +244,10 @@ const CallToActionSection = ({ } href={getCallToActionUrl(resource) || ""} > diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx index 0f242e754e..3af8b4f2c2 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.test.tsx @@ -105,6 +105,7 @@ describe("Learning Resource Expanded", () => { }) as HTMLAnchorElement expect(link.href).toMatch(new RegExp(`^${resource.url}/?$`)) + expect(link.getAttribute("data-ph-action")).toBe("click-cta") } }, ) diff --git a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx index d273f2f9cb..4fdefd396e 100644 --- a/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx +++ b/frontends/ol-components/src/components/LearningResourceExpanded/LearningResourceExpandedV2.tsx @@ -78,18 +78,19 @@ const RightContainer = styled.div({ }, }) -const ImageContainer = styled.div<{ aspect: number }>` - position: relative; - width: 100%; - padding-bottom: ${({ aspect }) => 100 / aspect}%; -` - -const Image = styled(NextImage)({ - borderRadius: "8px", +const ImageContainer = styled.div({ width: "100%", - objectFit: "cover", }) +const Image = styled(NextImage)<{ aspect: number }>` + position: relative !important; + border-radius: 8px; + width: 100%; + aspect-ratio: ${({ aspect }) => aspect}; + object-fit: cover; + z-index: -1; +` + const SkeletonImage = styled(Skeleton)<{ aspect: number }>((aspect) => ({ borderRadius: "8px", paddingBottom: `${100 / aspect.aspect}%`, @@ -243,20 +244,22 @@ const ImageSection: React.FC<{ ) } else if (resource?.image) { return ( - + {resource?.image.alt ) } else if (resource) { return ( - + {resource.image?.alt @@ -354,6 +357,10 @@ const CallToActionSection = ({ } href={getCallToActionUrl(resource) || ""} > diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts index 6d36d2d010..fae76e8f34 100644 --- a/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts +++ b/frontends/ol-utilities/src/learning-resources/learning-resources.test.ts @@ -1,6 +1,7 @@ -import { findBestRun } from "./learning-resources" +import { allRunsAreIdentical, findBestRun } from "./learning-resources" import * as factories from "api/test-utils/factories" import { faker } from "@faker-js/faker/locale/en" +import { CourseResourceDeliveryInnerCodeEnum } from "api" const makeRun = factories.learningResources.run const fromNow = (days: number): string => { @@ -80,3 +81,127 @@ describe("findBestRun", () => { expect(actual).toEqual(expected) }) }) + +describe("allRunsAreIdentical", () => { + test("returns true if no runs", () => { + const resource = factories.learningResources.resource() + resource.runs = [] + expect(allRunsAreIdentical(resource)).toBe(true) + }) + + test("returns true if only one run", () => { + const resource = factories.learningResources.resource() + resource.runs = [makeRun()] + expect(allRunsAreIdentical(resource)).toBe(true) + }) + + test("returns true if all runs are identical", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + ] + expect(allRunsAreIdentical(resource)).toBe(true) + }) + + test("returns false if prices differ", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: [{ amount: "150", currency: "USD" }], + delivery: delivery, + location: location, + }), + ] + expect(allRunsAreIdentical(resource)).toBe(false) + }) + + test("returns false if delivery methods differ", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: [ + { code: CourseResourceDeliveryInnerCodeEnum.Online, name: "Online" }, + ], + location: location, + }), + ] + expect(allRunsAreIdentical(resource)).toBe(false) + }) + + test("returns false if locations differ", () => { + const resource = factories.learningResources.resource() + const prices = [{ amount: "100", currency: "USD" }] + const delivery = [ + { code: CourseResourceDeliveryInnerCodeEnum.InPerson, name: "In person" }, + ] + const location = "New York" + resource.runs = [ + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: location, + }), + makeRun({ + resource_prices: prices, + delivery: delivery, + location: "San Francisco", + }), + ] + expect(allRunsAreIdentical(resource)).toBe(false) + }) +}) diff --git a/frontends/ol-utilities/src/learning-resources/learning-resources.ts b/frontends/ol-utilities/src/learning-resources/learning-resources.ts index 5c59d48cab..74e6973ec5 100644 --- a/frontends/ol-utilities/src/learning-resources/learning-resources.ts +++ b/frontends/ol-utilities/src/learning-resources/learning-resources.ts @@ -1,6 +1,6 @@ import moment from "moment" import type { LearningResource, LearningResourceRun } from "api" -import { ResourceTypeEnum } from "api" +import { DeliveryEnum, ResourceTypeEnum } from "api" import { capitalize } from "lodash" import { formatDate } from "../date/format" @@ -116,6 +116,52 @@ const formatRunDate = ( return null } +/** + * Checks if all runs of a given learning resource are identical in terms of price, delivery method, and location. + * + * @param resource - The learning resource to check. + * @returns `true` if all runs have the same price, delivery method, and location; otherwise, `false`. + */ +const allRunsAreIdentical = (resource: LearningResource) => { + if (!resource.runs) { + return true + } + if (resource.runs.length <= 1) { + return true + } + const amounts = new Set() + const currencies = new Set() + const deliveryMethods = new Set() + const locations = new Set() + for (const run of resource.runs) { + if (run.resource_prices) { + run.resource_prices.forEach((price) => { + if (!(resource.free && price.amount === "0")) { + amounts.add(price.amount) + currencies.add(price.currency) + } + }) + } + if (run.delivery) { + for (const dm of run.delivery) { + deliveryMethods.add(dm.code) + } + } + if (run.location) { + locations.add(run.location) + } + } + const hasInPerson = [...deliveryMethods].some( + (dm) => dm === DeliveryEnum.InPerson, + ) + return ( + amounts.size === 1 && + currencies.size === 1 && + deliveryMethods.size === 1 && + (hasInPerson ? locations.size === 1 : locations.size === 0) + ) +} + export { DEFAULT_RESOURCE_IMG, embedlyCroppedImage, @@ -123,5 +169,6 @@ export { getReadableResourceType, findBestRun, formatRunDate, + allRunsAreIdentical, } export type { EmbedlyConfig } diff --git a/frontends/ol-utilities/src/learning-resources/pricing.ts b/frontends/ol-utilities/src/learning-resources/pricing.ts index f8f5867a98..7b13bd0e75 100644 --- a/frontends/ol-utilities/src/learning-resources/pricing.ts +++ b/frontends/ol-utilities/src/learning-resources/pricing.ts @@ -1,4 +1,9 @@ -import { LearningResource, LearningResourcePrice, ResourceTypeEnum } from "api" +import { + LearningResource, + LearningResourcePrice, + LearningResourceRun, + ResourceTypeEnum, +} from "api" import { findBestRun } from "ol-utilities" import getSymbolFromCurrency from "currency-symbol-map" @@ -30,20 +35,23 @@ type Prices = { certificate: null | LearningResourcePrice[] } -const getPrices = (resource: LearningResource): Prices => { - const sortedNonzero = resource.resource_prices - ? resource.resource_prices - .sort( - (a: LearningResourcePrice, b: LearningResourcePrice) => - Number(a.amount) - Number(b.amount), - ) - .filter((price: LearningResourcePrice) => Number(price.amount) > 0) - : [] - +const getPrices = (prices: LearningResourcePrice[]) => { + const sortedNonzero = prices + .sort( + (a: LearningResourcePrice, b: LearningResourcePrice) => + Number(a.amount) - Number(b.amount), + ) + .filter((price: LearningResourcePrice) => Number(price.amount) > 0) const priceRange = sortedNonzero.filter( (price, index, arr) => index === 0 || index === arr.length - 1, ) - const prices = priceRange.length > 0 ? priceRange : null + return priceRange.length > 0 ? priceRange : null +} + +const getResourcePrices = (resource: LearningResource): Prices => { + const prices = resource.resource_prices + ? getPrices(resource.resource_prices) + : [] if (resource.free) { return resource.certification @@ -56,6 +64,15 @@ const getPrices = (resource: LearningResource): Prices => { } } +export const getRunPrices = (run: LearningResourceRun): Prices => { + const prices = run.resource_prices ? getPrices(run.resource_prices) : [] + + return { + course: prices ?? PAID, + certificate: null, + } +} + const getDisplayPrecision = (price: number) => { if (Number.isInteger(price)) { return price.toFixed(0) @@ -63,7 +80,9 @@ const getDisplayPrecision = (price: number) => { return price.toFixed(2) } -const getDisplayPrice = (price: Prices["course"] | Prices["certificate"]) => { +export const getDisplayPrice = ( + price: Prices["course"] | Prices["certificate"], +) => { if (price === null) { return null } @@ -82,7 +101,7 @@ const getDisplayPrice = (price: Prices["course"] | Prices["certificate"]) => { } export const getLearningResourcePrices = (resource: LearningResource) => { - const prices = getPrices(resource) + const prices = getResourcePrices(resource) return { course: { value: prices.course, diff --git a/frontends/package.json b/frontends/package.json index 7f5deed23e..2d6b861090 100644 --- a/frontends/package.json +++ b/frontends/package.json @@ -67,7 +67,7 @@ "jest-fail-on-console": "^3.2.0", "jest-watch-typeahead": "^2.2.2", "jest-when": "^3.6.0", - "postcss-styled-syntax": "^0.6.4", + "postcss-styled-syntax": "^0.7.0", "prettier": "^3.3.3", "prettier-plugin-django-alpine": "^1.2.6", "stylelint": "^15.2.0", diff --git a/learning_resources/permissions.py b/learning_resources/permissions.py index d5a6054eb2..50deec908d 100644 --- a/learning_resources/permissions.py +++ b/learning_resources/permissions.py @@ -47,6 +47,15 @@ def has_object_permission(self, request, view, obj): # noqa: ARG002 return can_edit +class HasLearningPathMembershipPermissions(BasePermission): + """ + Permission to view all LearningPath memberships + """ + + def has_permission(self, request, view): # noqa: ARG002 + return is_admin_user(request) or is_learning_path_editor(request) + + class HasLearningPathItemPermissions(BasePermission): """Permission to view/create/modify LearningPathItems""" diff --git a/learning_resources/urls.py b/learning_resources/urls.py index 6a770de2a5..29bd8ec8c2 100644 --- a/learning_resources/urls.py +++ b/learning_resources/urls.py @@ -100,6 +100,16 @@ router.register(r"offerors", views.OfferedByViewSet, basename="offerors_api") v1_urls = [ + path( + "learningpaths/membership/", + views.LearningPathMembershipViewSet.as_view({"get": "list"}), + name="learningpaths_api-membership", + ), + path( + "userlists/membership/", + views.UserListMembershipViewSet.as_view({"get": "list"}), + name="userlists_api-membership", + ), *router.urls, *nested_learning_resources_router.urls, *nested_courses_router.urls, diff --git a/learning_resources/views.py b/learning_resources/views.py index 550eb083ab..459a4ac1a6 100644 --- a/learning_resources/views.py +++ b/learning_resources/views.py @@ -18,6 +18,7 @@ from rest_framework.filters import OrderingFilter from rest_framework.generics import get_object_or_404 from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework_nested.viewsets import NestedViewSetMixin @@ -71,6 +72,8 @@ LearningResourceSchoolSerializer, LearningResourceSerializer, LearningResourceTopicSerializer, + MicroLearningPathRelationshipSerializer, + MicroUserListRelationshipSerializer, PodcastEpisodeResourceSerializer, PodcastResourceSerializer, ProgramResourceSerializer, @@ -428,6 +431,32 @@ def get_queryset(self): return queryset +@extend_schema_view( + list=extend_schema( + summary="List", description="Get a list of all learning path items" + ), +) +class LearningPathMembershipViewSet(viewsets.ReadOnlyModelViewSet): + """Viewset for listing all learning path relationships""" + + serializer_class = MicroLearningPathRelationshipSerializer + permission_classes = (permissions.HasLearningPathMembershipPermissions,) + http_method_names = ["get"] + + def get_queryset(self): + """ + Generate a QuerySet for fetching all LearningResourceRelationships + with a parent of resource type "learning_path" + + Returns: + QuerySet of LearningResourceRelationships objects with learning path parents + """ + return LearningResourceRelationship.objects.filter( + child__published=True, + parent__resource_type=LearningResourceType.learning_path.name, + ).order_by("child", "parent") + + @extend_schema_view( list=extend_schema( summary="Nested Learning Resource List", @@ -872,6 +901,31 @@ def podcast_rss_feed(request): # noqa: ARG001 ) +@extend_schema_view( + list=extend_schema( + summary="List", description="Get a list of all userlist items for a user" + ), +) +class UserListMembershipViewSet(viewsets.ReadOnlyModelViewSet): + """Viewset for all user list relationships""" + + serializer_class = MicroUserListRelationshipSerializer + permission_classes = (IsAuthenticated,) + http_method_names = ["get"] + + def get_queryset(self): + """ + Generate a QuerySet for fetching all UserListRelationships for the user + + Returns: + QuerySet of UserListRelationship objects authored by the user + """ + return UserListRelationship.objects.filter( + child__published=True, + parent__author=self.request.user, + ).order_by("child", "parent") + + @method_decorator(blocked_ip_exempt, name="dispatch") class WebhookOCWView(views.APIView): """ diff --git a/learning_resources/views_learningpath_test.py b/learning_resources/views_learningpath_test.py index f3214fdd43..a7b1d032f5 100644 --- a/learning_resources/views_learningpath_test.py +++ b/learning_resources/views_learningpath_test.py @@ -360,6 +360,28 @@ def test_learning_path_endpoint_delete(client, user, is_editor): ) +@pytest.mark.parametrize("is_editor", [True, False]) +def test_learning_path_endpoint_membership_get(client, user, is_editor): + """Test learning path membership endpoint""" + update_editor_group(user, is_editor) + learning_paths = factories.LearningResourceFactory.create_batch( + 3, is_learning_path=True + ) + relationships = models.LearningResourceRelationship.objects.filter( + parent__in=learning_paths + ).order_by("child", "parent") + + client.force_login(user) + resp = client.get(reverse("lr:v1:learningpaths_api-membership")) + if is_editor: + assert len(resp.data) == relationships.count() + for idx, relationship in enumerate(relationships): + assert resp.data[idx]["parent"] == relationship.parent_id + assert resp.data[idx]["child"] == relationship.child_id + else: + assert resp.status_code == 403 + + @pytest.mark.parametrize("is_editor", [True, False]) def test_get_resource_learning_paths(user_client, user, is_editor): """Test that the learning paths are returned for a resource""" diff --git a/learning_resources/views_userlist_test.py b/learning_resources/views_userlist_test.py index ff8679b09d..773efd4d81 100644 --- a/learning_resources/views_userlist_test.py +++ b/learning_resources/views_userlist_test.py @@ -112,6 +112,30 @@ def test_user_list_endpoint_patch(client, update_topics): ) +@pytest.mark.parametrize("is_authenticated", [True, False]) +def test_user_list_endpoint_membership_get(client, user, is_authenticated): + """Test user list membership endpoint""" + factories.UserListRelationshipFactory.create_batch( + 3, parent=factories.UserListFactory.create(author=user) + ) + factories.UserListRelationshipFactory.create_batch(2) + + relationships = UserListRelationship.objects.filter(parent__author=user).order_by( + "child", "parent" + ) + assert relationships.count() == 3 + if is_authenticated: + client.force_login(user) + resp = client.get(reverse("lr:v1:userlists_api-membership")) + if is_authenticated: + assert len(resp.data) == relationships.count() + for idx, relationship in enumerate(relationships): + assert resp.data[idx]["parent"] == relationship.parent_id + assert resp.data[idx]["child"] == relationship.child_id + else: + assert resp.status_code == 403 + + @pytest.mark.parametrize("is_author", [True, False]) def test_user_list_items_endpoint_create_item(client, user, is_author): """Test userlistitems endpoint for creating a UserListItem""" diff --git a/main/settings.py b/main/settings.py index 8b95c15ec9..70e30a6151 100644 --- a/main/settings.py +++ b/main/settings.py @@ -33,7 +33,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.24.3" +VERSION = "0.25.0" log = logging.getLogger() diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 120ae561ee..5be827d60f 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -5730,6 +5730,22 @@ paths: responses: '204': description: No response body + /api/v1/learningpaths/membership/: + get: + operationId: learningpaths_membership_list + description: Get a list of all learning path items + summary: List + tags: + - learningpaths + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MicroLearningPathRelationship' + description: '' /api/v1/offerors/: get: operationId: offerors_list @@ -7515,6 +7531,22 @@ paths: responses: '204': description: No response body + /api/v1/userlists/membership/: + get: + operationId: userlists_membership_list + description: Get a list of all userlist items for a user + summary: List + tags: + - userlists + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MicroUserListRelationship' + description: '' /api/v1/video_playlists/: get: operationId: video_playlists_list diff --git a/poetry.lock b/poetry.lock index 4bb078c4a4..db95fc6225 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5129,29 +5129,29 @@ files = [ [[package]] name = "ruff" -version = "0.7.2" +version = "0.7.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"}, - {file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"}, - {file = "ruff-0.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:853277dbd9675810c6826dad7a428d52a11760744508340e66bf46f8be9701d9"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21aae53ab1490a52bf4e3bf520c10ce120987b047c494cacf4edad0ba0888da2"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc7e0fc6e0cb3168443eeadb6445285abaae75142ee22b2b72c27d790ab60ba"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd77877a4e43b3a98e5ef4715ba3862105e299af0c48942cc6d51ba3d97dc859"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e00163fb897d35523c70d71a46fbaa43bf7bf9af0f4534c53ea5b96b2e03397b"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3c54b538633482dc342e9b634d91168fe8cc56b30a4b4f99287f4e339103e88"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b792468e9804a204be221b14257566669d1db5c00d6bb335996e5cd7004ba80"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba53ed84ac19ae4bfb4ea4bf0172550a2285fa27fbb13e3746f04c80f7fa088"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b19fafe261bf741bca2764c14cbb4ee1819b67adb63ebc2db6401dcd652e3748"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28bd8220f4d8f79d590db9e2f6a0674f75ddbc3847277dd44ac1f8d30684b828"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9fd67094e77efbea932e62b5d2483006154794040abb3a5072e659096415ae1e"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:576305393998b7bd6c46018f8104ea3a9cb3fa7908c21d8580e3274a3b04b691"}, - {file = "ruff-0.7.2-py3-none-win32.whl", hash = "sha256:fa993cfc9f0ff11187e82de874dfc3611df80852540331bc85c75809c93253a8"}, - {file = "ruff-0.7.2-py3-none-win_amd64.whl", hash = "sha256:dd8800cbe0254e06b8fec585e97554047fb82c894973f7ff18558eee33d1cb88"}, - {file = "ruff-0.7.2-py3-none-win_arm64.whl", hash = "sha256:bb8368cd45bba3f57bb29cbb8d64b4a33f8415d0149d2655c5c8539452ce7760"}, - {file = "ruff-0.7.2.tar.gz", hash = "sha256:2b14e77293380e475b4e3a7a368e14549288ed2931fce259a6f99978669e844f"}, + {file = "ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344"}, + {file = "ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"}, + {file = "ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16"}, + {file = "ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc"}, + {file = "ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088"}, + {file = "ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c"}, + {file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"}, ] [[package]] @@ -6094,4 +6094,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "3.12.6" -content-hash = "c4ab6db22daf236399f7726c5e76af7a7009cbb8f26779eff4ebc4118697ad38" +content-hash = "da8cfbd5627071399d2e10b73fc4566fb3da0eee7fde48217e323036f2c66632" diff --git a/pyproject.toml b/pyproject.toml index 297fd683b4..2af57b2ca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ django-scim2 = "^0.19.1" django-oauth-toolkit = "^2.3.0" youtube-transcript-api = "^0.6.2" posthog = "^3.5.0" -ruff = "0.7.2" +ruff = "0.7.3" dateparser = "^1.2.0" uwsgitop = "^0.12" pytest-lazy-fixtures = "^1.1.1" diff --git a/yarn.lock b/yarn.lock index e1fcaba9be..4511e2786a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1463,16 +1463,18 @@ __metadata: languageName: node linkType: hard -"@chromatic-com/storybook@npm:^1.9.0": - version: 1.9.0 - resolution: "@chromatic-com/storybook@npm:1.9.0" +"@chromatic-com/storybook@npm:^3.0.0": + version: 3.2.2 + resolution: "@chromatic-com/storybook@npm:3.2.2" dependencies: - chromatic: "npm:^11.4.0" + chromatic: "npm:^11.15.0" filesize: "npm:^10.0.12" jsonfile: "npm:^6.1.0" react-confetti: "npm:^6.1.0" strip-ansi: "npm:^7.1.0" - checksum: 10/27ca6930a4978a52471ed7256cbf549e57b5c9c45b650b55461400a63692f5b30a7a0a7436faadd713952ce6285b873041494c10e92cccdc5bdafee1f1755459 + peerDependencies: + storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + checksum: 10/71338edf56cdbc855074c78981f2e1612b364cd864fa99bbda5c0aad147769b9f476de2fd76816102fd504efc5c0c54ba559d5ac9e3828d53278fe7000863d54 languageName: node linkType: hard @@ -8439,9 +8441,9 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^11.4.0": - version: 11.12.5 - resolution: "chromatic@npm:11.12.5" +"chromatic@npm:^11.15.0": + version: 11.16.5 + resolution: "chromatic@npm:11.16.5" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 @@ -8454,7 +8456,7 @@ __metadata: chroma: dist/bin.js chromatic: dist/bin.js chromatic-cli: dist/bin.js - checksum: 10/1b67b8f1c3813871453910a3d90b1dfe20da9ecd0fc71bb5186b132d72fc3a11a6e74f14abd1b51e1ebbd99bd7089e1615ba7be8529be5dde7897b9778c48ae7 + checksum: 10/df200f3900be2b5bf662f2308b15693730d55380085636c20ccd7a78913b3b7e5401d32706098462e020542dc47997021ce894e17fb4bbf771a48b73378884c2 languageName: node linkType: hard @@ -11408,7 +11410,7 @@ __metadata: jest-fail-on-console: "npm:^3.2.0" jest-watch-typeahead: "npm:^2.2.2" jest-when: "npm:^3.6.0" - postcss-styled-syntax: "npm:^0.6.4" + postcss-styled-syntax: "npm:^0.7.0" prettier: "npm:^3.3.3" prettier-plugin-django-alpine: "npm:^1.2.6" stylelint: "npm:^15.2.0" @@ -16017,7 +16019,7 @@ __metadata: version: 0.0.0-use.local resolution: "ol-components@workspace:frontends/ol-components" dependencies: - "@chromatic-com/storybook": "npm:^1.9.0" + "@chromatic-com/storybook": "npm:^3.0.0" "@dnd-kit/core": "npm:^6.0.8" "@dnd-kit/sortable": "npm:^8.0.0" "@dnd-kit/utilities": "npm:^3.2.1" @@ -17119,14 +17121,14 @@ __metadata: languageName: node linkType: hard -"postcss-styled-syntax@npm:^0.6.4": - version: 0.6.4 - resolution: "postcss-styled-syntax@npm:0.6.4" +"postcss-styled-syntax@npm:^0.7.0": + version: 0.7.0 + resolution: "postcss-styled-syntax@npm:0.7.0" dependencies: - typescript: "npm:^5.3.3" + typescript: "npm:^5.6.3" peerDependencies: postcss: ^8.4.21 - checksum: 10/435bc414cc68d2d2297bb6354230226f554b88efb38610c0c35575eefc241edbabf12a4d4662957204e92091443fa25c85cb943916b61023c222e30a1127b1ff + checksum: 10/d52b4d556baf6b3c700fbb6b71bcc5bd3787d9af05a781bc1050031f61ac8e3ac60c5f7b8e924bd7ce6f8ff6b7d3314533f1526dd804729abdd021f835e35235 languageName: node linkType: hard @@ -20291,7 +20293,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5, typescript@npm:^5.3.3, typescript@npm:^5.4.3, typescript@npm:^5.5.4": +"typescript@npm:^5, typescript@npm:^5.4.3, typescript@npm:^5.5.4, typescript@npm:^5.6.3": version: 5.6.3 resolution: "typescript@npm:5.6.3" bin: @@ -20301,7 +20303,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5#optional!builtin, typescript@patch:typescript@npm%3A^5.3.3#optional!builtin, typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.5.4#optional!builtin": +"typescript@patch:typescript@npm%3A^5#optional!builtin, typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.5.4#optional!builtin, typescript@patch:typescript@npm%3A^5.6.3#optional!builtin": version: 5.6.3 resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" bin: