refactor(app actions): remove duplicate code & improve mobile ui

This commit is contained in:
Nicolas Meienberger 2022-08-10 21:00:04 +02:00
parent 417efce959
commit 2ff55d0de3
8 changed files with 108 additions and 101 deletions

View file

@ -8,3 +8,5 @@ fi
pnpm -r test
pnpm -r lint:fix
docker stop test-db

View file

@ -1,5 +1,6 @@
import { Button, Tooltip } from '@chakra-ui/react';
import React from 'react';
import { IconType } from 'react-icons';
import { FiExternalLink, FiPause, FiPlay, FiSettings, FiTrash2 } from 'react-icons/fi';
import { MdSystemUpdateAlt } from 'react-icons/md';
import { TiCancel } from 'react-icons/ti';
@ -19,6 +20,26 @@ interface IProps {
onCancel: () => void;
}
interface BtnProps {
Icon?: IconType;
onClick: () => void;
width?: number | null;
title?: string;
color?: string;
loading?: boolean;
}
const ActionButton: React.FC<BtnProps> = (props) => {
const { Icon, onClick, title, loading, width = 150, color = 'gray' } = props;
return (
<Button isLoading={loading} onClick={onClick} width={width || undefined} colorScheme={color} className="mt-3 mr-2">
{title}
{Icon && <Icon className="ml-1" />}
</Button>
);
};
const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
const hasSettings = Object.keys(app.form_fields).length > 0;
@ -30,110 +51,64 @@ const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onS
}
};
const StartButton = <ActionButton Icon={FiPlay} onClick={onStart} title="Start" color="green" />;
const RemoveButton = <ActionButton Icon={FiTrash2} onClick={onUninstall} title="Remove" />;
const SettingsButton = <ActionButton Icon={FiSettings} width={null} onClick={onUpdateSettings} />;
const StopButton = <ActionButton Icon={FiPause} onClick={onStop} title="Stop" color="red" />;
const OpenButton = <ActionButton Icon={FiExternalLink} onClick={onOpen} title="Open" />;
const LoadingButtion = <ActionButton loading onClick={() => null} color="green" />;
const CancelButton = <ActionButton Icon={TiCancel} onClick={onCancel} title="Cancel" />;
const InstallButton = <ActionButton onClick={onInstall} title="Install" color="green" />;
const UpdateButton = (
<Tooltip label="Download update">
<ActionButton Icon={MdSystemUpdateAlt} onClick={onUpdate} width={null} />
</Tooltip>
);
switch (status) {
case AppStatusEnum.Stopped:
buttons.push(
<Button onClick={onStart} width={150} colorScheme="green" className="mt-3 mr-2">
Start
<FiPlay className="ml-1" />
</Button>,
<Button onClick={onUninstall} width={150} colorScheme="gray" className="mt-3 mr-2">
Remove
<FiTrash2 className="ml-1" />
</Button>,
);
buttons.push(StartButton, RemoveButton);
if (hasSettings) {
buttons.push(
<Tooltip label="Update settings">
<Button onClick={onUpdateSettings} colorScheme="gray" className="mt-3 mr-2">
<FiSettings className="ml-1" />
</Button>
</Tooltip>,
);
buttons.push(SettingsButton);
}
if (updateAvailable) {
buttons.push(
<Tooltip label="Download update">
<Button onClick={onUpdate} colorScheme="gray" className="mt-3 mr-2">
<MdSystemUpdateAlt className="ml-1" />
</Button>
</Tooltip>,
);
buttons.push(UpdateButton);
}
break;
case AppStatusEnum.Running:
buttons.push(
<Button onClick={onStop} width={150} colorScheme="red" className="mt-3 mr-2">
Stop
<FiPause className="ml-1" />
</Button>,
<Button onClick={onOpen} width={150} colorScheme="gray" className="mt-3 mr-2">
Open
<FiExternalLink className="ml-1" />
</Button>,
);
buttons.push(StopButton, OpenButton);
if (hasSettings) {
buttons.push(
<Tooltip label="Update settings">
<Button onClick={onUpdateSettings} colorScheme="gray" className="mt-3 mr-2">
<FiSettings className="ml-1" />
</Button>
</Tooltip>,
);
buttons.push(SettingsButton);
}
if (updateAvailable) {
buttons.push(
<Tooltip label="Download update">
<Button onClick={onUpdate} colorScheme="gray" className="mt-3 mr-2">
<MdSystemUpdateAlt className="ml-1" />
</Button>
</Tooltip>,
);
buttons.push(UpdateButton);
}
break;
case AppStatusEnum.Installing:
case AppStatusEnum.Uninstalling:
case AppStatusEnum.Starting:
case AppStatusEnum.Stopping:
buttons.push(
<Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
Install
<FiPlay className="ml-1" />
</Button>,
<Button onClick={onCancel} colorScheme="gray" className="mt-3 mr-2 ml-2">
<TiCancel />
</Button>,
);
buttons.push(LoadingButtion, CancelButton);
break;
case AppStatusEnum.Updating:
buttons.push(
<Button isLoading onClick={() => null} width={160} colorScheme="green" className="mt-3">
Updating
<FiPlay className="ml-1" />
</Button>,
<Button onClick={onCancel} colorScheme="gray" className="mt-3 mr-2 ml-2">
<TiCancel />
</Button>,
);
buttons.push(LoadingButtion, CancelButton);
break;
case AppStatusEnum.Missing:
buttons.push(
<Button onClick={onInstall} width={160} colorScheme="green" className="mt-3">
Install
</Button>,
);
buttons.push(InstallButton);
break;
default:
break;
}
return (
<div className="flex items-center sm:items-start flex-col md:flex-row">
{buttons.map((button) => {
return button;
})}
{renderStatus()}
<div className="flex flex-1 flex-col justify-start">
<div className="flex flex-1 justify-center md:justify-start flex-wrap">
{buttons.map((button) => {
return button;
})}
</div>
<div className="mt-1 flex justify-center md:justify-start">{renderStatus()}</div>
</div>
);
};

