diff --git a/package.json b/package.json index b30427360d84d41e403ce3a669a7422ab1881f4c..5739c7e7b632a3f61ebb7c48c22f808353193554 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@types/jest": "^26.0.15", + "@types/js-yaml": "^4.0.5", "@types/node": "^12.0.0", "@types/react-dom": "^17.0.0", "axios": "^0.21.1", @@ -28,8 +29,8 @@ "react-hot-toast": "^2.0.0", "react-markdown": "^7.0.1", "react-redux": "^7.2.4", - "react-router-dom": "6.2.1", "react-router": "6.2.1", + "react-router-dom": "6.2.1", "react-scripts": "4.0.3", "react-simple-code-editor": "^0.11.0", "react-table": "^7.7.0", diff --git a/public/assets/monitoring.svg b/public/assets/monitoring.svg new file mode 100644 index 0000000000000000000000000000000000000000..fa9380825724e61e8f04f7effc0d83c41506ea0a --- /dev/null +++ b/public/assets/monitoring.svg @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 24.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:url(#SVGID_1_);} +</style> +<g> + + <linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-0.7593" y1="513.0059" x2="1.0811" y2="513.0059" gradientTransform="matrix(1.431349e-14 -254.0268 254.0268 1.690345e-14 -130061.25 434.0558)"> + <stop offset="0" style="stop-color:#FFF100"/> + <stop offset="1" style="stop-color:#F05A28"/> + </linearGradient> + <path class="st0" d="M490.8,226c-0.8-8.6-2.3-18.5-5.1-29.5c-2.8-10.9-7.1-22.8-13.3-35.3c-6.2-12.4-14.2-25.2-24.7-37.8 + c-4.1-4.9-8.6-9.7-13.4-14.4c7.2-28.6-8.7-53.5-8.7-53.5c-27.5-1.7-45,8.6-51.5,13.3c-1.1-0.4-2.1-1-3.2-1.4 + c-4.7-1.8-9.5-3.7-14.5-5.2c-4.9-1.6-10-3-15.2-4.2c-5.2-1.3-10.4-2.3-15.8-3.1c-1-0.1-1.8-0.3-2.8-0.4C310.6,16.1,276.1,0,276.1,0 + c-38.5,24.4-45.7,58.5-45.7,58.5s-0.1,0.7-0.4,2c-2.1,0.6-4.2,1.3-6.3,1.8c-3,0.8-5.9,2-8.7,3.1c-3,1.1-5.8,2.3-8.7,3.5 + c-5.8,2.5-11.6,5.4-17.2,8.5c-5.5,3.1-10.9,6.5-16.1,10c-0.7-0.3-1.4-0.6-1.4-0.6C118.2,66.6,70.9,91,70.9,91 + c-4.4,56.7,21.3,92.4,26.4,98.9c-1.3,3.5-2.4,7.1-3.5,10.6c-3.9,12.8-6.9,26-8.7,39.6c-0.3,2-0.6,3.9-0.7,5.9 + c-49.4,24.4-63.9,74.2-63.9,74.2c41,47.3,89,50.2,89,50.2l0.1-0.1c6.1,10.9,13.1,21.2,21,30.9c3.4,4.1,6.8,7.9,10.4,11.7 + c-15,42.9,2.1,78.4,2.1,78.4c45.7,1.7,75.7-20,82.1-25c4.5,1.6,9.2,3,13.8,4.1c14.1,3.7,28.5,5.8,42.9,6.3 + c3.5,0.1,7.2,0.3,10.7,0.1h1.7h1.1h2.3l2.3-0.1v0.1c21.6,30.7,59.4,35.1,59.4,35.1c26.9-28.4,28.5-56.6,28.5-62.6c0,0,0-0.1,0-0.4 + c0-0.6,0-0.8,0-0.8c0-0.4,0-0.8,0-1.3c5.6-4,11-8.2,16.1-12.8c10.7-9.7,20.2-20.9,28.1-32.9c0.7-1.1,1.4-2.3,2.1-3.4 + c30.5,1.7,52-18.9,52-18.9c-5.1-31.7-23.1-47.3-26.9-50.2c0,0-0.1-0.1-0.4-0.3c-0.3-0.1-0.3-0.3-0.3-0.3c-0.1-0.1-0.4-0.3-0.7-0.4 + c0.1-2,0.3-3.8,0.4-5.8c0.3-3.4,0.3-6.9,0.3-10.3V309v-1.3v-0.7c0-0.8,0-0.6,0-0.8l-0.1-2.1l-0.1-2.8c0-1-0.1-1.8-0.3-2.7 + c-0.1-0.8-0.1-1.8-0.3-2.7l-0.3-2.7l-0.4-2.7c-0.6-3.5-1.1-6.9-2-10.4c-3.2-13.7-8.6-26.7-15.5-38.4c-7.1-11.7-15.8-22-25.8-30.7 + c-9.9-8.7-21-15.8-32.6-21c-11.7-5.2-23.8-8.6-36-10.2c-6.1-0.8-12.1-1.1-18.2-1h-2.3h-0.6h-0.7h-1l-2.3,0.1 + c-0.8,0-1.7,0.1-2.4,0.1c-3.1,0.3-6.2,0.7-9.2,1.3c-12.1,2.3-23.6,6.6-33.6,12.7c-10,6.1-18.8,13.5-25.8,22 + c-7.1,8.5-12.6,17.9-16.4,27.6s-5.9,19.9-6.5,29.6c-0.1,2.4-0.1,4.9-0.1,7.3c0,0.6,0,1.3,0,1.8l0.1,2c0.1,1.1,0.1,2.4,0.3,3.5 + c0.4,4.9,1.4,9.7,2.7,14.2c2.7,9.2,6.9,17.5,12.1,24.5c5.2,7.1,11.6,12.8,18.2,17.5c6.6,4.5,13.8,7.8,20.9,9.9 + c7.1,2.1,14.1,3,20.7,3c0.8,0,1.7,0,2.4,0c0.4,0,0.8,0,1.3,0s0.8,0,1.3-0.1c0.7,0,1.4-0.1,2.1-0.1l0.6-0.1l0.7-0.1 + c0.4,0,0.8-0.1,1.3-0.1c0.8-0.1,1.6-0.3,2.4-0.4c0.8-0.1,1.6-0.3,2.3-0.6c1.6-0.3,3-0.8,4.4-1.3c2.8-1,5.6-2.1,8-3.4 + c2.5-1.3,4.8-2.8,7.1-4.2c0.6-0.4,1.3-0.8,1.8-1.4c2.3-1.8,2.7-5.2,0.8-7.5c-1.6-2-4.4-2.5-6.6-1.3c-0.6,0.3-1.1,0.6-1.7,0.8 + c-2,1-3.9,1.8-6.1,2.5c-2.1,0.7-4.4,1.3-6.6,1.7c-1.1,0.1-2.3,0.3-3.5,0.4c-0.6,0-1.1,0.1-1.8,0.1c-0.6,0-1.3,0-1.7,0 + c-0.6,0-1.1,0-1.7,0c-0.7,0-1.4,0-2.1-0.1c0,0-0.4,0-0.1,0h-0.3h-0.4c-0.3,0-0.7,0-1-0.1c-0.7-0.1-1.3-0.1-2-0.3 + c-5.2-0.7-10.4-2.3-15.4-4.5c-5.1-2.3-9.9-5.4-14.2-9.3c-4.4-4-8.2-8.6-11.1-14c-3-5.4-5.1-11.3-6.1-17.5c-0.4-3.1-0.7-6.3-0.6-9.5 + c0-0.8,0.1-1.7,0.1-2.5c0,0.3,0-0.1,0-0.1v-0.3v-0.7c0-0.4,0.1-0.8,0.1-1.3c0.1-1.7,0.4-3.4,0.7-5.1c2.4-13.5,9.2-26.8,19.6-36.8 + c2.7-2.5,5.5-4.8,8.5-6.9c3-2.1,6.2-3.9,9.6-5.5c3.4-1.6,6.8-2.8,10.4-3.8c3.5-1,7.2-1.6,11-2c1.8-0.1,3.7-0.3,5.6-0.3 + c0.6,0,0.8,0,1.3,0h1.6h1c0.4,0,0,0,0.1,0h0.4l1.6,0.1c4.1,0.3,8,0.8,12,1.8c7.9,1.7,15.7,4.7,22.8,8.6c14.4,8,26.7,20.5,34.1,35.4 + c3.8,7.5,6.5,15.5,7.8,23.8c0.3,2.1,0.6,4.2,0.7,6.3l0.1,1.6l0.1,1.6c0,0.6,0,1.1,0,1.6c0,0.6,0,1.1,0,1.6v1.4v1.6 + c0,1-0.1,2.7-0.1,3.7c-0.1,2.3-0.4,4.7-0.7,6.9c-0.3,2.3-0.7,4.5-1.1,6.8c-0.4,2.3-1,4.5-1.6,6.6c-1.1,4.4-2.5,8.7-4.2,13.1 + c-3.4,8.5-7.9,16.6-13.3,24.1c-10.9,15-25.7,27.1-42.6,34.8c-8.5,3.8-17.3,6.6-26.5,8c-4.5,0.8-9.2,1.3-13.8,1.4h-0.8h-0.7h-1.6 + h-2.3h-1.1c0.6,0-0.1,0-0.1,0h-0.4c-2.5,0-4.9-0.1-7.5-0.4c-9.9-0.7-19.6-2.5-29.2-5.2c-9.5-2.7-18.6-6.5-27.4-11 + c-17.3-9.3-33-22-45.1-37.4c-6.1-7.6-11.4-15.9-15.8-24.5c-4.4-8.6-7.9-17.8-10.4-26.9c-2.5-9.3-4.1-18.8-4.8-28.4l-0.1-1.8v-0.4 + v-0.4v-0.8v-1.6v-0.4v-0.6v-1.1V268v-0.4c0,0,0,0.1,0-0.1v-0.8c0-1.1,0-2.4,0-3.5c0.1-4.7,0.6-9.6,1.1-14.4 + c0.6-4.8,1.4-9.7,2.4-14.5c1-4.8,2.1-9.6,3.5-14.4c2.7-9.5,6.1-18.6,10-27.2c8-17.2,18.5-32.6,31-44.9c3.1-3.1,6.3-5.9,9.7-8.7 + c3.4-2.7,6.9-5.2,10.6-7.6c3.5-2.4,7.3-4.5,11.1-6.5c1.8-1,3.8-2,5.8-2.8c1-0.4,2-0.8,3-1.3c1-0.4,2-0.8,3-1.3 + c3.9-1.7,8-3.1,12.3-4.4c1-0.3,2.1-0.6,3.1-1c1-0.3,2.1-0.6,3.1-0.8c2.1-0.6,4.2-1.1,6.3-1.6c1-0.3,2.1-0.4,3.2-0.7 + c1.1-0.3,2.1-0.4,3.2-0.7c1.1-0.1,2.1-0.4,3.2-0.6l1.6-0.3l1.7-0.3c1.1-0.1,2.1-0.3,3.2-0.4c1.3-0.1,2.4-0.3,3.7-0.4 + c1-0.1,2.7-0.3,3.7-0.4c0.7-0.1,1.6-0.1,2.3-0.3l1.6-0.1l0.7-0.1h0.8c1.3-0.1,2.4-0.1,3.7-0.3l1.8-0.1c0,0,0.7,0,0.1,0h0.4h0.8 + c1,0,2.1-0.1,3.1-0.1c4.1-0.1,8.3-0.1,12.4,0c8.2,0.3,16.2,1.3,24,2.7c15.7,3,30.3,7.9,43.7,14.5c13.4,6.5,25.2,14.5,35.7,23.3 + c0.7,0.6,1.3,1.1,2,1.7c0.6,0.6,1.3,1.1,1.8,1.7c1.3,1.1,2.4,2.3,3.7,3.4c1.3,1.1,2.4,2.3,3.5,3.4c1.1,1.1,2.3,2.3,3.4,3.5 + c4.4,4.7,8.5,9.3,12.1,14.1c7.3,9.5,13.3,19,17.9,28.1c0.3,0.6,0.6,1.1,0.8,1.7s0.6,1.1,0.8,1.7c0.6,1.1,1.1,2.3,1.6,3.4 + c0.6,1.1,1,2.1,1.6,3.2c0.4,1.1,1,2.1,1.4,3.2c1.7,4.2,3.4,8.3,4.7,12.1c2.1,6.2,3.7,11.7,4.9,16.5c0.4,2,2.3,3.2,4.2,3 + c2.1-0.1,3.7-1.8,3.7-3.9C491.7,238.9,491.5,232.9,490.8,226z"/> +</g> +</svg> diff --git a/public/markdown/monitoring.md b/public/markdown/monitoring.md new file mode 100644 index 0000000000000000000000000000000000000000..8d03535b686dc41c12e46c8cb1c5620f76a3a1c8 --- /dev/null +++ b/public/markdown/monitoring.md @@ -0,0 +1 @@ +Monitor your system with Grafana diff --git a/public/markdown/support.md b/public/markdown/support.md new file mode 100644 index 0000000000000000000000000000000000000000..efd389bacae0b0d4ca8788b4bb9846a7bd915132 --- /dev/null +++ b/public/markdown/support.md @@ -0,0 +1 @@ +Access documentation website diff --git a/src/App.tsx b/src/App.tsx index b9797b0c763a2b654774ee5b4930004bfa9cfb48..7d2d7e1131480e90e50a7d6a249285a1bdce83bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; import { useAuth } from 'src/services/auth'; -import { Dashboard, Users, Login } from './modules'; +import { Dashboard, Users, Login, Apps, AppSingle } from './modules'; import { Layout } from './components'; import { LoginCallback } from './modules/login/LoginCallback'; @@ -43,7 +43,11 @@ function App() { <Routes> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/users" element={<ProtectedRoute />}> - <Route path="/users" element={<Users />} /> + <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> diff --git a/src/components/Form/Checkbox/Checkbox.tsx b/src/components/Form/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e31c20c725f2144bca2ae19d850bfb4e764f4c39 --- /dev/null +++ b/src/components/Form/Checkbox/Checkbox.tsx @@ -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; +}; diff --git a/src/components/Form/Checkbox/index.ts b/src/components/Form/Checkbox/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f59e4fbb54cf280c10a3e02778f41c4cb09a43a --- /dev/null +++ b/src/components/Form/Checkbox/index.ts @@ -0,0 +1 @@ +export { Checkbox } from './Checkbox'; diff --git a/src/components/Form/CodeEditor/CodeEditor.tsx b/src/components/Form/CodeEditor/CodeEditor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b395f8bd50fb790838349f8bbf1d159f0c3a63b3 --- /dev/null +++ b/src/components/Form/CodeEditor/CodeEditor.tsx @@ -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; +}; diff --git a/src/components/Form/CodeEditor/index.ts b/src/components/Form/CodeEditor/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f6648589e9fda8625b1ae948da0337d71bb8efa --- /dev/null +++ b/src/components/Form/CodeEditor/index.ts @@ -0,0 +1 @@ +export { CodeEditor } from './CodeEditor'; diff --git a/src/components/Form/Switch/Switch.tsx b/src/components/Form/Switch/Switch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d1596daa9ab545da3c4e35a085adb82883343fc2 --- /dev/null +++ b/src/components/Form/Switch/Switch.tsx @@ -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; +}; diff --git a/src/components/Form/Switch/index.ts b/src/components/Form/Switch/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cee89a18b17be637fed0ade07fadb4231d153757 --- /dev/null +++ b/src/components/Form/Switch/index.ts @@ -0,0 +1 @@ +export { Switch } from './Switch'; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index 2cc92984681dc27fabc68665ce8ab44a97834e7e..efb50653d1c3d164af8670013c7a3c1567c81a3f 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -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'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 97a24eda56729ac74454a0b75d12385be3c0d293..0c783e1597290ed72404d259fe90b04d0ac4f62f 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -14,6 +14,7 @@ const HYDRA_LOGOUT_URL = `${process.env.REACT_APP_HYDRA_PUBLIC_URL}/oauth2/sessi const navigation = [ { name: 'Dashboard', to: '/dashboard', requiresAdmin: false }, { name: 'Users', to: '/users', requiresAdmin: true }, + { name: 'Apps', to: '/apps', requiresAdmin: true }, ]; function classNames(...classes: any[]) { diff --git a/src/components/Modal/Modal/Modal.tsx b/src/components/Modal/Modal/Modal.tsx index e61aaee56622b8773885a9fb40af927c4e47c471..d64acfecb2c30877b7187498af02dbd616307510 100644 --- a/src/components/Modal/Modal/Modal.tsx +++ b/src/components/Modal/Modal/Modal.tsx @@ -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> diff --git a/src/components/Modal/Modal/types.ts b/src/components/Modal/Modal/types.ts index e679e696d4687ed1d1d2898721fcc82de22dedd3..52cf784b6f07871aa74f3a9c28a4b9c86e2d0a91 100644 --- a/src/components/Modal/Modal/types.ts +++ b/src/components/Modal/Modal/types.ts @@ -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; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 157b72896aea57e98df4683667a6346fa7dcbe1c..95f22567a3a0760f239c38ab0f8a03b6f1e8840c 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -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> diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 850dc1779b6bf76805d6e098822f586976aadaf6..2c8dea97ab953fc098ae5daf96a9cba06ebe916c 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -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> diff --git a/src/components/Tabs/types.ts b/src/components/Tabs/types.ts index 342266acd220d0ea74eb7af77183e8df4ab466f2..00584fd15fc4710b35ad50251954b8e4f08624db 100644 --- a/src/components/Tabs/types.ts +++ b/src/components/Tabs/types.ts @@ -6,6 +6,7 @@ type Tab = { export interface TabsProps { tabs: Tab[]; + onTabClick?: (index: number) => void; } export interface TabPanelProps { diff --git a/src/components/UserModal/consts.ts b/src/components/UserModal/consts.ts index f1566526e4ea6ef338681db255c68873128b1321..d17985ae10ef9db92f9997c4529c34b93c735044 100644 --- a/src/components/UserModal/consts.ts +++ b/src/components/UserModal/consts.ts @@ -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/', }, ]; diff --git a/src/modules/apps/AppSingle.tsx b/src/modules/apps/AppSingle.tsx index fc994e3838efce030b5e1fbe1f20f98e432787e9..92b84f5e488d5aa4a5db6c08c5b683927964d0d8 100644 --- a/src/modules/apps/AppSingle.tsx +++ b/src/modules/apps/AppSingle.tsx @@ -1,96 +1,173 @@ -import React from 'react'; -import { ChevronRightIcon } from '@heroicons/react/solid'; +/** + * 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 { Tabs, Banner } from 'src/components'; -import { Link } from 'react-router-dom'; +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'; -const pages = [ - { name: 'Apps', to: '/apps', current: true }, - { name: 'Nextcloud', to: '', current: false }, -]; +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 tabs = [ - { name: 'General', component: <GeneralTab /> }, - { name: 'Advanced Configuration', component: <AdvancedTab /> }, -]; + const initialDisableData = { slug: appSlug, removeAppData: false }; -export const AppSingle: React.FC = () => { - return ( - <> - <div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow"> - <nav className="flex" aria-label="Breadcrumb"> - <ol className="flex items-center space-x-4"> - <li> - <div className="flex items-center"> - <Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700"> - <span>Dashboard</span> - </Link> - </div> - </li> - {pages.map((page) => ( - <li key={page.name}> - <div className="flex items-center"> - <ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" /> - <Link - to={page.to} - className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700" - aria-current={page.current ? 'page' : undefined} - > - {page.name} - </Link> - </div> - </li> - ))} - </ol> - </nav> - </div> + const { control, reset, handleSubmit } = useForm<DisableAppForm>({ + defaultValues: initialDisableData, + }); - <div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden"> - <Banner title="Managing single app instances coming soon." titleSm="Comming soon!" /> - </div> + const removeAppDataWatch = useWatch({ + control, + name: 'removeAppData', + }); - <div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden opacity-40 cursor-default pointer-events-none select-none"> - <div className="bg-white overflow-hidden shadow rounded-sm mb-5"> - <div className="px-4 py-5 sm:p-6 flex justify-between items-center"> - <div className="mr-4 flex items-center"> - <img - className="h-24 w-24 rounded-md overflow-hidden mr-4" - src="./../assets/nextcloud.svg" - alt="Nextcloud" - /> + useEffect(() => { + setRemoveAppData(removeAppDataWatch); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [removeAppDataWatch]); - <div> - <h2 className="text-2xl leading-8 font-bold">Nextcloud</h2> - <div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div> - </div> - </div> + useEffect(() => { + if (appSlug) { + loadApp(appSlug); + } - <div className="flex flex-col"> - <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" - > - <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" - > - View Documentation - </button> - </div> + 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="bg-white shadow rounded-sm"> + <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> ); }; diff --git a/src/modules/apps/Apps.tsx b/src/modules/apps/Apps.tsx index 48d244fcf20b84c00c81da20719ed5171b28627e..ba83667f65c3475aec4e5f62f0541f6f6800d91e 100644 --- a/src/modules/apps/Apps.tsx +++ b/src/modules/apps/Apps.tsx @@ -1,167 +1,129 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import React, { useState, useCallback, useMemo } from 'react'; -import { ChevronRightIcon, SearchIcon, PlusIcon } from '@heroicons/react/solid'; -import { CogIcon, TrashIcon } from '@heroicons/react/outline'; -import { ConfirmationModal } from 'src/components/Modal'; -import { Table, Banner } from 'src/components'; -import { Link } from 'react-router-dom'; - -const pages = [{ name: 'Apps', href: '#', current: true }]; +/** + * 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 = () => { - const [selectedRowsIds, setSelectedRowsIds] = useState({}); - const [deleteModal, setDeleteModal] = useState(false); - const [search, setSearch] = useState(''); + // If you want to enable the App Install button again, uncomment this: + // const [installModalOpen, setInstallModalOpen] = useState(false); + // const [appSlug, setAppSlug] = useState(null); - const deleteModalOpen = () => setDeleteModal(true); - const deleteModalClose = () => setDeleteModal(false); + const [search, setSearch] = useState(''); + const { apps, appTableLoading, loadApps } = useApps(); const handleSearch = useCallback((event: any) => { setSearch(event.target.value); }, []); - const data: any[] = useMemo( - () => [ - { - id: 1, - name: 'Nextcloud', - status: 'Active for everyone', - assetSrc: './assets/nextcloud.svg', - }, - { - id: 2, - name: 'Wekan', - status: 'Active for everyone', - assetSrc: './assets/wekan.svg', - }, - { - id: 3, - name: 'Rocketchat', - status: 'Active for everyone', - assetSrc: './assets/rocketchat.svg', - }, - { - id: 4, - name: 'Wordpress', - status: 'Active for everyone', - assetSrc: './assets/wordpress.svg', - }, - ], - [], - ); + const debouncedSearch = useCallback(debounce(handleSearch, 250), []); + + useEffect(() => { + loadApps(); + + return () => { + debouncedSearch.cancel(); + }; + }, []); const filterSearch = useMemo(() => { - return data.filter((item: any) => item.name?.toLowerCase().includes(search.toLowerCase())); - }, [search]); + return _.filter(apps, (item) => item.name?.toLowerCase().includes(search.toLowerCase())); + }, [apps, search]); - const columns: any = useMemo( - () => [ - { - 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> + 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> - ); - }, - width: 'auto', - }, - { - Header: 'Status', - accessor: 'status', - Cell: (e: any) => { - return ( - <div className="flex items-center"> - <div className="flex-shrink-0 h-4 w-4 rounded-full bg-green-600" /> - <div className="ml-2 text-sm text-green-600">{e.cell.row.original.status}</div> + <div className="ml-4"> + <div className="text-sm font-medium text-gray-900">{app.name}</div> </div> - ); - }, - width: 'auto', + </div> + ); }, - { - Header: ' ', - Cell: () => { - return ( - <div className="text-right opacity-0 group-hover:opacity-100 transition-opacity"> - <button - onClick={() => {}} - 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" + 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)} > - <CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> - Configure - </button> - </div> - ); - }, - width: 'auto', + {status} + </div> + ) : ( + <div className={`ml-2 text-sm text-${getConstForStatus(status, 'colorClass')}`}>{status}</div> + )} + </div> + ); }, - ], - [], - ); - - const selectedRows = useCallback((rows: Record<string, boolean>) => { - setSelectedRowsIds(rows); - }, []); + 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="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow"> - <nav className="flex" aria-label="Breadcrumb"> - <ol className="flex items-center space-x-4"> - <li> - <div className="flex items-center"> - <Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700"> - <span>Dashboard</span> - </Link> - </div> - </li> - {pages.map((page) => ( - <li key={page.name}> - <div className="flex items-center"> - <ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" /> - <a - href={page.href} - className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700" - aria-current={page.current ? 'page' : undefined} - > - {page.name} - </a> - </div> - </li> - ))} - </ol> - </nav> - </div> - - <div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 overflow-hidden"> - <Banner - title="Your app instances management will be here soon, in the meantime, feel free to explore." - titleSm="Comming soon!" - /> - </div> - - <div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow"> - <div className="pb-5 border-b border-gray-200 sm:flex sm:items-center sm:justify-between"> + <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 className="mt-3 sm:mt-0 sm:ml-4"> - <button - disabled - 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" - > - <PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> - Add new app - </button> - </div> </div> <div className="flex justify-between w-100 my-3 items-center"> @@ -187,36 +149,30 @@ export const Apps: React.FC = () => { </div> </div> </div> - - {selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && ( - <button - onClick={deleteModalOpen} - 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 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={filterSearch} columns={columns} getSelectedRowIds={selectedRows} selectable /> + <Table + data={_.filter(filterSearch, (app) => app.slug !== 'dashboard') as any} + columns={columns} + loading={appTableLoading} + /> </div> </div> </div> </div> - - <ConfirmationModal - open={deleteModal} - onClose={deleteModalClose} - title="Delete service" - body="Are you sure you want to delete this service? All of your data will be permanently removed. This action cannot be undone." - /> </div> - </> + + { + // If you want to enable the App Install button again, uncomment this: + // + // installModalOpen && ( + // <AppInstallModal appSlug={appSlug} onClose={() => setInstallModalOpen(false)} open={installModalOpen} /> + // ) + } + </div> ); }; diff --git a/src/modules/apps/components/AdvancedTab/AdvancedTab.tsx b/src/modules/apps/components/AdvancedTab/AdvancedTab.tsx index a3b7c34dfd5f75d9208ea71ea6532a768e43c53c..ec3e9480606a0eae794e49a256a9341949a92402 100644 --- a/src/modules/apps/components/AdvancedTab/AdvancedTab.tsx +++ b/src/modules/apps/components/AdvancedTab/AdvancedTab.tsx @@ -1,20 +1,50 @@ -import React, { Fragment } from 'react'; +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 { 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'; -import { Secrets } from './components'; - -function classNames(...classes: any) { - return classes.filter(Boolean).join(' '); -} 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 ( <> @@ -22,9 +52,24 @@ export const AdvancedTab = () => { <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> - <div> - <div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg"> + <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"> @@ -89,62 +134,34 @@ export const AdvancedTab = () => { </Menu.Items> </Transition> </Menu> - </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 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> - <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 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" > - Cancel + 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 @@ -152,13 +169,12 @@ search: <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> - - <Secrets /> </> ); }; diff --git a/src/modules/apps/components/AppInstallModal/AppInstallModal.tsx b/src/modules/apps/components/AppInstallModal/AppInstallModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14e0b1a11f601e23d885149ae18d762ccf48c330 --- /dev/null +++ b/src/modules/apps/components/AppInstallModal/AppInstallModal.tsx @@ -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> + ); +}; diff --git a/src/modules/apps/consts.ts b/src/modules/apps/components/AppInstallModal/consts.ts similarity index 77% rename from src/modules/apps/consts.ts rename to src/modules/apps/components/AppInstallModal/consts.ts index 0a85e78e42e1e1f5ca377a5359f64ba6c6fa4e2c..22a30bcef0eb0be688147dcdfaf9f6ad6e7d1b47 100644 --- a/src/modules/apps/consts.ts +++ b/src/modules/apps/components/AppInstallModal/consts.ts @@ -1,5 +1,6 @@ -export const initialEditorYaml = () => { - return `luck: except +import { App } from 'src/services/apps'; + +export const initialCode = `luck: except natural: still near: though search: @@ -20,4 +21,7 @@ search: hospital: -1544066384.1973226 law: congress great: stomach`; -}; + +export const initialAppForm = { + url: '', +} as App; diff --git a/src/modules/apps/components/AppInstallModal/index.ts b/src/modules/apps/components/AppInstallModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0796470d67a1eb1aaf148ab86b42a1057341472e --- /dev/null +++ b/src/modules/apps/components/AppInstallModal/index.ts @@ -0,0 +1 @@ +export { AppInstallModal } from './AppInstallModal'; diff --git a/src/modules/apps/components/AppInstallModal/types.ts b/src/modules/apps/components/AppInstallModal/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f10e7e44be882761e584dbdfe002986469d0329 --- /dev/null +++ b/src/modules/apps/components/AppInstallModal/types.ts @@ -0,0 +1,5 @@ +export type AppInstallModalProps = { + open: boolean; + onClose: () => void; + appSlug: string | null; +}; diff --git a/src/modules/apps/components/GeneralTab/GeneralTab.tsx b/src/modules/apps/components/GeneralTab/GeneralTab.tsx index 1e5239be7fff6ea5df4fcecc1037ac4ec6a323b1..9b7054ddf79e3eb1ac9a7ab77dd731db8e79efc4 100644 --- a/src/modules/apps/components/GeneralTab/GeneralTab.tsx +++ b/src/modules/apps/components/GeneralTab/GeneralTab.tsx @@ -1,161 +1,59 @@ -import React, { useState } from 'react'; -import { RadioGroup } from '@headlessui/react'; +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'; -function classNames(...classes: any) { - return classes.filter(Boolean).join(' '); -} +export const GeneralTab = () => { + const { app, editApp } = useApps(); + const { control, reset, handleSubmit } = useForm<App>({ + defaultValues: { automaticUpdates: false }, + }); -const settings = [ - { name: 'Public access', description: 'This project would be available to anyone who has the link' }, - { name: 'Private to Project Members', description: 'Only members of this project would be able to access' }, - { name: 'Private to you', description: 'You are the only one able to access this project' }, -]; + useEffect(() => { + if (!_.isEmpty(app)) { + reset(app); + } -export const GeneralTab = () => { - const [selected, setSelected] = useState(settings[0]); + return () => { + reset({ automaticUpdates: false }); + }; + }, [app, reset]); + + const onSubmit: SubmitHandler<App> = (data) => { + try { + editApp(data); + } catch (e: any) { + // continue + } + }; return ( - <div className="py-4"> - <div> + <form onSubmit={handleSubmit(onSubmit)}> + <div className="py-4"> <div className="md:grid md:grid-cols-3 md:gap-6"> - <div className="md:col-span-1"> - <h3 className="text-lg font-medium leading-6 text-gray-900">Privacy</h3> - <p className="mt-1 text-sm text-gray-500">Change your app privacy</p> + <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="mt-5 md:mt-0 md:col-span-2"> - <div className="mt-1"> - <RadioGroup value={selected} onChange={setSelected}> - <RadioGroup.Label className="sr-only">Privacy setting</RadioGroup.Label> - <div className="bg-white rounded-md -space-y-px"> - {settings.map((setting, settingIdx) => ( - <RadioGroup.Option - key={setting.name} - value={setting} - className={({ checked }) => - classNames( - settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '', - settingIdx === settings.length - 1 ? 'rounded-bl-md rounded-br-md' : '', - checked ? 'bg-primary-50 border-primary-400 z-10' : 'border-gray-200', - 'relative border p-4 flex cursor-pointer focus:outline-none', - ) - } - > - {({ active, checked }) => ( - <> - <span - className={classNames( - checked ? 'bg-primary-600 border-transparent' : 'bg-white border-gray-300', - active ? 'ring-2 ring-offset-2 ring-primary-100' : '', - 'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center', - )} - aria-hidden="true" - > - <span className="rounded-full bg-white w-1.5 h-1.5" /> - </span> - <div className="ml-3 flex flex-col"> - <RadioGroup.Label - as="span" - className={classNames( - checked ? 'text-primary-900' : 'text-gray-900', - 'block text-sm font-medium', - )} - > - {setting.name} - </RadioGroup.Label> - <RadioGroup.Description - as="span" - className={classNames(checked ? 'text-primary-900' : 'text-gray-500', 'block text-sm')} - > - {setting.description} - </RadioGroup.Description> - </div> - </> - )} - </RadioGroup.Option> - ))} - </div> - </RadioGroup> - </div> + <div className="my-auto ml-auto"> + <Switch control={control} name="automaticUpdates" /> </div> </div> </div> - - <div className="mt-10"> - <div className="md:grid md:grid-cols-3 md:gap-6"> - <div className="md:col-span-1"> - <h3 className="text-lg font-medium leading-6 text-gray-900">Notifications</h3> - <p className="mt-1 text-sm text-gray-500">Change you notifications settings</p> - </div> - - <div className="mt-5 md:mt-0 md:col-span-2"> - <fieldset className="space-y-5 -mt-4"> - <legend className="sr-only">Notifications</legend> - <div className="relative flex items-start"> - <div className="flex items-center h-5"> - <input - id="comments" - aria-describedby="comments-description" - name="comments" - type="checkbox" - className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded" - /> - </div> - <div className="ml-3 text-sm"> - <label htmlFor="comments" className="font-medium text-gray-700"> - Comments - </label> - <p id="comments-description" className="text-gray-500"> - Get notified when someones posts a comment on a posting. - </p> - </div> - </div> - <div> - <div className="relative flex items-start"> - <div className="flex items-center h-5"> - <input - id="candidates" - aria-describedby="candidates-description" - name="candidates" - type="checkbox" - className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded" - /> - </div> - <div className="ml-3 text-sm"> - <label htmlFor="candidates" className="font-medium text-gray-700"> - Candidates - </label> - <p id="candidates-description" className="text-gray-500"> - Get notified when a candidate applies for a job. - </p> - </div> - </div> - </div> - <div> - <div className="relative flex items-start"> - <div className="flex items-center h-5"> - <input - id="offers" - aria-describedby="offers-description" - name="offers" - type="checkbox" - className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded" - /> - </div> - <div className="ml-3 text-sm"> - <label htmlFor="offers" className="font-medium text-gray-700"> - Offers - </label> - <p id="offers-description" className="text-gray-500"> - Get notified when a candidate accepts or rejects an offer. - </p> - </div> - </div> - </div> - </fieldset> - </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> - </div> + </form> ); }; diff --git a/src/modules/apps/components/index.ts b/src/modules/apps/components/index.ts index 77f373164adfe6b5d2691a1dd8b711b69fbae75b..c41720b8de1e9149394261b85b3fd65586430436 100644 --- a/src/modules/apps/components/index.ts +++ b/src/modules/apps/components/index.ts @@ -1,2 +1,3 @@ export { GeneralTab } from './GeneralTab'; export { AdvancedTab } from './AdvancedTab'; +export { AppInstallModal } from './AppInstallModal'; diff --git a/src/modules/apps/consts.tsx b/src/modules/apps/consts.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a43f3f157945c085722bf296ca15e1f54545b00 --- /dev/null +++ b/src/modules/apps/consts.tsx @@ -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); +}; diff --git a/src/modules/dashboard/Dashboard.tsx b/src/modules/dashboard/Dashboard.tsx index 959b99fd1d608e9ac1546bea7cf5c106370581f9..47968ef603a359036b92330aee6356b25ad64eb5 100644 --- a/src/modules/dashboard/Dashboard.tsx +++ b/src/modules/dashboard/Dashboard.tsx @@ -1,13 +1,28 @@ -import React from 'react'; -import clsx from 'clsx'; -import { DASHBOARD_APPS, DASHBOARD_QUICK_ACCESS } from './consts'; -import { DashboardCard } from './components'; +/* eslint-disable react-hooks/exhaustive-deps */ +/** + * Page that shows only installed applications, and links to them. + * + * "Utilities" is a special section that links to the Stackspin documentation, + * and that shows the "Monitoring" application if it is installed. + */ +import React, { useEffect } from 'react'; +import { useApps } from 'src/services/apps'; +import { AppStatusEnum } from 'src/services/apps/types'; +import { DashboardCard, DashboardUtility } from './components'; +import { DASHBOARD_QUICK_ACCESS, HIDDEN_APPS, UTILITY_APPS } from './consts'; export const Dashboard: React.FC = () => { const host = window.location.hostname; const splitedDomain = host.split('.'); splitedDomain.shift(); - const rootDomain = splitedDomain.join('.'); + const { apps, loadApps } = useApps(); + + // Tell React to load the apps + useEffect(() => { + loadApps(); + + return () => {}; + }, []); return ( <div className="relative"> @@ -19,36 +34,28 @@ export const Dashboard: React.FC = () => { <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow"> <div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-4 mb-10"> - {DASHBOARD_APPS(rootDomain!).map((app) => ( - <DashboardCard app={app} key={app.name} /> - ))} + {apps + .filter((app) => HIDDEN_APPS.concat(UTILITY_APPS).indexOf(app.slug) === -1) + .filter((app) => app.status !== AppStatusEnum.NotInstalled) + .map((app) => ( + <DashboardCard app={app} key={app.name} /> + ))} </div> - <div className="max-w-4xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow"> <div className="pb-4 border-b border-gray-200 sm:flex sm:items-center"> <h3 className="text-lg leading-6 font-medium text-gray-900">Utilities</h3> </div> <dl className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2"> - {DASHBOARD_QUICK_ACCESS(rootDomain!).map((item) => ( - <a - href={item.url} - key={item.name} - target="_blank" - rel="noreferrer" - className={clsx('bg-white rounded-lg overflow-hidden sm:p-2 flex items-center group', { - 'opacity-40 cursor-default': !item.active, - })} - > - <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 className="h-6 w-6 text-primary-900" aria-hidden="true" /> - </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">{item.description}</dd> - </div> - </a> + {DASHBOARD_QUICK_ACCESS.map((item) => ( + <DashboardUtility item={item} key={item.name} /> ))} + {apps + .filter((app) => UTILITY_APPS.indexOf(app.slug) !== -1 && app.url !== null) + .filter((app) => app.status !== AppStatusEnum.NotInstalled) + .map((app) => ( + <DashboardUtility item={app} key={app.name} /> + ))} </dl> </div> </div> diff --git a/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx b/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx index d8142e06151e6da786a18dbd77ab253d251dc089..1b9b820090d41765ab6ba4bb21181eb0776aee6c 100644 --- a/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx +++ b/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx @@ -25,7 +25,7 @@ export const DashboardCard: React.FC<any> = ({ app }: { app: any }) => { <img className="h-16 w-16 rounded-md overflow-hidden mr-4 flex-shrink-0" src={app.assetSrc} - alt="Nextcloud" + alt={app.name} /> <div> diff --git a/src/modules/dashboard/components/DashboardUtility/DashboardUtility.tsx b/src/modules/dashboard/components/DashboardUtility/DashboardUtility.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b1c2b17119a2d7d1f864331dff662769832c49f --- /dev/null +++ b/src/modules/dashboard/components/DashboardUtility/DashboardUtility.tsx @@ -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> + ); +}; diff --git a/src/modules/dashboard/components/DashboardUtility/index.ts b/src/modules/dashboard/components/DashboardUtility/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..24e4b682561aff959c8a6112f702067d47c375f7 --- /dev/null +++ b/src/modules/dashboard/components/DashboardUtility/index.ts @@ -0,0 +1 @@ +export { DashboardUtility } from './DashboardUtility'; diff --git a/src/modules/dashboard/components/index.ts b/src/modules/dashboard/components/index.ts index 479e087a9b0ae50e1771641c302de4f9636a9bf9..2a1468292e7106bd4f50c42b37e8f42a27ed45d4 100644 --- a/src/modules/dashboard/components/index.ts +++ b/src/modules/dashboard/components/index.ts @@ -1 +1,2 @@ export { DashboardCard } from './DashboardCard'; +export { DashboardUtility } from './DashboardUtility'; diff --git a/src/modules/dashboard/consts.ts b/src/modules/dashboard/consts.ts index c33481a614fd511ace53e8d99c33f3ee5729972d..55984982bdcb8d6e287b32e129dbd1c1cd0ddfe2 100644 --- a/src/modules/dashboard/consts.ts +++ b/src/modules/dashboard/consts.ts @@ -1,51 +1,16 @@ -import { ChartBarIcon, InformationCircleIcon } from '@heroicons/react/outline'; +import { InformationCircleIcon } from '@heroicons/react/outline'; -export const DASHBOARD_APPS = (rootDomain: string) => [ +export const DASHBOARD_QUICK_ACCESS = [ { - id: 1, - name: 'Nextcloud', - assetSrc: '/assets/nextcloud.svg', - markdownSrc: '/markdown/nextcloud.md', - url: `https://files.${rootDomain}`, - }, - { - id: 2, - name: 'Wekan', - assetSrc: '/assets/wekan.svg', - markdownSrc: '/markdown/wekan.md', - url: `https://wekan.${rootDomain}`, - }, - { - id: 3, - name: 'Zulip', - assetSrc: '/assets/zulip.svg', - markdownSrc: '/markdown/zulip.md', - url: `https://zulip.${rootDomain}`, - }, - { - id: 4, - name: 'Wordpress', - assetSrc: '/assets/wordpress.svg', - markdownSrc: '/markdown/wordpress.md', - url: `https://www.${rootDomain}`, - }, -]; - -export const DASHBOARD_QUICK_ACCESS = (rootDomain: string) => [ - { - id: 1, - name: 'Monitoring →', - url: `https://grafana.${rootDomain}`, - description: 'Monitor your system with Grafana', - icon: ChartBarIcon, - active: true, - }, - { - id: 2, - name: 'Support →', + name: 'Support', url: 'https://docs.stackspin.net', - description: 'Access documentation and forum', + markdownSrc: '/markdown/support.md', icon: InformationCircleIcon, - active: true, }, ]; + +/** Apps that should not be shown on the dashboard */ +export const HIDDEN_APPS = ['dashboard']; + +/** Apps that should be shown under "Utilities" */ +export const UTILITY_APPS = ['monitoring']; diff --git a/src/modules/login/Login.tsx b/src/modules/login/Login.tsx index 22f2a043686428ee9025c97fc2e80f81950b84ee..87be39e1acb6307d70ead6e9432a07b50c48d5d6 100644 --- a/src/modules/login/Login.tsx +++ b/src/modules/login/Login.tsx @@ -1,3 +1,6 @@ +/** + * Login page that starts the OAuth2 authentication flow. + */ import React from 'react'; import clsx from 'clsx'; import { LockClosedIcon } from '@heroicons/react/solid'; diff --git a/src/modules/users/Users.tsx b/src/modules/users/Users.tsx index 41cfafbf3444a685267465d88df1dd9938bee7dc..5d45dd526be0e7668e3d241c1bafb72e3b7108be 100644 --- a/src/modules/users/Users.tsx +++ b/src/modules/users/Users.tsx @@ -1,4 +1,9 @@ /* 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'; diff --git a/src/redux/store.ts b/src/redux/store.ts index ff46366bb70a13369bbedc183e2bcdfdff97dbcb..8e1c58d7e4fe763ff66996181348cd2c0c8db0e4 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -6,6 +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 = { @@ -17,6 +18,7 @@ const persistConfig = { const appReducer = combineReducers<State>({ auth: authReducer, users: usersReducer, + apps: appsReducer, }); const persistedReducer = persistReducer(persistConfig, appReducer); diff --git a/src/redux/types.ts b/src/redux/types.ts index 4d42236f498e361442d09523ab0959f90996e7e6..85057964407d1039c485391f99b46fd23b9ca76f 100644 --- a/src/redux/types.ts +++ b/src/redux/types.ts @@ -2,10 +2,12 @@ import { Store } from 'redux'; import { AuthState } from 'src/services/auth/redux'; import { UsersState } from 'src/services/users/redux'; +import { AppsState } from 'src/services/apps/redux'; export interface AppStore extends Store, State {} export interface State { auth: AuthState; users: UsersState; + apps: AppsState; } diff --git a/src/services/apps/hooks/index.ts b/src/services/apps/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3648cecfa8b1570cf48d1ded3364f3a696464ce --- /dev/null +++ b/src/services/apps/hooks/index.ts @@ -0,0 +1 @@ +export { useApps } from './use-apps'; diff --git a/src/services/apps/hooks/use-apps.ts b/src/services/apps/hooks/use-apps.ts new file mode 100644 index 0000000000000000000000000000000000000000..521df878a8ee74d1a3f4ccfecd0ed514f909399a --- /dev/null +++ b/src/services/apps/hooks/use-apps.ts @@ -0,0 +1,48 @@ +import { useDispatch, useSelector } from 'react-redux'; +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(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, + }; +} diff --git a/src/services/apps/index.ts b/src/services/apps/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..84e225b7bed326662bdd4c839b5912f60a6eb65c --- /dev/null +++ b/src/services/apps/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export { reducer } from './redux'; +export { useApps } from './hooks'; diff --git a/src/services/apps/redux/actions.ts b/src/services/apps/redux/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..09ebe36970760bdd9ddd6e495b21b57e3cf4d6ce --- /dev/null +++ b/src/services/apps/redux/actions.ts @@ -0,0 +1,155 @@ +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', + DELETE_APP = 'apps/delete_app', + CLEAR_APP = 'apps/clear_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 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: {}, + }); +}; diff --git a/src/services/apps/redux/index.ts b/src/services/apps/redux/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..88a7a71c10383e2778b42f7f81308310c63639e1 --- /dev/null +++ b/src/services/apps/redux/index.ts @@ -0,0 +1,4 @@ +export * from './actions'; +export { default as reducer } from './reducers'; +export { getApps } from './selectors'; +export * from './types'; diff --git a/src/services/apps/redux/reducers.ts b/src/services/apps/redux/reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..26736ae5e20562473e84116c2d59737d50238756 --- /dev/null +++ b/src/services/apps/redux/reducers.ts @@ -0,0 +1,45 @@ +import { AppActionTypes } from './actions'; + +const initialAppsState: any = { + apps: [], + app: {}, + appModalLoading: false, + appsLoading: false, +}; + +const appsReducer = (state: any = initialAppsState, 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, + }; + case AppActionTypes.CLEAR_APP: + case AppActionTypes.DELETE_APP: + return { + ...state, + currentApp: {}, + }; + default: + return state; + } +}; + +export default appsReducer; diff --git a/src/services/apps/redux/selectors.ts b/src/services/apps/redux/selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..751f24c897eef496ee8d9ebeb665fc9c15f16ee7 --- /dev/null +++ b/src/services/apps/redux/selectors.ts @@ -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; diff --git a/src/services/apps/redux/types.ts b/src/services/apps/redux/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..82a6c262f2cf5ef594c9626f789b6d2fffb57c31 --- /dev/null +++ b/src/services/apps/redux/types.ts @@ -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; +} diff --git a/src/services/apps/transformations.ts b/src/services/apps/transformations.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b98a878cd2190ff4021a2aa498c6070042c6be6 --- /dev/null +++ b/src/services/apps/transformations.ts @@ -0,0 +1,34 @@ +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 ?? '', + 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, + }; +}; diff --git a/src/services/apps/types.ts b/src/services/apps/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c5e0e22ca1ecaca969c6e111d4d281a20678459 --- /dev/null +++ b/src/services/apps/types.ts @@ -0,0 +1,29 @@ +export interface App { + 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', +} diff --git a/yarn.lock b/yarn.lock index 7eefd82bcb4976b58fbe32bba31690447d13c118..b8d82ca5206b4926492578ea8023735d190bd99c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1862,6 +1862,11 @@ jest-diff "^26.0.0" pretty-format "^26.0.0" +"@types/js-yaml@^4.0.5": + version "4.0.5" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138" + integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA== + "@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8": version "7.0.9" resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz"