feat: move app-store page to RSC

This commit is contained in:
Nicolas Meienberger 2023-09-19 10:34:43 -07:00 committed by Nicolas Meienberger
parent 4647bd206a
commit 304e4f7d66
32 changed files with 144 additions and 275 deletions

View file

@ -0,0 +1,25 @@
'use client';
import React from 'react';
import { AppStoreTile } from '../AppStoreTile';
import { AppTableData } from '../../helpers/table.types';
import { useAppStoreState } from '../../state/appStoreState';
import { sortTable } from '../../helpers/table.helpers';
interface IProps {
data: AppTableData;
}
export const AppStoreTable: React.FC<IProps> = ({ data }) => {
const { category, search, sort, sortDirection } = useAppStoreState();
const tableData = React.useMemo(() => sortTable({ data: data || [], col: sort, direction: sortDirection, category, search }), [data, sort, sortDirection, category, search]);
return (
<div className="row row-cards">
{tableData.map((app) => (
<AppStoreTile key={app.id} app={app} />
))}
</div>
);
};

View file

@ -0,0 +1 @@
export { AppStoreTable } from './AppStoreTable';

View file

@ -0,0 +1,21 @@
'use client';
import clsx from 'clsx';
import React from 'react';
import { Input } from '@/components/ui/Input';
import { useTranslations } from 'next-intl';
import styles from './AppStoreTableActions.module.scss';
import { useAppStoreState } from '../../state/appStoreState';
import { CategorySelector } from '../CategorySelector';
export const AppStoreTableActions = () => {
const { setCategory, category, search, setSearch } = useAppStoreState();
const t = useTranslations('apps.app-store');
return (
<div className="d-flex align-items-stretch align-items-md-center flex-column flex-md-row justify-content-end">
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('search-placeholder')} className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)} />
<CategorySelector initialValue={category} className={clsx('flex-fill mt-2 mt-md-0', styles.selector)} onSelect={setCategory} />
</div>
);
};

View file

@ -1,11 +1,13 @@
'use client';
import clsx from 'clsx';
import Link from 'next/link';
import React from 'react';
import { useTranslations } from 'next-intl';
import { AppCategory } from '@runtipi/shared';
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
import { AppLogo } from '@/components/AppLogo';
import styles from './AppStoreTile.module.scss';
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
type App = {
id: string;
@ -14,7 +16,7 @@ type App = {
short_desc: string;
};
const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
export const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
const t = useTranslations('apps.app-details');
return (
@ -34,5 +36,3 @@ const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
</Link>
);
};
export default AppStoreTile;

View file

@ -0,0 +1 @@
export { AppStoreTile } from './AppStoreTile';

View file

