diff --git a/database/migrations/functions/issues/get_issues_filters.sql b/database/migrations/functions/issues/get_issues_filters.sql index 5db832f..d7b6059 100644 --- a/database/migrations/functions/issues/get_issues_filters.sql +++ b/database/migrations/functions/issues/get_issues_filters.sql @@ -1,123 +1,146 @@ -- Return the filters that can be used when searching for issues in json format. create or replace function get_issues_filters() returns json as $$ - select json_build_array( - json_build_object( - 'title', 'Foundation', - 'key', 'foundation', - 'options', ( - select coalesce(json_agg(json_build_object( - 'name', display_name, - 'value', foundation_id - )), '[]') - from ( - select foundation_id, display_name - from foundation - order by foundation_id asc - ) f - ) + select json_build_object( + 'filters', json_build_array( + json_build_object( + 'title', 'Foundation', + 'key', 'foundation', + 'options', ( + select coalesce(json_agg(json_build_object( + 'name', display_name, + 'value', foundation_id + )), '[]') + from ( + select foundation_id, display_name + from foundation + order by foundation_id asc + ) f + ) + ), + json_build_object( + 'title', 'Maturity', + 'key', 'maturity', + 'options', ( + select coalesce(json_agg(json_build_object( + 'name', initcap(maturity), + 'value', maturity + )), '[]') + from ( + select distinct maturity + from project + where maturity is not null + order by maturity asc + ) m + ) + ), + json_build_object( + 'title', 'Project', + 'key', 'project', + 'options', ( + select coalesce(json_agg(json_build_object( + 'name', coalesce(display_name, name), + 'value', name + )), '[]') + from ( + select distinct p.display_name, p.name + from project p + join repository r using (project_id) + join issue i using (repository_id) + order by name asc + ) m + ) + ), + json_build_object( + 'title', 'Area', + 'key', 'area', + 'options', ( + select coalesce(json_agg(json_build_object( + 'name', initcap(area::text), + 'value', area::text + )), '[]') + from ( + select unnest(enum_range(null::area)) as area + ) a + ) + ), + json_build_object( + 'title', 'Kind', + 'key', 'kind', + 'options', ( + select coalesce(json_agg(json_build_object( + 'name', initcap(kind::text), + 'value', kind::text + )), '[]') + from ( + select unnest(enum_range(null::kind)) as kind + ) k + ) + ), + json_build_object( + 'title', 'Difficulty', + 'key', 'difficulty', + 'options', ( + select coalesce(json_agg(json_build_object( + 'name', initcap(difficulty::text), + 'value', difficulty::text + )), '[]') + from ( + select unnest(enum_range(null::difficulty)) as difficulty + ) d + ) + ), + json_build_object( + 'title', 'Language', + 'key', 'language', + 'options', ( + select coalesce(json_agg(json_build_object( + 'name', language, + 'value', language + )), '[]') + from ( + select distinct(unnest(languages)) as language + from repository + order by language asc + ) m + ) + ), + '{ + "title": "Other", + "options": [ + { + "name": "Good first issue", + "key": "good_first_issue", + "type": "boolean" + }, + { + "name": "Mentor available", + "key": "mentor_available", + "type": "boolean" + } + ] + }'::jsonb ), - json_build_object( - 'title', 'Maturity', - 'key', 'maturity', - 'options', ( - select coalesce(json_agg(json_build_object( - 'name', initcap(maturity), - 'value', maturity - )), '[]') + 'extra', json_build_object( + 'maturity', ( + select coalesce(json_object_agg(foundation_id, maturities), '{}') from ( - select distinct maturity + select distinct foundation_id, array_agg(distinct maturity) as maturities from project where maturity is not null - order by maturity asc - ) m - ) - ), - json_build_object( - 'title', 'Project', - 'key', 'project', - 'options', ( - select coalesce(json_agg(json_build_object( - 'name', coalesce(display_name, name), - 'value', name - )), '[]') - from ( - select distinct p.display_name, p.name - from project p - join repository r using (project_id) - join issue i using (repository_id) - order by name asc - ) m - ) - ), - json_build_object( - 'title', 'Area', - 'key', 'area', - 'options', ( - select coalesce(json_agg(json_build_object( - 'name', initcap(area::text), - 'value', area::text - )), '[]') - from ( - select unnest(enum_range(null::area)) as area - ) a - ) - ), - json_build_object( - 'title', 'Kind', - 'key', 'kind', - 'options', ( - select coalesce(json_agg(json_build_object( - 'name', initcap(kind::text), - 'value', kind::text - )), '[]') - from ( - select unnest(enum_range(null::kind)) as kind - ) k - ) - ), - json_build_object( - 'title', 'Difficulty', - 'key', 'difficulty', - 'options', ( - select coalesce(json_agg(json_build_object( - 'name', initcap(difficulty::text), - 'value', difficulty::text - )), '[]') - from ( - select unnest(enum_range(null::difficulty)) as difficulty - ) d - ) - ), - json_build_object( - 'title', 'Language', - 'key', 'language', - 'options', ( - select coalesce(json_agg(json_build_object( - 'name', language, - 'value', language - )), '[]') + group by foundation_id + order by foundation_id asc + ) as maturities + ), + 'project', ( + select coalesce(json_object_agg(foundation_id, projects), '{}') from ( - select distinct(unnest(languages)) as language - from repository - order by language asc - ) m + select distinct foundation_id, array_agg(distinct name) as projects + from project + group by foundation_id + order by foundation_id asc + ) as projects ) - ), - '{ - "title": "Other", - "options": [ - { - "name": "Good first issue", - "key": "good_first_issue", - "type": "boolean" - }, - { - "name": "Mentor available", - "key": "mentor_available", - "type": "boolean" - } - ] - }'::jsonb + ) ); $$ language sql; diff --git a/web/package.json b/web/package.json index 12eea06..9187496 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "dependencies": { "classnames": "^2.5.1", - "clo-ui": "https://github.com/cncf/clo-ui.git#v0.1.22", + "clo-ui": "https://github.com/cncf/clo-ui.git#v0.2.0", "lodash": "^4.17.21", "moment": "^2.30.1", "react": "^18.2.0", diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 7221fa7..80f16c6 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -2,7 +2,7 @@ import { isEmpty, isNull, isUndefined } from 'lodash'; import isArray from 'lodash/isArray'; import { DEFAULT_SORT_BY } from '../data'; -import { Error, ErrorKind, Filter, Issue, SearchQuery } from '../types'; +import { Error, ErrorKind, FiltersReponse, Issue, SearchQuery } from '../types'; interface FetchOptions { method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'HEAD'; @@ -104,7 +104,7 @@ class API_CLASS { .catch((error) => Promise.reject(error)); } - public getIssuesFilters(): Promise { + public getIssuesFilters(): Promise { return this.apiFetch({ url: `${this.API_BASE_URL}/filters/issues`, }); diff --git a/web/src/layout/common/Card.tsx b/web/src/layout/common/Card.tsx index fc94cec..8e4f197 100644 --- a/web/src/layout/common/Card.tsx +++ b/web/src/layout/common/Card.tsx @@ -139,7 +139,6 @@ const Card = (props: Props) => { maturityLevel={props.issue.project.maturity} maxLength={14} className="d-none d-sm-flex me-2" - onClick={() => searchByFilter(FilterKind.Maturity, props.issue.project.maturity!)} /> )} { onClick={() => searchByFilter(FilterKind.Foundation, props.issue.project.foundation)} /> {props.issue.project.maturity && ( - searchByFilter(FilterKind.Maturity, props.issue.project.maturity!)} - /> + )} diff --git a/web/src/layout/search/Filters.tsx b/web/src/layout/search/Filters.tsx index 550dcec..04c595d 100644 --- a/web/src/layout/search/Filters.tsx +++ b/web/src/layout/search/Filters.tsx @@ -3,6 +3,8 @@ import { isEmpty } from 'lodash'; import React from 'react'; import { IoMdCloseCircleOutline } from 'react-icons/io'; +import { FilterKind } from '../../types'; + interface Props { visibleTitle: boolean; filters: FilterSection[]; @@ -14,6 +16,7 @@ interface Props { onChange: (name: string, value: string, checked: boolean, type?: string) => void; onResetFilters?: () => void; device: string; + disabledSections: FilterKind[]; } const Filters = (props: Props) => { @@ -47,10 +50,17 @@ const Filters = (props: Props) => { {props.filters.map((section: FilterSection) => { const activeFilters = section.key ? props.activeFilters[section.key] : getActiveFiltersForOther(); - // Does not render project and language filters on mobile version - if (section.key && ['project', 'language'].includes(section.key)) return null; + const key = (section.key || section.title) as FilterKind; + // Does not render project and language filters or disabled sections on mobile version + if ( + [FilterKind.Language, FilterKind.Project].includes(key) || + props.disabledSections.includes(key) || + section.options.length === 0 + ) + return null; + return ( - + { withSearchBar={props.withSearchBar} onChange={onChangeFilter} visibleTitle={false} + disabled={props.disabled} /> ); @@ -101,6 +112,7 @@ const FiltersInLine = (props: Props) => { {props.filters.map((section: FilterSection, index: number) => { const isSearchSection = section.key && ['project', 'language'].includes(section.key); const activeFilters = section.key ? props.activeFilters[section.key] : getActiveFiltersForOther(); + const isDisabled = props.disabledSections.includes((section.key || section.title) as FilterKind); return ( @@ -109,28 +121,40 @@ const FiltersInLine = (props: Props) => { 'me-2 me-lg-3 me-xl-4': index !== props.filters.length - 1, })} > - - - + + + + } + tooltipWidth={210} + tooltipArrowClassName={styles.tooltipArrow} + tooltipMessage="Please select a foundation to use this filter" + visibleTooltip={isDisabled} + active + /> + {activeFilters && (
{activeFilters.map((filter: string) => { diff --git a/web/src/layout/search/index.tsx b/web/src/layout/search/index.tsx index cd45ca7..ea4aed6 100644 --- a/web/src/layout/search/index.tsx +++ b/web/src/layout/search/index.tsx @@ -1,6 +1,8 @@ import classNames from 'classnames'; import { + FilterOption, FilterSection, + Foundation, Loading, NoData, Pagination, @@ -9,7 +11,7 @@ import { Sidebar, SortOptions, } from 'clo-ui'; -import { isEmpty, isUndefined } from 'lodash'; +import { isEmpty, isNull, isUndefined } from 'lodash'; import { useContext, useEffect, useState } from 'react'; import { FaFilter } from 'react-icons/fa'; import { IoMdCloseCircleOutline } from 'react-icons/io'; @@ -18,7 +20,7 @@ import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom import API from '../../api'; import { AppContext, updateLimit, updateSort } from '../../context/AppContextProvider'; import { DEFAULT_SORT_BY, SORT_OPTIONS } from '../../data'; -import { Issue, OutletContext, SearchFiltersURL, SortBy } from '../../types'; +import { FilterKind, FiltersExtra, Issue, OutletContext, SearchFiltersURL, SortBy } from '../../types'; import buildSearchParams from '../../utils/buildSearchParams'; import prepareQueryString from '../../utils/prepareQueryString'; import Card from '../common/Card'; @@ -39,17 +41,22 @@ const Search = () => { const [text, setText] = useState(); const [mentorAvailable, setMentorAvailable] = useState(false); const [goodFirstIssue, setGoodFirstIssue] = useState(false); + const [fullFilters, setFullFilters] = useState(undefined); + const [cleanFilters, setCleanFilters] = useState(undefined); const [filters, setFilters] = useState(undefined); + const [filtersExtra, setFiltersExtra] = useState(); const [activeFilters, setActiveFilters] = useState({}); const [pageNumber, setPageNumber] = useState(1); const [total, setTotal] = useState(0); const [issues, setIssues] = useState(); const [isLoading, setIsLoading] = useState(false); const [apiError, setApiError] = useState(null); + const [selectedFoundation, setSelectedFoundation] = useState(null); // Check if some filters are active const ifActiveFilters = !isEmpty(activeFilters) || mentorAvailable || goodFirstIssue; const onResetFilters = (): void => { + setSelectedFoundation(null); navigate({ pathname: '/search', search: prepareQueryString({ @@ -69,19 +76,18 @@ const Search = () => { let newFilters = isUndefined(currentFilters[name]) ? [] : currentFilters[name].slice(); if (checked) { newFilters.push(value); - switch (name) { - case 'project': - additionalChanges = { foundation: [], maturity: [] }; - break; - case 'foundation': - case 'maturity': - additionalChanges = { project: [] }; - break; - default: - break; + if (name === FilterKind.Foundation) { + if (newFilters.length > 1) { + additionalChanges = { project: [], maturity: [] }; + } } } else { newFilters = newFilters.filter((el) => el !== value); + if (name === FilterKind.Foundation) { + if (newFilters.length !== 1) { + additionalChanges = { project: [], maturity: [] }; + } + } } updateCurrentPage({ @@ -158,6 +164,31 @@ const Search = () => { return pNumber && limit ? (pNumber - 1) * limit : 0; }; + const prepareFilters = (foundation: Foundation, allFilters?: FilterSection[], extra?: FiltersExtra) => { + let currentFilters = !isUndefined(allFilters) ? [...allFilters] : []; + if (foundation && !isUndefined(extra)) { + [FilterKind.Maturity, FilterKind.Project].forEach((k: FilterKind) => { + const kind = k as FilterKind.Project | FilterKind.Maturity; + const objIndex = currentFilters.findIndex((f: FilterSection) => (f.key || f.title) === k); + const values = extra[kind][foundation]; + const activeValues = (currentFilters[objIndex].options as FilterOption[]).filter((opt: FilterOption) => + values.includes(opt.value || opt.name) + ); + currentFilters[objIndex] = { ...currentFilters[objIndex], options: activeValues }; + }); + } + setFilters(currentFilters); + }; + + const cleanFullFilters = (f: FilterSection[]): FilterSection[] => { + let tmpFilters = [...f]; + [FilterKind.Maturity, FilterKind.Project].forEach((k: FilterKind) => { + const objIndex = tmpFilters.findIndex((f: FilterSection) => (f.key || f.title) === k); + tmpFilters[objIndex] = { ...tmpFilters[objIndex], options: [] }; + }); + return tmpFilters; + }; + useEffect(() => { async function getIssuesFilters() { setIsLoading(true); @@ -165,7 +196,16 @@ const Search = () => { scrollToTop(); try { - setFilters(await API.getIssuesFilters()); + const response = await API.getIssuesFilters(); + setFilters(response.filters); + setFullFilters(response.filters); + setCleanFilters(cleanFullFilters(response.filters)); + setFiltersExtra(response.extra); + + const selectedFoundations = searchParams.getAll('foundation'); + if (selectedFoundations.length === 1) { + prepareFilters(selectedFoundations[0] as Foundation, response.filters, response.extra); + } } catch { setFilters([]); } @@ -181,6 +221,19 @@ const Search = () => { setActiveFilters(formattedParams.filters || {}); setPageNumber(formattedParams.pageNumber); + const foundationActive: Foundation | null = + !isUndefined(formattedParams.filters) && + formattedParams.filters[FilterKind.Foundation] && + formattedParams.filters[FilterKind.Foundation].length === 1 + ? (formattedParams.filters[FilterKind.Foundation][0] as Foundation) + : null; + + if (foundationActive) { + setSelectedFoundation(foundationActive); + } else { + setSelectedFoundation(null); + } + async function searchIssues() { setIsLoading(true); setInvisibleFooter(true); @@ -218,6 +271,17 @@ const Search = () => { }, [searchParams, limit, sort.by]); /* eslint-enable react-hooks/exhaustive-deps */ + useEffect(() => { + if (!isUndefined(cleanFilters)) { + if (isNull(selectedFoundation)) { + setFilters(cleanFilters); + } else { + prepareFilters(selectedFoundation, fullFilters, filtersExtra); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedFoundation]); + return ( <> {/* Subnavbar */} @@ -226,61 +290,60 @@ const Search = () => {
- {!isUndefined(filters) && filters.length > 0 && ( - } - closeButtonClassName={styles.closeSidebar} - closeButton={ - <> - {isLoading ? ( - <> - - Searching... - - ) : ( - <>See {total} results - )} - - } - leftButton={ - <> - {ifActiveFilters && ( -
- - -
- )} - - } - header={
Filters
} - > -
- -
-
- )} + } + closeButtonClassName={styles.closeSidebar} + closeButton={ + <> + {isLoading ? ( + <> + + Searching... + + ) : ( + <>See {total} results + )} + + } + leftButton={ + <> + {ifActiveFilters && ( +
+ + +
+ )} + + } + header={
Filters
} + > +
+ +
+
{total > 0 && ( @@ -327,6 +390,7 @@ const Search = () => {