diff --git a/app-catalog/.gitignore b/app-catalog/.gitignore index 4d29575..6f9dae1 100644 --- a/app-catalog/.gitignore +++ b/app-catalog/.gitignore @@ -5,6 +5,8 @@ /.pnp .pnp.js +/dist + # testing /coverage diff --git a/app-catalog/src/api/charts.tsx b/app-catalog/src/api/charts.tsx index 171cef3..94f5cef 100644 --- a/app-catalog/src/api/charts.tsx +++ b/app-catalog/src/api/charts.tsx @@ -1,23 +1,32 @@ import { PAGE_OFFSET_COUNT_FOR_CHARTS } from '../components/charts/List'; -export function fetchChartsFromArtifact( +export async function fetchChartsFromArtifact( search: string = '', + verified: boolean, category: { title: string; value: number }, page: number, limit: number = PAGE_OFFSET_COUNT_FOR_CHARTS ) { - if (!category || category.value === 0) { - return fetch( - `https://artifacthub.io/api/v1/packages/search?kind=0&ts_query_web=${search}&sort=relevance&facets=true&limit=${limit}&offset=${ - (page - 1) * limit - }` - ).then(response => response.json()); + // note: we are currently defaulting to search by verified and official as default + const url = new URL('https://artifacthub.io/api/v1/packages/search'); + url.searchParams.set('offset', ((page - 1) * limit).toString()); + url.searchParams.set('limit', limit.toString()); + url.searchParams.set('facets', 'true'); + url.searchParams.set('kind', '0'); + url.searchParams.set('ts_query_web', search); + if (category.value) { + url.searchParams.set('category', category.value.toString()); } - return fetch( - `https://artifacthub.io/api/v1/packages/search?kind=0&ts_query_web=${search}&category=${ - category.value - }&sort=relevance&facets=true&limit=${limit}&offset=${(page - 1) * limit}` - ).then(response => response.json()); + url.searchParams.set('sort', 'relevance'); + url.searchParams.set('deprecated', 'false'); + url.searchParams.set('verified_publisher', verified.toString()); + + const response = await fetch(url.toString()); + const total = response.headers.get('pagination-total-count'); + + const dataResponse = await response.json(); + + return { dataResponse, total }; } export function fetchChartDetailFromArtifact(chartName: string, repoName: string) { diff --git a/app-catalog/src/components/charts/AppCatalogTitle.stories.tsx b/app-catalog/src/components/charts/AppCatalogTitle.stories.tsx new file mode 100644 index 0000000..889bd92 --- /dev/null +++ b/app-catalog/src/components/charts/AppCatalogTitle.stories.tsx @@ -0,0 +1,21 @@ +import { Meta, Story } from '@storybook/react'; +import React from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { AppCatalogTitle } from './AppCatalogTitle'; + +export default { + title: 'Components/AppCatalogTitle', + component: AppCatalogTitle, + decorators: [ + Story => ( + + + + ), + ], +} as Meta; + +const Template: Story = args => ; + +export const Title = Template.bind({}); +Title.args = {}; diff --git a/app-catalog/src/components/charts/AppCatalogTitle.tsx b/app-catalog/src/components/charts/AppCatalogTitle.tsx new file mode 100644 index 0000000..7109825 --- /dev/null +++ b/app-catalog/src/components/charts/AppCatalogTitle.tsx @@ -0,0 +1,17 @@ +import { Box, Typography } from '@mui/material'; +import { SettingsLink } from './SettingsLink'; + +export function AppCatalogTitle() { + return ( + + + Applications + + + + ); +} diff --git a/app-catalog/src/components/charts/ChartsList.stories.tsx b/app-catalog/src/components/charts/ChartsList.stories.tsx index 435e471..fd46863 100644 --- a/app-catalog/src/components/charts/ChartsList.stories.tsx +++ b/app-catalog/src/components/charts/ChartsList.stories.tsx @@ -32,27 +32,62 @@ const mockCharts = [ url: 'https://example2.com', }, }, + { + name: 'MockChart3', + version: '1.0', + description: 'This is a mock chart description.', + logo_image_id: 'zzzzz-3fce-4b63-bbf3-b9f649512a86', + repository: { + name: 'MockRepoy', + url: 'https://exampley.com', + verified_publisher: true, + }, + }, + { + name: 'MockChart4', + version: '1.1', + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor.', + logo_image_id: 'zzzzz-28b3-4ee8-98a2-30e00abf9f02', + repository: { + name: 'MockRepo2y', + url: 'https://example2y.com', + verified_publisher: true, + }, + }, ]; -const initialState = { +const initialStateTrue = { config: { + showOnlyVerified: true, settings: { tableRowsPerPageOptions: [15, 25, 50], }, }, }; -const mockStore = configureStore({ - reducer: (state = initialState) => state, -}); +const initialStateFalse = { + config: { + showOnlyVerified: false, + settings: { + tableRowsPerPageOptions: [15, 25, 50], + }, + }, +}; + +const Template: Story = ({ initialState, ...args }) => { + const mockStore = configureStore({ + reducer: (state = initialState) => state, + }); -const Template: Story = args => ( - - - - - -); + return ( + + + + + + ); +}; export const EmptyCharts = Template.bind({}); EmptyCharts.args = { @@ -81,3 +116,33 @@ SomeCharts.args = { ], }), }; + +export const WithShowOnlyVerifiedTrue = Template.bind({}); +WithShowOnlyVerifiedTrue.args = { + initialState: initialStateTrue, + fetchCharts: () => + Promise.resolve({ + packages: mockCharts, + facets: [ + { + title: 'Category', + options: [{ name: 'All', total: 0 }], + }, + ], + }), +}; + +export const WithShowOnlyVerifiedFalse = Template.bind({}); +WithShowOnlyVerifiedFalse.args = { + initialState: initialStateFalse, + fetchCharts: () => + Promise.resolve({ + packages: mockCharts, + facets: [ + { + title: 'Category', + options: [{ name: 'All', total: 0 }], + }, + ], + }), +}; diff --git a/app-catalog/src/components/charts/List.tsx b/app-catalog/src/components/charts/List.tsx index c02eb1a..b9cf36c 100644 --- a/app-catalog/src/components/charts/List.tsx +++ b/app-catalog/src/components/charts/List.tsx @@ -1,4 +1,5 @@ import { Icon } from '@iconify/react'; +import { ConfigStore } from '@kinvolk/headlamp-plugin/lib'; import { Link as RouterLink, Loader, @@ -21,10 +22,20 @@ import { Autocomplete, Pagination } from '@mui/material'; import { useEffect, useState } from 'react'; //import { jsonToYAML, yamlToJSON } from '../../helpers'; import { fetchChartsFromArtifact } from '../../api/charts'; -import CNCFLight from './cncf-icon-color.svg'; +import { AppCatalogTitle } from './AppCatalogTitle'; //import { createRelease } from '../../api/releases'; import { EditorDialog } from './EditorDialog'; +interface AppCatalogConfig { + /** + * Show only verified packages. If set to false shows all the packages + */ + showOnlyVerified: boolean; +} + +export const store = new ConfigStore('app-catalog'); +const useStoreConfig = store.useConfig(); + export const PAGE_OFFSET_COUNT_FOR_CHARTS = 9; export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { @@ -39,7 +50,7 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { { title: 'Storage', value: 7 }, { title: 'Streaming and messaging', value: 8 }, ]; - const [charts, setCharts] = useState(null); + const [charts, setCharts] = useState(null); const [openEditor, setEditorOpen] = useState(false); const [page, setPage] = useState(1); const [chartsTotalCount, setChartsTotalCount] = useState(0); @@ -47,45 +58,43 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { const [search, setSearch] = useState(''); const [selectedChartForInstall, setSelectedChartForInstall] = useState(null); - useEffect(() => { - setCharts(null); - fetchCharts(search, chartCategory, page).then(response => { - setCharts(response.packages); - const facets = response.facets; - const categoryOptions = facets.find( - (facet: { - title: string; - options: { - name: string; - total: number; - }[]; - }) => facet.title === 'Category' - ).options; - if (chartCategory.title === 'All') { - const totalCount = categoryOptions.reduce( - ( - acc: number, - option: { - name: string; - total: number; - } - ) => acc + option.total, - 0 - ); - setChartsTotalCount(totalCount); - return; - } - const totalCount = categoryOptions.find( - (option: { name: string; total: number }) => option.name === chartCategory?.title - ).total; - setChartsTotalCount(totalCount); - }); - }, [page, chartCategory, search]); + // note: since we default to true for showOnlyVerified and the settings page is not accessible from anywhere else but the list comp + // we must have the default value here and have it imported for use in the settings tab + const config = useStoreConfig(); + const showOnlyVerified = config?.showOnlyVerified ?? true; + // note: When the users changes the chartCategory or search, then we always go back to the first page useEffect(() => { setPage(1); }, [chartCategory, search]); + // note: When the page changes, we fetch the charts, this will run as a reaction to the previous useEffect + useEffect( + function fetchChartsOnPageChange() { + setCharts(null); + + store.set({ showOnlyVerified: showOnlyVerified }); + + if (props.showOnlyVerified!!) { + store.set({ showOnlyVerified: props.showOnlyVerified }); + } + + async function fetchData() { + try { + const response: any = await fetchCharts(search, showOnlyVerified, chartCategory, page); + setCharts(response.dataResponse.packages); + setChartsTotalCount(parseInt(response.total)); + } catch (err) { + console.error('Error fetching charts', err); + setCharts([]); + } + } + + fetchData(); + }, + [page, chartCategory, search, showOnlyVerified] + ); + function Search() { return ( setEditorOpen(open)} /> - , ]} /> + } actions={[, ]} /> {!charts ? ( ) : ( - charts.map(chart => { - return ( - - - - + + {charts.map(chart => { + return ( + + - {(chart.cncf || chart.repository.cncf) && ( - - - - )} - {(chart.official || chart.repository.official) && ( - - - - )} - {chart.repository.verified_publisher && ( - - - - )} + + + {(chart.cncf || chart.repository.cncf) && ( + + + + )} + {(chart.official || chart.repository.official) && ( + + + + )} + {chart.repository.verified_publisher && ( + + + + )} + - - - - - - - {chart.name} - - - - - - v{chart.version} - - {chart?.repository?.name || ''} + + + + {chart.name} + + - - - - - {chart?.description?.slice(0, 100)} - {chart?.description?.length > 100 && ( - - + + v{chart.version} + + + {chart?.repository?.name || ''} - )} - - - - - - - Learn More - - - - - ); - }) + + + Learn More + + + + + ); + })} + )} {charts && charts.length !== 0 && ( @@ -318,7 +355,7 @@ export function ChartsList({ fetchCharts = fetchChartsFromArtifact }) { size="large" shape="rounded" page={page} - count={Math.floor(chartsTotalCount / PAGE_OFFSET_COUNT_FOR_CHARTS)} + count={Math.ceil(chartsTotalCount / PAGE_OFFSET_COUNT_FOR_CHARTS)} color="primary" onChange={(e, page: number) => { setPage(page); diff --git a/app-catalog/src/components/charts/SettingsLink.stories.tsx b/app-catalog/src/components/charts/SettingsLink.stories.tsx new file mode 100644 index 0000000..7bf82de --- /dev/null +++ b/app-catalog/src/components/charts/SettingsLink.stories.tsx @@ -0,0 +1,21 @@ +import { Meta, Story } from '@storybook/react'; +import React from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { SettingsLink } from './SettingsLink'; + +export default { + title: 'Components/SettingsLink', + component: SettingsLink, + decorators: [ + Story => ( + + + + ), + ], +} as Meta; + +const Template: Story = args => ; + +export const Title = Template.bind({}); +Title.args = {}; diff --git a/app-catalog/src/components/charts/SettingsLink.tsx b/app-catalog/src/components/charts/SettingsLink.tsx new file mode 100644 index 0000000..a871f7f --- /dev/null +++ b/app-catalog/src/components/charts/SettingsLink.tsx @@ -0,0 +1,20 @@ +import { Link as RouterLink } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { Typography, useTheme } from '@mui/material'; + +export function SettingsLink() { + const theme = useTheme(); + + return ( + + + Settings + + + ); +} diff --git a/app-catalog/src/components/charts/__snapshots__/AppCatalogTitle.stories.storyshot b/app-catalog/src/components/charts/__snapshots__/AppCatalogTitle.stories.storyshot new file mode 100644 index 0000000..a2703d5 --- /dev/null +++ b/app-catalog/src/components/charts/__snapshots__/AppCatalogTitle.stories.storyshot @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Components/AppCatalogTitle Title 1`] = ` +
+
+

