1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-21 14:50:08 +03:00

F #3951: Add role form & infinite list components (#218)

* Add process screens in role form
* Add infinite list component
* Add top scroll when rerender
This commit is contained in:
Sergio Betanzos 2020-09-16 16:45:13 +02:00 committed by GitHub
parent 12d87e7a42
commit a680a4c94e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 793 additions and 171 deletions

View File

@ -43,9 +43,11 @@
"express": "^4.17.1",
"fireedge-genpotfile": "^1.0.0",
"fs-extra": "^9.0.1",
"fuse.js": "^6.4.1",
"helmet": "^3.23.3",
"http": "0.0.1-security",
"immutable": "^4.0.0-rc.12",
"intersection-observer": "^0.11.0",
"jsonschema": "^1.2.6",
"jsonwebtoken": "^8.5.1",
"jwt-simple": "^0.5.6",

View File

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { Card, CardActionArea, Fade, makeStyles } from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import useNearScreen from 'client/hooks/useNearScreen';
const useStyles = makeStyles(theme => ({
root: {
height: '100%'
},
selected: {
color: theme.palette.primary.contrastText,
backgroundColor: theme.palette.primary.main
},
actionArea: {
height: '100%',
minHeight: 140,
padding: theme.spacing(1)
}
}));
const SelectCard = React.memo(
({ isSelected, handleSelect, handleUnselect, ID, NAME }) => {
const classes = useStyles();
const { isNearScreen, fromRef } = useNearScreen({
distance: '100px'
});
return (
<div ref={fromRef}>
{isNearScreen ? (
<Fade in={isNearScreen}>
<Card
className={clsx(classes.root, { [classes.selected]: isSelected })}
>
<CardActionArea
className={classes.actionArea}
onClick={() =>
isSelected ? handleUnselect(ID) : handleSelect(ID)
}
>
<span>{`📦 ${NAME}`}</span>
</CardActionArea>
</Card>
</Fade>
) : (
<Skeleton variant="rect" width="100%" height={140} />
)}
</div>
);
}
);
SelectCard.propTypes = {
isSelected: PropTypes.bool,
handleSelect: PropTypes.func,
handleUnselect: PropTypes.func,
ID: PropTypes.string,
NAME: PropTypes.string
};
SelectCard.defaultProps = {
isSelected: false,
handleSelect: () => undefined,
handleUnselect: () => undefined,
ID: undefined,
NAME: undefined
};
export default SelectCard;

View File

