diff --git a/.eslintrc b/.eslintrc index f1b1cc9e..a7e5165c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,6 +39,7 @@ "curly": "error", "@typescript-eslint/indent": "off", "@typescript-eslint/brace-style": "off", + "import/prefer-default-export": "off", "no-underscore-dangle": [ "error", { diff --git a/cypress/e2e/allCollections/allCollections.cy.ts b/cypress/e2e/allCollections/allCollections.cy.ts index 6c897f34..2567655f 100644 --- a/cypress/e2e/allCollections/allCollections.cy.ts +++ b/cypress/e2e/allCollections/allCollections.cy.ts @@ -1,17 +1,20 @@ import { CategoryType } from '@graasp/sdk'; -import { namespaces } from '@graasp/translations'; +import { langs, namespaces } from '@graasp/translations'; -import { i18nConfig } from '../../../src/config/i18n'; +import { LIBRARY_NAMESPACE, i18nConfig } from '../../../src/config/i18n'; import { ALL_COLLECTIONS_ROUTE } from '../../../src/config/routes'; import { ALL_COLLECTIONS_GRID_ID, ALL_COLLECTIONS_TITLE_ID, ENABLE_IN_DEPTH_SEARCH_CHECKBOX_ID, + SEARCH_FILTER_LANG_ID, + SEARCH_FILTER_POPPER_LANG_ID, buildCategoryOptionSelector, buildCollectionCardGridId, buildSearchFilterCategoryId, buildSearchFilterPopperButtonId, } from '../../../src/config/selectors'; +import LIBRARY from '../../../src/langs/constants'; import { SAMPLE_CATEGORIES } from '../../fixtures/categories'; import { buildPublicAndPrivateEnvironments } from '../../fixtures/environment'; import { PUBLISHED_ITEMS } from '../../fixtures/items'; @@ -45,17 +48,10 @@ buildPublicAndPrivateEnvironments(PUBLISHED_ITEMS).forEach((environment) => { 'contain.text', i18n.t(CategoryType.Discipline, { ns: namespaces.categories }), ); - cy.get(`#${buildSearchFilterCategoryId(CategoryType.Language)}`).should( + cy.get(`#${SEARCH_FILTER_LANG_ID}`).should( 'contain.text', - i18n.t(CategoryType.Language, { ns: namespaces.categories }), + i18n.t(LIBRARY.SEARCH_FILTER_LANG_TITLE, { ns: LIBRARY_NAMESPACE }), ); - // todo: add back when license filtering is enabled - // cy.get(`#${buildSearchFilterCategoryId(CATEGORY_TYPES.LICENSE)}`).should( - // 'contain.text', - // // todo: add translations - // // i18n.t(CATEGORIES.EDUCATION_LEVEL, { ns: namespaces.categories }), - // 'License', - // ); // verify 2 item cards are displayed (without children) cy.get(`#${ALL_COLLECTIONS_GRID_ID}`); @@ -75,11 +71,7 @@ buildPublicAndPrivateEnvironments(PUBLISHED_ITEMS).forEach((environment) => { it('display menu options', () => { cy.wait(['@getCategories']); - [ - CategoryType.Level, - CategoryType.Discipline, - CategoryType.Language, - ].forEach((categoryType) => { + [CategoryType.Level, CategoryType.Discipline].forEach((categoryType) => { cy.get( `#not-sticky button#${buildSearchFilterPopperButtonId(categoryType)}`, ) @@ -97,6 +89,16 @@ buildPublicAndPrivateEnvironments(PUBLISHED_ITEMS).forEach((environment) => { }); }); + it('display language options', () => { + cy.wait(['@getCategories']); + cy.get(`#not-sticky button#${SEARCH_FILTER_POPPER_LANG_ID}`) + .filter(':visible') + .click(); + Object.entries(langs).forEach((l, idx) => { + cy.get(buildCategoryOptionSelector(idx)).contains(l[1]); + }); + }); + it.skip('scroll to bottom and search should pop out', () => { cy.get(`#${ALL_COLLECTIONS_GRID_ID}`); diff --git a/package.json b/package.json index 01aae47f..4ae2f07e 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "@emotion/react": "11.13.3", "@emotion/server": "11.11.0", "@emotion/styled": "11.13.0", - "@graasp/query-client": "5.0.0", - "@graasp/sdk": "4.32.1", + "@graasp/query-client": "5.1.0", + "@graasp/sdk": "4.33.0", "@graasp/stylis-plugin-rtl": "2.2.0", "@graasp/translations": "1.40.0", "@graasp/ui": "5.4.0", diff --git a/src/components/collection/Collection.tsx b/src/components/collection/Collection.tsx index 3b785dff..121561b0 100644 --- a/src/components/collection/Collection.tsx +++ b/src/components/collection/Collection.tsx @@ -4,7 +4,7 @@ import { validate } from 'uuid'; import { useContext, useEffect } from 'react'; -import { Box } from '@mui/material'; +import { Box, Skeleton } from '@mui/material'; import { AccountType, @@ -27,11 +27,7 @@ type Props = { }; const Collection = ({ id }: Props) => { const { hooks, mutations } = useContext(QueryClientContext); - const { - data: collection, - isLoading: isLoadingItem, - isError, - } = hooks.useItem(id); + const { data: collection, isLoading: isLoadingItem } = hooks.useItem(id); const { data: currentMember } = hooks.useCurrentMember(); // get item published const { @@ -68,47 +64,50 @@ const Collection = ({ id }: Props) => { ); } - if (isError) { + if (currentMember?.type === AccountType.Guest) { + return null; + } + if (collection) { return ( - - - + <> + + + + + ); } - if (currentMember?.type === AccountType.Guest) { - return null; + if (isLoadingItem) { + return ; } return ( - <> - - - - - + + + ); }; diff --git a/src/components/collection/summary/Summary.tsx b/src/components/collection/summary/Summary.tsx index df9ee5df..3c5ececa 100644 --- a/src/components/collection/summary/Summary.tsx +++ b/src/components/collection/summary/Summary.tsx @@ -16,7 +16,7 @@ import SummaryDetails from './SummaryDetails'; import SummaryHeader from './SummaryHeader'; type SummaryProps = { - collection?: DiscriminatedItem; + collection: DiscriminatedItem; publishedRoot?: ItemPublished | null; isLoading: boolean; totalViews: number; diff --git a/src/components/collection/summary/SummaryDetails.tsx b/src/components/collection/summary/SummaryDetails.tsx index 1e00fca3..9cfb1e90 100644 --- a/src/components/collection/summary/SummaryDetails.tsx +++ b/src/components/collection/summary/SummaryDetails.tsx @@ -18,6 +18,7 @@ import { DiscriminatedItem, formatDate, } from '@graasp/sdk'; +import { DEFAULT_LANG, langs } from '@graasp/translations'; import { CATEGORY_COLORS, UrlSearch } from '../../../config/constants'; import { @@ -90,7 +91,7 @@ const CategoryDisplay = ({ }; type SummaryDetailsProps = { - collection?: DiscriminatedItem; + collection: DiscriminatedItem; publishedRootItem?: DiscriminatedItem; lang: string; isLoading: boolean; @@ -126,10 +127,9 @@ const SummaryDetails: React.FC = ({ ?.filter((c) => c.category.type === CategoryType.Discipline) ?.map((c) => c.category); - // TODO: should use item language - const languages = itemCategories - ?.filter((c) => c.category.type === CategoryType.Language) - ?.map((c) => c.category); + const langKey = collection.lang in langs ? collection.lang : DEFAULT_LANG; + // @ts-ignore + const langValue = langs[langKey]; return ( = ({ - {t(LIBRARY.COLLECTION_LANGUAGES_TITLE)} + {t(LIBRARY.COLLECTION_LANGUAGE_TITLE)} - {languages ? ( - - ) : ( - - )} + diff --git a/src/components/filters/CategoryFilter.tsx b/src/components/filters/CategoryFilter.tsx new file mode 100644 index 00000000..b69f9523 --- /dev/null +++ b/src/components/filters/CategoryFilter.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { Category } from '@graasp/sdk'; + +import { useCategoriesTranslation } from '../../config/i18n'; +import { + buildSearchFilterCategoryId, + buildSearchFilterPopperButtonId, +} from '../../config/selectors'; +import { Filter, FilterProps } from './Filter'; + +type CategoryFilterProps = { + category: string; + title: string; + options?: Category[]; + selectedOptionIds: FilterProps['selectedOptionIds']; + onOptionChange: (key: string, newValue: boolean) => void; + onClearOptions: () => void; + isLoading: boolean; +}; + +// eslint-disable-next-line react/function-component-definition +export function CategoryFilter({ + category, + title, + onOptionChange, + onClearOptions, + options, + selectedOptionIds, + isLoading, +}: CategoryFilterProps) { + const { t: translateCategories } = useCategoriesTranslation(); + + return ( + [c.id, translateCategories(c.name)])} + selectedOptionIds={selectedOptionIds} + onOptionChange={onOptionChange} + onClearOptions={onClearOptions} + /> + ); +} diff --git a/src/components/filters/Filter.tsx b/src/components/filters/Filter.tsx new file mode 100644 index 00000000..33f1f685 --- /dev/null +++ b/src/components/filters/Filter.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from 'react'; + +import { ExpandMoreRounded } from '@mui/icons-material'; +import { Box, Button, Skeleton, Stack, Typography } from '@mui/material'; + +import { GRAASP_COLOR } from '../../config/constants'; +import { useLibraryTranslation } from '../../config/i18n'; +import LIBRARY from '../../langs/constants'; +import { FilterPopper, FilterPopperProps } from './FilterPopper'; + +export type FilterProps = { + title: string; + selectedOptionIds: string[]; + isLoading?: boolean; + onClearOptions: FilterPopperProps['onClearOptions']; + onOptionChange: FilterPopperProps['onOptionChange']; + id: string; + buttonId: string; + options?: FilterPopperProps['options']; +}; + +export const Filter = ({ + title, + selectedOptionIds, + isLoading, + onClearOptions, + onOptionChange, + id, + buttonId, + options, +}: FilterProps) => { + const { t } = useLibraryTranslation(); + const [showPopper, setShowPopper] = useState(false); + const togglePopper = () => { + setShowPopper((oldVal) => !oldVal); + }; + + const popperAnchor = useRef(null); + const popper = useRef(null); + + const onDocumentScrolled = () => { + setShowPopper(() => false); + }; + + const onDocumentClicked = (event: MouseEvent) => { + if ( + !popper.current?.contains(event.target as Node) && + !popperAnchor.current?.contains(event.target as Node) + ) { + setShowPopper(() => false); + } + }; + // Listens for clicks outside of the popper to dismiss it when we click outside. + useEffect(() => { + if (showPopper) { + document.addEventListener('click', onDocumentClicked); + document.addEventListener('scroll', onDocumentScrolled); + } + return () => { + document.removeEventListener('click', onDocumentClicked); + document.removeEventListener('scroll', onDocumentScrolled); + }; + }, [showPopper]); + + const content = isLoading ? ( + + ) : ( + + ); + + return ( + + + {title} + + + {content} + + + + ); +}; diff --git a/src/components/filters/FilterHeader.tsx b/src/components/filters/FilterHeader.tsx index a986db0c..578209f7 100644 --- a/src/components/filters/FilterHeader.tsx +++ b/src/components/filters/FilterHeader.tsx @@ -2,23 +2,19 @@ import groupBy from 'lodash.groupby'; import React, { FC, useContext, useEffect, useRef, useState } from 'react'; -import { ExpandMoreRounded } from '@mui/icons-material'; import { Box, - Button, Checkbox, Container, Divider, FormControlLabel, - Skeleton, Stack, Typography, styled, } from '@mui/material'; -import { Category, CategoryType } from '@graasp/sdk'; +import { CategoryType } from '@graasp/sdk'; -import { GRAASP_COLOR } from '../../config/constants'; import { useCategoriesTranslation, useLibraryTranslation, @@ -26,155 +22,12 @@ import { import { ALL_COLLECTIONS_TITLE_ID, ENABLE_IN_DEPTH_SEARCH_CHECKBOX_ID, - buildSearchFilterCategoryId, - buildSearchFilterPopperButtonId, } from '../../config/selectors'; import LIBRARY from '../../langs/constants'; import { QueryClientContext } from '../QueryClientContext'; import Search from '../search/Search'; -import FilterPopper from './FilterPopper'; - -type FilterProps = { - category: string; - title: string; - options?: Category[]; - // IDs of selected options. - selectedOptions: string[]; - onOptionChange: (key: string, newValue: boolean) => void; - onClearOptions: () => void; - isLoading: boolean; -}; - -const Filter: React.FC = ({ - category, - title, - onOptionChange, - onClearOptions, - options, - selectedOptions, - isLoading, -}) => { - const { t: translateCategories } = useCategoriesTranslation(); - const { t } = useLibraryTranslation(); - const [showPopper, setShowPopper] = useState(false); - const togglePopper = () => { - setShowPopper((oldVal) => !oldVal); - }; - - const popperAnchor = useRef(null); - const popper = useRef(null); - - const onDocumentScrolled = () => { - setShowPopper(() => false); - }; - - const onDocumentClicked = (event: MouseEvent) => { - if ( - !popper.current?.contains(event.target as Node) && - !popperAnchor.current?.contains(event.target as Node) - ) { - setShowPopper(() => false); - } - }; - - const selectionCount = React.useMemo( - () => - selectedOptions.filter((id) => options?.find((opt) => opt.id === id)) - .length, - [selectedOptions, options], - ); - - const selectionStr = React.useMemo(() => { - const optionsStr = - options - ?.filter((it) => selectedOptions.includes(it.id)) - .map((it) => translateCategories(it.name))?.[0] ?? - t(LIBRARY.FILTER_DROPDOWN_NO_FILTER); - return optionsStr; - }, [selectedOptions, options]); - - // Listens for clicks outside of the popper to dismiss it when we click outside. - useEffect(() => { - if (showPopper) { - document.addEventListener('click', onDocumentClicked); - document.addEventListener('scroll', onDocumentScrolled); - } - return () => { - document.removeEventListener('click', onDocumentClicked); - document.removeEventListener('scroll', onDocumentScrolled); - }; - }, [showPopper]); - - const content = isLoading ? ( - - ) : ( - - ); - - return ( - - - {title} - - - {content} - - - - ); -}; +import { CategoryFilter } from './CategoryFilter'; +import { LangFilter } from './LangFilter'; const StyledFilterContainer = styled(Stack)(() => ({ backgroundColor: 'white', @@ -212,17 +65,21 @@ type FilterHeaderProps = { searchPreset?: string; categoryPreset?: string[][]; isLoadingResults: boolean; + setLangs: (langs: string[]) => void; + langs: string[]; }; const FilterHeader: FC = ({ onFiltersChanged, onChangeSearch, + setLangs, onSearch, searchPreset, categoryPreset, isLoadingResults, onIncludeContentChange, shouldIncludeContent, + langs, }) => { const { t: translateCategories } = useCategoriesTranslation(); const { t } = useLibraryTranslation(); @@ -239,26 +96,6 @@ const FilterHeader: FC = ({ const allCategories = groupBy(categories, (entry) => entry.type); const levelList = allCategories[CategoryType.Level]; const disciplineList = allCategories[CategoryType.Discipline]; - const languageList = allCategories[CategoryType.Language]; - - // TODO: Replace with real values. - // const licenseList: List = convertJs([ - // { - // id: '3f811e5f-5221-4d22-a20c-1086af809bda', - // name: 'Public Domain (CC0)', - // type: '3f811e5f-5221-4d22-a20c-1086af809bd0', - // }, - // { - // id: '3f811e5f-5221-4d22-a20c-1086af809bdb', - // name: 'For Commercial Use', - // type: '3f811e5f-5221-4d22-a20c-1086af809bd0', - // }, - // { - // id: '3f811e5f-5221-4d22-a20c-1086af809bdc', - // name: 'Derivable', - // type: '3f811e5f-5221-4d22-a20c-1086af809bd0', - // }, - // ]); useEffect(() => { setSelectedFilters(categoryPreset ? categoryPreset.flat() : []); @@ -322,47 +159,40 @@ const FilterHeader: FC = ({ /> ); + const selectedDisciplineOptions = selectedFilters.filter((id) => + disciplineList?.find((opt) => opt.id === id), + ); + const selectedLevelOptions = selectedFilters.filter((id) => + levelList?.find((opt) => opt.id === id), + ); + const filters = [ - onClearCategory(levelList?.map((l) => l.id))} isLoading={isCategoriesLoading} />, - onClearCategory(disciplineList?.map((d) => d.id))} isLoading={isCategoriesLoading} />, - onClearCategory(languageList?.map((d) => d.id))} - isLoading={isCategoriesLoading} + title={t(LIBRARY.SEARCH_FILTER_LANG_TITLE)} + selectedOptionIds={langs} + setLangs={setLangs} />, - // onClearCategory(licenseList?.map((d) => d.id))} - // isLoading={isCategoriesLoading} - // />, ]; return ( diff --git a/src/components/filters/FilterPopper.tsx b/src/components/filters/FilterPopper.tsx index b1f7d3f5..53b48fed 100644 --- a/src/components/filters/FilterPopper.tsx +++ b/src/components/filters/FilterPopper.tsx @@ -8,17 +8,11 @@ import { Grow, Popper, Stack, - Typography, styled, } from '@mui/material'; import { TransitionProps as MUITransitionProps } from '@mui/material/transitions'; -import { Category } from '@graasp/sdk'; - -import { - useCategoriesTranslation, - useLibraryTranslation, -} from '../../config/i18n'; +import { useLibraryTranslation } from '../../config/i18n'; import { CLEAR_FILTER_POPPER_BUTTON_ID, FILTER_POPPER_ID, @@ -35,29 +29,27 @@ const StyledPopper = styled(Stack)(() => ({ boxShadow: '0 2px 15px rgba(0, 0, 0, 0.08)', })); -type FilterPopperProps = { +export type FilterPopperProps = { open: boolean; anchorEl: HTMLElement | null; - options?: Category[]; - // IDs of selected options. - selectedOptions: string[]; + options?: [k: string, v: string][]; + selectedOptionIds: string[]; onOptionChange: (id: string, newSelected: boolean) => void; onClearOptions: () => void; }; -const FilterPopper = React.forwardRef( +export const FilterPopper = React.forwardRef( ( { + options, anchorEl, onOptionChange, open, - options, - selectedOptions, + selectedOptionIds, onClearOptions, }, ref, ) => { - const { t: translateCategories } = useCategoriesTranslation(); const { t } = useLibraryTranslation(); return ( ( // eslint-disable-next-line react/jsx-props-no-spreading - {options - ?.map((c) => ({ ...c, name: translateCategories(c.name) })) - ?.sort(compare) - .map((option, idx) => { - const isSelected = selectedOptions.includes(option.id); - return ( - - - - onOptionChange(option.id, !isSelected) - } - /> - } - label={option.name} - labelPlacement="end" - /> - - - ); - }) || ( - - {t(LIBRARY.FILTER_DROPDOWN_NO_CATEGORIES_AVAILABLE)} - - )} + {options?.sort(compare).map(([k, v], idx) => { + const isSelected = selectedOptionIds.includes(k); + return ( + + + onOptionChange(k, !isSelected)} + /> + } + label={v} + labelPlacement="end" + /> + + + ); + })}