Add nest endpoints and pages

This commit is contained in:
Matthew Penner 2020-12-31 17:27:16 -07:00
parent 359769244f
commit 6c85be72fa
12 changed files with 473 additions and 10 deletions

View file

@ -0,0 +1,12 @@
import http from '@/api/http';
import { Nest } from '@/api/admin/nests/getNests';
export default (name: string, description?: string): Promise<Nest> => {
return new Promise((resolve, reject) => {
http.post('/api/application/nests', {
name, description,
})
.then(({ data }) => resolve(data.attributes))
.catch(reject);
});
};

View file

@ -0,0 +1,34 @@
import http from '@/api/http';
import { rawDataToEgg } from '@/api/transformers';
export interface Egg {
id: number;
uuid: string;
nest_id: number;
author: string;
name: string;
description: string | null;
features: string[] | null;
dockerImages: string[];
configFiles: string | null;
configStartup: string | null;
configLogs: string | null;
configStop: string | null;
configFrom: number | null;
startup: string;
scriptContainer: string;
copyScriptFrom: number | null;
scriptEntry: string;
scriptIsPrivileged: boolean;
scriptInstall: string | null;
createdAt: Date;
updatedAt: Date;
}
export default (id: number): Promise<Egg[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/application/nests/${id}`)
.then(({ data }) => resolve((data.data || []).map(rawDataToEgg)))
.catch(reject);
});
};

View file

@ -0,0 +1,20 @@
import http from '@/api/http';
import { rawDataToNest } from '@/api/transformers';
export interface Nest {
id: number;
uuid: string;
author: string;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
}
export default (): Promise<Nest[]> => {
return new Promise((resolve, reject) => {
http.get('/api/application/nests')
.then(({ data }) => resolve((data.data || []).map(rawDataToNest)))
.catch(reject);
});
};

View file

@ -1,3 +1,5 @@
import { Egg } from '@/api/admin/nests/eggs/getEggs';
import { Nest } from '@/api/admin/nests/getNests';
import { Role } from '@/api/admin/roles/getRoles'; import { Role } from '@/api/admin/roles/getRoles';
import { Allocation } from '@/api/server/getServer'; import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http'; import { FractalResponseData } from '@/api/http';
@ -81,3 +83,37 @@ export const rawDataToAdminRole = ({ attributes }: FractalResponseData): Role =>
name: attributes.name, name: attributes.name,
description: attributes.description, description: attributes.description,
}); });
export const rawDataToNest = ({ attributes }: FractalResponseData): Nest => ({
id: attributes.id,
uuid: attributes.uuid,
author: attributes.author,
name: attributes.name,
description: attributes.description,
createdAt: new Date(attributes.created_at),
updatedAt: new Date(attributes.updated_at),
});
export const rawDataToEgg = ({ attributes }: FractalResponseData): Egg => ({
id: attributes.id,
uuid: attributes.uuid,
nest_id: attributes.nest_id,
author: attributes.author,
name: attributes.name,
description: attributes.description,
features: attributes.features,
dockerImages: attributes.docker_images,
configFiles: attributes.config_files,
configStartup: attributes.config_startup,
configLogs: attributes.config_logs,
configStop: attributes.config_stop,
configFrom: attributes.config_from,
startup: attributes.startup,
scriptContainer: attributes.script_container,
copyScriptFrom: attributes.copy_script_from,
scriptEntry: attributes.script_entry,
scriptIsPrivileged: attributes.script_is_privileged,
scriptInstall: attributes.script_install,
createdAt: new Date(attributes.created_at),
updatedAt: new Date(attributes.updated_at),
});

View file

@ -0,0 +1,24 @@
import Input from '@/components/elements/Input';
import React from 'react';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
const Checkbox = styled(Input)`
&& {
${tw`border-neutral-500 bg-transparent`};
&:not(:checked) {
${tw`hover:border-neutral-300`};
}
}
`;
export default ({ name }: { name: string }) => {
return (
<Checkbox
name={'selectedItems'}
value={name}
type={'checkbox'}
/>
);
};

View file

@ -1,20 +1,212 @@
import Button from '@/components/elements/Button'; import AdminCheckbox from '@/components/admin/AdminCheckbox';
import React from 'react'; import React, { useEffect, useState } from 'react';
import getNests from '@/api/admin/nests/getNests';
import { httpErrorToHuman } from '@/api/http';
import NewNestButton from '@/components/admin/nests/NewNestButton';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
import useFlash from '@/plugins/useFlash';
import { AdminContext } from '@/state/admin';
import { NavLink, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro'; import tw from 'twin.macro';
import AdminContentBlock from '@/components/admin/AdminContentBlock'; import AdminContentBlock from '@/components/admin/AdminContentBlock';
export default () => { export default () => {
const match = useRouteMatch();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
const nests = useDeepMemoize(AdminContext.useStoreState(state => state.nests.data));
const setNests = AdminContext.useStoreActions(state => state.nests.setNests);
useEffect(() => {
setLoading(!nests.length);
clearFlashes('nests');
getNests()
.then(nests => setNests(nests))
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'nests' });
})
.then(() => setLoading(false));
}, []);
return ( return (
<AdminContentBlock> <AdminContentBlock>
<div css={tw`w-full flex flex-row items-center`}> <div css={tw`w-full flex flex-row items-center mb-8`}>
<div css={tw`flex flex-col`}> <div css={tw`flex flex-col`}>
<h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>Nests</h2> <h2 css={tw`text-2xl text-neutral-50 font-header font-medium`}>Nests</h2>
<p css={tw`text-base text-neutral-400`}>All nests currently available on this system.</p> <p css={tw`text-base text-neutral-400`}>All nests currently available on this system.</p>
</div> </div>
<Button type={'button'} size={'large'} css={tw`h-10 ml-auto px-4 py-0`}> <NewNestButton/>
New Nest </div>
</Button>
<FlashMessageRender byKey={'nests'} css={tw`mb-4`}/>
<div css={tw`w-full flex flex-col`}>
<div css={tw`w-full flex flex-col bg-neutral-700 rounded-lg shadow-md`}>
{ loading ?
<div css={tw`w-full flex flex-col items-center justify-center`} style={{ height: '24rem' }}>
<Spinner size={'base'}/>
</div>
:
nests.length < 1 ?
<div css={tw`w-full flex flex-col items-center justify-center pb-6 py-2 sm:py-8 md:py-10 px-8`}>
<div css={tw`h-64 flex`}>
<img src={'/assets/svgs/not_found.svg'} alt={'No Items'} css={tw`h-full select-none`}/>
</div>
<p css={tw`text-lg text-neutral-300 text-center font-normal sm:mt-8`}>No items could be found, it&apos;s almost like they are hiding.</p>
</div>
:
<>
<div css={tw`flex flex-row items-center h-12 px-6`}>
<div css={tw`flex flex-row items-center`}>
<AdminCheckbox name={'selectAll'}/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" css={tw`w-4 h-4 ml-1 text-neutral-200`}>
<path clipRule="evenodd" fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
</svg>
</div>
<div css={tw`flex flex-row items-center px-2 py-1 ml-auto rounded cursor-pointer bg-neutral-600`}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" css={tw`w-6 h-6 text-neutral-300`}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" css={tw`w-4 h-4 ml-1 text-neutral-200`}>
<path clipRule="evenodd" fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
</svg>
</div>
</div>
<div css={tw`overflow-x-auto`}>
<table css={tw`w-full table-auto`}>
<thead css={tw`bg-neutral-900 border-t border-b border-neutral-500`}>
<tr>
<th css={tw`px-6 py-2`}/>
<th css={tw`px-6 py-2`}>
<span css={tw`flex flex-row items-center cursor-pointer`}>
<span css={tw`text-xs font-medium tracking-wider uppercase text-neutral-300 whitespace-nowrap`}>ID</span>
<div css={tw`ml-1`}>
<svg fill="none" viewBox="0 0 20 20" css={tw`w-4 h-4 text-neutral-400`}>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M13 7L10 4L7 7"/>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M7 13L10 16L13 13"/>
</svg>
</div>
</span>
</th>
<th css={tw`px-6 py-2`}>
<span css={tw`flex flex-row items-center cursor-pointer`}>
<span css={tw`text-xs font-medium tracking-wider uppercase text-neutral-300 whitespace-nowrap`}>Name</span>
<div css={tw`ml-1`}>
<svg fill="none" viewBox="0 0 20 20" css={tw`w-4 h-4 text-neutral-400`}>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M13 7L10 4L7 7"/>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M7 13L10 16L13 13"/>
</svg>
</div>
</span>
</th>
<th css={tw`px-6 py-2`}>
<span css={tw`flex flex-row items-center cursor-pointer`}>
<span css={tw`text-xs font-medium tracking-wider uppercase text-neutral-300 whitespace-nowrap`}>Description</span>
<div css={tw`ml-1`}>
<svg fill="none" viewBox="0 0 20 20" css={tw`w-4 h-4 text-neutral-400`}>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M13 7L10 4L7 7"/>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M7 13L10 16L13 13"/>
</svg>
</div>
</span>
</th>
{/* <th css={tw`px-6 py-2`}/> */}
</tr>
</thead>
<tbody>
{
nests.map(nest => (
<tr key={nest.id} css={tw`h-12 hover:bg-neutral-600`}>
<td css={tw`pl-6`}>
<AdminCheckbox name={nest.id.toString()}/>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap pl-8`}>{nest.id}</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap`}>
<NavLink to={`${match.url}/${nest.id}`}>
{nest.name}
</NavLink>
</td>
<td css={tw`px-6 text-sm text-neutral-200 text-left whitespace-nowrap pr-8`}>{nest.description}</td>
</tr>
))
}
</tbody>
</table>
</div>
<div css={tw`flex flex-row items-center w-full px-6 py-3 border-t border-neutral-500`}>
<p css={tw`text-sm leading-5 text-neutral-400`}>
Showing <span css={tw`text-neutral-300`}>1</span> to <span css={tw`text-neutral-300`}>10</span> of <span css={tw`text-neutral-300`}>97</span> results
</p>
<div css={tw`flex flex-row ml-auto`}>
<nav css={tw`relative z-0 inline-flex shadow-sm`}>
<a href="javascript:void(0)" css={tw`relative inline-flex items-center px-1 py-1 text-sm font-medium leading-5 transition duration-150 ease-in-out border rounded-l-md border-neutral-500 bg-neutral-600 text-neutral-400 hover:text-neutral-200 focus:z-10 focus:outline-none focus:border-primary-300 active:bg-neutral-100 active:text-neutral-500`} aria-label="Previous">
<svg css={tw`w-5 h-5`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path clipRule="evenodd" fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"/>
</svg>
</a>
<a href="javascript:void(0)" css={tw`relative inline-flex items-center px-3 py-1 -ml-px text-sm font-medium leading-5 transition duration-150 ease-in-out border border-neutral-500 bg-neutral-500 text-neutral-50 focus:z-10 focus:outline-none focus:border-primary-300`}>
1
</a>
<a href="javascript:void(0)" css={tw`relative inline-flex items-center px-3 py-1 -ml-px text-sm font-medium leading-5 transition duration-150 ease-in-out border border-neutral-500 bg-neutral-600 text-neutral-200 hover:text-neutral-300 focus:z-10 focus:outline-none focus:border-primary-300 active:bg-neutral-100 active:text-neutral-700`}>
2
</a>
<a href="javascript:void(0)" css={tw`relative items-center hidden px-3 py-1 -ml-px text-sm font-medium leading-5 transition duration-150 ease-in-out border md:inline-flex border-neutral-500 bg-neutral-600 text-neutral-200 hover:text-neutral-300 focus:z-10 focus:outline-none focus:border-primary-300 active:bg-neutral-100 active:text-neutral-700`}>
3
</a>
<span css={tw`relative inline-flex items-center px-3 py-1 -ml-px text-sm font-medium leading-5 border border-neutral-500 bg-neutral-600 text-neutral-200 cursor-default`}>
...
</span>
<a href="javascript:void(0)" css={tw`relative items-center hidden px-3 py-1 -ml-px text-sm font-medium leading-5 transition duration-150 ease-in-out border md:inline-flex border-neutral-500 bg-neutral-600 text-neutral-200 hover:text-neutral-300 focus:z-10 focus:outline-none focus:border-primary-300 active:bg-neutral-100 active:text-neutral-700`}>
7
</a>
<a href="javascript:void(0)" css={tw`relative inline-flex items-center px-3 py-1 -ml-px text-sm font-medium leading-5 transition duration-150 ease-in-out border border-neutral-500 bg-neutral-600 text-neutral-200 hover:text-neutral-300 focus:z-10 focus:outline-none focus:border-primary-300 active:bg-neutral-100 active:text-neutral-700`}>
8
</a>
<a href="javascript:void(0)" css={tw`relative inline-flex items-center px-3 py-1 -ml-px text-sm font-medium leading-5 transition duration-150 ease-in-out border border-neutral-500 bg-neutral-600 text-neutral-200 hover:text-neutral-300 focus:z-10 focus:outline-none focus:border-primary-300 active:bg-neutral-100 active:text-neutral-700`}>
9
</a>
<a href="javascript:void(0)" css={tw`relative inline-flex items-center px-1 py-1 text-sm font-medium leading-5 transition duration-150 ease-in-out border rounded-r-md border-neutral-500 bg-neutral-600 text-neutral-400 hover:text-neutral-200 focus:z-10 focus:outline-none focus:border-primary-300 active:bg-neutral-100 active:text-neutral-500`} aria-label="Previous">
<svg css={tw`w-5 h-5`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path clipRule="evenodd" fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"/>
</svg>
</a>
</nav>
</div>
</div>
</>
}
</div>
</div> </div>
</AdminContentBlock> </AdminContentBlock>
); );

View file

@ -0,0 +1,111 @@
import React, { useState } from 'react';
import createNest from '@/api/admin/nests/createNest';
import { httpErrorToHuman } from '@/api/http';
import { AdminContext } from '@/state/admin';
import Button from '@/components/elements/Button';
import Field from '@/components/elements/Field';
import Modal from '@/components/elements/Modal';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { Form, Formik, FormikHelpers } from 'formik';
import tw from 'twin.macro';
import { object, string } from 'yup';
interface Values {
name: string,
description: string,
}
const schema = object().shape({
name: string()
.required('A nest name must be provided.')
.max(32, 'Nest name must not exceed 32 characters.'),
description: string()
.max(255, 'Nest description must not exceed 255 characters.'),
});
export default () => {
const [ visible, setVisible ] = useState(false);
const { addError, clearFlashes } = useFlash();
const appendNest = AdminContext.useStoreActions(actions => actions.nests.appendNest);
const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('nest:create');
setSubmitting(true);
createNest(name, description)
.then(nest => {
appendNest(nest);
setVisible(false);
})
.catch(error => {
addError({ key: 'nest:create', message: httpErrorToHuman(error) });
setSubmitting(false);
});
};
return (
<>
<Formik
onSubmit={submit}
initialValues={{ name: '', description: '' }}
validationSchema={schema}
>
{
({ isSubmitting, resetForm }) => (
<Modal
visible={visible}
dismissable={!isSubmitting}
showSpinnerOverlay={isSubmitting}
onDismissed={() => {
resetForm();
setVisible(false);
}}
>
<FlashMessageRender byKey={'nest:create'} css={tw`mb-6`}/>
<h2 css={tw`text-neutral-100 text-2xl mb-6`}>New Nest</h2>
<Form css={tw`m-0`}>
<Field
type={'string'}
id={'name'}
name={'name'}
label={'Name'}
description={'A short name used to identify this nest.'}
/>
<div css={tw`mt-6`}>
<Field
type={'string'}
id={'description'}
name={'description'}
label={'Description'}
description={'A description for this nest.'}
/>
</div>
<div css={tw`flex flex-wrap justify-end mt-6`}>
<Button
type={'button'}
isSecondary
css={tw`w-full sm:w-auto sm:mr-2`}
onClick={() => setVisible(false)}
>
Cancel
</Button>
<Button css={tw`w-full mt-4 sm:w-auto sm:mt-0`} type={'submit'}>
Create Nest
</Button>
</div>
</Form>
</Modal>
)
}
</Formik>
<Button type={'button'} size={'large'} css={tw`h-10 ml-auto px-4 py-0`} onClick={() => setVisible(true)}>
New Nest
</Button>
</>
);
};

View file

@ -64,7 +64,7 @@ export default () => {
}} }}
> >
<FlashMessageRender byKey={'role:create'} css={tw`mb-6`}/> <FlashMessageRender byKey={'role:create'} css={tw`mb-6`}/>
<h2 css={tw`text-2xl mb-6`}>New Role</h2> <h2 css={tw`text-neutral-100 text-2xl mb-6`}>New Role</h2>
<Form css={tw`m-0`}> <Form css={tw`m-0`}>
<Field <Field
type={'string'} type={'string'}

View file

@ -41,7 +41,7 @@ export default () => {
<p css={tw`text-base text-neutral-400`}>Soon&trade;</p> <p css={tw`text-base text-neutral-400`}>Soon&trade;</p>
</div> </div>
<NewRoleButton /> <NewRoleButton/>
</div> </div>
<FlashMessageRender byKey={'roles'} css={tw`mb-4`}/> <FlashMessageRender byKey={'roles'} css={tw`mb-4`}/>

View file

@ -1,12 +1,15 @@
import { createContextStore } from 'easy-peasy'; import { createContextStore } from 'easy-peasy';
import { composeWithDevTools } from 'redux-devtools-extension'; import { composeWithDevTools } from 'redux-devtools-extension';
import nests, { AdminNestStore } from '@/state/admin/nests';
import roles, { AdminRoleStore } from '@/state/admin/roles'; import roles, { AdminRoleStore } from '@/state/admin/roles';
interface AdminStore { interface AdminStore {
nests: AdminNestStore
roles: AdminRoleStore; roles: AdminRoleStore;
} }
export const AdminContext = createContextStore<AdminStore>({ export const AdminContext = createContextStore<AdminStore>({
nests,
roles, roles,
}, { }, {
compose: composeWithDevTools({ compose: composeWithDevTools({

View file

@ -0,0 +1,31 @@
import { action, Action } from 'easy-peasy';
import { Nest } from '@/api/admin/nests/getNests';
export interface AdminNestStore {
data: Nest[];
setNests: Action<AdminNestStore, Nest[]>;
appendNest: Action<AdminNestStore, Nest>;
removeNest: Action<AdminNestStore, number>;
}
const nests: AdminNestStore = {
data: [],
setNests: action((state, payload) => {
state.data = payload;
}),
appendNest: action((state, payload) => {
if (state.data.find(nest => nest.id === payload.id)) {
state.data = state.data.map(nest => nest.id === payload.id ? payload : nest);
} else {
state.data = [ ...state.data, payload ];
}
}),
removeNest: action((state, payload) => {
state.data = [ ...state.data.filter(nest => nest.id !== payload) ];
}),
};
export default nests;

View file

@ -16,8 +16,8 @@ const roles: AdminRoleStore = {
}), }),
appendRole: action((state, payload) => { appendRole: action((state, payload) => {
if (state.data.find(database => database.id === payload.id)) { if (state.data.find(role => role.id === payload.id)) {
state.data = state.data.map(database => database.id === payload.id ? payload : database); state.data = state.data.map(role => role.id === payload.id ? payload : role);
} else { } else {
state.data = [ ...state.data, payload ]; state.data = [ ...state.data, payload ];
} }