@ -1,5 +1,5 @@
import React from 'react';
import CategorySelector from './CategorySelector';
import { CategorySelector } from './CategorySelector';
import { fireEvent, render, screen } from '../../../../../../tests/test-utils';
describe('Test: CategorySelector', () => {

View file

@ -1,10 +1,10 @@
import { Icon } from '@tabler/icons-react';
import React from 'react';
import Select, { SingleValue, OptionProps, ControlProps, components } from 'react-select';
import { Icon } from '@tabler/icons-react';
import { useTranslations } from 'next-intl';
import { AppCategory } from '@runtipi/shared';
import { APP_CATEGORIES } from '../../../../core/constants';
import { useUIStore } from '../../../../state/uiStore';
import { useUIStore } from '@/client/state/uiStore';
import { iconForCategory } from '../../helpers/table.helpers';
const { Option, Control } = components;
@ -47,10 +47,10 @@ const ControlComponent = (props: ControlProps<OptionsType>) => {
return <Control {...rest}> {children}</Control>;
};
const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
export const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
const t = useTranslations('apps');
const { darkMode } = useUIStore();
const options: OptionsType[] = APP_CATEGORIES.map((category) => ({
const options: OptionsType[] = iconForCategory.map((category) => ({
value: category.id,
label: t(`app-details.categories.${category.id}`),
icon: category.icon,
@ -112,5 +112,3 @@ const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue
/>
);
};
export default CategorySelector;

View file

@ -0,0 +1 @@
export { CategorySelector } from './CategorySelector';

View file

@ -1,4 +1,22 @@
import { AppCategory, AppInfo } from '@runtipi/shared';
import {
Icon,
IconBook,
IconBrain,
IconBroadcast,
IconCamera,
IconCode,
IconDatabase,
IconDeviceGamepad2,
IconMovie,
IconMusic,
IconPigMoney,
IconRobot,
IconShieldLock,
IconStar,
IconTool,
IconUsers,
} from '@tabler/icons-react';
import { AppTableData } from './table.types';
type SortParams = {
@ -47,3 +65,26 @@ export const colorSchemeForCategory: Record<AppCategory, string> = {
gaming: 'pink',
ai: 'gray',
};
type AppCategoryEntry = {
id: AppCategory;
icon: Icon;
};
export const iconForCategory: AppCategoryEntry[] = [
{ id: 'network', icon: IconBroadcast },
{ id: 'media', icon: IconMovie },
{ id: 'development', icon: IconCode },
{ id: 'automation', icon: IconRobot },
{ id: 'social', icon: IconUsers },
{ id: 'utilities', icon: IconTool },
{ id: 'photography', icon: IconCamera },
{ id: 'security', icon: IconShieldLock },
{ id: 'featured', icon: IconStar },
{ id: 'books', icon: IconBook },
{ id: 'data', icon: IconDatabase },
{ id: 'music', icon: IconMusic },
{ id: 'finance', icon: IconPigMoney },
{ id: 'gaming', icon: IconDeviceGamepad2 },
{ id: 'ai', icon: IconBrain },
];

View file

@ -0,0 +1,23 @@
import { AppServiceClass } from '@/server/services/apps/apps.service';
import React from 'react';
import { Metadata } from 'next';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { AppStoreTable } from './components/AppStoreTable';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('apps.app-store.title')} - Tipi`,
};
}
export default async function AppStorePage() {
const { apps } = await AppServiceClass.listApps();
return (
<div className="card px-3 pb-3">
<AppStoreTable data={apps} />
</div>
);
}

View file

@ -9,8 +9,8 @@ import { useTranslations } from 'next-intl';
import type { AppInfo } from '@runtipi/shared';
import { AppLogo } from '@/components/AppLogo';
import { AppStatus } from '@/components/AppStatus';
import { limitText } from '@/client/modules/AppStore/helpers/table.helpers';
import styles from './AppTile.module.scss';
import { limitText } from '../../app-store/helpers/table.helpers';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;

View file

@ -0,0 +1,15 @@
'use client';
import { usePathname } from 'next/navigation';
import React from 'react';
import { AppStoreTableActions } from '../../app-store/components/AppStoreTableActions/AppStoreTableActions';
export const LayoutActions = () => {
const pathname = usePathname();
if (pathname === '/app-store') {
return <AppStoreTableActions />;
}
return null;
};

View file

@ -1,27 +0,0 @@
import { faker } from '@faker-js/faker';
import React from 'react';
import { render } from '../../../../../tests/test-utils';
import { SystemRouterOutput } from '../../../../server/routers/system/system.router';
import { DashboardContainer } from './DashboardContainer';
describe('Test: Dashboard', () => {
it('should render', () => {
const data: SystemRouterOutput['systemInfo'] = {
disk: {
available: faker.number.int(),
total: faker.number.int(),
used: faker.number.int(),
},
memory: {
available: faker.number.int(),
total: faker.number.int(),
used: faker.number.int(),
},
cpu: {
load: faker.number.int(),
},
};
render(<DashboardContainer data={data} isLoading={false} />);
});
});

View file

@ -7,6 +7,7 @@ import clsx from 'clsx';
import { Header } from './components/Header';
import { PageTitle } from './components/PageTitle';
import styles from './layout.module.scss';
import { LayoutActions } from './components/LayoutActions/LayoutActions';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await getUserFromCookie();
@ -29,7 +30,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
<div className="me-3 text-white">
<PageTitle />
</div>
<div className="flex-fill">{}</div>
<div className="flex-fill">
<LayoutActions />
</div>
</div>
</div>
</div>

View file

@ -1,42 +0,0 @@
import {
Icon,
IconBook,
IconBrain,
IconBroadcast,
IconCamera,
IconCode,
IconDatabase,
IconDeviceGamepad2,
IconMovie,
IconMusic,
IconPigMoney,
IconRobot,
IconShieldLock,
IconStar,
IconTool,
IconUsers,
} from '@tabler/icons-react';
import { AppCategory } from '@runtipi/shared';
type AppCategoryEntry = {
id: AppCategory;
icon: Icon;
};
export const APP_CATEGORIES: AppCategoryEntry[] = [
{ id: 'network', icon: IconBroadcast },
{ id: 'media', icon: IconMovie },
{ id: 'development', icon: IconCode },
{ id: 'automation', icon: IconRobot },
{ id: 'social', icon: IconUsers },
{ id: 'utilities', icon: IconTool },
{ id: 'photography', icon: IconCamera },
{ id: 'security', icon: IconShieldLock },
{ id: 'featured', icon: IconStar },
{ id: 'books', icon: IconBook },
{ id: 'data', icon: IconDatabase },
{ id: 'music', icon: IconMusic },
{ id: 'finance', icon: IconPigMoney },
{ id: 'gaming', icon: IconDeviceGamepad2 },
{ id: 'ai', icon: IconBrain },
];

View file

@ -1,16 +0,0 @@
import React from 'react';
import AppStoreTileLoading from '../AppStoreTile/AppStoreTile.loading';
const AppStoreTableLoading: React.FC = () => {
const elements = Array.from({ length: 30 }, (_, i) => i);
return (
<div data-testid="app-store-table-loading" className="row row-cards">
{elements.map((n) => (
<AppStoreTileLoading key={n} />
))}
</div>
);
};
export default AppStoreTableLoading;

View file

@ -1,27 +0,0 @@
import React from 'react';
import { AppTableData, SortableColumns, SortDirection } from '../../helpers/table.types';
import AppStoreTile from '../AppStoreTile';
import AppStoreTableLoading from './AppStoreTable.loading';
interface IProps {
data: AppTableData;
onSortBy?: (value: SortableColumns) => void;
onChangeDirection?: (value: SortDirection) => void;
loading?: boolean;
}
const AppStoreTable: React.FC<IProps> = ({ data, loading }) => {
if (loading) {
return <AppStoreTableLoading />;
}
return (
<div data-testid="app-store-table" className="row row-cards">
{data.map((app) => (
<AppStoreTile key={app.id} app={app} />
))}
</div>
);
};
export default AppStoreTable;

View file

@ -1 +0,0 @@
export { default } from './AppStoreTable';

View file

@ -1,17 +0,0 @@
import React from 'react';
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
const AppStoreTile: React.FC = () => (
<div className="cursor-progress col-sm-6 col-lg-4 p-2 mt-4">
<div className="d-flex overflow-hidden align-items-center py-2 ps-2 placeholder-glow">
<AppLogo />
<div className="card-body">
<div className="placeholder col-6 mb-2" />
<div className="text-bold h-3 placeholder col-9 mb-2" />
<div className="text-bold h-3 placeholder col-4 mt-1 mb-2" />
</div>
</div>
</div>
);
export default AppStoreTile;

View file

@ -1 +0,0 @@
export { default } from './AppStoreTile';

View file

@ -1 +0,0 @@
export { default } from './CategorySelector';

View file

@ -1,16 +0,0 @@
import React from 'react';
import AppStoreTable from '../../components/AppStoreTable';
import { AppTableData } from '../../helpers/table.types';
interface IProps {
apps: AppTableData;
loading?: boolean;
}
const AppStoreContainer: React.FC<IProps> = ({ apps, loading }) => (
<div className="card px-3 pb-3">
<AppStoreTable loading={loading} data={apps} />
</div>
);
export default AppStoreContainer;

View file

@ -1 +0,0 @@
export { default } from './AppStoreContainer';

View file

@ -1,59 +0,0 @@
import React from 'react';
import { render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { AppStorePage } from './AppStorePage';
describe('Test: AppStorePage', () => {
it('should render error state when error occurs', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['app', 'listApps'], message: 'test error' }));
render(<AppStorePage />);
// Assert
await waitFor(() => {
expect(screen.getByText('An error occured')).toBeInTheDocument();
});
expect(screen.getByText('test error')).toBeInTheDocument();
});
it('should render', async () => {
// Arrange
render(<AppStorePage />);
expect(screen.getByTestId('app-store-layout')).toBeInTheDocument();
});
it('should render app store table', async () => {
// Arrange
render(<AppStorePage />);
expect(screen.getByTestId('app-store-layout')).toBeInTheDocument();
// Assert
await waitFor(() => {
expect(screen.getByTestId('app-store-table')).toBeInTheDocument();
});
});
it('should render app store table loading when data is not here', async () => {
// Arrange
render(<AppStorePage />);
expect(screen.getByTestId('app-store-layout')).toBeInTheDocument();
// Assert
await waitFor(() => {
expect(screen.getByTestId('app-store-table-loading')).toBeInTheDocument();
});
});
it('should render empty state when no apps are available', async () => {
// Arrange
server.use(getTRPCMock({ path: ['app', 'listApps'], response: { apps: [], total: 0 } }));
render(<AppStorePage />);
// Assert
await waitFor(() => {
expect(screen.getByText('No app found')).toBeInTheDocument();
});
});
});

View file

@ -1,37 +0,0 @@
import React from 'react';
import type { NextPage } from 'next';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';
import styles from './AppStorePage.module.scss';
import { useAppStoreState } from '../../state/appStoreState';
import { Input } from '../../../../components/ui/Input';
import CategorySelector from '../../components/CategorySelector';
import { sortTable } from '../../helpers/table.helpers';
import { Layout } from '../../../../components/Layout';
import { EmptyPage } from '../../../../components/ui/EmptyPage';
import AppStoreContainer from '../../containers/AppStoreContainer';
import { ErrorPage } from '../../../../components/ui/ErrorPage';
import { trpc } from '../../../../utils/trpc';
export const AppStorePage: NextPage = () => {
const t = useTranslations('apps.app-store');
const { data, isLoading, error } = trpc.app.listApps.useQuery();
const { setCategory, setSearch, category, search, sort, sortDirection } = useAppStoreState();
const actions = (
<div className="d-flex align-items-stretch align-items-md-center flex-column flex-md-row justify-content-end">
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('search-placeholder')} className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)} />
<CategorySelector initialValue={category} className={clsx('flex-fill mt-2 mt-md-0', styles.selector)} onSelect={setCategory} />
</div>
);
const tableData = React.useMemo(() => sortTable({ data: data?.apps || [], col: sort, direction: sortDirection, category, search }), [data?.apps, sort, sortDirection, category, search]);
return (
<Layout title={t('title')} actions={actions}>
{(tableData.length > 0 || isLoading) && <AppStoreContainer loading={isLoading} apps={tableData} />}
{tableData.length === 0 && !error && <EmptyPage title={t('no-results')} subtitle={t('no-results-subtitle')} />}
{error && <ErrorPage error={error.message} />}
</Layout>
);
};

View file

@ -1 +0,0 @@
export { AppStorePage } from './AppStorePage';

View file

@ -1,14 +0,0 @@
import { getAuthedPageProps, getMessagesPageProps } from '@/utils/page-helpers';
import merge from 'lodash.merge';
import { GetServerSideProps } from 'next';
export { AppStorePage as default } from '../../client/modules/AppStore/pages/AppStorePage';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const authedProps = await getAuthedPageProps(ctx);
const messagesProps = await getMessagesPageProps(ctx);
return merge(authedProps, messagesProps, {
props: {},
});
};