implement fetching apps from BE
impl install modal
This commit is contained in:
parent
a3adcfc3da
commit
5b728d35d7
24 changed files with 649 additions and 163 deletions
53
src/components/Form/Switch/Switch.tsx
Normal file
53
src/components/Form/Switch/Switch.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useController } from 'react-hook-form';
|
||||||
|
import { Switch as HeadlessSwitch } from '@headlessui/react';
|
||||||
|
|
||||||
|
function classNames(...classes: any) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
/* eslint-disable react/react-in-jsx-scope */
|
||||||
|
export const Switch = ({ control, name, label, required }: SwitchProps) => {
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
// fieldState: { invalid, isTouched, isDirty },
|
||||||
|
// formState: { touchedFields, dirtyFields },
|
||||||
|
} = useController({
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules: { required },
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<HeadlessSwitch
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
className={classNames(
|
||||||
|
field.value ? 'bg-primary-600' : 'bg-gray-200',
|
||||||
|
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
field.value ? 'translate-x-5' : 'translate-x-0',
|
||||||
|
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</HeadlessSwitch>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type SwitchProps = {
|
||||||
|
control: any;
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
1
src/components/Form/Switch/index.ts
Normal file
1
src/components/Form/Switch/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { Switch } from './Switch';
|
|
@ -1,2 +1,3 @@
|
||||||
export { Input } from './Input';
|
export { Input } from './Input';
|
||||||
export { Select } from './Select';
|
export { Select } from './Select';
|
||||||
|
export { Switch } from './Switch';
|
||||||
|
|
|
@ -1,26 +1,40 @@
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { XCircleIcon } from '@heroicons/react/outline';
|
import { XCircleIcon } from '@heroicons/react/outline';
|
||||||
|
import { useApps } from 'src/services/apps';
|
||||||
import { Tabs } from 'src/components';
|
import { Tabs } from 'src/components';
|
||||||
import { appAccessList } from 'src/components/UserModal/consts';
|
import { appAccessList } from 'src/components/UserModal/consts';
|
||||||
import { AdvancedTab, GeneralTab } from './components';
|
import { AdvancedTab, GeneralTab } from './components';
|
||||||
|
|
||||||
const pages = [
|
|
||||||
{ name: 'Apps', to: '/apps', current: true },
|
|
||||||
{ name: 'Nextcloud', to: '', current: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ name: 'General', component: <GeneralTab /> },
|
|
||||||
{ name: 'Advanced Configuration', component: <AdvancedTab /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const AppSingle: React.FC = () => {
|
export const AppSingle: React.FC = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const appSlug = params.slug;
|
const appSlug = params.slug;
|
||||||
|
const { app, loadApp } = useApps();
|
||||||
|
|
||||||
const appInfo = _.find(appAccessList, { name: appSlug });
|
useEffect(() => {
|
||||||
|
if (appSlug) {
|
||||||
|
loadApp(appSlug);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [appSlug]);
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const appImageSrc = _.find(appAccessList, { name: appSlug })?.image;
|
||||||
|
|
||||||
|
const handleAutomaticUpdatesChange = () => {
|
||||||
|
app.automaticUpdates = !app.automaticUpdates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'General',
|
||||||
|
component: <GeneralTab automaticUpdates={app.automaticUpdates} onChange={handleAutomaticUpdatesChange} />,
|
||||||
|
},
|
||||||
|
{ name: 'Advanced Configuration', component: <AdvancedTab /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden lg:flex lg:flex-row">
|
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden lg:flex lg:flex-row">
|
||||||
|
@ -29,9 +43,9 @@ export const AppSingle: React.FC = () => {
|
||||||
style={{ height: 'fit-content' }}
|
style={{ height: 'fit-content' }}
|
||||||
>
|
>
|
||||||
<div className="px-4 py-5 sm:p-6 flex flex-col">
|
<div className="px-4 py-5 sm:p-6 flex flex-col">
|
||||||
<img className="h-24 w-24 rounded-md overflow-hidden mr-4 mb-3" src={appInfo?.image} alt={appInfo?.label} />
|
<img className="h-24 w-24 rounded-md overflow-hidden mr-4 mb-3" src={appImageSrc} alt={app.name} />
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h2 className="text-2xl leading-8 font-bold">{appInfo?.label}</h2>
|
<h2 className="text-2xl leading-8 font-bold">{app.name}</h2>
|
||||||
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
|
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -1,19 +1,104 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import React, { useState, useCallback, useMemo } from 'react';
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { SearchIcon } from '@heroicons/react/solid';
|
import { SearchIcon } from '@heroicons/react/solid';
|
||||||
|
import _, { debounce } from 'lodash';
|
||||||
import { Table } from 'src/components';
|
import { Table } from 'src/components';
|
||||||
import { columns, data } from './consts';
|
import { appAccessList } from 'src/components/UserModal/consts';
|
||||||
|
import { App, AppStatus, useApps } from 'src/services/apps';
|
||||||
|
import { AppInstallModal } from './components';
|
||||||
|
import { getConstForStatus } from './consts';
|
||||||
|
|
||||||
export const Apps: React.FC = () => {
|
export const Apps: React.FC = () => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [installModalOpen, setInstallModalOpen] = useState(false);
|
||||||
|
const [appSlug, setAppSlug] = useState(null);
|
||||||
|
const { apps, appTableLoading, loadApps } = useApps();
|
||||||
|
|
||||||
const handleSearch = useCallback((event: any) => {
|
const handleSearch = useCallback((event: any) => {
|
||||||
setSearch(event.target.value);
|
setSearch(event.target.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const debouncedSearch = useCallback(debounce(handleSearch, 250), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadApps();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filterSearch = useMemo(() => {
|
const filterSearch = useMemo(() => {
|
||||||
return data.filter((item: any) => item.name?.toLowerCase().includes(search.toLowerCase()));
|
return _.filter(apps, (item) => item.name?.toLowerCase().includes(search.toLowerCase()));
|
||||||
}, [search]);
|
}, [apps, search]);
|
||||||
|
|
||||||
|
const columns: any = [
|
||||||
|
{
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: 'name',
|
||||||
|
Cell: (e: any) => {
|
||||||
|
const app = e.cell.row.original as App;
|
||||||
|
const imageSrc = _.find(appAccessList, { name: app.slug })?.image;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 h-10 w-10">
|
||||||
|
<img className="h-10 w-10 rounded-md overflow-hidden" src={imageSrc} alt={app.name} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{app.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: 'auto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Status',
|
||||||
|
accessor: 'status',
|
||||||
|
Cell: (e: any) => {
|
||||||
|
const status = e.cell.row.original.status as AppStatus;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={`flex-shrink-0 h-4 w-4 rounded-full bg-${getConstForStatus(status, 'colorClass')}`} />
|
||||||
|
<div className={`ml-2 text-sm text-${getConstForStatus(status, 'colorClass')}`}>{status}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: 'auto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: ' ',
|
||||||
|
Cell: (e: any) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const appStatus = e.cell.row.original.status as AppStatus;
|
||||||
|
if (appStatus === AppStatus.Installing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { slug } = e.cell.row.original;
|
||||||
|
let buttonFuntion = () => navigate(`/apps/${slug}`);
|
||||||
|
if (appStatus === AppStatus.NotInstalled) {
|
||||||
|
buttonFuntion = () => {
|
||||||
|
setAppSlug(slug);
|
||||||
|
setInstallModalOpen(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={buttonFuntion}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
{getConstForStatus(appStatus, 'buttonIcon')}
|
||||||
|
{getConstForStatus(appStatus, 'buttonTitle')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: 'auto',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
@ -51,12 +136,18 @@ export const Apps: React.FC = () => {
|
||||||
<div className="-my-2 sm:-mx-6 lg:-mx-8">
|
<div className="-my-2 sm:-mx-6 lg:-mx-8">
|
||||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||||
<div className="shadow border-b border-gray-200 sm:rounded-lg">
|
<div className="shadow border-b border-gray-200 sm:rounded-lg">
|
||||||
<Table data={filterSearch} columns={columns} />
|
<Table
|
||||||
|
data={_.filter(filterSearch, (app) => app.slug !== 'dashboard') as any}
|
||||||
|
columns={columns}
|
||||||
|
loading={appTableLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AppInstallModal appSlug={appSlug} onClose={() => setInstallModalOpen(false)} open={installModalOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,6 +22,38 @@ export const AdvancedTab = () => {
|
||||||
<h1 className="text-2xl leading-6 font-medium text-gray-900">Configuration</h1>
|
<h1 className="text-2xl leading-6 font-medium text-gray-900">Configuration</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-flow-col grid-cols-2 gap-8">
|
<div className="grid grid-flow-col grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="bg-gray-100 overflow-hidden rounded-lg">
|
||||||
|
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
|
||||||
|
<span className="text-gray-600 text-lg leading-6 font-medium">Current Configuration</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-5 sm:p-6 overflow-x-auto">
|
||||||
|
<pre className="font-mono text-sm font-light">
|
||||||
|
{`luck: except
|
||||||
|
natural: still
|
||||||
|
near: though
|
||||||
|
search:
|
||||||
|
- feature
|
||||||
|
- - 1980732354.689713
|
||||||
|
- hour
|
||||||
|
- butter:
|
||||||
|
ordinary: 995901949.8974948
|
||||||
|
teeth: true
|
||||||
|
whole:
|
||||||
|
- -952367353
|
||||||
|
- - talk: -1773961379
|
||||||
|
temperature: false
|
||||||
|
oxygen: true
|
||||||
|
laugh:
|
||||||
|
flag:
|
||||||
|
in: 2144751662
|
||||||
|
hospital: -1544066384.1973226
|
||||||
|
law: congress
|
||||||
|
great: stomach`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg">
|
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg">
|
||||||
|
@ -102,38 +134,6 @@ export const AdvancedTab = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="bg-gray-100 overflow-hidden rounded-lg">
|
|
||||||
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
|
|
||||||
<span className="text-gray-600 text-lg leading-6 font-medium">Current Configuration</span>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-5 sm:p-6 overflow-x-auto">
|
|
||||||
<pre className="font-mono text-sm font-light">
|
|
||||||
{`luck: except
|
|
||||||
natural: still
|
|
||||||
near: though
|
|
||||||
search:
|
|
||||||
- feature
|
|
||||||
- - 1980732354.689713
|
|
||||||
- hour
|
|
||||||
- butter:
|
|
||||||
ordinary: 995901949.8974948
|
|
||||||
teeth: true
|
|
||||||
whole:
|
|
||||||
- -952367353
|
|
||||||
- - talk: -1773961379
|
|
||||||
temperature: false
|
|
||||||
oxygen: true
|
|
||||||
laugh:
|
|
||||||
flag:
|
|
||||||
in: 2144751662
|
|
||||||
hospital: -1544066384.1973226
|
|
||||||
law: congress
|
|
||||||
great: stomach`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end mt-10">
|
<div className="flex justify-end mt-10">
|
||||||
<button
|
<button
|
||||||
|
|
136
src/modules/apps/components/AppInstallModal/AppInstallModal.tsx
Normal file
136
src/modules/apps/components/AppInstallModal/AppInstallModal.tsx
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Editor from 'react-simple-code-editor';
|
||||||
|
import { highlight, languages } from 'prismjs';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useApps } from 'src/services/apps';
|
||||||
|
import { Modal, Tabs } from 'src/components';
|
||||||
|
import { AppInstallModalProps } from './types';
|
||||||
|
|
||||||
|
const initialCode = `luck: except
|
||||||
|
natural: still
|
||||||
|
near: though
|
||||||
|
search:
|
||||||
|
- feature
|
||||||
|
- - 1980732354.689713
|
||||||
|
- hour
|
||||||
|
- butter:
|
||||||
|
ordinary: 995901949.8974948
|
||||||
|
teeth: true
|
||||||
|
whole:
|
||||||
|
- -952367353
|
||||||
|
- - talk: -1773961379
|
||||||
|
temperature: false
|
||||||
|
oxygen: true
|
||||||
|
laugh:
|
||||||
|
flag:
|
||||||
|
in: 2144751662
|
||||||
|
hospital: -1544066384.1973226
|
||||||
|
law: congress
|
||||||
|
great: stomach`;
|
||||||
|
|
||||||
|
export const AppInstallModal = ({ open, onClose, appSlug }: AppInstallModalProps) => {
|
||||||
|
const [code, setCode] = useState(initialCode);
|
||||||
|
const { app, appLoading, loadApp } = useApps();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appSlug) {
|
||||||
|
loadApp(appSlug);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [appSlug, open]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
_.noop();
|
||||||
|
// todo: implement
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: any) => {
|
||||||
|
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSubdomain = () => {
|
||||||
|
return (
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<input name="subdomain" placeholder="Subdomain" onKeyPress={handleKeyPress} required={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderConfiguration = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bg-gray-100 overflow-hidden rounded-lg">
|
||||||
|
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
|
||||||
|
<span className="text-gray-600 text-lg leading-6 font-medium">Current Configuration</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-5 sm:p-6 overflow-x-auto">
|
||||||
|
<Editor
|
||||||
|
value={code}
|
||||||
|
onValueChange={(value) => setCode(value)}
|
||||||
|
highlight={(value) => highlight(value, languages.js, 'yaml')}
|
||||||
|
preClassName="font-mono whitespace-normal font-light"
|
||||||
|
textareaClassName="font-mono overflow-auto font-light"
|
||||||
|
className="font-mono text-sm font-light"
|
||||||
|
/>
|
||||||
|
<pre className="font-mono text-sm font-light">
|
||||||
|
{`luck: except
|
||||||
|
natural: still
|
||||||
|
near: though
|
||||||
|
search:
|
||||||
|
- feature
|
||||||
|
- - 1980732354.689713
|
||||||
|
- hour
|
||||||
|
- butter:
|
||||||
|
ordinary: 995901949.8974948
|
||||||
|
teeth: true
|
||||||
|
whole:
|
||||||
|
- -952367353
|
||||||
|
- - talk: -1773961379
|
||||||
|
temperature: false
|
||||||
|
oxygen: true
|
||||||
|
laugh:
|
||||||
|
flag:
|
||||||
|
in: 2144751662
|
||||||
|
hospital: -1544066384.1973226
|
||||||
|
law: congress
|
||||||
|
great: stomach`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'Subdomain', component: renderSubdomain() },
|
||||||
|
{ name: 'Advanced Configuration', component: renderConfiguration() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal onClose={handleClose} open={open} onSave={handleSave} isLoading={appLoading} useCancelButton>
|
||||||
|
<div className="bg-white px-4">
|
||||||
|
<div className="space-y-10 divide-y divide-gray-200">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900">Install app {_.get(app, 'name')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-5 sm:p-6">
|
||||||
|
<Tabs tabs={tabs} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
1
src/modules/apps/components/AppInstallModal/index.ts
Normal file
1
src/modules/apps/components/AppInstallModal/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { AppInstallModal } from './AppInstallModal';
|
5
src/modules/apps/components/AppInstallModal/types.ts
Normal file
5
src/modules/apps/components/AppInstallModal/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export type AppInstallModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
appSlug: string | null;
|
||||||
|
};
|
|
@ -1,13 +1,16 @@
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { Switch } from '@headlessui/react';
|
import { Switch } from '@headlessui/react';
|
||||||
|
|
||||||
function classNames(...classes: any) {
|
function classNames(...classes: any) {
|
||||||
return classes.filter(Boolean).join(' ');
|
return classes.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GeneralTab = () => {
|
interface GeneralTabProps {
|
||||||
const [enabled, setEnabled] = useState(false);
|
automaticUpdates: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GeneralTab = ({ automaticUpdates, onChange }: GeneralTabProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<div className="md:grid md:grid-cols-3 md:gap-6">
|
<div className="md:grid md:grid-cols-3 md:gap-6">
|
||||||
|
@ -20,17 +23,17 @@ export const GeneralTab = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="my-auto ml-auto">
|
<div className="my-auto ml-auto">
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={automaticUpdates}
|
||||||
onChange={setEnabled}
|
onChange={onChange}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
automaticUpdates ? 'bg-primary-600' : 'bg-gray-200',
|
||||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none',
|
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
automaticUpdates ? 'translate-x-5' : 'translate-x-0',
|
||||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
export { GeneralTab } from './GeneralTab';
|
export { GeneralTab } from './GeneralTab';
|
||||||
export { AdvancedTab } from './AdvancedTab';
|
export { AdvancedTab } from './AdvancedTab';
|
||||||
|
export { AppInstallModal } from './AppInstallModal';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CogIcon, PlusCircleIcon } from '@heroicons/react/outline';
|
import { CogIcon, PlusCircleIcon } from '@heroicons/react/outline';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { useNavigate } from 'react-router';
|
import { AppStatus } from 'src/services/apps';
|
||||||
|
|
||||||
export const initialEditorYaml = () => {
|
export const initialEditorYaml = () => {
|
||||||
return `luck: except
|
return `luck: except
|
||||||
|
@ -27,12 +27,6 @@ search:
|
||||||
great: stomach`;
|
great: stomach`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum AppStatus {
|
|
||||||
NotInstalled = 'Not installed',
|
|
||||||
Installed = 'Installed',
|
|
||||||
Installing = 'Installing',
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableConsts = [
|
const tableConsts = [
|
||||||
{
|
{
|
||||||
status: AppStatus.Installed,
|
status: AppStatus.Installed,
|
||||||
|
@ -54,104 +48,7 @@ const tableConsts = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const getConstForStatus = (appStatus: AppStatus, paramName: string) => {
|
export const getConstForStatus = (appStatus: AppStatus, paramName: string) => {
|
||||||
const tableConst = _.find(tableConsts, { status: appStatus });
|
const tableConst = _.find(tableConsts, { status: appStatus });
|
||||||
return _.get(tableConst, paramName);
|
return _.get(tableConst, paramName);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const columns: any = [
|
|
||||||
{
|
|
||||||
Header: 'Name',
|
|
||||||
accessor: 'name',
|
|
||||||
Cell: (e: any) => {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0 h-10 w-10">
|
|
||||||
<img className="h-10 w-10 rounded-md overflow-hidden" src={e.cell.row.original.assetSrc} alt="" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{e.cell.row.original.name}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
width: 'auto',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Header: 'Status',
|
|
||||||
accessor: 'status',
|
|
||||||
Cell: (e: any) => {
|
|
||||||
const appStatus = e.cell.row.original.status as AppStatus;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`flex-shrink-0 h-4 w-4 rounded-full bg-${getConstForStatus(appStatus, 'colorClass')}`} />
|
|
||||||
<div className={`ml-2 text-sm text-${getConstForStatus(appStatus, 'colorClass')}`}>{appStatus}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
width: 'auto',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Header: ' ',
|
|
||||||
Cell: (e: any) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const appStatus = e.cell.row.original.status as AppStatus;
|
|
||||||
const appSlug = e.cell.row.original.slug;
|
|
||||||
if (appStatus === AppStatus.Installing) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/apps/${appSlug}`)}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
|
||||||
>
|
|
||||||
{getConstForStatus(appStatus, 'buttonIcon')}
|
|
||||||
{getConstForStatus(appStatus, 'buttonTitle')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
width: 'auto',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const data: any[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Nextcloud',
|
|
||||||
slug: 'nextcloud',
|
|
||||||
status: AppStatus.Installed,
|
|
||||||
assetSrc: './assets/nextcloud.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Wekan',
|
|
||||||
slug: 'wekan',
|
|
||||||
status: AppStatus.Installing,
|
|
||||||
assetSrc: './assets/wekan.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Zulip',
|
|
||||||
slug: 'zulip',
|
|
||||||
status: AppStatus.NotInstalled,
|
|
||||||
assetSrc: './assets/zulip.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Wordpress',
|
|
||||||
slug: 'wordpress',
|
|
||||||
status: AppStatus.Installed,
|
|
||||||
assetSrc: './assets/wordpress.svg',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface AppInfo {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
status: AppStatus;
|
|
||||||
assetSrc: string;
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import storage from 'redux-persist/lib/storage';
|
||||||
import { reducer as authReducer } from 'src/services/auth';
|
import { reducer as authReducer } from 'src/services/auth';
|
||||||
|
|
||||||
import usersReducer from 'src/services/users/redux/reducers';
|
import usersReducer from 'src/services/users/redux/reducers';
|
||||||
|
import appsReducer from 'src/services/apps/redux/reducers';
|
||||||
import { State } from './types';
|
import { State } from './types';
|
||||||
|
|
||||||
const persistConfig = {
|
const persistConfig = {
|
||||||
|
@ -17,6 +18,7 @@ const persistConfig = {
|
||||||
const appReducer = combineReducers<State>({
|
const appReducer = combineReducers<State>({
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
users: usersReducer,
|
users: usersReducer,
|
||||||
|
apps: appsReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedReducer = persistReducer(persistConfig, appReducer);
|
const persistedReducer = persistReducer(persistConfig, appReducer);
|
||||||
|
|
|
@ -2,10 +2,12 @@ import { Store } from 'redux';
|
||||||
|
|
||||||
import { AuthState } from 'src/services/auth/redux';
|
import { AuthState } from 'src/services/auth/redux';
|
||||||
import { UsersState } from 'src/services/users/redux';
|
import { UsersState } from 'src/services/users/redux';
|
||||||
|
import { AppsState } from 'src/services/apps/redux';
|
||||||
|
|
||||||
export interface AppStore extends Store, State {}
|
export interface AppStore extends Store, State {}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
auth: AuthState;
|
auth: AuthState;
|
||||||
users: UsersState;
|
users: UsersState;
|
||||||
|
apps: AppsState;
|
||||||
}
|
}
|
||||||
|
|
1
src/services/apps/hooks/index.ts
Normal file
1
src/services/apps/hooks/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { useApps } from './use-apps';
|
38
src/services/apps/hooks/use-apps.ts
Normal file
38
src/services/apps/hooks/use-apps.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { fetchApps, fetchAppBySlug, updateAppBySlug, installAppBySlug } from '../redux';
|
||||||
|
import { getCurrentApp, getAppLoading, getAppsLoading, getApps } from '../redux/selectors';
|
||||||
|
|
||||||
|
export function useApps() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const apps = useSelector(getApps);
|
||||||
|
const app = useSelector(getCurrentApp);
|
||||||
|
const appLoading = useSelector(getAppLoading);
|
||||||
|
const appTableLoading = useSelector(getAppsLoading);
|
||||||
|
|
||||||
|
function loadApps() {
|
||||||
|
return dispatch(fetchApps());
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadApp(slug: string) {
|
||||||
|
return dispatch(fetchAppBySlug(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
function editAppBySlug(data: any) {
|
||||||
|
return dispatch(updateAppBySlug(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function installApp(data: any) {
|
||||||
|
return dispatch(installAppBySlug(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apps,
|
||||||
|
app,
|
||||||
|
loadApp,
|
||||||
|
loadApps,
|
||||||
|
editAppBySlug,
|
||||||
|
appLoading,
|
||||||
|
appTableLoading,
|
||||||
|
installApp,
|
||||||
|
};
|
||||||
|
}
|
3
src/services/apps/index.ts
Normal file
3
src/services/apps/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './types';
|
||||||
|
export { reducer } from './redux';
|
||||||
|
export { useApps } from './hooks';
|
119
src/services/apps/redux/actions.ts
Normal file
119
src/services/apps/redux/actions.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { Dispatch } from 'redux';
|
||||||
|
import { showToast, ToastType } from 'src/common/util/show-toast';
|
||||||
|
import { performApiCall } from 'src/services/api';
|
||||||
|
import { transformAppRequest, transformApp, transformInstallAppRequest } from '../transformations';
|
||||||
|
|
||||||
|
export enum AppActionTypes {
|
||||||
|
FETCH_APPS = 'apps/fetch_apps',
|
||||||
|
FETCH_APP = 'apps/fetch_app',
|
||||||
|
UPDATE_APP = 'apps/update_app',
|
||||||
|
INSTALL_APP = 'apps/install_app',
|
||||||
|
SET_APP_LOADING = 'apps/app_loading',
|
||||||
|
SET_APPS_LOADING = 'apps/apps_loading',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAppsLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch({
|
||||||
|
type: AppActionTypes.SET_APPS_LOADING,
|
||||||
|
payload: isLoading,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAppLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch({
|
||||||
|
type: AppActionTypes.SET_APP_LOADING,
|
||||||
|
payload: isLoading,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchApps = () => async (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch(setAppsLoading(true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await performApiCall({
|
||||||
|
path: '/apps',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AppActionTypes.FETCH_APPS,
|
||||||
|
payload: data.map(transformApp),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setAppsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAppBySlug = (slug: string) => async (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch(setAppLoading(true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await performApiCall({
|
||||||
|
path: `/apps/${slug}`,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AppActionTypes.FETCH_APP,
|
||||||
|
payload: transformApp(data),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setAppLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAppBySlug = (app: any) => async (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch(setAppLoading(true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await performApiCall({
|
||||||
|
path: `/apps/${app.slug}`,
|
||||||
|
method: 'PUT',
|
||||||
|
body: transformAppRequest(app),
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AppActionTypes.UPDATE_APP,
|
||||||
|
payload: transformApp(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast('App updated successfully.', ToastType.Success);
|
||||||
|
|
||||||
|
dispatch(fetchApps());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setAppLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installAppBySlug = (app: any) => async (dispatch: Dispatch<any>) => {
|
||||||
|
dispatch(setAppLoading(true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await performApiCall({
|
||||||
|
path: `/apps/${app.slug}/install`,
|
||||||
|
method: 'POST',
|
||||||
|
body: transformInstallAppRequest(app),
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: AppActionTypes.INSTALL_APP,
|
||||||
|
payload: transformApp(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast('App installing...', ToastType.Success);
|
||||||
|
|
||||||
|
dispatch(fetchApps());
|
||||||
|
} catch (err: any) {
|
||||||
|
dispatch(setAppLoading(false));
|
||||||
|
showToast(`${err}`, ToastType.Error);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setAppLoading(false));
|
||||||
|
};
|
4
src/services/apps/redux/index.ts
Normal file
4
src/services/apps/redux/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './actions';
|
||||||
|
export { default as reducer } from './reducers';
|
||||||
|
export { getApps } from './selectors';
|
||||||
|
export * from './types';
|
39
src/services/apps/redux/reducers.ts
Normal file
39
src/services/apps/redux/reducers.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { AppActionTypes } from './actions';
|
||||||
|
|
||||||
|
const initialUsersState: any = {
|
||||||
|
users: [],
|
||||||
|
user: {},
|
||||||
|
userModalLoading: false,
|
||||||
|
usersLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const appsReducer = (state: any = initialUsersState, action: any) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case AppActionTypes.FETCH_APPS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
apps: action.payload,
|
||||||
|
};
|
||||||
|
case AppActionTypes.SET_APP_LOADING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
appLoading: action.payload,
|
||||||
|
};
|
||||||
|
case AppActionTypes.SET_APPS_LOADING:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
appsLoading: action.payload,
|
||||||
|
};
|
||||||
|
case AppActionTypes.FETCH_APP:
|
||||||
|
case AppActionTypes.UPDATE_APP:
|
||||||
|
case AppActionTypes.INSTALL_APP:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentApp: action.payload,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default appsReducer;
|
6
src/services/apps/redux/selectors.ts
Normal file
6
src/services/apps/redux/selectors.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { State } from 'src/redux';
|
||||||
|
|
||||||
|
export const getApps = (state: State) => state.apps.apps;
|
||||||
|
export const getCurrentApp = (state: State) => state.apps.currentApp;
|
||||||
|
export const getAppLoading = (state: State) => state.apps.appLoading;
|
||||||
|
export const getAppsLoading = (state: State) => state.apps.appsLoading;
|
14
src/services/apps/redux/types.ts
Normal file
14
src/services/apps/redux/types.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { ApiStatus } from 'src/services/api/redux';
|
||||||
|
|
||||||
|
import { App } from '../types';
|
||||||
|
|
||||||
|
export interface CurrentUserState extends App {
|
||||||
|
_status: ApiStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppsState {
|
||||||
|
currentApp: CurrentUserState;
|
||||||
|
apps: App[];
|
||||||
|
appLoading: boolean;
|
||||||
|
appsLoading: boolean;
|
||||||
|
}
|
37
src/services/apps/transformations.ts
Normal file
37
src/services/apps/transformations.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { App, AppStatus, FormApp } from './types';
|
||||||
|
|
||||||
|
const transformAppStatus = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'installed':
|
||||||
|
return AppStatus.Installed;
|
||||||
|
case 'installing':
|
||||||
|
return AppStatus.Installing;
|
||||||
|
case 'not_installed':
|
||||||
|
return AppStatus.NotInstalled;
|
||||||
|
default:
|
||||||
|
return AppStatus.NotInstalled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformApp = (response: any): App => {
|
||||||
|
return {
|
||||||
|
id: response.id ?? '',
|
||||||
|
name: response.name ?? '',
|
||||||
|
slug: response.slug ?? '',
|
||||||
|
status: transformAppStatus(response.status),
|
||||||
|
subdomain: response.subdomain,
|
||||||
|
automaticUpdates: response.automatic_updates,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformAppRequest = (data: FormApp) => {
|
||||||
|
return {
|
||||||
|
automatic_updates: data.automaticUpdates,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transformInstallAppRequest = (data: FormApp) => {
|
||||||
|
return {
|
||||||
|
subdomain: data.subdomain,
|
||||||
|
};
|
||||||
|
};
|
18
src/services/apps/types.ts
Normal file
18
src/services/apps/types.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export interface App {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
status?: AppStatus;
|
||||||
|
subdomain?: string;
|
||||||
|
automaticUpdates: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormApp extends App {
|
||||||
|
configuration: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AppStatus {
|
||||||
|
NotInstalled = 'Not installed',
|
||||||
|
Installed = 'Installed',
|
||||||
|
Installing = 'Installing',
|
||||||
|
}
|
Loading…
Reference in a new issue