ソースを参照

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

Nicolas Meienberger 2 年 前
コミット
2ff55d0de3

+ 2 - 0
.husky/pre-push

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

+ 51 - 76
packages/dashboard/src/modules/Apps/components/AppActions.tsx

@@ -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>
   );
 };

+ 10 - 8
packages/dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -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>

+ 1 - 0
packages/system-api/jest.config.cjs

@@ -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,

+ 14 - 5
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -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`] = '';

+ 25 - 4
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -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', () => {

+ 0 - 8
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -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) => {

+ 5 - 0
packages/system-api/src/test/jest-setup.ts

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