@ -1,5 +1,6 @@
import ClusterCard from 'client/components/Cards/ClusterCard';
import NetworkCard from 'client/components/Cards/NetworkCard';
import RoleCard from 'client/components/Cards/RoleCard';
import SelectCard from 'client/components/Cards/SelectCard';
export { ClusterCard, NetworkCard, RoleCard };
export { ClusterCard, NetworkCard, RoleCard, SelectCard };

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import {
@ -14,7 +14,7 @@ import { yupResolver } from '@hookform/resolvers';
import { Tr } from 'client/components/HOC';
const DialogForm = React.memo(
const DialogForm = memo(
({ open, title, values, resolver, onSubmit, onCancel, children }) => {
const isMobile = useMediaQuery(theme => theme.breakpoints.only('xs'));
@ -30,25 +30,33 @@ const DialogForm = React.memo(
open={open}
maxWidth="lg"
scroll="paper"
PaperProps={{ style: { height: '80%', minWidth: '80%' } }}
PaperProps={{
style: { height: '100%', minHeight: '80%', minWidth: '80%' }
}}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent dividers>
<FormProvider {...methods}>{children}</FormProvider>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
{Tr('Cancel')}
</Button>
<Button
type="submit"
variant="contained"
color="primary"
onClick={handleSubmit(onSubmit)}
>
{Tr('Save')}
</Button>
</DialogActions>
{(onCancel || onSubmit) && (
<DialogActions>
{onCancel && (
<Button onClick={onCancel} color="primary">
{Tr('Cancel')}
</Button>
)}
{onSubmit && (
<Button
type="submit"
variant="contained"
color="primary"
onClick={handleSubmit(onSubmit)}
>
{Tr('Save')}
</Button>
)}
</DialogActions>
)}
</Dialog>
);
}
@ -75,8 +83,8 @@ DialogForm.defaultProps = {
title: 'Title dialog form',
values: {},
resolver: {},
onSubmit: () => undefined,
onCancel: () => undefined,
onSubmit: undefined,
onCancel: undefined,
children: null
};

View File

@ -18,6 +18,7 @@ export default makeStyles(theme => ({
color: theme.palette.error.dark
},
link: {
color: theme.palette.primary.light
color: theme.palette.primary.light,
marginLeft: theme.spacing(1)
}
}));

View File

@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import { Box } from '@material-ui/core';
import { useFormContext } from 'react-hook-form';
import ErrorHelper from 'client/components/FormControl/ErrorHelper';
@ -14,7 +13,7 @@ function FormList({ step, data, setFormData }) {
const { id, preRender, ListComponent, DialogComponent, DEFAULT_DATA } = step;
useEffect(() => {
preRender && preRender();
if (preRender) preRender();
}, []);
const handleSubmit = values => {
@ -55,19 +54,24 @@ function FormList({ step, data, setFormData }) {
const handleClose = () => setShowDialog(false);
return (
<Box component="form">
<>
{typeof errors[id]?.message === 'string' && (
<ErrorHelper label={errors[id]?.message} />
)}
<ListComponent
list={data}
addCardClick={() => handleOpen()}
itemsProps={({ index }) => ({
handleEdit: () => handleOpen(index),
handleClone: () => handleClone(index),
handleRemove: () => handleRemove(index)
})}
/>
{useMemo(
() => (
<ListComponent
list={data}
handleCreate={() => handleOpen()}
itemsProps={({ index }) => ({
handleEdit: () => handleOpen(index),
handleClone: () => handleClone(index),
handleRemove: () => handleRemove(index)
})}
/>
),
[data, handleOpen, handleClone, handleRemove]
)}
{showDialog && DialogComponent && (
<DialogComponent
open={showDialog}
@ -76,7 +80,7 @@ function FormList({ step, data, setFormData }) {
onCancel={handleClose}
/>
)}
</Box>
</>
);
}

View File

@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Box, Grid } from '@material-ui/core';
import { Grid } from '@material-ui/core';
import { useFormContext } from 'react-hook-form';
import ErrorHelper from '../FormControl/ErrorHelper';
@ -11,7 +11,7 @@ function FormListSelect({ step, data, setFormData }) {
const { id, onlyOneSelect, preRender, list, ItemComponent } = step;
useEffect(() => {
preRender && preRender();
if (preRender) preRender();
}, []);
const handleSelect = index =>
@ -27,26 +27,24 @@ function FormListSelect({ step, data, setFormData }) {
}));
return (
<Box component="form">
<Grid container spacing={3}>
{typeof errors[id]?.message === 'string' && (
<Grid item xs={12}>
<ErrorHelper label={errors[id]?.message} />
<Grid container spacing={3}>
{typeof errors[id]?.message === 'string' && (
<Grid item xs={12}>
<ErrorHelper label={errors[id]?.message} />
</Grid>
)}
{Array.isArray(list) &&
list?.map((info, index) => (
<Grid key={`${id}-${index}`} item xs={6} sm={4} md={3} lg={1}>
<ItemComponent
value={info}
isSelected={data?.some(selected => selected === info?.ID)}
handleSelect={handleSelect}
handleUnselect={handleUnselect}
/>
</Grid>
)}
{Array.isArray(list) &&
list?.map((info, index) => (
<Grid key={`${id}-${index}`} item xs={6} sm={4} md={3} lg={1}>
<ItemComponent
info={info}
isSelected={data?.some(selected => selected === info?.ID)}
handleSelect={handleSelect}
handleUnselect={handleUnselect}
/>
</Grid>
))}
</Grid>
</Box>
))}
</Grid>
);
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useFormContext } from 'react-hook-form';
@ -11,29 +11,32 @@ function FormStep({ step, data, setFormData }) {
const { id, preRender, FormComponent, DialogComponent } = step;
useEffect(() => {
preRender && preRender();
if (preRender) preRender();
}, []);
const handleOpen = () => setShowDialog(true);
const handleClose = () => setShowDialog(false);
const handleSubmit = d => console.log(d);
return (
<>
{typeof errors[id]?.message === 'string' && (
<ErrorHelper label={errors[id]?.message} />
)}
{React.useMemo(
{useMemo(
() => (
<FormComponent id={id} handleClick={handleOpen} />
<FormComponent
id={id}
values={data}
setFormData={setFormData}
handleClick={handleOpen}
/>
),
[id, handleOpen]
[id, handleOpen, setFormData, data]
)}
{showDialog && DialogComponent && (
<DialogComponent
open={showDialog}
values={data}
onSubmit={handleSubmit}
onCancel={handleClose}
/>
)}
@ -43,7 +46,11 @@ function FormStep({ step, data, setFormData }) {
FormStep.propTypes = {
step: PropTypes.objectOf(PropTypes.any).isRequired,
data: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired,
data: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object,
PropTypes.string
]).isRequired,
setFormData: PropTypes.func.isRequired
};

View File

@ -23,9 +23,6 @@ const Group = () => {
group && setPrimaryGroup({ group });
};
const filterSearch = ({ NAME }, search) =>
NAME?.toLowerCase().includes(search);
const renderResult = ({ ID, NAME }, handleClose) => {
const isSelected =
(filterPool === ALL_RESOURCES && ALL_RESOURCES === ID) ||
@ -47,16 +44,18 @@ const Group = () => {
);
};
const sortGroupAsMainFirst = (a, b) => {
if (a.ID === authUser?.GUID) {
return -1;
} else if (b.ID === authUser?.GUID) {
return 1;
}
return 0;
};
const sortMainGroupFirst = groups
?.concat({ ID: ALL_RESOURCES, NAME: 'Show All' })
?.sort((a, b) => {
if (a.ID === authUser?.GUID) {
return -1;
} else if (b.ID === authUser?.GUID) {
return 1;
}
return 0;
});
?.sort(sortGroupAsMainFirst);
return (
<HeaderPopover
@ -68,8 +67,12 @@ const Group = () => {
{({ handleClose }) => (
<Search
list={sortMainGroupFirst}
listOptions={{
shouldSort: true,
sortFn: sortGroupAsMainFirst,
keys: ['NAME']
}}
maxResults={5}
filterSearch={filterSearch}
renderResult={group => renderResult(group, handleClose)}
/>
)}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import {
@ -19,17 +19,17 @@ const useStyles = makeStyles(() => ({
}
}));
function ListCards({ addCardClick, list, CardComponent, cardsProps }) {
function ListCards({ handleCreate, list, CardComponent, cardsProps }) {
const classes = useStyles();
return (
<Grid container spacing={3}>
{addCardClick &&
React.useMemo(
{handleCreate &&
useMemo(
() => (
<Grid item xs={12} sm={4} md={3} lg={2}>
<Card className={classes.cardPlus} raised>
<CardActionArea onClick={addCardClick}>
<CardActionArea onClick={handleCreate}>
<CardContent>
<AddIcon />
</CardContent>
@ -37,7 +37,7 @@ function ListCards({ addCardClick, list, CardComponent, cardsProps }) {
</Card>
</Grid>
),
[addCardClick, classes]
[handleCreate, classes]
)}
{Array.isArray(list) &&
list?.map((value, index) => (
@ -51,7 +51,7 @@ function ListCards({ addCardClick, list, CardComponent, cardsProps }) {
ListCards.propTypes = {
list: PropTypes.arrayOf(PropTypes.any).isRequired,
addCardClick: PropTypes.func,
handleCreate: PropTypes.func,
CardComponent: PropTypes.oneOfType([
PropTypes.node,
PropTypes.object,
@ -62,7 +62,7 @@ ListCards.propTypes = {
ListCards.defaultProps = {
list: [],
addCardClick: [],
handleCreate: undefined,
CardComponent: null,
cardsProps: () => undefined
};

View File

@ -0,0 +1,71 @@
import React, { useRef, useEffect, useCallback, createRef } from 'react';
import PropTypes from 'prop-types';
import { debounce, LinearProgress } from '@material-ui/core';
import useNearScreen from 'client/hooks/useNearScreen';
import useList from 'client/hooks/useList';
const ListInfiniteScroll = ({ list, renderResult }) => {
const gridRef = createRef();
const { loading, shortList, finish, reset, setLength } = useList({
list,
initLength: 50
});
const loaderRef = useRef();
const { isNearScreen } = useNearScreen({
distance: '100px',
externalRef: loading ? null : loaderRef,
once: false
});
useEffect(() => {
reset(list);
gridRef.current.scrollIntoView({ block: 'start' });
}, [list]);
const debounceHandleNextPage = useCallback(
debounce(() => {
setLength(prevLength => prevLength + 20);
}, 200),
[setLength]
);
useEffect(() => {
if (isNearScreen && !finish) debounceHandleNextPage();
}, [isNearScreen, finish, debounceHandleNextPage]);
return (
<div style={{ overflowY: 'auto', padding: 10 }}>
<div
ref={gridRef}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gridGap: 10
}}
>
{shortList?.map(renderResult)}
</div>
{!finish && (
<LinearProgress
ref={loaderRef}
style={{ width: '100%', marginTop: 10 }}
/>
)}
</div>
);
};
ListInfiniteScroll.propTypes = {
list: PropTypes.arrayOf(PropTypes.any),
renderResult: PropTypes.func
};
ListInfiniteScroll.defaultProps = {
list: [],
renderResult: () => null
};
export default ListInfiniteScroll;

View File

@ -0,0 +1,86 @@
import React, { useState, useEffect, createElement } from 'react';
import PropTypes from 'prop-types';
import { Fade, Button, IconButton } from '@material-ui/core';
import BackIcon from '@material-ui/icons/ArrowBackIosOutlined';
function ProcessScreen({ screens, id, values, setFormData }) {
const [process, setProcess] = useState(undefined);
useEffect(() => {
const keyValues = Object.keys(values);
if (keyValues.length > 0) {
const currentScreen = keyValues[0];
const index = screens.findIndex(scr => scr.id === currentScreen);
if (index !== -1) setProcess(index);
}
}, []);
const handleSetData = data =>
setFormData(prevData => ({
...prevData,
[id]: data ? { [screens[process]?.id]: data } : undefined
}));
const handleBack = () => {
setProcess(undefined);
handleSetData();
};
return (
<>
{process !== undefined ? (
createElement(screens[process]?.screen, {
backButton: (
<IconButton onClick={handleBack}>
<BackIcon />
</IconButton>
),
handleSetData,
currentValue: values[screens[process]?.id]
})
) : (
<div style={{ flexGrow: 1 }}>
<div
style={{
height: '100%',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
flexWrap: 'wrap'
}}
>
{screens?.map(({ id, button }, index) => (
<Fade in timeout={500} key={`option-${id}`}>
<Button
variant="contained"
style={{ backgroundColor: '#fff' }}
onClick={() => setProcess(index)}
>
{button}
</Button>
</Fade>
))}
</div>
</div>
)}
</>
);
}
ProcessScreen.propTypes = {
screens: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
button: PropTypes.element,
screen: PropTypes.func
})
)
};
ProcessScreen.defaultProps = {
screens: []
};
export default ProcessScreen;

View File

@ -1,54 +1,81 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useMemo } from 'react';
import PropTypes from 'prop-types';
import { TextField, Box } from '@material-ui/core';
import Fuse from 'fuse.js';
import { TextField, Box, debounce } from '@material-ui/core';
import ListInfiniteScroll from 'client/components/List/ListInfiniteScroll';
const Search = ({
list,
maxResults,
filterSearch,
listOptions,
renderResult,
ResultBoxProps
startAdornment,
searchBoxProps
}) => {
const [search, setSearch] = useState('');
const [result, setResult] = useState(list);
const [query, setQuery] = useState('');
const [result, setResult] = useState(undefined);
const listFuse = useMemo(
() => new Fuse(list, listOptions, Fuse.createIndex(listOptions.keys, list)),
[list, listOptions]
);
useEffect(() => {
setResult(list?.filter(item => filterSearch(item, search)));
}, [search, list]);
const debounceResult = React.useCallback(
debounce(value => {
const search = listFuse.search(value)?.map(({ item }) => item);
const handleChange = event => setSearch(event.target.value);
setResult(value ? search : undefined);
}, 1000),
[list]
);
const handleChange = event => {
const { value: nextValue } = event?.target;
setQuery(nextValue);
debounceResult(nextValue);
};
return (
<>
<TextField
type="search"
value={search}
onChange={handleChange}
fullWidth
placeholder="Search..."
/>
<Box {...ResultBoxProps}>
{result?.slice(0, maxResults).map(renderResult)}
<Box {...searchBoxProps}>
{startAdornment && startAdornment}
<TextField
type="search"
value={query}
onChange={handleChange}
fullWidth
placeholder="Search..."
/>
</Box>
{result?.length === 0 ? (
<h4>{`Your search did not match`}</h4>
) : (
<ListInfiniteScroll list={result ?? list} renderResult={renderResult} />
)}
</>
);
};
Search.propTypes = {
list: PropTypes.arrayOf(PropTypes.object).isRequired,
maxResults: PropTypes.number,
filterSearch: PropTypes.func,
listOptions: PropTypes.shape({
isCaseSensitive: PropTypes.bool,
shouldSort: PropTypes.bool,
sortFn: PropTypes.func,
keys: PropTypes.arrayOf(PropTypes.string)
}),
renderResult: PropTypes.func,
ResultBoxProps: PropTypes.objectOf(PropTypes.object)
startAdornment: PropTypes.objectOf(PropTypes.any),
searchBoxProps: PropTypes.objectOf(PropTypes.any)
};
Search.defaultProps = {
list: [],
maxResults: undefined,
filterSearch: (item, search) => item.toLowerCase().includes(search),
fullList: [],
listOptions: {},
renderResult: item => item,
ResultBoxProps: {}
startAdornment: undefined,
searchBoxProps: {}
};
export default Search;

View File

@ -26,10 +26,10 @@ const Networks = () => {
},
resolver: STEP_FORM_SCHEMA,
DEFAULT_DATA: NETWORK_FORM_SCHEMA.default(),
ListComponent: ({ list, addCardClick, itemsProps }) => (
ListComponent: ({ list, handleCreate, itemsProps }) => (
<ListCards
list={list}
addCardClick={addCardClick}
handleCreate={handleCreate}
CardComponent={NetworkCard}
cardsProps={itemsProps}
/>

View File

@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
function ImportDockerFile({ backButton }) {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
{backButton}
<h1 style={{ marginLeft: 5, flexGrow: 1 }}>Docker file</h1>
</div>
);
}
ImportDockerFile.propTypes = {
backButton: PropTypes.node
};
ImportDockerFile.defaultProps = {
backButton: null
};
export default ImportDockerFile;

View File

@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import useOpennebula from 'client/hooks/useOpennebula';
import Search from 'client/components/Search';
import { SelectCard } from 'client/components/Cards';
const sortByID = (a, b) => a.ID - b.ID;
function ListMarketApp({ backButton, currentValue, handleSetData }) {
const { apps, getMarketApps } = useOpennebula();
useEffect(() => {
getMarketApps();
}, []);
const handleSelect = index => handleSetData(index);
const handleUnselect = () => handleSetData();
const renderApp = app => (
<SelectCard
key={`app-${app.ID}`}
isSelected={app.ID === currentValue}
handleSelect={handleSelect}
handleUnselect={handleUnselect}
{...app}
/>
);
return (
<Search
list={apps?.sort(sortByID)}
listOptions={{ shouldSort: true, sortFn: sortByID, keys: ['NAME'] }}
renderResult={renderApp}
startAdornment={backButton}
searchBoxProps={{
style: {
display: 'flex',
padding: '1rem 0',
gap: 10
}
}}
/>
);
}
ListMarketApp.propTypes = {
backButton: PropTypes.node,
currentValue: PropTypes.string,
handleSetData: PropTypes.func
};
ListMarketApp.defaultProps = {
backButton: null,
currentValue: undefined,
handleSetData: () => undefined
};
export default ListMarketApp;

View File

@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import useOpennebula from 'client/hooks/useOpennebula';
import Search from 'client/components/Search';
import { SelectCard } from 'client/components/Cards';
const sortByID = (a, b) => a.ID - b.ID;
function ListTemplates({ backButton, currentValue, handleSetData }) {
const { templates, getTemplates } = useOpennebula();
useEffect(() => {
getTemplates();
}, []);
const handleSelect = index => handleSetData(index);
const handleUnselect = () => handleSetData();
const renderTemplate = tmp => (
<SelectCard
key={`tmp-${tmp.ID}`}
isSelected={tmp.ID === currentValue}
handleSelect={handleSelect}
handleUnselect={handleUnselect}
{...tmp}
/>
);
return (
<Search
list={templates?.sort(sortByID)}
listOptions={{ shouldSort: true, sortFn: sortByID, keys: ['NAME'] }}
renderResult={renderTemplate}
startAdornment={backButton}
searchBoxProps={{
style: {
display: 'flex',
padding: '1rem 0',
gap: 10
}
}}
/>
);
}
ListTemplates.propTypes = {
backButton: PropTypes.node,
currentValue: PropTypes.string,
handleSetData: PropTypes.func
};
ListTemplates.defaultProps = {
backButton: null,
currentValue: undefined,
handleSetData: () => undefined
};
export default ListTemplates;

View File

@ -1,18 +1,43 @@
import React from 'react';
import TemplateIcon from '@material-ui/icons/InsertDriveFileOutlined';
import MarketplaceIcon from '@material-ui/icons/ShoppingCartOutlined';
import DockerLogo from 'client/icons/docker';
import ProcessScreen from 'client/components/ProcessScreen';
import FormStep from 'client/components/FormStepper/FormStep';
import ListTemplates from './List/Templates';
import ListMarketApps from './List/MarketApps';
import DockerFile from './List/Docker';
import { STEP_FORM_SCHEMA } from './schema';
const Template = () => {
const STEP_ID = 'template';
const SCREENS = [
{
id: 'template',
button: <TemplateIcon style={{ fontSize: 100 }} />,
screen: ListTemplates
},
{
id: 'app',
button: <MarketplaceIcon style={{ fontSize: 100 }} />,
screen: ListMarketApps
},
{
id: 'docker',
button: <DockerLogo width="100" height="100%" color="#066da5" />,
screen: DockerFile
}
];
return {
id: STEP_ID,
label: 'Template VM',
content: FormStep,
resolver: STEP_FORM_SCHEMA,
FormComponent: () => <h1>Screen with options</h1>
FormComponent: props => ProcessScreen({ screens: SCREENS, ...props })
};
};

View File

@ -1,21 +1,22 @@
import * as yup from 'yup';
import { TYPE_INPUT } from 'client/constants';
import { getValidationFromFields } from 'client/utils/helpers';
export const FORM_FIELDS = [
{
name: 'template',
label: 'Template VM',
type: TYPE_INPUT.TEXT,
validation: yup
.string()
.min(1)
.trim()
.required('Template field is required')
.default('0')
validation: yup.string().trim()
},
{
name: 'app',
validation: yup.string().trim()
},
{
name: 'docker',
validation: yup.string().trim()
}
];
export const STEP_FORM_SCHEMA = yup.object(
getValidationFromFields(FORM_FIELDS)
);
export const STEP_FORM_SCHEMA = yup
.object(getValidationFromFields(FORM_FIELDS))
.required('Template is required')
.default(undefined);

View File

@ -1,12 +1,9 @@
import React, { useMemo } from 'react';
import * as yup from 'yup';
import { useForm, FormProvider } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers';
import FormStepper from 'client/components/FormStepper';
import { DialogForm } from 'client/components/Dialogs';
import FormStep from 'client/components/FormStepper/FormStep';
import FormList from 'client/components/FormStepper/FormList';
import FlowWithFAB from 'client/components/Flows/FlowWithFAB';
import Steps from './Steps';
@ -15,37 +12,47 @@ const Roles = () => {
const STEP_ID = 'roles';
const { steps, defaultValues, resolvers } = Steps();
const methods = useForm({
mode: 'onBlur',
defaultValues,
resolver: yupResolver(resolvers)
});
const onSubmit = d => console.log('role data form', d);
return useMemo(
() => ({
id: STEP_ID,
label: 'Defining each role',
content: FormStep,
content: FormList,
DEFAULT_DATA: defaultValues,
resolver: yup
.array()
.of(resolvers)
.min(1)
.required()
.default([]),
DialogComponent: props => (
<DialogForm title={'Role form'} resolver={resolvers} {...props}>
<FormProvider {...methods}>
ListComponent: ({ list, handleCreate }) => (
<div>
<button onClick={handleCreate}>Add role</button>
<div>{JSON.stringify(list)}</div>
</div>
),
DialogComponent: ({ values, onSubmit, onCancel, ...props }) => (
<DialogForm
title={'Role form'}
resolver={resolvers}
values={values}
onCancel={onCancel}
{...props}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<FormStepper
steps={steps}
initialValue={defaultValues}
initialValue={values}
onSubmit={onSubmit}
/>
</FormProvider>
</div>
</DialogForm>
),
FormComponent: FlowWithFAB
)
}),
[]
);

View File

@ -1,23 +1,23 @@
import * as yup from 'yup';
import BasicConfiguration from './BasicConfiguration';
import Clusters from './Clusters';
import Networks from './Networks';
import Roles from './Roles';
import Clusters from './Clusters';
const Steps = () => {
const basic = BasicConfiguration();
const clusters = Clusters();
const networks = Networks();
const roles = Roles();
const clusters = Clusters();
const steps = [basic, networks, roles, clusters];
const steps = [basic, clusters, networks, roles];
const resolvers = yup.object({
[basic.id]: basic.resolver,
[clusters.id]: clusters.resolver,
[networks.id]: networks.resolver,
[roles.id]: roles.resolver,
[clusters.id]: clusters.resolver
[roles.id]: roles.resolver
});
const defaultValues = resolvers.default();

View File

@ -0,0 +1,64 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { fakeDelay } from 'client/utils/helpers';
function useList({ list, initLength }) {
const [fullList, setFullList] = useState([]);
const [shortList, setShortList] = useState([]);
const [finish, setFinish] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingNextPage, setLoadingNextPage] = useState(false);
const [length, setLength] = useState(initLength);
useEffect(() => {
/* FIRST TIME */
if (list?.length === 0 || shortList.length !== 0) return;
setLoading(true);
fakeDelay(200)
.then(() => setFullList(list))
.then(() => setShortList(list.slice(0, initLength)))
.then(() => setLoading(false));
}, [list]);
useEffect(() => {
/* SHOW NEXT PAGE */
if (finish) return;
if (length === initLength) return;
setLoadingNextPage(true);
fakeDelay(500)
.then(() =>
setShortList(prev => prev.concat(fullList.slice(prev.length, length)))
)
.then(() => setLoadingNextPage(false))
.then(() => setFinish(shortList.length >= fullList.length));
}, [length, setLength]);
const reset = newList => {
/* RESET VALUES */
setLength(initLength);
setFullList(newList);
setShortList(newList.slice(0, initLength));
setFinish(newList.length < initLength);
};
return { loading, loadingNextPage, shortList, finish, reset, setLength };
}
useList.propTypes = {
list: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchList: PropTypes.func.isRequired,
initLength: PropTypes.string
};
useList.defaultProps = {
list: [],
fetchList: () => undefined,
initLength: 50
};
export default useList;

View File

@ -0,0 +1,46 @@
import { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
const useNearScreen = ({ externalRef, distance, once = true } = {}) => {
const [isNearScreen, setShow] = useState(false);
const fromRef = useRef();
useEffect(() => {
let observer;
const element = externalRef ? externalRef.current : fromRef.current;
const onChange = entries => {
entries.forEach(({ isIntersecting }) => {
if (isIntersecting) {
setShow(true);
once && observer.disconnect();
} else {
!once && setShow(false);
}
});
};
Promise.resolve(
typeof IntersectionObserver !== 'undefined'
? IntersectionObserver
: import('intersection-observer')
).then(() => {
observer = new IntersectionObserver(onChange, {
// root: listRef.current
rootMargin: distance
});
if (element) observer.observe(element);
});
return () => observer && observer.disconnect();
});
return { isNearScreen, fromRef };
};
useNearScreen.propTypes = {};
useNearScreen.defaultProps = {};
export default useNearScreen;

View File

@ -7,6 +7,7 @@ import actions, {
} from 'client/actions/pool';
import * as servicePool from 'client/services/pool';
import { filterBy } from 'client/utils/helpers';
export default function useOpennebula() {
const dispatch = useDispatch();
@ -59,21 +60,31 @@ export default function useOpennebula() {
.catch(err => dispatch(failureOneRequest({ error: err })));
}, [dispatch, filter]);
const getTemplates = useCallback(() => {
dispatch(startOneRequest());
return servicePool
.getTemplates({ filter })
.then(data => dispatch(actions.setTemplates(data)))
.catch(err => dispatch(failureOneRequest({ error: err })));
}, [dispatch, filter]);
const getTemplates = useCallback(
({ end, start } = { end: -1, start: -1 }) => {
dispatch(startOneRequest());
return servicePool
.getTemplates({ filter, end, start })
.then(data =>
dispatch(actions.setTemplates(filterBy(templates.concat(data), 'ID')))
)
.catch(err => dispatch(failureOneRequest({ error: err })));
},
[dispatch, filter, templates]
);
const getMarketApps = useCallback(() => {
dispatch(startOneRequest());
return servicePool
.getMarketApps({ filter })
.then(data => dispatch(actions.setApps(data)))
.catch(err => dispatch(failureOneRequest({ error: err })));
}, [dispatch, filter]);
const getMarketApps = useCallback(
({ end, start } = { end: -1, start: -1 }) => {
dispatch(startOneRequest());
return servicePool
.getMarketApps({ filter, end, start })
.then(data =>
dispatch(actions.setApps(filterBy(apps.concat(data), 'ID')))
)
.catch(err => dispatch(failureOneRequest({ error: err })));
},
[dispatch, filter, apps]
);
const getClusters = useCallback(() => {
dispatch(startOneRequest());

View File

@ -0,0 +1,29 @@
import React from 'react';
import { number, string, oneOfType } from 'prop-types';
function DockerLogo({ viewBox, width, height, color, ...props }) {
return (
<svg viewBox={viewBox} width={width} height={height} {...props} {...props}>
<path
fill={color}
d="M296 245h42v-38h-42zm-50 0h42v-38h-42zm-49 0h42v-38h-42zm-49 0h41v-38h-41zm-50 0h42v-38H98zm50-46h41v-38h-41zm49 0h42v-38h-42zm49 0h42v-38h-42zm0-46h42v-38h-42zm226 75s-18-17-55-11c-4-29-35-46-35-46s-29 35-8 74c-6 3-16 7-31 7H68c-5 19-5 145 133 145 99 0 173-46 208-130 52 4 63-39 63-39z"
/>
</svg>
);
}
DockerLogo.propTypes = {
width: oneOfType([number, string]).isRequired,
height: oneOfType([number, string]).isRequired,
viewBox: string,
color: string
};
DockerLogo.defaultProps = {
width: 360,
height: 360,
viewBox: '0 0 512 512',
color: '#066da5'
};
export default DockerLogo;

View File

@ -87,7 +87,7 @@ const Logo = ({ width, height, spinner, withText, viewBox, ...props }) => {
Logo.propTypes = {
width: oneOfType([number, string]).isRequired,
height: number.isRequired,
height: oneOfType([number, string]).isRequired,
viewBox: string,
spinner: bool,
withText: bool
@ -100,4 +100,5 @@ Logo.defaultProps = {
spinner: false,
withText: false
};
export default Logo;

View File

@ -65,10 +65,10 @@ export const getVNetworksTemplates = ({ filter }) => {
});
};
export const getTemplates = ({ filter }) => {
export const getTemplates = ({ filter, end, start }) => {
const name = Template.Actions.TEMPLATE_POOL_INFO;
const { url, options } = requestParams(
{ filter },
{ filter, end, start },
{ name, ...Template.Commands[name] }
);
@ -79,10 +79,10 @@ export const getTemplates = ({ filter }) => {
});
};
export const getMarketApps = ({ filter }) => {
export const getMarketApps = ({ filter, end, start }) => {
const name = MarketApp.Actions.MARKETAPP_POOL_INFO;
const { url, options } = requestParams(
{ filter },
{ filter, end, start },
{ name, ...MarketApp.Commands[name] }
);

View File

@ -8,3 +8,20 @@ export const getValidationFromFields = schema =>
}),
{}
);
export const filterBy = (arr, predicate) => {
const callback =
typeof predicate === 'function' ? predicate : output => output[predicate];
return [
...arr
.reduce((map, item) => {
const key = item === null || item === undefined ? item : callback(item);
map.has(key) || map.set(key, item);
return map;
}, new Map())
.values()
];
};

View File

@ -4,8 +4,9 @@ import { from as resourceFrom } from 'server/utils/constants/defaults';
export const getQueries = params =>
Object.entries(params)
?.filter(([, { from, value }]) =>
Boolean(from === resourceFrom.query && value)
?.filter(
([, { from, value }]) =>
from === resourceFrom.query && value !== undefined
)
?.map(([name, { value }]) => `${name}=${encodeURI(value)}`)
?.join('&');