Skip to content

Commit

Permalink
feat: add rsd host filter when at least one remote defined
Browse files Browse the repository at this point in the history
feat: add remote_rsd_name env variable to communicate RSD name to remotes
  • Loading branch information
dmijatovic committed Dec 5, 2024
1 parent 8d3025f commit 624569c
Show file tree
Hide file tree
Showing 16 changed files with 411 additions and 58 deletions.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
# automatically name the containers.
COMPOSE_PROJECT_NAME="rsd"

# RSD REMOTE NAME
# identify this instance as remote by this name
# it is used as source label in RPC aggregated_software_overview
RSD_REMOTE_NAME=Local RSD

# ---- PUBLIC ENV VARIABLES -------------

# postgresql
Expand Down
59 changes: 59 additions & 0 deletions database/025-rsd-info.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- SPDX-FileCopyrightText: 2024 Dusan Mijatovic (Netherlands eScience Center)
-- SPDX-FileCopyrightText: 2024 Netherlands eScience Center
--
-- SPDX-License-Identifier: Apache-2.0

-- RSD info table
-- used to obtain RSD name to use for remotes
-- it should provide basic info about rsd instance (eg. endpoints)
CREATE TABLE rsd_info (
key VARCHAR(100) PRIMARY KEY,
value VARCHAR(250),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE FUNCTION sanitise_insert_rsd_info() RETURNS TRIGGER LANGUAGE plpgsql AS
$$
BEGIN
NEW.created_at = LOCALTIMESTAMP;
NEW.updated_at = NEW.created_at;
return NEW;
END
$$;

CREATE TRIGGER sanitise_insert_rsd_info BEFORE INSERT ON
rsd_info FOR EACH ROW EXECUTE PROCEDURE sanitise_insert_rsd_info();

CREATE FUNCTION sanitise_update_rsd_info() RETURNS TRIGGER LANGUAGE plpgsql AS
$$
BEGIN
NEW.created_at = OLD.created_at;
NEW.updated_at = LOCALTIMESTAMP;
return NEW;
END
$$;

CREATE TRIGGER sanitise_update_rsd_info BEFORE UPDATE ON
rsd_info FOR EACH ROW EXECUTE PROCEDURE sanitise_update_rsd_info();

-- Insert remote_name key extracted from env variable RSD_REMOTE_NAME, default value is 'Local RSD'
-- AND basic endpoints info
INSERT INTO rsd_info VALUES
('remote_name', COALESCE(current_setting('rsd.remote_name',true),'Local RSD')),
('postgrest_api','/api/v1'),
('images_api','/images'),
('swagger','/swagger'),
('codemeta','/metadata/codemeta')
;

-- RLS
-- rsd info table
ALTER TABLE rsd_info ENABLE ROW LEVEL SECURITY;
-- anyone can read (SELECT)
CREATE POLICY anyone_can_read ON rsd_info FOR SELECT TO rsd_web_anon, rsd_user
USING (TRUE);
-- rsd_admin has all rights
CREATE POLICY admin_all_rights ON rsd_info TO rsd_admin
USING (TRUE)
WITH CHECK (TRUE);
65 changes: 61 additions & 4 deletions database/124-aggregated-software-views.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ CREATE FUNCTION aggregated_software_overview() RETURNS TABLE (
$$
SELECT
software_overview.id,
NULL AS source,
-- use remote_name information for local name
(SELECT value FROM rsd_info WHERE KEY='remote_name') AS source,
NULL AS domain,
software_overview.slug,
software_overview.brand_name,
Expand Down Expand Up @@ -128,7 +129,8 @@ CREATE FUNCTION aggregated_software_keywords_filter(
search_filter TEXT DEFAULT '',
keyword_filter CITEXT[] DEFAULT '{}',
prog_lang_filter TEXT[] DEFAULT '{}',
license_filter VARCHAR[] DEFAULT '{}'
license_filter VARCHAR[] DEFAULT '{}',
source_filter VARCHAR[] DEFAULT NULL
) RETURNS TABLE (
keyword CITEXT,
keyword_cnt INTEGER
Expand All @@ -145,6 +147,12 @@ WHERE
COALESCE(prog_lang, '{}') @> prog_lang_filter
AND
COALESCE(licenses, '{}') @> license_filter
AND
CASE
WHEN source_filter IS NULL THEN TRUE
ELSE
source = ANY(source_filter)
END
GROUP BY
keyword
;
Expand All @@ -156,7 +164,8 @@ CREATE FUNCTION aggregated_software_languages_filter(
search_filter TEXT DEFAULT '',
keyword_filter CITEXT[] DEFAULT '{}',
prog_lang_filter TEXT[] DEFAULT '{}',
license_filter VARCHAR[] DEFAULT '{}'
license_filter VARCHAR[] DEFAULT '{}',
source_filter VARCHAR[] DEFAULT NULL
) RETURNS TABLE (
prog_language TEXT,
prog_language_cnt INTEGER
Expand All @@ -173,6 +182,12 @@ WHERE
COALESCE(prog_lang, '{}') @> prog_lang_filter
AND
COALESCE(licenses, '{}') @> license_filter
AND
CASE
WHEN source_filter IS NULL THEN TRUE
ELSE
source = ANY(source_filter)
END
GROUP BY
prog_language
;
Expand All @@ -184,7 +199,8 @@ CREATE FUNCTION aggregated_software_licenses_filter(
search_filter TEXT DEFAULT '',
keyword_filter CITEXT[] DEFAULT '{}',
prog_lang_filter TEXT[] DEFAULT '{}',
license_filter VARCHAR[] DEFAULT '{}'
license_filter VARCHAR[] DEFAULT '{}',
source_filter VARCHAR[] DEFAULT NULL
) RETURNS TABLE (
license VARCHAR,
license_cnt INTEGER
Expand All @@ -201,7 +217,48 @@ WHERE
COALESCE(prog_lang, '{}') @> prog_lang_filter
AND
COALESCE(licenses, '{}') @> license_filter
AND
CASE
WHEN source_filter IS NULL THEN TRUE
ELSE
source = ANY(source_filter)
END
GROUP BY
license
;
$$;

-- REACTIVE SOURCE FILTER WITH COUNTS FOR SOFTWARE
-- DEPENDS ON: aggregated_software_search
CREATE FUNCTION aggregated_software_sources_filter(
search_filter TEXT DEFAULT '',
keyword_filter CITEXT[] DEFAULT '{}',
prog_lang_filter TEXT[] DEFAULT '{}',
license_filter VARCHAR[] DEFAULT '{}',
source_filter VARCHAR[] DEFAULT NULL
) RETURNS TABLE (
source VARCHAR,
source_cnt INTEGER
) LANGUAGE sql STABLE AS
$$
SELECT
source AS source,
COUNT(id) AS source_cnt
FROM
aggregated_software_search(search_filter)
WHERE
COALESCE(keywords, '{}') @> keyword_filter
AND
COALESCE(prog_lang, '{}') @> prog_lang_filter
AND
COALESCE(licenses, '{}') @> license_filter
AND
CASE
WHEN source_filter IS NULL THEN TRUE
ELSE
source = ANY(source_filter)
END
GROUP BY
source
;
$$;
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ services:
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_AUTHENTICATOR_PASSWORD
- RSD_REMOTE_NAME
# insert remote_name into postgres settings, this value is used in initial SQL scripts
command: postgres -D /var/lib/postgresql/data -c 'rsd.remote_name=${RSD_REMOTE_NAME}'
volumes:
# persist data in named docker volume
# to remove use: docker compose down --volumes
Expand Down
15 changes: 9 additions & 6 deletions frontend/__tests__/SoftwareOverview.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 - 2023 dv4all
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0

Expand All @@ -23,16 +23,19 @@ const mockProps = {
keywords:null,
prog_lang: null,
licenses:null,
order:null,
sources: null,
order: '',
page: 1,
rows: 12,
count: 408,
layout: 'masonry' as LayoutType,
keywordsList: mockData.keywordsList,
languagesList: mockData.languagesList,
licensesList: mockData.licensesList,
sourcesList: [],
software: mockData.software as any,
highlights: mockData.highlights as any
highlights: mockData.highlights as any,
hasRemotes: false
}

describe('pages/software/index.tsx', () => {
Expand All @@ -57,7 +60,7 @@ describe('pages/software/index.tsx', () => {
<SoftwareOverviewPage {...mockProps} />
</WithAppContext>
)
const carousel = screen.getByTestId('highlights-carousel')
screen.getByTestId('highlights-carousel')
const cards = screen.getAllByTestId('highlights-card')
expect(cards.length).toEqual(mockData.highlights.length)
})
Expand All @@ -71,7 +74,7 @@ describe('pages/software/index.tsx', () => {
// get reference to filter panel
const panel = screen.getByTestId('filters-panel')
// find order by testid
const order = within(panel).getByTestId('filters-order-by')
within(panel).getByTestId('filters-order-by')
// should have 3 filters
const filters = within(panel).getAllByRole('combobox')
expect(filters.length).toEqual(3)
Expand Down
4 changes: 1 addition & 3 deletions frontend/components/admin/remote-rsd/useRemoteRsd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ export default function useRemoteRsd() {
},[token, searchFor, page, rows])

useEffect(()=>{
if(token){
loadRemoteRsd()
}
loadRemoteRsd()
// we do not include setCount in order to avoid loop
// eslint-disable-next-line react-hooks/exhaustive-deps
},[token, searchFor, page, rows])
Expand Down
82 changes: 82 additions & 0 deletions frontend/components/filter/RsdSourceFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2023 - 2024 Dusan Mijatovic (Netherlands eScience Center)
// SPDX-FileCopyrightText: 2023 - 2024 Netherlands eScience Center
// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2023 dv4all
//
// SPDX-License-Identifier: Apache-2.0

import {useEffect, useState} from 'react'

import Autocomplete from '@mui/material/Autocomplete'
import TextField from '@mui/material/TextField'
import FilterTitle from './FilterTitle'
import FilterOption from './FilterOption'

export type SourcesFilterOption = {
source: string
source_cnt: number
}

type RsdSourceFilterProps = Readonly<{
sources: string[],
sourcesList: SourcesFilterOption[],
handleQueryChange: (key: string, value: string | string[]) => void
title?: string
}>

export default function RsdSourceFilter({sources, sourcesList,handleQueryChange,title='Host RSD'}: RsdSourceFilterProps) {
const [selected, setSelected] = useState<SourcesFilterOption[]>([])
const [options, setOptions] = useState<SourcesFilterOption[]>(sourcesList)

useEffect(() => {
if (sources.length > 0 && sourcesList.length > 0) {
const selected = sourcesList.filter(option => {
return sources.includes(option.source)
})
setSelected(selected)
} else {
setSelected([])
}
setOptions(sourcesList)
},[sources,sourcesList])

return (
<div>
<FilterTitle
title={title}
count={sourcesList.length ?? ''}
/>
<Autocomplete
className="mt-4"
value={selected}
size="small"
multiple
clearOnEscape
options={options}
getOptionLabel={(option) => option.source}
isOptionEqualToValue={(option, value) => {
return option.source === value.source
}}
defaultValue={[]}
filterSelectedOptions
renderOption={(props, option) => (
<FilterOption
key={option.source}
props={props}
label={option.source}
count={option.source_cnt}
/>
)}
renderInput={(params) => (
<TextField {...params} placeholder={title} />
)}
onChange={(_, newValue) => {
// extract values into string[] for url query
const queryFilter = newValue.map(item => item.source)
// update query url
handleQueryChange('sources', queryFilter)
}}
/>
</div>
)
}
19 changes: 16 additions & 3 deletions frontend/components/software/overview/SoftwareOverviewContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import SoftwareListItemContent from './list/SoftwareListItemContent'
import OverviewListItem from './list/OverviewListItem'
import {getItemKey, getPageUrl} from './useSoftwareOverviewProps'

type SoftwareOverviewContentProps = {
type SoftwareOverviewContentProps = Readonly<{
layout: LayoutType
software: SoftwareOverviewItemProps[]
}
hasRemotes?: boolean
}>

export default function SoftwareOverviewContent({layout, software}: SoftwareOverviewContentProps) {
export default function SoftwareOverviewContent({layout, software, hasRemotes}: SoftwareOverviewContentProps) {

if (!software || software.length === 0) {
return <NoContent />
Expand All @@ -37,6 +38,10 @@ export default function SoftwareOverviewContent({layout, software}: SoftwareOver
<SoftwareOverviewMasonry>
{software.map((item) => {
const cardKey = getItemKey({id:item.id,domain:item.domain})
// remove source if remotes are not present
if (hasRemotes===false && item.source!==null){
item.source = null
}
return (
<div key={cardKey} className="mb-8 break-inside-avoid">
<SoftwareMasonryCard item={item}/>
Expand All @@ -53,6 +58,10 @@ export default function SoftwareOverviewContent({layout, software}: SoftwareOver
{software.map(item => {
const listKey = getItemKey({id:item.id,domain:item.domain})
const pageUrl = getPageUrl({domain:item.domain,slug:item.slug})
// remove source if remotes are not present
if (hasRemotes===false && item.source!==null){
item.source = null
}
return (
<Link
data-testid="software-list-item"
Expand Down Expand Up @@ -88,6 +97,10 @@ export default function SoftwareOverviewContent({layout, software}: SoftwareOver
<SoftwareOverviewGrid>
{software.map((item) => {
const cardKey = getItemKey({id:item.id,domain:item.domain})
// remove source if remotes are not present
if (hasRemotes===false && item.source!==null){
item.source = null
}
return <SoftwareGridCard key={cardKey} {...item}/>
})}
</SoftwareOverviewGrid>
Expand Down
Loading

0 comments on commit 624569c

Please sign in to comment.