Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

upcoming: [M3-7874] - Linode Create Refactor - Marketplace App Sections #10520

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { stackScriptFactory } from 'src/factories';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { AppSection } from './AppSection';

describe('AppSection', () => {
it('should render a title', () => {
const { getByText } = renderWithTheme(
<AppSection
onOpenDetailsDrawer={vi.fn()}
onSelect={vi.fn()}
selectedStackscriptId={undefined}
stackscripts={[]}
title="Test title"
/>
);

expect(getByText('Test title')).toBeVisible();
});

it('should render apps', () => {
const app = stackScriptFactory.build({
id: 0,
label: 'Linode Marketplace App',
});

const { getByText } = renderWithTheme(
<AppSection
onOpenDetailsDrawer={vi.fn()}
onSelect={vi.fn()}
selectedStackscriptId={undefined}
stackscripts={[app]}
title="Test title"
/>
);

expect(getByText('Linode Marketplace App')).toBeVisible();
});

it('should call `onOpenDetailsDrawer` when the details button is clicked for an app', async () => {
const app = stackScriptFactory.build({ id: 0 });
const onOpenDetailsDrawer = vi.fn();

const { getByLabelText } = renderWithTheme(
<AppSection
onOpenDetailsDrawer={onOpenDetailsDrawer}
onSelect={vi.fn()}
selectedStackscriptId={undefined}
stackscripts={[app]}
title="Test title"
/>
);

await userEvent.click(getByLabelText(`Info for "${app.label}"`));

expect(onOpenDetailsDrawer).toHaveBeenCalledWith(app.id);
});

it('should call `onSelect` when an app is clicked', async () => {
const app = stackScriptFactory.build({ id: 0 });
const onSelect = vi.fn();

const { getByText } = renderWithTheme(
<AppSection
onOpenDetailsDrawer={vi.fn()}
onSelect={onSelect}
selectedStackscriptId={undefined}
stackscripts={[app]}
title="Test title"
/>
);

await userEvent.click(getByText(app.label));

expect(onSelect).toHaveBeenCalledWith(app);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Grid from '@mui/material/Unstable_Grid2';
import React from 'react';

import { Divider } from 'src/components/Divider';
import { Stack } from 'src/components/Stack';
import { Typography } from 'src/components/Typography';
import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2';

import { AppSelectionCard } from './AppSelectionCard';

import type { StackScript } from '@linode/api-v4';

interface Props {
onOpenDetailsDrawer: (stackscriptId: number) => void;
onSelect: (stackscript: StackScript) => void;
selectedStackscriptId: null | number | undefined;
stackscripts: StackScript[];
title: string;
}

export const AppSection = (props: Props) => {
const {
onOpenDetailsDrawer,
onSelect,
selectedStackscriptId,
stackscripts,
title,
} = props;

return (
<Stack>
<Typography variant="h2">{title}</Typography>
<Divider spacingBottom={16} spacingTop={16} />
<Grid container spacing={2}>
{stackscripts?.map((stackscript) => (
<AppSelectionCard
checked={stackscript.id === selectedStackscriptId}
iconUrl={`/assets/${oneClickApps[stackscript.id].logo_url}`}
key={stackscript.id}
label={stackscript.label}
onOpenDetailsDrawer={() => onOpenDetailsDrawer(stackscript.id)}
onSelect={() => onSelect(stackscript)}
/>
))}
</Grid>
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import Grid from '@mui/material/Unstable_Grid2';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';

import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
import { Box } from 'src/components/Box';
import { CircularProgress } from 'src/components/CircularProgress';
import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField';
import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { Paper } from 'src/components/Paper';
import { Stack } from 'src/components/Stack';
import { Typography } from 'src/components/Typography';
import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2';
import { useMarketplaceAppsQuery } from 'src/queries/stackscripts';

import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities';
import { AppSelectionCard } from './AppSelectionCard';
import { AppsList } from './AppsList';
import { categoryOptions } from './utilities';

import type { LinodeCreateFormValues } from '../../utilities';

interface Props {
/**
* Opens the Marketplace App details drawer for the given app
Expand All @@ -28,60 +20,8 @@ interface Props {

export const AppSelect = (props: Props) => {
const { onOpenDetailsDrawer } = props;
const { setValue } = useFormContext<LinodeCreateFormValues>();
const { field } = useController<LinodeCreateFormValues, 'stackscript_id'>({
name: 'stackscript_id',
});

const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery(
true
);

const renderContent = () => {
if (isLoading) {
return (
<Box
alignItems="center"
display="flex"
height="100%"
justifyContent="center"
width="100%"
>
<CircularProgress />
</Box>
);
}

if (error) {
return <ErrorState errorText={error?.[0].reason} />;
}

return (
<Grid container spacing={2}>
{stackscripts?.map((stackscript) => {
if (!oneClickApps[stackscript.id]) {
return null;
}
return (
<AppSelectionCard
onSelect={() => {
setValue(
'stackscript_data',
getDefaultUDFData(stackscript.user_defined_fields)
);
field.onChange(stackscript.id);
}}
checked={field.value === stackscript.id}
iconUrl={`/assets/${oneClickApps[stackscript.id].logo_url}`}
key={stackscript.id}
label={stackscript.label}
onOpenDetailsDrawer={() => onOpenDetailsDrawer(stackscript.id)}
/>
);
})}
</Grid>
);
};
const { isLoading } = useMarketplaceAppsQuery(true);

return (
<Paper>
Expand Down Expand Up @@ -111,7 +51,7 @@ export const AppSelect = (props: Props) => {
/>
</Stack>
<Box height="500px" sx={{ overflowX: 'hidden', overflowY: 'auto' }}>
{renderContent()}
<AppsList onOpenDetailsDrawer={onOpenDetailsDrawer} />
</Box>
</Stack>
</Paper>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Grid from '@mui/material/Unstable_Grid2';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';

import { Box } from 'src/components/Box';
import { CircularProgress } from 'src/components/CircularProgress';
import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { Stack } from 'src/components/Stack';
import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2';
import { useMarketplaceAppsQuery } from 'src/queries/stackscripts';

import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities';
import { AppSection } from './AppSection';
import { AppSelectionCard } from './AppSelectionCard';
import { getAppSections } from './utilities';

import type { LinodeCreateFormValues } from '../../utilities';
import type { StackScript } from '@linode/api-v4';

interface Props {
/**
* Opens the Marketplace App details drawer for the given app
*/
onOpenDetailsDrawer: (stackscriptId: number) => void;
}

export const AppsList = ({ onOpenDetailsDrawer }: Props) => {
const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery(
true
);

const filter = null;

const { setValue } = useFormContext<LinodeCreateFormValues>();
const { field } = useController<LinodeCreateFormValues, 'stackscript_id'>({
name: 'stackscript_id',
});

const onSelect = (stackscript: StackScript) => {
setValue(
'stackscript_data',
getDefaultUDFData(stackscript.user_defined_fields)
);
field.onChange(stackscript.id);
};

if (isLoading) {
return (
<Box
alignItems="center"
display="flex"
height="100%"
justifyContent="center"
width="100%"
>
<CircularProgress />
</Box>
);
}

if (error) {
return <ErrorState errorText={error?.[0].reason} />;
}

if (filter) {
return (
<Grid container spacing={2}>
{stackscripts?.map((stackscript) => (
<AppSelectionCard
checked={field.value === stackscript.id}
iconUrl={`/assets/${oneClickApps[stackscript.id].logo_url}`}
key={stackscript.id}
label={stackscript.label}
onOpenDetailsDrawer={() => onOpenDetailsDrawer(stackscript.id)}
onSelect={() => onSelect(stackscript)}
/>
))}
</Grid>
);
}

const sections = getAppSections(stackscripts);

return (
<Stack spacing={2}>
{sections.map(({ stackscripts, title }) => (
<AppSection
key={title}
onOpenDetailsDrawer={onOpenDetailsDrawer}
onSelect={onSelect}
selectedStackscriptId={field.value}
stackscripts={stackscripts}
title={title}
/>
))}
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { oneClickApps } from 'src/features/OneClickApps/oneClickApps';
import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2';

import type { StackScript } from '@linode/api-v4';

/**
* Get all categories from our marketplace apps list so
* we can generate a dynamic list of category options.
*/
const categories = oneClickApps.reduce((acc, app) => {
const categories = Object.values(oneClickApps).reduce((acc, app) => {
return [...acc, ...app.categories];
}, []);

Expand All @@ -16,3 +18,37 @@ export const uniqueCategories = Array.from(new Set(categories));
export const categoryOptions = uniqueCategories.map((category) => ({
label: category,
}));

/**
* Returns an array of Marketplace app sections given an array
* of Marketplace app StackScripts
*/
export const getAppSections = (stackscripts: StackScript[]) => {
// To check if an app is 'new', we check our own 'oneClickApps' list for the 'isNew' value
const newApps = stackscripts.filter(
(stackscript) => oneClickApps[stackscript.id]?.isNew
);

// Items are ordered by popularity already, take the first 10
const popularApps = stackscripts.slice(0, 10);

// In the all apps section, show everything in alphabetical order
const allApps = [...stackscripts].sort((a, b) =>
a.label.toLowerCase().localeCompare(b.label.toLowerCase())
);

return [
{
stackscripts: newApps,
title: 'New apps',
},
{
stackscripts: popularApps,
title: 'Popular apps',
},
{
stackscripts: allApps,
title: 'All apps',
},
];
};
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,7 @@ export const oneClickApps: Record<number, OCA> = {
start: '1d76ba',
},
description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`,
isNew: true,
logo_url: 'jitsi.svg',
name: 'Jitsi Cluster',
related_guides: [
Expand All @@ -2436,6 +2437,7 @@ export const oneClickApps: Record<number, OCA> = {
},
1350783: {
alt_description: 'Open source, highly available, shared filesystem.',
isNew: true,
alt_name: 'GlusterFS',
categories: ['Development'],
colors: {
Expand Down
Loading
Loading