From 3820d4e1567ed7b034b194a0beca0cf579551f74 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 22 Feb 2020 20:07:56 -0800 Subject: [PATCH] Add view for editing the details of a schedule --- resources/scripts/.eslintrc.yml | 1 + .../scripts/components/elements/Checkbox.tsx | 42 +++++++++ .../elements/FormikFieldWrapper.tsx | 31 +++++++ .../components/elements/InputError.tsx | 27 ++++++ .../scripts/components/elements/Switch.tsx | 85 +++++++++++++++++ .../server/schedules/EditScheduleModal.tsx | 93 ++++++++++++++++++- .../server/schedules/ScheduleContainer.tsx | 19 ++-- resources/scripts/routers/ServerRouter.tsx | 59 ++++++------ resources/scripts/state/hooks.ts | 9 ++ resources/styles/components/forms.css | 18 ++-- tailwind.js | 3 + 11 files changed, 334 insertions(+), 53 deletions(-) create mode 100644 resources/scripts/components/elements/Checkbox.tsx create mode 100644 resources/scripts/components/elements/FormikFieldWrapper.tsx create mode 100644 resources/scripts/components/elements/InputError.tsx create mode 100644 resources/scripts/components/elements/Switch.tsx create mode 100644 resources/scripts/state/hooks.ts diff --git a/resources/scripts/.eslintrc.yml b/resources/scripts/.eslintrc.yml index 003d346cc..564306640 100644 --- a/resources/scripts/.eslintrc.yml +++ b/resources/scripts/.eslintrc.yml @@ -29,6 +29,7 @@ rules: "react-hooks/exhaustive-deps": 0 "@typescript-eslint/explicit-function-return-type": 0 "@typescript-eslint/explicit-member-accessibility": 0 + "@typescript-eslint/ban-ts-ignore": 0 "@typescript-eslint/no-unused-vars": 0 "@typescript-eslint/no-explicit-any": 0 "@typescript-eslint/no-non-null-assertion": 0 diff --git a/resources/scripts/components/elements/Checkbox.tsx b/resources/scripts/components/elements/Checkbox.tsx new file mode 100644 index 000000000..bd7b7a708 --- /dev/null +++ b/resources/scripts/components/elements/Checkbox.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Field, FieldProps } from 'formik'; + +interface Props { + name: string; + value: string; +} + +type OmitFields = 'name' | 'value' | 'type' | 'checked' | 'onChange'; + +type InputProps = Omit, HTMLInputElement>, OmitFields>; + +const Checkbox = ({ name, value, ...props }: Props & InputProps) => ( + + {({ field, form }: FieldProps) => { + if (!Array.isArray(field.value)) { + console.error('Attempting to mount a checkbox using a field value that is not an array.'); + + return null; + } + + return ( + form.setFieldTouched(field.name, true)} + onChange={e => { + const set = new Set(field.value); + set.has(value) ? set.delete(value) : set.add(value); + + field.onChange(e); + form.setFieldValue(field.name, Array.from(set)); + }} + /> + ); + }} + +); + +export default Checkbox; diff --git a/resources/scripts/components/elements/FormikFieldWrapper.tsx b/resources/scripts/components/elements/FormikFieldWrapper.tsx new file mode 100644 index 000000000..37031710b --- /dev/null +++ b/resources/scripts/components/elements/FormikFieldWrapper.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Field, FieldProps } from 'formik'; +import classNames from 'classnames'; +import InputError from '@/components/elements/InputError'; + +interface Props { + name: string; + children: React.ReactNode; + className?: string; + label?: string; + description?: string; + validate?: (value: any) => undefined | string | Promise; +} + +const FormikFieldWrapper = ({ name, label, className, description, validate, children }: Props) => ( + + { + ({ field, form: { errors, touched } }: FieldProps) => ( +
+ {label && } + {children} + + {description ?

{description}

: null} +
+
+ ) + } +
+); + +export default FormikFieldWrapper; diff --git a/resources/scripts/components/elements/InputError.tsx b/resources/scripts/components/elements/InputError.tsx new file mode 100644 index 000000000..df5bc7369 --- /dev/null +++ b/resources/scripts/components/elements/InputError.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import capitalize from 'lodash-es/capitalize'; +import { FormikErrors, FormikTouched } from 'formik'; + +interface Props { + errors: FormikErrors; + touched: FormikTouched; + name: string; + children?: React.ReactNode; +} + +const InputError = ({ errors, touched, name, children }: Props) => ( + touched[name] && errors[name] ? +

+ {typeof errors[name] === 'string' ? + capitalize(errors[name] as string) + : + capitalize((errors[name] as unknown as string[])[0]) + } +

+ : + + {children} + +); + +export default InputError; diff --git a/resources/scripts/components/elements/Switch.tsx b/resources/scripts/components/elements/Switch.tsx new file mode 100644 index 000000000..932d8a67c --- /dev/null +++ b/resources/scripts/components/elements/Switch.tsx @@ -0,0 +1,85 @@ +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import v4 from 'uuid/v4'; +import classNames from 'classnames'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import { Field, FieldProps } from 'formik'; + +const ToggleContainer = styled.div` + ${tw`relative select-none w-12 leading-normal`}; + + & > input[type="checkbox"] { + ${tw`hidden`}; + + &:checked + label { + ${tw`bg-primary-500 border-primary-700 shadow-none`}; + } + + &:checked + label:before { + right: 0.125rem; + } + } + + & > label { + ${tw`mb-0 block overflow-hidden cursor-pointer bg-neutral-400 border border-neutral-700 rounded-full h-6 shadow-inner`}; + transition: all 75ms linear; + + &::before { + ${tw`absolute block bg-white border h-5 w-5 rounded-full`}; + top: 0.125rem; + right: calc(50% + 0.125rem); + //width: 1.25rem; + //height: 1.25rem; + content: ""; + transition: all 75ms ease-in; + } + } +`; + +interface Props { + name: string; + description?: string; + label: string; + enabled?: boolean; +} + +const Switch = ({ name, label, description }: Props) => { + const uuid = useMemo(() => v4(), []); + + return ( + +
+ + + {({ field, form }: FieldProps) => ( + { + form.setFieldTouched(name); + form.setFieldValue(field.name, !field.value); + }} + defaultChecked={field.value} + /> + )} + + +
+ + {description && +

+ {description} +

+ } +
+
+
+ ); +}; + +export default Switch; diff --git a/resources/scripts/components/server/schedules/EditScheduleModal.tsx b/resources/scripts/components/server/schedules/EditScheduleModal.tsx index 76a1e9108..c89e36949 100644 --- a/resources/scripts/components/server/schedules/EditScheduleModal.tsx +++ b/resources/scripts/components/server/schedules/EditScheduleModal.tsx @@ -1,15 +1,98 @@ import React from 'react'; import { Schedule } from '@/api/server/schedules/getServerSchedules'; import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import Field from '@/components/elements/Field'; +import { connect } from 'react-redux'; +import { Form, FormikProps, withFormik } from 'formik'; +import { Actions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import Switch from '@/components/elements/Switch'; +import { boolean, object, string } from 'yup'; -type Props = { - schedule?: Schedule; -} & RequiredModalProps; +type OwnProps = { schedule: Schedule } & RequiredModalProps; -export default ({ schedule, ...props }: Props) => { +interface ReduxProps { + addError: ApplicationStore['flashes']['addError']; +} + +type ComponentProps = OwnProps & ReduxProps; + +interface Values { + name: string; + dayOfWeek: string; + dayOfMonth: string; + hour: string; + minute: string; + enabled: boolean; +} + +const EditScheduleModal = ({ values, schedule, ...props }: ComponentProps & FormikProps) => { return ( -

Testing

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+

+ The schedule system supports the use of Cronjob syntax when defining when tasks should begin + running. Use the fields above to specify when these tasks should begin running. +

+
+ +
+
+ +
+
); }; + +export default connect( + null, + // @ts-ignore + (dispatch: Actions) => ({ + addError: dispatch.flashes.addError, + }), +)( + withFormik({ + handleSubmit: (values, { props }) => { + }, + + mapPropsToValues: ({ schedule }) => ({ + name: schedule.name, + dayOfWeek: schedule.cron.dayOfWeek, + dayOfMonth: schedule.cron.dayOfMonth, + hour: schedule.cron.hour, + minute: schedule.cron.minute, + enabled: schedule.isActive, + }), + + validationSchema: object().shape({ + name: string().required(), + enabled: boolean().required(), + }), + })(EditScheduleModal), +); diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx index 332bb9f0d..e7495abe9 100644 --- a/resources/scripts/components/server/schedules/ScheduleContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import getServerSchedules, { Schedule } from '@/api/server/schedules/getServerSchedules'; import { ServerContext } from '@/state/server'; import Spinner from '@/components/elements/Spinner'; -import { Link, RouteComponentProps } from 'react-router-dom'; +import { RouteComponentProps } from 'react-router-dom'; import FlashMessageRender from '@/components/FlashMessageRender'; import ScheduleRow from '@/components/server/schedules/ScheduleRow'; import { httpErrorToHuman } from '@/api/http'; @@ -14,8 +14,9 @@ interface Params { schedule?: string; } -export default ({ history, match, location: { hash } }: RouteComponentProps) => { +export default ({ history, match }: RouteComponentProps) => { const { id, uuid } = ServerContext.useStoreState(state => state.server.data!); + const [ active, setActive ] = useState(0); const [ schedules, setSchedules ] = useState(null); const { clearFlashes, addError } = useStoreActions((actions: Actions) => actions.flashes); @@ -29,7 +30,9 @@ export default ({ history, match, location: { hash } }: RouteComponentProps schedule.id === Number(hash.match(/\d+$/) || 0)); + const matched = useMemo(() => { + return schedules?.find(schedule => schedule.id === active); + }, [ active ]); return (
@@ -38,13 +41,13 @@ export default ({ history, match, location: { hash } }: RouteComponentProps : schedules.map(schedule => ( - setActive(schedule.id)} + className={'grey-row-box cursor-pointer'} > - +
)) } {matched && @@ -52,7 +55,7 @@ export default ({ history, match, location: { hash } }: RouteComponentProps history.push(match.url)} + onDismissed={() => setActive(0)} /> } diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 6a50585c2..7589a0d03 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -6,7 +6,6 @@ import TransitionRouter from '@/TransitionRouter'; import Spinner from '@/components/elements/Spinner'; import WebsocketHandler from '@/components/server/WebsocketHandler'; import { ServerContext } from '@/state/server'; -import { Provider } from 'react-redux'; import DatabasesContainer from '@/components/server/databases/DatabasesContainer'; import FileManagerContainer from '@/components/server/files/FileManagerContainer'; import { CSSTransition } from 'react-transition-group'; @@ -41,36 +40,34 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) - - - - {!server ? -
- -
- : - - - - - ( - - - - )} - exact - /> - - {/* */} - - - - - } -
-
+ + + {!server ? +
+ +
+ : + + + + + ( + + + + )} + exact + /> + + {/* */} + + + + + } +
); }; diff --git a/resources/scripts/state/hooks.ts b/resources/scripts/state/hooks.ts new file mode 100644 index 000000000..4b990183b --- /dev/null +++ b/resources/scripts/state/hooks.ts @@ -0,0 +1,9 @@ +import { createTypedHooks } from 'easy-peasy'; +import { ApplicationStore } from '@/state/index'; + +const hooks = createTypedHooks(); + +export const useStore = hooks.useStore; +export const useStoreState = hooks.useStoreState; +export const useStoreActions = hooks.useStoreActions; +export const useStoreDispatch = hooks.useStoreDispatch; diff --git a/resources/styles/components/forms.css b/resources/styles/components/forms.css index b3ce4e78f..5dbe0c20a 100644 --- a/resources/styles/components/forms.css +++ b/resources/styles/components/forms.css @@ -47,14 +47,6 @@ input[type=number] { &:disabled { @apply .bg-neutral-100 .border-neutral-200; } - - & + .input-help { - @apply .text-xs .text-neutral-400 .pt-2; - - &.error { - @apply .text-red-600; - } - } } .input-dark:not(select) { @@ -70,7 +62,7 @@ input[type=number] { } & + .input-help { - @apply .text-xs .text-neutral-400 .mt-2 + @apply .text-xs .text-neutral-400; } &.error { @@ -86,6 +78,14 @@ input[type=number] { } } +.input-help { + @apply .text-xs .text-neutral-400 .pt-2; + + &.error { + @apply .text-red-400; + } +} + label { @apply .block .text-xs .uppercase .text-neutral-700 .mb-2; } diff --git a/tailwind.js b/tailwind.js index eca66c6eb..b98b2c785 100644 --- a/tailwind.js +++ b/tailwind.js @@ -459,6 +459,7 @@ module.exports = { '2': '0.5rem', '3': '0.75rem', '4': '1rem', + '5': '1.25rem', '6': '1.5rem', '8': '2rem', '10': '2.5rem', @@ -506,6 +507,7 @@ module.exports = { '2': '0.5rem', '3': '0.75rem', '4': '1rem', + '5': '1.25rem', '6': '1.5rem', '8': '2rem', '10': '2.5rem', @@ -539,6 +541,7 @@ module.exports = { '2': '0.5rem', '3': '0.75rem', '4': '1rem', + '5': '1.25rem', '6': '1.5rem', '8': '2rem', '10': '2.5rem',