feat: move app-store page to RSC
This commit is contained in:
parent
4647bd206a
commit
304e4f7d66
32 changed files with 144 additions and 275 deletions
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { AppStoreTable } from './AppStoreTable';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { AppStoreTile } from './AppStoreTile';
|
|
@ -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', () => {
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { CategorySelector } from './CategorySelector';
|
|
@ -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 },
|
||||
];
|
23
src/app/(dashboard)/app-store/page.tsx
Normal file
23
src/app/(dashboard)/app-store/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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'>;
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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} />);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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 },
|
||||
];
|
|
@ -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;
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './AppStoreTable';
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './AppStoreTile';
|
|
@ -1 +0,0 @@
|
|||
export { default } from './CategorySelector';
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default } from './AppStoreContainer';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export { AppStorePage } from './AppStorePage';
|
|
@ -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: {},
|
||||
});
|
||||
};
|
Loading…
Add table
Reference in a new issue