View file

@ -1,4 +1,4 @@
import { SlideFade, VStack, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
import { SlideFade, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
import React from 'react';
import { FiExternalLink } from 'react-icons/fi';
import { useSytemStore } from '../../../state/systemStore';
@ -133,17 +133,19 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
window.open(`http://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
};
const version = [info?.version || 'unknown', app?.version ? `(${app.version})` : ''].join(' ');
return (
<SlideFade in className="flex flex-1" offsetY="20px">
<div className="flex flex-1 p-4 mt-3 rounded-lg flex-col">
<div className="flex flex-1 p-4 mt-3 rounded-lg flex-col">
<Flex className="flex-col md:flex-row">
<AppLogo id={info.id} size={180} className="self-center sm:self-auto" alt={info.name} />
<VStack align="flex-start" justify="space-between" className="ml-0 md:ml-4">
<div className="mt-3 items-center self-center flex flex-col sm:items-start sm:self-start md:mt-0">
<AppLogo id={info.id} size={180} className="self-center md:self-auto" alt={info.name} />
<div className="flex flex-col justify-between flex-1 ml-0 md:ml-4">
<div className="mt-3 items-center self-center flex flex-col md:items-start md:self-start md:mt-0">
<h1 className="font-bold text-2xl">{info.name}</h1>
<h2 className="text-center md:text-left">{info.short_desc}</h2>
<h3 className="text-center md:text-left text-sm">
version: <b>{info.version}</b> ({app?.version})
version: <b>{version}</b>
</h3>
{info.source && (
<a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.source}>
@ -155,7 +157,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
)}
<p className="text-xs text-gray-600">By {info.author}</p>
</div>
<div className="flex justify-center xs:absolute md:static top-0 right-5 self-center sm:self-auto">
<div className="flex flex-1">
<AppActions
updateAvailable={updateAvailable}
onUpdate={updateDisclosure.onOpen}
@ -170,7 +172,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
status={app?.status}
/>
</div>
</VStack>
</div>
</Flex>
<Divider className="mt-5" />
<Markdown className="mt-3">{info.description}</Markdown>

View file

@ -5,6 +5,7 @@ module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFiles: ['<rootDir>/src/test/dotenv-config.ts'],
setupFilesAfterEnv: ['<rootDir>/src/test/jest-setup.ts'],
collectCoverage: true,
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/migrations/**/*.{ts,tsx}', '!**/config/**/*.{ts,tsx}'],
passWithNoTests: true,

View file

@ -27,11 +27,6 @@ const createApp = async (props: IProps) => {
env_variable: 'TEST_FIELD',
},
],
requirements: requiredPort
? {
ports: [requiredPort],
}
: undefined,
name: faker.random.word(),
description: faker.random.words(),
tipi_version: faker.datatype.number({ min: 1, max: 10 }),
@ -41,6 +36,20 @@ const createApp = async (props: IProps) => {
categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
};
if (randomField) {
appInfo.form_fields?.push({
type: FieldTypes.random,
label: faker.random.word(),
env_variable: 'RANDOM_FIELD',
});
}
if (requiredPort) {
appInfo.requirements = {
ports: [requiredPort],
};
}
let MockFiles: any = {};
MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
MockFiles[`${config.ROOT_FOLDER}/repos/repo-id`] = '';

View file

@ -7,6 +7,7 @@ import App from '../app.entity';
import { createApp } from './apps.factory';
import { setupConnection, teardownConnection } from '../../../test/connection';
import { DataSource } from 'typeorm';
import { getEnvMap } from '../apps.helpers';
jest.mock('fs-extra');
jest.mock('child_process');
@ -98,10 +99,15 @@ describe('Install app', () => {
});
it('Correctly generates a random value if the field has a "random" type', async () => {
// const { appInfo } = await createApp({ randomField: true });
// await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
// const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`).toString();
// expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appInfo.port}\nTEST_FIELD=${appInfo.randomValue}`);
const { appInfo, MockFiles } = await createApp({ randomField: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'yolo' });
const envMap = getEnvMap(appInfo.id);
expect(envMap.get('RANDOM_FIELD')).toBeDefined();
expect(envMap.get('RANDOM_FIELD')).toHaveLength(32);
});
});
@ -258,6 +264,21 @@ describe('Update app config', () => {
it('Should throw if app is not installed', async () => {
await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('App test-app-2 not found');
});
it('Should not recreate random field if already present in .env', async () => {
const { appInfo, MockFiles } = await createApp({ randomField: true, installed: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`).toString();
fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
await AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' });
const envMap = getEnvMap(appInfo.id);
expect(envMap.get('RANDOM_FIELD')).toBe('test');
});
});
describe('Get app config', () => {

View file

@ -55,14 +55,6 @@ export const checkEnvFile = (appName: string) => {
});
};
export const checkAppExists = (appName: string) => {
const appExists = fileExists(`/app-data/${appName}`);
if (!appExists) {
throw new Error(`App ${appName} not installed`);
}
};
export const runAppScript = async (params: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
runScript('/scripts/app.sh', [...params, config.ROOT_FOLDER_HOST, config.APPS_REPO_ID], (err: string) => {

View file

@ -0,0 +1,5 @@
jest.mock('../config/logger/logger', () => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
}));