@acms-subir/core 0.3.52 → 0.3.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/docs/mobile.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/dynamicBlocks.ts +4 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.ts +3 -1
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/styles/forms.css +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.53
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1b8201d: Forms get a **Time** question and can speak every language your site has.
|
|
8
|
+
|
|
9
|
+
**Time questions.** Add one from the builder's question menu (next to Date). Visitors pick a time with their browser's time picker; it's saved as a 24-hour time like `18:30` and shows that way in Responses, CSV exports and alert emails. Required works like any other question, and anything that isn't a real time is refused. Event registration forms can ask it too.
|
|
10
|
+
|
|
11
|
+
**Forms in your site's other languages.** On a site with more than one language, the form builder now has a language switcher (English · Español …) above the canvas, and so does an event's registration form. Pick a language to write its words: the title, introduction, button and thank-you message, and each question's label, help text, placeholder and choices. You type straight onto the form, with the main language showing in grey where you haven't written anything yet, and the side panel shows the main language's words above each box. Anything left blank shows the main language. The questions themselves (types, order, choices, Required and the other settings) stay the same in every language, and when you add, remove or reorder choices, their translations move with them. Translation changes to a published form go live with **Publish changes**, and the menu opens the live form in each language.
|
|
12
|
+
|
|
13
|
+
- `/es/forms/<id>` shows the form in Spanish, and a Form block on a Spanish page, post, event or home page opens it in Spanish. Event pages in another language show the registration questions in that language too.
|
|
14
|
+
- The form's own words around your questions ("Submit", "Choose…", "Other", "Send another response" and the default thank-you) follow the page's language in English, Spanish, French, German and Portuguese. Error messages are still in English.
|
|
15
|
+
- Answers are always saved with the main-language choice, so Responses, exports and alerts read the same whatever language someone answered in. A translated choice can't read the same as a different choice, so every answer maps back to exactly one.
|
|
16
|
+
- Rewording a translated label, help text or title doesn't change a form's version, so someone halfway through the form, on the website or in an app, can still send it. Rewording a translated choice does, so an answer in the old words is sent back for a reload instead of being stored as the wrong choice. Sending the same website response again (a refresh after sending) shows the thank-you again and stores it once.
|
|
17
|
+
|
|
18
|
+
**API:** `GET /api/v1/forms/{id}` takes an optional `locale` and returns the form's words in that language (the main language's where it has none); `version` is the same in every language. `POST /api/v1/forms/{id}/responses` accepts a choice in the language it was shown in or in the main one, and an optional `?locale=` for the receipt's thank-you. `GET /api/v1/events/{slug}/rsvp?locale=` returns the registration questions in that language, and RSVPs may send their choices the same way. Field `type` can now be `time` (24-hour `HH:mm`). Stored forms gain optional `translations` on the form and on each question; nothing changes for forms without them, and no migration is needed.
|
|
19
|
+
|
|
3
20
|
## 0.3.52
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/docs/mobile.md
CHANGED
|
@@ -124,7 +124,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
124
124
|
| `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
|
|
125
125
|
| `useConfirmBooking(token)` | an RSVP on a waitlist: `waitlisted` shows "#3 in line · 2 seats" (`waitlistLineText(booking, words, locale)`) and can leave (`useCancelBooking`); `offered` holds a spot until `booking.holdUntil` (`holdText`: "We're holding it until Fri, Oct 24 at 9 PM") and `can.confirm` takes it. `isReleased(error)`: the hold ran out (410) and the spot went to the next person |
|
|
126
126
|
| `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
|
|
127
|
-
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms
|
|
127
|
+
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms (in the app's language where the form has its words) and sending answers (choices as shown; `time` answers are 24-hour `HH:mm`, and `answerProblems` checks them); check `features.links` / `features.forms`. Ask a question type you don't know as short text |
|
|
128
128
|
| `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
|
|
129
129
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
130
130
|
| `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
|
|
@@ -32,7 +32,7 @@ const tokens = {
|
|
|
32
32
|
|
|
33
33
|
type Answers = Record<string, string | string[]>;
|
|
34
34
|
const OTHER = '__other__';
|
|
35
|
-
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
35
|
+
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url', time: 'numbers-and-punctuation' };
|
|
36
36
|
// "Other" choices send the typed text instead
|
|
37
37
|
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
38
38
|
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
@@ -269,7 +269,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
269
269
|
}
|
|
270
270
|
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
271
271
|
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
272
|
-
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
272
|
+
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : f.type === 'time' ? words.timePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
273
273
|
});
|
|
274
274
|
};
|
|
275
275
|
|
|
@@ -32,7 +32,7 @@ const tokens = {
|
|
|
32
32
|
|
|
33
33
|
type Answers = Record<string, string | string[]>;
|
|
34
34
|
const OTHER = '__other__';
|
|
35
|
-
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
35
|
+
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url', time: 'numbers-and-punctuation' };
|
|
36
36
|
// "Other" choices send the typed text instead
|
|
37
37
|
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
38
38
|
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
@@ -269,7 +269,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
269
269
|
}
|
|
270
270
|
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
271
271
|
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
272
|
-
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
272
|
+
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : f.type === 'time' ? words.timePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
273
273
|
});
|
|
274
274
|
};
|
|
275
275
|
|
package/package.json
CHANGED
|
@@ -19,11 +19,11 @@ const { page, settings } = Astro.props;
|
|
|
19
19
|
const theme = getTheme(settings.activeTheme);
|
|
20
20
|
const layout = settings.sectionPages[page.id];
|
|
21
21
|
const repo = await getRepo(Astro.locals);
|
|
22
|
-
const
|
|
22
|
+
const locale = Astro.props.locale ?? requestLocale(Astro.url, Astro.locals);
|
|
23
|
+
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo, locale)) : '';
|
|
23
24
|
// admin design preview: sections carry their ids so the Design screen can
|
|
24
25
|
// open the one you click (see Site.astro's preview script)
|
|
25
26
|
const editable = Astro.props.editable ?? (await resolvePreview(Astro.url, Astro.cookies, Astro.locals)).role === 'admin';
|
|
26
|
-
const locale = Astro.props.locale ?? requestLocale(Astro.url, Astro.locals);
|
|
27
27
|
const now = Date.now();
|
|
28
28
|
const nowWall = wallNow(settings.timezone || 'UTC', now);
|
|
29
29
|
// another language: dates/times format in it (components read settings.locale)
|
|
@@ -1162,6 +1162,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1162
1162
|
eventId={docId}
|
|
1163
1163
|
title={meta.title}
|
|
1164
1164
|
status={meta.status}
|
|
1165
|
+
languages={languages}
|
|
1166
|
+
mainLanguage={mainLanguage}
|
|
1165
1167
|
tags={<TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />}
|
|
1166
1168
|
/>
|
|
1167
1169
|
</div>
|
|
@@ -13,6 +13,7 @@ import { RegistrationCard } from './RegistrationCard';
|
|
|
13
13
|
import { WhenCard } from './WhenCard';
|
|
14
14
|
import { SingleDates } from './SingleDates';
|
|
15
15
|
import { registrationMode } from './registration';
|
|
16
|
+
import type { SiteLanguage } from '../../forms/FormBuilder';
|
|
16
17
|
|
|
17
18
|
// Every event setting, on the page above the event's own content, as one
|
|
18
19
|
// settings column: When · Where · CTA · Organize · Status, with a
|
|
@@ -31,6 +32,9 @@ interface Props {
|
|
|
31
32
|
title: string;
|
|
32
33
|
status: string;
|
|
33
34
|
tags?: ReactNode;
|
|
35
|
+
/** the site's other languages and its main one, for the registration form's words in each */
|
|
36
|
+
languages?: SiteLanguage[];
|
|
37
|
+
mainLanguage?: SiteLanguage;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
const SECTIONS = [
|
|
@@ -43,7 +47,7 @@ const SECTIONS = [
|
|
|
43
47
|
|
|
44
48
|
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
45
49
|
|
|
46
|
-
export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags }: Props) {
|
|
50
|
+
export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags, languages, mainLanguage }: Props) {
|
|
47
51
|
const [places, setPlaces] = useState<Place[]>([]);
|
|
48
52
|
const [current, setCurrent] = useState<string>(SECTIONS[0].id);
|
|
49
53
|
const root = useRef<HTMLDivElement>(null);
|
|
@@ -129,7 +133,7 @@ export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, ma
|
|
|
129
133
|
onLocation={(name) => onChange({ location: { ...meta.location, name, placeId: '' } })}
|
|
130
134
|
/>
|
|
131
135
|
</Section>
|
|
132
|
-
<RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} />
|
|
136
|
+
<RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} languages={languages} mainLanguage={mainLanguage} />
|
|
133
137
|
<OrganizeCard meta={meta} onChange={onChange} calendars={calendars} fields={fields} tags={tags} />
|
|
134
138
|
<StatusSection meta={meta} onChange={onChange} />
|
|
135
139
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { starter } from '../../forms/FormBuilder';
|
|
2
|
+
import { starter, type SiteLanguage } from '../../forms/FormBuilder';
|
|
3
3
|
import type { EventCta } from '../../../lib/schema';
|
|
4
4
|
import type { EventMeta } from '../EventPanel';
|
|
5
5
|
import { Section } from './Section';
|
|
@@ -13,7 +13,7 @@ const MODES: { id: RegistrationMode; label: string; sub: string }[] = [
|
|
|
13
13
|
{ id: 'rsvp', label: 'Built-in form', sub: 'Collect RSVPs here' },
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
-
export function RegistrationCard({ meta, onChange, eventId, title = '' }: { eventId?: string; title?: string; meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void }) {
|
|
16
|
+
export function RegistrationCard({ meta, onChange, eventId, title = '', languages, mainLanguage }: { eventId?: string; title?: string; meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void; languages?: SiteLanguage[]; mainLanguage?: SiteLanguage }) {
|
|
17
17
|
// "Link button" with nothing typed yet is still link mode, so keep the
|
|
18
18
|
// choice locally and only fall back to the derived one on first render
|
|
19
19
|
const [mode, setMode] = useState<RegistrationMode>(() => registrationMode(meta));
|
|
@@ -94,7 +94,7 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
94
94
|
<button type="button" className="pe-btn sm" aria-haspopup="dialog" onClick={() => setEditing(true)}>Edit form</button>
|
|
95
95
|
</div>
|
|
96
96
|
</div>
|
|
97
|
-
{editing && <RegistrationFormEditor form={form} maxSeats={meta.rsvp.maxSeats} eventTitle={title} onClose={() => setEditing(false)} onChange={(f) => onChange({ rsvp: { ...meta.rsvp, form: f } })} />}
|
|
97
|
+
{editing && <RegistrationFormEditor form={form} maxSeats={meta.rsvp.maxSeats} eventTitle={title} languages={languages} mainLanguage={mainLanguage} onClose={() => setEditing(false)} onChange={(f) => onChange({ rsvp: { ...meta.rsvp, form: f } })} />}
|
|
98
98
|
</div>
|
|
99
99
|
)}
|
|
100
100
|
</Section>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { actions } from 'astro:actions';
|
|
3
|
-
import FormBuilder, { starter } from '../../forms/FormBuilder';
|
|
3
|
+
import FormBuilder, { starter, type SiteLanguage } from '../../forms/FormBuilder';
|
|
4
4
|
import type { SystemField } from '../../forms/meta';
|
|
5
5
|
import type { FormDefinition, SavedForm } from '../../../lib/forms/schema';
|
|
6
6
|
import '../../../styles/forms.css';
|
|
@@ -10,12 +10,15 @@ type Choice = { id: string; name: string; note: string; definition: () => FormDe
|
|
|
10
10
|
// The event's own registration questions in the full forms builder, on top
|
|
11
11
|
// of the event editor. Edits go straight into the event's meta (its own copy);
|
|
12
12
|
// the event's Update/autosave persists them, so "Done" only closes.
|
|
13
|
-
export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, eventTitle }: {
|
|
13
|
+
export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, eventTitle, languages, mainLanguage }: {
|
|
14
14
|
form: FormDefinition;
|
|
15
15
|
onChange: (form: FormDefinition) => void;
|
|
16
16
|
onClose: () => void;
|
|
17
17
|
maxSeats: number;
|
|
18
18
|
eventTitle: string;
|
|
19
|
+
/** the site's other languages: the builder's switcher writes the questions' words in each */
|
|
20
|
+
languages?: SiteLanguage[];
|
|
21
|
+
mainLanguage?: SiteLanguage;
|
|
19
22
|
}) {
|
|
20
23
|
const ref = useRef<HTMLDialogElement>(null);
|
|
21
24
|
const [templates, setTemplates] = useState<SavedForm[]>([]);
|
|
@@ -135,7 +138,7 @@ export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, even
|
|
|
135
138
|
</div>
|
|
136
139
|
)}
|
|
137
140
|
<div className="pe-evr-body">
|
|
138
|
-
<FormBuilder value={form} onChange={onChange} mode="event" systemFields={system} />
|
|
141
|
+
<FormBuilder value={form} onChange={onChange} mode="event" systemFields={system} languages={languages} mainLanguage={mainLanguage} />
|
|
139
142
|
</div>
|
|
140
143
|
</dialog>
|
|
141
144
|
);
|
|
@@ -2,6 +2,8 @@ import { Fragment, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { escapeForm, renderControl, renderField, renderFields } from '../../lib/forms/fields';
|
|
3
3
|
import { isHalf, type FormDefinition, type FormField } from '../../lib/forms/schema';
|
|
4
4
|
import { answersFrom, firstError } from '../../lib/forms/validate';
|
|
5
|
+
import { formStrings, thanksText, type FormStrings } from '../../lib/forms/words';
|
|
6
|
+
import { rsvpStrings } from '../../lib/calendar/rsvp-words';
|
|
5
7
|
import { META, type Selection, type SystemField } from './meta';
|
|
6
8
|
|
|
7
9
|
/** Plain-text contentEditable that doesn't fight the caret: the DOM is only rewritten when the value changes from outside. */
|
|
@@ -37,20 +39,22 @@ type CanvasProps = {
|
|
|
37
39
|
onSelect: (s: Selection) => void; patch: (next: Partial<FormDefinition>, key: string) => void;
|
|
38
40
|
patchField: (id: string, next: Partial<FormField>, key: string) => void; onLabelDone: (id: string) => void;
|
|
39
41
|
onInsertAfter: (id: string | null) => void; onDuplicate: (id: string) => void; onRemove: (id: string) => void;
|
|
42
|
+
/** writing another language: `value` holds its words ('' = not written yet), `source` the main form, whose words show where it has none */
|
|
43
|
+
source?: FormDefinition; words?: FormStrings; submitFallback?: string;
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
/** The form drawn with the public form markup/classes; labels, help, title, intro and the button edit in place. */
|
|
43
47
|
export function Canvas(p: CanvasProps) {
|
|
44
|
-
const { value, system, selection, event, onSelect } = p;
|
|
48
|
+
const { value, system, selection, event, onSelect, source } = p;
|
|
45
49
|
const prefix = useId().replace(/:/g, '');
|
|
46
50
|
const sel = (s: Selection) => JSON.stringify(s) === JSON.stringify(selection);
|
|
47
|
-
const submitFallback = event ? 'Confirm my seat' : 'Submit';
|
|
51
|
+
const submitFallback = p.submitFallback ?? (event ? 'Confirm my seat' : 'Submit');
|
|
48
52
|
|
|
49
53
|
if (selection?.kind === 'thanks') return (
|
|
50
54
|
<div className="fb-paper fb-paper-done">
|
|
51
55
|
<p className="fb-paper-tag">After someone submits</p>
|
|
52
|
-
<h2 className="fb-title">{value.title || 'Thank you'}</h2>
|
|
53
|
-
<p className={`fb-thanks on`}><Inline value={value.confirmation} onChange={v => p.patch({ confirmation: v }, 'confirmation')} placeholder={event ? 'Uses the booking confirmation message' : '
|
|
56
|
+
<h2 className="fb-title">{value.title || source?.title || 'Thank you'}</h2>
|
|
57
|
+
<p className={`fb-thanks on`}><Inline value={value.confirmation} onChange={v => p.patch({ confirmation: v }, 'confirmation')} placeholder={event ? source?.confirmation || 'Uses the booking confirmation message' : thanksText(source?.confirmation ?? '', p.words ?? formStrings('en'))} max={1000} multiline label="Thank-you message" /></p>
|
|
54
58
|
<button type="button" className="fb-link" onClick={() => onSelect(null)}>← Back to the questions</button>
|
|
55
59
|
</div>
|
|
56
60
|
);
|
|
@@ -66,8 +70,8 @@ export function Canvas(p: CanvasProps) {
|
|
|
66
70
|
return (
|
|
67
71
|
<div className="fb-paper" onClick={e => { if (e.target === e.currentTarget) onSelect(null); }}>
|
|
68
72
|
<div className={`fb-item fb-head${sel({ kind: 'form' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'form' })}>
|
|
69
|
-
<h2 className="fb-title"><Inline value={value.title} onChange={v => p.patch({ title: v }, 'title')} placeholder=
|
|
70
|
-
<p className="fb-intro"><Inline value={value.intro} onChange={v => p.patch({ intro: v }, 'intro')} placeholder=
|
|
73
|
+
<h2 className="fb-title"><Inline value={value.title} onChange={v => p.patch({ title: v }, 'title')} placeholder={source?.title || 'Form title'} max={120} label="Form title" /></h2>
|
|
74
|
+
<p className="fb-intro"><Inline value={value.intro} onChange={v => p.patch({ intro: v }, 'intro')} placeholder={source?.intro || 'Add an introduction (optional)'} max={1000} multiline label="Introduction" /></p>
|
|
71
75
|
</div>
|
|
72
76
|
{system.map((f, i) => (
|
|
73
77
|
<div key={`sys-${i}`} className={`fb-item fb-sys${sel({ kind: 'system', index: i }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'system', index: i })} title="Always asked">
|
|
@@ -81,27 +85,30 @@ export function Canvas(p: CanvasProps) {
|
|
|
81
85
|
</button>
|
|
82
86
|
)}
|
|
83
87
|
<div className={`fb-item fb-submit-wrap${sel({ kind: 'submit' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'submit' })}>
|
|
84
|
-
<div className="pc-bk-submit fb-submit"><Inline value={value.submitLabel} onChange={v => p.patch({ submitLabel: v }, 'submit')} placeholder={submitFallback} max={80} label="Submit button label" /></div>
|
|
88
|
+
<div className="pc-bk-submit fb-submit"><Inline value={value.submitLabel} onChange={v => p.patch({ submitLabel: v }, 'submit')} placeholder={source?.submitLabel || submitFallback} max={80} label="Submit button label" /></div>
|
|
85
89
|
</div>
|
|
86
90
|
</div>
|
|
87
91
|
);
|
|
88
92
|
}
|
|
89
93
|
|
|
90
|
-
function Item({ field: f, prefix, on, onSelect, patchField, onLabelDone, onInsertAfter, onDuplicate, onRemove }: CanvasProps & { field: FormField; prefix: string; on: boolean }) {
|
|
94
|
+
function Item({ field: f, prefix, on, onSelect, patchField, onLabelDone, onInsertAfter, onDuplicate, onRemove, source, words }: CanvasProps & { field: FormField; prefix: string; on: boolean }) {
|
|
91
95
|
const id = `${prefix}-${f.id}`;
|
|
92
|
-
|
|
96
|
+
// the main-language question while writing another language
|
|
97
|
+
const main = source?.fields.find(x => x.id === f.id);
|
|
98
|
+
const hasHelp = Boolean(f.help || main?.help || on);
|
|
99
|
+
const label = <Inline value={f.label} onChange={v => patchField(f.id, { label: v }, `label:${f.id}`)} onDone={() => onLabelDone(f.id)} placeholder={main?.label || META[f.type].fresh} max={200} label={`${META[f.type].label} label`} />;
|
|
93
100
|
const req = f.required ? <span className="fb-req-mark" aria-hidden="true"> *</span> : null;
|
|
94
|
-
const help =
|
|
95
|
-
const control = <div className="fb-control" inert dangerouslySetInnerHTML={{ __html: renderControl(f, '', id) }} />;
|
|
101
|
+
const help = hasHelp ? <small className="fb-help"><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder={main?.help || (f.type === 'heading' ? 'Add a description (optional)' : 'Add help text (optional)')} max={300} label="Help text" /></small> : null;
|
|
102
|
+
const control = <div className="fb-control" inert dangerouslySetInnerHTML={{ __html: renderControl(f, '', id, words) }} />;
|
|
96
103
|
const body = f.type === 'heading'
|
|
97
|
-
? <div className="pc-form-heading"><h3>{label}</h3>{
|
|
104
|
+
? <div className="pc-form-heading"><h3>{label}</h3>{hasHelp && <p><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder={main?.help || 'Add a description (optional)'} max={300} label="Section description" /></p>}</div>
|
|
98
105
|
: f.type === 'checkbox' || f.type === 'consent'
|
|
99
106
|
? <div className="pc-bk-check"><input type="checkbox" tabIndex={-1} aria-hidden="true" readOnly checked={false} /><span>{label}{req}{help}</span></div>
|
|
100
107
|
: f.type === 'radio' || f.type === 'checkboxes'
|
|
101
108
|
? <div className="pc-bk-field pc-bk-choices"><div className="fb-legend">{label}{req}</div>{control}{help}</div>
|
|
102
109
|
: <div className="pc-bk-field"><span>{label}{req}</span>{control}{help}</div>;
|
|
103
110
|
return (
|
|
104
|
-
<div className={`fb-item${on ? ' on' : ''}${isHalf(f) ? ' is-half' : ''}`} data-item={f.id} tabIndex={-1} aria-label={`${META[f.type].label}: ${f.label}`}
|
|
111
|
+
<div className={`fb-item${on ? ' on' : ''}${isHalf(f) ? ' is-half' : ''}`} data-item={f.id} tabIndex={-1} aria-label={`${META[f.type].label}: ${f.label || main?.label || ''}`}
|
|
105
112
|
onClick={e => { onSelect({ kind: 'field', id: f.id }); if (!(e.target as HTMLElement).closest('.fb-inline, button')) e.currentTarget.focus({ preventScroll: true }); }}>
|
|
106
113
|
{body}
|
|
107
114
|
{on && (
|
|
@@ -119,13 +126,18 @@ function Item({ field: f, prefix, on, onSelect, patchField, onLabelDone, onInser
|
|
|
119
126
|
);
|
|
120
127
|
}
|
|
121
128
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Interactive preview: type, tick and press the button to check validation.
|
|
131
|
+
* Nothing is sent. lang: the language previewed (`value` is already in it),
|
|
132
|
+
* for the form's own words around the questions.
|
|
133
|
+
*/
|
|
134
|
+
export function Preview({ value, system, event, device, lang = '' }: { value: FormDefinition; system: SystemField[]; event: boolean; device: 'desktop' | 'phone'; lang?: string }) {
|
|
124
135
|
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
|
125
136
|
const formRef = useRef<HTMLFormElement>(null);
|
|
126
137
|
const e = escapeForm;
|
|
127
|
-
const
|
|
128
|
-
const
|
|
138
|
+
const w = formStrings(lang), rsvp = rsvpStrings(lang);
|
|
139
|
+
const sys = system.map((f, i) => renderField({ id: `sys${i}`, type: f.type, label: f.label, required: f.type !== 'select', help: '', options: f.type === 'select' ? ['1', '2'] : [] }, '', 'pv-sys', w)).join('');
|
|
140
|
+
const html = `${value.title ? `<h2>${e(value.title)}</h2>` : ''}${value.intro ? `<p class="fb-pv-intro">${e(value.intro)}</p>` : ''}${sys}${renderFields(value, {}, 'pv', w)}`;
|
|
129
141
|
const key = JSON.stringify(value);
|
|
130
142
|
return (
|
|
131
143
|
<div className={`fb-pv-frame ${device}`}>
|
|
@@ -136,13 +148,13 @@ export function Preview({ value, system, event, device }: { value: FormDefinitio
|
|
|
136
148
|
const data = new FormData(ev.currentTarget);
|
|
137
149
|
try {
|
|
138
150
|
const error = firstError(value, answersFrom(name => data.getAll(name), value));
|
|
139
|
-
setResult(error ? { ok: false, text: error } : { ok: true, text: value.confirmation ||
|
|
151
|
+
setResult(error ? { ok: false, text: error } : { ok: true, text: event ? value.confirmation || 'Your seat is saved.' : thanksText(value.confirmation, w) });
|
|
140
152
|
} catch (err) { setResult({ ok: false, text: err instanceof Error ? err.message : 'Invalid answer.' }); }
|
|
141
153
|
}}
|
|
142
154
|
>
|
|
143
155
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
144
156
|
{result && <p className={`fb-pv-result ${result.ok ? 'ok' : 'bad'}`} role="status">{result.ok ? <><b>Looks good.</b> Visitors would see: “{result.text}” Nothing was sent.</> : result.text}</p>}
|
|
145
|
-
<button type="submit" className="pc-bk-submit">{value.submitLabel || (event ?
|
|
157
|
+
<button type="submit" className="pc-bk-submit">{value.submitLabel || (event ? rsvp.confirmSeat : w.submit)}</button>
|
|
146
158
|
</form>
|
|
147
159
|
</div>
|
|
148
160
|
);
|
|
@@ -2,18 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { actions, isActionError } from 'astro:actions';
|
|
4
4
|
import type { FormDefinition, SavedForm } from '../../lib/forms/schema';
|
|
5
|
-
import FormBuilder from './FormBuilder';
|
|
5
|
+
import FormBuilder, { type SiteLanguage } from './FormBuilder';
|
|
6
6
|
import { Menu } from './Inspector';
|
|
7
7
|
import { definitionIssue } from './meta';
|
|
8
8
|
|
|
9
9
|
type SaveState = 'saved' | 'dirty' | 'saving' | 'error' | 'invalid' | 'conflict';
|
|
10
|
-
type Props = { form: Pick<SavedForm, 'id' | 'name' | 'kind' | 'purpose' | 'status' | 'revision'> & { hasDraft: boolean }; definition: FormDefinition; responses?: number
|
|
10
|
+
type Props = { form: Pick<SavedForm, 'id' | 'name' | 'kind' | 'purpose' | 'status' | 'revision'> & { hasDraft: boolean }; definition: FormDefinition; responses?: number;
|
|
11
|
+
/** the site's other languages and its main one (none: no language switcher) */
|
|
12
|
+
languages?: SiteLanguage[]; mainLanguage?: SiteLanguage;
|
|
13
|
+
};
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* The Build page island: the builder plus autosave (1.2s after the last
|
|
14
17
|
* change), Publish, and the header actions (portaled into FormHeader's slot).
|
|
15
18
|
*/
|
|
16
|
-
export default function FormBuildPage({ form, definition, responses = 0 }: Props) {
|
|
19
|
+
export default function FormBuildPage({ form, definition, responses = 0, languages = [], mainLanguage }: Props) {
|
|
17
20
|
const [value, setValue] = useState(definition);
|
|
18
21
|
const [name, setName] = useState(form.name);
|
|
19
22
|
const [status, setStatus] = useState(form.status);
|
|
@@ -30,6 +33,8 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
30
33
|
const inflight = useRef<Promise<boolean> | null>(null);
|
|
31
34
|
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
32
35
|
const template = form.kind === 'template';
|
|
36
|
+
// language names for "In Español, …" issues (fixed for the page's life)
|
|
37
|
+
const names = useRef(Object.fromEntries(languages.map(l => [l.code, l.name]))).current;
|
|
33
38
|
|
|
34
39
|
useEffect(() => { setSlot(document.getElementById('fb-actions')); }, []);
|
|
35
40
|
|
|
@@ -38,7 +43,7 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
38
43
|
while (inflight.current) await inflight.current;
|
|
39
44
|
const snapshot = latest.current, json = JSON.stringify(snapshot);
|
|
40
45
|
if (op === 'save' && json === saved.current) { setState('saved'); return true; }
|
|
41
|
-
const issue = definitionIssue(snapshot.value) || (!snapshot.name.trim() ? 'The form needs a name.' : '');
|
|
46
|
+
const issue = definitionIssue(snapshot.value, names) || (!snapshot.name.trim() ? 'The form needs a name.' : '');
|
|
42
47
|
if (issue) { setState('invalid'); setMessage(issue); return false; }
|
|
43
48
|
setState('saving');
|
|
44
49
|
const run = (async () => {
|
|
@@ -138,7 +143,8 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
138
143
|
? form.purpose === 'general' && <button type="button" className="btn primary" disabled={busy || state === 'conflict'} onClick={useTemplate}>Use template</button>
|
|
139
144
|
: <button type="button" className="btn primary" disabled={busy || !canPublish} onClick={publish}>{live ? 'Publish changes' : 'Publish'}</button>}
|
|
140
145
|
<Menu items={[
|
|
141
|
-
...(!template && live ? [{ label: 'Open public form ↗', run: () => window.open(`/forms/${form.id}`, '_blank', 'noopener') }
|
|
146
|
+
...(!template && live ? [{ label: 'Open public form ↗', run: () => window.open(`/forms/${form.id}`, '_blank', 'noopener') },
|
|
147
|
+
...languages.map(l => ({ label: `Open in ${l.name} ↗`, run: () => window.open(`/${l.code}/forms/${form.id}`, '_blank', 'noopener') }))] : []),
|
|
142
148
|
{ label: template ? 'Duplicate template' : 'Save as new template', disabled: busy, run: saveAsTemplate },
|
|
143
149
|
...(status !== 'archived' ? [{ label: template ? 'Archive template' : 'Archive form', danger: true, run: archive }] : []),
|
|
144
150
|
]} />
|
|
@@ -149,7 +155,7 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
149
155
|
<>
|
|
150
156
|
{slot && createPortal(headerActions, slot)}
|
|
151
157
|
<FormBuilder value={value} onChange={setValue} mode={form.purpose === 'event' ? 'event' : 'standalone'} name={name} onNameChange={setName}
|
|
152
|
-
responses={responses} preview={preview} onPreviewChange={setPreview} />
|
|
158
|
+
responses={responses} preview={preview} onPreviewChange={setPreview} languages={languages} mainLanguage={mainLanguage} />
|
|
153
159
|
</>
|
|
154
160
|
);
|
|
155
161
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { arrayMove } from '@dnd-kit/sortable';
|
|
3
|
-
import { starter, type FormDefinition, type FormField } from '../../lib/forms/schema';
|
|
3
|
+
import { starter, type FieldWords, type FormDefinition, type FormField, type FormWords } from '../../lib/forms/schema';
|
|
4
|
+
import { choicesIn, localizeForm } from '../../lib/forms/localize';
|
|
5
|
+
import { formStrings } from '../../lib/forms/words';
|
|
6
|
+
import { rsvpStrings } from '../../lib/calendar/rsvp-words';
|
|
4
7
|
import { Canvas, Preview } from './Canvas';
|
|
5
8
|
import { Inspector } from './Inspector';
|
|
6
9
|
import { InsertMenu } from './InsertMenu';
|
|
7
10
|
import { Outline } from './Outline';
|
|
8
|
-
import { DEFAULT_EVENT_SYSTEM, META, clean, keyFrom, newField, switchType, type FieldType, type Selection, type SystemField } from './meta';
|
|
11
|
+
import { DEFAULT_EVENT_SYSTEM, META, clean, keyFrom, newField, rememberChoices, switchType, withFieldWords, withFormWords, type FieldType, type Language, type RememberedChoices, type Selection, type SystemField } from './meta';
|
|
9
12
|
import { useFormHistory } from './useFormHistory';
|
|
10
13
|
|
|
14
|
+
/** A site language: its code and its name in itself ("Español"). */
|
|
15
|
+
export type SiteLanguage = { code: string; name: string };
|
|
16
|
+
const ENGLISH: SiteLanguage = { code: 'en', name: 'English' };
|
|
17
|
+
|
|
11
18
|
export type FormBuilderProps = {
|
|
12
19
|
value: FormDefinition;
|
|
13
20
|
onChange: (v: FormDefinition) => void;
|
|
@@ -24,6 +31,9 @@ export type FormBuilderProps = {
|
|
|
24
31
|
/** Controlled preview (the Build page drives it from its header). */
|
|
25
32
|
preview?: boolean;
|
|
26
33
|
onPreviewChange?: (on: boolean) => void;
|
|
34
|
+
/** The site's other languages (lib/i18n.ts extraLocales): a switcher to write the form's words in each. None: no switcher. */
|
|
35
|
+
languages?: SiteLanguage[];
|
|
36
|
+
mainLanguage?: SiteLanguage;
|
|
27
37
|
};
|
|
28
38
|
type Layout = 'wide' | 'mid' | 'phone';
|
|
29
39
|
|
|
@@ -32,12 +42,19 @@ type Layout = 'wide' | 'mid' | 'phone';
|
|
|
32
42
|
* canvas + inspector (outline in a drawer) under 1100px of its own width, and
|
|
33
43
|
* to a question list with a bottom-sheet inspector under 760px.
|
|
34
44
|
*/
|
|
35
|
-
export default function FormBuilder({ value, onChange, event = false, mode, systemFields, name, onNameChange, responses = 0, preview: previewProp, onPreviewChange }: FormBuilderProps) {
|
|
45
|
+
export default function FormBuilder({ value, onChange, event = false, mode, systemFields, name, onNameChange, responses = 0, preview: previewProp, onPreviewChange, languages = [], mainLanguage }: FormBuilderProps) {
|
|
36
46
|
const isEvent = mode ? mode === 'event' : event;
|
|
37
47
|
const system = systemFields ?? (isEvent ? DEFAULT_EVENT_SYSTEM : []);
|
|
38
48
|
const history = useFormHistory(value, onChange);
|
|
39
49
|
const { commit } = history;
|
|
40
50
|
const valueRef = useRef(value); valueRef.current = value;
|
|
51
|
+
// '' = the main language; another one writes only that language's words
|
|
52
|
+
// (the questions, their choices and settings stay shared)
|
|
53
|
+
const [langChoice, setLang] = useState('');
|
|
54
|
+
const main = mainLanguage ?? ENGLISH;
|
|
55
|
+
const current = languages.find(l => l.code === langChoice);
|
|
56
|
+
const lang = current?.code ?? '';
|
|
57
|
+
const language: Language = { code: lang, name: current?.name ?? main.name, main: main.name, names: Object.fromEntries([main, ...languages].map(l => [l.code, l.name])) };
|
|
41
58
|
const [selection, setSelection] = useState<Selection>(null);
|
|
42
59
|
const [inserting, setInserting] = useState<{ after: string | null } | null>(null);
|
|
43
60
|
const [drawer, setDrawer] = useState(false);
|
|
@@ -52,7 +69,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
52
69
|
// Keys of questions that already existed when there were responses stay fixed; label edits keep new questions' keys in sync until a key is typed by hand.
|
|
53
70
|
const lockedKeys = useMemo(() => new Set(responses > 0 ? value.fields.map(f => f.id) : []), [responses]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
54
71
|
const autoKeys = useRef(new Set<string>());
|
|
55
|
-
const optionMemory = useRef(new Map<string,
|
|
72
|
+
const optionMemory = useRef(new Map<string, RememberedChoices>());
|
|
56
73
|
|
|
57
74
|
useLayoutEffect(() => {
|
|
58
75
|
const el = rootRef.current; if (!el) return;
|
|
@@ -72,6 +89,13 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
72
89
|
const patchField = useCallback((id: string, next: Partial<FormField>, key: string) => {
|
|
73
90
|
setFields(valueRef.current.fields.map(f => f.id === id ? clean({ ...f, ...next }) : f), key);
|
|
74
91
|
}, [setFields]);
|
|
92
|
+
// the words of the language being written
|
|
93
|
+
const patchWords = useCallback((next: FormWords, key: string) => {
|
|
94
|
+
if (lang) commit({ ...valueRef.current, ...withFormWords(valueRef.current, lang, next) }, key);
|
|
95
|
+
}, [commit, lang]);
|
|
96
|
+
const patchFieldWords = useCallback((id: string, next: FieldWords, key: string) => {
|
|
97
|
+
if (lang) setFields(valueRef.current.fields.map(f => f.id === id ? withFieldWords(f, lang, next) : f), key);
|
|
98
|
+
}, [setFields, lang]);
|
|
75
99
|
const select = (s: Selection) => { setSelection(s); setDrawer(false); if (layout === 'phone' && s) setSheet(true); };
|
|
76
100
|
|
|
77
101
|
// Focus after React has rendered the moved/new question.
|
|
@@ -128,7 +152,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
128
152
|
};
|
|
129
153
|
const setType = (id: string, type: FieldType) => {
|
|
130
154
|
const f = valueRef.current.fields.find(x => x.id === id); if (!f) return;
|
|
131
|
-
if (f.options.length) optionMemory.current.set(id, f
|
|
155
|
+
if (f.options.length) optionMemory.current.set(id, rememberChoices(f));
|
|
132
156
|
setFields(valueRef.current.fields.map(x => x.id === id ? switchType(x, type, optionMemory.current.get(id)) : x));
|
|
133
157
|
};
|
|
134
158
|
const onLabelDone = (id: string) => {
|
|
@@ -183,23 +207,46 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
183
207
|
return () => document.removeEventListener('keydown', onDoc);
|
|
184
208
|
}, []);
|
|
185
209
|
const openInsert = (after: string | null = selection?.kind === 'field' ? selection.id : null) => { setDrawer(false); setInserting({ after }); };
|
|
210
|
+
// another language: the list and preview show the form as its visitors see
|
|
211
|
+
// it; the canvas holds its own words ('' where none yet) over the main ones
|
|
212
|
+
const shown = lang ? localizeForm(value, lang) : value;
|
|
213
|
+
const t = (lang && value.translations?.[lang]) || {};
|
|
214
|
+
const words = lang ? {
|
|
215
|
+
...value, title: t.title ?? '', intro: t.intro ?? '', submitLabel: t.submitLabel ?? '', confirmation: t.confirmation ?? '',
|
|
216
|
+
fields: value.fields.map((f) => {
|
|
217
|
+
const w = f.translations?.[lang];
|
|
218
|
+
return { ...f, label: w?.label ?? '', help: w?.help ?? '', options: choicesIn(f, lang), placeholder: w?.placeholder?.trim() || f.placeholder };
|
|
219
|
+
}),
|
|
220
|
+
} : value;
|
|
221
|
+
const canvasEdits = lang ? {
|
|
222
|
+
patch: (next: Partial<FormDefinition>, key: string) => patchWords(next, `${key}:${lang}`),
|
|
223
|
+
patchField: (id: string, next: Partial<FormField>, key: string) => patchFieldWords(id, { ...(next.label !== undefined ? { label: next.label } : {}), ...(next.help !== undefined ? { help: next.help } : {}) }, `${key}:${lang}`),
|
|
224
|
+
onLabelDone: () => {},
|
|
225
|
+
source: value, words: formStrings(lang), submitFallback: isEvent ? rsvpStrings(lang).confirmSeat : formStrings(lang).submit,
|
|
226
|
+
} : { patch, patchField, onLabelDone };
|
|
227
|
+
const langSwitch = languages.length > 0 && (
|
|
228
|
+
<div className="fb-seg fb-langs" role="group" aria-label="Language">
|
|
229
|
+
{[main, ...languages].map(l => <button key={l.code} type="button" aria-pressed={l.code === main.code ? !lang : lang === l.code} onClick={() => setLang(l.code === main.code ? '' : l.code)}>{l.name}</button>)}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
186
232
|
const outline = (
|
|
187
|
-
<Outline fields={fields} system={system} selection={selection} onSelect={s => {
|
|
233
|
+
<Outline fields={shown.fields} system={system} selection={selection} onSelect={s => {
|
|
188
234
|
select(s);
|
|
189
235
|
// Picking from the list brings the question into view on the canvas.
|
|
190
236
|
if (s?.kind === 'field' && layout !== 'phone') requestAnimationFrame(() => rootRef.current?.querySelector(`[data-item="${CSS.escape(s.id)}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
|
191
237
|
}} phone={layout === 'phone'}
|
|
192
238
|
onReorder={(from, to) => setFields(arrayMove(valueRef.current.fields, from, to))} onAdd={() => openInsert()} actions={actions}
|
|
193
|
-
labels={{ title:
|
|
239
|
+
labels={{ title: shown.title, submit: shown.submitLabel || (isEvent ? 'Confirm my seat' : 'Submit') }} />
|
|
194
240
|
);
|
|
195
241
|
const inspector = (
|
|
196
242
|
<Inspector value={value} selection={selection} system={system} event={isEvent} phone={layout === 'phone'} name={name} onNameChange={onNameChange}
|
|
197
|
-
lockedKeys={lockedKeys} patch={patch} patchField={patchField} setType={setType} onLabelDone={onLabelDone} setKey={setKey} actions={actions} onAdd={() => openInsert()}
|
|
243
|
+
lockedKeys={lockedKeys} patch={patch} patchField={patchField} setType={setType} onLabelDone={onLabelDone} setKey={setKey} actions={actions} onAdd={() => openInsert()}
|
|
244
|
+
language={language} patchWords={patchWords} patchFieldWords={patchFieldWords} />
|
|
198
245
|
);
|
|
199
246
|
const canvas = preview
|
|
200
|
-
? <Preview value={
|
|
201
|
-
: <Canvas value={
|
|
202
|
-
|
|
247
|
+
? <Preview value={shown} system={system} event={isEvent} device={layout === 'phone' ? 'phone' : device} lang={lang || main.code} />
|
|
248
|
+
: <Canvas value={words} system={system} selection={selection} event={isEvent} onSelect={select} {...canvasEdits}
|
|
249
|
+
onInsertAfter={after => openInsert(after)} onDuplicate={actions.duplicate} onRemove={actions.remove} />;
|
|
203
250
|
|
|
204
251
|
const undoRedo = <>
|
|
205
252
|
<button type="button" className="fb-icon" aria-label="Undo" title="Undo (⌘Z)" disabled={!history.canUndo} onClick={history.undo}>
|
|
@@ -225,6 +272,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
225
272
|
<button type="button" aria-pressed={preview} onClick={() => setPreview(true)}>Preview</button>
|
|
226
273
|
</div>
|
|
227
274
|
)}
|
|
275
|
+
{langSwitch}
|
|
228
276
|
<span className="fb-bar-sp" />
|
|
229
277
|
{preview && layout !== 'phone' && (
|
|
230
278
|
<div className="fb-seg" role="group" aria-label="Preview width">
|
|
@@ -249,6 +297,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
249
297
|
</>)}
|
|
250
298
|
<div className="fb-canvas" onClick={e => { if (e.target === e.currentTarget) setSelection(null); }}>
|
|
251
299
|
{!showBar && <div className="fb-float">{undoRedo}</div>}
|
|
300
|
+
{!showBar && langSwitch && <div className="fb-float fb-float-l">{langSwitch}</div>}
|
|
252
301
|
{canvas}
|
|
253
302
|
</div>
|
|
254
303
|
{!preview && <aside className="fb-insp" aria-label="Question settings">{inspector}</aside>}
|