Skip to content

Commit

Permalink
feat: add keywords filter to software
Browse files Browse the repository at this point in the history
refactor: projects and organisations index page to use adapted method of building url string
tests: adapt existing tests to match new url contruction and add more tests
  • Loading branch information
dmijatovic committed Jul 26, 2022
1 parent 99cff62 commit e44561d
Show file tree
Hide file tree
Showing 13 changed files with 518 additions and 158 deletions.
71 changes: 67 additions & 4 deletions database/100-create-api-views.sql
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ $$;

-- Keywords by software
-- for selecting keywords of specific software
-- using filter ?project=eq.UUID
-- using filter ?software=eq.UUID
CREATE FUNCTION keywords_by_software() RETURNS TABLE (
id UUID,
keyword CITEXT,
Expand All @@ -61,6 +61,30 @@ INNER JOIN
END
$$;

-- Keywords grouped by software as an array for filtering
-- for selecting software with specific keywords (AND)
CREATE FUNCTION keyword_filter_for_software() RETURNS TABLE (
software UUID,
keywords CITEXT[]
) LANGUAGE plpgsql STABLE AS
$$
BEGIN
RETURN QUERY
SELECT
keyword_for_software.software AS software,
array_agg(
keyword.value
ORDER BY value
) AS keywords
FROM
keyword_for_software
INNER JOIN
keyword ON keyword.id = keyword_for_software.keyword
GROUP BY keyword_for_software.software
;
END
$$;

-- COUNT contributors per software
CREATE FUNCTION count_software_countributors() RETURNS TABLE (software UUID, contributor_cnt BIGINT) LANGUAGE plpgsql STABLE AS
$$
Expand Down Expand Up @@ -103,15 +127,51 @@ END
$$;

-- SOFTWARE OVERVIEW LIST WITH COUNTS
CREATE FUNCTION software_list() RETURNS TABLE (
-- DEPRECATED from 1.3.0 (2022-07-25)
-- CREATE FUNCTION software_list() RETURNS TABLE (
-- id UUID,
-- slug VARCHAR,
-- brand_name VARCHAR,
-- short_statement VARCHAR,
-- updated_at TIMESTAMPTZ,
-- contributor_cnt BIGINT,
-- mention_cnt BIGINT,
-- is_published BOOLEAN
-- ) LANGUAGE plpgsql STABLE AS
-- $$
-- BEGIN
-- RETURN QUERY
-- SELECT
-- software.id,
-- software.slug,
-- software.brand_name,
-- software.short_statement,
-- software.updated_at,
-- count_software_countributors.contributor_cnt,
-- count_software_mentions.mention_cnt,
-- software.is_published
-- FROM
-- software
-- LEFT JOIN
-- count_software_countributors() ON software.id=count_software_countributors.software
-- LEFT JOIN
-- count_software_mentions() ON software.id=count_software_mentions.software
-- ;
-- END
-- $$;

-- SOFTWARE OVERVIEW LIST FOR SEARCH
-- WITH COUNTS and KEYWORDS for filtering
CREATE FUNCTION software_search() RETURNS TABLE (
id UUID,
slug VARCHAR,
brand_name VARCHAR,
short_statement VARCHAR,
updated_at TIMESTAMPTZ,
contributor_cnt BIGINT,
mention_cnt BIGINT,
is_published BOOLEAN
is_published BOOLEAN,
keywords citext[]
) LANGUAGE plpgsql STABLE AS
$$
BEGIN
Expand All @@ -124,13 +184,16 @@ BEGIN
software.updated_at,
count_software_countributors.contributor_cnt,
count_software_mentions.mention_cnt,
software.is_published
software.is_published,
keyword_filter_for_software.keywords
FROM
software
LEFT JOIN
count_software_countributors() ON software.id=count_software_countributors.software
LEFT JOIN
count_software_mentions() ON software.id=count_software_mentions.software
LEFT JOIN
keyword_filter_for_software() ON software.id=keyword_filter_for_software.software
;
END
$$;
Expand Down
5 changes: 3 additions & 2 deletions frontend/__tests__/OrganisationsIndex.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('pages/organisations/index.tsx', () => {
})

it('getServerSideProps returns mocked values in the props', async () => {
const resp = await getServerSideProps({req:{cookies:{}}})
const resp = await getServerSideProps({req: {cookies: {}}, query: {}})
expect(resp).toEqual({
props:{
// count is extracted from response header
Expand All @@ -35,7 +35,8 @@ describe('pages/organisations/index.tsx', () => {
page:0,
rows:12,
// mocked data
organisations: organisationsOverview
organisations: organisationsOverview,
search: null
}
})
})
Expand Down
3 changes: 2 additions & 1 deletion frontend/__tests__/ProjectsIndex.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ describe('pages/projects/index.tsx', () => {
page:0,
rows:12,
// mocked data
projects
projects,
search: null
}
})
})
Expand Down
11 changes: 9 additions & 2 deletions frontend/components/form/Searchbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@ import SearchIcon from '@mui/icons-material/Search'
import {useDebounce} from '~/utils/useDebounce'
import ClearIcon from '@mui/icons-material/Clear'

export default function Searchbox({placeholder, onSearch, delay = 400}: { placeholder:string,onSearch:Function,delay?:number}) {
type SearchboxProps = {
placeholder: string,
onSearch: Function,
delay?: number,
defaultValue?:string
}

export default function Searchbox({placeholder, onSearch, delay = 400, defaultValue=''}: SearchboxProps) {
const [state,setState]=useState({
value:'',
value:defaultValue ?? '',
wait:true
})
const searchFor=useDebounce(state.value,delay)
Expand Down
179 changes: 179 additions & 0 deletions frontend/components/keyword/KeywordFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// SPDX-FileCopyrightText: 2021 - 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2021 - 2022 dv4all
//
// SPDX-License-Identifier: Apache-2.0

import {useState, useEffect} from 'react'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import ListItemText from '@mui/material/ListItemText'
import Checkbox from '@mui/material/Checkbox'
import Badge from '@mui/material/Badge'
import Divider from '@mui/material/Divider'
import Button from '@mui/material/Button'
import FilterAltIcon from '@mui/icons-material/FilterAlt'
import ClearAllIcon from '@mui/icons-material/ClearAll'
import CloseIcon from '@mui/icons-material/Close'
import {TagItem} from '../../utils/getSoftware'
import Popover from '@mui/material/Popover'
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'

import TagListItem from '~/components/layout/TagListItem'
import FindKeyword, {Keyword} from './FindKeyword'
import {searchForSoftwareKeyword} from '../software/edit/information/searchForSoftwareKeyword'

type KeywordFilterProps = {
items?: string[]
onApply: (items: string[]) => void
}

/**
* Keywords filter component. It receives array of keywords and returns
* array of selected tags to use in filter using onSelect callback function
*/
export default function KeywordsFilter({items=[], onApply}:KeywordFilterProps) {
const [selectedItems, setSelectedItems] = useState<string[]>(items ?? [])
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)

// console.group('KeywordsFilter')
// console.log('selectedItems...', selectedItems)
// console.log('open...', open)
// console.groupEnd()


function handleOpen(event: React.MouseEvent<HTMLElement>){
setAnchorEl(event.currentTarget)
}
function handleClose(){
setAnchorEl(null)
}

function handleClear(){
setSelectedItems([])
onApply([])
handleClose()
}

function handleApply(){
onApply(selectedItems)
handleClose()
}

function handleDelete(pos:number) {
const newList = [
...selectedItems.slice(0, pos),
...selectedItems.slice(pos+1)
]
setSelectedItems(newList)
}

function onAdd(item: Keyword) {
const find = selectedItems.find(keyword => keyword.toLowerCase() === item.keyword.toLowerCase())
// new item
if (typeof find == 'undefined') {
const newList = [
...selectedItems,
item.keyword
].sort()
setSelectedItems(newList)
}
}

function renderSelectedItems() {
if (selectedItems && selectedItems.length > 0) {
return (
<section className="flex flex-wrap items-center px-4 pt-4 gap-2">
{selectedItems.map((item, pos) => {
if (pos > 0) {
return (
<div key={pos}>
<span className="text-md">+</span>
<Chip
label={item}
size="small"
onDelete={() => handleDelete(pos)}
/>
</div>
)
}
return (
<Chip
key={pos}
label={item}
size="small"
onDelete={() => handleDelete(pos)}
/>
)
})}
</section>
)
}
return null
}

return (
<>
<Tooltip title={`Filter: ${selectedItems.join(' + ')}`}>
<IconButton onClick={handleOpen}>
<Badge badgeContent={selectedItems.length} color="primary">
<FilterAltIcon />
</Badge>
</IconButton>
</Tooltip>
<Popover
anchorEl={anchorEl}
open={open}
onClose={handleClose}
// align menu to the right from the menu button
transformOrigin={{horizontal: 'right', vertical: 'top'}}
anchorOrigin={{horizontal: 'right', vertical: 'bottom'}}
sx={{
maxWidth:'24rem'
}}
>
<h3 className="px-4 py-3 text-primary">
Filter by keyword
</h3>
<Divider />
{renderSelectedItems()}
<section className="px-4 py-4 w-[22rem]">
<FindKeyword
config={{
freeSolo: false,
minLength: 1,
label: 'Add keyword to filter',
// help: 'Search for avilable keywords',
help: '',
reset: true
}}
searchForKeyword={searchForSoftwareKeyword}
onAdd={onAdd}
// onCreate={onCreate}
/>
</section>
<Divider />
<div className="flex items-center justify-between px-4 py-2">
<Button
color="secondary"
startIcon={<CloseIcon />}
onClick={handleClear}>
Clear
</Button>
<Button
onClick={handleApply}
startIcon={<PlayArrowIcon />}
disabled={selectedItems.length===0}
>
Apply
</Button>
</div>
</Popover>
</>
)
}
Loading

0 comments on commit e44561d

Please sign in to comment.