add form layouts

This commit is contained in:
Michael Schramm 2021-05-15 17:30:49 +02:00
parent de1180d547
commit d5dff46816
19 changed files with 498 additions and 172 deletions

View File

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- add environment list in [doc](doc/environment.md)
- show error message on homepage in case there is a problem with api connection
- new slider field type
- new card layout for forms
### Changed
@ -26,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- links at the bottom for new users
- fixes for hide contrib setting
- fix problem with node-prune on production build
- translation for prev / continue during form submission
### Security

View File

@ -1,4 +1,4 @@
import { Form, Input, Tabs } from 'antd'
import { Form, Input, Select, Tabs } from 'antd'
import { TabPaneProps } from 'antd/lib/tabs'
import React from 'react'
import { useTranslation } from 'react-i18next'
@ -12,6 +12,20 @@ export const DesignTab: React.FC<TabPaneProps> = (props) => {
<Form.Item label={t('form:design.font')} name={['form', 'design', 'font']}>
<Input />
</Form.Item>
<Form.Item label={t('form:design.layouts')} name={['form', 'design', 'layout']}>
<Select
options={[
{
value: null,
label: t('form:design.layout.slider'),
},
{
value: 'card',
label: t('form:design.layout.card'),
},
]}
/>
</Form.Item>
{['background', 'question', 'answer', 'button', 'buttonActive', 'buttonText'].map((name) => (
<Form.Item

View File

@ -0,0 +1,63 @@
import React from 'react'
import {
FormPublicDesignFragment,
FormPublicFieldFragment,
} from '../../../../graphql/fragment/form.public.fragment'
import { StyledH1 } from '../../../styled/h1'
import { StyledMarkdown } from '../../../styled/markdown'
import { useRouter } from '../../../use.router'
import { fieldTypes } from '../../types'
import { TextType } from '../../types/text.type'
import { FieldTypeProps } from '../../types/type.props'
interface Props {
field: FormPublicFieldFragment
design: FormPublicDesignFragment
}
export const Field: React.FC<Props> = ({ field, design, ...props }) => {
const router = useRouter()
const FieldInput: React.FC<FieldTypeProps> = fieldTypes[field.type] || TextType
const getUrlDefault = (): string => {
if (router.query[field.id]) {
return router.query[field.id] as string
}
if (router.query[field.slug]) {
return router.query[field.slug] as string
}
return undefined
}
return (
<div
{...props}
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: 32,
justifyContent: 'flex-end',
}}
>
<StyledH1 design={design} type={'question'}>
{field.title}
</StyledH1>
{field.description && (
<StyledMarkdown design={design} type={'question'} source={field.description} />
)}
<FieldInput design={design} field={field} urlValue={getUrlDefault()} />
</div>
</div>
)
}

View File

@ -0,0 +1,139 @@
import { Card, Form, message, Modal, Spin } from 'antd'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { Omf } from '../../../omf'
import { StyledButton } from '../../../styled/button'
import { darken, lighten } from '../../../styled/color.change'
import { LayoutProps } from '../layout.props'
import { Field } from './field'
import { Page } from './page'
type Step = 'start' | 'form' | 'end'
const MyCard = styled.div<{ background: string }>`
background: ${(props) => darken(props.background, 10)};
height: 100%;
min-height: 100vh;
padding: 32px;
.ant-card {
background: ${(props) => props.background};
border-color: ${(props) => lighten(props.background, 40)};
width: 800px;
margin: auto;
max-width: 90%;
}
`
export const CardLayout: React.FC<LayoutProps> = (props) => {
const { t } = useTranslation()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [step, setStep] = useState<Step>(props.form.startPage.show ? 'start' : 'form')
const { design, startPage, endPage, fields } = props.form
const { setField } = props.submission
const finish = async (data: { [key: number]: unknown }) => {
console.log('data', data)
setLoading(true)
try {
// save fields
await Promise.all(Object.keys(data).map((fieldId) => setField(fieldId, data[fieldId])))
await props.submission.finish()
if (endPage.show) {
setStep('end')
} else {
Modal.success({
content: t('form:submitted'),
okText: t('from:restart'),
onOk: () => {
window.location.reload()
},
})
}
} catch (e) {
console.error(e)
void message.error({
content: 'Error saving Input',
})
}
setLoading(false)
}
const render = () => {
switch (step) {
case 'start':
return <Page page={startPage} design={design} next={() => setStep('form')} />
case 'form':
return (
<Card>
<Form form={form} onFinish={finish}>
{fields.map((field, i) => {
if (field.type === 'hidden') {
return null
}
return <Field key={field.id} field={field} design={design} />
})}
<div
style={{
padding: 32,
display: 'flex',
}}
>
{startPage.show && (
<StyledButton
background={design.colors.button}
color={design.colors.buttonText}
highlight={design.colors.buttonActive}
onClick={() => setStep('start')}
>
{t('form:previous')}
</StyledButton>
)}
<div style={{ flex: 1 }} />
<StyledButton
background={design.colors.button}
color={design.colors.buttonText}
highlight={design.colors.buttonActive}
size={'large'}
onClick={form.submit}
>
{t('form:next')}
</StyledButton>
</div>
</Form>
</Card>
)
case 'end':
return (
<Page
page={endPage}
design={design}
next={() => {
window.location.reload()
}}
/>
)
}
}
return (
<MyCard background={design.colors.background}>
<Omf />
<Spin spinning={loading}>{render()}</Spin>
</MyCard>
)
}

View File

@ -0,0 +1,65 @@
import { Card } from 'antd'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
FormPublicDesignFragment,
FormPublicPageFragment,
} from '../../../../graphql/fragment/form.public.fragment'
import { StyledButton } from '../../../styled/button'
import { StyledH1 } from '../../../styled/h1'
import { StyledMarkdown } from '../../../styled/markdown'
import { PageButtons } from '../page.buttons'
interface Props {
page: FormPublicPageFragment
design: FormPublicDesignFragment
next?: () => void
prev?: () => void
}
export const Page: React.FC<Props> = ({ design, page, next, prev }) => {
const { t } = useTranslation()
return (
<Card>
<StyledH1 design={design} type={'question'}>
{page.title}
</StyledH1>
<StyledMarkdown design={design} type={'question'} source={page.paragraph} />
<div
style={{
padding: 32,
display: 'flex',
}}
>
{prev && (
<StyledButton
background={design.colors.button}
color={design.colors.buttonText}
highlight={design.colors.buttonActive}
onClick={prev}
>
{t('form:restart')}
</StyledButton>
)}
<PageButtons buttons={page.buttons} />
<div style={{ flex: 1 }} />
{next && (
<StyledButton
background={design.colors.button}
color={design.colors.buttonText}
highlight={design.colors.buttonActive}
size={'large'}
onClick={next}
>
{page.buttonText || t('form:continue')}
</StyledButton>
)}
</div>
</Card>
)
}

View File

@ -0,0 +1,7 @@
import { FormPublicFragment, } from '../../../graphql/fragment/form.public.fragment'
import { Submission } from '../../use.submission'
export interface LayoutProps {
form: FormPublicFragment
submission: Submission
}

View File

@ -0,0 +1,34 @@
import { Space } from 'antd'
import React from 'react'
import { FormPublicPageButtonFragment } from '../../../graphql/fragment/form.public.fragment'
import { StyledButton } from '../../styled/button'
interface Props {
buttons: FormPublicPageButtonFragment[]
}
export const PageButtons: React.FC<Props> = ({ buttons }) => {
if (buttons.length === 0) {
return null
}
return (
<Space>
{buttons.map((button, key) => {
return (
<StyledButton
background={button.bgColor}
color={button.color}
highlight={button.activeColor}
key={key}
href={button.url}
target={'_blank'}
rel={'noreferrer'}
>
{button.text}
</StyledButton>
)
})}
</Space>
)
}

View File

@ -1,17 +1,18 @@
import { Form, message } from 'antd'
import { useForm } from 'antd/lib/form/Form'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
FormPublicDesignFragment,
FormPublicFieldFragment,
} from '../../graphql/fragment/form.public.fragment'
import { StyledButton } from '../styled/button'
import { StyledH1 } from '../styled/h1'
import { StyledMarkdown } from '../styled/markdown'
import { useRouter } from '../use.router'
import { fieldTypes } from './types'
import { TextType } from './types/text.type'
import { FieldTypeProps } from './types/type.props'
} from '../../../../graphql/fragment/form.public.fragment'
import { StyledButton } from '../../../styled/button'
import { StyledH1 } from '../../../styled/h1'
import { StyledMarkdown } from '../../../styled/markdown'
import { useRouter } from '../../../use.router'
import { fieldTypes } from '../../types'
import { TextType } from '../../types/text.type'
import { FieldTypeProps } from '../../types/type.props'
interface Props {
field: FormPublicFieldFragment
@ -26,6 +27,7 @@ interface Props {
export const Field: React.FC<Props> = ({ field, save, design, next, prev, ...props }) => {
const [form] = useForm()
const router = useRouter()
const { t } = useTranslation()
const FieldInput: React.FC<FieldTypeProps> = fieldTypes[field.type] || TextType
@ -92,7 +94,7 @@ export const Field: React.FC<Props> = ({ field, save, design, next, prev, ...pro
highlight={design.colors.buttonActive}
onClick={prev}
>
{'Previous'}
{t('form:previous')}
</StyledButton>
<div style={{ flex: 1 }} />
@ -104,7 +106,7 @@ export const Field: React.FC<Props> = ({ field, save, design, next, prev, ...pro
size={'large'}
onClick={form.submit}
>
{'Next'}
{t('form:next')}
</StyledButton>
</div>
</Form>

View File

@ -0,0 +1,97 @@
import { Modal } from 'antd'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Swiper from 'react-id-swiper'
import { ReactIdSwiperProps } from 'react-id-swiper/lib/types'
import * as OriginalSwiper from 'swiper'
import { Omf } from '../../../omf'
import { LayoutProps } from '../layout.props'
import { Field } from './field'
import { FormPage } from './page'
export const SliderLayout: React.FC<LayoutProps> = (props) => {
const { t } = useTranslation()
const [swiper, setSwiper] = useState<OriginalSwiper.default>(null)
const { design, startPage, endPage, fields } = props.form
const { finish, setField } = props.submission
const goNext = () => {
if (!swiper) return
swiper.allowSlideNext = true
swiper.slideNext()
swiper.allowSlideNext = false
}
const goPrev = () => swiper && swiper.slidePrev()
const swiperConfig: ReactIdSwiperProps = {
direction: 'vertical',
allowSlideNext: false,
allowSlidePrev: true,
noSwiping: true,
updateOnWindowResize: true,
}
return (
<div
style={{
background: design.colors.background,
}}
>
<Omf />
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */}
<Swiper {...swiperConfig} ref={(element) => element && setSwiper((element as any).swiper)}>
{[
startPage.show ? (
<FormPage key={'start'} page={startPage} design={design} next={goNext} prev={goPrev} />
) : undefined,
...fields
.map((field, i) => {
if (field.type === 'hidden') {
return null
}
return (
<Field
key={field.id}
field={field}
design={design}
save={async (values: { [key: string]: unknown }) => {
await setField(field.id, values[field.id])
if (fields.length === i + 1) {
finish()
}
}}
next={() => {
if (fields.length === i + 1) {
// prevent going back!
swiper.allowSlidePrev = true
if (!endPage.show) {
Modal.success({
content: t('form:submitted'),
okText: t('from:restart'),
onOk: () => {
window.location.reload()
},
})
}
}
goNext()
}}
prev={goPrev}
/>
)
})
.filter((e) => e !== null),
endPage.show ? (
<FormPage key={'end'} page={endPage} design={design} next={finish} prev={goPrev} />
) : undefined,
].filter((e) => !!e)}
</Swiper>
</div>
)
}

View File

@ -1,16 +1,16 @@
import { Space } from 'antd'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
FormPublicDesignFragment,
FormPublicPageFragment,
} from '../../graphql/fragment/form.public.fragment'
import { StyledButton } from '../styled/button'
import { StyledH1 } from '../styled/h1'
import { StyledMarkdown } from '../styled/markdown'
} from '../../../../graphql/fragment/form.public.fragment'
import { StyledButton } from '../../../styled/button'
import { StyledH1 } from '../../../styled/h1'
import { StyledMarkdown } from '../../../styled/markdown'
import { PageButtons } from '../page.buttons'
import scss from './page.module.scss'
interface Props {
type: 'start' | 'end'
page: FormPublicPageFragment
design: FormPublicDesignFragment
className?: string
@ -20,6 +20,8 @@ interface Props {
}
export const FormPage: React.FC<Props> = ({ page, design, next, prev, className, ...props }) => {
const { t } = useTranslation()
if (!page.show) {
return null
}
@ -38,26 +40,17 @@ export const FormPage: React.FC<Props> = ({ page, design, next, prev, className,
display: 'flex',
}}
>
{prev && <div />}
{page.buttons.length > 0 && (
<Space>
{page.buttons.map((button, key) => {
return (
<StyledButton
background={button.bgColor}
color={button.color}
highlight={button.activeColor}
key={key}
href={button.url}
target={'_blank'}
rel={'noreferrer'}
>
{button.text}
</StyledButton>
)
})}
</Space>
{prev && (
<StyledButton
background={design.colors.button}
color={design.colors.buttonText}
highlight={design.colors.buttonActive}
onClick={prev}
>
{t('form:previous')}
</StyledButton>
)}
<PageButtons buttons={page.buttons} />
<div style={{ flex: 1 }} />
@ -68,7 +61,7 @@ export const FormPage: React.FC<Props> = ({ page, design, next, prev, className,
size={'large'}
onClick={next}
>
{page.buttonText || 'Continue'}
{page.buttonText || t('form:continue')}
</StyledButton>
</div>
</div>

View File

@ -1,19 +0,0 @@
.main {
display: flex;
flex-direction: column;
.content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
overflow: auto;
padding: 16px;
@media (max-width: 600px) {
display: block;
}
}
}

View File

@ -11,10 +11,10 @@ import {
SubmissionStartMutationVariables,
} from '../graphql/mutation/submission.start.mutation'
interface Submission {
export interface Submission {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setField: (fieldId: string, data: unknown) => Promise<void>
finish: () => void
finish: () => Promise<void>
}
export const useSubmission = (formId: string): Submission => {
@ -70,8 +70,10 @@ export const useSubmission = (formId: string): Submission => {
[submission]
)
const finish = useCallback(() => {
const finish = useCallback(async () => {
console.log('finish submission!!', formId)
await Promise.resolve()
}, [submission])
return {

View File

@ -96,6 +96,7 @@ export interface FormFragment {
buttonText: string
}
font?: string
layout?: string
}
startPage: FormPageFragment
endPage: FormPageFragment
@ -176,6 +177,7 @@ export const FORM_FRAGMENT = gql`
buttonText
}
font
layout
}
startPage {
id

View File

@ -1,20 +1,22 @@
import { gql } from '@apollo/client/core'
export interface FormPublicPageButtonFragment {
id: string
url?: string
action?: string
text?: string
bgColor?: string
activeColor?: string
color?: string
}
export interface FormPublicPageFragment {
id: string
show: boolean
title?: string
paragraph?: string
buttonText?: string
buttons: {
id: string
url?: string
action?: string
text?: string
bgColor?: string
activeColor?: string
color?: string
}[]
buttons: FormPublicPageButtonFragment[]
}
export interface FormPublicFieldOptionFragment {
@ -62,6 +64,7 @@ export interface FormPublicDesignFragment {
buttonText: string
}
font?: string
layout?: string
}
export interface FormPublicFragment {
@ -134,6 +137,7 @@ export const FORM_PUBLIC_FRAGMENT = gql`
buttonText
}
font
layout
}
startPage {

View File

@ -8,6 +8,7 @@
"baseDataTab": "Base Data",
"building": "Building Form",
"confirmDelete": "Do you really want to delete this form with all submissions?",
"continue": "Continue",
"create": "Create new form",
"created": "Form Created",
"createNow": "Save",
@ -24,6 +25,11 @@
"buttonText": "Button Text Color",
"question": "Question Color"
},
"layout": {
"card": "Card",
"slider": "Slider"
},
"layouts": "Layout",
"font": "Font"
},
"designTab": "Design",
@ -52,7 +58,9 @@
"loading": "Loading Form",
"mange": "Edit Form \"{{title}}\"",
"new": "New Form",
"next": "Next",
"notifications": {
"add": "Add Notification",
"enabled": "Enabled",
"fromEmail": "Sender Email",
"fromEmailInfo": "Make sure your mailserver can send from this email",
@ -66,6 +74,8 @@
"toField": "Email Field",
"toFieldInfo": "Field with Email for receipt"
},
"notificationsTab": "Notifications",
"previous": "Previous",
"restart": "Restart Form",
"row": {
"admin": "Owner",

View File

@ -1,29 +1,22 @@
import { Modal } from 'antd'
import { ErrorPage } from 'components/error.page'
import { Field } from 'components/form/field'
import { FormPage } from 'components/form/page'
import { LoadingPage } from 'components/loading.page'
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Swiper from 'react-id-swiper'
import { ReactIdSwiperProps } from 'react-id-swiper/lib/types'
import * as OriginalSwiper from 'swiper'
import { Omf } from '../../../components/omf'
import { CardLayout } from '../../../components/form/layouts/card'
import { SliderLayout } from '../../../components/form/layouts/slider'
import { useSubmission } from '../../../components/use.submission'
import { useFormPublicQuery } from '../../../graphql/query/form.public.query'
const Index: NextPage = () => {
const { t, i18n } = useTranslation()
const router = useRouter()
const id = router.query.id as string
const [swiper, setSwiper] = useState<OriginalSwiper.default>(null)
const submission = useSubmission(id)
const submission = useSubmission(router.query.id as string)
const { loading, data, error } = useFormPublicQuery({
variables: {
id,
id: router.query.id as string,
},
})
@ -34,6 +27,7 @@ const Index: NextPage = () => {
}
if (i18n.language !== data.form.language) {
// TODO prompt for language change if is not a match!
i18n
.changeLanguage(data.form.language)
.catch((e: Error) => console.error('failed to change language', e))
@ -48,100 +42,14 @@ const Index: NextPage = () => {
return <ErrorPage />
}
const design = data.form.design
switch (data.form.design.layout) {
case 'card':
return <CardLayout form={data.form} submission={submission} />
const goNext = () => {
if (!swiper) return
swiper.allowSlideNext = true
swiper.slideNext()
swiper.allowSlideNext = false
case 'slider':
default:
return <SliderLayout form={data.form} submission={submission} />
}
const goPrev = () => swiper && swiper.slidePrev()
const swiperConfig: ReactIdSwiperProps = {
direction: 'vertical',
allowSlideNext: false,
allowSlidePrev: true,
noSwiping: true,
updateOnWindowResize: true,
}
return (
<div
style={{
background: design.colors.background,
}}
>
<Omf />
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */}
<Swiper {...swiperConfig} ref={(element) => element && setSwiper((element as any).swiper)}>
{[
data.form.startPage.show ? (
<FormPage
key={'start'}
type={'start'}
page={data.form.startPage}
design={design}
next={goNext}
prev={goPrev}
/>
) : undefined,
...data.form.fields
.map((field, i) => {
if (field.type === 'hidden') {
return null
}
return (
<Field
key={field.id}
field={field}
design={design}
save={async (values: { [key: string]: unknown }) => {
await submission.setField(field.id, values[field.id])
if (data.form.fields.length === i + 1) {
submission.finish()
}
}}
next={() => {
if (data.form.fields.length === i + 1) {
// prevent going back!
swiper.allowSlidePrev = true
if (!data.form.endPage.show) {
Modal.success({
content: t('form:submitted'),
okText: t('from:restart'),
onOk: () => {
window.location.reload()
},
})
}
}
goNext()
}}
prev={goPrev}
/>
)
})
.filter((e) => e !== null),
data.form.endPage.show ? (
<FormPage
key={'end'}
type={'end'}
page={data.form.endPage}
design={design}
next={submission.finish}
prev={goPrev}
/>
) : undefined,
].filter((e) => !!e)}
</Swiper>
</div>
)
}
export default Index

View File

@ -36,6 +36,7 @@ type Deleted {
type Design {
colors: Colors!
font: String
layout: String
}
type Device {
@ -283,6 +284,7 @@ input ColorsInput {
input DesignInput {
colors: ColorsInput!
font: String
layout: String
}
input DeviceInput {
@ -294,6 +296,7 @@ input DeviceInput {
input FormCreateInput {
isLive: Boolean
language: String!
layout: String
showFooter: Boolean
title: String!
}