Skip to content

Commit 8a05fbc

Browse files
committed
atomic forms submissions #875
1 parent d5f79da commit 8a05fbc

44 files changed

Lines changed: 16858 additions & 12412 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

browser/data-browser/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@tiptap/react": "^3.23.6",
4848
"@tiptap/starter-kit": "^3.23.6",
4949
"@tiptap/suggestion": "^3.23.6",
50+
"@tomic/form-renderer": "workspace:*",
5051
"@tomic/lib": "workspace:*",
5152
"@tomic/plugin": "workspace:*",
5253
"@tomic/react": "workspace:*",

browser/data-browser/src/chunks/FormBuilder/FormBuilderPage.tsx

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ import { ContainerFull } from '@components/Containers';
55
import { EditableTitle } from '@components/EditableTitle';
66
import { Row } from '@components/Row';
77
import type { ResourcePageProps } from '@views/ResourcePage';
8-
import { PageSidebar } from './PageSidebar';
8+
import { PageTabBar } from './PageTabBar';
99
import { FieldList } from './FieldList';
1010
import { FieldSettingsPanel } from './FieldSettingsPanel';
1111
import { PublishToggle } from './PublishToggle';
12+
import { FormPreviewButton } from './FormPreviewDialog';
1213

1314
export function FormBuilderPage({ resource }: ResourcePageProps): JSX.Element {
1415
const titleId = useId();
1516
const [pages] = useArray(resource, forms.properties.formPages);
16-
const [dataClassSubject] = useString(resource, forms.properties.formDataClass);
17+
const [dataClassSubject] = useString(
18+
resource,
19+
forms.properties.formDataClass,
20+
);
1721

1822
const [activePage, setActivePage] = useState<string | undefined>(pages[0]);
1923
const [selectedField, setSelectedField] = useState<string | undefined>();
@@ -31,19 +35,12 @@ export function FormBuilderPage({ resource }: ResourcePageProps): JSX.Element {
3135
<TitleSlot>
3236
<Row justify='space-between' center>
3337
<EditableTitle resource={resource} id={titleId} />
34-
<PublishToggle resource={resource} />
38+
<Row gap='0.5rem' center>
39+
<FormPreviewButton formSubject={resource.subject} />
40+
<PublishToggle resource={resource} />
41+
</Row>
3542
</Row>
3643
</TitleSlot>
37-
<SidebarSlot>
38-
<PageSidebar
39-
formResource={resource}
40-
activePage={activePage}
41-
onSelectPage={subject => {
42-
setActivePage(subject);
43-
setSelectedField(undefined);
44-
}}
45-
/>
46-
</SidebarSlot>
4744
<MainSlot>
4845
{activePage && dataClassSubject && (
4946
<FieldList
@@ -62,29 +59,33 @@ export function FormBuilderPage({ resource }: ResourcePageProps): JSX.Element {
6259
/>
6360
)}
6461
</SettingsSlot>
62+
<PageBarSlot>
63+
<PageTabBar
64+
formResource={resource}
65+
activePage={activePage}
66+
onSelectPage={subject => {
67+
setActivePage(subject);
68+
setSelectedField(undefined);
69+
}}
70+
/>
71+
</PageBarSlot>
6572
</FormBuilderGrid>
6673
</ContainerFull>
6774
);
6875
}
6976

7077
const FormBuilderGrid = styled.div`
7178
display: grid;
72-
grid-template-areas: 'title title title' 'sidebar main settings';
73-
grid-template-columns: 14rem 1fr 18rem;
74-
grid-template-rows: 4rem auto;
79+
grid-template-areas: 'title title' 'main settings' 'pages pages';
80+
grid-template-columns: 1fr 18rem;
81+
grid-template-rows: 4rem auto min-content;
7582
width: 100%;
7683
min-height: ${p => p.theme.heights.fullPage};
7784
78-
@container (max-width: 900px) {
79-
grid-template-areas: 'title title' 'sidebar main' 'settings settings';
80-
grid-template-columns: 14rem 1fr;
81-
grid-template-rows: 4rem auto auto;
82-
}
83-
8485
@container (max-width: 600px) {
85-
grid-template-areas: 'title' 'sidebar' 'main' 'settings';
86+
grid-template-areas: 'title' 'main' 'settings' 'pages';
8687
grid-template-columns: 100cqw;
87-
grid-template-rows: 4rem auto auto auto;
88+
grid-template-rows: 4rem auto auto min-content;
8889
}
8990
`;
9091

@@ -93,12 +94,6 @@ const TitleSlot = styled.div`
9394
padding: ${p => p.theme.size()};
9495
`;
9596

96-
const SidebarSlot = styled.div`
97-
grid-area: sidebar;
98-
padding: ${p => p.theme.size()};
99-
border-right: 1px solid ${p => p.theme.colors.bg2};
100-
`;
101-
10297
const MainSlot = styled.div`
10398
grid-area: main;
10499
padding: ${p => p.theme.size()};
@@ -109,3 +104,10 @@ const SettingsSlot = styled.div`
109104
padding: ${p => p.theme.size()};
110105
border-left: 1px solid ${p => p.theme.colors.bg2};
111106
`;
107+
108+
const PageBarSlot = styled.div`
109+
grid-area: pages;
110+
padding: ${p => p.theme.size()};
111+
border-top: 1px solid ${p => p.theme.colors.bg2};
112+
min-width: 0;
113+
`;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useEffect, useState, type JSX } from 'react';
2+
import { useStore } from '@tomic/react';
3+
import { FormRenderer, type FormDefinition } from '@tomic/form-renderer';
4+
import '@tomic/form-renderer/style.css';
5+
import {
6+
Dialog,
7+
DialogActions,
8+
DialogContent,
9+
DialogTitle,
10+
useDialog,
11+
} from '@components/Dialog';
12+
import { Button } from '@components/Button';
13+
import { buildFormDefinitionClientSide } from './buildFormDefinition';
14+
15+
interface FormPreviewButtonProps {
16+
formSubject: string;
17+
}
18+
19+
/** Renders the form exactly as a visitor would see it at `/form/:id`, using
20+
* the same `@tomic/form-renderer` component — built from a client-side
21+
* mirror of the server's definition JSON (`buildFormDefinitionClientSide`).
22+
* `preview` disables the honeypot and turns Submit into a no-op so no real
23+
* submission row is ever written from here. */
24+
export function FormPreviewButton({
25+
formSubject,
26+
}: FormPreviewButtonProps): JSX.Element {
27+
const store = useStore();
28+
const [dialogProps, show, close, isOpen] = useDialog();
29+
const [definition, setDefinition] = useState<FormDefinition>();
30+
31+
useEffect(() => {
32+
if (!isOpen) return;
33+
34+
let cancelled = false;
35+
36+
buildFormDefinitionClientSide(store, formSubject).then(def => {
37+
if (!cancelled) setDefinition(def);
38+
});
39+
40+
return () => {
41+
cancelled = true;
42+
};
43+
}, [isOpen, store, formSubject]);
44+
45+
return (
46+
<>
47+
<Button subtle onClick={show}>
48+
Preview
49+
</Button>
50+
<Dialog {...dialogProps} width='40rem'>
51+
<DialogTitle>
52+
<h1>Preview</h1>
53+
</DialogTitle>
54+
<DialogContent>
55+
{definition ? (
56+
<FormRenderer
57+
definition={definition}
58+
preview
59+
onSubmit={async () => ({ ok: true })}
60+
/>
61+
) : (
62+
<p>Loading preview…</p>
63+
)}
64+
</DialogContent>
65+
<DialogActions>
66+
<Button onClick={() => close(false)}>Close</Button>
67+
</DialogActions>
68+
</Dialog>
69+
</>
70+
);
71+
}

browser/data-browser/src/chunks/FormBuilder/PageSidebar.tsx renamed to browser/data-browser/src/chunks/FormBuilder/PageTabBar.tsx

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,23 @@ import {
1010
import { useState, type JSX } from 'react';
1111
import { styled } from 'styled-components';
1212
import { FaPlus, FaTrash } from 'react-icons/fa6';
13-
import { Column, Row } from '@components/Row';
13+
import { Row } from '@components/Row';
1414
import { Button } from '@components/Button';
1515
import { InputStyled } from '@components/forms/InputStyles';
1616
import { IconButton, IconButtonVariant } from '@components/IconButton/IconButton';
1717
import { ReorderableList } from './ReorderableList';
1818

19-
interface PageSidebarProps {
19+
interface PageTabBarProps {
2020
formResource: Resource;
2121
activePage: string | undefined;
2222
onSelectPage: (subject: string) => void;
2323
}
2424

25-
export function PageSidebar({
25+
export function PageTabBar({
2626
formResource,
2727
activePage,
2828
onSelectPage,
29-
}: PageSidebarProps): JSX.Element {
29+
}: PageTabBarProps): JSX.Element {
3030
const store = useStore();
3131
const [pages, setPages] = useArray(formResource, forms.properties.formPages, {
3232
commit: true,
@@ -62,26 +62,29 @@ export function PageSidebar({
6262
};
6363

6464
return (
65-
<Column gap='0.5rem'>
66-
<ReorderableList
67-
subjects={pages}
68-
onReorder={setPages}
69-
renderItem={subject => (
70-
<PageTab
71-
subject={subject}
72-
active={subject === activePage}
73-
canDelete={pages.length > 1}
74-
onSelect={() => onSelectPage(subject)}
75-
onDelete={() => deletePage(subject)}
76-
/>
77-
)}
78-
/>
65+
<TabBarRow gap='0.5rem' center>
66+
<ScrollArea>
67+
<ReorderableList
68+
subjects={pages}
69+
onReorder={setPages}
70+
orientation='horizontal'
71+
renderItem={subject => (
72+
<PageTab
73+
subject={subject}
74+
active={subject === activePage}
75+
canDelete={pages.length > 1}
76+
onSelect={() => onSelectPage(subject)}
77+
onDelete={() => deletePage(subject)}
78+
/>
79+
)}
80+
/>
81+
</ScrollArea>
7982
<AddButton type='button' subtle onClick={addPage}>
8083
<Row gap='.5rem' center>
8184
<FaPlus /> Add page
8285
</Row>
8386
</AddButton>
84-
</Column>
87+
</TabBarRow>
8588
);
8689
}
8790

@@ -161,14 +164,26 @@ function PageTab({
161164
);
162165
}
163166

167+
const TabBarRow = styled(Row)`
168+
width: 100%;
169+
min-width: 0;
170+
`;
171+
172+
const ScrollArea = styled.div`
173+
flex: 1;
174+
min-width: 0;
175+
overflow-x: auto;
176+
overflow-y: hidden;
177+
`;
178+
164179
const TabRow = styled(Row)<{ $active: boolean }>`
165180
align-items: center;
166181
gap: 0.25rem;
167-
width: 100%;
182+
flex-shrink: 0;
183+
white-space: nowrap;
168184
`;
169185

170186
const TabButton = styled.button<{ $active: boolean }>`
171-
flex: 1;
172187
text-align: left;
173188
padding: 0.35rem 0.6rem;
174189
border: none;
@@ -177,14 +192,15 @@ const TabButton = styled.button<{ $active: boolean }>`
177192
color: ${p => (p.$active ? p.theme.colors.text : p.theme.colors.textLight)};
178193
font-weight: ${p => (p.$active ? 'bold' : 'normal')};
179194
cursor: pointer;
195+
white-space: nowrap;
180196
181197
&:hover {
182198
background-color: ${p => p.theme.colors.bg1};
183199
}
184200
`;
185201

186202
const AddButton = styled(Button)`
187-
align-self: stretch;
203+
flex-shrink: 0;
188204
box-shadow: none;
189205
border: 1px dashed ${p => p.theme.colors.bg2};
190206
background: none;

0 commit comments

Comments
 (0)