+ Applications +

+ + +

+ Settings +

+
+
+
+
+`; diff --git a/app-catalog/src/components/charts/__snapshots__/SettingsLink.stories.storyshot b/app-catalog/src/components/charts/__snapshots__/SettingsLink.stories.storyshot new file mode 100644 index 0000000..6817eb9 --- /dev/null +++ b/app-catalog/src/components/charts/__snapshots__/SettingsLink.stories.storyshot @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots Components/SettingsLink Title 1`] = ` + +`; diff --git a/app-catalog/src/components/settings/AppCatalogSettings.stories.tsx b/app-catalog/src/components/settings/AppCatalogSettings.stories.tsx new file mode 100644 index 0000000..f8683ee --- /dev/null +++ b/app-catalog/src/components/settings/AppCatalogSettings.stories.tsx @@ -0,0 +1,30 @@ +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { Meta, Story } from '@storybook/react'; +import React from 'react'; +import { AppCatalogSettings, AppCatalogSettingsProps } from './AppCatalogSettings'; + +export default { + title: 'Components/AppCatalogSettings', + component: AppCatalogSettings, +} as Meta; + +const theme = createTheme(); + +const Template: Story = args => ( + + + +); + +export const Default = Template.bind({}); +Default.args = {}; + +export const ShowOnlyVerifiedTrue = Template.bind({}); +ShowOnlyVerifiedTrue.args = { + initialConfig: { showOnlyVerified: true }, +}; + +export const ShowOnlyVerifiedFalse = Template.bind({}); +ShowOnlyVerifiedFalse.args = { + initialConfig: { showOnlyVerified: false }, +}; diff --git a/app-catalog/src/components/settings/AppCatalogSettings.tsx b/app-catalog/src/components/settings/AppCatalogSettings.tsx new file mode 100644 index 0000000..c292b39 --- /dev/null +++ b/app-catalog/src/components/settings/AppCatalogSettings.tsx @@ -0,0 +1,55 @@ +import { HoverInfoLabel, NameValueTable } from '@kinvolk/headlamp-plugin/lib/components/common'; +import { Box, Typography } from '@mui/material'; +import { useState } from 'react'; +import { store } from '../charts/List'; +import { EnableSwitch } from './EnableSwitch'; + +export interface AppCatalogSettingsProps { + initialConfig?: { showOnlyVerified: boolean }; +} + +export function AppCatalogSettings({ initialConfig }: AppCatalogSettingsProps) { + const config = initialConfig || store.get(); + + const [currentConfig, setCurrentConfig] = useState(config); + + function handleSave(value) { + const updatedConfig = { showOnlyVerified: value }; + store.set(updatedConfig); + setCurrentConfig(store.get()); + return; + } + + function toggleShowOnlyVerified() { + const newShowOnlyVerified = currentConfig?.showOnlyVerified; + handleSave(!newShowOnlyVerified); + } + + return ( + + App Catalog Settings + + ), + value: ( + + ), + }, + ]} + /> + + ); +} diff --git a/app-catalog/src/components/settings/EnableSwitch.Stories.tsx b/app-catalog/src/components/settings/EnableSwitch.Stories.tsx new file mode 100644 index 0000000..4a63a65 --- /dev/null +++ b/app-catalog/src/components/settings/EnableSwitch.Stories.tsx @@ -0,0 +1,20 @@ +import { Meta, Story } from '@storybook/react'; +import React from 'react'; +import { EnableSwitch } from './EnableSwitch'; + +export default { + title: 'Components/EnableSwitch', + component: EnableSwitch, +} as Meta; + +const Template: Story = args => ; + +export const CheckedTrue = Template.bind({}); +CheckedTrue.args = { + checked: true, +}; + +export const CheckedFalse = Template.bind({}); +CheckedFalse.args = { + checked: false, +}; diff --git a/app-catalog/src/components/settings/EnableSwitch.tsx b/app-catalog/src/components/settings/EnableSwitch.tsx new file mode 100644 index 0000000..2f30fcf --- /dev/null +++ b/app-catalog/src/components/settings/EnableSwitch.tsx @@ -0,0 +1,60 @@ +import { Switch, SwitchProps, useTheme } from '@mui/material'; + +export function EnableSwitch(props: SwitchProps) { + const theme = useTheme(); + + return ( + + ); +} diff --git a/app-catalog/src/index.tsx b/app-catalog/src/index.tsx index ca3c1e7..dc019e2 100644 --- a/app-catalog/src/index.tsx +++ b/app-catalog/src/index.tsx @@ -1,4 +1,9 @@ -import { registerRoute, registerSidebarEntry } from '@kinvolk/headlamp-plugin/lib'; +import { + registerPluginSettings, + registerRoute, + registerSidebarEntry, +} from '@kinvolk/headlamp-plugin/lib'; +import { AppCatalogSettings } from '../src/components/settings/AppCatalogSettings'; import ChartDetails from './components/charts/Details'; import { ChartsList } from './components/charts/List'; import ReleaseDetail from './components/releases/Detail'; @@ -91,4 +96,14 @@ if (isElectron()) { exact: true, component: () => , }); + + registerRoute({ + path: '/settings/plugins/app-catalog', + sidebar: 'Charts', + name: 'App Catalog', + exact: true, + component: () => , + }); } + +registerPluginSettings('app-catalog', AppCatalogSettings, false);