merge
This commit is contained in:
commit
b2a18c5a21
69 changed files with 2122 additions and 119 deletions
16
src/App.tsx
16
src/App.tsx
|
|
@ -3,13 +3,16 @@ import { Helmet } from 'react-helmet';
|
|||
import { Toaster } from 'react-hot-toast';
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { Layout } from './components';
|
||||
import { useAuth } from 'src/services/auth';
|
||||
import { Dashboard } from './modules';
|
||||
import { Layout } from './components';
|
||||
import { AppIframe } from './modules/dashboard/AppIframe';
|
||||
import { Login } from './modules/login';
|
||||
import { LoginCallback } from './modules/login/LoginCallback';
|
||||
import { useApps } from './services/apps';
|
||||
import { useAuth } from './services/auth';
|
||||
import { Login } from './modules/login';
|
||||
import { Users } from './modules/users/Users';
|
||||
import { AppSingle } from './modules/apps/AppSingle';
|
||||
import { Apps } from './modules/apps/Apps';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function App() {
|
||||
|
|
@ -53,6 +56,13 @@ function App() {
|
|||
{apps.map((app) => (
|
||||
<Route key={app.name} path={app.slug} element={<AppIframe app={app} />} />
|
||||
))}
|
||||
<Route path="/users" element={<ProtectedRoute />}>
|
||||
<Route index element={<Users />} />
|
||||
</Route>
|
||||
<Route path="/apps" element={<ProtectedRoute />}>
|
||||
<Route path=":slug" element={<AppSingle />} />
|
||||
<Route index element={<Apps />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
|
|
|||
43
src/components/Form/Checkbox/Checkbox.tsx
Normal file
43
src/components/Form/Checkbox/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import { useController } from 'react-hook-form';
|
||||
|
||||
/* eslint-disable react/react-in-jsx-scope */
|
||||
export const Checkbox = ({ control, name, label, ...props }: CheckboxProps) => {
|
||||
const {
|
||||
field,
|
||||
// fieldState: { invalid, isTouched, isDirty },
|
||||
// formState: { touchedFields, dirtyFields },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && (
|
||||
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={name}
|
||||
onChange={field.onChange} // send value to hook form
|
||||
onBlur={field.onBlur} // notify when input is touched/blur
|
||||
checked={field.value}
|
||||
name={name} // send down the checkbox name
|
||||
className="shadow-sm focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type CheckboxProps = {
|
||||
control: any;
|
||||
name: string;
|
||||
id?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
1
src/components/Form/Checkbox/index.ts
Normal file
1
src/components/Form/Checkbox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Checkbox } from './Checkbox';
|
||||
39
src/components/Form/CodeEditor/CodeEditor.tsx
Normal file
39
src/components/Form/CodeEditor/CodeEditor.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import { useController } from 'react-hook-form';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
|
||||
/* eslint-disable react/react-in-jsx-scope */
|
||||
export const CodeEditor = ({ control, name, required, disabled = false }: CodeEditorProps) => {
|
||||
const {
|
||||
field,
|
||||
// fieldState: { invalid, isTouched, isDirty },
|
||||
// formState: { touchedFields, dirtyFields },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
rules: { required },
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
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"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type CodeEditorProps = {
|
||||
control: any;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
1
src/components/Form/CodeEditor/index.ts
Normal file
1
src/components/Form/CodeEditor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CodeEditor } from './CodeEditor';
|
||||
51
src/components/Form/Switch/Switch.tsx
Normal file
51
src/components/Form/Switch/Switch.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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 }: SwitchProps) => {
|
||||
const {
|
||||
field,
|
||||
// fieldState: { invalid, isTouched, isDirty },
|
||||
// formState: { touchedFields, dirtyFields },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
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;
|
||||
};
|
||||
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,3 +1,6 @@
|
|||
export { Input } from './Input';
|
||||
export { Select } from './Select';
|
||||
export { Switch } from './Switch';
|
||||
export { CodeEditor } from './CodeEditor';
|
||||
export { TextArea } from './TextArea';
|
||||
export { Checkbox } from './Checkbox';
|
||||
|
|
|
|||
|
|
@ -1,17 +1,67 @@
|
|||
import React from 'react';
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import React, { Fragment, useMemo, useState } from 'react';
|
||||
import { Disclosure, Menu, Transition } from '@headlessui/react';
|
||||
import { MenuIcon, XIcon } from '@heroicons/react/outline';
|
||||
import clsx from 'clsx';
|
||||
import { useAuth } from 'src/services/auth';
|
||||
import Gravatar from 'react-gravatar';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useApps } from 'src/services/apps';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { UserModal } from '../UserModal';
|
||||
|
||||
const HYDRA_LOGOUT_URL = `${process.env.REACT_APP_HYDRA_PUBLIC_URL}/oauth2/sessions/logout`;
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', to: '/dashboard', requiresAdmin: false },
|
||||
{ name: 'Users', to: '/users', requiresAdmin: true },
|
||||
{ name: 'Apps', to: '/apps', requiresAdmin: true },
|
||||
];
|
||||
|
||||
function classNames(...classes: any[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function filterNavigationByDashboardRole(isAdmin: boolean) {
|
||||
if (isAdmin) {
|
||||
return navigation;
|
||||
}
|
||||
|
||||
return navigation.filter((item) => !item.requiresAdmin);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface HeaderProps {}
|
||||
|
||||
const Header: React.FC<HeaderProps> = () => {
|
||||
const [currentUserModal, setCurrentUserModal] = useState(false);
|
||||
const [currentUserId, setCurrentUserId] = useState(null);
|
||||
const { logOut, currentUser, isAdmin } = useAuth();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const { apps, loadApps, appTableLoading } = useApps();
|
||||
|
||||
const currentUserModalOpen = (id: any) => {
|
||||
setCurrentUserId(id);
|
||||
setCurrentUserModal(true);
|
||||
};
|
||||
|
||||
const currentUserModalClose = () => {
|
||||
setCurrentUserModal(false);
|
||||
setCurrentUserId(null);
|
||||
};
|
||||
|
||||
const navigationItems = filterNavigationByDashboardRole(isAdmin);
|
||||
|
||||
const signOutUrl = useMemo(() => {
|
||||
const { hostname } = window.location;
|
||||
// If we are developing locally, we need to use the init cluster's public URL
|
||||
if (hostname === 'localhost') {
|
||||
return HYDRA_LOGOUT_URL;
|
||||
}
|
||||
return `https://${hostname.replace(/^dashboard/, 'sso')}/oauth2/sessions/logout`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Disclosure as="nav" className="bg-white shadow relative z-10">
|
||||
|
|
@ -54,33 +104,86 @@ const Header: React.FC<HeaderProps> = () => {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
|
||||
{/* Profile dropdown */}
|
||||
<Menu as="div" className="ml-3 relative">
|
||||
<div>
|
||||
<Menu.Button className="bg-white rounded-full flex text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<span className="inline-flex items-center justify-center h-8 w-8 rounded-full bg-gray-500 overflow-hidden">
|
||||
<Gravatar email={currentUser?.email || undefined} size={32} rating="pg" protocol="https://" />
|
||||
</span>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
onClick={() => currentUserModalOpen(currentUser?.id)}
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 cursor-pointer' : '',
|
||||
'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
Configure profile
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
onClick={() => logOut()}
|
||||
href={signOutUrl}
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 cursor-pointer' : '',
|
||||
'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
|
||||
)}
|
||||
>
|
||||
Sign out
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Disclosure.Panel className="sm:hidden">
|
||||
<div className="pt-2 pb-4 space-y-1">
|
||||
{
|
||||
// @ts-ignore
|
||||
apps.map((app) => (
|
||||
<Link
|
||||
key={app.name}
|
||||
to={app.slug}
|
||||
className={clsx(
|
||||
'border-transparent litbutton block pl-3 pr-4 py-2 border-l-4 litbutton text-base font-medium',
|
||||
{
|
||||
'litbutton-active border-primary-400 block pl-3 pr-4 py-2': pathname.includes(app.slug),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{app.name}
|
||||
</Link>
|
||||
))
|
||||
}
|
||||
{apps.map((app) => (
|
||||
<Link
|
||||
key={app.name}
|
||||
to={app.slug}
|
||||
className={clsx(
|
||||
'border-transparent litbutton block pl-3 pr-4 py-2 border-l-4 litbutton text-base font-medium',
|
||||
{
|
||||
'litbutton-active border-primary-400 block pl-3 pr-4 py-2': pathname.includes(app.slug),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{app.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
|
||||
{currentUserModal && (
|
||||
<UserModal open={currentUserModal} onClose={currentUserModalClose} userId={currentUserId} setUserId={_.noop} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ export const Modal: React.FC<ModalProps> = ({
|
|||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
saveButtonTitle = 'Save Changes',
|
||||
children,
|
||||
title = '',
|
||||
useCancelButton = false,
|
||||
cancelButtonTitle = 'Cancel',
|
||||
isLoading = false,
|
||||
leftActions = <></>,
|
||||
saveButtonDisabled = false,
|
||||
|
|
@ -94,7 +96,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||
ref={saveButtonRef}
|
||||
disabled={saveButtonDisabled}
|
||||
>
|
||||
Save Changes
|
||||
{saveButtonTitle}
|
||||
</button>
|
||||
{useCancelButton && (
|
||||
<button
|
||||
|
|
@ -103,7 +105,7 @@ export const Modal: React.FC<ModalProps> = ({
|
|||
onClick={onClose}
|
||||
ref={cancelButtonRef}
|
||||
>
|
||||
Cancel
|
||||
{cancelButtonTitle}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ export type ModalProps = {
|
|||
onClose: () => void;
|
||||
title?: string;
|
||||
onSave?: () => void;
|
||||
saveButtonTitle?: string;
|
||||
useCancelButton?: boolean;
|
||||
cancelButtonTitle?: string;
|
||||
isLoading?: boolean;
|
||||
leftActions?: React.ReactNode;
|
||||
saveButtonDisabled?: boolean;
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export const Table = <T extends Record<string, unknown>>({
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-primary-600 mt-2">Loading users</p>
|
||||
<p className="text-sm text-primary-600 mt-2">Loading...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ import React, { useState } from 'react';
|
|||
import { TabPanel } from './TabPanel';
|
||||
import { TabsProps } from './types';
|
||||
|
||||
export const Tabs = ({ tabs }: TabsProps) => {
|
||||
export const Tabs = ({ tabs, onTabClick }: TabsProps) => {
|
||||
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
|
||||
|
||||
const handleTabPress = (index: number) => () => {
|
||||
setActiveTabIndex(index);
|
||||
if (onTabClick) {
|
||||
onTabClick(index);
|
||||
}
|
||||
};
|
||||
|
||||
function classNames(...classes: any) {
|
||||
|
|
@ -23,7 +26,6 @@ export const Tabs = ({ tabs }: TabsProps) => {
|
|||
id="tabs"
|
||||
name="tabs"
|
||||
className="block w-full focus:ring-primary-500 focus:border-primary-500 border-gray-300 rounded-md"
|
||||
// defaultValue={tabs ? tabs.find((tab) => tab.current).name : undefined}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.name}>{tab.name}</option>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ type Tab = {
|
|||
|
||||
export interface TabsProps {
|
||||
tabs: Tab[];
|
||||
onTabClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface TabPanelProps {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Banner, Modal, ConfirmationModal } from 'src/components';
|
|||
import { Input, Select } from 'src/components/Form';
|
||||
import { User, UserRole, useUsers } from 'src/services/users';
|
||||
import { useAuth } from 'src/services/auth';
|
||||
import { HIDDEN_APPS } from 'src/modules/dashboard/consts';
|
||||
import { appAccessList, initialUserForm } from './consts';
|
||||
import { UserModalProps } from './types';
|
||||
|
||||
|
|
@ -221,7 +222,7 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
|
|||
<div className="flow-root mt-6">
|
||||
<ul className="-my-5 divide-y divide-gray-200">
|
||||
{fields.map((item, index) => {
|
||||
if (item.name === 'dashboard') {
|
||||
if (item.name != null && HIDDEN_APPS.indexOf(item.name) !== -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,21 +5,31 @@ export const appAccessList = [
|
|||
name: 'wekan',
|
||||
image: '/assets/wekan.svg',
|
||||
label: 'Wekan',
|
||||
documentationUrl: 'https://github.com/wekan/wekan/wiki',
|
||||
},
|
||||
{
|
||||
name: 'wordpress',
|
||||
image: '/assets/wordpress.svg',
|
||||
label: 'Wordpress',
|
||||
documentationUrl: 'https://wordpress.org/support/',
|
||||
},
|
||||
{
|
||||
name: 'nextcloud',
|
||||
image: '/assets/nextcloud.svg',
|
||||
label: 'Nextcloud',
|
||||
documentationUrl: 'https://docs.nextcloud.com/server/latest/user_manual/en/',
|
||||
},
|
||||
{
|
||||
name: 'zulip',
|
||||
image: '/assets/zulip.svg',
|
||||
label: 'Zulip',
|
||||
documentationUrl: 'https://docs.zulip.com/help/',
|
||||
},
|
||||
{
|
||||
name: 'monitoring',
|
||||
image: '/assets/monitoring.svg',
|
||||
label: 'Monitoring',
|
||||
documentationUrl: 'https://grafana.com/docs/',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -53,6 +63,10 @@ export const initialAppRoles = [
|
|||
name: 'zulip',
|
||||
role: UserRole.User,
|
||||
},
|
||||
{
|
||||
name: 'monitoring',
|
||||
role: UserRole.NoAccess,
|
||||
},
|
||||
];
|
||||
|
||||
export const initialUserForm = {
|
||||
|
|
|
|||
173
src/modules/apps/AppSingle.tsx
Normal file
173
src/modules/apps/AppSingle.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* This page shows information about a single application. It contains several
|
||||
* configuration options (that are not implemented in the back-end yet) such as:
|
||||
*
|
||||
* 1. Toggling auto-updates
|
||||
* 2. Advanced configuration by overwriting helm values
|
||||
* 3. Deleting the application
|
||||
*
|
||||
* This page is only available for Admin users.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import _ from 'lodash';
|
||||
import { XCircleIcon } from '@heroicons/react/outline';
|
||||
import { DisableAppForm, useApps } from 'src/services/apps';
|
||||
import { Modal, Tabs } from 'src/components';
|
||||
import { Checkbox } from 'src/components/Form';
|
||||
import { appAccessList } from 'src/components/UserModal/consts';
|
||||
import { AdvancedTab, GeneralTab } from './components';
|
||||
|
||||
export const AppSingle: React.FC = () => {
|
||||
const [disableAppModal, setDisableAppModal] = useState(false);
|
||||
const [removeAppData, setRemoveAppData] = useState(false);
|
||||
const params = useParams();
|
||||
const appSlug = params.slug;
|
||||
const { app, loadApp, disableApp, clearSelectedApp } = useApps();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const initialDisableData = { slug: appSlug, removeAppData: false };
|
||||
|
||||
const { control, reset, handleSubmit } = useForm<DisableAppForm>({
|
||||
defaultValues: initialDisableData,
|
||||
});
|
||||
|
||||
const removeAppDataWatch = useWatch({
|
||||
control,
|
||||
name: 'removeAppData',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setRemoveAppData(removeAppDataWatch);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [removeAppDataWatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appSlug) {
|
||||
loadApp(appSlug);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearSelectedApp();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appSlug]);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appImageSrc = _.find(appAccessList, { name: appSlug })?.image;
|
||||
const appDocumentationUrl = _.find(appAccessList, { name: appSlug })?.documentationUrl;
|
||||
|
||||
const openDocumentationInNewTab = () => {
|
||||
window.open(appDocumentationUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'General',
|
||||
component: <GeneralTab />,
|
||||
},
|
||||
{ name: 'Advanced Configuration', component: <AdvancedTab /> },
|
||||
];
|
||||
|
||||
const onDisableApp = async () => {
|
||||
try {
|
||||
await handleSubmit((data) => disableApp(data))();
|
||||
} catch (e: any) {
|
||||
// Continue
|
||||
}
|
||||
setDisableAppModal(false);
|
||||
clearSelectedApp();
|
||||
navigate('/apps');
|
||||
};
|
||||
|
||||
const handleCloseDisableModal = () => {
|
||||
reset(initialDisableData);
|
||||
setDisableAppModal(false);
|
||||
};
|
||||
|
||||
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="block bg-white overflow-hidden shadow rounded-sm basis-4/12 mx-auto sm:px-6 lg:px-8 overflow-hidden block lg:flex-none"
|
||||
style={{ height: 'fit-content' }}
|
||||
>
|
||||
<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={appImageSrc} alt={app.name} />
|
||||
<div className="mb-3">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-yellow-900 bg-yellow-300 hover:bg-yellow-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
|
||||
onClick={() => setDisableAppModal(true)}
|
||||
>
|
||||
<XCircleIcon className="-ml-0.5 mr-2 h-4 w-4 text-yellow-900" aria-hidden="true" />
|
||||
Disable App
|
||||
</button>
|
||||
<button
|
||||
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 justify-center"
|
||||
onClick={openDocumentationInNewTab}
|
||||
>
|
||||
View manual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto sm:mt-8 lg:mt-0 lg:px-8 overflow-hidden block">
|
||||
<div className="bg-white sm:px-6 shadow rounded-sm basis-8/12">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<Tabs tabs={tabs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{disableAppModal && (
|
||||
<Modal
|
||||
onClose={handleCloseDisableModal}
|
||||
open={disableAppModal}
|
||||
onSave={onDisableApp}
|
||||
saveButtonTitle={removeAppData ? `Yes, delete and it's data` : 'Yes, delete'}
|
||||
cancelButtonTitle="No, cancel"
|
||||
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">Disable app</h3>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
Are you sure you want to disable {app.name}? The app will get uninstalled and none of your users will
|
||||
be able to access the app.
|
||||
</div>
|
||||
<fieldset className="px-4 py-5 sm:p-6">
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<Checkbox control={control} name="removeAppData" id="removeAppData" />
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="removeAppData" className="font-medium text-gray-700">
|
||||
Remove app data
|
||||
</label>
|
||||
<p id="removeAppData-description" className="text-gray-500">
|
||||
{removeAppData
|
||||
? `The app's data will be removed. After this operation is done you will not be able to access the app, nor the app data. If you re-install the app, it will have none of the data it had before.`
|
||||
: `The app's data does not get removed. If you install the app again, you will be able to access the data again.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
178
src/modules/apps/Apps.tsx
Normal file
178
src/modules/apps/Apps.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/**
|
||||
* This page shows all the applications and their status in a table.
|
||||
*
|
||||
* This page is only available for Admin users.
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
// import { useNavigate } from 'react-router';
|
||||
import { SearchIcon } from '@heroicons/react/solid';
|
||||
import { showToast, ToastType } from 'src/common/util/show-toast';
|
||||
import _, { debounce } from 'lodash';
|
||||
import { Table } from 'src/components';
|
||||
import { App, AppStatusEnum, useApps } from 'src/services/apps';
|
||||
// import { AppInstallModal } from './components';
|
||||
import { getConstForStatus } from './consts';
|
||||
|
||||
export const Apps: React.FC = () => {
|
||||
// If you want to enable the App Install button again, uncomment this:
|
||||
// const [installModalOpen, setInstallModalOpen] = useState(false);
|
||||
// const [appSlug, setAppSlug] = useState(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const { apps, appTableLoading, loadApps } = useApps();
|
||||
|
||||
const handleSearch = useCallback((event: any) => {
|
||||
setSearch(event.target.value);
|
||||
}, []);
|
||||
|
||||
const debouncedSearch = useCallback(debounce(handleSearch, 250), []);
|
||||
|
||||
useEffect(() => {
|
||||
loadApps();
|
||||
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filterSearch = useMemo(() => {
|
||||
return _.filter(apps, (item) => item.name?.toLowerCase().includes(search.toLowerCase()));
|
||||
}, [apps, search]);
|
||||
|
||||
const columns: any = [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
Cell: (e: any) => {
|
||||
const app = e.cell.row.original as App;
|
||||
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={app.assetSrc} 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 AppStatusEnum;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-shrink-0 h-4 w-4 rounded-full bg-${getConstForStatus(status, 'colorClass')}`} />
|
||||
{status === AppStatusEnum.Installing ? (
|
||||
<div
|
||||
className={`ml-2 cursor-pointer text-sm text-${getConstForStatus(status, 'colorClass')}`}
|
||||
onClick={() => showToast('Installing an app can take up to 10 minutes.', ToastType.Success)}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`ml-2 text-sm text-${getConstForStatus(status, 'colorClass')}`}>{status}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
// If you want to enable the App Install button again, uncomment this:
|
||||
//
|
||||
// We need to implement installation and configuration in the back-end to be
|
||||
// able to use those buttons.
|
||||
// {
|
||||
// Header: ' ',
|
||||
// Cell: (e: any) => {
|
||||
// const navigate = useNavigate();
|
||||
// const appStatus = e.cell.row.original.status as AppStatusEnum;
|
||||
// if (appStatus === AppStatusEnum.Installing) {
|
||||
// return null;
|
||||
// }
|
||||
// const { slug } = e.cell.row.original;
|
||||
// let buttonFunction = () => navigate(`/apps/${slug}`);
|
||||
// if (appStatus === AppStatusEnum.NotInstalled) {
|
||||
// buttonFunction = () => {
|
||||
// setAppSlug(slug);
|
||||
// setInstallModalOpen(true);
|
||||
// };
|
||||
// }
|
||||
// return (
|
||||
// <div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
// <button
|
||||
// onClick={buttonFunction}
|
||||
// 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 (
|
||||
<div className="relative">
|
||||
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
|
||||
<div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl leading-6 font-bold text-gray-900">Apps</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-100 my-3 items-center">
|
||||
<div className="flex items-center">
|
||||
<div className="inline-block">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 sr-only">
|
||||
Search candidates
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<div className="relative flex items-stretch flex-grow focus-within:z-10">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="email"
|
||||
id="email"
|
||||
className="focus:ring-primary-dark focus:border-primary-dark block w-full rounded-md pl-10 sm:text-sm border-gray-200"
|
||||
placeholder="Search Apps"
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<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="shadow border-b border-gray-200 sm:rounded-lg">
|
||||
<Table
|
||||
data={_.filter(filterSearch, (app) => app.slug !== 'dashboard') as any}
|
||||
columns={columns}
|
||||
loading={appTableLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
// If you want to enable the App Install button again, uncomment this:
|
||||
//
|
||||
// installModalOpen && (
|
||||
// <AppInstallModal appSlug={appSlug} onClose={() => setInstallModalOpen(false)} open={installModalOpen} />
|
||||
// )
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
180
src/modules/apps/components/AdvancedTab/AdvancedTab.tsx
Normal file
180
src/modules/apps/components/AdvancedTab/AdvancedTab.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
// import { Menu, Transition } from '@headlessui/react';
|
||||
// import { ChevronDownIcon } from '@heroicons/react/solid';
|
||||
import yaml from 'js-yaml';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import 'prismjs/components/prism-clike';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/themes/prism.css';
|
||||
import { showToast, ToastType } from 'src/common/util/show-toast';
|
||||
import { useApps } from 'src/services/apps';
|
||||
import { initialEditorYaml } from '../../consts';
|
||||
|
||||
export const AdvancedTab = () => {
|
||||
const [code, setCode] = React.useState(initialEditorYaml);
|
||||
const { app, editApp } = useApps();
|
||||
|
||||
const resetCode = () => {
|
||||
setCode(initialEditorYaml);
|
||||
showToast('Code was reset.');
|
||||
};
|
||||
|
||||
const isConfigurationValid = () => {
|
||||
try {
|
||||
yaml.load(code);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCode = () => {
|
||||
if (isConfigurationValid()) {
|
||||
showToast('Configuration is valid.', ToastType.Success);
|
||||
} else {
|
||||
showToast('Configuration is not valid! Please fix configuration issues and try again.', ToastType.Error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveChanges = () => {
|
||||
if (isConfigurationValid()) {
|
||||
editApp({ ...app, configuration: code });
|
||||
return;
|
||||
}
|
||||
showToast('Configuration is not valid! Please fix configuration issues and try again.', ToastType.Error);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pb-5 border-b border-gray-200 sm:flex sm:items-center sm:justify-between mb-5">
|
||||
<h1 className="text-2xl leading-6 font-medium text-gray-900">Configuration</h1>
|
||||
</div>
|
||||
<div className="grid grid-flow-col grid-cols-2 gap-8">
|
||||
<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={initialEditorYaml}
|
||||
onValueChange={_.noop}
|
||||
highlight={(value) => highlight(value, languages.js, 'yaml')}
|
||||
preClassName="font-mono text-sm font-light"
|
||||
textareaClassName="font-mono overflow-auto font-light"
|
||||
className="font-mono text-sm font-light"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
{/* <div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg">
|
||||
<span className="text-gray-600 text-lg leading-6 font-medium">Edit Configuration</span>
|
||||
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-primary-500">
|
||||
Versions
|
||||
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="z-10 origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
Save Version
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
View Version History
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
Restore Current Version
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div> */}
|
||||
<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">Edit Configuration</span>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:p-6 border border-t-0 border-gray-200">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetCode}
|
||||
className="mr-3 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"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={verifyCode}
|
||||
className="mr-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveChanges}
|
||||
className="inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import _ from 'lodash';
|
||||
import { App, useApps } from 'src/services/apps';
|
||||
import { Modal } from 'src/components';
|
||||
import { Input } from 'src/components/Form';
|
||||
import { AppInstallModalProps } from './types';
|
||||
import { initialAppForm, initialCode } from './consts';
|
||||
|
||||
export const AppInstallModal = ({ open, onClose, appSlug }: AppInstallModalProps) => {
|
||||
const [appName, setAppName] = useState('');
|
||||
const { app, appLoading, installApp, loadApp, clearSelectedApp } = useApps();
|
||||
|
||||
const { control, reset, handleSubmit } = useForm<App>({
|
||||
defaultValues: initialAppForm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (appSlug) {
|
||||
loadApp(appSlug);
|
||||
}
|
||||
|
||||
return () => {
|
||||
reset(initialAppForm);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appSlug, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!_.isEmpty(app)) {
|
||||
setAppName(app.name);
|
||||
reset({ url: app.url, configuration: initialCode, slug: appSlug ?? '' });
|
||||
}
|
||||
|
||||
return () => {
|
||||
reset({ url: initialAppForm.url, configuration: initialCode, slug: appSlug ?? '' });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [app, reset, open]);
|
||||
|
||||
const handleClose = () => {
|
||||
clearSelectedApp();
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await handleSubmit((data) => installApp(data))();
|
||||
} catch (e: any) {
|
||||
// Continue
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: any) => {
|
||||
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
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 {appName}</h3>
|
||||
</div>
|
||||
|
||||
<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 control={control} name="url" label="URL" onKeyPress={handleKeyPress} required={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
27
src/modules/apps/components/AppInstallModal/consts.ts
Normal file
27
src/modules/apps/components/AppInstallModal/consts.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { App } from 'src/services/apps';
|
||||
|
||||
export 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 initialAppForm = {
|
||||
url: '',
|
||||
} as App;
|
||||
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;
|
||||
};
|
||||
59
src/modules/apps/components/GeneralTab/GeneralTab.tsx
Normal file
59
src/modules/apps/components/GeneralTab/GeneralTab.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import _ from 'lodash';
|
||||
import React, { useEffect } from 'react';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { Switch } from 'src/components/Form';
|
||||
import { App, useApps } from 'src/services/apps';
|
||||
|
||||
export const GeneralTab = () => {
|
||||
const { app, editApp } = useApps();
|
||||
const { control, reset, handleSubmit } = useForm<App>({
|
||||
defaultValues: { automaticUpdates: false },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!_.isEmpty(app)) {
|
||||
reset(app);
|
||||
}
|
||||
|
||||
return () => {
|
||||
reset({ automaticUpdates: false });
|
||||
};
|
||||
}, [app, reset]);
|
||||
|
||||
const onSubmit: SubmitHandler<App> = (data) => {
|
||||
try {
|
||||
editApp(data);
|
||||
} catch (e: any) {
|
||||
// continue
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-4">
|
||||
<div className="md:grid md:grid-cols-3 md:gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Automatic updates</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla et nibh sit amet mauris faucibus molestie
|
||||
gravida at orci.
|
||||
</p>
|
||||
</div>
|
||||
<div className="my-auto ml-auto">
|
||||
<Switch control={control} name="automaticUpdates" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 sm:flex">
|
||||
<div className="ml-auto sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
3
src/modules/apps/components/index.ts
Normal file
3
src/modules/apps/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { GeneralTab } from './GeneralTab/GeneralTab';
|
||||
export { AdvancedTab } from './AdvancedTab/AdvancedTab';
|
||||
export { AppInstallModal } from './AppInstallModal';
|
||||
52
src/modules/apps/consts.tsx
Normal file
52
src/modules/apps/consts.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import { CogIcon, PlusCircleIcon } from '@heroicons/react/outline';
|
||||
import _ from 'lodash';
|
||||
import { AppStatusEnum } from 'src/services/apps';
|
||||
|
||||
export const initialEditorYaml = `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`;
|
||||
|
||||
const tableConsts = [
|
||||
{
|
||||
status: AppStatusEnum.Installed,
|
||||
colorClass: 'green-600',
|
||||
buttonTitle: 'Configure',
|
||||
buttonIcon: <CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
status: AppStatusEnum.NotInstalled,
|
||||
colorClass: 'gray-600',
|
||||
buttonTitle: 'Install',
|
||||
buttonIcon: <PlusCircleIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
status: AppStatusEnum.Installing,
|
||||
colorClass: 'primary-600',
|
||||
buttonTitle: null,
|
||||
buttonIcon: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const getConstForStatus = (appStatus: AppStatusEnum, paramName: string) => {
|
||||
const tableConst = _.find(tableConsts, { status: appStatus });
|
||||
return _.get(tableConst, paramName);
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
export const DashboardUtility: React.FC<any> = ({ item }: { item: any }) => {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(item.markdownSrc)
|
||||
.then((res) => res.text())
|
||||
.then((md) => {
|
||||
return setContent(md);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [item.markdownSrc]);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={item.url}
|
||||
key={item.name}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="bg-white rounded-lg overflow-hidden sm:p-2 flex items-center group"
|
||||
>
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-primary-100 group-hover:bg-primary-200 transition-colors rounded-lg mr-4">
|
||||
{item.icon && <item.icon className="h-6 w-6 text-primary-900" aria-hidden="true" />}
|
||||
{item.assetSrc && <img className="h-6 w-6" src={item.assetSrc} alt={item.name} />}
|
||||
</div>
|
||||
<div>
|
||||
<dt className="truncate text-sm leading-5 font-medium">{item.name}</dt>
|
||||
<dd className="mt-1 text-gray-500 text-sm leading-5 font-normal">
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</dd>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { DashboardUtility } from './DashboardUtility';
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { DashboardCard } from './DashboardCard';
|
||||
export { DashboardUtility } from './DashboardUtility';
|
||||
|
|
|
|||
16
src/modules/dashboard/consts.ts
Normal file
16
src/modules/dashboard/consts.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { InformationCircleIcon } from '@heroicons/react/outline';
|
||||
|
||||
export const DASHBOARD_QUICK_ACCESS = [
|
||||
{
|
||||
name: 'Support',
|
||||
url: 'https://docs.stackspin.net',
|
||||
markdownSrc: '/markdown/support.md',
|
||||
icon: InformationCircleIcon,
|
||||
},
|
||||
];
|
||||
|
||||
/** Apps that should not be shown on the dashboard */
|
||||
export const HIDDEN_APPS = ['dashboard', 'velero'];
|
||||
|
||||
/** Apps that should be shown under "Utilities" */
|
||||
export const UTILITY_APPS = ['monitoring'];
|
||||
190
src/modules/users/Users.tsx
Normal file
190
src/modules/users/Users.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/**
|
||||
* This page shows a table of all users. It is only available for Admin users.
|
||||
*
|
||||
* Admin users can add one or more users, or edit a user.
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { SearchIcon, PlusIcon, ViewGridAddIcon } from '@heroicons/react/solid';
|
||||
import { CogIcon, TrashIcon } from '@heroicons/react/outline';
|
||||
import { useUsers } from 'src/services/users';
|
||||
import { Table } from 'src/components';
|
||||
import { debounce } from 'lodash';
|
||||
import { useAuth } from 'src/services/auth';
|
||||
|
||||
import { UserModal } from '../../components/UserModal';
|
||||
// import { MultipleUsersModal } from '../components/multipleUsersModal';
|
||||
|
||||
export const Users: React.FC = () => {
|
||||
const [selectedRowsIds, setSelectedRowsIds] = useState({});
|
||||
const [configureModal, setConfigureModal] = useState(false);
|
||||
const [multipleUsersModal, setMultipleUsersModal] = useState(false);
|
||||
const [userId, setUserId] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const { users, loadUsers, userTableLoading } = useUsers();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const handleSearch = (event: any) => {
|
||||
setSearch(event.target.value);
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback(debounce(handleSearch, 250), []);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filterSearch = useMemo(() => {
|
||||
return users.filter((item: any) => item.email?.toLowerCase().includes(search.toLowerCase()));
|
||||
}, [search, users]);
|
||||
|
||||
const configureModalOpen = (id: any) => {
|
||||
setUserId(id);
|
||||
setConfigureModal(true);
|
||||
};
|
||||
|
||||
const configureModalClose = () => setConfigureModal(false);
|
||||
|
||||
const multipleUsersModalClose = () => setMultipleUsersModal(false);
|
||||
|
||||
const columns: any = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: 'Email',
|
||||
accessor: 'email',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: 'Status',
|
||||
accessor: 'status',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: ' ',
|
||||
Cell: (props: any) => {
|
||||
const { row } = props;
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<div className="text-right lg:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => configureModalOpen(row.original.id)}
|
||||
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"
|
||||
>
|
||||
<CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
],
|
||||
[isAdmin],
|
||||
);
|
||||
|
||||
const selectedRows = useCallback((rows: Record<string, boolean>) => {
|
||||
setSelectedRowsIds(rows);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
|
||||
<div className="pb-5 mt-6 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl leading-6 font-bold text-gray-900">Users</h1>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mt-3 sm:mt-0 sm:ml-4">
|
||||
<button
|
||||
onClick={() => configureModalOpen(null)}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800 mx-5 "
|
||||
>
|
||||
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Add new user
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMultipleUsersModal(true)}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
|
||||
>
|
||||
<ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Add new users
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-100 my-3 items-center mb-5 ">
|
||||
<div className="flex items-center">
|
||||
<div className="inline-block">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 sr-only">
|
||||
Search candidates
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<div className="relative flex items-stretch flex-grow focus-within:z-10">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="email"
|
||||
id="email"
|
||||
className="focus:ring-primary-500 focus:border-primary-500 block w-full rounded-md pl-10 sm:text-sm border-gray-200"
|
||||
placeholder="Search Users"
|
||||
onChange={debouncedSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => {}}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto 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="shadow border-b border-gray-200 sm:rounded-lg overflow-hidden">
|
||||
<Table
|
||||
data={filterSearch as any}
|
||||
columns={columns}
|
||||
getSelectedRowIds={selectedRows}
|
||||
loading={userTableLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configureModal && (
|
||||
<UserModal open={configureModal} onClose={configureModalClose} userId={userId} setUserId={setUserId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,9 +6,7 @@ import storage from 'redux-persist/lib/storage';
|
|||
import { reducer as authReducer } from 'src/services/auth';
|
||||
|
||||
import usersReducer from 'src/services/users/redux/reducers';
|
||||
|
||||
import appsReducer from 'src/services/apps/redux/reducers';
|
||||
|
||||
import { State } from './types';
|
||||
|
||||
const persistConfig = {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,48 @@
|
|||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { fetchApps } from '../redux/actions';
|
||||
import { getApps } from '../redux';
|
||||
import { getAppById, getAppslLoading } from '../redux/selectors';
|
||||
import { fetchApps, fetchAppBySlug, updateApp, installAppBySlug, clearCurrentApp, deleteApp } from '../redux';
|
||||
import { getCurrentApp, getAppLoading, getAppsLoading, getApps } from '../redux/selectors';
|
||||
|
||||
export function useApps() {
|
||||
const dispatch = useDispatch();
|
||||
const apps = useSelector(getApps);
|
||||
const app = useSelector(getAppById);
|
||||
const appTableLoading = useSelector(getAppslLoading);
|
||||
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 editApp(data: any) {
|
||||
return dispatch(updateApp(data));
|
||||
}
|
||||
|
||||
function installApp(data: any) {
|
||||
return dispatch(installAppBySlug(data));
|
||||
}
|
||||
|
||||
function disableApp(data: any) {
|
||||
return dispatch(deleteApp(data));
|
||||
}
|
||||
|
||||
function clearSelectedApp() {
|
||||
return dispatch(clearCurrentApp());
|
||||
}
|
||||
|
||||
return {
|
||||
apps,
|
||||
app,
|
||||
loadApp,
|
||||
loadApps,
|
||||
editApp,
|
||||
appLoading,
|
||||
appTableLoading,
|
||||
installApp,
|
||||
disableApp,
|
||||
clearSelectedApp,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
export * from './types';
|
||||
|
||||
export { reducer } from './redux';
|
||||
|
||||
export { useApps } from './hooks';
|
||||
|
||||
export { fetchApps } from './api';
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
import _ from 'lodash';
|
||||
import { Dispatch } from 'redux';
|
||||
import { showToast, ToastType } from 'src/common/util/show-toast';
|
||||
import { State } from 'src/redux/types';
|
||||
import { performApiCall } from 'src/services/api';
|
||||
import { AuthActionTypes } from 'src/services/auth';
|
||||
import { transformApp } from '../transformations';
|
||||
import { transformAppRequest, transformApp, transformInstallAppRequest } from '../transformations';
|
||||
|
||||
export enum AppActionTypes {
|
||||
FETCH_APPS = 'apps/fetch_apps',
|
||||
FETCH_APP = 'apps/fetch_app',
|
||||
UPDATE_APP = 'apps/update_app',
|
||||
CREATE_APP = 'apps/create_app',
|
||||
INSTALL_APP = 'apps/install_app',
|
||||
DELETE_APP = 'apps/delete_app',
|
||||
SET_APP_MODAL_LOADING = 'apps/app_modal_loading',
|
||||
CLEAR_APP = 'apps/clear_app',
|
||||
SET_APP_LOADING = 'apps/app_loading',
|
||||
SET_APPS_LOADING = 'apps/apps_loading',
|
||||
CREATE_BATCH_APPS = 'apps/create_batch_apps',
|
||||
}
|
||||
|
||||
export const setAppsLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
|
||||
|
|
@ -24,9 +21,9 @@ export const setAppsLoading = (isLoading: boolean) => (dispatch: Dispatch<any>)
|
|||
});
|
||||
};
|
||||
|
||||
export const setAppModalLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
|
||||
export const setAppLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
type: AppActionTypes.SET_APP_MODAL_LOADING,
|
||||
type: AppActionTypes.SET_APP_LOADING,
|
||||
payload: isLoading,
|
||||
});
|
||||
};
|
||||
|
|
@ -51,12 +48,12 @@ export const fetchApps = () => async (dispatch: Dispatch<any>) => {
|
|||
dispatch(setAppsLoading(false));
|
||||
};
|
||||
|
||||
export const fetchAPPById = (id: string) => async (dispatch: Dispatch<any>) => {
|
||||
dispatch(setAppModalLoading(true));
|
||||
export const fetchAppBySlug = (slug: string) => async (dispatch: Dispatch<any>) => {
|
||||
dispatch(setAppLoading(true));
|
||||
|
||||
try {
|
||||
const { data } = await performApiCall({
|
||||
path: `/apps/${id}`,
|
||||
path: `/apps/${slug}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
|
|
@ -68,5 +65,91 @@ export const fetchAPPById = (id: string) => async (dispatch: Dispatch<any>) => {
|
|||
console.error(err);
|
||||
}
|
||||
|
||||
dispatch(setAppModalLoading(false));
|
||||
dispatch(setAppLoading(false));
|
||||
};
|
||||
|
||||
export const updateApp = (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 is updated!', 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: 'PATCH',
|
||||
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));
|
||||
};
|
||||
|
||||
export const deleteApp = (appData: any) => async (dispatch: Dispatch<any>) => {
|
||||
dispatch(setAppLoading(true));
|
||||
|
||||
try {
|
||||
await performApiCall({
|
||||
path: `/apps/${appData.slug}`,
|
||||
method: 'DELETE',
|
||||
body: { remove_app_data: appData.removeAppData },
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: AppActionTypes.DELETE_APP,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
showToast('App disabled', ToastType.Success);
|
||||
|
||||
dispatch(fetchApps());
|
||||
} catch (err: any) {
|
||||
dispatch(setAppLoading(false));
|
||||
showToast(`${err}`, ToastType.Error);
|
||||
throw err;
|
||||
}
|
||||
|
||||
dispatch(setAppLoading(false));
|
||||
};
|
||||
|
||||
export const clearCurrentApp = () => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
type: AppActionTypes.CLEAR_APP,
|
||||
payload: {},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ const appsReducer = (state: any = initialAppsState, action: any) => {
|
|||
...state,
|
||||
apps: action.payload,
|
||||
};
|
||||
case AppActionTypes.SET_APP_MODAL_LOADING:
|
||||
case AppActionTypes.SET_APP_LOADING:
|
||||
return {
|
||||
...state,
|
||||
appModalLoading: action.payload,
|
||||
appLoading: action.payload,
|
||||
};
|
||||
case AppActionTypes.SET_APPS_LOADING:
|
||||
return {
|
||||
|
|
@ -26,17 +26,16 @@ const appsReducer = (state: any = initialAppsState, action: any) => {
|
|||
};
|
||||
case AppActionTypes.FETCH_APP:
|
||||
case AppActionTypes.UPDATE_APP:
|
||||
case AppActionTypes.CREATE_APP:
|
||||
case AppActionTypes.CREATE_BATCH_APPS:
|
||||
case AppActionTypes.INSTALL_APP:
|
||||
return {
|
||||
...state,
|
||||
isModalVisible: false,
|
||||
app: action.payload,
|
||||
currentApp: action.payload,
|
||||
};
|
||||
case AppActionTypes.CLEAR_APP:
|
||||
case AppActionTypes.DELETE_APP:
|
||||
return {
|
||||
...state,
|
||||
app: {},
|
||||
currentApp: {},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { State } from 'src/redux';
|
||||
|
||||
export const getApps = (state: State) => state.apps.apps;
|
||||
export const getAppById = (state: State) => state.apps.app;
|
||||
export const getAppslLoading = (state: State) => state.apps.appsLoading;
|
||||
export const getCurrentApp = (state: State) => state.apps.currentApp;
|
||||
export const getAppLoading = (state: State) => state.apps.appLoading;
|
||||
export const getAppsLoading = (state: State) => state.apps.appsLoading;
|
||||
|
|
|
|||
|
|
@ -9,17 +9,6 @@ export interface CurrentUserState extends App {
|
|||
export interface AppsState {
|
||||
currentApp: CurrentUserState;
|
||||
apps: App[];
|
||||
app: App;
|
||||
appLoading: boolean;
|
||||
appsLoading: boolean;
|
||||
}
|
||||
|
||||
// export interface CurrentUserUpdateAPI {
|
||||
// id: number;
|
||||
// phoneNumber?: string;
|
||||
// email?: string;
|
||||
// language?: string;
|
||||
// country?: string;
|
||||
// firstName?: string;
|
||||
// lastName?: string;
|
||||
// password?: string;
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,10 +1,34 @@
|
|||
import { App } from './types';
|
||||
import { App, AppStatus, AppStatusEnum } from './types';
|
||||
|
||||
const transformAppStatus = (status: AppStatus) => {
|
||||
if (status.installed && status.ready) return AppStatusEnum.Installed;
|
||||
if (status.installed && !status.ready) return AppStatusEnum.Installing;
|
||||
return AppStatusEnum.NotInstalled;
|
||||
};
|
||||
|
||||
export const transformApp = (response: any): App => {
|
||||
return {
|
||||
id: response.id ?? '',
|
||||
name: response.name ?? '',
|
||||
slug: response.slug ?? '',
|
||||
url: response.url ?? '',
|
||||
status: transformAppStatus(response.status),
|
||||
url: response.url,
|
||||
automaticUpdates: response.automatic_updates,
|
||||
assetSrc: `/assets/${response.slug}.svg`,
|
||||
markdownSrc: `/markdown/${response.slug}.md`,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformAppRequest = (data: App) => {
|
||||
return {
|
||||
automatic_updates: data.automaticUpdates,
|
||||
configuration: data.configuration,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformInstallAppRequest = (data: App) => {
|
||||
return {
|
||||
url: data.url,
|
||||
configuration: data.configuration,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,29 @@
|
|||
export interface App {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
status?: AppStatusEnum;
|
||||
url: string;
|
||||
automaticUpdates: boolean;
|
||||
configuration?: string;
|
||||
assetSrc?: string;
|
||||
markdownSrc?: string;
|
||||
}
|
||||
|
||||
export interface DisableAppForm {
|
||||
slug: string;
|
||||
removeAppData: boolean;
|
||||
}
|
||||
|
||||
export interface AppStatus {
|
||||
installed: boolean;
|
||||
ready: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export enum AppStatusEnum {
|
||||
NotInstalled = 'Not installed',
|
||||
Installed = 'Installed',
|
||||
Installing = 'Installing',
|
||||
External = 'External',
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue