diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3dc11c1b3..5e6400f3e 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1476,5 +1476,91 @@ "Test event delivered to {{name}}": "Test event delivered to {{name}}", "Updated SIEM destination": "Updated SIEM destination", "Verify TLS certificate": "Verify TLS certificate", - "e.g. Splunk prod": "e.g. Splunk prod" + "e.g. Splunk prod": "e.g. Splunk prod", + "Add a property": "Add a property", + "Add all properties": "Add all properties", + "Add description": "Add description", + "Add question": "Add question", + "Add your first question": "Add your first question", + "After submitting": "After submitting", + "Allow submitting another response": "Allow submitting another response", + "Anyone can respond. Responses are anonymous.": "Anyone can respond. Responses are anonymous.", + "Anyone with the link": "Anyone with the link", + "Back to form": "Back to form", + "Choose who can submit responses to this form.": "Choose who can submit responses to this form.", + "Closed": "Closed", + "Confirmation message": "Confirmation message", + "Confirmation title": "Confirmation title", + "Drag to reorder": "Drag to reorder", + "Each question fills a property of this base, and every response becomes a new row.": "Each question fills a property of this base, and every response becomes a new row.", + "Enter a valid date": "Enter a valid date", + "Enter a valid email address": "Enter a valid email address", + "Enter a valid link starting with http:// or https://": "Enter a valid link starting with http:// or https://", + "Enter a valid number": "Enter a valid number", + "Exit preview": "Exit preview", + "Failed to update form sharing": "Failed to update form sharing", + "Form": "Form", + "Form description": "Form description", + "Form link": "Form link", + "Form not found": "Form not found", + "Form settings": "Form settings", + "Form title": "Form title", + "Insert a form that collects responses into a base": "Insert a form that collects responses into a base", + "It's no longer accepting responses.": "It's no longer accepting responses.", + "Members sign in to respond. Their name is recorded with each response.": "Members sign in to respond. Their name is recorded with each response.", + "Move down": "Move down", + "Move up": "Move up", + "Never submit passwords or other sensitive information through this form.": "Never submit passwords or other sensitive information through this form.", + "New property": "New property", + "No options found": "No options found", + "Nobody can submit responses.": "Nobody can submit responses.", + "Not available in forms": "Not available in forms", + "Only members of {{workspace}} can respond to this form.": "Only members of {{workspace}} can respond to this form.", + "Only workspace members can respond to this form.": "Only workspace members can respond to this form.", + "Open form": "Open form", + "Public forms are available on paid plans.": "Public forms are available on paid plans.", + "Public sharing is disabled for this workspace or space, so this form isn't accepting responses.": "Public sharing is disabled for this workspace or space, so this form isn't accepting responses.", + "Public sharing is disabled for this workspace or space.": "Public sharing is disabled for this workspace or space.", + "Question": "Question", + "Question actions": "Question actions", + "Question description": "Question description", + "Remove from form": "Remove from form", + "Required": "Required", + "Responding as {{name}}": "Responding as {{name}}", + "Select an option": "Select an option", + "Select one of the available options": "Select one of the available options", + "Select options": "Select options", + "Share form": "Share form", + "Sign in to fill out this form": "Sign in to fill out this form", + "Something went wrong": "Something went wrong", + "Submit": "Submit", + "Submit another response": "Submit another response", + "Submit button label": "Submit button label", + "Thank you!": "Thank you!", + "This answer is too long": "This answer is too long", + "This answer isn't valid": "This answer isn't valid", + "This form doesn't exist or is no longer available.": "This form doesn't exist or is no longer available.", + "This form doesn't have any questions yet.": "This form doesn't have any questions yet.", + "This form has changed. Reload the page to see the latest version.": "This form has changed. Reload the page to see the latest version.", + "This form is closed": "This form is closed", + "This form isn't accepting responses.": "This form isn't accepting responses.", + "This property is being converted. The question will return when it's done.": "This property is being converted. The question will return when it's done.", + "This property type can't be used in forms. Respondents won't see this question.": "This property type can't be used in forms. Respondents won't see this question.", + "This question is required": "This question is required", + "Untitled form": "Untitled form", + "Upgrade plan": "Upgrade plan", + "We couldn't load this form.": "We couldn't load this form.", + "Workspace members": "Workspace members", + "Yes": "Yes", + "You're sending responses too quickly. Wait a moment and try again.": "You're sending responses too quickly. Wait a moment and try again.", + "Your answer": "Your answer", + "Your response has been recorded.": "Your response has been recorded.", + "Failed to update form": "Failed to update form", + "Clear form": "Clear form", + "Search people": "Search people", + "Search for a person": "Search for a person", + "No people found": "No people found", + "Only shown to signed-in workspace members": "Only shown to signed-in workspace members", + "Select people from this workspace": "Select people from this workspace", + "You're previewing this form. Submitted responses are saved.": "You're previewing this form. Submitted responses are saved." } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index e4238a0a6..f5f58da91 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -54,6 +54,7 @@ const PublicSpaceDirectoryPage = lazy( () => import("@/pages/public-space/public-space-directory-page.tsx"), ); const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); +const PublicFormPage = lazy(() => import("@/ee/base/pages/public-form-page.tsx")); const MfaChallengePage = lazy(() => import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({ default: m.MfaChallengePage, @@ -137,6 +138,8 @@ export default function App() { /> + } /> + } /> } /> } /> diff --git a/apps/client/src/ee/base/atoms/base-atoms.ts b/apps/client/src/ee/base/atoms/base-atoms.ts index 9404e0410..e824739cc 100644 --- a/apps/client/src/ee/base/atoms/base-atoms.ts +++ b/apps/client/src/ee/base/atoms/base-atoms.ts @@ -53,3 +53,7 @@ export type PendingTypeInsert = { } | null; export const pendingTypeInsertAtom = atom(null); + +export const formPreviewAtomFamily = atomFamily((_viewId: string) => + atom(false), +); diff --git a/apps/client/src/ee/base/components/base-toolbar.tsx b/apps/client/src/ee/base/components/base-toolbar.tsx index 213ce5874..adcebe07a 100644 --- a/apps/client/src/ee/base/components/base-toolbar.tsx +++ b/apps/client/src/ee/base/components/base-toolbar.tsx @@ -1,10 +1,12 @@ import { useState, useCallback, useMemo } from "react"; -import { ActionIcon, Tooltip, Badge } from "@mantine/core"; +import { useAtom } from "jotai"; +import { ActionIcon, Button, Tooltip, Badge } from "@mantine/core"; import { Table } from "@tanstack/react-table"; import { IconSortAscending, IconFilter, IconEye, + IconEyeOff, IconDownload, IconArrowsDiagonal, IconLayoutColumns, @@ -27,6 +29,10 @@ import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter- import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility"; import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker"; import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties"; +import { FormSettingsPopover } from "@/ee/base/components/form/form-settings-popover"; +import { FormSharePopover } from "@/ee/base/components/form/form-share-popover"; +import { formPreviewAtomFamily } from "@/ee/base/atoms/base-atoms"; +import { useBaseEditable } from "@/ee/base/context/base-editable"; import { useTranslation } from "react-i18next"; import classes from "@/ee/base/styles/grid.module.css"; import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css"; @@ -68,6 +74,11 @@ export function BaseToolbar({ const [exporting, setExporting] = useState(false); const isKanban = activeView?.type === "kanban"; + const isForm = activeView?.type === "form"; + const editable = useBaseEditable(); + const [formPreview, setFormPreview] = useAtom( + formPreviewAtomFamily(activeView?.id ?? ""), + ); const handleExport = useCallback(async () => { if (exporting) return; @@ -149,34 +160,59 @@ export function BaseToolbar({ - setFilterOpened(false)} - conditions={conditions} - properties={base.properties} - onChange={handleFiltersChange} - > - - 0 ? "blue" : "gray"} - onClick={() => openToolbar("filter")} + {!isForm && ( + setFilterOpened(false)} + conditions={conditions} + properties={base.properties} + onChange={handleFiltersChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("filter")} + > + + {conditions.length > 0 && ( + + {conditions.length} + + )} + + + + )} + + {isForm && activeView && editable && ( + <> + + + + + )} {isKanban && activeView && ( <> @@ -213,7 +249,7 @@ export function BaseToolbar({ )} - {!isKanban && ( + {!isKanban && !isForm && ( <> ; } if (baseError) { @@ -415,7 +418,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV /> ); - const kanbanBand = ( + const band = (
{embedded ? null : titleSlot} {banner} @@ -449,12 +452,32 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV /> ); + if (isForm) { + return ( + + {embedded ? ( + <> + {band} + {viewRenderer(null)} + + ) : ( +
+
+ {band} + {viewRenderer(null)} +
+
+ )} +
+ ); + } + if (embedded) { if (isKanban) { return ( - {kanbanBand} + {band} {viewRenderer(null)}
- {kanbanBand} + {band} {viewRenderer(null)}
diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx b/apps/client/src/ee/base/components/cells/cell-system-user.tsx similarity index 91% rename from apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx rename to apps/client/src/ee/base/components/cells/cell-system-user.tsx index b6dc90c52..1cfed0bdd 100644 --- a/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx +++ b/apps/client/src/ee/base/components/cells/cell-system-user.tsx @@ -4,7 +4,7 @@ import { useReferenceStore } from "@/ee/base/reference/reference-store"; import { CustomAvatar } from "@/components/ui/custom-avatar"; import cellClasses from "@/ee/base/styles/cells.module.css"; -type CellLastEditedByProps = { +type CellSystemUserProps = { value: unknown; property: IBaseProperty; rowId: string; @@ -13,7 +13,7 @@ type CellLastEditedByProps = { onCancel: () => void; }; -export function CellLastEditedBy({ value, property }: CellLastEditedByProps) { +export function CellSystemUser({ value, property }: CellSystemUserProps) { const userId = typeof value === "string" ? value : null; const store = useReferenceStore(property.pageId); diff --git a/apps/client/src/ee/base/components/form/base-form.tsx b/apps/client/src/ee/base/components/form/base-form.tsx new file mode 100644 index 000000000..138e66d04 --- /dev/null +++ b/apps/client/src/ee/base/components/form/base-form.tsx @@ -0,0 +1,75 @@ +import { useEffect, useMemo } from "react"; +import { useAtom } from "jotai"; +import { IBase, IBaseView } from "@/ee/base/types/base.types"; +import { formPreviewAtomFamily } from "@/ee/base/atoms/base-atoms"; +import { useSubmitFormPreviewMutation } from "@/ee/base/queries/base-form-query"; +import { useReferenceStore } from "@/ee/base/reference/reference-store"; +import { usePageQuery } from "@/features/page/queries/page-query"; +import { buildFormDefinition } from "@/ee/base/components/form/form-definition"; +import { FormBuilder } from "@/ee/base/components/form/form-builder"; +import { FormFill } from "@/ee/base/components/form/form-fill"; +import { FormRespond } from "@/ee/base/components/form/form-respond"; +import classes from "@/ee/base/styles/form.module.css"; + +type BaseFormProps = { + base: IBase; + view: IBaseView; + pageId: string; + editable: boolean; + embedded?: boolean; +}; + +export function BaseForm({ + base, + view, + pageId, + editable, + embedded, +}: BaseFormProps) { + const [preview, setPreview] = useAtom(formPreviewAtomFamily(view.id)); + const { users } = useReferenceStore(pageId); + const { data: page } = usePageQuery({ pageId }); + const submitPreview = useSubmitFormPreviewMutation(); + + useEffect(() => () => setPreview(false), [setPreview]); + + const definition = useMemo( + () => buildFormDefinition(view.config?.form, base.properties, page?.title), + [view.config?.form, base.properties, page?.title], + ); + + let content; + if (!editable) { + content = ( + + ); + } else if (preview) { + content = ( + + submitPreview.mutateAsync({ pageId, viewId: view.id, answers }) + } + onExitPreview={() => setPreview(false)} + /> + ); + } else { + content = ( + + ); + } + + return ( +
+ {content} +
+ ); +} diff --git a/apps/client/src/ee/base/components/form/form-add-question.tsx b/apps/client/src/ee/base/components/form/form-add-question.tsx new file mode 100644 index 000000000..1b88d4bd0 --- /dev/null +++ b/apps/client/src/ee/base/components/form/form-add-question.tsx @@ -0,0 +1,129 @@ +import { useRef } from "react"; +import { Button, Menu, ScrollArea } from "@mantine/core"; +import { IconListCheck, IconPlus } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + BasePropertyType, + FormFieldConfig, + IBase, +} from "@/ee/base/types/base.types"; +import { + getDescriptor, + PROPERTY_PICKER_ORDER, +} from "@/ee/base/property-types/property-type.registry"; +import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover"; +import { + FORM_FIELD_TYPES, + MAX_FORM_FIELDS, + isFormFieldProperty, +} from "@/ee/base/components/form/form-definition"; + +const NON_FORM_PROPERTY_TYPES = new Set( + PROPERTY_PICKER_ORDER.filter((type) => !FORM_FIELD_TYPES.has(type)), +); + +type FormAddQuestionProps = { + base: IBase; + fields: FormFieldConfig[]; + pageId: string; + onAdd: (propertyIds: string[]) => void; +}; + +export function FormAddQuestion({ + base, + fields, + pageId, + onAdd, +}: FormAddQuestionProps) { + const { t } = useTranslation(); + const anchorRef = useRef(null); + const used = new Set(fields.map((field) => field.propertyId)); + const unused = base.properties.filter((p) => !used.has(p.id)); + const available = unused.filter(isFormFieldProperty); + const unavailable = unused.filter((p) => !isFormFieldProperty(p)); + const remaining = MAX_FORM_FIELDS - fields.length; + + return ( + onAdd([property.id])} + renderTarget={(openCreate) => ( +
+ + + + + + + {available.length > 0 && ( + {t("Add a property")} + )} + {available.map((property) => { + const Icon = getDescriptor(property.type)?.icon; + return ( + : undefined} + onClick={() => onAdd([property.id])} + > + {property.name} + + ); + })} + {available.length > 1 && ( + } + onClick={() => + onAdd(available.slice(0, remaining).map((p) => p.id)) + } + > + {t("Add all properties")} + + )} + {unavailable.length > 0 && ( + <> + {t("Not available in forms")} + {unavailable.map((property) => { + const Icon = getDescriptor(property.type)?.icon; + return ( + : undefined} + disabled + > + {property.name} + + ); + })} + + )} + + {unused.length > 0 && } + } + onClick={() => openCreate(anchorRef.current ?? undefined)} + > + {t("New property")} + + + +
+ )} + /> + ); +} diff --git a/apps/client/src/ee/base/components/form/form-builder-question.tsx b/apps/client/src/ee/base/components/form/form-builder-question.tsx new file mode 100644 index 000000000..848be80d3 --- /dev/null +++ b/apps/client/src/ee/base/components/form/form-builder-question.tsx @@ -0,0 +1,404 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + ActionIcon, + Menu, + Popover, + Switch, + UnstyledButton, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconUsers, + IconArrowDown, + IconArrowUp, + IconChevronDown, + IconDots, + IconGripVertical, + IconTextPlus, + IconTrash, +} from "@tabler/icons-react"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + attachClosestEdge, + extractClosestEdge, + type Edge, +} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { + FormFieldConfig, + IBaseProperty, + UserRef, +} from "@/ee/base/types/base.types"; +import { getDescriptor } from "@/ee/base/property-types/property-type.registry"; +import { PropertyMenuContent } from "@/ee/base/components/property/property-menu"; +import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator"; +import { + propertyMenuCloseRequestAtomFamily, + propertyMenuDirtyAtomFamily, +} from "@/ee/base/atoms/base-atoms"; +import { FormInlineTextarea } from "@/ee/base/components/form/form-inline-textarea"; +import { FormFieldControl } from "@/ee/base/components/form/form-field-control"; +import { FormRequiredMark } from "@/ee/base/components/form/form-required-mark"; +import { + initialAnswer, + isFormFieldProperty, + isMemberOnlyField, + toFormField, +} from "@/ee/base/components/form/form-definition"; +import classes from "@/ee/base/styles/form.module.css"; + +const FORM_QUESTION_DRAG_TYPE = "base-form-question"; + +const noop = () => {}; + +type FormBuilderQuestionProps = { + field: FormFieldConfig; + property: IBaseProperty; + pageId: string; + users: Record; + isFirst: boolean; + isLast: boolean; + autoFocus: boolean; + onAutoFocused: () => void; + onChange: (patch: Partial) => void; + onRemove: () => void; + onMove: (offset: -1 | 1) => void; + onReorder: (activeId: string, targetId: string, edge: Edge) => void; +}; + +export function FormBuilderQuestion({ + field, + property, + pageId, + users, + isFirst, + isLast, + autoFocus, + onAutoFocused, + onChange, + onRemove, + onMove, + onReorder, +}: FormBuilderQuestionProps) { + const { t } = useTranslation(); + const rowRef = useRef(null); + const handleRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [closestEdge, setClosestEdge] = useState(null); + const [addingDescription, setAddingDescription] = useState(false); + const [propertyMenuOpen, setPropertyMenuOpen] = useState(false); + const [actionsMenuOpen, setActionsMenuOpen] = useState(false); + + const onReorderRef = useRef(onReorder); + useLayoutEffect(() => { + onReorderRef.current = onReorder; + }); + + useEffect(() => { + const row = rowRef.current; + const handle = handleRef.current; + if (!row || !handle) return; + return combine( + draggable({ + element: handle, + getInitialData: () => ({ + type: FORM_QUESTION_DRAG_TYPE, + propertyId: property.id, + }), + onGenerateDragPreview: ({ nativeSetDragImage }) => + nativeSetDragImage?.(row, 0, 0), + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + dropTargetForElements({ + element: row, + canDrop: ({ source }) => + source.data.type === FORM_QUESTION_DRAG_TYPE && + source.data.propertyId !== property.id, + getData: ({ input, element }) => + attachClosestEdge( + { propertyId: property.id }, + { input, element, allowedEdges: ["top", "bottom"] }, + ), + onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), + onDragLeave: () => setClosestEdge(null), + onDrop: ({ source, self }) => { + setClosestEdge(null); + const edge = extractClosestEdge(self.data); + if (!edge) return; + onReorderRef.current( + source.data.propertyId as string, + property.id, + edge, + ); + }, + }), + ); + }, [property.id]); + + const formField = isFormFieldProperty(property) + ? toFormField(property, field) + : null; + const hasDescription = !!field.description?.trim(); + + useEffect(() => { + if (hasDescription) setAddingDescription(false); + }, [hasDescription]); + + return ( +
+ + +
+
+ + onChange({ + label: next && next !== property.name ? next : undefined, + }) + } + className={classes.inlineLabel} + ariaLabel={t("Question")} + placeholder={property.name} + maxLength={300} + singleLine + autoFocus={autoFocus} + onAutoFocused={onAutoFocused} + /> + {field.required && } +
+ + {(hasDescription || addingDescription) && ( + onChange({ description: next || undefined })} + className={classes.inlineHelp} + ariaLabel={t("Question description")} + placeholder={t("Add a description")} + maxLength={1000} + autoFocus={addingDescription && !hasDescription} + onBlur={(value) => { + if (!value) setAddingDescription(false); + }} + /> + )} + + {formField ? ( +
+ +
+ ) : ( +
+ + {property.pendingType + ? t( + "This property is being converted. The question will return when it's done.", + ) + : t( + "This property type can't be used in forms. Respondents won't see this question.", + )} +
+ )} + + {formField && isMemberOnlyField(formField) && ( +
+ + {t("Only shown to signed-in workspace members")} +
+ )} + +
+ + {!hasDescription && !addingDescription && ( + setAddingDescription(true)} + > + + + {t("Add description")} + + + )} + + onChange({ required: event.currentTarget.checked || undefined }) + } + /> + + + + + + + + } + disabled={isFirst} + onClick={() => onMove(-1)} + > + {t("Move up")} + + } + disabled={isLast} + onClick={() => onMove(1)} + > + {t("Move down")} + + + } + onClick={onRemove} + > + {t("Remove from form")} + + + +
+
+ + {closestEdge && } +
+ ); +} + +function FormPropertyChip({ + property, + pageId, + onOpenChange, +}: { + property: IBaseProperty; + pageId: string; + onOpenChange: (opened: boolean) => void; +}) { + const [opened, setOpened] = useState(false); + const [dirty, setDirty] = useAtom( + propertyMenuDirtyAtomFamily(pageId), + ) as unknown as [boolean, (val: boolean) => void]; + const [closeRequest, setCloseRequest] = useAtom( + propertyMenuCloseRequestAtomFamily(pageId), + ) as unknown as [number, (val: number) => void]; + + const handleClose = useCallback(() => setOpened(false), []); + + const wasOpenedRef = useRef(opened); + useEffect(() => { + if (wasOpenedRef.current && !opened) setDirty(false); + wasOpenedRef.current = opened; + onOpenChange(opened); + }, [opened, setDirty, onOpenChange]); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (next) return; + if (dirty) { + setCloseRequest(closeRequest + 1); + } else { + handleClose(); + } + }, + [dirty, closeRequest, setCloseRequest, handleClose], + ); + + const toggle = useCallback(() => { + if (opened) { + handleOpenChange(false); + } else if (!dirty) { + setOpened(true); + } + }, [opened, dirty, handleOpenChange]); + + const Icon = getDescriptor(property.type)?.icon; + const isChoice = + property.type === "select" || property.type === "multiSelect"; + + return ( + + + + {Icon && } + {property.name} + + + + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + + ); +} diff --git a/apps/client/src/ee/base/components/form/form-builder.tsx b/apps/client/src/ee/base/components/form/form-builder.tsx new file mode 100644 index 000000000..9f48b34a9 --- /dev/null +++ b/apps/client/src/ee/base/components/form/form-builder.tsx @@ -0,0 +1,203 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder"; +import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index"; +import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { + FormConfig, + FormFieldConfig, + IBase, + IBaseView, + UserRef, +} from "@/ee/base/types/base.types"; +import { useUpdateFormConfig } from "@/ee/base/queries/base-form-query"; +import { FormInlineTextarea } from "@/ee/base/components/form/form-inline-textarea"; +import { FormBuilderQuestion } from "@/ee/base/components/form/form-builder-question"; +import { FormAddQuestion } from "@/ee/base/components/form/form-add-question"; +import { FormSubmitButton } from "@/ee/base/components/form/form-submit-button"; +import classes from "@/ee/base/styles/form.module.css"; + +type FormBuilderProps = { + base: IBase; + view: IBaseView; + pageId: string; + pageTitle: string | undefined; + users: Record; +}; + +export function FormBuilder({ + base, + view, + pageId, + pageTitle, + users, +}: FormBuilderProps) { + const { t } = useTranslation(); + const updateForm = useUpdateFormConfig(pageId, view.id); + const [focusPropertyId, setFocusPropertyId] = useState(null); + + const form: FormConfig = view.config?.form ?? {}; + const fields = form.fields ?? []; + const propertiesById = useMemo( + () => new Map(base.properties.map((p) => [p.id, p])), + [base.properties], + ); + const visibleFields = fields.filter((field) => + propertiesById.has(field.propertyId), + ); + + const updateFields = useCallback( + (recipe: (current: FormFieldConfig[]) => FormFieldConfig[]) => + updateForm((current) => { + const previous = current.fields ?? []; + const next = recipe(previous); + return next === previous ? current : { ...current, fields: next }; + }), + [updateForm], + ); + + const handleAdd = useCallback( + (propertyIds: string[]) => { + updateFields((current) => { + const existing = new Set(current.map((f) => f.propertyId)); + const added = propertyIds + .filter((id) => !existing.has(id)) + .map((propertyId) => ({ propertyId })); + return added.length ? [...current, ...added] : current; + }); + if (propertyIds.length === 1) setFocusPropertyId(propertyIds[0]); + }, + [updateFields], + ); + + const handleChange = useCallback( + (propertyId: string, patch: Partial) => + updateFields((current) => + current.map((f) => + f.propertyId === propertyId ? { ...f, ...patch } : f, + ), + ), + [updateFields], + ); + + const handleRemove = useCallback( + (propertyId: string) => + updateFields((current) => + current.filter((f) => f.propertyId !== propertyId), + ), + [updateFields], + ); + + const handleMove = useCallback( + (propertyId: string, offset: -1 | 1) => + updateFields((current) => { + const startIndex = current.findIndex((f) => f.propertyId === propertyId); + const finishIndex = startIndex + offset; + if (startIndex === -1 || finishIndex < 0 || finishIndex >= current.length) { + return current; + } + return reorder({ list: current, startIndex, finishIndex }); + }), + [updateFields], + ); + + const handleReorder = useCallback( + (activeId: string, targetId: string, edge: Edge) => + updateFields((current) => { + const startIndex = current.findIndex((f) => f.propertyId === activeId); + const indexOfTarget = current.findIndex((f) => f.propertyId === targetId); + if (startIndex === -1 || indexOfTarget === -1) return current; + const finishIndex = getReorderDestinationIndex({ + startIndex, + indexOfTarget, + closestEdgeOfTarget: edge, + axis: "vertical", + }); + if (finishIndex === startIndex) return current; + return reorder({ list: current, startIndex, finishIndex }); + }), + [updateFields], + ); + + const clearFocus = useCallback(() => setFocusPropertyId(null), []); + + const addQuestion = ( + + ); + + return ( +
+
+ + updateForm((current) => ({ ...current, title: title || undefined })) + } + className={classes.inlineTitle} + ariaLabel={t("Form title")} + placeholder={pageTitle?.trim() || t("Untitled form")} + maxLength={300} + singleLine + /> + + updateForm((current) => ({ + ...current, + description: description || undefined, + })) + } + className={classes.inlineDescription} + ariaLabel={t("Form description")} + placeholder={t("Add a description")} + maxLength={5000} + /> +
+ + {visibleFields.length === 0 ? ( +
+ {t("Add your first question")} + + {t( + "Each question fills a property of this base, and every response becomes a new row.", + )} + + {addQuestion} +
+ ) : ( +
+ {visibleFields.map((field, index) => ( + handleChange(field.propertyId, patch)} + onRemove={() => handleRemove(field.propertyId)} + onMove={(offset) => handleMove(field.propertyId, offset)} + onReorder={handleReorder} + /> + ))} +
+ )} + +
+ {visibleFields.length > 0 && addQuestion} +
+ +
+
+
+ ); +} diff --git a/apps/client/src/ee/base/components/form/form-definition.ts b/apps/client/src/ee/base/components/form/form-definition.ts new file mode 100644 index 000000000..d7225fad1 --- /dev/null +++ b/apps/client/src/ee/base/components/form/form-definition.ts @@ -0,0 +1,357 @@ +import type { TFunction } from "i18next"; +import { + BasePropertyType, + FormConfig, + FormFieldConfig, + FormFieldError, + FormFieldType, + IBaseProperty, + NumberTypeOptions, + PersonTypeOptions, + PublicForm, + PublicFormChoice, + PublicFormField, + SelectTypeOptions, +} from "@/ee/base/types/base.types"; + +export type FormAnswers = Record; + +export const FORM_FIELD_TYPES: ReadonlySet = + new Set([ + "text", + "longText", + "number", + "select", + "multiSelect", + "date", + "checkbox", + "url", + "email", + "person", + ]); + +const MEMBER_ONLY_FIELD_TYPES: ReadonlySet = + new Set(["person"]); + +export const CHOICE_LIST_LIMIT = 10; + +export const MAX_FORM_FIELDS = 100; + +export const AUTOFILL_OPT_OUT = { + autoComplete: "off", + "data-1p-ignore": "true", + "data-lpignore": "true", + "data-bwignore": "true", + "data-form-type": "other", +} as const; + +const TEXT_MAX_LENGTH: Partial> = { + text: 1000, + longText: 25000, + url: 2048, + email: 320, +}; + +const ISO_DATE = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?)?$/; + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function isFormFieldProperty(property: IBaseProperty): boolean { + return FORM_FIELD_TYPES.has(property.type) && !property.pendingType; +} + +export function isMemberOnlyField(field: PublicFormField): boolean { + return MEMBER_ONLY_FIELD_TYPES.has(field.type); +} + +export function toFormField( + property: IBaseProperty, + config: FormFieldConfig, +): PublicFormField { + const type = property.type as FormFieldType; + const field: PublicFormField = { + propertyId: property.id, + type, + label: config.label?.trim() || property.name, + description: config.description?.trim() || null, + required: config.required === true, + defaultValue: resolveFieldDefault(property), + }; + + if (type === "select" || type === "multiSelect") { + field.choices = orderedChoices(property.typeOptions as SelectTypeOptions); + } + if (type === "number") { + const { format, precision, currencyCode, currencySymbol } = + (property.typeOptions ?? {}) as NumberTypeOptions; + field.numberFormat = { format, precision, currencyCode, currencySymbol }; + } + if (type === "person") { + field.allowMultiple = + (property.typeOptions as PersonTypeOptions)?.allowMultiple === true; + } + + return field; +} + +export function buildFormDefinition( + form: FormConfig | undefined, + properties: IBaseProperty[], + pageTitle: string | undefined, +): PublicForm { + const propertiesById = new Map(properties.map((p) => [p.id, p])); + const fields: PublicFormField[] = []; + for (const config of form?.fields ?? []) { + const property = propertiesById.get(config.propertyId); + if (property && isFormFieldProperty(property)) { + fields.push(toFormField(property, config)); + } + } + + return { + title: form?.title?.trim() || pageTitle?.trim() || null, + description: form?.description?.trim() || null, + submitLabel: form?.submitLabel?.trim() || null, + successTitle: form?.successTitle?.trim() || null, + successMessage: form?.successMessage?.trim() || null, + allowResubmit: form?.allowResubmit !== false, + access: "public", + fields, + }; +} + +function orderedChoices( + options: SelectTypeOptions | undefined, +): PublicFormChoice[] { + const choices = options?.choices ?? []; + const choicesById = new Map(choices.map((c) => [c.id, c])); + const ordered = new Map(); + for (const id of options?.choiceOrder ?? []) { + const choice = choicesById.get(id); + if (choice) ordered.set(id, choice); + } + for (const choice of choices) { + if (!ordered.has(choice.id)) ordered.set(choice.id, choice); + } + return Array.from(ordered.values(), ({ id, name, color }) => ({ + id, + name, + color, + })); +} + +function resolveFieldDefault(property: IBaseProperty): unknown { + const options = (property.typeOptions ?? {}) as { + defaultValue?: unknown; + choices?: { id: string }[]; + }; + const value = options.defaultValue; + + if (property.type === "checkbox") return value === true; + if (value === undefined || value === null) return null; + + if (property.type === "select" || property.type === "multiSelect") { + const ids = new Set((options.choices ?? []).map((c) => c.id)); + if (property.type === "multiSelect") { + const live = (Array.isArray(value) ? value : [value]).filter( + (id): id is string => typeof id === "string" && ids.has(id), + ); + return live.length ? live : null; + } + return typeof value === "string" && ids.has(value) ? value : null; + } + + if (property.type === "number") { + return typeof value === "number" ? value : null; + } + + if (property.type === "person") { + const ids = (Array.isArray(value) ? value : [value]).filter( + (id): id is string => typeof id === "string", + ); + if (ids.length === 0) return null; + return (property.typeOptions as PersonTypeOptions)?.allowMultiple + ? ids + : ids[0]; + } + + return typeof value === "string" && value.trim() ? value : null; +} + +export function initialAnswer(field: PublicFormField): unknown { + switch (field.type) { + case "checkbox": + return field.defaultValue === true; + case "multiSelect": + return Array.isArray(field.defaultValue) ? field.defaultValue : []; + case "person": + if (field.allowMultiple) { + return Array.isArray(field.defaultValue) ? field.defaultValue : []; + } + return typeof field.defaultValue === "string" ? field.defaultValue : null; + case "select": + case "date": + return typeof field.defaultValue === "string" ? field.defaultValue : null; + case "number": + return typeof field.defaultValue === "number" ? field.defaultValue : ""; + default: + return typeof field.defaultValue === "string" ? field.defaultValue : ""; + } +} + +export function buildInitialAnswers(fields: PublicFormField[]): FormAnswers { + return Object.fromEntries( + fields.map((field) => [field.propertyId, initialAnswer(field)]), + ); +} + +const INVALID = Symbol("invalid"); + +function normalizeAnswer(field: PublicFormField, raw: unknown): unknown { + if (field.type === "checkbox") return raw === true; + if (raw === undefined || raw === null) return undefined; + + if (field.type === "number") { + if (typeof raw === "number") return Number.isFinite(raw) ? raw : INVALID; + if (typeof raw !== "string") return INVALID; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : INVALID; + } + + if (typeof raw === "string") { + const trimmed = raw.trim(); + return trimmed ? trimmed : undefined; + } + + if (Array.isArray(raw)) return raw.length ? raw : undefined; + + return raw; +} + +function isValidAnswer(field: PublicFormField, value: unknown): boolean { + if ( + typeof value === "string" && + value.length > (TEXT_MAX_LENGTH[field.type] ?? Infinity) + ) { + return false; + } + + switch (field.type) { + case "text": + case "longText": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "select": + return ( + typeof value === "string" && + (field.choices ?? []).some((c) => c.id === value) + ); + case "multiSelect": { + const ids = new Set((field.choices ?? []).map((c) => c.id)); + return ( + Array.isArray(value) && + value.length <= 100 && + value.every((id) => typeof id === "string" && ids.has(id)) + ); + } + case "date": + return isIsoDate(value); + case "url": + return isHttpUrl(value); + case "email": + return typeof value === "string" && EMAIL.test(value); + case "checkbox": + return typeof value === "boolean"; + case "person": + return field.allowMultiple + ? Array.isArray(value) && + value.length <= 100 && + value.every((id) => typeof id === "string") + : typeof value === "string"; + } +} + +function isIsoDate(value: unknown): boolean { + if (typeof value !== "string" || !ISO_DATE.test(value)) return false; + const day = value.slice(0, 10); + const calendarDay = new Date(`${day}T00:00:00Z`); + return ( + !Number.isNaN(calendarDay.getTime()) && + calendarDay.toISOString().slice(0, 10) === day && + Number.isFinite(Date.parse(value)) + ); +} + +function isHttpUrl(value: unknown): boolean { + if (typeof value !== "string") return false; + try { + const { protocol } = new URL(value); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} + +export function validateFormAnswers( + fields: PublicFormField[], + answers: FormAnswers, +): Record { + const errors: Record = {}; + for (const field of fields) { + const value = normalizeAnswer(field, answers[field.propertyId]); + if (value === INVALID) { + errors[field.propertyId] = "invalid"; + } else if (value === undefined || (field.type === "checkbox" && !value)) { + if (field.required) errors[field.propertyId] = "required"; + } else if (!isValidAnswer(field, value)) { + errors[field.propertyId] = "invalid"; + } + } + return errors; +} + +export function buildSubmissionAnswers( + fields: PublicFormField[], + answers: FormAnswers, +): FormAnswers { + const submission: FormAnswers = {}; + for (const field of fields) { + const value = normalizeAnswer(field, answers[field.propertyId]); + if (value !== undefined && value !== INVALID) { + submission[field.propertyId] = value; + } + } + return submission; +} + +export function formFieldErrorMessage( + t: TFunction, + field: PublicFormField, + error: FormFieldError, +): string { + if (error === "required") return t("This question is required"); + switch (field.type) { + case "email": + return t("Enter a valid email address"); + case "url": + return t("Enter a valid link starting with http:// or https://"); + case "number": + return t("Enter a valid number"); + case "date": + return t("Enter a valid date"); + case "text": + case "longText": + return t("This answer is too long"); + case "select": + case "multiSelect": + return t("Select one of the available options"); + case "person": + return t("Select people from this workspace"); + default: + return t("This answer isn't valid"); + } +} diff --git a/apps/client/src/ee/base/components/form/form-field-control.tsx b/apps/client/src/ee/base/components/form/form-field-control.tsx new file mode 100644 index 000000000..5f1f7ffdb --- /dev/null +++ b/apps/client/src/ee/base/components/form/form-field-control.tsx @@ -0,0 +1,340 @@ +import { + Checkbox, + MultiSelect, + NumberInput, + Radio, + Select, + Textarea, + TextInput, + UnstyledButton, +} from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { IconCalendar } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { + PublicFormChoice, + PublicFormField, + PublicFormNumberFormat, + UserRef, +} from "@/ee/base/types/base.types"; +import { + AUTOFILL_OPT_OUT, + CHOICE_LIST_LIMIT, +} from "@/ee/base/components/form/form-definition"; +import { FormPersonPicker } from "@/ee/base/components/form/form-person-picker"; +import { toDateCellValue, toISODateString } from "@/ee/base/utils/date-cell"; +import classes from "@/ee/base/styles/form.module.css"; + +type FormFieldControlProps = { + field: PublicFormField; + value: unknown; + onChange: (value: unknown) => void; + controlId: string; + ariaLabelledBy: string; + ariaDescribedBy?: string; + invalid?: boolean; + users?: Record; +}; + +const fieldClassNames = { wrapper: classes.field, input: classes.input }; + +function currencySymbol(format: PublicFormNumberFormat): string | undefined { + if (format.currencySymbol) return format.currencySymbol; + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: format.currencyCode || "USD", + currencyDisplay: "narrowSymbol", + }) + .formatToParts(0) + .find((part) => part.type === "currency")?.value; + } catch { + return undefined; + } +} + +function numberAffixes(format: PublicFormNumberFormat | undefined) { + if (format?.format === "currency") { + const symbol = currencySymbol(format); + return { prefix: symbol ? `${symbol} ` : undefined, suffix: undefined }; + } + if (format?.format === "percent" || format?.format === "progress") { + return { prefix: undefined, suffix: "%" }; + } + return { prefix: undefined, suffix: undefined }; +} + +function rovingTabIndex( + choices: PublicFormChoice[], + choiceId: string, + selectedId: string | null, +): number { + const anchor = choices.find((c) => c.id === selectedId) ?? choices[0]; + return anchor?.id === choiceId ? 0 : -1; +} + +export function FormFieldControl({ + field, + value, + onChange, + controlId, + ariaLabelledBy, + ariaDescribedBy, + invalid, + users, +}: FormFieldControlProps) { + const { t } = useTranslation(); + const labelling = { + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + }; + const inputProps = { + ...labelling, + id: controlId, + size: "sm" as const, + radius: "md" as const, + error: invalid, + classNames: fieldClassNames, + "aria-required": field.required || undefined, + "aria-invalid": invalid || undefined, + }; + const textInputProps = { ...inputProps, ...AUTOFILL_OPT_OUT }; + + switch (field.type) { + case "longText": + return ( +