diff --git a/database/100-create-api-views.sql b/database/100-create-api-views.sql index 9627cc999..534487ece 100644 --- a/database/100-create-api-views.sql +++ b/database/100-create-api-views.sql @@ -135,26 +135,6 @@ BEGIN END $$; --- programming language counts for software --- used in software filter - language dropdown -CREATE FUNCTION prog_lang_cnt_for_software() RETURNS TABLE ( - prog_lang TEXT, - cnt BIGINT -) LANGUAGE plpgsql STABLE AS -$$ -BEGIN - RETURN QUERY - SELECT - JSONB_OBJECT_KEYS(languages) AS "prog_lang", - COUNT(software) AS cnt - FROM - repository_url - GROUP BY - JSONB_OBJECT_KEYS(languages) - ; -END -$$; - -- programming language filter for software -- used by software_overview func CREATE FUNCTION prog_lang_filter_for_software() RETURNS TABLE ( @@ -177,23 +157,6 @@ BEGIN END $$; --- license counts for software --- used in software filter - license dropdown -CREATE FUNCTION license_cnt_for_software() RETURNS TABLE ( - license VARCHAR, - cnt BIGINT -) LANGUAGE sql STABLE AS -$$ -SELECT - license_for_software.license, - COUNT(license_for_software.license) AS cnt -FROM - license_for_software -GROUP BY - license_for_software.license -; -$$; - -- license filter for software -- used by software_search func CREATE FUNCTION license_filter_for_software() RETURNS TABLE ( @@ -1247,36 +1210,6 @@ BEGIN END $$; --- RESEARCH DOMAIN count used in project filter --- to show used research domains with count -CREATE FUNCTION research_domain_count_for_projects() RETURNS TABLE ( - id UUID, - key VARCHAR, - name VARCHAR, - cnt BIGINT -) LANGUAGE plpgsql STABLE AS -$$ -BEGIN - RETURN QUERY - SELECT - research_domain.id, - research_domain.key, - research_domain.name, - research_domain_count.cnt - FROM - research_domain - LEFT JOIN - (SELECT - research_domain_for_project.research_domain, - COUNT(research_domain_for_project.research_domain) AS cnt - FROM - research_domain_for_project - GROUP BY research_domain_for_project.research_domain - ) AS research_domain_count ON research_domain.id = research_domain_count.research_domain - ; -END -$$; - -- GLOBAL SEARCH -- we use search_text to concatenate all values to use CREATE FUNCTION global_search() RETURNS TABLE( diff --git a/database/105-project-views.sql b/database/105-project-views.sql index 72f32da18..52034d3ba 100644 --- a/database/105-project-views.sql +++ b/database/105-project-views.sql @@ -5,135 +5,6 @@ -- -- SPDX-License-Identifier: Apache-2.0 --- PROJECT OVERVIEW LIST --- WITH KEYWORDS and research domain for filtering -CREATE FUNCTION project_overview() RETURNS TABLE ( - id UUID, - slug VARCHAR, - title VARCHAR, - subtitle VARCHAR, - current_state VARCHAR, - date_start DATE, - updated_at TIMESTAMPTZ, - is_published BOOLEAN, - image_contain BOOLEAN, - image_id VARCHAR, - keywords citext[], - keywords_text TEXT, - research_domain VARCHAR[], - research_domain_text TEXT -) LANGUAGE plpgsql STABLE AS -$$ -BEGIN - RETURN QUERY - SELECT - project.id, - project.slug, - project.title, - project.subtitle, - CASE - WHEN project.date_start IS NULL THEN 'Starting'::VARCHAR - WHEN project.date_start > now() THEN 'Starting'::VARCHAR - WHEN project.date_end < now() THEN 'Finished'::VARCHAR - ELSE 'Running'::VARCHAR - END AS current_state, - project.date_start, - project.updated_at, - project.is_published, - project.image_contain, - project.image_id, - keyword_filter_for_project.keywords, - keyword_filter_for_project.keywords_text, - research_domain_filter_for_project.research_domain, - research_domain_filter_for_project.research_domain_text - FROM - project - LEFT JOIN - keyword_filter_for_project() ON project.id=keyword_filter_for_project.project - LEFT JOIN - research_domain_filter_for_project() ON project.id=research_domain_filter_for_project.project - ; -END -$$; - --- PROJECT OVERVIEW LIST FOR SEARCH --- WITH KEYWORDS and research domain for filtering -CREATE FUNCTION project_search(search VARCHAR) RETURNS TABLE ( - id UUID, - slug VARCHAR, - title VARCHAR, - subtitle VARCHAR, - current_state VARCHAR, - date_start DATE, - updated_at TIMESTAMPTZ, - is_published BOOLEAN, - image_contain BOOLEAN, - image_id VARCHAR, - keywords citext[], - keywords_text TEXT, - research_domain VARCHAR[], - research_domain_text TEXT -) LANGUAGE sql STABLE AS -$$ -SELECT - project.id, - project.slug, - project.title, - project.subtitle, - CASE - WHEN project.date_start IS NULL THEN 'Starting'::VARCHAR - WHEN project.date_start > now() THEN 'Starting'::VARCHAR - WHEN project.date_end < now() THEN 'Finished'::VARCHAR - ELSE 'Running'::VARCHAR - END AS current_state, - project.date_start, - project.updated_at, - project.is_published, - project.image_contain, - project.image_id, - keyword_filter_for_project.keywords, - keyword_filter_for_project.keywords_text, - research_domain_filter_for_project.research_domain, - research_domain_filter_for_project.research_domain_text -FROM - project -LEFT JOIN - keyword_filter_for_project() ON project.id=keyword_filter_for_project.project -LEFT JOIN - research_domain_filter_for_project() ON project.id=research_domain_filter_for_project.project -WHERE - project.title ILIKE CONCAT('%', search, '%') - OR - project.slug ILIKE CONCAT('%', search, '%') - OR - project.subtitle ILIKE CONCAT('%', search, '%') - OR - keyword_filter_for_project.keywords_text ILIKE CONCAT('%', search, '%') - OR - research_domain_filter_for_project.research_domain_text ILIKE CONCAT('%', search, '%') -ORDER BY - CASE - WHEN title ILIKE search THEN 0 - WHEN title ILIKE CONCAT(search, '%') THEN 1 - WHEN title ILIKE CONCAT('%', search, '%') THEN 2 - ELSE 3 - END, - CASE - WHEN slug ILIKE search THEN 0 - WHEN slug ILIKE CONCAT(search, '%') THEN 1 - WHEN slug ILIKE CONCAT('%', search, '%') THEN 2 - ELSE 3 - END, - CASE - WHEN subtitle ILIKE search THEN 0 - WHEN subtitle ILIKE CONCAT(search, '%') THEN 1 - WHEN subtitle ILIKE CONCAT('%', search, '%') THEN 2 - ELSE 3 - END -; -$$; - - CREATE FUNCTION count_project_team_members() RETURNS TABLE ( project UUID, team_member_cnt INTEGER, @@ -389,3 +260,249 @@ INNER JOIN (project.id = project_for_project.origin AND project_for_project.relation = project_id) ; $$; + +-- AGGREGATE participating organisations per project for project_overview RPC +-- use only TOP LEVEL organisations (paren IS NULL) +CREATE FUNCTION project_participating_organisations() RETURNS TABLE ( + project UUID, + organisations VARCHAR[] +) LANGUAGE sql STABLE AS +$$ +SELECT + project_for_organisation.project, + ARRAY_AGG(organisation.name) AS organisations +FROM + organisation +INNER JOIN + project_for_organisation ON organisation.id = project_for_organisation.organisation +WHERE + project_for_organisation.role = 'participating' AND organisation.parent IS NULL +GROUP BY + project_for_organisation.project +; +$$; + + +-- PROJECT OVERVIEW LIST +-- WHEN FILTERING/SEARCH IS NOT USED +CREATE FUNCTION project_overview() RETURNS TABLE ( + id UUID, + slug VARCHAR, + title VARCHAR, + subtitle VARCHAR, + date_start DATE, + date_end DATE, + updated_at TIMESTAMPTZ, + is_published BOOLEAN, + image_contain BOOLEAN, + image_id VARCHAR, + keywords citext[], + keywords_text TEXT, + research_domain VARCHAR[], + research_domain_text TEXT, + participating_organisations VARCHAR[], + impact_cnt INTEGER, + output_cnt INTEGER +) LANGUAGE sql STABLE AS +$$ +SELECT + project.id, + project.slug, + project.title, + project.subtitle, + project.date_start, + project.date_end, + project.updated_at, + project.is_published, + project.image_contain, + project.image_id, + keyword_filter_for_project.keywords, + keyword_filter_for_project.keywords_text, + research_domain_filter_for_project.research_domain, + research_domain_filter_for_project.research_domain_text, + project_participating_organisations.organisations AS participating_organisations, + COALESCE(count_project_impact.impact_cnt, 0) AS impact_cnt, + COALESCE(count_project_output.output_cnt, 0) AS output_cnt +FROM + project +LEFT JOIN + keyword_filter_for_project() ON project.id=keyword_filter_for_project.project +LEFT JOIN + research_domain_filter_for_project() ON project.id=research_domain_filter_for_project.project +LEFT JOIN + project_participating_organisations() ON project.id=project_participating_organisations.project +LEFT JOIN + count_project_impact() ON project.id = count_project_impact.project +LEFT JOIN + count_project_output() ON project.id = count_project_output.project +; +$$; + +-- PROJECT OVERVIEW LIST FOR SEARCH +-- WITH keywords, research domain and participating organisations for filtering +CREATE FUNCTION project_search(search VARCHAR) RETURNS TABLE ( + id UUID, + slug VARCHAR, + title VARCHAR, + subtitle VARCHAR, + date_start DATE, + date_end DATE, + updated_at TIMESTAMPTZ, + is_published BOOLEAN, + image_contain BOOLEAN, + image_id VARCHAR, + keywords citext[], + keywords_text TEXT, + research_domain VARCHAR[], + research_domain_text TEXT, + participating_organisations VARCHAR[], + impact_cnt INTEGER, + output_cnt INTEGER +) LANGUAGE sql STABLE AS +$$ +SELECT + project.id, + project.slug, + project.title, + project.subtitle, + project.date_start, + project.date_end, + project.updated_at, + project.is_published, + project.image_contain, + project.image_id, + keyword_filter_for_project.keywords, + keyword_filter_for_project.keywords_text, + research_domain_filter_for_project.research_domain, + research_domain_filter_for_project.research_domain_text, + project_participating_organisations.organisations AS participating_organisations, + COALESCE(count_project_impact.impact_cnt, 0), + COALESCE(count_project_output.output_cnt, 0) +FROM + project +LEFT JOIN + keyword_filter_for_project() ON project.id=keyword_filter_for_project.project +LEFT JOIN + research_domain_filter_for_project() ON project.id=research_domain_filter_for_project.project +LEFT JOIN + project_participating_organisations() ON project.id=project_participating_organisations.project +LEFT JOIN + count_project_impact() ON project.id = count_project_impact.project +LEFT JOIN + count_project_output() ON project.id = count_project_output.project +WHERE + project.title ILIKE CONCAT('%', search, '%') + OR + project.slug ILIKE CONCAT('%', search, '%') + OR + project.subtitle ILIKE CONCAT('%', search, '%') + OR + keyword_filter_for_project.keywords_text ILIKE CONCAT('%', search, '%') + OR + research_domain_filter_for_project.research_domain_text ILIKE CONCAT('%', search, '%') +ORDER BY + CASE + WHEN title ILIKE search THEN 0 + WHEN title ILIKE CONCAT(search, '%') THEN 1 + WHEN title ILIKE CONCAT('%', search, '%') THEN 2 + ELSE 3 + END, + CASE + WHEN slug ILIKE search THEN 0 + WHEN slug ILIKE CONCAT(search, '%') THEN 1 + WHEN slug ILIKE CONCAT('%', search, '%') THEN 2 + ELSE 3 + END, + CASE + WHEN subtitle ILIKE search THEN 0 + WHEN subtitle ILIKE CONCAT(search, '%') THEN 1 + WHEN subtitle ILIKE CONCAT('%', search, '%') THEN 2 + ELSE 3 + END +; +$$; + +-- REACTIVE KEYWORD FILTER WITH COUNTS FOR PROJECTS +-- PROVIDES AVAILABLE KEYWORDS FOR APPLIED FILTERS +CREATE FUNCTION project_keywords_filter( + search_filter TEXT DEFAULT '', + keyword_filter CITEXT[] DEFAULT '{}', + research_domain_filter VARCHAR[] DEFAULT '{}', + organisation_filter VARCHAR[] DEFAULT '{}' +) RETURNS TABLE ( + keyword CITEXT, + keyword_cnt INTEGER +) LANGUAGE sql STABLE AS +$$ +SELECT + UNNEST(keywords) AS keyword, + COUNT(id) AS keyword_cnt +FROM + project_search(search_filter) +WHERE + COALESCE(keywords, '{}') @> keyword_filter + AND + COALESCE(research_domain, '{}') @> research_domain_filter + AND + COALESCE(participating_organisations, '{}') @> organisation_filter +GROUP BY + keyword +; +$$; + + +-- REACTIVE RESEARCH DOMAIN FILTER WITH COUNTS FOR PROJECTS +-- PROVIDES AVAILABLE DOMAINS FOR APPLIED FILTERS +CREATE FUNCTION project_domains_filter( + search_filter TEXT DEFAULT '', + keyword_filter CITEXT[] DEFAULT '{}', + research_domain_filter VARCHAR[] DEFAULT '{}', + organisation_filter VARCHAR[] DEFAULT '{}' +) RETURNS TABLE ( + domain VARCHAR, + domain_cnt INTEGER +) LANGUAGE sql STABLE AS +$$ +SELECT + UNNEST(research_domain) AS domain, + COUNT(id) AS domain_cnt +FROM + project_search(search_filter) +WHERE + COALESCE(keywords, '{}') @> keyword_filter + AND + COALESCE(research_domain, '{}') @> research_domain_filter + AND + COALESCE(participating_organisations, '{}') @> organisation_filter +GROUP BY + domain +; +$$; + +-- REACTIVE PARTICIPATING ORGANISATIONS FILTER WITH COUNTS FOR PROJECTS +-- PROVIDES AVAILABLE DOMAINS FOR APPLIED FILTERS +CREATE FUNCTION project_participating_organisations_filter( + search_filter TEXT DEFAULT '', + keyword_filter CITEXT[] DEFAULT '{}', + research_domain_filter VARCHAR[] DEFAULT '{}', + organisation_filter VARCHAR[] DEFAULT '{}' +) RETURNS TABLE ( + organisation VARCHAR, + organisation_cnt INTEGER +) LANGUAGE sql STABLE AS +$$ +SELECT + UNNEST(participating_organisations) AS organisation, + COUNT(id) AS organisation_cnt +FROM + project_search(search_filter) +WHERE + COALESCE(keywords, '{}') @> keyword_filter + AND + COALESCE(research_domain, '{}') @> research_domain_filter + AND + COALESCE(participating_organisations, '{}') @> organisation_filter +GROUP BY + organisation +; +$$; \ No newline at end of file diff --git a/frontend/__tests__/ProjectsIndex.test.tsx b/frontend/__tests__/ProjectsIndex.test.tsx deleted file mode 100644 index bf140df60..000000000 --- a/frontend/__tests__/ProjectsIndex.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2022 - 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {render, screen} from '@testing-library/react' - -import {mockResolvedValue} from '../utils/jest/mockFetch' -import {WrappedComponentWithProps} from '../utils/jest/WrappedComponents' - -import projectsOverview from './__mocks__/projectsOverview.json' - -import ProjectsIndexPage, {getServerSideProps} from '../pages/projects/index' -import {RsdUser} from '../auth' - - -describe('pages/projects/index.tsx', () => { - beforeEach(() => { - mockResolvedValue(projectsOverview, { - status: 206, - headers: { - // mock getting Content-Range from the header - get: () => '0-11/200' - }, - statusText: 'OK', - }) - }) - - it('getServerSideProps returns mocked values in the props', async () => { - const resp = await getServerSideProps({req: {cookies: {}}}) - - expect(resp).toEqual({ - props:{ - // count is extracted from response header - count:200, - // default query param values - page:1, - rows:12, - // mocked data - projects: projectsOverview, - search: null, - keywords: null, - domains: [] - } - }) - }) - - it('renders heading with the title Projects', async() => { - render(WrappedComponentWithProps( - ProjectsIndexPage, { - props: { - count:200, - page:0, - rows:12, - projects:projectsOverview, - }, - // user session - session:{ - status: 'missing', - token: 'test-token', - user: {name:'Test user'} as RsdUser - } - } - )) - const heading = await screen.findByRole('heading',{ - name: 'Projects' - }) - expect(heading).toBeInTheDocument() - }) - - it('renders project as card (based on title)', async() => { - render(WrappedComponentWithProps( - ProjectsIndexPage, { - props: { - count:3, - page:0, - rows:12, - projects:projectsOverview, - }, - // user session - session:{ - status: 'missing', - token: 'test-token', - user: {name:'Test user'} as RsdUser - } - } - )) - - const title = projectsOverview[0].title - const card = await screen.findByText(title) - expect(card).toBeInTheDocument() - }) -}) diff --git a/frontend/__tests__/ProjectsOverviewPage.test.tsx b/frontend/__tests__/ProjectsOverviewPage.test.tsx new file mode 100644 index 000000000..0ee61e57b --- /dev/null +++ b/frontend/__tests__/ProjectsOverviewPage.test.tsx @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {render, screen, within} from '@testing-library/react' +import {WithAppContext} from '~/utils/jest/WithAppContext' + +import ProjectOverviewPage from '../pages/projects/index' +import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup' + +import mockData from './__mocks__/projectsOverview.json' + +const mockProps = { + search: null, + order: null, + keywords: null, + domains: null, + organisations: null, + page: 1, + rows: 12, + count: 408, + layout: 'masonry' as LayoutType, + keywordsList: mockData.keywordsList, + domainsList: mockData.domainsList, + organisationsList: mockData.organisationsList, + projects: mockData.projects as any +} + + +describe('pages/projects/index.tsx', () => { + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders title All projects', () => { + render( + + + + ) + const heading = screen.getByRole('heading',{ + name: 'All projects' + }) + expect(heading).toBeInTheDocument() + }) + + it('renders project filter panel with orderBy and 3 filters (combobox)', () => { + render( + + + + ) + // get reference to filter panel + const panel = screen.getByTestId('filters-panel') + // find order by testid + const order = within(panel).getByTestId('filters-order-by') + // should have 3 filters + const filters = within(panel).getAllByRole('combobox') + expect(filters.length).toEqual(3) + // screen.debug(filters) + }) + + it('renders searchbox with placeholder Find project', async () => { + render( + + + + ) + screen.getByPlaceholderText('Find project') + }) + + it('renders layout options (toggle button group)', async () => { + mockProps.layout='masonry' + render( + + + + ) + screen.getByTestId('card-layout-options') + }) + + it('renders (12) grid cards (even for masonry layout type)', async () => { + mockProps.layout='masonry' + render( + + + + ) + const cards = screen.getAllByTestId('project-grid-card') + expect(cards.length).toEqual(mockProps.projects.length) + }) + + it('renders (12) list items', async () => { + mockProps.layout='list' + render( + + + + ) + const cards = screen.getAllByTestId('project-list-item') + expect(cards.length).toEqual(mockProps.projects.length) + }) + +}) diff --git a/frontend/__tests__/SoftwareOverview.test.tsx b/frontend/__tests__/SoftwareOverview.test.tsx index efd1a9204..a5e8f11a6 100644 --- a/frontend/__tests__/SoftwareOverview.test.tsx +++ b/frontend/__tests__/SoftwareOverview.test.tsx @@ -7,10 +7,11 @@ import {render, screen, within} from '@testing-library/react' import {WithAppContext} from '~/utils/jest/WithAppContext' import SoftwareOverviewPage from '../pages/software/index' -import {LayoutType} from '~/components/software/overview/SearchSection' +import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup' // mocked data & props import mockData from './__mocks__/softwareOverviewData.json' + const mockProps = { search:null, keywords:null, @@ -62,7 +63,7 @@ describe('pages/software/index.tsx', () => { ) // get reference to filter panel - const panel = screen.getByTestId('software-filters-panel') + const panel = screen.getByTestId('filters-panel') // find order by testid const order = within(panel).getByTestId('filters-order-by') // should have 3 filters diff --git a/frontend/__tests__/__mocks__/projectsOverview.json b/frontend/__tests__/__mocks__/projectsOverview.json index 9dd3718a3..525aba85d 100644 --- a/frontend/__tests__/__mocks__/projectsOverview.json +++ b/frontend/__tests__/__mocks__/projectsOverview.json @@ -1,5 +1,1951 @@ -[ - { +{ + "keywordsList": [ + { + "keyword": "Big data", + "keyword_cnt": 46 + }, + { + "keyword": "GPU", + "keyword_cnt": 33 + }, + { + "keyword": "High performance computing", + "keyword_cnt": 23 + }, + { + "keyword": "Image processing", + "keyword_cnt": 33 + }, + { + "keyword": "Inter-operability & linked data", + "keyword_cnt": 36 + }, + { + "keyword": "Machine learning", + "keyword_cnt": 17 + }, + { + "keyword": "Multi-scale & multi model simulations", + "keyword_cnt": 32 + }, + { + "keyword": "Optimized data handling", + "keyword_cnt": 38 + }, + { + "keyword": "Real time data analysis", + "keyword_cnt": 37 + }, + { + "keyword": "Text analysis & natural language processing", + "keyword_cnt": 33 + }, + { + "keyword": "Visualization", + "keyword_cnt": 38 + }, + { + "keyword": "Workflow technologies", + "keyword_cnt": 31 + } + ], + "domainsList":[ + { + "key": "LS1_1", + "domain": "LS1_1: Macromolecular complexes including interactions involving nucleic acids, proteins, lipids and carbohydrates", + "domain_cnt": 2 + }, + { + "key": "LS1_13", + "domain": "LS1_13: Early translational research and drug design", + "domain_cnt": 2 + }, + { + "key": "LS1_14", + "domain": "LS1_14: Innovative methods and modelling in molecular, structural and synthetic biology", + "domain_cnt": 3 + }, + { + "key": "LS1_5", + "domain": "LS1_5: Lipid biology", + "domain_cnt": 3 + }, + { + "key": "LS1_9", + "domain": "LS1_9: Molecular mechanisms of signalling processes", + "domain_cnt": 6 + }, + { + "key": "LS2_12", + "domain": "LS2_12: Biostatistics", + "domain_cnt": 2 + }, + { + "key": "LS2_4", + "domain": "LS2_4: Gene regulation", + "domain_cnt": 1 + }, + { + "key": "LS2_8", + "domain": "LS2_8: Proteomics", + "domain_cnt": 3 + }, + { + "key": "LS3_1", + "domain": "LS3_1: Cell cycle, cell division and growth", + "domain_cnt": 2 + }, + { + "key": "LS3_14", + "domain": "LS3_14: Regeneration", + "domain_cnt": 4 + }, + { + "key": "LS3_5", + "domain": "LS3_5: Cell signalling and signal transduction, exosome biology", + "domain_cnt": 1 + }, + { + "key": "LS3_9", + "domain": "LS3_9: Cell differentiation, formation of tissues and organs", + "domain_cnt": 3 + }, + { + "key": "LS4", + "domain": "LS4: Physiology in Health, Disease and Ageing", + "domain_cnt": 5 + }, + { + "key": "LS4_1", + "domain": "LS4_1: Organ and tissue physiology and pathophysiology", + "domain_cnt": 3 + }, + { + "key": "LS4_13", + "domain": "LS4_13: Other non-communicable diseases (except disorders of the nervous system and immunity-related diseases)", + "domain_cnt": 1 + }, + { + "key": "LS4_5", + "domain": "LS4_5: Non-hormonal mechanisms of inter-organ and tissue communication", + "domain_cnt": 9 + }, + { + "key": "LS4_9", + "domain": "LS4_9: Metabolism and metabolic disorders, including diabetes and obesity", + "domain_cnt": 4 + }, + { + "key": "LS5_13", + "domain": "LS5_13: Nervous system injuries and trauma, stroke", + "domain_cnt": 1 + }, + { + "key": "LS5_14", + "domain": "LS5_14: Repair and regeneration of the nervous system", + "domain_cnt": 1 + }, + { + "key": "LS5_17", + "domain": "LS5_17: Imaging in neuroscience", + "domain_cnt": 1 + }, + { + "key": "LS5_4", + "domain": "LS5_4: Neural stem cells", + "domain_cnt": 1 + }, + { + "key": "LS5_5", + "domain": "LS5_5: Neural networks and plasticity", + "domain_cnt": 1 + }, + { + "key": "LS5_9", + "domain": "LS5_9: Neural basis of cognition", + "domain_cnt": 4 + }, + { + "key": "LS6_4", + "domain": "LS6_4: Immune-related diseases", + "domain_cnt": 4 + }, + { + "key": "LS6_8", + "domain": "LS6_8: Biological basis of prevention and treatment of infection", + "domain_cnt": 2 + }, + { + "key": "LS6_9", + "domain": "LS6_9: Antimicrobials, antimicrobial resistance", + "domain_cnt": 3 + }, + { + "key": "LS7_1", + "domain": "LS7_1: Medical imaging for prevention, diagnosis and monitoring of diseases", + "domain_cnt": 2 + }, + { + "key": "LS7_10", + "domain": "LS7_10: Preventative and prognostic medicine", + "domain_cnt": 3 + }, + { + "key": "LS7_15", + "domain": "LS7_15: Medical ethics", + "domain_cnt": 3 + }, + { + "key": "LS7_5", + "domain": "LS7_5: Applied gene, cell and immune therapies", + "domain_cnt": 1 + }, + { + "key": "LS7_6", + "domain": "LS7_6: Other medical therapeutic interventions, including transplantation", + "domain_cnt": 3 + }, + { + "key": "LS8", + "domain": "LS8: Environmental Biology, Ecology and Evolution", + "domain_cnt": 3 + }, + { + "key": "LS8_13", + "domain": "LS8_13: Marine biology and ecology", + "domain_cnt": 3 + }, + { + "key": "LS8_3", + "domain": "LS8_3: Conservation biology", + "domain_cnt": 1 + }, + { + "key": "LS8_4", + "domain": "LS8_4: Population biology, population dynamics, population genetics", + "domain_cnt": 3 + }, + { + "key": "LS8_8", + "domain": "LS8_8: Phylogenetics, systematics, comparative biology", + "domain_cnt": 3 + }, + { + "key": "LS9_1", + "domain": "LS9_1: Bioengineering for synthetic and chemical biology", + "domain_cnt": 1 + }, + { + "key": "LS9_5", + "domain": "LS9_5: Food biotechnology and bioengineering", + "domain_cnt": 3 + }, + { + "key": "LS9_6", + "domain": "LS9_6: Marine biotechnology and bioengineering", + "domain_cnt": 1 + }, + { + "key": "LS9_9", + "domain": "LS9_9: Plant pathology and pest resistance", + "domain_cnt": 2 + }, + { + "key": "PE", + "domain": "PE: Physical Sciences and Engineering", + "domain_cnt": 5 + }, + { + "key": "PE10", + "domain": "PE10: Earth System Science", + "domain_cnt": 2 + }, + { + "key": "PE10_1", + "domain": "PE10_1: Atmospheric chemistry, atmospheric composition, air pollution", + "domain_cnt": 4 + }, + { + "key": "PE10_10", + "domain": "PE10_10: Mineralogy, petrology, igneous petrology, metamorphic petrology", + "domain_cnt": 2 + }, + { + "key": "PE10_14", + "domain": "PE10_14: Earth observations from space/remote sensing", + "domain_cnt": 2 + }, + { + "key": "PE10_18", + "domain": "PE10_18: Cryosphere, dynamics of snow and ice cover, sea ice, permafrosts and ice sheets", + "domain_cnt": 2 + }, + { + "key": "PE10_19", + "domain": "PE10_19: Planetary geology and geophysics", + "domain_cnt": 4 + }, + { + "key": "PE10_5", + "domain": "PE10_5: Geology, tectonics, volcanology", + "domain_cnt": 1 + }, + { + "key": "PE10_6", + "domain": "PE10_6: Palaeoclimatology, palaeoecology", + "domain_cnt": 3 + }, + { + "key": "PE11_10", + "domain": "PE11_10: Soft materials engineering, e.g. gels, foams, colloids", + "domain_cnt": 8 + }, + { + "key": "PE11_14", + "domain": "PE11_14: Computational methods for materials engineering", + "domain_cnt": 2 + }, + { + "key": "PE11_2", + "domain": "PE11_2: Engineering of metals and alloys", + "domain_cnt": 1 + }, + { + "key": "PE1_13", + "domain": "PE1_13: Probability", + "domain_cnt": 5 + }, + { + "key": "PE11_6", + "domain": "PE11_6: Engineering of carbon materials", + "domain_cnt": 1 + }, + { + "key": "PE1_18", + "domain": "PE1_18: Numerical analysis", + "domain_cnt": 6 + }, + { + "key": "PE1_22", + "domain": "PE1_22: Application of mathematics in industry and society", + "domain_cnt": 8 + }, + { + "key": "PE1_5", + "domain": "PE1_5: Lie groups, Lie algebras", + "domain_cnt": 4 + }, + { + "key": "PE1_9", + "domain": "PE1_9: Operator algebras and functional analysis", + "domain_cnt": 1 + }, + { + "key": "PE2", + "domain": "PE2: Fundamental Constituents of Matter", + "domain_cnt": 6 + }, + { + "key": "PE2_13", + "domain": "PE2_13: Quantum optics and quantum information", + "domain_cnt": 3 + }, + { + "key": "PE2_17", + "domain": "PE2_17: Metrology and measurement", + "domain_cnt": 6 + }, + { + "key": "PE2_4", + "domain": "PE2_4: Experimental particle physics without accelerators", + "domain_cnt": 3 + }, + { + "key": "PE2_8", + "domain": "PE2_8: Gas and plasma physics", + "domain_cnt": 1 + }, + { + "key": "PE2_9", + "domain": "PE2_9: Electromagnetism", + "domain_cnt": 3 + }, + { + "key": "PE3_12", + "domain": "PE3_12: Molecular electronics", + "domain_cnt": 5 + }, + { + "key": "PE3_3", + "domain": "PE3_3: Transport properties of condensed matter", + "domain_cnt": 2 + }, + { + "key": "PE3_8", + "domain": "PE3_8: Magnetism and strongly correlated systems", + "domain_cnt": 2 + }, + { + "key": "PE4_13", + "domain": "PE4_13: Theoretical and computational chemistry", + "domain_cnt": 5 + }, + { + "key": "PE4_17", + "domain": "PE4_17: Characterisation methods of materials", + "domain_cnt": 4 + }, + { + "key": "PE4_5", + "domain": "PE4_5: Analytical chemistry", + "domain_cnt": 3 + }, + { + "key": "PE4_9", + "domain": "PE4_9: Method development in chemistry", + "domain_cnt": 5 + }, + { + "key": "PE5_12", + "domain": "PE5_12: Chemistry of condensed matter", + "domain_cnt": 2 + }, + { + "key": "PE5_17", + "domain": "PE5_17: Organic chemistry", + "domain_cnt": 3 + }, + { + "key": "PE5_4", + "domain": "PE5_4: Thin films", + "domain_cnt": 3 + }, + { + "key": "PE5_8", + "domain": "PE5_8: Intelligent materials synthesis – self assembled materials", + "domain_cnt": 3 + }, + { + "key": "PE6", + "domain": "PE6: Computer Science and Informatics", + "domain_cnt": 6 + }, + { + "key": "PE6_11", + "domain": "PE6_11: Machine learning, statistical data processing and applications using signal processing ( e.g. speech,image,video)", + "domain_cnt": 5 + }, + { + "key": "PE6_3", + "domain": "PE6_3: Software engineering, programming languages and systems", + "domain_cnt": 3 + }, + { + "key": "PE6_7", + "domain": "PE6_7: Artificial intelligence, intelligent systems, natural language processing", + "domain_cnt": 5 + }, + { + "key": "PE7_1", + "domain": "PE7_1: Control engineering", + "domain_cnt": 2 + }, + { + "key": "PE7_10", + "domain": "PE7_10: Robotics", + "domain_cnt": 7 + }, + { + "key": "PE7_2", + "domain": "PE7_2: Electrical engineering: power components and/or systems", + "domain_cnt": 4 + }, + { + "key": "PE7_6", + "domain": "PE7_6: Communication systems, wireless technology, high-frequency technology", + "domain_cnt": 4 + }, + { + "key": "PE8_11", + "domain": "PE8_11: Environmental engineering, e.g. sustainable design, waste and water treatment, recycling, regeneration or recovery of compounds, carbon capture & storage", + "domain_cnt": 8 + }, + { + "key": "PE8_2", + "domain": "PE8_2: Chemical engineering, technical chemistry", + "domain_cnt": 3 + }, + { + "key": "PE8_3", + "domain": "PE8_3: Civil engineering, architecture, offshore construction, lightweight construction, geotechnics", + "domain_cnt": 2 + }, + { + "key": "PE8_7", + "domain": "PE8_7: Mechanical engineering", + "domain_cnt": 4 + }, + { + "key": "PE9_1", + "domain": "PE9_1: Solar physics – the Sun and the heliosphere", + "domain_cnt": 2 + }, + { + "key": "PE9_10", + "domain": "PE9_10: Relativistic astrophysics and compact objects", + "domain_cnt": 2 + }, + { + "key": "PE9_2", + "domain": "PE9_2: Solar system science", + "domain_cnt": 3 + }, + { + "key": "PE9_5", + "domain": "PE9_5: Interstellar medium and star formation", + "domain_cnt": 1 + }, + { + "key": "PE9_6", + "domain": "PE9_6: Stars – stellar physics, stellar systems", + "domain_cnt": 4 + }, + { + "key": "SH1_1", + "domain": "SH1_1: Macroeconomics; monetary economics; economic growth", + "domain_cnt": 3 + }, + { + "key": "SH1_10", + "domain": "SH1_10: Management; strategy; organisational behaviour", + "domain_cnt": 4 + }, + { + "key": "SH1_14", + "domain": "SH1_14: Health economics; economics of education", + "domain_cnt": 1 + }, + { + "key": "SH1_15", + "domain": "SH1_15: Public economics; political economics; law and economics", + "domain_cnt": 2 + }, + { + "key": "SH1_2", + "domain": "SH1_2: International trade; international management; international business; spatial economics", + "domain_cnt": 3 + }, + { + "key": "SH1_5", + "domain": "SH1_5: Corporate finance; banking and financial intermediation; accounting; auditing; insurance", + "domain_cnt": 2 + }, + { + "key": "SH1_6", + "domain": "SH1_6: Econometrics; operations research", + "domain_cnt": 2 + }, + { + "key": "SH2_2", + "domain": "SH2_2: Democratisation and social movements", + "domain_cnt": 2 + }, + { + "key": "SH2_3", + "domain": "SH2_3: Conflict resolution, war, peace building, international law", + "domain_cnt": 3 + }, + { + "key": "SH2_6", + "domain": "SH2_6: Humanitarian assistance and development", + "domain_cnt": 1 + }, + { + "key": "SH2_7", + "domain": "SH2_7: Political and legal philosophy", + "domain_cnt": 5 + }, + { + "key": "SH3", + "domain": "SH3: The Social World and Its Diversity", + "domain_cnt": 1 + }, + { + "key": "SH3_11", + "domain": "SH3_11: Social aspects of teaching and learning, curriculum studies, education and educational policies", + "domain_cnt": 2 + }, + { + "key": "SH3_12", + "domain": "SH3_12: Communication and information, networks, media", + "domain_cnt": 4 + }, + { + "key": "SH3_3", + "domain": "SH3_3: Aggression and violence, antisocial behaviour, crime", + "domain_cnt": 7 + }, + { + "key": "SH3_7", + "domain": "SH3_7: Kinship; diversity and identities, gender, interethnic relations", + "domain_cnt": 1 + }, + { + "key": "SH3_8", + "domain": "SH3_8: Social policies, welfare, work and employment", + "domain_cnt": 3 + }, + { + "key": "SH4_11", + "domain": "SH4_11: Pragmatics, sociolinguistics, linguistic anthropology, discourse analysis", + "domain_cnt": 1 + }, + { + "key": "SH4_12", + "domain": "SH4_12: Philosophy of mind, philosophy of language", + "domain_cnt": 1 + }, + { + "key": "SH4_3", + "domain": "SH4_3: Clinical and health psychology", + "domain_cnt": 2 + }, + { + "key": "SH4_7", + "domain": "SH4_7: Reasoning, decision-making; intelligence", + "domain_cnt": 1 + }, + { + "key": "SH4_8", + "domain": "SH4_8: Language learning and processing (first and second languages)", + "domain_cnt": 4 + }, + { + "key": "SH5_12", + "domain": "SH5_12: Computational modelling and digitisation in the cultural sphere", + "domain_cnt": 3 + }, + { + "key": "SH5_3", + "domain": "SH5_3: Philology; text and image studies", + "domain_cnt": 3 + }, + { + "key": "SH5_7", + "domain": "SH5_7: Museums, exhibitions, conservation and restoration", + "domain_cnt": 4 + }, + { + "key": "SH6_12", + "domain": "SH6_12: Social and economic history", + "domain_cnt": 1 + }, + { + "key": "SH6_13", + "domain": "SH6_13: Gender history, cultural history, history of collective identities and memories, history of religions", + "domain_cnt": 1 + }, + { + "key": "SH6_4", + "domain": "SH6_4: Prehistory, palaeoanthropology, palaeodemography, protohistory, bioarchaeology", + "domain_cnt": 1 + }, + { + "key": "SH6_7", + "domain": "SH6_7: Medieval history", + "domain_cnt": 2 + }, + { + "key": "SH6_8", + "domain": "SH6_8: Early modern history", + "domain_cnt": 2 + }, + { + "key": "SH7", + "domain": "SH7: Human Mobility, Environment, and Space", + "domain_cnt": 1 + }, + { + "key": "SH7_1", + "domain": "SH7_1: Human, economic and social geography", + "domain_cnt": 2 + }, + { + "key": "SH7_10", + "domain": "SH7_10: GIS, spatial analysis; big data in geographical studies", + "domain_cnt": 1 + }, + { + "key": "SH7_2", + "domain": "SH7_2: Migration", + "domain_cnt": 3 + }, + { + "key": "SH7_5", + "domain": "SH7_5: Sustainability sciences, environment and resources", + "domain_cnt": 2 + }, + { + "key": "SH7_6", + "domain": "SH7_6: Environmental and climate change, societal impact and policy", + "domain_cnt": 4 + } + ], + "organisationsList":[ + { + "organisation": "Organisation: 1080p Berkshire Card Hermaphrodite Oriental fooey Pataca panel an tectonics Europium Tonga East parse", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: 1080p coulomb", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: above Coupe Pants Mini Southeast Producer Transmasculine Rhode Illinois matrix Garden Senior through SAS facilitate Computer Uruguayo Assurance silver Latin interactive heartfelt redunda", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Account Direct misfire vitae", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Advanced Indiana wassail Jewelery", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Agender programming array reinvent", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Alhambra", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Alhambra Frozen Group invoice Electronics Aston after", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: alias CSS female", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: among Home protocol", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Analyst Incredible Naira Wisconsin Jaguar African Integration Chips CLI Refined South Valleys", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Analyst optio azure", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: applications", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: application Screen female Pizza", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Arab", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Architect below Tantalum", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Arianna Greg bypass", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Arizona welcome", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: array deposit Route payment Checking Audi Reading Bicycle Plastic Louisiana Bedfordshire Lodge Som derivative Electronic male Pizza Southeast Franc spin target hard", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Assistant", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: back finally", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Bacon vitro orange male", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Bedfordshire Regional Nebraska", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Bedfordshire SUV", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Bentley Synergistic asynchronous firsthand CLI pink chapter female ew Interactions green Enhanced Woman ampere quantifying Hybrid Montenegro quantifying amet Regional researches male IP ", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Berkshire South lumen Borders interface Northeast Rustic an synthesizing South Van", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Bicycle", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: Bicycle female", + "organisation_cnt": 6 + }, + { + "organisation": "Organisation: Bicycle Hybrid Hyundai", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: Bike Cyclocross psst", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Bike Diesel North", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: blockchains", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: bluetooth transmit West World shoehorn Liaison embrace North Quality about encoding ew virtual vero copyright", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Borders Bugatti convergence", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Brooks", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Buckinghamshire Bedfordshire SUV drive tesla Omnigender unwieldy Visalia Falls International wireless Wooden Pound turquoise Dubnium Trans compress Hat Rubber navigate female workforce r", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Buckinghamshire Bugatti", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Buckinghamshire Macedonia Computer Villages cumque index North Reichert becquerel Jeep plus Marketing decompress female Arvada until global Customer Borders Account Sports hybrid Caesium", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: but Northwest", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: bypassing Persevering dynamic", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: calculate hmph or", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: calculating", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Cambridgeshire Hybrid Hat Granite", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Cameroon Strategist", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Canada Buckinghamshire model Branding equally Northwest standardization navigating now female Taylorsville East whose Checking generating after tan generate Designer turquoise while Luxu", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Car bypassing maroon capacity engage California Towels Bentley driver Northwest Erbium Hybrid Handcrafted Transexual THX programming West Direct Peso Birr ability female meter hertz matr", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Checking North fragrant disintermediate", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Chips invoice Mauritius", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Chromium Factors unlawful", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: circuit", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Cis Rock", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: City olive programming Shoes unnaturally Berkshire Honda Industrial Electronics Pickup multimedia ashtray connecting East plum Will Belize Omer Bulgaria", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Classical", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: cohere Uganda Van payment", + "organisation_cnt": 6 + }, + { + "organisation": "Organisation: compliance", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: content", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: contingency each", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Coordinator ratione port", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: copy revolutionary teal East", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Corporate", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Cotton", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Cotton bifurcated Cuyahoga", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Cotton Northeast Electronic Oman", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: coulomb Territories", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Customer azure Wooden secondary", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: customer Consultant likewise", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Customer Profound meter Latin", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: cyan Exclusive Incredible", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Cyprus Danny Terbium Internal", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: dearly erosion Cuban deposit", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: degree", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: deleniti lumen", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: deliberately", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: deposit black afterwards Coupe barring Northwest", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: deposit Ohio", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: deselect Surinam", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Developer", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Diesel ew", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Diesel meter", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Diesel partnerships Oregon", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: dignissimos abbreviate", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: disappointment", + "organisation_cnt": 7 + }, + { + "organisation": "Organisation: driver Nauru", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: dual", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: East Netherlands emery Northwest Investor without Officer", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: efficient Savings Franc interchange Frozen Forward Dollar connecting save Luxurious Granite Chevrolet monitor Auto back Wall coulomb", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Electric", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Electric Southeast steradian interface Branding Wall software transmit North though Loan Astatine mortally how primary TCP cohesive Crew Guarani purple Meitnerium Solutions", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Elegant change racer Movies", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Elkhart withdrawal Cobalt", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: embrace Adventure Polarised National", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: emotional Rancho", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: empathise Indiana", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: encryption male within Ferrari", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Engineer Kroon psst Regional", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: equally", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Ergonomic intranet bypass Liaison", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: excepting generation Dale", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: excepturi Direct Buffalo Georgia Hatchback parsing silver Bedfordshire against bobble Bronze", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Extended hmph Handmade Bermuda Plastic Associate Tools Rial soundness pascal invoice Account maroon Hybrid vastly Road Account withdrawal Mobility as Home Chief Chief matrix", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: eyeballs Hybrid", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Facilitator Elegant", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Facilitator Market massive Alabama framework county Non payment transmitting implementation District Director scalable qua Personal SSL afore Funk Nevada Rubber Applications", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: feed Licensed perspiciatis Metal", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: female Data hacking", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: female models fuchsia", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: finally except input interface", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Franc", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Franc wireless Digitized", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Fresh", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Frozen Concrete Minivan", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: furiously methodology Kids Cab Berkshire", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: furthermore Hawaii Soul Man", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Garden capability", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Gasoline Pataca", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: generate Salad", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Gloves", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Gloves panel Shoes Refined", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Gorgeous North", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Granite broadly", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Greece", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: green", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Group Auto Fresh Mouse Van deposit totam integrated Consultant Kameron Jeep orchid Home Bicycle Car York panel Toys maroon BMW Crew Southeast Books", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Gustave Fish dynamic", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Hampshire Transexual forenenst", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Hanford SSD", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: harness Sawayn", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: HDD", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: hertz Greenland", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: hertz Rhode Cary", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: hmph", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: homogeneous", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Hop Arizona", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: hub", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: huzzah", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Hybrid World global", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Iceland Platinum oof haptic", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: if Future analyzing Northwest", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Illinois unless payment", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: illum Bicycle", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Implementation yippee Volkswagen Synergized", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Industrial", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: inside", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: instead weber Account rudely Northeast Audi orange Nepalese Iran Chair Directives invoice female sensitize Loan green male Carolina while", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Intelligent Bicycle Soul ah", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Interactions redundant up", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: interface dame Wagon duh Selenium Cargo tan whack North deposit", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: interface Smart", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: intranet", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Investment", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: invoice Tracy revolutionize", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: IP bypassing outside", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Israel cappelletti feed", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: ivory unless East Falls Tala Northwest Prospect transitional gray Supervisor Trial stranger ha Oriental Bicycle Sports Guinea dinosaur aside male Wagon generating indeed", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: jolly pace male Global", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: katal Configuration ick shrilly", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: katal Pop brown French Northwest synthesize olive Walk Wooden crushing matrix Plastic Gorgeous Accountability Alison Delaware male Phased stopwatch Southwest Idaho Technician foreground", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Keyboard microchip", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Kiarra Vista B2B optical", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: kilogram", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: kilogram Borders", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Kina Van connect Bronze", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Kong Cambridgeshire Iridium", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Lamborghini", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Lead whose", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Legacy Concrete", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Lehner gracious male Steel", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: lest katal Credit", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Liberian Road", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: limp Market lime Northwest whimper", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Loan Franc ick", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Loan Male Northwest male Frozen", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Lonnie parse", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Louisiana Burundi gee seize Southwest Convertible Hybrid quos insignificant sensor Oregon HEX Unbranded analyzing Diesel", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: magenta Delaware Rubidium Bicycle", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: male Country Multigender Cambridgeshire", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: male henry", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Man Modesto", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Marketing yahoo female newton pace Barbados Loan withdrawal female whose violet mellow elementary multimedia Loan Sedan Latin like Market lobster architecto Bronze watt", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: maroon auxiliary TCP put Checking National backing deeply male divalent Steel unrealistic East fearless Northwest Cotton", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: mealy violet Clothing", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: methodologies Plastic", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Metical systemic", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Metrics", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Michigan silver digital circuit Salvador Virginia Northeast orchid hertz Customer Cotton Technician Account Transexual waffle Shirt blue hertz Grocery El until Towels Functionality M2F", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: microchip Associate Tuna incomparable Fitness betoken capacitor Diesel Bicycle Wooden Tugrik Games boo reiciendis Strategist man intensely er solid laborum blue unleash technologies pro ", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Minivan", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Modern Kids nor Movies North deposit Cambridgeshire Shoes", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Money", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: morph Coordinator", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: motivating Developer Supervisor save onto aw kelvin SDD card Praseodymium salmon seamless Southwest Perris Non psst Integration anenst orchid inwardly Hybrid Terbium ew Tesla bypass maro", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: MTF optical", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: National Account North Loan", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: navigating East Regional outrageous", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: networks vortals iterate", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: New Jazz", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Ngultrum Park withdrawal volt", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Nobelium approach Southwest North Investor outside excluding Bike Ergonomic programming quantifying", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Non Tricycle yellow Product Sheboygan Lamborghini Borders Orchestrator Unbranded divine enhance Dynamic Cambridgeshire South Nya male primary Engineer card smoothly synthesize networks S", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Northeast Automotive salmon pricing Brooks Cisgender Bronze yellow Tricycle Guernsey meanwhile incidentally generating customized Circle Touring Funk", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Northeast South", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Northwest Alabama Recumbent", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Northwest Buckinghamshire Haute restrict", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Northwest till analyzing Sedan rich Balboa notwithstanding bluetooth Small Indiana Alhambra male Celsius Fish East orange quantifying Cambridgeshire", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: offensively hierarchy olive", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Officer East", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: ohm Cambridgeshire Distributed", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: olive Minivan", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: orange steradian", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Oriental camper VGA", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: out", + "organisation_cnt": 8 + }, + { + "organisation": "Organisation: parse", + "organisation_cnt": 7 + }, + { + "organisation": "Organisation: Passenger", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: past likewise Outdoors Dong drive male unnecessarily invoice Sausages Granite Account protocol separate Loan dignissimos Guatemala transmitting payment pish Awesome Serbia save Response ", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: past Research Borders Specialist", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: payment East Berkshire", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: payment geez maroon katal Producer Taiwan quasi Tennessee disintermediate reprehenderit weber Sleek port West Supervisor phew Usability generation Idaho Honda East female Chair Dynamic M", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: payment Granite East Francisca Argon Group extend Audi Orchestrator Electric Gender whereas Idaho navigating World Litas Keys than Vanadium", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: payment Home male", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: PCI Sausages", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Personal hack", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: Peso Crooks reluctantly", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: pixel communities Sodium", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Plastic", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: PNG Northeast Hyundai Folding", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Polonium", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Porsche", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Ports trunk Frozen", + "organisation_cnt": 6 + }, + { + "organisation": "Organisation: Praseodymium", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: prime Accounts", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Producer", + "organisation_cnt": 7 + }, + { + "organisation": "Organisation: productize Investor Fresh unto", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: productize Wagon Granite Ferrari", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: program Frozen Director Lebsack psst West Bedfordshire connecting thorny Fresh payment Steel Recumbent RAM revolutionize Bedfordshire Zloty happily deliverables Chevrolet asynchronous Mi", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: programming", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: programming Francisco Account Markets", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: provision Modern", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: purple", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: purple Pickup Avon Kuna", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: purple Rand SUV", + "organisation_cnt": 7 + }, + { + "organisation": "Organisation: purple Woman", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: quantify", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: quisquam ivory", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Rap Hat this", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Rap Kwacha Interactions Handcrafted", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Reactive", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Reading incubate unibody SUV Italy female Devolved Borders North purple female Plastic Southeast website Neodymium Spencer Legacy Steel plum orange New woot Northwest firewall nervously ", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Recumbent", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Refined Ohio Dram female", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Reggae", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Regional Missouri", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Republic Global", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Rhenium Wisconsin Hybrid", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Rolls Research back payment Southeast rich Rubber as silver Intelligent boohoo storm male lime ohm Account volt furthermore payment Granite Metal male Solutions Sports", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Rover deposit which Ryan Technician Account invoice thrill provided woman Identity scale Chief Practical Soft Livermore Tricycle Manager Plastic indexing", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Rubber sprawl", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Saint quantify", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: salmon scalable Cambridgeshire East", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: sample coat pfft South", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: SAS aspernatur", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: second Southwest", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Sedan", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Sedan payment Sleek Integration", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Sedan yellow fluid Account", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: sharply", + "organisation_cnt": 6 + }, + { + "organisation": "Organisation: Shirt Diesel Dubnium Salad", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Shoes Legacy Games", + "organisation_cnt": 9 + }, + { + "organisation": "Organisation: Shoes Tandem", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Silicon alongside Frozen Sedan lumberman fuga Parma Cotton Ergonomic invoice calculate East drive male azure Orchestrator Shoes Unbranded Principal Oganesson Wooden Product Baby sed once", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: silver even", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Small", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Soap", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: socialism technologies sint Internal kelvin orange Diesel unabashedly Cruiser Future deign Manat Dynamic unto Niobium Metal signify Identity Kia Club Cambridgeshire", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Soft", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Soft hollow phooey auxiliary", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: South teal input to", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: Southwest copy besides", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: SQL zowie", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: SSL", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: steradian welcome haptic", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Supervisor intuitive adapter Money", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: system Dominica Concrete", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Table South meter", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Tasty tan", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: technologies microchip Salad matrix expedite teal Folk Managed Bicycle Ruthenium utilize Cadmium odit Dynamic project invoice Tesla Pop sparse paradigm yahoo Helena Neodymium system paym", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: throughput Hybrid South Haven", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Togo envisioneer Cotton Latin female transmitter Northwest up deposit Sleek Mauritius second Republic utilize Mississippi Transmasculine Hat Avon fast Account whoa Cotton yellow", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: tourism Owensboro composite", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: towards Mazda close Reggae New aliquid", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Towels male Soap Androgyne Van female Metal Borders North interfaces HTTP Southeast mid Security anodize Pickup Account composite East Dinar raiment payment secured Account", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Trial except", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: um plus Screen Bicycle", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Unbranded Pocatello riffle female state Accountability", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Unbranded versus metrics", + "organisation_cnt": 4 + }, + { + "organisation": "Organisation: Vanadium filing transition", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Van withdrawal porro Lebanese", + "organisation_cnt": 3 + }, + { + "organisation": "Organisation: violet Cadmium", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: viral Bedfordshire Handcrafted", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: virtual invoice mole unbearably", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: virtual pajamas sit eyrie Account backing Direct Accountability Borders Account Agent Coupe popularize Directives Bicycle harness Legacy Chair Omnigender Kennewick Research indelible met", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: Visionary", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: voluntarily Unbranded", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: Volvo Rock", + "organisation_cnt": 5 + }, + { + "organisation": "Organisation: Web TCP Industrial UTF8 hard systemic Estates indexing Cadillac Southwest executive searchingly testy Product ew hacking Diesel yellow worth Fitness whether Kyat Convertible Morar TCP dr", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: West", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: wherever", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: wherever quas East Brand Volkswagen double Wisconsin Rap Account Osmium Towels Director Electronic Northwest panel Cambridgeshire Hydrogen sky indexing male Titanium Diesel Chevrolet ben", + "organisation_cnt": 2 + }, + { + "organisation": "Organisation: white", + "organisation_cnt": 8 + }, + { + "organisation": "Organisation: why caress magenta", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: wireless Pickup Oman", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: withdrawal middleware South Analyst Account Rubber calculate laudantium Chino Electronics ivory shameless TCP", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: without", + "organisation_cnt": 1 + }, + { + "organisation": "Organisation: yippee", + "organisation_cnt": 2 + } + ], + "projects":[{ "id": "d8d60d90-589f-4dbb-8d6f-a7d796d7594c", "slug": "test-project", "title": "Test project", @@ -154,5 +2100,5 @@ "is_published": true, "image_id": "7386533b-e762-4272-9eaa-eae5589ba2ee", "keywords": null - } -] \ No newline at end of file + }] +} \ No newline at end of file diff --git a/frontend/__tests__/__mocks__/projectsOverview.json.license b/frontend/__tests__/__mocks__/projectsOverview.json.license index a3b178ba3..3bb7d18c2 100644 --- a/frontend/__tests__/__mocks__/projectsOverview.json.license +++ b/frontend/__tests__/__mocks__/projectsOverview.json.license @@ -1,4 +1,4 @@ -SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) -SPDX-FileCopyrightText: 2022 dv4all +SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +SPDX-FileCopyrightText: 2022 - 2023 dv4all SPDX-License-Identifier: Apache-2.0 diff --git a/frontend/components/AppHeader/index.tsx b/frontend/components/AppHeader/index.tsx index 29e171c1a..b0246ac8b 100644 --- a/frontend/components/AppHeader/index.tsx +++ b/frontend/components/AppHeader/index.tsx @@ -148,7 +148,6 @@ export default function AppHeader() { {/* LOGIN / USER MENU */} - diff --git a/frontend/components/admin/organisations/OrganisationItem.tsx b/frontend/components/admin/organisations/OrganisationItem.tsx index 964ad3915..673900e31 100644 --- a/frontend/components/admin/organisations/OrganisationItem.tsx +++ b/frontend/components/admin/organisations/OrganisationItem.tsx @@ -62,7 +62,7 @@ export default function OrganisationItem({item, onDelete}: OrganisationItemProps sx={{ width: '4rem', height: '4rem', - fontSize: '2rem', + fontSize: '1.5rem', marginRight: '1rem', '& img': { height:'auto' diff --git a/frontend/components/admin/rsd-contributors/config.tsx b/frontend/components/admin/rsd-contributors/config.tsx index 75a525b73..64da016e6 100644 --- a/frontend/components/admin/rsd-contributors/config.tsx +++ b/frontend/components/admin/rsd-contributors/config.tsx @@ -10,6 +10,7 @@ import ContributorAvatar from '~/components/software/ContributorAvatar' import {Column} from '~/components/table/EditableTable' import {getImageUrl} from '~/utils/editImage' import {patchPerson, RsdContributor} from './apiContributors' +import {getDisplayInitials} from '~/utils/getDisplayName' export function createColumns(token: string) { const columns: Column[] = [{ @@ -25,8 +26,11 @@ export function createColumns(token: string) { return ( ) } diff --git a/frontend/components/admin/software-highlights/SoftwareOptionFound.tsx b/frontend/components/admin/software-highlights/SoftwareOptionFound.tsx index 87f3140a4..6521bfeaa 100644 --- a/frontend/components/admin/software-highlights/SoftwareOptionFound.tsx +++ b/frontend/components/admin/software-highlights/SoftwareOptionFound.tsx @@ -31,7 +31,7 @@ export default function SoftwareOptionFound({option}: { option: AutocompleteOpti sx={{ width: '4rem', height: '4rem', - fontSize: '2rem', + fontSize: '1.5rem', marginRight: '1rem', '& img': { height:'auto' diff --git a/frontend/components/admin/software-highlights/SortableHightlightItem.tsx b/frontend/components/admin/software-highlights/SortableHightlightItem.tsx index 043660d05..e193d301c 100644 --- a/frontend/components/admin/software-highlights/SortableHightlightItem.tsx +++ b/frontend/components/admin/software-highlights/SortableHightlightItem.tsx @@ -68,7 +68,7 @@ export default function SortableHighlightItem({pos, item, onEdit, onDelete}: Hig sx={{ width: '4rem', height: '4rem', - fontSize: '3rem', + fontSize: '1.5rem', marginRight: '1rem', '& img': { height:'auto' diff --git a/frontend/components/cards/CardTitleSubtitle.tsx b/frontend/components/cards/CardTitleSubtitle.tsx index 6701715ba..59356738e 100644 --- a/frontend/components/cards/CardTitleSubtitle.tsx +++ b/frontend/components/cards/CardTitleSubtitle.tsx @@ -11,14 +11,12 @@ type CardTitleSubtitleProps = { export default function CardTitleSubtitle({title,subtitle}:CardTitleSubtitleProps) { return ( <> -

+

{title}

-
-

- {subtitle} -

-
+

+ {subtitle} +

) } diff --git a/frontend/components/cards/KeywordList.tsx b/frontend/components/cards/KeywordList.tsx index 52e8dacd6..4dc8c3b9b 100644 --- a/frontend/components/cards/KeywordList.tsx +++ b/frontend/components/cards/KeywordList.tsx @@ -13,13 +13,14 @@ export default function KeywordList({keywords=[], visibleNumberOfKeywords = 3}: if (!keywords || keywords.length===0) return null return ( -
    +
      {// limits the keywords to 'visibleNumberOfKeywords' per software. keywords?.slice(0, visibleNumberOfKeywords) .map((keyword:string, index: number) => (
    • {keyword}
    • ))} @@ -27,7 +28,7 @@ export default function KeywordList({keywords=[], visibleNumberOfKeywords = 3}: (keywords?.length > 0) && (keywords?.length > visibleNumberOfKeywords) && (keywords?.length - visibleNumberOfKeywords > 0) - && `+ ${keywords?.length - visibleNumberOfKeywords}` + &&
    • {`+ ${keywords?.length - visibleNumberOfKeywords}`}
    • }
    ) diff --git a/frontend/components/cards/SoftwareMetrics.tsx b/frontend/components/cards/SoftwareMetrics.tsx deleted file mode 100644 index 4ecf36ab6..000000000 --- a/frontend/components/cards/SoftwareMetrics.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import ContributorIcon from '~/components/icons/ContributorIcon' -import DownloadsIcon from '~/components/icons/DownloadsIcon' -import MentionIcon from '~/components/icons/MentionIcon' - -type SoftwareMetricsProps = { - contributor_cnt?: number | null - mention_cnt?: number | null - downloads?: number -} - - -export default function SoftwareMetrics({contributor_cnt,mention_cnt,downloads}:SoftwareMetricsProps) { - return ( -
    -
    - - {contributor_cnt || 0} -
    - -
    - - {mention_cnt || 0} -
    - - {/* TODO Add download counts to the cards */} - {(downloads && downloads > 0) && -
    - - {downloads} -
    - } -
    - ) -} diff --git a/frontend/components/charts/progress/PeriodProgressBar.tsx b/frontend/components/charts/progress/PeriodProgressBar.tsx new file mode 100644 index 000000000..94651158e --- /dev/null +++ b/frontend/components/charts/progress/PeriodProgressBar.tsx @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {scaleTime} from 'd3' + +type PeriodProgressBar = { + date_start: string | null, + date_end: string | null + className?: string + height?:string +} + +export default function PeriodProgressBar({date_start,date_end,className, height='0.5rem'}:PeriodProgressBar) { + + const progress = getProgressValue({ + date_start, + date_end + }) + + function getProgressValue({date_start, date_end}: PeriodProgressBar) { + + if (date_start === null || date_end === null) return 0 + + const start_date = new Date(date_start) + const end_date = new Date(date_end) + const now = new Date() + + // define x scale as time scale + // from 0 - 100 so we convert is to % + const xScale = scaleTime() + .domain([start_date, end_date]) + .range([0,100]) + + const progress = xScale(now) + + if (progress > 100) return 100 + if (progress < 0) return 0 + return Math.floor(progress) + } + + return ( +
    +
    +
    + ) +} diff --git a/frontend/components/filter/FilterPopover.tsx b/frontend/components/filter/FilterPopover.tsx deleted file mode 100644 index 34326dad5..000000000 --- a/frontend/components/filter/FilterPopover.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-FileCopyrightText: 2021 - 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2021 - 2022 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {useState} from 'react' -import Badge from '@mui/material/Badge' -import IconButton from '@mui/material/IconButton' -import Button from '@mui/material/Button' -import Tooltip from '@mui/material/Tooltip' -import Popover from '@mui/material/Popover' -import FilterAltIcon from '@mui/icons-material/FilterAlt' -import CloseIcon from '@mui/icons-material/Close' -import DeleteIcon from '@mui/icons-material/Delete' -import Divider from '@mui/material/Divider' -import useDisableScrollLock from '~/utils/useDisableScrollLock' - -type FilterPopoverProps = { - title: string - filterTooltip: string - badgeContent: number - disableClear: boolean - children: any - onClear: () => void -} - -/** - * FilterPopover shared component for software and project filters. - * It handles opening/closing of filter popover using filter icon. - * The content of the popover is received via children prop. - */ -export default function FilterPopover(props: FilterPopoverProps) { - const disable = useDisableScrollLock() - const [anchorEl, setAnchorEl] = useState(null) - const open = Boolean(anchorEl) - const { - title, filterTooltip, badgeContent, - disableClear, children, onClear - } = props - - function handleOpen(event: React.MouseEvent){ - setAnchorEl(event.currentTarget) - } - function handleClose(){ - setAnchorEl(null) - } - - function handleClear() { - // close popover - setAnchorEl(null) - // pass clear event - onClear() - } - - return ( - <> - - - - - - - - -

    - {title} -

    - - {/* POPOVER BODY */} - {children} - - {/* POPOVER NAV */} - -
    - - -
    -
    - - ) -} diff --git a/frontend/components/filter/FindFilterOptions.test.tsx b/frontend/components/filter/FindFilterOptions.test.tsx deleted file mode 100644 index c51da47b0..000000000 --- a/frontend/components/filter/FindFilterOptions.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2022 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {render, screen} from '@testing-library/react' -import {WrappedComponentWithProps} from '~/utils/jest/WrappedComponents' - -import FindFilterOptions from './FindFilterOptions' - -const mockSearchApi = jest.fn() -const mockOnAdd = jest.fn() -const mockItemsToOptions = jest.fn() - -const mockName='Select or type a keyword' -const mockProps = { - config:{ - freeSolo: false, - minLength: 0, - label: mockName, - help: '', - reset: true, - noOptions: { - empty: 'Type keyword', - minLength: 'Too short', - notFound: 'There are no projects with this keyword' - } - }, - searchApi: mockSearchApi, - onAdd: mockOnAdd, - itemsToOptions: mockItemsToOptions -} - -beforeEach(() => { - jest.resetAllMocks() -}) - -it('renders component with input/combobox', () => { - render(WrappedComponentWithProps(FindFilterOptions, { - props: mockProps - })) - - const input = screen.getByRole('combobox', { - name: mockName - }) - - expect(input).toBeInTheDocument() -}) - -it('makes initial call to searchApi', () => { - // mock response - mockSearchApi.mockResolvedValueOnce(['item 1', 'item 2', 'item 3']) - // render component - render(WrappedComponentWithProps(FindFilterOptions, { - props: mockProps - })) - - // expect api to be called - expect(mockSearchApi).toBeCalledTimes(1) - expect(mockSearchApi).toBeCalledWith({searchFor: ''}) - // screen.debug() -}) - diff --git a/frontend/components/filter/FindFilterOptions.tsx b/frontend/components/filter/FindFilterOptions.tsx deleted file mode 100644 index 2f3c7cb1f..000000000 --- a/frontend/components/filter/FindFilterOptions.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences -// SPDX-FileCopyrightText: 2022 Matthias Rüster (GFZ) -// SPDX-FileCopyrightText: 2022 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {HTMLAttributes, useEffect, useState} from 'react' - -import AsyncAutocompleteSC, { - AsyncAutocompleteConfig, AutocompleteOption -} from '~/components/form/AsyncAutocompleteSC' - -type RequiredData = { - cnt: number|null -} - -type FindFilterOptionsProps = { - config: AsyncAutocompleteConfig - searchApi: ({searchFor}: {searchFor: string}) => Promise - onAdd: (item: T) => void - itemsToOptions: (items:T[]) => AutocompleteOption[] - // onCreate?: (item: string) => void -} - -export default function FindFilterOptions({ - config, onAdd, searchApi, itemsToOptions}: FindFilterOptionsProps) { - const [initalList, setInitalList] = useState[]>([]) - const [options, setOptions] = useState[]>([]) - const [status, setStatus] = useState<{ - loading: boolean, - searchFor: string | undefined - foundFor: string | undefined - }>({ - loading: false, - searchFor: undefined, - foundFor: undefined - }) - - useEffect(() => { - async function getInitalList() { - const resp = await searchApi({ - // we trim raw search value - searchFor: '' - }) - // convert items to autocomplete options - const options = itemsToOptions(resp) - // set options - setOptions(options) - setInitalList(options) - } - getInitalList() - // ignore linter for searchForKeyword fn - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - async function onSearch(searchFor: string) { - // console.log('onSearch...searchFor...', searchFor) - // set loading status and clear foundFor - setStatus({loading: true, searchFor, foundFor: undefined}) - // make search request - const resp = await searchApi({ - // we trim raw search value - searchFor: searchFor.trim() - }) - // convert items to autocomplete options - const options = itemsToOptions(resp) - // set options - setOptions(options) - // debugger - // stop loading - setStatus({ - loading: false, - searchFor, - foundFor: searchFor - }) - } - - function onAddItem(selected:AutocompleteOption) { - if (selected && selected.data) { - onAdd(selected.data) - // if we use reset of selected input - // we also load inital list of keywords - if (config.reset === true) { - setOptions(initalList) - } - } - } - - function renderOption(props: HTMLAttributes, - option: AutocompleteOption, - state: object) { - - return ( -
  • - {/* if new option (has input) show label and count */} -
    - {option.label} - - ({option.data?.cnt ?? 0}) - -
    -
  • - ) - } - - return ( -
    - setOptions(initalList)} - config={config} - /> -
    - ) -} diff --git a/frontend/components/filter/SelectedFilterItems.test.tsx b/frontend/components/filter/SelectedFilterItems.test.tsx deleted file mode 100644 index 341078a48..000000000 --- a/frontend/components/filter/SelectedFilterItems.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2022 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {fireEvent, render, screen} from '@testing-library/react' - -import SelectedFilterItems from './SelectedFilterItems' - -const mockDelete = jest.fn() -const items = ['Item 1', 'Item 2',' Item 3'] - -beforeEach(() => { - render() -}) - -it('renders list items', () => { - // get all chips - const chips = screen.getAllByRole('button') - // expect all items to be present - expect(chips.length).toEqual(items.length) -}) - -it('calls onDelete when item deleted', () => { - // get all delete icons - const deletes = screen.getAllByTestId('CancelIcon') - expect(deletes.length).toEqual(items.length) - - // click on the icon - fireEvent.click(deletes[1]) - - expect(mockDelete).toBeCalledTimes(1) - expect(mockDelete).toBeCalledWith(1) -}) diff --git a/frontend/components/filter/SelectedFilterItems.tsx b/frontend/components/filter/SelectedFilterItems.tsx deleted file mode 100644 index 81e05c21e..000000000 --- a/frontend/components/filter/SelectedFilterItems.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2022 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import Chip from '@mui/material/Chip' -import {Fragment} from 'react' - -type SelectedFilterItemsProps = { - items: string[] - onDelete: (pos:number)=>void -} - -export default function SelectedFilterItems({items=[], onDelete}: SelectedFilterItemsProps) { - if (items.length===0) return null - return ( -
    - {items.map((item, pos) => { - if (pos > 0) { - return ( - - + - onDelete(pos)} - sx={{ - borderRadius:'0.25rem' - }} - /> - - ) - } - return ( - onDelete(pos)} - sx={{ - borderRadius:'0.25rem' - }} - /> - ) - })} -
    - ) -} diff --git a/frontend/components/icons/ImpactIcon.tsx b/frontend/components/icons/ImpactIcon.tsx new file mode 100644 index 000000000..88199f150 --- /dev/null +++ b/frontend/components/icons/ImpactIcon.tsx @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +export default function ImpactIcon() { + return ( + + + + ) +} diff --git a/frontend/components/keyword/KeywordFilter.test.tsx b/frontend/components/keyword/KeywordFilter.test.tsx deleted file mode 100644 index 0ee85dc50..000000000 --- a/frontend/components/keyword/KeywordFilter.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2022 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {fireEvent, render, screen} from '@testing-library/react' -import KeywordFilter, {KeywordFilterProps} from './KeywordFilter' - -const mockApi = jest.fn() -const mockApply = jest.fn() - -const mockProps:KeywordFilterProps = { - items: [], - onApply: mockApply, - searchApi: mockApi, -} - -beforeEach(() => { - jest.resetAllMocks() -}) - -it('renders component', () => { - // mock api call - mockApi.mockResolvedValueOnce([]) - // render - render( - - ) -}) - -it('renders keyword list returned from api', async() => { - const mockInput = 'Keyword 1' - // mock api call - mockApi.mockResolvedValueOnce(['keyword 1','keyword 2']) - // render - render( - - ) - - expect(mockApi).toBeCalledTimes(1) - expect(mockApi).toBeCalledWith({searchFor: ''}) -}) - -it('handles delete of keyword', async() => { - // add some items - mockProps.items = ['Keyword 1', 'Keyword 2', 'Keyword 3'] - // mock api call - mockApi.mockResolvedValueOnce(['keyword 1', 'keyword 2']) - - // render - render( - - ) - - // get chips - const chips = screen.getAllByTestId('filter-item-chip') - expect(chips.length).toEqual(3) - - // get delete button - const deleteBtn = chips[0].querySelector('[data-testid="CancelIcon"]') - expect(deleteBtn).toBeInTheDocument() - - // use delete - fireEvent.click(deleteBtn) - - expect(mockApply).toBeCalledTimes(1) - expect(mockApply).toBeCalledWith(['Keyword 2', 'Keyword 3']) -}) diff --git a/frontend/components/keyword/KeywordFilter.tsx b/frontend/components/keyword/KeywordFilter.tsx deleted file mode 100644 index a247cce36..000000000 --- a/frontend/components/keyword/KeywordFilter.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-FileCopyrightText: 2021 - 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2021 - 2023 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// -// SPDX-License-Identifier: Apache-2.0 - -import {Keyword} from '~/components/keyword/FindKeyword' -import FindFilterOptions from '~/components/filter/FindFilterOptions' -import SelectedFilterItems from '~/components/filter/SelectedFilterItems' - -type SeachApiProps = { - searchFor: string -} - -export type KeywordFilterProps = { - items: string[] - onApply: (items: string[]) => void - searchApi: ({searchFor}:SeachApiProps)=> Promise -} - -/** - * 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 KeywordFilter({items=[], searchApi, onApply}:KeywordFilterProps) { - - function handleDelete(pos:number) { - const newList = [ - ...items.slice(0, pos), - ...items.slice(pos+1) - ] - // apply change - onApply(newList) - } - - function onAdd(item: Keyword) { - const find = items.find(keyword => keyword.toLowerCase() === item.keyword.toLowerCase()) - // new item - if (typeof find == 'undefined') { - const newList = [ - ...items, - item.keyword - ].sort() - // apply change - onApply(newList) - } - } - - function itemsToOptions(items: Keyword[]) { - const options = items.map(item => ({ - key: item.keyword, - label: item.keyword, - data: item - })) - return options - } - - return ( - <> -
    -

    By keyword

    - -
    - - - ) -} diff --git a/frontend/components/layout/EditPageButton.tsx b/frontend/components/layout/EditPageButton.tsx index 0558772ff..61e844762 100644 --- a/frontend/components/layout/EditPageButton.tsx +++ b/frontend/components/layout/EditPageButton.tsx @@ -55,6 +55,7 @@ export default function EditPageButton({title, url, isMaintainer, variant}: Edit right: { lg:'1rem' }, + textTransform:'capitalize' // minWidth: '6rem' }} onClick={() => { @@ -62,7 +63,8 @@ export default function EditPageButton({title, url, isMaintainer, variant}: Edit router.push(url) }} > - Edit page + {/* Edit page */} + {title} ) diff --git a/frontend/components/layout/ImageWithPlaceholder.tsx b/frontend/components/layout/ImageWithPlaceholder.tsx index 319ec743b..ce91e4d6d 100644 --- a/frontend/components/layout/ImageWithPlaceholder.tsx +++ b/frontend/components/layout/ImageWithPlaceholder.tsx @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 import PhotoSizeSelectActualOutlinedIcon from '@mui/icons-material/PhotoSizeSelectActualOutlined' +import useValidateImageSrc from '~/utils/useValidateImageSrc' export type ImageWithPlaceholderProps = { src: string | null | undefined @@ -22,8 +23,9 @@ export default function ImageWithPlaceholder({ width = '4rem', height = '4rem', type='icon' }: ImageWithPlaceholderProps ) { + const validImg = useValidateImageSrc(src) - if (!src) { + if (!src || validImg===false) { if (type === 'gradient') { return (
    } sx={{ - minWidth: '6rem' + minWidth: '6rem', + textTransform:'capitalize' }} onClick={() => { // const slug = router.query['slug'] @@ -29,7 +31,8 @@ export default function ViewPageButton({title,url,disabled}:ViewButtonProps) { }} disabled={disabled} > - View page + {/* View page */} + {label ?? title} ) } diff --git a/frontend/components/software/overview/filters/FilterHeader.tsx b/frontend/components/layout/filter/FilterHeader.tsx similarity index 80% rename from frontend/components/software/overview/filters/FilterHeader.tsx rename to frontend/components/layout/filter/FilterHeader.tsx index eaece4786..3bb95c9da 100644 --- a/frontend/components/software/overview/filters/FilterHeader.tsx +++ b/frontend/components/layout/filter/FilterHeader.tsx @@ -7,10 +7,11 @@ import Button from '@mui/material/Button' type FilterHeaderProps = { filterCnt: number + disableClear: boolean resetFilters: () => void } -export default function FilterHeader({filterCnt,resetFilters}:FilterHeaderProps) { +export default function FilterHeader({filterCnt,disableClear=true,resetFilters}:FilterHeaderProps) { return (
    @@ -24,8 +25,8 @@ export default function FilterHeader({filterCnt,resetFilters}:FilterHeaderProps) + + + + ) +} diff --git a/frontend/components/projects/overview/filters/ProjectKeywordsFilter.tsx b/frontend/components/projects/overview/filters/ProjectKeywordsFilter.tsx new file mode 100644 index 000000000..c65c390e4 --- /dev/null +++ b/frontend/components/projects/overview/filters/ProjectKeywordsFilter.tsx @@ -0,0 +1,84 @@ +// 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 '~/components/layout/filter/FilterTitle' +import FilterOption from '~/components/layout/filter/FilterOption' +import useProjectOverviewParams from '../useProjectOverviewParams' + +export type KeywordFilterOption = { + keyword: string + keyword_cnt: number +} + +type ProjectKeywordsFilterProps = { + keywords: string[], + keywordsList: KeywordFilterOption[] +} + +export default function ProjectKeywordsFilter({keywords, keywordsList}: ProjectKeywordsFilterProps) { + const {handleQueryChange} = useProjectOverviewParams() + const [selected, setSelected] = useState([]) + const [options, setOptions] = useState(keywordsList) + + // console.group('KeywordsFilter') + // console.log('keywordsList...', keywordsList) + // console.log('options...', options) + // console.groupEnd() + + useEffect(() => { + if (keywords.length > 0 && keywordsList.length) { + const selectedKeywords = keywordsList.filter(option => { + return keywords.includes(option.keyword) + }) + setSelected(selectedKeywords) + } else { + setSelected([]) + } + setOptions(keywordsList) + },[keywords,keywordsList]) + + return ( +
    + + (option.keyword)} + isOptionEqualToValue={(option, value) => { + return option.keyword === value.keyword + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( + + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.keyword) + handleQueryChange('keywords', queryFilter) + }} + /> +
    + ) +} diff --git a/frontend/components/projects/overview/filters/ResearchDomainFilter.tsx b/frontend/components/projects/overview/filters/ResearchDomainFilter.tsx new file mode 100644 index 000000000..b8e5ab1a5 --- /dev/null +++ b/frontend/components/projects/overview/filters/ResearchDomainFilter.tsx @@ -0,0 +1,85 @@ +// 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 '~/components/layout/filter/FilterTitle' +import FilterOption from '~/components/layout/filter/FilterOption' +import useProjectOverviewParams from '../useProjectOverviewParams' + +export type ResearchDomainOption = { + key: string + domain: string, + domain_cnt: number +} + +type ResearchDomainFilterProps = { + domains: string[], + domainsList: ResearchDomainOption[] +} + +export default function ResearchDomainFilter({domains, domainsList}: ResearchDomainFilterProps) { + const {handleQueryChange} = useProjectOverviewParams() + const [selected, setSelected] = useState([]) + const [options, setOptions] = useState(domainsList) + + // console.group('ResearchDomainFilter') + // console.log('domainsList...', domainsList) + // console.log('options...', options) + // console.groupEnd() + + useEffect(() => { + if (domains.length > 0 && domainsList.length) { + const selectedKeywords = domainsList.filter(option => { + return domains.includes(option.key) + }) + setSelected(selectedKeywords) + } else { + setSelected([]) + } + setOptions(domainsList) + },[domains,domainsList]) + + return ( +
    + + (option.domain)} + isOptionEqualToValue={(option, value) => { + return option.domain === value.domain + }} + defaultValue={[]} + filterSelectedOptions + renderOption={(props, option) => ( + + )} + renderInput={(params) => ( + + )} + onChange={(event, newValue) => { + // extract values into string[] for url query + const queryFilter = newValue.map(item => item.key) + handleQueryChange('domains', queryFilter) + }} + /> +
    + ) +} diff --git a/frontend/components/projects/overview/filters/projectFiltersApi.ts b/frontend/components/projects/overview/filters/projectFiltersApi.ts new file mode 100644 index 000000000..09e401221 --- /dev/null +++ b/frontend/components/projects/overview/filters/projectFiltersApi.ts @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {createJsonHeaders, getBaseUrl} from '~/utils/fetchHelpers' +import logger from '~/utils/logger' +import {KeywordFilterOption} from './ProjectKeywordsFilter' +import {ResearchDomainOption} from './ResearchDomainFilter' + +export type ResearchDomainInfo = { + key: string, + name: string, +} + +type DomainsFilterOption = { + domain: string, + domain_cnt: number +} + +type ParicipatingOrganisationFilterOption = { + organisation: string + organisation_cnt: string +} + +type ProjectFilterProps = { + search?: string + keywords?: string[] + domains?: string[] + organisations?: string[] +} + +type ProjectFilterApiProps = { + search_filter?: string + keyword_filter?: string[] + research_domain_filter?: string[] + organisation_filter?: string[] +} + +function buildProjectFilter({search, keywords, domains, organisations}: ProjectFilterProps) { + const filter: ProjectFilterApiProps = {} + if (search) { + filter['search_filter'] = search + } + if (keywords) { + filter['keyword_filter'] = keywords + } + if (domains) { + filter['research_domain_filter'] = domains + } + if (organisations) { + filter['organisation_filter'] = organisations + } + // console.group('buildProjectFilter') + // console.log('filter...', filter) + // console.groupEnd() + return filter +} + + +export async function projectKeywordsFilter({search, keywords, domains, organisations}: ProjectFilterProps) { + try { + const query = 'rpc/project_keywords_filter?order=keyword' + const url = `${getBaseUrl()}/${query}` + const filter = buildProjectFilter({ + search, + keywords, + domains, + organisations + }) + + // console.group('softwareKeywordsFilter') + // console.log('filter...', JSON.stringify(filter)) + // console.log('url...', url) + // console.groupEnd() + + const resp = await fetch(url, { + method: 'POST', + headers: createJsonHeaders(), + body: filter ? JSON.stringify(filter) : undefined + }) + + if (resp.status === 200) { + const json: KeywordFilterOption[] = await resp.json() + return json + } + + logger(`projectKeywordsFilter: ${resp.status} ${resp.statusText}`, 'warn') + return [] + + } catch (e: any) { + logger(`projectKeywordsFilter: ${e?.message}`, 'error') + return [] + } +} + +export async function projectDomainsFilter({search, keywords, domains, organisations}: ProjectFilterProps) { + try { + // get possible options + const domainsOptions = await getDomainsFilterList({search, keywords, domains, organisations}) + + if (domainsOptions.length > 0) { + const keys = domainsOptions.map(item => item.domain) + // get research domain info (labels for keys) + const domainsInfo = await getResearchDomainInfo(keys) + // combine keys, names and counts + const domainsList = createDomainsList( + domainsOptions, + domainsInfo + ) + return domainsList + } + + return [] + } catch (e: any) { + logger(`projectDomainsFilter: ${e?.message}`, 'error') + return [] + } +} + +export async function getDomainsFilterList({search, keywords, domains, organisations}: ProjectFilterProps) { + try { + const query = 'rpc/project_domains_filter?order=domain' + const url = `${getBaseUrl()}/${query}` + const filter = buildProjectFilter({ + search, + keywords, + domains, + organisations + }) + + // console.group('softwareKeywordsFilter') + // console.log('filter...', JSON.stringify(filter)) + // console.log('url...', url) + // console.groupEnd() + + const resp = await fetch(url, { + method: 'POST', + headers: createJsonHeaders(), + body: filter ? JSON.stringify(filter) : undefined + }) + + if (resp.status === 200) { + const json: DomainsFilterOption[] = await resp.json() + return json + } + + logger(`getDomainsFilterList: ${resp.status} ${resp.statusText}`, 'warn') + return [] + + } catch (e: any) { + logger(`getDomainsFilterList: ${e?.message}`, 'error') + return [] + } +} + +export async function getResearchDomainInfo(keys: string[]) { + try { + // ignore when keys not provided + if (typeof keys === 'undefined' || keys === null) return [] + // GET research domains info by key + const query = `key=in.("${keys.join('","')}")` + const select = 'select=key,name' + const url = `${getBaseUrl()}/research_domain?${select}&${query}` + + const resp = await fetch(url, { + method: 'GET' + }) + if (resp.status === 200) { + const json: ResearchDomainInfo[] = await resp.json() + if (json.length > 0) { + return json + } + return [] + } + logger(`getResearchDomainInfo: ${resp.status} ${resp.statusText}`, 'warn') + return [] + } catch (e: any) { + logger(`getResearchDomainInfo: ${e?.message}`, 'error') + return [] + } +} + +export function createDomainsList(domainOptions: DomainsFilterOption[], domainInfo: ResearchDomainInfo[]) { + let domainsList: ResearchDomainOption[] = [] + // return empty list + if (domainOptions.length === 0 || domainInfo.length === 0) return domainsList + // + domainOptions.forEach(option => { + const info = domainInfo.find(i => i.key === option.domain) + if (info) { + domainsList.push({ + key: option.domain, + domain: `${option.domain}: ${info.name}`, + domain_cnt: option.domain_cnt + }) + } + }) + return domainsList +} + + +export async function projectParticipatingOrganisationsFilter({search, keywords, domains, organisations}: ProjectFilterProps) { + try { + const query = 'rpc/project_participating_organisations_filter?order=organisation' + const url = `${getBaseUrl()}/${query}` + const filter = buildProjectFilter({ + search, + keywords, + domains, + organisations + }) + + // console.group('softwareKeywordsFilter') + // console.log('filter...', JSON.stringify(filter)) + // console.log('url...', url) + // console.groupEnd() + + const resp = await fetch(url, { + method: 'POST', + headers: createJsonHeaders(), + body: filter ? JSON.stringify(filter) : undefined + }) + + if (resp.status === 200) { + const json: ParicipatingOrganisationFilterOption[] = await resp.json() + return json + } + + logger(`projectParticipatingOrganisationsFilter: ${resp.status} ${resp.statusText}`, 'warn') + return [] + + } catch (e: any) { + logger(`projectParticipatingOrganisationsFilter: ${e?.message}`, 'error') + return [] + } +} diff --git a/frontend/components/projects/overview/list/ProjectOverviewList.tsx b/frontend/components/projects/overview/list/ProjectOverviewList.tsx new file mode 100644 index 000000000..ce836b595 --- /dev/null +++ b/frontend/components/projects/overview/list/ProjectOverviewList.tsx @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {ProjectListItem} from '~/types/Project' +import ProjectOverviewListItem from './ProjectOverviewListItem' + +export default function ProjectOverviewList({projects = []}: { projects: ProjectListItem[] }) { + return ( +
    + {projects.map(item => )} +
    + ) +} diff --git a/frontend/components/projects/overview/list/ProjectOverviewListItem.tsx b/frontend/components/projects/overview/list/ProjectOverviewListItem.tsx new file mode 100644 index 000000000..e8bcfcc34 --- /dev/null +++ b/frontend/components/projects/overview/list/ProjectOverviewListItem.tsx @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import Link from 'next/link' +import {getImageUrl} from '~/utils/editImage' +import useValidateImageSrc from '~/utils/useValidateImageSrc' +import {ProjectListItem} from '~/types/Project' +import ProjectMetrics from '../cards/ProjectMetrics' + + +export default function ProjectOverviewListItem({item}: { item: ProjectListItem }) { + const imgSrc = getImageUrl(item.image_id ?? null) + const validImg = useValidateImageSrc(imgSrc) + return ( + +
    + {validImg ? + {`Cover + : +
    + } +
    +
    +
    + {item.title} +
    +
    + {item.subtitle} +
    +
    + + {/* Metrics */} +
    + +
    +
    +
    + + ) +} diff --git a/frontend/components/projects/overview/search/ProjectSearchSection.tsx b/frontend/components/projects/overview/search/ProjectSearchSection.tsx new file mode 100644 index 000000000..3f9023699 --- /dev/null +++ b/frontend/components/projects/overview/search/ProjectSearchSection.tsx @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import useMediaQuery from '@mui/material/useMediaQuery' +import Button from '@mui/material/Button' + +import SearchInput from '~/components/search/SearchInput' +import SelectRows from '~/components/software/overview/search/SelectRows' +import ViewToggleGroup, {ProjectLayoutType} from './ViewToggleGroup' +import useProjectOverviewParams from '../useProjectOverviewParams' + +// export type LayoutType = 'list'|'grid'|'masonry' + +type SearchSectionProps = { + page: number + rows: number + count: number + placeholder: string + layout: ProjectLayoutType + search?: string | null + setModal: (modal: boolean) => void + setView: (view:ProjectLayoutType)=>void +} + + +export default function SoftwareSearchSection({ + search, placeholder, page, rows, count, layout, + setView, setModal +}: SearchSectionProps) { + const {handleQueryChange} = useProjectOverviewParams() + const smallScreen = useMediaQuery('(max-width:640px)') + + return ( +
    +
    + handleQueryChange('search', search)} + defaultValue={search ?? ''} + /> + + +
    +
    +
    + Page {page ?? 1} of {count} results +
    + {smallScreen === true && + + } +
    +
    + ) +} diff --git a/frontend/components/projects/overview/search/ViewToggleGroup.tsx b/frontend/components/projects/overview/search/ViewToggleGroup.tsx new file mode 100644 index 000000000..6596869ac --- /dev/null +++ b/frontend/components/projects/overview/search/ViewToggleGroup.tsx @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import ViewListIcon from '@mui/icons-material/ViewList' +import ViewModuleIcon from '@mui/icons-material/ViewModule' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' + +export type ProjectLayoutType = 'list'|'grid' + +type ViewToggleGroupProps = { + layout: ProjectLayoutType + onSetView: (view:ProjectLayoutType)=>void +} + +export default function ViewToggleGroup({layout,onSetView}:ViewToggleGroupProps) { + return ( + onSetView(view)} + sx={{ + backgroundColor: 'background.paper', + }} + > + {/* + + */} + + + + + + + + ) +} diff --git a/frontend/components/software/overview/SearchInput.tsx b/frontend/components/search/SearchInput.tsx similarity index 100% rename from frontend/components/software/overview/SearchInput.tsx rename to frontend/components/search/SearchInput.tsx diff --git a/frontend/components/software/RelatedSoftwareSection.tsx b/frontend/components/software/RelatedSoftwareSection.tsx index 900c462b2..35df6d864 100644 --- a/frontend/components/software/RelatedSoftwareSection.tsx +++ b/frontend/components/software/RelatedSoftwareSection.tsx @@ -1,12 +1,12 @@ -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2022 dv4all +// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 - 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 import useMediaQuery from '@mui/material/useMediaQuery' import PageContainer from '../layout/PageContainer' -import SoftwareGrid, {SoftwareGridType} from './SoftwareGrid' +import SoftwareGrid, {SoftwareGridType} from '../user/software/SoftwareGrid' export default function RelatedSoftwareSection({relatedSoftware = []}: { relatedSoftware: SoftwareGridType[] }) { // use media query hook for small screen logic diff --git a/frontend/components/software/edit/EditSoftwareStickyHeader.tsx b/frontend/components/software/edit/EditSoftwareStickyHeader.tsx index e31528dc8..c0bd9a72c 100644 --- a/frontend/components/software/edit/EditSoftwareStickyHeader.tsx +++ b/frontend/components/software/edit/EditSoftwareStickyHeader.tsx @@ -30,7 +30,8 @@ export default function EditSoftwareStickyHeader() { ) diff --git a/frontend/components/software/filter/ProgrammingLanguageFilter.tsx b/frontend/components/software/filter/ProgrammingLanguageFilter.tsx deleted file mode 100644 index 9172be440..000000000 --- a/frontend/components/software/filter/ProgrammingLanguageFilter.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import SelectedFilterItems from '~/components/filter/SelectedFilterItems' -import FindFilterOptions from '~/components/filter/FindFilterOptions' -import {ProgrammingLanguage} from './softwareFilterApi' - - -type SeachApiProps = { - searchFor: string -} - -type ResearchDomainFilterProps = { - items?: string[] - onApply: (items: string[]) => void - searchApi: ({searchFor}:SeachApiProps)=> Promise -} - -export default function ProgrammingLanguageFilter({items=[], searchApi, onApply}:ResearchDomainFilterProps) { - - function handleDelete(pos:number) { - const newList = [ - ...items.slice(0, pos), - ...items.slice(pos+1) - ] - // apply change - onApply(newList) - } - - function onAdd(item: ProgrammingLanguage) { - const find = items.find(lang => lang.toLowerCase() === item.prog_lang.toLowerCase()) - // new item - if (typeof find == 'undefined') { - const newList = [ - ...items, - item.prog_lang - ].sort() - // apply change - onApply(newList) - } - } - - function itemsToOptions(items: ProgrammingLanguage[]) { - const options = items.map(item => ({ - key: item.prog_lang, - label: item.prog_lang, - data: item - })) - return options - } - - return ( - <> -
    -

    By programming language

    - -
    - - - ) -} diff --git a/frontend/components/software/filter/SoftwareFilterIndex.test.tsx b/frontend/components/software/filter/SoftwareFilterIndex.test.tsx deleted file mode 100644 index 46cebd308..000000000 --- a/frontend/components/software/filter/SoftwareFilterIndex.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {fireEvent, render, screen,within} from '@testing-library/react' - -import SoftwareFilter, {SoftwareFilterProps} from './index' - -// MOCK -import mockKeywords from './__mocks__/softwareFilterKeywords.json' -import mockLanguages from './__mocks__/softwareProgLang.json' - -const mockSearchForKeyword = jest.fn(props => Promise.resolve([] as any)) -const mockSearchForProgrammingLanguage = jest.fn(props => Promise.resolve([] as any)) -jest.mock('./softwareFilterApi', () => ({ - searchForKeyword: jest.fn(props => mockSearchForKeyword(props)), - searchForProgrammingLanguage: jest.fn(props => mockSearchForProgrammingLanguage(props)), -})) - -const mockOnApply=jest.fn() -const mockProps:SoftwareFilterProps = { - keywords: [], - prog_lang: [], - onApply: mockOnApply -} as any - -describe('frontend/components/software/filter/index.tsx', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('can open filter with message no filter active', () => { - // no active filters - mockProps.keywords = [] - mockProps.prog_lang = [] - - render( - - ) - - // open popup - const filterBtn = screen.getByRole('button') - fireEvent.click(filterBtn) - - // validate popover - const popover = screen.getByRole('presentation') - // has no filter active message - const noFilterMsg = within(popover).getByText('No filter active') - }) - - it('can apply software filters', async() => { - // no active filters - mockProps.keywords = [] - mockProps.prog_lang = [] - // return mocked keywords - mockSearchForKeyword.mockResolvedValueOnce(mockKeywords) - // return mocked programming languages - mockSearchForProgrammingLanguage.mockResolvedValueOnce(mockLanguages) - - render( - - ) - - const filterBtn = screen.getByRole('button') - fireEvent.click(filterBtn) - - const popover = screen.getByRole('presentation') - - // we have 2 search/combos - const combos = within(popover).getAllByRole('combobox') - expect(combos.length).toEqual(2) - - // first is keyword - const keywordSearch = combos[0] - // second is programming language - const progLangSearch = combos[1] - - // use mouse down to open options - fireEvent.mouseDown(keywordSearch) - - // keyword options - const optionsK = await screen.findAllByRole('option') - expect(optionsK.length).toEqual(mockKeywords.length) - // select first keyword option - fireEvent.click(optionsK[0]) - - - // we have one keyword selected - const chips = screen.getAllByTestId('filter-item-chip') - expect(chips.length).toEqual(1) - - // validate apply called after selection - expect(mockOnApply).toBeCalledTimes(1) - expect(mockOnApply).toBeCalledWith({ - keywords: [mockKeywords[0].keyword], - prog_lang: [] - }) - - - // select first keyword option - fireEvent.mouseDown(progLangSearch) - - // screen.debug(progLangSearch) - // programming languages - const optionsL = await screen.findAllByRole('option') - expect(optionsL.length).toEqual(mockLanguages.length) - // select first prog lang option - fireEvent.click(optionsL[0]) - - // we have one keyword selected - const allChips = screen.getAllByTestId('filter-item-chip') - expect(allChips.length).toEqual(2) - - // validate apply called after selection - expect(mockOnApply).toBeCalledTimes(2) - expect(mockOnApply).toBeCalledWith({ - keywords: [mockKeywords[0].keyword], - prog_lang: [mockLanguages[0].prog_lang] - }) - }) - - it('can remove keyword filter', async() => { - // no active filters - const keywords = ['Big data','Keyword 2'] - mockProps.keywords = keywords - mockProps.prog_lang = [] - // return mocked keywords - mockSearchForKeyword.mockResolvedValueOnce(mockKeywords) - - render( - - ) - - const filterBtn = screen.getByRole('button') - fireEvent.click(filterBtn) - - // get keyword selected - const allChips = await screen.findAllByTestId('filter-item-chip') - expect(allChips.length).toEqual(keywords.length) - - // remove first chip - const delBtn = within(allChips[0]).getByTestId('CancelIcon') - fireEvent.click(delBtn) - - // validate chip is removed - const remainedChips = await screen.findAllByTestId('filter-item-chip') - expect(remainedChips.length).toEqual(allChips.length - 1) - - // validate apply called - expect(mockOnApply).toBeCalledTimes(1) - expect(mockOnApply).toBeCalledWith({ - keywords: [keywords[1]], - prog_lang: [] - }) - }) - - it('can remove programming language filter', async() => { - // no active filters - const prog_lang = ['Lang 1','Lang 2'] - mockProps.keywords = [] - mockProps.prog_lang = prog_lang - // return mocked keywords - mockSearchForKeyword.mockResolvedValueOnce(mockKeywords) - - render( - - ) - - const filterBtn = screen.getByRole('button') - fireEvent.click(filterBtn) - - // get keyword selected - const allChips = await screen.findAllByTestId('filter-item-chip') - expect(allChips.length).toEqual(prog_lang.length) - - // remove first chip - const delBtn = within(allChips[0]).getByTestId('CancelIcon') - fireEvent.click(delBtn) - - // validate chip is removed - const remainedChips = await screen.findAllByTestId('filter-item-chip') - expect(remainedChips.length).toEqual(allChips.length - 1) - - // validate apply called - expect(mockOnApply).toBeCalledTimes(1) - expect(mockOnApply).toBeCalledWith({ - keywords: [], - prog_lang: [prog_lang[1]] - }) - }) -}) - diff --git a/frontend/components/software/filter/__mocks__/softwareFilterKeywords.json b/frontend/components/software/filter/__mocks__/softwareFilterKeywords.json deleted file mode 100644 index df94f155d..000000000 --- a/frontend/components/software/filter/__mocks__/softwareFilterKeywords.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "id": "764ee9d9-7f67-40d4-ba35-bd33746236c2", - "keyword": "Big data", - "cnt": 3 - }, - { - "id": "b990fd26-e9fc-49f9-a54d-0de00fc73c99", - "keyword": "GPU", - "cnt": 3 - }, - { - "id": "3eb68203-8588-4355-ab87-3e3c5929a4fa", - "keyword": "Testing", - "cnt": 3 - }, - { - "id": "b0317f1a-c6ce-4afe-bb21-23509c31eb96", - "keyword": "Software Reuse", - "cnt": 1 - } -] \ No newline at end of file diff --git a/frontend/components/software/filter/__mocks__/softwareFilterKeywords.json.license b/frontend/components/software/filter/__mocks__/softwareFilterKeywords.json.license deleted file mode 100644 index 1dd52fcb6..000000000 --- a/frontend/components/software/filter/__mocks__/softwareFilterKeywords.json.license +++ /dev/null @@ -1,4 +0,0 @@ -SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -SPDX-FileCopyrightText: 2023 dv4all - -SPDX-License-Identifier: Apache-2.0 diff --git a/frontend/components/software/filter/__mocks__/softwareProgLang.json b/frontend/components/software/filter/__mocks__/softwareProgLang.json deleted file mode 100644 index 30171b7e9..000000000 --- a/frontend/components/software/filter/__mocks__/softwareProgLang.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "prog_lang": "Shell", - "cnt": 23 - }, - { - "prog_lang": "Python", - "cnt": 18 - }, - { - "prog_lang": "CSS", - "cnt": 12 - }, - { - "prog_lang": "Dockerfile", - "cnt": 9 - }, - { - "prog_lang": "HTML", - "cnt": 7 - }, - { - "prog_lang": "GLSL", - "cnt": 7 - }, - { - "prog_lang": "TeX", - "cnt": 7 - }, - { - "prog_lang": "JavaScript", - "cnt": 7 - }, - { - "prog_lang": "Haskell", - "cnt": 5 - }, - { - "prog_lang": "Lua", - "cnt": 5 - }, - { - "prog_lang": "Makefile", - "cnt": 4 - }, - { - "prog_lang": "R", - "cnt": 3 - }, - { - "prog_lang": "Ruby", - "cnt": 2 - }, - { - "prog_lang": "C++", - "cnt": 2 - }, - { - "prog_lang": "Swift", - "cnt": 2 - }, - { - "prog_lang": "Dart", - "cnt": 2 - }, - { - "prog_lang": "PLpgSQL", - "cnt": 2 - }, - { - "prog_lang": "CMake", - "cnt": 2 - }, - { - "prog_lang": "Go", - "cnt": 2 - }, - { - "prog_lang": "Java", - "cnt": 2 - }, - { - "prog_lang": "TypeScript", - "cnt": 2 - } -] \ No newline at end of file diff --git a/frontend/components/software/filter/__mocks__/softwareProgLang.json.license b/frontend/components/software/filter/__mocks__/softwareProgLang.json.license deleted file mode 100644 index 1dd52fcb6..000000000 --- a/frontend/components/software/filter/__mocks__/softwareProgLang.json.license +++ /dev/null @@ -1,4 +0,0 @@ -SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -SPDX-FileCopyrightText: 2023 dv4all - -SPDX-License-Identifier: Apache-2.0 diff --git a/frontend/components/software/filter/index.tsx b/frontend/components/software/filter/index.tsx deleted file mode 100644 index 3845a3328..000000000 --- a/frontend/components/software/filter/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-FileCopyrightText: 2021 - 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2021 - 2023 dv4all -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// -// SPDX-License-Identifier: Apache-2.0 - -import {useState} from 'react' -import Alert from '@mui/material/Alert' -import AlertTitle from '@mui/material/AlertTitle' - -import FilterPopover from '~/components/filter/FilterPopover' -import KeywordFilter from '~/components/keyword/KeywordFilter' -import ProgrammingLanguageFilter from './ProgrammingLanguageFilter' -import {searchForKeyword,searchForProgrammingLanguage} from './softwareFilterApi' - -export type SoftwareFilterProps = { - keywords: string[], - prog_lang: string[], - onApply: ({keywords,prog_lang}:{keywords: string[],prog_lang: 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 SoftwareFilter({keywords = [], prog_lang=[], onApply}: SoftwareFilterProps) { - const [selectedKeywords, setSelectedKeywords] = useState(keywords) - const [selectedLanguages, setSelectedLanguages] = useState(prog_lang) - const selectedItems = [ - ...selectedKeywords, - ...selectedLanguages - ] - - function onClear() { - setSelectedKeywords([]) - setSelectedLanguages([]) - onApply({keywords:[],prog_lang:[]}) - } - - function applyKeywords(keywords: string[]) { - setSelectedKeywords(keywords) - onApply({ - keywords, - prog_lang: selectedLanguages - }) - } - - function applyLanguages(prog_lang: string[]) { - setSelectedLanguages(prog_lang) - onApply({ - keywords: selectedKeywords, - prog_lang - }) - } - - function renderMessage() { - if (selectedItems.length === 0) { - return ( - - No filter active - Select a keyword from the list or type to search. - - ) - } - return
    - } - - return ( - 0 ? selectedItems.join(' + ') : 'None'}`} - badgeContent={selectedItems.length} - disableClear={selectedItems.length === 0} - onClear={onClear} - > - - - {renderMessage()} - - ) -} diff --git a/frontend/components/software/filter/softwareFilterApi.test.ts b/frontend/components/software/filter/softwareFilterApi.test.ts deleted file mode 100644 index d834e3317..000000000 --- a/frontend/components/software/filter/softwareFilterApi.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {mockResolvedValueOnce, mockRejectedValueOnce} from '~/utils/jest/mockFetch' -import {searchForKeyword, searchForProgrammingLanguage} from './softwareFilterApi' - - -beforeEach(() => { - jest.clearAllMocks() -}) - -describe('searchForKeyword', () => { - it('calls fetch with proper params', async () => { - const searchFor = 'Test search' - const expectUrl = `/api/v1/rpc/keyword_count_for_software?keyword=ilike.*${encodeURIComponent(searchFor)}*&cnt=gt.0&order=cnt.desc.nullslast,keyword.asc&limit=30` - const expectPayload = { - 'method': 'GET' - } - - mockResolvedValueOnce([]) - - const resp = await searchForKeyword({searchFor}) - - expect(global.fetch).toBeCalledTimes(1) - expect(global.fetch).toBeCalledWith(expectUrl, expectPayload) - }) - - it('returns [] on error/promise.reject', async () => { - const searchFor = 'Test search' - const message = 'This is test' - mockRejectedValueOnce({message}) - - const resp = await searchForKeyword({searchFor}) - - // fetch called - expect(global.fetch).toBeCalledTimes(1) - // returned [] on error - expect(resp).toEqual([]) - // and called logger to log error - expect(global.console.error).toBeCalledTimes(1) - expect(global.console.error).toBeCalledWith(`[ERROR] searchForKeyword: ${message}`) - }) - -}) - -describe('searchForProgrammingLanguage', () => { - - it('calls fetch with proper params', async () => { - const searchFor = 'Test search' - const expectUrl = `/api/v1/rpc/prog_lang_cnt_for_software?prog_lang=ilike.*${encodeURIComponent(searchFor)}*&cnt=gt.0&order=cnt.desc.nullslast,prog_lang.asc&limit=30` - const expectPayload = { - 'method': 'GET' - } - - mockResolvedValueOnce([]) - - const resp = await searchForProgrammingLanguage({searchFor}) - - expect(global.fetch).toBeCalledTimes(1) - expect(global.fetch).toBeCalledWith(expectUrl, expectPayload) - }) - - it('returns [] on error/promise.reject', async () => { - const searchFor = 'Test search' - const message = 'This is test' - - mockRejectedValueOnce({message}) - - const resp = await searchForProgrammingLanguage({searchFor}) - - // fetch called - expect(global.fetch).toBeCalledTimes(1) - // returned [] on error - expect(resp).toEqual([]) - // and called logger to log error - expect(global.console.error).toBeCalledTimes(1) - expect(global.console.error).toBeCalledWith(`[ERROR] searchForProgrammingLanguage: ${message}`) - }) - -}) diff --git a/frontend/components/software/filter/softwareFilterApi.ts b/frontend/components/software/filter/softwareFilterApi.ts deleted file mode 100644 index 2d2301a0b..000000000 --- a/frontend/components/software/filter/softwareFilterApi.ts +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import {Keyword} from '~/components/keyword/FindKeyword' -import {getBaseUrl} from '~/utils/fetchHelpers' -import logger from '~/utils/logger' - -// this is always frontend call -export async function searchForKeyword( - {searchFor}: { searchFor: string } -) { - try { - const searchForEncoded = encodeURIComponent(searchFor) - const baseUrl = getBaseUrl() - // GET top 30 matches with count > 0 - const query = `keyword=ilike.*${searchForEncoded}*&cnt=gt.0&order=cnt.desc.nullslast,keyword.asc&limit=30` - const url = `${baseUrl}/rpc/keyword_count_for_software?${query}` - const resp = await fetch(url, { - method: 'GET' - }) - - if (resp.status === 200) { - const json: Keyword[] = await resp.json() - return json - } - - // return extractReturnMessage(resp, project ?? '') - logger(`searchForKeyword: ${resp.status} ${resp.statusText}`, 'warn') - return [] - } catch (e: any) { - logger(`searchForKeyword: ${e?.message}`, 'error') - return [] - } -} - -export type ProgrammingLanguage = { - prog_lang: string - cnt: number -} - -// this is always frontend call -export async function searchForProgrammingLanguage({searchFor}: - { searchFor: string }) { - try { - const searchForEncoded = encodeURIComponent(searchFor) - const baseUrl = getBaseUrl() - // GET top 30 matches WITH count > 0 - const query = `prog_lang=ilike.*${searchForEncoded}*&cnt=gt.0&order=cnt.desc.nullslast,prog_lang.asc&limit=30` - const url = `${baseUrl}/rpc/prog_lang_cnt_for_software?${query}` - const resp = await fetch(url, { - method: 'GET' - }) - if (resp.status === 200) { - const json: ProgrammingLanguage[] = await resp.json() - if (json.length > 0) { - return json - } - return [] - } - // return extractReturnMessage(resp, project ?? '') - logger(`searchForProgrammingLanguage: ${resp.status} ${resp.statusText}`, 'warn') - return [] - } catch (e: any) { - logger(`searchForProgrammingLanguage: ${e?.message}`, 'error') - return [] - } -} - diff --git a/frontend/components/software/overview/SearchSection.tsx b/frontend/components/software/overview/SearchSection.tsx deleted file mode 100644 index 8206bbcf1..000000000 --- a/frontend/components/software/overview/SearchSection.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import ViewListIcon from '@mui/icons-material/ViewList' -import ViewModuleIcon from '@mui/icons-material/ViewModule' -import ViewQuiltIcon from '@mui/icons-material/ViewQuilt' -import ToggleButton from '@mui/material/ToggleButton' -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' -import useMediaQuery from '@mui/material/useMediaQuery' -import Button from '@mui/material/Button' -import FormControl from '@mui/material/FormControl' -import Select from '@mui/material/Select' -import MenuItem from '@mui/material/MenuItem' - -import {rowsPerPageOptions} from '~/config/pagination' -import SearchInput from './SearchInput' -import {setDocumentCookie} from './userSettings' - -export type LayoutType = 'list'|'grid'|'masonry' - -type SearchSectionProps = { - page: number - rows: number - count: number - placeholder: string - layout: LayoutType - search?: string | null - resetFilters: () => void - setModal: (modal: boolean) => void - setView: (view:LayoutType)=>void - handleQueryChange: (key: string, value: string | string[]) => void -} - - -export default function SearchSection({ - search, placeholder, page, rows, count, layout, - handleQueryChange, setView, setModal -}: SearchSectionProps) { - const smallScreen = useMediaQuery('(max-width:640px)') - return ( -
    -
    - handleQueryChange('search', search)} - defaultValue={search ?? ''} - /> - setView(view)} - sx={{ - backgroundColor: 'background.paper', - }} - > - - - - - - - - - - - - - -
    -
    -
    - Page {page ?? 1} of {count} results -
    - {/* Filter button for mobile */} - {smallScreen === true && - - } -
    -
    - ) -} diff --git a/frontend/components/software/overview/SoftwareHighlights.tsx b/frontend/components/software/overview/SoftwareHighlights.tsx index 0cf43cdbd..9a4746f97 100644 --- a/frontend/components/software/overview/SoftwareHighlights.tsx +++ b/frontend/components/software/overview/SoftwareHighlights.tsx @@ -4,7 +4,7 @@ // // SPDX-License-Identifier: Apache-2.0 -import {HighlightsCarousel} from '../highlights/HighlightsCarousel' +import {HighlightsCarousel} from './highlights/HighlightsCarousel' import {SoftwareHighlight} from '~/components/admin/software-highlights/apiSoftwareHighlights' export default function SoftwareHighlights({highlights}: { highlights: SoftwareHighlight[] }) { @@ -17,7 +17,7 @@ export default function SoftwareHighlights({highlights}: { highlights: SoftwareH if (highlights.length===0) return null return ( -
    +
    ) diff --git a/frontend/components/software/overview/SoftwareOverviewContent.tsx b/frontend/components/software/overview/SoftwareOverviewContent.tsx index 8f7f47dd6..145950a70 100644 --- a/frontend/components/software/overview/SoftwareOverviewContent.tsx +++ b/frontend/components/software/overview/SoftwareOverviewContent.tsx @@ -4,11 +4,11 @@ // SPDX-License-Identifier: Apache-2.0 import {SoftwareListItem} from '~/types/SoftwareTypes' -import {LayoutType} from './SearchSection' +import NoContent from '~/components/layout/NoContent' +import {LayoutType} from './search/ViewToggleGroup' +import SoftwareOverviewList from './list/SoftwareOverviewList' import SoftwareOverviewMasonry from './SoftwareOverviewMasonry' import SoftwareOverviewGrid from './SoftwareOverviewGrid' -import SoftwareOverviewList from './SoftwareOverviewList' -import NoContent from '~/components/layout/NoContent' type SoftwareOverviewContentProps = { layout: LayoutType diff --git a/frontend/components/software/overview/SoftwareOverviewGrid.tsx b/frontend/components/software/overview/SoftwareOverviewGrid.tsx index 2b73e4d9d..ff95baa1d 100644 --- a/frontend/components/software/overview/SoftwareOverviewGrid.tsx +++ b/frontend/components/software/overview/SoftwareOverviewGrid.tsx @@ -5,25 +5,17 @@ // SPDX-License-Identifier: Apache-2.0 import {SoftwareListItem} from '~/types/SoftwareTypes' -import SoftwareGridCard from './SoftwareGridCard' -import FlexibleGridSection from '~/components/layout/FlexibleGridSection' +import SoftwareGridCard from './cards/SoftwareGridCard' export default function SoftwareOverviewGrid({software = []}: { software: SoftwareListItem[] }) { - const grid={ - height: '28rem', - minWidth: '18rem', - maxWidth: '1fr' - } - return ( - {software.map((item) => ( ))} - + ) } diff --git a/frontend/components/software/overview/SoftwareOverviewList.tsx b/frontend/components/software/overview/SoftwareOverviewList.tsx deleted file mode 100644 index 1957e23c3..000000000 --- a/frontend/components/software/overview/SoftwareOverviewList.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) -// SPDX-FileCopyrightText: 2023 dv4all -// -// SPDX-License-Identifier: Apache-2.0 - -import Link from 'next/link' -import List from '@mui/material/List' -import Img from 'next/image' -import {SoftwareListItem} from '~/types/SoftwareTypes' -import {getImageUrl} from '~/utils/editImage' -import ContributorIcon from '~/components/icons/ContributorIcon' -import MentionIcon from '~/components/icons/MentionIcon' -import DownloadsIcon from '~/components/icons/DownloadsIcon' -import ImageWithPlaceholder from '~/components/layout/ImageWithPlaceholder' - -export default function SoftwareOverviewList({software = []}: { software: SoftwareListItem[] }) { - return ( -
    - {software.map(item => ( - -
    - {item.image_id ? - {`Cover - : -
    - } -
    -
    -
    - {item.brand_name} -
    -
    - {item.short_statement} -
    -
    - - {/* Indicators */} -
    -
    - - {item.contributor_cnt || 0} -
    -
    - - {item.mention_cnt || 0} -
    - - {/* TODO Add download counts to the cards */} - {(item?.downloads && item?.downloads > 0) && -
    - - 34K -
    - } -
    -
    -
    - - ) - )} -
    - ) -} diff --git a/frontend/components/software/overview/SoftwareOverviewMasonry.tsx b/frontend/components/software/overview/SoftwareOverviewMasonry.tsx index a4539e36e..448bb244f 100644 --- a/frontend/components/software/overview/SoftwareOverviewMasonry.tsx +++ b/frontend/components/software/overview/SoftwareOverviewMasonry.tsx @@ -5,7 +5,7 @@ // SPDX-License-Identifier: Apache-2.0 import {SoftwareListItem} from '~/types/SoftwareTypes' -import SoftwareMasonryCard from './SoftwareMasonryCard' +import SoftwareMasonryCard from './cards/SoftwareMasonryCard' export default function SoftwareOverviewMasonry({software=[]}: { software:SoftwareListItem[]}) { return ( diff --git a/frontend/components/cards/ProgrammingLanguageList.tsx b/frontend/components/software/overview/cards/ProgrammingLanguageList.tsx similarity index 85% rename from frontend/components/cards/ProgrammingLanguageList.tsx rename to frontend/components/software/overview/cards/ProgrammingLanguageList.tsx index 7a62ecb4c..41cf799bb 100644 --- a/frontend/components/cards/ProgrammingLanguageList.tsx +++ b/frontend/components/software/overview/cards/ProgrammingLanguageList.tsx @@ -12,7 +12,7 @@ export default function ProgrammingLanguageList({ prog_lang = [], visibleNumberOfProgLang = 3}: ProgrammingLanguageListProps ) { return ( -
      +
        {// limits the keywords to 'visibleNumberOfProgLang' per software. prog_lang?.slice(0, visibleNumberOfProgLang) .map((lang:string, index: number) => ( @@ -22,7 +22,7 @@ export default function ProgrammingLanguageList({ (prog_lang?.length > 0) && (prog_lang?.length > visibleNumberOfProgLang) && (prog_lang?.length - visibleNumberOfProgLang > 0) - && `+ ${prog_lang?.length - visibleNumberOfProgLang}` + &&
      • {`+ ${prog_lang?.length - visibleNumberOfProgLang}`}
      • }
      ) diff --git a/frontend/components/software/overview/SoftwareGridCard.tsx b/frontend/components/software/overview/cards/SoftwareGridCard.tsx similarity index 84% rename from frontend/components/software/overview/SoftwareGridCard.tsx rename to frontend/components/software/overview/cards/SoftwareGridCard.tsx index 81a7a641c..462d12b56 100644 --- a/frontend/components/software/overview/SoftwareGridCard.tsx +++ b/frontend/components/software/overview/cards/SoftwareGridCard.tsx @@ -8,11 +8,11 @@ import Link from 'next/link' import {SoftwareListItem} from '~/types/SoftwareTypes' import {getImageUrl} from '~/utils/editImage' -import KeywordList from '../../cards/KeywordList' -import ProgrammingLanguageList from '../../cards/ProgrammingLanguageList' -import SoftwareMetrics from '../../cards/SoftwareMetrics' -import CardTitleSubtitle from '../../cards/CardTitleSubtitle' +import KeywordList from '~/components/cards/KeywordList' +import CardTitleSubtitle from '~/components/cards/CardTitleSubtitle' import ImageWithPlaceholder from '~/components/layout/ImageWithPlaceholder' +import ProgrammingLanguageList from './ProgrammingLanguageList' +import SoftwareMetrics from './SoftwareMetrics' type SoftwareCardProps = { item: SoftwareListItem @@ -48,7 +48,7 @@ export default function SoftwareGridCard({item}:SoftwareCardProps){ /> {/* keywords */} -
      +
      {/* Metrics */}
      diff --git a/frontend/components/software/overview/SoftwareMasonryCard.tsx b/frontend/components/software/overview/cards/SoftwareMasonryCard.tsx similarity index 72% rename from frontend/components/software/overview/SoftwareMasonryCard.tsx rename to frontend/components/software/overview/cards/SoftwareMasonryCard.tsx index 97925d19b..06317fe63 100644 --- a/frontend/components/software/overview/SoftwareMasonryCard.tsx +++ b/frontend/components/software/overview/cards/SoftwareMasonryCard.tsx @@ -7,16 +7,19 @@ import Link from 'next/link' import {SoftwareListItem} from '~/types/SoftwareTypes' import {getImageUrl} from '~/utils/editImage' +import useValidateImageSrc from '~/utils/useValidateImageSrc' import KeywordList from '~/components/cards/KeywordList' import CardTitleSubtitle from '~/components/cards/CardTitleSubtitle' -import ProgrammingLanguageList from '~/components/cards/ProgrammingLanguageList' -import SoftwareMetrics from '~/components/cards/SoftwareMetrics' +import ProgrammingLanguageList from './ProgrammingLanguageList' +import SoftwareMetrics from './SoftwareMetrics' type SoftwareCardProps = { item: SoftwareListItem } export default function SoftwareMasonryCard({item}:SoftwareCardProps){ + const imgSrc = getImageUrl(item.image_id ?? null) + const validImg = useValidateImageSrc(imgSrc) const visibleNumberOfKeywords: number = 3 const visibleNumberOfProgLang: number = 3 @@ -28,29 +31,30 @@ export default function SoftwareMasonryCard({item}:SoftwareCardProps){ href={`/software/${item.slug}`} className="hover:text-inherit">
      - {/* Cover image */} + {/* Cover image, show only if valid image link */} { - item.image_id && + validImg && {`Cover } - {/* Card content */}
      - - - -
      + {item.keywords && +
      + +
      + } +
      {/* Languages */}
      -
      diff --git a/frontend/components/software/overview/cards/SoftwareMetrics.tsx b/frontend/components/software/overview/cards/SoftwareMetrics.tsx new file mode 100644 index 000000000..3181bdcf8 --- /dev/null +++ b/frontend/components/software/overview/cards/SoftwareMetrics.tsx @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import Tooltip from '@mui/material/Tooltip' +import ContributorIcon from '~/components/icons/ContributorIcon' +import DownloadsIcon from '~/components/icons/DownloadsIcon' +import MentionIcon from '~/components/icons/MentionIcon' + +type SoftwareMetricsProps = { + contributor_cnt?: number | null + mention_cnt?: number | null + downloads?: number +} + + +export default function SoftwareMetrics({contributor_cnt, mention_cnt, downloads}: SoftwareMetricsProps) { + + function mentionCntMessage() { + if (mention_cnt && mention_cnt === 1) { + return `${mention_cnt} mention` + } + return `${mention_cnt} mentions` + } + + function contributorsMessage() { + if (contributor_cnt && contributor_cnt === 1) { + return `${contributor_cnt} contributor` + } + return `${contributor_cnt} contributors` + } + + + return ( +
      + +
      + + {contributor_cnt || 0} +
      +
      + +
      + + {mention_cnt || 0} +
      +
      + {/* TODO Add download counts to the cards */} + {(downloads && downloads > 0) && +
      + + {downloads} +
      + } +
      + ) +} diff --git a/frontend/components/software/overview/filters/LicensesFilter.tsx b/frontend/components/software/overview/filters/LicensesFilter.tsx index 089336515..eef633989 100644 --- a/frontend/components/software/overview/filters/LicensesFilter.tsx +++ b/frontend/components/software/overview/filters/LicensesFilter.tsx @@ -8,17 +8,18 @@ import {useEffect, useState} from 'react' import Autocomplete from '@mui/material/Autocomplete' import TextField from '@mui/material/TextField' import {LicensesFilterOption} from './softwareFiltersApi' -import FilterTitle from './FilterTitle' -import FilterOption from './FilterOption' +import FilterTitle from '../../../layout/filter/FilterTitle' +import FilterOption from '../../../layout/filter/FilterOption' +import useSoftwareOverviewParams from '../useSoftwareOverviewParams' type LicensesFilterProps = { licenses: string[], licensesList: LicensesFilterOption[], - handleQueryChange: (key: string, value: string | string[]) => void } -export default function LicensesFilter({licenses,licensesList,handleQueryChange}:LicensesFilterProps) { +export default function LicensesFilter({licenses, licensesList}: LicensesFilterProps) { + const {handleQueryChange} = useSoftwareOverviewParams() const [selected, setSelected] = useState([]) useEffect(() => { diff --git a/frontend/components/software/overview/filters/OrderBy.tsx b/frontend/components/software/overview/filters/OrderSoftwareBy.tsx similarity index 85% rename from frontend/components/software/overview/filters/OrderBy.tsx rename to frontend/components/software/overview/filters/OrderSoftwareBy.tsx index e72a629fe..21fb8fc93 100644 --- a/frontend/components/software/overview/filters/OrderBy.tsx +++ b/frontend/components/software/overview/filters/OrderSoftwareBy.tsx @@ -7,6 +7,7 @@ import FormControl from '@mui/material/FormControl' import InputLabel from '@mui/material/InputLabel' import MenuItem from '@mui/material/MenuItem' import Select from '@mui/material/Select' +import useSoftwareOverviewParams from '../useSoftwareOverviewParams' export const softwareOrderOptions = [ {key: 'contributor_cnt', label: 'Contributors', direction:'desc.nullslast'}, @@ -16,10 +17,10 @@ export const softwareOrderOptions = [ type OrderByProps = { orderBy: string - handleQueryChange: (key: string, value: string | string[]) => void } -export default function OrderBy({orderBy,handleQueryChange}:OrderByProps) { +export default function OrderSoftwareBy({orderBy}: OrderByProps) { + const {handleQueryChange} = useSoftwareOverviewParams() return ( void + languagesList: LanguagesFilterOption[] } -export default function ProgrammingLanguagesFilter({prog_lang,languagesList,handleQueryChange}:ProgrammingLanguagesFilterProps) { +export default function ProgrammingLanguagesFilter({prog_lang, languagesList}: ProgrammingLanguagesFilterProps) { + const {handleQueryChange} = useSoftwareOverviewParams() const [selected, setSelected] = useState([]) useEffect(() => { diff --git a/frontend/components/software/overview/filters/FilterModal.tsx b/frontend/components/software/overview/filters/SoftwareFiltersModal.tsx similarity index 88% rename from frontend/components/software/overview/filters/FilterModal.tsx rename to frontend/components/software/overview/filters/SoftwareFiltersModal.tsx index 7038e862f..0ec17d6a5 100644 --- a/frontend/components/software/overview/filters/FilterModal.tsx +++ b/frontend/components/software/overview/filters/SoftwareFiltersModal.tsx @@ -12,7 +12,7 @@ import DialogActions from '@mui/material/DialogActions' import Button from '@mui/material/Button' import {KeywordFilterOption, LanguagesFilterOption, LicensesFilterOption} from './softwareFiltersApi' -type FilterModalProps = { +type SoftwareFiltersModalProps = { open: boolean, keywords?: string[] keywordsList: KeywordFilterOption[], @@ -22,19 +22,16 @@ type FilterModalProps = { licensesList: LicensesFilterOption[], order: string, filterCnt: number, - resetFilters: () => void - handleQueryChange: (key: string, value: string | string[]) => void setModal:(open:boolean)=>void } -export default function FilterModal({ +export default function SoftwareFiltersModal({ open, keywords, keywordsList, prog_lang, languagesList, licenses, licensesList, filterCnt, order, - resetFilters, setModal, - handleQueryChange -}:FilterModalProps) { + setModal +}:SoftwareFiltersModalProps) { const smallScreen = useMediaQuery('(max-width:640px)') return (
      diff --git a/frontend/components/software/overview/filters/KeywordsFilter.tsx b/frontend/components/software/overview/filters/SoftwareKeywordsFilter.tsx similarity index 79% rename from frontend/components/software/overview/filters/KeywordsFilter.tsx rename to frontend/components/software/overview/filters/SoftwareKeywordsFilter.tsx index fd4eb867e..e642b4399 100644 --- a/frontend/components/software/overview/filters/KeywordsFilter.tsx +++ b/frontend/components/software/overview/filters/SoftwareKeywordsFilter.tsx @@ -7,17 +7,23 @@ import {useEffect, useState} from 'react' import Autocomplete from '@mui/material/Autocomplete' import TextField from '@mui/material/TextField' -import {KeywordFilterOption} from './softwareFiltersApi' -import FilterTitle from './FilterTitle' -import FilterOption from './FilterOption' +import FilterTitle from '../../../layout/filter/FilterTitle' +import FilterOption from '../../../layout/filter/FilterOption' +import useSoftwareOverviewParams from '../useSoftwareOverviewParams' -type KeywordsFilterProps = { +export type KeywordFilterOption = { + keyword: string + keyword_cnt: number +} + + +type SoftwareKeywordsFilterProps = { keywords: string[], - keywordsList: KeywordFilterOption[], - handleQueryChange: (key: string, value: string | string[]) => void + keywordsList: KeywordFilterOption[] } -export default function KeywordsFilter({keywords,keywordsList,handleQueryChange}:KeywordsFilterProps) { +export default function SoftwareKeywordsFilter({keywords, keywordsList}: SoftwareKeywordsFilterProps) { + const {handleQueryChange} = useSoftwareOverviewParams() const [selected, setSelected] = useState([]) const [options, setOptions] = useState(keywordsList) diff --git a/frontend/components/software/overview/filters/index.tsx b/frontend/components/software/overview/filters/index.tsx index d7489ea5b..8576e9d75 100644 --- a/frontend/components/software/overview/filters/index.tsx +++ b/frontend/components/software/overview/filters/index.tsx @@ -4,9 +4,10 @@ // // SPDX-License-Identifier: Apache-2.0 -import FilterHeader from './FilterHeader' -import OrderBy from './OrderBy' -import KeywordsFilter from './KeywordsFilter' +import FilterHeader from '~/components/layout/filter/FilterHeader' +import useSoftwareOverviewParams from '../useSoftwareOverviewParams' +import OrderSoftwareBy from './OrderSoftwareBy' +import SoftwareKeywordsFilter from './SoftwareKeywordsFilter' import ProgrammingLanguagesFilter from './ProgrammingLanguagesFilter' import LicensesFilter from './LicensesFilter' import {KeywordFilterOption, LanguagesFilterOption,LicensesFilterOption} from './softwareFiltersApi' @@ -16,7 +17,7 @@ export type LicenseWithCount = { cnt: number; } -type SoftwareFilterPanelProps = { +type SoftwareFilterProps = { keywords: string[] keywordsList: KeywordFilterOption[] languages: string[] @@ -24,9 +25,7 @@ type SoftwareFilterPanelProps = { licenses: string[] licensesList: LicensesFilterOption[] orderBy: string, - filterCnt: number, - resetFilters: () => void - handleQueryChange: (key: string, value: string | string[]) => void + filterCnt: number } export default function SoftwareFilters({ @@ -37,39 +36,41 @@ export default function SoftwareFilters({ licenses, licensesList, filterCnt, - handleQueryChange, - orderBy, - resetFilters -}:SoftwareFilterPanelProps) { + orderBy +}:SoftwareFilterProps) { + const {resetFilters} = useSoftwareOverviewParams() + + function clearDisabled() { + if (filterCnt && filterCnt > 0) return false + if (orderBy) return false + return true + } return ( <> {/* Order by */} - {/* Keywords */} - {/* Programme Languages */} {/* Licenses */} ) diff --git a/frontend/components/software/highlights/HighlightsCard.tsx b/frontend/components/software/overview/highlights/HighlightsCard.tsx similarity index 69% rename from frontend/components/software/highlights/HighlightsCard.tsx rename to frontend/components/software/overview/highlights/HighlightsCard.tsx index ef0823113..c62166bb3 100644 --- a/frontend/components/software/highlights/HighlightsCard.tsx +++ b/frontend/components/software/overview/highlights/HighlightsCard.tsx @@ -9,14 +9,17 @@ import {SoftwareListItem} from '~/types/SoftwareTypes' import {getImageUrl} from '~/utils/editImage' import KeywordList from '~/components/cards/KeywordList' import CardTitleSubtitle from '~/components/cards/CardTitleSubtitle' -import ProgrammingLanguageList from '~/components/cards//ProgrammingLanguageList' -import SoftwareMetrics from '~/components/cards/SoftwareMetrics' +import ProgrammingLanguageList from '~/components/software/overview/cards/ProgrammingLanguageList' +import SoftwareMetrics from '~/components/software/overview/cards/SoftwareMetrics' +import useValidateImageSrc from '~/utils/useValidateImageSrc' type SoftwareCardProps = { item: SoftwareListItem } -export default function HighlightsCard({item}:SoftwareCardProps){ +export default function HighlightsCard({item}: SoftwareCardProps) { + const imgSrc = getImageUrl(item.image_id ?? null) + const validImg = useValidateImageSrc(imgSrc) const visibleNumberOfKeywords: number = 3 const visibleNumberOfProgLang: number = 3 @@ -27,33 +30,33 @@ export default function HighlightsCard({item}:SoftwareCardProps){ key={item.id} href={`/software/${item.slug}`} className="hover:text-inherit"> -
      - {/* Cover image */} +
      + {/* Cover image, show only if valid image link */} { - item.image_id && + validImg && {`Cover } - {/* Card content */} -
      +
      {/* keywords */} -
      +
      -
      +
      {/* Languages */} + {software.map(item => )} + + ) +} diff --git a/frontend/components/software/overview/list/SoftwareOverviewListItem.tsx b/frontend/components/software/overview/list/SoftwareOverviewListItem.tsx new file mode 100644 index 000000000..24d8c9c41 --- /dev/null +++ b/frontend/components/software/overview/list/SoftwareOverviewListItem.tsx @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import Link from 'next/link' + +import {getImageUrl} from '~/utils/editImage' +import {SoftwareListItem} from '~/types/SoftwareTypes' +import useValidateImageSrc from '~/utils/useValidateImageSrc' +import ContributorIcon from '~/components/icons/ContributorIcon' +import MentionIcon from '~/components/icons/MentionIcon' +import DownloadsIcon from '~/components/icons/DownloadsIcon' + +export default function SoftwareOverviewListItem({item}:{item:SoftwareListItem}) { + const imgSrc = getImageUrl(item.image_id ?? null) + const validImg = useValidateImageSrc(imgSrc) + return ( + +
      + {validImg ? + {`Cover + : +
      + } +
      +
      +
      + {item.brand_name} +
      +
      + {item.short_statement} +
      +
      + + {/* Indicators */} +
      +
      + + {item.contributor_cnt || 0} +
      +
      + + {item.mention_cnt || 0} +
      + + {/* TODO Add download counts to the cards */} + {(item?.downloads && item?.downloads > 0) && +
      + + 34K +
      + } +
      +
      +
      + + ) +} diff --git a/frontend/components/software/overview/search/SelectRows.tsx b/frontend/components/software/overview/search/SelectRows.tsx new file mode 100644 index 000000000..0c1b75b01 --- /dev/null +++ b/frontend/components/software/overview/search/SelectRows.tsx @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import FormControl from '@mui/material/FormControl' +import Select from '@mui/material/Select' +import MenuItem from '@mui/material/MenuItem' + +import {setDocumentCookie} from '../userSettings' +import {rowsPerPageOptions} from '~/config/pagination' + +type SelectRowsProps = { + rows: number + handleQueryChange: (key: string, value: string | string[]) => void +} + +export default function SelectRows({rows,handleQueryChange}:SelectRowsProps) { + return ( + + + + ) +} diff --git a/frontend/components/software/overview/search/SoftwareSearchSection.tsx b/frontend/components/software/overview/search/SoftwareSearchSection.tsx new file mode 100644 index 000000000..6acd9f87f --- /dev/null +++ b/frontend/components/software/overview/search/SoftwareSearchSection.tsx @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import useMediaQuery from '@mui/material/useMediaQuery' +import Button from '@mui/material/Button' + +import SearchInput from '~/components/search/SearchInput' +import ViewToggleGroup, {LayoutType} from './ViewToggleGroup' +import SelectRows from './SelectRows' +import useSoftwareOverviewParams from '../useSoftwareOverviewParams' + +// export type LayoutType = 'list'|'grid'|'masonry' + +type SearchSectionProps = { + page: number + rows: number + count: number + placeholder: string + layout: LayoutType + search?: string | null + setModal: (modal: boolean) => void + setView: (view:LayoutType)=>void +} + + +export default function SoftwareSearchSection({ + search, placeholder, page, rows, count, layout, + setView, setModal +}: SearchSectionProps) { + const {handleQueryChange} = useSoftwareOverviewParams() + const smallScreen = useMediaQuery('(max-width:640px)') + + return ( +
      +
      + handleQueryChange('search', search)} + defaultValue={search ?? ''} + /> + + +
      +
      +
      + Page {page ?? 1} of {count} results +
      + {smallScreen === true && + + } +
      +
      + ) +} diff --git a/frontend/components/software/overview/search/ViewToggleGroup.tsx b/frontend/components/software/overview/search/ViewToggleGroup.tsx new file mode 100644 index 000000000..f0450cf6b --- /dev/null +++ b/frontend/components/software/overview/search/ViewToggleGroup.tsx @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import ViewListIcon from '@mui/icons-material/ViewList' +import ViewModuleIcon from '@mui/icons-material/ViewModule' +import ViewQuiltIcon from '@mui/icons-material/ViewQuilt' +import ToggleButton from '@mui/material/ToggleButton' +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' + +export type LayoutType = 'list'|'grid'|'masonry' + +type ViewToggleGroupProps = { + layout: LayoutType + onSetView: (view:LayoutType)=>void +} + +export default function ViewToggleGroup({layout,onSetView}:ViewToggleGroupProps) { + return ( + onSetView(view)} + sx={{ + backgroundColor: 'background.paper', + }} + > + + + + + + + + + + + ) +} diff --git a/frontend/components/projects/ProjectCard.tsx b/frontend/components/user/project/ProjectCard.tsx similarity index 93% rename from frontend/components/projects/ProjectCard.tsx rename to frontend/components/user/project/ProjectCard.tsx index 3fb950433..88debf2fc 100644 --- a/frontend/components/projects/ProjectCard.tsx +++ b/frontend/components/user/project/ProjectCard.tsx @@ -5,12 +5,12 @@ import Link from 'next/link' -import {getTimeAgoSince} from '../../utils/dateFn' -import ImageAsBackground from '../layout/ImageAsBackground' -import {getImageUrl} from '../../utils/editImage' +import {getTimeAgoSince} from '../../../utils/dateFn' +import ImageAsBackground from '../../layout/ImageAsBackground' +import {getImageUrl} from '../../../utils/editImage' import FeaturedIcon from '~/components/icons/FeaturedIcon' import NotPublishedIcon from '~/components/icons/NotPublishedIcon' -import CardTitle from '../layout/CardTitle' +import CardTitle from '../../layout/CardTitle' export type ProjectCardProps = { slug: string, diff --git a/frontend/components/projects/ProjectsGrid.tsx b/frontend/components/user/project/ProjectsGrid.tsx similarity index 73% rename from frontend/components/projects/ProjectsGrid.tsx rename to frontend/components/user/project/ProjectsGrid.tsx index eaf87df72..d169406a1 100644 --- a/frontend/components/projects/ProjectsGrid.tsx +++ b/frontend/components/user/project/ProjectsGrid.tsx @@ -1,10 +1,10 @@ -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2022 dv4all +// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2022 - 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 -import FlexibleGridSection, {FlexGridProps} from '../layout/FlexibleGridSection' -import NoContent from '../layout/NoContent' +import FlexibleGridSection, {FlexGridProps} from '~/components/layout/FlexibleGridSection' +import NoContent from '~/components/layout/NoContent' import ProjectCard, {ProjectCardProps} from './ProjectCard' type ProjectGridProps = FlexGridProps & { diff --git a/frontend/components/user/project/index.tsx b/frontend/components/user/project/index.tsx index ac9144287..05aaa4d85 100644 --- a/frontend/components/user/project/index.tsx +++ b/frontend/components/user/project/index.tsx @@ -1,5 +1,5 @@ +// SPDX-FileCopyrightText: 2022 - 2023 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 - 2023 dv4all -// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all) // SPDX-FileCopyrightText: 2022 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences // SPDX-FileCopyrightText: 2022 Matthias Rüster (GFZ) // SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) (dv4all) @@ -9,12 +9,11 @@ import {useEffect} from 'react' import {Session} from '~/auth' -import ProjectsGrid from '~/components/projects/ProjectsGrid' import usePaginationWithSearch from '~/utils/usePaginationWithSearch' - -import useUserProjects from './useUserProjects' import {useAdvicedDimensions} from '~/components/layout/FlexibleGridSection' import ContentLoader from '~/components/layout/ContentLoader' +import ProjectsGrid from './ProjectsGrid' +import useUserProjects from './useUserProjects' export default function UserProjects({session}: { session: Session }) { const {itemHeight, minWidth, maxWidth} = useAdvicedDimensions() diff --git a/frontend/components/software/SoftwareCard.tsx b/frontend/components/user/software/SoftwareCard.tsx similarity index 98% rename from frontend/components/software/SoftwareCard.tsx rename to frontend/components/user/software/SoftwareCard.tsx index 845a2d558..dacd3582d 100644 --- a/frontend/components/software/SoftwareCard.tsx +++ b/frontend/components/user/software/SoftwareCard.tsx @@ -9,7 +9,7 @@ import Tooltip from '@mui/material/Tooltip' import PeopleAltOutlinedIcon from '@mui/icons-material/PeopleAltOutlined' import InsertCommentOutlinedIcon from '@mui/icons-material/InsertCommentOutlined' -import {getTimeAgoSince} from '../../utils/dateFn' +import {getTimeAgoSince} from '~/utils/dateFn' import FeaturedIcon from '~/components/icons/FeaturedIcon' import NotPublishedIcon from '~/components/icons/NotPublishedIcon' import CardTitle from '~/components/layout/CardTitle' diff --git a/frontend/components/software/SoftwareGrid.tsx b/frontend/components/user/software/SoftwareGrid.tsx similarity index 83% rename from frontend/components/software/SoftwareGrid.tsx rename to frontend/components/user/software/SoftwareGrid.tsx index 586c7a827..8c6c79395 100644 --- a/frontend/components/software/SoftwareGrid.tsx +++ b/frontend/components/user/software/SoftwareGrid.tsx @@ -1,11 +1,11 @@ -// SPDX-FileCopyrightText: 2021 - 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2021 - 2022 dv4all +// SPDX-FileCopyrightText: 2021 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2021 - 2023 dv4all // // SPDX-License-Identifier: Apache-2.0 +import FlexibleGridSection, {FlexGridProps} from '~/components/layout/FlexibleGridSection' +import NoContent from '~/components/layout/NoContent' import SoftwareCard from './SoftwareCard' -import FlexibleGridSection, {FlexGridProps} from '../layout/FlexibleGridSection' -import NoContent from '../layout/NoContent' export type SoftwareGridType = { slug: string diff --git a/frontend/components/user/software/index.tsx b/frontend/components/user/software/index.tsx index 62f8ad0cd..bec5d6508 100644 --- a/frontend/components/user/software/index.tsx +++ b/frontend/components/user/software/index.tsx @@ -7,11 +7,11 @@ import {useEffect} from 'react' import {Session} from '~/auth' -import SoftwareGrid from '~/components/software/SoftwareGrid' import usePaginationWithSearch from '~/utils/usePaginationWithSearch' -import useUserSoftware from './useUserSoftware' import {useAdvicedDimensions} from '~/components/layout/FlexibleGridSection' import ContentLoader from '~/components/layout/ContentLoader' +import useUserSoftware from './useUserSoftware' +import SoftwareGrid from './SoftwareGrid' export default function UserSoftware({session}: { session: Session }) { const {itemHeight, minWidth, maxWidth} = useAdvicedDimensions('software') diff --git a/frontend/pages/projects/index.tsx b/frontend/pages/projects/index.tsx index 513295272..2fc75df0e 100644 --- a/frontend/pages/projects/index.tsx +++ b/frontend/pages/projects/index.tsx @@ -3,173 +3,238 @@ // // SPDX-License-Identifier: Apache-2.0 -import {MouseEvent, ChangeEvent} from 'react' +import {useEffect, useState} from 'react' import {GetServerSidePropsContext} from 'next' -import {useRouter} from 'next/router' -import TablePagination from '@mui/material/TablePagination' import Pagination from '@mui/material/Pagination' +import useMediaQuery from '@mui/material/useMediaQuery' import {app} from '~/config/app' -import {rowsPerPageOptions} from '~/config/pagination' -import {ProjectSearchRpc} from '~/types/Project' +import {ProjectListItem} from '~/types/Project' import {getProjectList} from '~/utils/getProjects' import {ssrProjectsParams} from '~/utils/extractQueryParam' -import {projectListUrl, ssrProjectsUrl} from '~/utils/postgrestUrl' +import {projectListUrl} from '~/utils/postgrestUrl' import {getBaseUrl} from '~/utils/fetchHelpers' -import Searchbox from '~/components/form/Searchbox' -import DefaultLayout from '~/components/layout/DefaultLayout' -import PageTitle from '~/components/layout/PageTitle' -import ProjectsGrid from '~/components/projects/ProjectsGrid' -import ProjectFilter from '~/components/projects/filter' -import {getResearchDomainInfo, ResearchDomain} from '~/components/projects/filter/projectFilterApi' -import {useAdvicedDimensions} from '~/components/layout/FlexibleGridSection' +import AppHeader from '~/components/AppHeader' +import AppFooter from '~/components/AppFooter' +import MainContent from '~/components/layout/MainContent' import PageMeta from '~/components/seo/PageMeta' import CanonicalUrl from '~/components/seo/CanonicalUrl' import {getUserSettings, setDocumentCookie} from '~/components/software/overview/userSettings' import useProjectOverviewParams from '~/components/projects/overview/useProjectOverviewParams' +import OverviewPageBackground from '~/components/software/overview/PageBackground' +import FiltersPanel from '~/components/layout/filter/FiltersPanel' +import {KeywordFilterOption} from '~/components/software/overview/filters/softwareFiltersApi' +import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup' +import {ProjectLayoutType} from '~/components/projects/overview/search/ViewToggleGroup' +import { + projectDomainsFilter, + projectKeywordsFilter, + projectParticipatingOrganisationsFilter +} from '~/components/projects/overview/filters/projectFiltersApi' +import {projectOrderOptions} from '~/components/projects/overview/filters/OrderProjectsBy' +import {ResearchDomainOption} from '~/components/projects/overview/filters/ResearchDomainFilter' +import {OrganisationOption} from '~/components/projects/overview/filters/OrganisationFilter' +import ProjectFilters from '~/components/projects/overview/filters/ProjectFilters' +import ProjectSearchSection from '~/components/projects/overview/search/ProjectSearchSection' +import ProjectOverviewContent from '~/components/projects/overview/ProjectOverviewContent' +import ProjectFiltersModal from '~/components/projects/overview/filters/ProjectFiltersModal' -type ProjectsIndexPageProps = { - count: number, +export type ProjectOverviewPageProps = { + search?: string | null + order?: string | null, + keywords?: string[] | null, + keywordsList: KeywordFilterOption[], + domains?: string[] | null + domainsList: ResearchDomainOption[] + organisations: string[] | null + organisationsList: OrganisationOption[] page: number, rows: number, - projects: ProjectSearchRpc[], - search?: string, - keywords?: string[], - domains?: ResearchDomain[] + count: number, + layout: LayoutType, + projects: ProjectListItem[] } const pageTitle = `Projects | ${app.title}` const pageDesc = 'The list of research projects registerd in the Research Software Directory.' -export default function ProjectsIndexPage( - {projects=[], count, page, rows, search, keywords,domains}: ProjectsIndexPageProps -) { - // use next router (hook is only for browser) - const router = useRouter() - const {itemHeight, minWidth, maxWidth} = useAdvicedDimensions() +export default function ProjectsOverviewPage({ + search, order, + keywords, keywordsList, + domains, domainsList, + organisations, organisationsList, + page, rows, count, layout, + projects +}: ProjectOverviewPageProps) { const {handleQueryChange} = useProjectOverviewParams() + const smallScreen = useMediaQuery('(max-width:640px)') + const [view, setView] = useState('grid') + const [modal,setModal] = useState(false) const numPages = Math.ceil(count / rows) + const filterCnt = getFilterCount() - // console.group('ProjectsIndexPage') - // console.log('query...', router.query) + // console.group('ProjectsOverviewPage') + // console.log('search...', search) + // console.log('keywords...', keywords) + // console.log('domains...', domains) + // console.log('organisations...', organisations) + // console.log('order...', order) + // console.log('page...', page) + // console.log('rows...', rows) + // console.log('count...', count) + // console.log('layout...', layout) + // console.log('keywordsList...', keywordsList) + // console.log('domainsList...', domainsList) + // console.log('organisationsList...', organisationsList) + // console.log('projects...', projects) // console.groupEnd() - function handleTablePageChange( - _: MouseEvent | null, - newPage: number, - ) { - // Pagination component starts counting from 0, but we need to start from 1 - handleQueryChange('page',(newPage + 1).toString()) - } + // Update view state based on layout value + useEffect(() => { + if (layout) { + if (layout === 'masonry') { + setView('grid') + } else { + setView(layout) + } + } + },[layout]) - function handleChangeRowsPerPage( - event: ChangeEvent, - ) { - handleQueryChange('rows',event.target.value) + function setLayout(view: ProjectLayoutType) { + // update local view + setView(view) // save to cookie - setDocumentCookie(event.target.value,'rsd_page_rows') - } - - function handleSearch(searchFor: string) { - handleQueryChange('search',searchFor) + setDocumentCookie(view,'rsd_page_layout') } - function handleFilters({keywords,domains}:{keywords: string[],domains:string[]}){ - const url = ssrProjectsUrl({ - // take existing params from url (query) - ...ssrProjectsParams(router.query), - keywords, - domains, - // start from first page - page: 1, - }) - router.push(url) + function getFilterCount() { + let count = 0 + if (keywords) count++ + if (domains) count++ + if (organisations) count++ + if (search) count++ + return count } return ( - + <> {/* Page Head meta tags */} - - -
      -
      - - + + + {/* App header */} + + + {/* Page title */} +

      + All projects +

      + {/* Page grid with 2 sections: left filter panel and main content */} +
      + {/* Filters panel large screen */} + {smallScreen===false && + + + + } + {/* Search & main content section */} +
      + + {/* Project content: masonry, cards or list */} + + {/* Pagination */} + {numPages > 1 && +
      + { + handleQueryChange('page',page.toString()) + }} + /> +
      + } +
      - -
      - - - - - {numPages > 1 && -
      - { - handleQueryChange('page',page.toString()) - }} - /> -
      + + + + {/* filter for mobile */} + { + smallScreen===true && + } - + ) } // fetching data server side // see documentation https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering export async function getServerSideProps(context: GetServerSidePropsContext) { - let offset=0 + let orderBy, offset=0 // extract from page-query - const {search, rows, page, keywords, domains} = ssrProjectsParams(context.query) + const {search, rows, page, keywords, domains, organisations, order} = ssrProjectsParams(context.query) // extract user settings from cookie - const {rsd_page_rows} = getUserSettings(context.req) + const {rsd_page_layout, rsd_page_rows} = getUserSettings(context.req) // use url param if present else user settings let page_rows = rows ?? rsd_page_rows // calculate offset when page & rows present if (page_rows && page) { offset = page_rows * (page - 1) } + if (order) { + // extract order direction from definitions + const orderInfo = projectOrderOptions.find(item=>item.key===order) + if (orderInfo) orderBy=`${order}.${orderInfo.direction}` + } const url = projectListUrl({ baseUrl: getBaseUrl(), search, keywords, domains, - // when search is used the order is already applied in the rpc - order: search ? undefined : 'current_state.desc,date_start.desc,title', + organisations, + order: orderBy, limit: page_rows, offset }) @@ -185,20 +250,33 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // when token is passed it will return not published items too // 2. domains filter uses key values for filtering but // we also need labels to show in filter chips - const [projects, domainsInfo] = await Promise.all([ + const [ + projects, + keywordsList, + domainsList, + organisationsList + ] = await Promise.all([ getProjectList({url}), - getResearchDomainInfo(domains) + projectKeywordsFilter({search,keywords,domains,organisations}), + projectDomainsFilter({search, keywords, domains, organisations}), + projectParticipatingOrganisationsFilter({search, keywords, domains, organisations}), ]) return { // pass this to page component as props props: { search, + order, keywords, - domains: domainsInfo, + keywordsList, + domains, + domainsList, + organisations, + organisationsList, count: projects.count, page, rows: page_rows, + layout: rsd_page_layout, projects: projects.data, }, } diff --git a/frontend/pages/software/index.tsx b/frontend/pages/software/index.tsx index e96466225..d5f1121a0 100644 --- a/frontend/pages/software/index.tsx +++ b/frontend/pages/software/index.tsx @@ -15,18 +15,19 @@ import {getSoftwareList} from '~/utils/getSoftware' import {ssrSoftwareParams} from '~/utils/extractQueryParam' import {SoftwareListItem} from '~/types/SoftwareTypes' import MainContent from '~/components/layout/MainContent' +import FiltersPanel from '~/components/layout/filter/FiltersPanel' import AppHeader from '~/components/AppHeader' import AppFooter from '~/components/AppFooter' import PageMeta from '~/components/seo/PageMeta' import CanonicalUrl from '~/components/seo/CanonicalUrl' + import { SoftwareHighlight, getSoftwareHighlights } from '~/components/admin/software-highlights/apiSoftwareHighlights' -import SoftwareFiltersPanel from '~/components/software/overview/SoftwareFiltersPanel' import SoftwareHighlights from '~/components/software/overview/SoftwareHighlights' import OverviewPageBackground from '~/components/software/overview/PageBackground' -import SearchSection, {LayoutType} from '~/components/software/overview/SearchSection' +import SoftwareSearchSection from '~/components/software/overview/search/SoftwareSearchSection' import useSoftwareOverviewParams from '~/components/software/overview/useSoftwareOverviewParams' import SoftwareOverviewContent from '~/components/software/overview/SoftwareOverviewContent' import SoftwareFilters from '~/components/software/overview/filters/index' @@ -35,9 +36,11 @@ import { softwareKeywordsFilter, softwareLanguagesFilter, softwareLicesesFilter } from '~/components/software/overview/filters/softwareFiltersApi' -import FilterModal from '~/components/software/overview/filters/FilterModal' +import SoftwareFiltersModal from '~/components/software/overview/filters/SoftwareFiltersModal' import {getUserSettings, setDocumentCookie} from '~/components/software/overview/userSettings' -import {softwareOrderOptions} from '~/components/software/overview/filters/OrderBy' +import {softwareOrderOptions} from '~/components/software/overview/filters/OrderSoftwareBy' +import {LayoutType} from '~/components/software/overview/search/ViewToggleGroup' + type SoftwareOverviewProps = { search?: string | null @@ -69,7 +72,7 @@ export default function SoftwareOverviewPage({ }: SoftwareOverviewProps) { const [view, setView] = useState('masonry') const smallScreen = useMediaQuery('(max-width:640px)') - const {handleQueryChange, resetFilters} = useSoftwareOverviewParams() + const {handleQueryChange} = useSoftwareOverviewParams() const [modal,setModal] = useState(false) const numPages = Math.ceil(count / rows) @@ -92,7 +95,7 @@ export default function SoftwareOverviewPage({ // console.log('highlights...', highlights) // console.groupEnd() - // Update view state based on layout value from cookie + // Update view state based on layout value useEffect(() => { if (layout) { setView(layout) @@ -134,7 +137,7 @@ export default function SoftwareOverviewPage({ {/* Page title */}

      @@ -144,7 +147,7 @@ export default function SoftwareOverviewPage({
      {/* Filters panel large screen */} {smallScreen===false && - + - + } {/* Search & main content section */}
      - - {/* Software content: cards or list */} + {/* Software content: masonry, cards or list */} } diff --git a/frontend/public/data/settings.json b/frontend/public/data/settings.json index 31262642f..1985eec1d 100644 --- a/frontend/public/data/settings.json +++ b/frontend/public/data/settings.json @@ -41,7 +41,7 @@ "light": { "colors": { "base-100": "#fff", - "base-200": "#eeeeee", + "base-200": "#f5f5f7", "base-300": "#dcdcdc", "base-400": "#bdbdbd", "base-500": "#9e9e9e", diff --git a/frontend/styles/rsdMuiTheme.ts b/frontend/styles/rsdMuiTheme.ts index fc01c5b3d..3f80cc10b 100644 --- a/frontend/styles/rsdMuiTheme.ts +++ b/frontend/styles/rsdMuiTheme.ts @@ -1,5 +1,5 @@ -// SPDX-FileCopyrightText: 2021 - 2022 Dusan Mijatovic (dv4all) -// SPDX-FileCopyrightText: 2021 - 2022 dv4all +// SPDX-FileCopyrightText: 2021 - 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2021 - 2023 dv4all // SPDX-FileCopyrightText: 2022 Jesús García Gonzalez (Netherlands eScience Center) // SPDX-FileCopyrightText: 2022 Netherlands eScience Center // @@ -271,6 +271,14 @@ function applyThemeConfig({colors, action, typography}: ThemeConfig) { } } } + }, + MuiAvatar: { + styleOverrides: { + colorDefault: { + color: colors['primary-content'], + backgroundColor: colors['base-600'] + } + } } }, }) diff --git a/frontend/types/Project.ts b/frontend/types/Project.ts index a823b5bf2..94a4c66bd 100644 --- a/frontend/types/Project.ts +++ b/frontend/types/Project.ts @@ -40,17 +40,22 @@ export type EditProject = Project & { } export type CurrentState = 'Starting' | 'Running' | 'Finished' -export type ProjectSearchRpc = { +export type ProjectListItem = { id: string slug: string title: string subtitle: string - current_state: CurrentState date_start: string | null + date_end: string | null updated_at: string | null is_published: boolean image_id: string | null + image_contain: boolean keywords: string[] + research_domain: string[] + participating_organisations: string[] + impact_cnt: number | null + output_cnt: number | null } // object returned from api diff --git a/frontend/utils/extractQueryParam.test.ts b/frontend/utils/extractQueryParam.test.ts index ab013b807..2d7074880 100644 --- a/frontend/utils/extractQueryParam.test.ts +++ b/frontend/utils/extractQueryParam.test.ts @@ -106,6 +106,8 @@ it('extracts ssrProjectsParams from url query', () => { 'search': 'testing search', 'keywords': '["Big data","GPU"]', 'domains': '["SH6","LS"]', + 'organisations':'["Organisation 1","Organisation 2"]', + 'order': 'impact_cnt', 'page': '1', 'rows': '24' } @@ -113,6 +115,8 @@ it('extracts ssrProjectsParams from url query', () => { search: 'testing search', keywords: ['Big data', 'GPU'], domains: ['SH6', 'LS'], + organisations: ['Organisation 1','Organisation 2'], + order: 'impact_cnt', page: 1, rows: 24 } diff --git a/frontend/utils/extractQueryParam.ts b/frontend/utils/extractQueryParam.ts index 879ae7145..818c68313 100644 --- a/frontend/utils/extractQueryParam.ts +++ b/frontend/utils/extractQueryParam.ts @@ -137,12 +137,26 @@ export function ssrProjectsParams(query: ParsedUrlQuery) { castToType: 'json-encoded', defaultValue: null }) + const organisations = extractQueryParam({ + query, + param: 'organisations', + castToType: 'json-encoded', + defaultValue: null + }) + const order: string = extractQueryParam({ + query, + param: 'order', + castToType: 'string', + defaultValue: null + }) return { search, + order, rows, page, keywords, - domains + domains, + organisations } } diff --git a/frontend/utils/getProjects.ts b/frontend/utils/getProjects.ts index bdd09bec1..6226346ef 100644 --- a/frontend/utils/getProjects.ts +++ b/frontend/utils/getProjects.ts @@ -9,7 +9,7 @@ import {mentionColumns, MentionForProject, MentionItemProps} from '~/types/Menti import { KeywordForProject, OrganisationsOfProject, Project, - ProjectLink, ProjectSearchRpc, RelatedProjectForProject, + ProjectLink, ProjectListItem, RelatedProjectForProject, ResearchDomain, SearchProject, TeamMember } from '~/types/Project' import {RelatedSoftwareOfProject} from '~/types/SoftwareTypes' @@ -29,7 +29,7 @@ export async function getProjectList({url, token}: { url: string, token?: string }) if ([200, 206].includes(resp.status)) { - const json: ProjectSearchRpc[] = await resp.json() + const json: ProjectListItem[] = await resp.json() // set return { count: extractCountFromHeader(resp.headers), diff --git a/frontend/utils/postgrestUrl.ts b/frontend/utils/postgrestUrl.ts index f2efdb729..9019e414a 100644 --- a/frontend/utils/postgrestUrl.ts +++ b/frontend/utils/postgrestUrl.ts @@ -25,6 +25,7 @@ type baseQueryStringProps = { domains?: string[] | null, prog_lang?: string[] | null, licenses?: string[] | null, + organisations?: string[] | null, order?: string, limit?: number, offset?: number @@ -42,6 +43,7 @@ export type QueryParams={ domains?:string[], prog_lang?: string[], licenses?: string[], + organisations?: string[], page?:number, rows?:number } @@ -101,7 +103,11 @@ function buildUrlQuery({query, param, value}: BuildUrlQueryProps) { export function buildFilterUrl(params: QueryParams, view:string) { - const {search,order, keywords, domains, licenses, prog_lang, rows, page} = params + const { + search, order, keywords, domains, + licenses, prog_lang, organisations, + rows, page + } = params // console.log('buildFilterUrl...params...', params) let url = `/${view}?` let query = '' @@ -136,6 +142,12 @@ export function buildFilterUrl(params: QueryParams, view:string) { param: 'licenses', value: licenses }) + // organisations + query = buildUrlQuery({ + query, + param: 'organisations', + value: organisations + }) // sortBy query = buildUrlQuery({ query, @@ -183,7 +195,7 @@ export function paginationUrlParams({rows=12, page=0}: * @returns string */ export function baseQueryString(props: baseQueryStringProps) { - const {keywords, domains, prog_lang,licenses,order,limit,offset} = props + const {keywords,domains,prog_lang,licenses,organisations,order,limit,offset} = props let query // console.group('baseQueryString') // console.log('keywords...', keywords) @@ -234,12 +246,10 @@ export function baseQueryString(props: baseQueryStringProps) { query = `prog_lang=cs.%7B${languagesAll}%7D` } } - // if (typeof licenses !== 'undefined' && licenses !== null && typeof licenses === 'object') { - // sort and convert research domains array to comma separated string - // we need to sort because search is on ARRAY field in pgSql + // sort and convert array to comma separated string const licensesAll = licenses.sort().map((item: string) => `"${encodeURIComponent(item)}"`).join(',') // use cs. command to find if (query) { @@ -248,6 +258,19 @@ export function baseQueryString(props: baseQueryStringProps) { query = `licenses=cs.%7B${licensesAll}%7D` } } + if (typeof organisations !== 'undefined' && + organisations !== null && + typeof organisations === 'object') { + // sort and convert array to comma separated string + // we need to sort because search is on ARRAY field in pgSql + const organisationsAll = organisations.sort().map((item: string) => `"${encodeURIComponent(item)}"`).join(',') + // use cs. command to find + if (query) { + query = `${query}&participating_organisations=cs.%7B${organisationsAll}%7D` + } else { + query = `participating_organisations=cs.%7B${organisationsAll}%7D` + } + } // order if (order) { if (query) { diff --git a/frontend/utils/useValidateImageSrc.ts b/frontend/utils/useValidateImageSrc.ts new file mode 100644 index 000000000..f3b82d93f --- /dev/null +++ b/frontend/utils/useValidateImageSrc.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 Dusan Mijatovic (dv4all) +// SPDX-FileCopyrightText: 2023 dv4all +// +// SPDX-License-Identifier: Apache-2.0 + +import {useEffect, useState} from 'react' + + +export default function useValidateImageSrc(src?: string|null) { + const [valid, setValid] = useState() + + useEffect(() => { + let abort = false + + if (typeof src === 'string') { + const image = new Image() + // listen for events + image.onload = (props) => { + // console.log('useValidImageLink...onload...',props) + if (abort) return + setValid(true) + } + image.onerror = ((props) => { + // console.log('useValidImageLink...onerror...', props) + if (abort) return + setValid(false) + }) + // assign value + image.src = src + } else { + setValid(false) + } + return () => { abort = true } + }, [src]) + + return valid +}