From 9528bfde62db6e9356518ffdedc0d381cca3e6cd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 06:49:44 -0700 Subject: [PATCH 1/3] chore(deps): update dependency meilisearch to ^0.41.0 (#1136) --- package-lock.json | 25 ++++++++++++++++++------- package.json | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index aedabd7410..24626f53d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "formik": "2.4.6", "jszip": "^3.10.1", "lodash": "4.17.21", - "meilisearch": "^0.38.0", + "meilisearch": "^0.41.0", "moment": "2.30.1", "npm": "^10.8.1", "prop-types": "^15.8.1", @@ -11676,6 +11676,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -11687,6 +11688,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -11697,7 +11699,8 @@ "node_modules/hosted-git-info/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/hpack.js": { "version": "2.1.6", @@ -14535,9 +14538,10 @@ } }, "node_modules/meilisearch": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.38.0.tgz", - "integrity": "sha512-bHaq8nYxSKw9/Qslq1Zes5g9tHgFkxy/I9o8942wv2PqlNOT0CzptIkh/x98N52GikoSZOXSQkgt6oMjtf5uZw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.41.0.tgz", + "integrity": "sha512-5KcGLxEXD7E+uNO7R68rCbGSHgCqeM3Q3RFFLSsN7ZrIgr8HPDXVAIlP4LHggAZfk0FkSzo8VSXifHCwa2k80g==", + "license": "MIT", "dependencies": { "cross-fetch": "^3.1.6" } @@ -15061,6 +15065,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", @@ -15075,6 +15080,7 @@ "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -21050,6 +21056,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -21058,12 +21065,14 @@ "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==" + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -21072,7 +21081,8 @@ "node_modules/spdx-license-ids": { "version": "3.0.18", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", - "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==" + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", + "dev": true }, "node_modules/spdy": { "version": "4.0.2", @@ -22756,6 +22766,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" diff --git a/package.json b/package.json index d7153c67ec..288ced20b9 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "formik": "2.4.6", "jszip": "^3.10.1", "lodash": "4.17.21", - "meilisearch": "^0.38.0", + "meilisearch": "^0.41.0", "moment": "2.30.1", "npm": "^10.8.1", "prop-types": "^15.8.1", From 8cf26e1a751e78446512837df443373acc187aea Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 8 Jul 2024 07:10:08 -0700 Subject: [PATCH 2/3] Version bump for Paragon to 22.6.1, with stricter typing (#1146) * fix(deps): update dependency @openedx/paragon to v22.6.1 * fix: lint errors from stricter types in new paragon version --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 15 ++++++++++++--- .../ContentTagsCollapsible.jsx | 10 +++++----- src/content-tags-drawer/ContentTagsDrawer.jsx | 2 +- .../ContentTagsDropDownSelector.jsx | 2 +- .../videos-page/VideoThumbnail.jsx | 2 +- .../components/WhatsInClipboard.jsx | 2 +- src/generic/clipboard/paste-component/index.jsx | 2 +- src/search-modal/SearchFilterWidget.tsx | 2 +- src/search-modal/SearchResult.tsx | 4 ++-- 9 files changed, 25 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24626f53d3..330871fb21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4536,9 +4536,17 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@openedx/paragon": { - "version": "22.5.1", - "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.5.1.tgz", - "integrity": "sha512-GSDC28jlsfP8LPUoebXtkzw5cIxl44+9dhscvz2znZ7uMYjEbmp5waPR5rAJ4lKtNzFBZUX/mAiaNilhhZXu9Q==", + "version": "22.6.1", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.6.1.tgz", + "integrity": "sha512-xblrspAfsYsiDzyLIh+tceiTPgx1HY6v0eceatTYSj/BINxN8Dcqh9uQOZi2eJc1os3w2dr0nZRGnTt8cYu2BA==", + "license": "Apache-2.0", + "workspaces": [ + "example", + "component-generator", + "www", + "icons", + "dependent-usage-analyzer" + ], "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -4628,6 +4636,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx index 57e2eff976..66bd7e100a 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx @@ -74,7 +74,7 @@ const CustomMenu = (props) => {
diff --git a/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx index aca6d3f0cc..22d4d0ca4c 100644 --- a/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx +++ b/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx @@ -34,7 +34,7 @@ const WhatsInClipboard = ({ />

togglePopover(isOpen); const renderPopover = (props) => ( -

+
= ({ appliedFilters, ...props }) => { const [isOpen, open, close] = useToggle(false); - const [target, setTarget] = React.useState(null); + const [target, setTarget] = React.useState(null); return ( <> diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx index 77f806be4a..57584cefe5 100644 --- a/src/search-modal/SearchResult.tsx +++ b/src/search-modal/SearchResult.tsx @@ -22,13 +22,13 @@ import type { ContentHit } from './data/api'; import Highlight from './Highlight'; import messages from './messages'; -const STRUCTURAL_TYPE_ICONS: Record = { +const STRUCTURAL_TYPE_ICONS: Record = { vertical: TYPE_ICONS_MAP.vertical, sequential: Folder, chapter: Folder, }; -function getItemIcon(blockType: string): React.ReactElement { +function getItemIcon(blockType: string): React.ComponentType { return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article; } From 83489b098315fe2e8c6408a8d6d48b3ae9d45ed4 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Mon, 8 Jul 2024 17:35:43 +0300 Subject: [PATCH 3/3] feat: Add filters/sorting for the libraries v2 tab on studio home (#1117) --- src/library/data/api.ts | 66 ++++++++ src/studio-home/data/api.js | 24 +-- src/studio-home/data/api.test.js | 2 +- .../data/{apiHooks.js => apiHooks.ts} | 5 +- .../libraries-v2-tab/{index.jsx => index.tsx} | 86 +++++++---- .../libraries-v2-filters/index.test.tsx | 144 ++++++++++++++++++ .../libraries-v2-filters/index.tsx | 109 +++++++++++++ .../libraries-v2-filter-menu/index.tsx | 58 +++++++ .../libraries-v2-order-filter-menu/index.tsx | 55 +++++++ .../messages.ts | 26 ++++ src/studio-home/tabs-section/messages.js | 12 ++ 11 files changed, 535 insertions(+), 52 deletions(-) create mode 100644 src/library/data/api.ts rename src/studio-home/data/{apiHooks.js => apiHooks.ts} (57%) rename src/studio-home/tabs-section/libraries-v2-tab/{index.jsx => index.tsx} (54%) create mode 100644 src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.test.tsx create mode 100644 src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx create mode 100644 src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-filter-menu/index.tsx create mode 100644 src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/index.tsx create mode 100644 src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/messages.ts diff --git a/src/library/data/api.ts b/src/library/data/api.ts new file mode 100644 index 0000000000..2a31db309e --- /dev/null +++ b/src/library/data/api.ts @@ -0,0 +1,66 @@ +import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + +export interface LibraryV2 { + id: string, + type: string, + org: string, + slug: string, + title: string, + description: string, + numBlocks: number, + version: number, + lastPublished: string | null, + allowLti: boolean, + allowPublicLearning: boolean, + allowPublicRead: boolean, + hasUnpublishedChanges: boolean, + hasUnpublishedDeletes: boolean, + license: string, +} + +export interface LibrariesV2Response { + next: string | null, + previous: string | null, + count: number, + numPages: number, + currentPage: number, + start: number, + results: LibraryV2[], +} + +/* Additional custom parameters for the API request. */ +export interface GetLibrariesV2CustomParams { + /* (optional) Library type, default `complex` */ + type?: string, + /* (optional) Page number of results */ + page?: number, + /* (optional) The number of results on each page, default `50` */ + pageSize?: number, + /* (optional) Whether pagination is supported, default `true` */ + pagination?: boolean, + /* (optional) Library field to order results by. Prefix with '-' for descending */ + order?: string, + /* (optional) Search query to filter v2 Libraries by */ + search?: string, +} + +/** + * Get's studio home v2 Libraries. + */ +export async function getStudioHomeLibrariesV2(customParams: GetLibrariesV2CustomParams): Promise { + // Set default params if not passed in + const customParamsDefaults = { + type: customParams.type || 'complex', + page: customParams.page || 1, + pageSize: customParams.pageSize || 50, + pagination: customParams.pagination !== undefined ? customParams.pagination : true, + order: customParams.order || 'title', + textSearch: customParams.search, + }; + const customParamsFormat = snakeCaseObject(customParamsDefaults); + const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/libraries/v2/`, { params: customParamsFormat }); + return camelCaseObject(data); +} diff --git a/src/studio-home/data/api.js b/src/studio-home/data/api.js index 2124f6fed7..630bb52b6c 100644 --- a/src/studio-home/data/api.js +++ b/src/studio-home/data/api.js @@ -1,3 +1,4 @@ +// @ts-check import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -8,7 +9,6 @@ export const getCourseNotificationUrl = (url) => new URL(url, getApiBaseUrl()).h /** * Get's studio home data. - * @param {string} search * @returns {Promise} */ export async function getStudioHomeData() { @@ -40,28 +40,6 @@ export async function getStudioHomeLibraries() { return camelCaseObject(data); } -/** - * Get's studio home v2 Libraries. - * @param {object} customParams - Additional custom paramaters for the API request. - * @param {string} [customParams.type] - (optional) Library type, default `complex` - * @param {number} [customParams.page] - (optional) Page number of results - * @param {number} [customParams.pageSize] - (optional) The number of results on each page, default `50` - * @param {boolean} [customParams.pagination] - (optional) Whether pagination is supported, default `true` - * @returns {Promise} - A Promise that resolves to the response data container the studio home v2 libraries. - */ -export async function getStudioHomeLibrariesV2(customParams) { - // Set default params if not passed in - const customParamsDefaults = { - type: customParams.type || 'complex', - page: customParams.page || 1, - pageSize: customParams.pageSize || 50, - pagination: customParams.pagination !== undefined ? customParams.pagination : true, - }; - const customParamsFormat = snakeCaseObject(customParamsDefaults); - const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/libraries/v2/`, { params: customParamsFormat }); - return camelCaseObject(data); -} - /** * Handle course notification requests. * @param {string} url diff --git a/src/studio-home/data/api.test.js b/src/studio-home/data/api.test.js index 66f6ee279f..5d255d7fb2 100644 --- a/src/studio-home/data/api.test.js +++ b/src/studio-home/data/api.test.js @@ -13,8 +13,8 @@ import { getStudioHomeCourses, getStudioHomeCoursesV2, getStudioHomeLibraries, - getStudioHomeLibrariesV2, } from './api'; +import { getStudioHomeLibrariesV2 } from '../../library/data/api'; import { generateGetStudioCoursesApiResponse, generateGetStudioHomeDataApiResponse, diff --git a/src/studio-home/data/apiHooks.js b/src/studio-home/data/apiHooks.ts similarity index 57% rename from src/studio-home/data/apiHooks.js rename to src/studio-home/data/apiHooks.ts index 92575bf717..99e9606fb3 100644 --- a/src/studio-home/data/apiHooks.js +++ b/src/studio-home/data/apiHooks.ts @@ -1,14 +1,15 @@ import { useQuery } from '@tanstack/react-query'; -import { getStudioHomeLibrariesV2 } from './api'; +import { GetLibrariesV2CustomParams, getStudioHomeLibrariesV2 } from '../../library/data/api'; /** * Builds the query to fetch list of V2 Libraries */ -const useListStudioHomeV2Libraries = (customParams) => ( +const useListStudioHomeV2Libraries = (customParams: GetLibrariesV2CustomParams) => ( useQuery({ queryKey: ['listV2Libraries', customParams], queryFn: () => getStudioHomeLibrariesV2(customParams), + keepPreviousData: true, }) ); diff --git a/src/studio-home/tabs-section/libraries-v2-tab/index.jsx b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx similarity index 54% rename from src/studio-home/tabs-section/libraries-v2-tab/index.jsx rename to src/studio-home/tabs-section/libraries-v2-tab/index.tsx index c3b58df554..8678a69ea8 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/index.jsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx @@ -1,8 +1,14 @@ import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { Icon, Row, Pagination } from '@openedx/paragon'; +import { + Icon, + Row, + Pagination, + Alert, + Button, +} from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig, getPath } from '@edx/frontend-platform'; +import { Error } from '@openedx/paragon/icons'; import { constructLibraryAuthoringURL } from '../../../utils'; import useListStudioHomeV2Libraries from '../../data/apiHooks'; @@ -10,26 +16,38 @@ import { LoadingSpinner } from '../../../generic/Loading'; import AlertMessage from '../../../generic/alert-message'; import CardItem from '../../card-item'; import messages from '../messages'; +import LibrariesV2Filters from './libraries-v2-filters'; -const LibrariesV2Tab = ({ +const LibrariesV2Tab: React.FC<{ + libraryAuthoringMfeUrl: string, + redirectToLibraryAuthoringMfe: boolean +}> = ({ libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, }) => { const intl = useIntl(); const [currentPage, setCurrentPage] = useState(1); + const [filterParams, setFilterParams] = useState({}); - const handlePageSelect = (page) => { + const isFiltered = Object.keys(filterParams).length > 0; + + const handlePageSelect = (page: number) => { setCurrentPage(page); }; + const handleClearFilters = () => { + setFilterParams({}); + setCurrentPage(1); + }; + const { data, isLoading, isError, - } = useListStudioHomeV2Libraries({ page: currentPage }); + } = useListStudioHomeV2Libraries({ page: currentPage, ...filterParams }); - if (isLoading) { + if (isLoading && !isFiltered) { return ( @@ -37,7 +55,7 @@ const LibrariesV2Tab = ({ ); } - const libURL = (id) => ( + const libURL = (id: string) => ( libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe ? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${id}`) // Redirection to the placeholder is done in the MFE rather than @@ -46,6 +64,8 @@ const LibrariesV2Tab = ({ : `${window.location.origin}${getPath(getConfig().PUBLIC_PATH)}library/${id}` ); + const hasV2Libraries = !isLoading && ((data!.results.length || 0) > 0); + return ( isError ? (
- {/* Temporary div to add spacing. This will be replaced with lib search/filters */} -
-

- {intl.formatMessage(messages.coursesPaginationInfo, { - length: data.results.length, - total: data.count, - })} -

+ + { !isLoading + && ( +

+ {intl.formatMessage(messages.coursesPaginationInfo, { + length: data!.results.length, + total: data!.count, + })} +

+ )}
- { - data.results.map(({ + { hasV2Libraries + ? data!.results.map(({ id, org, slug, title, }) => ( - )) - } + )) : isFiltered && !isLoading && ( + + + {intl.formatMessage(messages.librariesV2TabLibraryNotFoundAlertTitle)} + +

+ {intl.formatMessage(messages.librariesV2TabLibraryNotFoundAlertMessage)} +

+ +
+ )} { - data.numPages > 1 + hasV2Libraries && (data!.numPages || 0) > 1 && ( @@ -103,9 +142,4 @@ const LibrariesV2Tab = ({ ); }; -LibrariesV2Tab.propTypes = { - libraryAuthoringMfeUrl: PropTypes.string.isRequired, - redirectToLibraryAuthoringMfe: PropTypes.bool.isRequired, -}; - export default LibrariesV2Tab; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.test.tsx b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.test.tsx new file mode 100644 index 0000000000..ca78fa570f --- /dev/null +++ b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { + screen, fireEvent, render, waitFor, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import LibrariesV2Filters, { LibrariesV2FiltersProps } from '.'; + +describe('LibrariesV2Filters', () => { + const setFilterParamsMock = jest.fn(); + const setCurrentPageMock = jest.fn(); + + const IntlProviderWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + + ); + + const renderComponent = (overrideProps: Partial = {}) => render( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render search field and order filter', () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + expect(searchInput).toBeInTheDocument(); + const orderFilter = screen.getByText('Name A-Z'); + expect(orderFilter).toBeInTheDocument(); + }); + + it('should call setFilterParams and setCurrentPage when search input changes', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + await waitFor(() => expect(setFilterParamsMock).toHaveBeenCalled()); + await waitFor(() => expect(setCurrentPageMock).toHaveBeenCalled()); + }); + + it('should call setFilterParams and setCurrentPage when a menu item order menu is selected', async () => { + renderComponent(); + const libraryV2OrderMenuFilter = screen.getByText('Name A-Z'); + fireEvent.click(libraryV2OrderMenuFilter); + const newestLibV2sMenuItem = screen.getByText('Newest'); + fireEvent.click(newestLibV2sMenuItem); + expect(setFilterParamsMock).toHaveBeenCalled(); + expect(setCurrentPageMock).toHaveBeenCalled(); + }); + + it('should clear the search input when filters cleared', async () => { + const { rerender } = renderComponent({ isFiltered: true }); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + await waitFor(() => expect(setFilterParamsMock).toHaveBeenCalled()); + await waitFor(() => expect(setCurrentPageMock).toHaveBeenCalled()); + + rerender( + + + , + ); + + await waitFor(() => expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('')); + }); + + it('should update states with the correct parameters when a order menu item is selected', () => { + renderComponent(); + const libraryV2OrderMenuFilter = screen.getByText('Name A-Z'); + fireEvent.click(libraryV2OrderMenuFilter); + const oldestLibV2sMenuItem = screen.getByText('Oldest'); + fireEvent.click(oldestLibV2sMenuItem); + + // Check that setFilterParams is called with the correct payload + expect(setFilterParamsMock).toHaveBeenCalledWith(expect.objectContaining({ + search: undefined, + order: 'created', + })); + + // Check that setCurrentPage is called with `1` + expect(setCurrentPageMock).toHaveBeenCalledWith(1); + }); + + it('should call setFilterParams after debounce delay when the search input changes', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + await waitFor(() => expect(setFilterParamsMock).toHaveBeenCalled(), { timeout: 500 }); + expect(setFilterParamsMock).toHaveBeenCalledWith(expect.anything()); + }); + + it('should not call setFilterParams with only spaces when search only spaces', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: ' ' } }); + + await waitFor(() => expect(setFilterParamsMock).not.toHaveBeenCalledWith(expect.objectContaining({ + search: ' ', + order: 'created', + })), { timeout: 500 }); + }); + + it('should display the loading spinner when isLoading is true', () => { + renderComponent({ isLoading: true }); + const spinner = screen.getByText('Loading...'); + expect(spinner).toBeInTheDocument(); + }); + + it('should not display the loading spinner when isLoading is false', () => { + renderComponent({ isLoading: false }); + const spinner = screen.queryByText('Loading...'); + expect(spinner).not.toBeInTheDocument(); + }); + + it('should clear the search input and call dispatch when the reset button is clicked', async () => { + renderComponent(); + const searchInput = screen.getByRole('searchbox') as HTMLInputElement; + fireEvent.change(searchInput, { target: { value: 'test' } }); + const form = searchInput.closest('form'); + if (!form) { + throw new Error('Form not found'); + } + const resetButton = form.querySelector('button'); + if (!resetButton || !(resetButton instanceof HTMLButtonElement)) { + throw new Error('Reset button not found'); + } + fireEvent.click(resetButton); + expect(searchInput.value).toBe(''); + }); +}); diff --git a/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx new file mode 100644 index 0000000000..0af40ddf92 --- /dev/null +++ b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/index.tsx @@ -0,0 +1,109 @@ +/* eslint-disable react/require-default-props */ +import React, { useState, useCallback, useEffect } from 'react'; +import { SearchField } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { LoadingSpinner } from '../../../../generic/Loading'; +import LibrariesV2OrderFilterMenu from './libraries-v2-order-filter-menu'; +import messages from '../../messages'; + +export interface LibrariesV2FiltersProps { + isLoading?: boolean; + isFiltered?: boolean; + filterParams: { search?: string | undefined, order?: string }; + setFilterParams: React.Dispatch>; + setCurrentPage: React.Dispatch>; +} + +const LibrariesV2Filters: React.FC = ({ + isLoading = false, + isFiltered = false, + filterParams, + setFilterParams, + setCurrentPage, +}) => { + const intl = useIntl(); + + const [search, setSearch] = useState(''); + const [order, setOrder] = useState('title'); + + // Reset search & order when filters cleared + useEffect(() => { + if (!isFiltered) { + setSearch(filterParams.search); + setOrder('title'); + } + }, [isFiltered, setSearch, search, setOrder, filterParams.search]); + + const getOrderFromFilterType = (filterType: string) => { + const orders = { + sortLibrariesV2AZ: 'title', + sortLibrariesV2ZA: '-title', + sortLibrariesV2Newest: '-created', + sortLibrariesV2Oldest: 'created', + }; + + // Default to 'A-Z` if invalid filtertype + return orders[filterType] || 'title'; + }; + + const getFilterTypeData = (baseFilters: { search: string | undefined; order: string; }) => ({ + sortLibrariesV2AZ: { ...baseFilters, order: 'title' }, + sortLibrariesV2ZA: { ...baseFilters, order: '-title' }, + sortLibrariesV2Newest: { ...baseFilters, order: '-created' }, + sortLibrariesV2Oldest: { ...baseFilters, order: 'created' }, + }); + + const handleMenuFilterItemSelected = (filterType: string) => { + setOrder(getOrderFromFilterType(filterType)); + + const baseFilters = { + search, + order, + }; + + const menuFilterParams = getFilterTypeData(baseFilters); + const filterParamsFormat = menuFilterParams[filterType] || baseFilters; + + setFilterParams(filterParamsFormat); + setCurrentPage(1); + }; + + const handleSearchLibrariesV2 = useCallback((searchValue: string) => { + const valueFormatted = searchValue.trim(); + const updatedFilterParams = { + search: valueFormatted.length > 0 ? valueFormatted : undefined, + order, + }; + + // Check if the search is different from the current search and it's not only spaces + if (valueFormatted !== search || valueFormatted) { + setSearch(valueFormatted); + setFilterParams(updatedFilterParams); + setCurrentPage(1); + } + }, [order, search]); + + return ( +
+
+ {}} + onChange={handleSearchLibrariesV2} + value={search} + className="mr-4" + placeholder={intl.formatMessage(messages.librariesV2TabLibrarySearchPlaceholder)} + /> + {isLoading && ( + + + + )} +
+ + +
+ ); +}; + +export default LibrariesV2Filters; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-filter-menu/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-filter-menu/index.tsx new file mode 100644 index 0000000000..bdc52f5bf1 --- /dev/null +++ b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-filter-menu/index.tsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect } from 'react'; +import { Icon, Dropdown } from '@openedx/paragon'; +import { Check } from '@openedx/paragon/icons'; + +const LibrariesV2FilterMenu: React.FC<{ + id: string; + menuItems: { id: string, name: string, value: string }[]; + onItemMenuSelected: (value: string) => void; + defaultItemSelectedText: string; + isFiltered: boolean; +}> = ({ + id: idProp, + menuItems = [], + onItemMenuSelected, + defaultItemSelectedText = '', + isFiltered, +}) => { + const [itemMenuSelected, setItemMenuSelected] = useState(defaultItemSelectedText); + const handleOrderSelected = (name: string, value: string) => { + setItemMenuSelected(name); + onItemMenuSelected(value); + }; + + const libraryV2OrderSelectedIcon = (itemValue: string) => (itemValue === itemMenuSelected ? ( + + ) : null); + + useEffect(() => { + if (!isFiltered) { + setItemMenuSelected(defaultItemSelectedText); + } + }, [isFiltered]); + + return ( + + + {itemMenuSelected} + + + {menuItems.map(({ id, name, value }) => ( + handleOrderSelected(name, value)} + > + {name} {libraryV2OrderSelectedIcon(name)} + + ))} + + + ); +}; + +export default LibrariesV2FilterMenu; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/index.tsx new file mode 100644 index 0000000000..00e5794f23 --- /dev/null +++ b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/index.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +import LibrariesV2FilterMenu from '../libraries-v2-filter-menu'; + +const LibrariesV2OrderFilterMenu: React.FC<{ + onItemMenuSelected: (value: string) => void; + isFiltered: boolean; +}> = ({ onItemMenuSelected, isFiltered }) => { + const intl = useIntl(); + + const libraryV2Orders = useMemo( + () => [ + { + id: 'sort-libraries-v2-az', + name: intl.formatMessage(messages.librariesV2OrderFilterMenuAscendantLibrariesV2), + value: 'sortLibrariesV2AZ', + }, + { + id: 'sort-libraries-v2-za', + name: intl.formatMessage(messages.librariesV2OrderFilterMenuDescendantLibrariesV2), + value: 'sortLibrariesV2ZA', + }, + { + id: 'sort-libraries-v2-newest', + name: intl.formatMessage(messages.librariesV2OrderFilterMenuNewestLibrariesV2), + value: 'sortLibrariesV2Newest', + }, + { + id: 'sort-libraries-v2-oldest', + name: intl.formatMessage(messages.librariesV2OrderFilterMenuOldestLibrariesV2), + value: 'sortLibrariesV2Oldest', + }, + ], + [intl], + ); + + const handleLibraryV2OrderSelected = (libraryV2Order: string) => { + onItemMenuSelected(libraryV2Order); + }; + + return ( + + ); +}; + +export default LibrariesV2OrderFilterMenu; diff --git a/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/messages.ts b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/messages.ts new file mode 100644 index 0000000000..0826a32eae --- /dev/null +++ b/src/studio-home/tabs-section/libraries-v2-tab/libraries-v2-filters/libraries-v2-order-filter-menu/messages.ts @@ -0,0 +1,26 @@ +import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n'; +import type { defineMessages as defineMessagesType } from 'react-intl'; + +// frontend-platform currently doesn't provide types... do it ourselves. +const defineMessages = _defineMessages as typeof defineMessagesType; + +const messages = defineMessages({ + librariesV2OrderFilterMenuAscendantLibrariesV2: { + id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.ascendant-librariesv2', + defaultMessage: 'Name A-Z', + }, + librariesV2OrderFilterMenuDescendantLibrariesV2: { + id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.descendant-librariesv2', + defaultMessage: 'Name Z-A', + }, + librariesV2OrderFilterMenuNewestLibrariesV2: { + id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.newest-librariesv2', + defaultMessage: 'Newest', + }, + librariesV2OrderFilterMenuOldestLibrariesV2: { + id: 'course-authoring.studio-home.libraries.tab.order-filter-menu.oldest-librariesv2', + defaultMessage: 'Oldest', + }, +}); + +export default messages; diff --git a/src/studio-home/tabs-section/messages.js b/src/studio-home/tabs-section/messages.js index 0ed614f55a..076f9c8eb7 100644 --- a/src/studio-home/tabs-section/messages.js +++ b/src/studio-home/tabs-section/messages.js @@ -58,6 +58,18 @@ const messages = defineMessages({ id: 'course-authoring.studio-home.libraries.placeholder.body', defaultMessage: 'This is a placeholder page, as the Library Authoring MFE is not enabled.', }, + librariesV2TabLibrarySearchPlaceholder: { + id: 'course-authoring.studio-home.libraries.tab.library.search-placeholder', + defaultMessage: 'Search', + }, + librariesV2TabLibraryNotFoundAlertTitle: { + id: 'course-authoring.studio-home.libraries.tab.library.not.found.alert.title', + defaultMessage: 'We could not find any result', + }, + librariesV2TabLibraryNotFoundAlertMessage: { + id: 'course-authoring.studio-home.libraries.tab.library.not.found.alert.message', + defaultMessage: 'There are no libraries with the current filters.', + }, }); export default messages;