diff --git a/assets/global.scss b/assets/global.scss index 5bbb37b..5f2fadd 100644 --- a/assets/global.scss +++ b/assets/global.scss @@ -22,3 +22,7 @@ color: #1890ff; } } + +.ant-spin-nested-loading > div > .ant-spin { + max-height: unset; +} diff --git a/components/auth/footer.tsx b/components/auth/footer.tsx new file mode 100644 index 0000000..febe80c --- /dev/null +++ b/components/auth/footer.tsx @@ -0,0 +1,54 @@ +import {Button} from 'antd' +import Link from 'next/link' +import React from 'react' + +export const AuthFooter: React.FC = () => { + return ( +
+ + + + + + + + + + + +
+ ) +} diff --git a/components/auth/layout.tsx b/components/auth/layout.tsx new file mode 100644 index 0000000..ae9e3fa --- /dev/null +++ b/components/auth/layout.tsx @@ -0,0 +1,19 @@ +import {Layout, Spin} from 'antd' +import React from 'react' + +interface Props { + loading?: boolean +} + +export const AuthLayout: React.FC = props => { + return ( + + + {props.children} + + + ) +} diff --git a/components/clean.input.ts b/components/clean.input.ts new file mode 100644 index 0000000..40fc4d7 --- /dev/null +++ b/components/clean.input.ts @@ -0,0 +1,27 @@ + +const omitDeepArrayWalk = (arr, key) => { + return arr.map((val) => { + if (Array.isArray(val)) return omitDeepArrayWalk(val, key) + else if (typeof val === 'object') return omitDeep(val, key) + return val + }) +} + +const omitDeep = (obj: any, key: string | number): any => { + const keys: Array = Object.keys(obj); + const newObj: any = {}; + keys.forEach((i: any) => { + if (i !== key) { + const val: any = obj[i]; + if (val instanceof Date) newObj[i] = val; + else if (Array.isArray(val)) newObj[i] = omitDeepArrayWalk(val, key); + else if (typeof val === 'object' && val !== null) newObj[i] = omitDeep(val, key); + else newObj[i] = val; + } + }); + return newObj; +} + +export const cleanInput = (obj: T): T => { + return omitDeep(obj, '__typename') +} diff --git a/components/error.page.tsx b/components/error.page.tsx new file mode 100644 index 0000000..1fb4604 --- /dev/null +++ b/components/error.page.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +export const ErrorPage: React.FC = () => { + return ( +
+

ERROR

+

there was an error with your request

+
+ ) +} diff --git a/components/form/admin/base.data.tab.tsx b/components/form/admin/base.data.tab.tsx new file mode 100644 index 0000000..c4c884e --- /dev/null +++ b/components/form/admin/base.data.tab.tsx @@ -0,0 +1,55 @@ +import {Form, Input, Select, Switch, Tabs} from 'antd' +import {TabPaneProps} from 'antd/lib/tabs' +import React from 'react' +import {languages} from '../../../i18n' + +export const BaseDataTab: React.FC = props => { + return ( + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/form/admin/design.tab.tsx b/components/form/admin/design.tab.tsx new file mode 100644 index 0000000..93ac569 --- /dev/null +++ b/components/form/admin/design.tab.tsx @@ -0,0 +1,29 @@ +import {Form, Input, Tabs} from 'antd' +import {TabPaneProps} from 'antd/lib/tabs' +import React from 'react' +import {InputColor} from '../../input/color' + +export const DesignTab: React.FC = props => { + return ( + + + + + + {[ + {name: 'backgroundColor', label: 'Background Color'}, + {name: 'questionColor', label: 'Question Color'}, + {name: 'answerColor', label: 'Answer Color'}, + {name: 'buttonColor', label: 'Button Color'}, + {name: 'buttonTextColor', label: 'Button Text Color'}, + ].map(({label, name}) => ( + + + + ))} + + ) +} diff --git a/components/form/admin/end.page.tab.tsx b/components/form/admin/end.page.tab.tsx new file mode 100644 index 0000000..3e61806 --- /dev/null +++ b/components/form/admin/end.page.tab.tsx @@ -0,0 +1,98 @@ +import {DeleteOutlined, PlusOutlined} from '@ant-design/icons/lib' +import {Button, Card, Form, Input, Switch, Tabs} from 'antd' +import {TabPaneProps} from 'antd/lib/tabs' +import React from 'react' +import {InputColor} from '../../input/color' + +export const EndPageTab: React.FC = props => { + return ( + + + + + + + + + + + + + + + + + + + {(fields, { add, remove }) => { + return ( +
+ {fields.map((field, index) => ( + + remove(index)} /> + ]} + > + + + + + + + + + + + + + + + + + + ) + )} + + + +
+ ) + }} +
+
+ ) +} diff --git a/components/form/admin/field.card.tsx b/components/form/admin/field.card.tsx new file mode 100644 index 0000000..fb81039 --- /dev/null +++ b/components/form/admin/field.card.tsx @@ -0,0 +1,130 @@ +import {DeleteOutlined} from '@ant-design/icons/lib' +import {Button, Card, Checkbox, Form, Input, Popconfirm, Tag} from 'antd' +import {FormInstance} from 'antd/lib/form' +import {FieldData} from 'rc-field-form/lib/interface' +import React, {useEffect, useState} from 'react' +import {AdminFormFieldFragment} from '../../../graphql/fragment/admin.form.fragment' +import {DateType} from './types/date.type' +import {DropdownType} from './types/dropdown.type' +import {EmailType} from './types/email.type' +import {HiddenType} from './types/hidden.type' +import {LinkType} from './types/link.type' +import {NumberType} from './types/number.type' +import {RadioType} from './types/radio.type' +import {RatingType} from './types/rating.type' +import {TextType} from './types/text.type' +import {TextareaType} from './types/textarea.type' +import {YesNoType} from './types/yes_no.type' + +export const availableTypes = { + 'textfield': TextType, + 'date': DateType, + 'email': EmailType, + 'textarea': TextareaType, + 'link': LinkType, + 'dropdown': DropdownType, + 'rating': RatingType, + 'radio': RadioType, + 'hidden': HiddenType, + 'yes_no': YesNoType, + 'number': NumberType, +} + +interface Props { + form: FormInstance + fields: AdminFormFieldFragment[] + onChangeFields: (fields: AdminFormFieldFragment[]) => any + field: FieldData + remove: (index: number) => void + index: number +} + +export const FieldCard: React.FC = props => { + const { + form, + field, + fields, + onChangeFields, + remove, + index, + } = props + + const type = form.getFieldValue(['form', 'fields', field.name as string, 'type']) + const TypeComponent: React.FC = availableTypes[type] || TextType + + const [nextTitle, setNextTitle] = useState(form.getFieldValue(['form', 'fields', field.name as string, 'title'])) + + useEffect(() => { + const id = setTimeout(() => { + console.log('update fields') + onChangeFields(fields.map((field, i) => { + if (i === index) { + return { + ...field, + title: nextTitle, + } + } else { + return field + } + })) + }, 500) + + return () => clearTimeout(id) + }, [nextTitle]) + + return ( + + {type} + { + remove(index) + onChangeFields(fields.filter((e, i) => i !== index)) + }} + > + + + + )} + actions={[ + remove(index)} /> + ]} + > + + + setNextTitle(e.target.value)}/> + + + + + + + + + + + ) +} diff --git a/components/form/admin/fields.tab.tsx b/components/form/admin/fields.tab.tsx new file mode 100644 index 0000000..c218d35 --- /dev/null +++ b/components/form/admin/fields.tab.tsx @@ -0,0 +1,101 @@ +import {PlusOutlined} from '@ant-design/icons/lib' +import {Button, Form, Select, Space, Tabs} from 'antd' +import {FormInstance} from 'antd/lib/form' +import {TabPaneProps} from 'antd/lib/tabs' +import React, {useCallback, useState} from 'react' +import {AdminFormFieldFragment} from '../../../graphql/fragment/admin.form.fragment' +import {availableTypes, FieldCard} from './field.card' + +interface Props extends TabPaneProps { + form: FormInstance + fields: AdminFormFieldFragment[] + onChangeFields: (fields: AdminFormFieldFragment[]) => any +} + +export const FieldsTab: React.FC = props => { + const [nextType, setNextType] = useState('textfield') + + const renderType = useCallback((field, index, remove) => { + return ( + + ) + }, [props.fields]) + + const addField = useCallback((add, index) => { + return ( + + + + + + + ) + }, [props.fields, nextType]) + + + return ( + + + + {(fields, { add, remove, move }) => { + const addAndMove = (index) => (defaults) => { + add(defaults) + move(fields.length, index) + } + + return ( +
+ {addField(addAndMove(0), 0)} + {fields.map((field, index) => ( +
+ + {renderType(field, index, remove)} + + {addField(addAndMove(index + 1), index + 1)} +
+ ))} +
+ ) + }} +
+ +
+ ) +} diff --git a/components/form/admin/respondent.notifications.tab.tsx b/components/form/admin/respondent.notifications.tab.tsx new file mode 100644 index 0000000..fd15dab --- /dev/null +++ b/components/form/admin/respondent.notifications.tab.tsx @@ -0,0 +1,107 @@ +import {Form, Input, Select, Switch, Tabs} from 'antd' +import {FormInstance} from 'antd/lib/form' +import {TabPaneProps} from 'antd/lib/tabs' +import React, {useEffect, useState} from 'react' +import {AdminFormFieldFragment} from '../../../graphql/fragment/admin.form.fragment' + +interface Props extends TabPaneProps { + form: FormInstance + fields: AdminFormFieldFragment[] +} + +export const RespondentNotificationsTab: React.FC = props => { + const [enabled, setEnabled] = useState() + + useEffect(() => { + const next = props.form.getFieldValue(['form', 'respondentNotifications', 'enabled']) + + if (next !== enabled) { + setEnabled(next) + } + }, [props.form.getFieldValue(['form', 'respondentNotifications', 'enabled'])]) + + useEffect(() => { + props.form.validateFields([ + ['form', 'respondentNotifications', 'subject'], + ['form', 'respondentNotifications', 'htmlTemplate'], + ['form', 'respondentNotifications', 'toField'], + ]) + }, [enabled]) + + const groups = {} + + props.fields.forEach(field => { + if (!groups[field.type]) { + groups[field.type] = [] + } + groups[field.type].push(field) + }) + + return ( + + + setEnabled(e.valueOf())} /> + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/form/admin/self.notifications.tab.tsx b/components/form/admin/self.notifications.tab.tsx new file mode 100644 index 0000000..f625d12 --- /dev/null +++ b/components/form/admin/self.notifications.tab.tsx @@ -0,0 +1,99 @@ +import {Form, Input, Select, Switch, Tabs} from 'antd' +import {FormInstance} from 'antd/lib/form' +import {TabPaneProps} from 'antd/lib/tabs' +import React, {useEffect, useState} from 'react' +import {AdminFormFieldFragment} from '../../../graphql/fragment/admin.form.fragment' + +interface Props extends TabPaneProps { + form: FormInstance + fields: AdminFormFieldFragment[] +} + +export const SelfNotificationsTab: React.FC = props => { + const [enabled, setEnabled] = useState() + + useEffect(() => { + const next = props.form.getFieldValue(['form', 'selfNotifications', 'enabled']) + + if (next !== enabled) { + setEnabled(next) + } + }, [props.form.getFieldValue(['form', 'selfNotifications', 'enabled'])]) + + useEffect(() => { + props.form.validateFields([ + ['form', 'selfNotifications', 'subject'], + ['form', 'selfNotifications', 'htmlTemplate'], + ]) + }, [enabled]) + + const groups = {} + props.fields.forEach(field => { + if (!groups[field.type]) { + groups[field.type] = [] + } + groups[field.type].push(field) + }) + + return ( + + + setEnabled(e.valueOf())} /> + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/form/admin/start.page.tab.tsx b/components/form/admin/start.page.tab.tsx new file mode 100644 index 0000000..981e076 --- /dev/null +++ b/components/form/admin/start.page.tab.tsx @@ -0,0 +1,98 @@ +import {DeleteOutlined, PlusOutlined} from '@ant-design/icons/lib' +import {Button, Card, Form, Input, Switch, Tabs} from 'antd' +import {TabPaneProps} from 'antd/lib/tabs' +import React from 'react' +import {InputColor} from '../../input/color' + +export const StartPageTab: React.FC = props => { + return ( + + + + + + + + + + + + + + + + + + + {(fields, { add, remove }) => { + return ( +
+ {fields.map((field, index) => ( + + remove(index)} /> + ]} + > + + + + + + + + + + + + + + + + + + ) + )} + + + +
+ ) + }} +
+
+ ) +} diff --git a/components/form/admin/types/date.type.tsx b/components/form/admin/types/date.type.tsx new file mode 100644 index 0000000..99630b4 --- /dev/null +++ b/components/form/admin/types/date.type.tsx @@ -0,0 +1,34 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const DateType: React.FC = props => { + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/components/form/admin/types/dropdown.type.tsx b/components/form/admin/types/dropdown.type.tsx new file mode 100644 index 0000000..660a6ae --- /dev/null +++ b/components/form/admin/types/dropdown.type.tsx @@ -0,0 +1,21 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const DropdownType: React.FC = props => { + // TODO add dropdown options + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/email.type.tsx b/components/form/admin/types/email.type.tsx new file mode 100644 index 0000000..76d623d --- /dev/null +++ b/components/form/admin/types/email.type.tsx @@ -0,0 +1,23 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const EmailType: React.FC = props => { + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/hidden.type.tsx b/components/form/admin/types/hidden.type.tsx new file mode 100644 index 0000000..f7d16ab --- /dev/null +++ b/components/form/admin/types/hidden.type.tsx @@ -0,0 +1,20 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const HiddenType: React.FC = props => { + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/link.type.tsx b/components/form/admin/types/link.type.tsx new file mode 100644 index 0000000..4f2570f --- /dev/null +++ b/components/form/admin/types/link.type.tsx @@ -0,0 +1,23 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const LinkType: React.FC = props => { + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/number.type.tsx b/components/form/admin/types/number.type.tsx new file mode 100644 index 0000000..2019b98 --- /dev/null +++ b/components/form/admin/types/number.type.tsx @@ -0,0 +1,20 @@ +import {Form, InputNumber} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const NumberType: React.FC = props => { + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/radio.type.tsx b/components/form/admin/types/radio.type.tsx new file mode 100644 index 0000000..6e0da7e --- /dev/null +++ b/components/form/admin/types/radio.type.tsx @@ -0,0 +1,22 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const RadioType: React.FC = props => { + // TODO Add radio support + + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/rating.type.tsx b/components/form/admin/types/rating.type.tsx new file mode 100644 index 0000000..5131d8f --- /dev/null +++ b/components/form/admin/types/rating.type.tsx @@ -0,0 +1,22 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const RatingType: React.FC = props => { + // TODO add ratings + + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/text.type.tsx b/components/form/admin/types/text.type.tsx new file mode 100644 index 0000000..9a41b65 --- /dev/null +++ b/components/form/admin/types/text.type.tsx @@ -0,0 +1,20 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const TextType: React.FC = props => { + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/textarea.type.tsx b/components/form/admin/types/textarea.type.tsx new file mode 100644 index 0000000..e4ef04a --- /dev/null +++ b/components/form/admin/types/textarea.type.tsx @@ -0,0 +1,20 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const TextareaType: React.FC = props => { + return ( +
+ + + +
+ ) +} diff --git a/components/form/admin/types/yes_no.type.tsx b/components/form/admin/types/yes_no.type.tsx new file mode 100644 index 0000000..4dc6436 --- /dev/null +++ b/components/form/admin/types/yes_no.type.tsx @@ -0,0 +1,21 @@ +import {Form, Input} from 'antd' +import React from 'react' + +interface Props { + field: any +} + +export const YesNoType: React.FC = props => { + // TODO add switch + return ( +
+ + + +
+ ) +} diff --git a/components/form/is.live.tsx b/components/form/is.live.tsx new file mode 100644 index 0000000..51aebd7 --- /dev/null +++ b/components/form/is.live.tsx @@ -0,0 +1,22 @@ +import {CheckCircleOutlined, CloseCircleOutlined} from '@ant-design/icons/lib' +import React from 'react' + +interface Props { + isLive: boolean +} + +export const FormIsLive: React.FC = props => { + if (props.isLive) { + return ( + + ) + } + + return ( + + ) +} diff --git a/components/input/color.tsx b/components/input/color.tsx new file mode 100644 index 0000000..a909be4 --- /dev/null +++ b/components/input/color.tsx @@ -0,0 +1,38 @@ +import React, {useEffect} from 'react' +import {BlockPicker} from 'react-color' + +interface Props { + value?: string + onChange?: any +} + +export const InputColor: React.FC = props => { + useEffect(() => { + if (!props.value) { + props.onChange('#FFF') + } + }, [props.value]) + + return ( + props.onChange(e.hex)} + styles={{ + default: { + card: { + flexDirection: 'row', + display: 'flex', + boxShadow: 'none' + }, + head: { + flex: 1, + borderRadius: 6, + height: 'auto', + }, + }, + }} + /> + ) +} diff --git a/components/loading.page.tsx b/components/loading.page.tsx new file mode 100644 index 0000000..798121d --- /dev/null +++ b/components/loading.page.tsx @@ -0,0 +1,21 @@ +import {Spin} from 'antd' +import React from 'react' + +interface Props { + message?: string +} + +export const LoadingPage: React.FC = props => { + return ( +
+ + {props.message} +
+ ) +} diff --git a/components/sidemenu.tsx b/components/sidemenu.tsx index 39588e0..d362f97 100644 --- a/components/sidemenu.tsx +++ b/components/sidemenu.tsx @@ -15,26 +15,33 @@ export const sideMenu: SideMenuElement[] = [ { key: 'home', name: 'Home', - href: '/', + href: '/admin', icon: , }, { - key: 'communication', - name: 'Communication', + key: 'public', + name: 'Forms', + group: true, + items: [ + { + key: 'forms', + name: 'Forms', + href: '/admin/forms', + icon: , + }, + ] + }, + { + key: 'administration', + name: 'Administration', group: true, items: [ { key: 'users', name: 'Users', - href: '/users', + href: '/admin/users', icon: , }, - { - key: 'chats', - name: 'Chats', - href: '/chats', - icon: , - }, ], }, ] diff --git a/components/structure.tsx b/components/structure.tsx index 847144f..f8c13c7 100644 --- a/components/structure.tsx +++ b/components/structure.tsx @@ -139,9 +139,7 @@ const Structure: FunctionComponent = (props) => { onClick: () => setSidebar(!sidebar), })} - {'RecTag'} - - {publicRuntimeConfig.area.toUpperCase()} + {'OhMyForm'}
{ + localStorage.setItem('access', access) + localStorage.setItem('refresh', refresh) +} export const authConfig = async (config: AxiosRequestConfig = {}): Promise => { if (!config.headers) { @@ -11,7 +16,12 @@ export const authConfig = async (config: AxiosRequestConfig = {}): Promise { +export const withAuth = (Component, roles: string[] = []): React.FC => { return props => { - const [signedIn, setSignedIn] = React.useState(false); - const [loading, setLoading] = React.useState(true); + const router = useRouter() + const [access, setAccess] = useState(false) + const {loading, data, error} = useQuery(ME_QUERY) + useEffect(() => { + if (!error) { + return + } - React.useEffect(() => { - (async () => { - try { - } catch (err) { - console.error(err); - } + localStorage.clear() + const path = router.asPath || router.pathname + localStorage.setItem('redirect', path) - setLoading(false) - })(); - }, []); + router.push('/login') + }, [error]) + + useEffect(() => { + if (!data || roles.length === 0) { + return + } + + const next = roles + .map(role => data.me.roles.includes(role)) + .filter(p => p) + .length > 0 + + setAccess(next) + + if (!next) { + router.push('/') + } + }, [data]) if (loading) { - return ( -
- -
- ) + return } - if (!signedIn) { - // TODO + if (!access) { + return } return diff --git a/graphql/fragment/admin.form.fragment.ts b/graphql/fragment/admin.form.fragment.ts new file mode 100644 index 0000000..70760ee --- /dev/null +++ b/graphql/fragment/admin.form.fragment.ts @@ -0,0 +1,143 @@ +import {gql} from 'apollo-boost' + +export interface AdminFormPageFragment { + show: boolean + title?: string + paragraph?: string + buttonText?: string + buttons: { + url?: string + action?: string + text?: string + bgColor?: string + color?: string + }[] +} + +export interface AdminFormFieldFragment { + id: string + title: string + type: string + description: string + required: boolean + value: string +} + +export interface AdminFormFragment { + id?: string + title: string + created: string + lastModified?: string + language: string + showFooter: boolean + isLive: boolean + fields: AdminFormFieldFragment[] + selfNotifications: { + enabled: boolean + subject?: string + htmlTemplate?: string + fromField?: string + toEmail?: string + } + respondentNotifications: { + enabled: boolean + subject?: string + htmlTemplate?: string + toField?: string + fromEmail?: string + } + design: { + colors: { + backgroundColor: string + questionColor: string + answerColor: string + buttonColor: string + buttonTextColor: string + } + font?: string + } + startPage: AdminFormPageFragment + endPage: AdminFormPageFragment + admin: { + id: string + username: string + email: string + } +} + +export const ADMIN_FORM_FRAGMENT = gql` + fragment AdminForm on Form { + id + title + created + lastModified + language + showFooter + isLive + + fields { + id + title + type + description + required + value + } + + selfNotifications { + enabled + subject + htmlTemplate + fromField + toEmail + } + respondentNotifications { + enabled + subject + htmlTemplate + toField + fromEmail + } + design { + colors { + backgroundColor + questionColor + answerColor + buttonColor + buttonTextColor + } + font + } + startPage { + show + title + paragraph + buttonText + buttons { + url + action + text + bgColor + color + } + } + endPage { + show + title + paragraph + buttonText + buttons { + url + action + text + bgColor + color + } + } + admin { + id + username + email + } + } +` diff --git a/graphql/mutation/admin.form.create.mutation.ts b/graphql/mutation/admin.form.create.mutation.ts new file mode 100644 index 0000000..15d43f6 --- /dev/null +++ b/graphql/mutation/admin.form.create.mutation.ts @@ -0,0 +1,20 @@ +import {gql} from 'apollo-boost' +import {ADMIN_FORM_FRAGMENT, AdminFormFragment} from '../fragment/admin.form.fragment' + +export interface AdminFormCreateMutationData { + form: AdminFormFragment +} + +export interface AdminFormCreateMutationVariables { + form: AdminFormFragment +} + +export const ADMIN_FORM_CREATE_MUTATION = gql` + mutation update($$form: FormCreateInput!) { + form: createForm(form: $form) { + ...AdminForm + } + } + + ${ADMIN_FORM_FRAGMENT} +` diff --git a/graphql/mutation/admin.form.update.mutation.ts b/graphql/mutation/admin.form.update.mutation.ts new file mode 100644 index 0000000..a133140 --- /dev/null +++ b/graphql/mutation/admin.form.update.mutation.ts @@ -0,0 +1,20 @@ +import {gql} from 'apollo-boost' +import {ADMIN_FORM_FRAGMENT, AdminFormFragment} from '../fragment/admin.form.fragment' + +export interface AdminFormUpdateMutationData { + form: AdminFormFragment +} + +export interface AdminFormUpdateMutationVariables { + form: AdminFormFragment +} + +export const ADMIN_FORM_UPDATE_MUTATION = gql` + mutation update($form: FormUpdateInput!) { + form: updateForm(form: $form) { + ...AdminForm + } + } + + ${ADMIN_FORM_FRAGMENT} +` diff --git a/graphql/mutation/login.mutation.ts b/graphql/mutation/login.mutation.ts new file mode 100644 index 0000000..182699f --- /dev/null +++ b/graphql/mutation/login.mutation.ts @@ -0,0 +1,10 @@ +import {gql} from 'apollo-boost' + +export const LOGIN_MUTATION = gql` + mutation login($username: String!, $password: String!) { + tokens: authLogin(username: $username, password: $password) { + access: accessToken + refresh: refreshToken + } + } +` diff --git a/graphql/mutation/register.mutation.ts b/graphql/mutation/register.mutation.ts new file mode 100644 index 0000000..a4b447e --- /dev/null +++ b/graphql/mutation/register.mutation.ts @@ -0,0 +1,10 @@ +import {gql} from 'apollo-boost' + +export const REGISTER_MUTATION = gql` + mutation register($user: UserCreateInput!) { + tokens: authRegister(user: $user) { + access: accessToken + refresh: refreshToken + } + } +` diff --git a/graphql/query/admin.form.query.ts b/graphql/query/admin.form.query.ts new file mode 100644 index 0000000..ca713bc --- /dev/null +++ b/graphql/query/admin.form.query.ts @@ -0,0 +1,20 @@ +import {gql} from 'apollo-boost' +import {ADMIN_FORM_FRAGMENT, AdminFormFragment} from '../fragment/admin.form.fragment' + +export interface AdminFormQueryData { + form: AdminFormFragment +} + +export interface AdminFormQueryVariables { + id: string +} + +export const ADMIN_FORM_QUERY = gql` + query form($id: ID!){ + form:getFormById(id: $id) { + ...AdminForm + } + } + + ${ADMIN_FORM_FRAGMENT} +` diff --git a/graphql/query/me.query.ts b/graphql/query/me.query.ts new file mode 100644 index 0000000..cecc368 --- /dev/null +++ b/graphql/query/me.query.ts @@ -0,0 +1,18 @@ +import {gql} from 'apollo-boost' + +export interface MeQueryData { + me: { + id: string + + roles: string[] + } +} + +export const ME_QUERY = gql` + query { + me { + id + roles + } + } +` diff --git a/graphql/query/pager.form.query.ts b/graphql/query/pager.form.query.ts new file mode 100644 index 0000000..5de469d --- /dev/null +++ b/graphql/query/pager.form.query.ts @@ -0,0 +1,53 @@ +import {gql} from 'apollo-boost' + +export interface PagerFormEntryQueryData { + id: string + created: string + lastModified?: string + title: string + isLive: boolean + language: string + admin: { + id: string + email: string + username: string + } +} + +export interface PagerFormQueryData { + pager: { + entries: PagerFormEntryQueryData[] + + total: number + limit: number + start: number + } +} + +export interface PagerFormQueryVariables { + start?: number + limit?: number +} + +export const PAGER_FORM_QUERY = gql` + query pager($start: Int, $limit: Int){ + pager: listForms(start: $start, limit: $limit) { + entries { + id + created + lastModified + title + isLive + language + admin { + id + email + username + } + } + total + limit + start + } + } +` diff --git a/i18n.ts b/i18n.ts new file mode 100644 index 0000000..addd5bc --- /dev/null +++ b/i18n.ts @@ -0,0 +1,5 @@ + +export const languages = [ + 'de', + 'en', +] diff --git a/next.config.js b/next.config.js index 6240b81..50816b5 100644 --- a/next.config.js +++ b/next.config.js @@ -1,9 +1,10 @@ const withImages = require('next-images') -const module = require('./package.json') +const p = require('./package.json') -const version = module.version; +const version = p.version; module.exports = withImages({ + publicRuntimeConfig: { endpoint: process.env.API_HOST || '/graphql', version, diff --git a/package.json b/package.json index 7509a69..3e9621a 100644 --- a/package.json +++ b/package.json @@ -3,20 +3,28 @@ "version": "0.1.0", "license": "MIT", "scripts": { - "dev": "next dev", + "start:dev": "next dev -p 4000", "build": "next build", "export": "next build && next export", - "start": "next start" + "start": "next start -p 4010", + "server": "node server.js" }, "dependencies": { "@ant-design/icons": "^4.1.0", + "@apollo/react-hooks": "^3.1.5", + "@lifeomic/axios-fetch": "^1.4.2", "antd": "^4.2.2", + "apollo-boost": "^0.4.9", "axios": "^0.19.2", "dayjs": "^1.8.27", + "express": "^4.17.1", + "graphql": "^15.0.0", + "http-proxy-middleware": "^1.0.4", "next": "9.4.0", "next-images": "^1.4.0", "next-redux-wrapper": "^6.0.0", "react": "16.13.1", + "react-color": "^2.18.1", "react-dom": "16.13.1", "react-icons": "^3.10.0", "react-redux": "^7.2.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index 40bf010..30d9d45 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,23 +1,36 @@ +import {ApolloProvider} from '@apollo/react-common' +import {buildAxiosFetch} from '@lifeomic/axios-fetch' import 'antd/dist/antd.css' +import ApolloClient from 'apollo-boost' import 'assets/global.scss' import 'assets/variables.scss' +import axios from 'axios' import {AppProps} from 'next/app' import getConfig from 'next/config' import Head from 'next/head' import React from 'react' import {wrapper} from 'store' +import {authConfig} from '../components/with.auth' const { publicRuntimeConfig } = getConfig() +const client = new ApolloClient({ + uri: publicRuntimeConfig.endpoint, + fetch: buildAxiosFetch(axios), + request: async (operation): Promise => { + operation.setContext(await authConfig()) + } +}) + const App: React.FC = ({ Component, pageProps }) => { return ( - <> + OhMyForm - + ) } diff --git a/pages/admin/forms/[id]/index.tsx b/pages/admin/forms/[id]/index.tsx new file mode 100644 index 0000000..ae9b7af --- /dev/null +++ b/pages/admin/forms/[id]/index.tsx @@ -0,0 +1,134 @@ +import {useMutation, useQuery} from '@apollo/react-hooks' +import {Button, Form, Input, message, Tabs} from 'antd' +import {useForm} from 'antd/lib/form/Form' +import {NextPage} from 'next' +import {useRouter} from 'next/router' +import React, {useState} from 'react' +import {cleanInput} from '../../../../components/clean.input' +import {BaseDataTab} from '../../../../components/form/admin/base.data.tab' +import {DesignTab} from '../../../../components/form/admin/design.tab' +import {EndPageTab} from '../../../../components/form/admin/end.page.tab' +import {FieldsTab} from '../../../../components/form/admin/fields.tab' +import {RespondentNotificationsTab} from '../../../../components/form/admin/respondent.notifications.tab' +import {SelfNotificationsTab} from '../../../../components/form/admin/self.notifications.tab' +import {StartPageTab} from '../../../../components/form/admin/start.page.tab' +import Structure from '../../../../components/structure' +import {withAuth} from '../../../../components/with.auth' +import {AdminFormFieldFragment} from '../../../../graphql/fragment/admin.form.fragment' +import { + ADMIN_FORM_UPDATE_MUTATION, + AdminFormUpdateMutationData, + AdminFormUpdateMutationVariables +} from '../../../../graphql/mutation/admin.form.update.mutation' +import {ADMIN_FORM_QUERY, AdminFormQueryData, AdminFormQueryVariables} from '../../../../graphql/query/admin.form.query' + +const Index: NextPage = () => { + const router = useRouter() + const [form] = useForm() + const [saving, setSaving] = useState(false) + const [fields, setFields] = useState([]) + const [update] = useMutation(ADMIN_FORM_UPDATE_MUTATION) + + const {data, loading, error} = useQuery(ADMIN_FORM_QUERY, { + variables: { + id: router.query.id as string + }, + onCompleted: next => { + form.setFieldsValue(next) + setFields(next.form.fields) + } + }) + + const save = async (formData: AdminFormQueryData) => { + setSaving(true) + console.log('try to save form!', formData) + + formData.form.fields = formData.form.fields.filter(e => e && e.type) + + try { + const next = (await update({ + variables: cleanInput(formData), + })).data + + form.setFieldsValue(next) + setFields(next.form.fields) + + message.success('Form Updated') + } catch (e) { + console.error('failed to save', e) + message.error('Could not save Form') + } + + setSaving(false) + } + + + + return ( + + Save + , + ]} + style={{paddingTop: 0}} + > +
{ + message.error('Required fields are missing') + }} + labelCol={{ + xs: { span: 24 }, + sm: { span: 6 }, + }} + wrapperCol={{ + xs: { span: 24 }, + sm: { span: 18 }, + }} + > + + + + + + + + + + + +
+
+ ) +} + +export default withAuth(Index, ['admin']) diff --git a/pages/admin/forms/index.tsx b/pages/admin/forms/index.tsx new file mode 100644 index 0000000..8b4c6a3 --- /dev/null +++ b/pages/admin/forms/index.tsx @@ -0,0 +1,135 @@ +import {DeleteOutlined, EditOutlined, GlobalOutlined} from '@ant-design/icons/lib' +import {useQuery} from '@apollo/react-hooks' +import {Button, Popconfirm, Space, Table, Tooltip} from 'antd' +import {PaginationProps} from 'antd/es/pagination' +import {NextPage} from 'next' +import Link from 'next/link' +import React, {useState} from 'react' +import {DateTime} from '../../../components/date.time' +import {FormIsLive} from '../../../components/form/is.live' +import Structure from '../../../components/structure' +import {TimeAgo} from '../../../components/time.ago' +import {withAuth} from '../../../components/with.auth' +import { + PAGER_FORM_QUERY, + PagerFormEntryQueryData, + PagerFormQueryData, + PagerFormQueryVariables +} from '../../../graphql/query/pager.form.query' + +const Index: NextPage = () => { + const [pagination, setPagination] = useState({ + pageSize: 25, + }) + const [entries, setEntries] = useState() + // TODO limit forms if user is only admin! + const {loading, refetch} = useQuery(PAGER_FORM_QUERY, { + variables: { + limit: pagination.pageSize, + start: pagination.current * pagination.pageSize || 0 + }, + onCompleted: ({pager}) => { + setPagination({ + ...pagination, + total: pager.total, + }) + setEntries(pager.entries) + } + }) + + const deleteForm = async (form) => { + // TODO + } + + const columns = [ + { + title: 'Live', + dataIndex: 'isLive', + render: live => + }, + { + title: 'Title', + dataIndex: 'title', + }, + { + title: 'Owner', + dataIndex: 'admin', + render: user => ( + + + + + + ) + }, + { + title: 'Language', + dataIndex: 'language', + }, + { + title: 'Created', + dataIndex: 'created', + render: date => + }, + { + title: 'Last Change', + dataIndex: 'lastModified', + render: date => + }, + { + render: row => { + return ( + + + + + + + + + + + + + + ) + } + }, + ] + + return ( + + { + setPagination(pagination) + }} + /> + + ) +} + +export default withAuth(Index, ['admin']) diff --git a/pages/admin/index.tsx b/pages/admin/index.tsx index e69de29..d926f8e 100644 --- a/pages/admin/index.tsx +++ b/pages/admin/index.tsx @@ -0,0 +1,16 @@ +import {NextPage} from 'next' +import React from 'react' +import Structure from '../../components/structure' +import {withAuth} from '../../components/with.auth' + +const Index: NextPage = () => { + return ( + + ok! + + ) +} + +export default withAuth(Index, ['admin']) diff --git a/pages/admin/users/[id]/index.tsx b/pages/admin/users/[id]/index.tsx new file mode 100644 index 0000000..87a2da0 --- /dev/null +++ b/pages/admin/users/[id]/index.tsx @@ -0,0 +1,20 @@ +import {NextPage} from 'next' +import React from 'react' +import Structure from '../../../../components/structure' +import {withAuth} from '../../../../components/with.auth' + +const Index: NextPage = () => { + return ( + + ok! + + ) +} + +export default withAuth(Index, ['admin']) diff --git a/pages/admin/users/index.tsx b/pages/admin/users/index.tsx new file mode 100644 index 0000000..68e7d56 --- /dev/null +++ b/pages/admin/users/index.tsx @@ -0,0 +1,19 @@ +import {NextPage} from 'next' +import React from 'react' +import Structure from '../../../components/structure' +import {withAuth} from '../../../components/with.auth' + +const Index: NextPage = () => { + return ( + + ok! + + ) +} + +export default withAuth(Index, ['admin']) diff --git a/pages/form/[id]/index.tsx b/pages/form/[id]/index.tsx new file mode 100644 index 0000000..ffcccd9 --- /dev/null +++ b/pages/form/[id]/index.tsx @@ -0,0 +1,11 @@ +import {NextPage} from 'next' +import React from 'react' +import {ErrorPage} from '../../../components/error.page' + +const Index: NextPage = () => { + return ( + + ) +} + +export default Index diff --git a/pages/form/index.tsx b/pages/form/index.tsx new file mode 100644 index 0000000..ec2d948 --- /dev/null +++ b/pages/form/index.tsx @@ -0,0 +1,10 @@ +import {NextPage} from 'next' +import React from 'react' +import {ErrorPage} from '../../components/error.page' + +const Index: NextPage = () => { + return ( + + ) +} +export default Index diff --git a/pages/index.tsx b/pages/index.tsx index cf4dde6..b24eb9a 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,18 +1,27 @@ -import {Alert} from 'antd' -import {withAuth} from 'components/with.auth' +import {Layout} from 'antd' import {NextPage} from 'next' import React from 'react' -import Structure from '../components/structure' +import {AuthFooter} from '../components/auth/footer' const Index: NextPage = () => { return ( - - - + + + + + ) } -export default withAuth(Index) +export default Index diff --git a/pages/login/confirm/[code].tsx b/pages/login/confirm/[code].tsx new file mode 100644 index 0000000..8000ecf --- /dev/null +++ b/pages/login/confirm/[code].tsx @@ -0,0 +1,26 @@ +import {Alert} from 'antd' +import {NextPage} from 'next' +import React from 'react' +import {AuthFooter} from '../../../components/auth/footer' +import {AuthLayout} from '../../../components/auth/layout' + +const Index: NextPage = () => { + return ( + + + + + + ) +} + +export default Index diff --git a/pages/login/index.tsx b/pages/login/index.tsx new file mode 100644 index 0000000..140005c --- /dev/null +++ b/pages/login/index.tsx @@ -0,0 +1,132 @@ +import {useMutation} from '@apollo/react-hooks' +import {Button, Form, Input, message} from 'antd' +import {useForm} from 'antd/lib/form/Form' +import {NextPage} from 'next' +import Link from 'next/link' +import {useRouter} from 'next/router' +import React, {useState} from 'react' +import {AuthFooter} from '../../components/auth/footer' +import {AuthLayout} from '../../components/auth/layout' +import {setAuth} from '../../components/with.auth' +import {LOGIN_MUTATION} from '../../graphql/mutation/login.mutation' + +const Index: NextPage = () => { + const [form] = useForm() + const router = useRouter() + const [loading, setLoading] = useState(false) + const [login] = useMutation(LOGIN_MUTATION) + + const finish = async (data) => { + setLoading(true) + try { + const result = await login({ + variables: data, + }) + + await setAuth( + result.data.tokens.access, + result.data.tokens.refresh + ) + + message.success('Welcome back!') + + router.push('/admin') + } catch (e) { + message.error('username / password are invalid') + } + + setLoading(false) + } + + const failed = () => { + message.error('mandatory fields missing') + } + + return ( + +
+ {'OhMyForm'} + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} + +export default Index diff --git a/pages/login/recover.tsx b/pages/login/recover.tsx new file mode 100644 index 0000000..b413a95 --- /dev/null +++ b/pages/login/recover.tsx @@ -0,0 +1,26 @@ +import {Alert} from 'antd' +import {NextPage} from 'next' +import React from 'react' +import {AuthFooter} from '../../components/auth/footer' +import {AuthLayout} from '../../components/auth/layout' + +const Recover: NextPage = () => { + return ( + + + + + + ) +} + +export default Recover diff --git a/pages/register.tsx b/pages/register.tsx new file mode 100644 index 0000000..4cd12ae --- /dev/null +++ b/pages/register.tsx @@ -0,0 +1,143 @@ +import {useMutation} from '@apollo/react-hooks' +import {Button, Form, Input, message} from 'antd' +import {useForm} from 'antd/lib/form/Form' +import {NextPage} from 'next' +import Link from 'next/link' +import {useRouter} from 'next/router' +import React, {useState} from 'react' +import {AuthFooter} from '../components/auth/footer' +import {AuthLayout} from '../components/auth/layout' +import {setAuth} from '../components/with.auth' +import {REGISTER_MUTATION} from '../graphql/mutation/register.mutation' + +const Register: NextPage = () => { + const [form] = useForm() + const router = useRouter() + const [loading, setLoading] = useState(false) + + const [register] = useMutation(REGISTER_MUTATION) + + const finish = async (data) => { + setLoading(true) + + try { + const result = await register({ + variables: { + user: data + }, + }) + + await setAuth( + result.data.tokens.access, + result.data.tokens.refresh + ) + + message.success('Welcome, please also confirm your email') + + router.push('/') + } catch (e) { + message.error('Some data already in use!') + setLoading(false) + } + } + + const failed = () => { + message.error('mandatory fields missing') + } + + return ( + +
+ {'OhMyForm'} + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} + +export default Register diff --git a/store/auth/index.ts b/store/auth/index.ts new file mode 100644 index 0000000..5b8aa2d --- /dev/null +++ b/store/auth/index.ts @@ -0,0 +1,22 @@ +import redux, {Reducer} from 'redux' + +export interface AuthState { + authenticated?: boolean + +} + +type ActionTypes = 'AUTH_INIT' | 'AUTH_LOGOUT' | 'AUTH_UPDATE_SETTINGS'; +type Action = redux.Action & redux.AnyAction + +export const actionTypes: {[key: string]: ActionTypes} = { + INIT: 'AUTH_INIT', + LOGOUT: 'AUTH_LOGOUT', + UPDATE_SETTINGS: 'AUTH_UPDATE_SETTINGS', +}; + +const initialState: AuthState = { +} + +export const auth: Reducer = (state = initialState, action: Action): AuthState => { + return state +} diff --git a/store/index.ts b/store/index.ts index 5dea0d4..adc72ea 100644 --- a/store/index.ts +++ b/store/index.ts @@ -2,31 +2,30 @@ import {createWrapper, HYDRATE, MakeStore} from 'next-redux-wrapper' import {AnyAction, applyMiddleware, combineReducers, createStore} from 'redux' import {composeWithDevTools} from 'redux-devtools-extension' import thunkMiddleware from 'redux-thunk' +import {auth, AuthState} from './auth' + export interface State { + auth: AuthState } -const state = {} - const root = (state: State, action: AnyAction) => { switch (action.type) { case HYDRATE: return {...state, ...action.payload}; - default: - return state; } + + const combined = combineReducers({ + auth + }) + + return combined(state, action); }; const makeStore: MakeStore = (context) => { return createStore( - (state, action): State => { - const simple = combineReducers({ - // TODO add child reducers - }) - - return root(simple, action) - }, - {}, + root, + undefined, composeWithDevTools(applyMiddleware( thunkMiddleware, ))