feat: forms

This commit is contained in:
Philipinho
2026-10-01 02:22:07 +01:00
parent b6434371ed
commit f1c10f0c66
62 changed files with 4630 additions and 82 deletions
@@ -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."
}
+3
View File
@@ -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() {
/>
</Route>
<Route path={"/forms/:formKey"} element={<PublicFormPage />} />
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
@@ -53,3 +53,7 @@ export type PendingTypeInsert = {
} | null;
export const pendingTypeInsertAtom = atom<PendingTypeInsert>(null);
export const formPreviewAtomFamily = atomFamily((_viewId: string) =>
atom<boolean>(false),
);
@@ -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({
</ActionIcon>
</Tooltip>
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
conditions={conditions}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={conditions.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
{!isForm && (
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
conditions={conditions}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={conditions.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
>
<IconFilter size={16} />
{conditions.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
)}
{isForm && activeView && editable && (
<>
<FormSettingsPopover pageId={base.id} view={activeView} />
<Button
size="compact-sm"
variant="default"
fw={500}
leftSection={
formPreview ? (
<IconEyeOff size={16} stroke={1.75} />
) : (
<IconEye size={16} stroke={1.75} />
)
}
aria-pressed={formPreview}
onClick={() => setFormPreview(!formPreview)}
>
<IconFilter size={16} />
{conditions.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
{formPreview ? t("Exit preview") : t("Preview")}
</Button>
<FormSharePopover pageId={base.id} view={activeView} />
</>
)}
{isKanban && activeView && (
<>
@@ -213,7 +249,7 @@ export function BaseToolbar({
</>
)}
{!isKanban && (
{!isKanban && !isForm && (
<>
<ViewSortConfigPopover
opened={sortOpened}
@@ -50,6 +50,7 @@ import { useNavigate } from "react-router-dom";
import classes from "@/ee/base/styles/grid.module.css";
import viewClasses from "@/ee/base/styles/base-view.module.css";
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
import formClasses from "@/ee/base/styles/form.module.css";
type BaseViewProps = {
pageId: string;
@@ -150,6 +151,8 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
// Gate on base to avoid a "bland" list request before the active view's
// config resolves, which would double network traffic for sorted/filtered views.
const isKanban = activeView?.type === "kanban";
const isForm = activeView?.type === "form";
const loadsRows = !isKanban && !isForm;
const {
data: rowsData,
@@ -157,7 +160,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts);
} = useBaseRowsQuery(base && loadsRows ? pageId : undefined, activeFilter, activeSorts);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
@@ -373,7 +376,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
[editable, pageId, reorderRow],
);
if (baseLoading || (!isKanban && rowsLoading)) {
if (baseLoading || (loadsRows && rowsLoading)) {
return <BaseTableSkeleton />;
}
if (baseError) {
@@ -415,7 +418,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
/>
);
const kanbanBand = (
const band = (
<div className={kanbanClasses.bandWrap}>
{embedded ? null : titleSlot}
{banner}
@@ -449,12 +452,32 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
/>
);
if (isForm) {
return (
<BaseEditableProvider editable={editable}>
{embedded ? (
<>
{band}
{viewRenderer(null)}
</>
) : (
<div className={viewClasses.fullHeight}>
<div className={formClasses.scroll}>
{band}
{viewRenderer(null)}
</div>
</div>
)}
</BaseEditableProvider>
);
}
if (embedded) {
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{band}
{viewRenderer(null)}
</RowExpandProvider>
<RowDetailModal
@@ -497,7 +520,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
<BaseEditableProvider editable={editable}>
<div className={kanbanClasses.standalone}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{band}
{viewRenderer(null)}
</RowExpandProvider>
</div>
@@ -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);
@@ -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 = (
<FormRespond view={view} pageId={pageId} definition={definition} />
);
} else if (preview) {
content = (
<FormFill
form={definition}
mode="preview"
users={users}
onSubmit={(answers) =>
submitPreview.mutateAsync({ pageId, viewId: view.id, answers })
}
onExitPreview={() => setPreview(false)}
/>
);
} else {
content = (
<FormBuilder
base={base}
view={view}
pageId={pageId}
pageTitle={page?.title}
users={users}
/>
);
}
return (
<div className={classes.canvas} data-embedded={embedded || undefined}>
{content}
</div>
);
}
@@ -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<BasePropertyType>(
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<HTMLDivElement>(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 (
<CreatePropertyPopover
pageId={pageId}
properties={base.properties}
excludeTypes={NON_FORM_PROPERTY_TYPES}
onPropertyCreated={(property) => onAdd([property.id])}
renderTarget={(openCreate) => (
<div ref={anchorRef}>
<Menu
position="bottom-start"
width={280}
shadow="md"
withinPortal
returnFocus={false}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
leftSection={<IconPlus size={16} />}
disabled={remaining <= 0}
>
{t("Add question")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize mah={320} type="auto">
{available.length > 0 && (
<Menu.Label>{t("Add a property")}</Menu.Label>
)}
{available.map((property) => {
const Icon = getDescriptor(property.type)?.icon;
return (
<Menu.Item
key={property.id}
leftSection={Icon ? <Icon size={14} /> : undefined}
onClick={() => onAdd([property.id])}
>
{property.name}
</Menu.Item>
);
})}
{available.length > 1 && (
<Menu.Item
leftSection={<IconListCheck size={14} />}
onClick={() =>
onAdd(available.slice(0, remaining).map((p) => p.id))
}
>
{t("Add all properties")}
</Menu.Item>
)}
{unavailable.length > 0 && (
<>
<Menu.Label>{t("Not available in forms")}</Menu.Label>
{unavailable.map((property) => {
const Icon = getDescriptor(property.type)?.icon;
return (
<Menu.Item
key={property.id}
leftSection={Icon ? <Icon size={14} /> : undefined}
disabled
>
{property.name}
</Menu.Item>
);
})}
</>
)}
</ScrollArea.Autosize>
{unused.length > 0 && <Menu.Divider />}
<Menu.Item
leftSection={<IconPlus size={14} />}
onClick={() => openCreate(anchorRef.current ?? undefined)}
>
{t("New property")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
)}
/>
);
}
@@ -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<string, UserRef>;
isFirst: boolean;
isLast: boolean;
autoFocus: boolean;
onAutoFocused: () => void;
onChange: (patch: Partial<FormFieldConfig>) => 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<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(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 (
<div
ref={rowRef}
className={classes.builderQuestion}
data-dragging={isDragging || undefined}
>
<div
ref={handleRef}
className={classes.dragHandle}
title={t("Drag to reorder")}
aria-hidden="true"
>
<IconGripVertical size={16} />
</div>
<div
className={classes.questionCard}
data-pinned={propertyMenuOpen || actionsMenuOpen || undefined}
>
<div className={classes.labelRow}>
<FormInlineTextarea
value={field.label?.trim() || property.name}
onCommit={(next) =>
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 && <FormRequiredMark />}
</div>
{(hasDescription || addingDescription) && (
<FormInlineTextarea
value={field.description ?? ""}
onCommit={(next) => 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 ? (
<div className={classes.controlPreview} inert>
<FormFieldControl
field={formField}
value={initialAnswer(formField)}
onChange={noop}
controlId={`form-builder-${pageId}-${property.id}`}
ariaLabelledBy=""
users={users}
/>
</div>
) : (
<div className={classes.questionWarning}>
<IconAlertTriangle size={14} />
{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.",
)}
</div>
)}
{formField && isMemberOnlyField(formField) && (
<div className={classes.questionHint}>
<IconUsers size={14} />
{t("Only shown to signed-in workspace members")}
</div>
)}
<div className={classes.questionToolbar}>
<FormPropertyChip
property={property}
pageId={pageId}
onOpenChange={setPropertyMenuOpen}
/>
{!hasDescription && !addingDescription && (
<UnstyledButton
className={classes.propertyChip}
onClick={() => setAddingDescription(true)}
>
<IconTextPlus size={14} />
<span className={classes.propertyChipName}>
{t("Add description")}
</span>
</UnstyledButton>
)}
<Switch
size="xs"
label={t("Required")}
checked={field.required === true}
onChange={(event) =>
onChange({ required: event.currentTarget.checked || undefined })
}
/>
<Menu
position="bottom-end"
shadow="md"
width={200}
withinPortal
onChange={setActionsMenuOpen}
>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("Question actions")}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconArrowUp size={14} />}
disabled={isFirst}
onClick={() => onMove(-1)}
>
{t("Move up")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowDown size={14} />}
disabled={isLast}
onClick={() => onMove(1)}
>
{t("Move down")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={onRemove}
>
{t("Remove from form")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
</div>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
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 (
<Popover
opened={opened}
onChange={handleOpenChange}
onClose={handleClose}
position="bottom-start"
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<UnstyledButton
className={classes.propertyChip}
data-active={opened || undefined}
aria-haspopup="dialog"
onClick={toggle}
>
{Icon && <Icon size={14} />}
<span className={classes.propertyChipName}>{property.name}</span>
<IconChevronDown size={12} />
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={opened}
onClose={handleClose}
onDirtyChange={setDirty}
pageId={pageId}
initialPanel={isChoice && !property.pendingType ? "options" : "main"}
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -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<string, UserRef>;
};
export function FormBuilder({
base,
view,
pageId,
pageTitle,
users,
}: FormBuilderProps) {
const { t } = useTranslation();
const updateForm = useUpdateFormConfig(pageId, view.id);
const [focusPropertyId, setFocusPropertyId] = useState<string | null>(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<FormFieldConfig>) =>
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 = (
<FormAddQuestion
base={base}
fields={fields}
pageId={pageId}
onAdd={handleAdd}
/>
);
return (
<div className={classes.builder}>
<div className={classes.builderHeader}>
<FormInlineTextarea
value={form.title ?? ""}
onCommit={(title) =>
updateForm((current) => ({ ...current, title: title || undefined }))
}
className={classes.inlineTitle}
ariaLabel={t("Form title")}
placeholder={pageTitle?.trim() || t("Untitled form")}
maxLength={300}
singleLine
/>
<FormInlineTextarea
value={form.description ?? ""}
onCommit={(description) =>
updateForm((current) => ({
...current,
description: description || undefined,
}))
}
className={classes.inlineDescription}
ariaLabel={t("Form description")}
placeholder={t("Add a description")}
maxLength={5000}
/>
</div>
{visibleFields.length === 0 ? (
<div className={classes.emptyBuilder}>
<Text fw={600}>{t("Add your first question")}</Text>
<Text size="sm" c="dimmed">
{t(
"Each question fills a property of this base, and every response becomes a new row.",
)}
</Text>
{addQuestion}
</div>
) : (
<div className={classes.builderQuestions}>
{visibleFields.map((field, index) => (
<FormBuilderQuestion
key={field.propertyId}
field={field}
property={propertiesById.get(field.propertyId)!}
pageId={pageId}
users={users}
isFirst={index === 0}
isLast={index === visibleFields.length - 1}
autoFocus={focusPropertyId === field.propertyId}
onAutoFocused={clearFocus}
onChange={(patch) => handleChange(field.propertyId, patch)}
onRemove={() => handleRemove(field.propertyId)}
onMove={(offset) => handleMove(field.propertyId, offset)}
onReorder={handleReorder}
/>
))}
</div>
)}
<div className={classes.builderActions}>
{visibleFields.length > 0 && addQuestion}
<div inert>
<FormSubmitButton label={form.submitLabel?.trim() || t("Submit")} />
</div>
</div>
</div>
);
}
@@ -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<string, unknown>;
export const FORM_FIELD_TYPES: ReadonlySet<BasePropertyType> =
new Set<BasePropertyType>([
"text",
"longText",
"number",
"select",
"multiSelect",
"date",
"checkbox",
"url",
"email",
"person",
]);
const MEMBER_ONLY_FIELD_TYPES: ReadonlySet<FormFieldType> =
new Set<FormFieldType>(["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<Record<FormFieldType, number>> = {
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<string, PublicFormChoice>();
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<string, FormFieldError> {
const errors: Record<string, FormFieldError> = {};
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");
}
}
@@ -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<string, UserRef>;
};
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 (
<Textarea
{...textInputProps}
autosize
minRows={3}
maxRows={12}
maxLength={25000}
placeholder={t("Your answer")}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.currentTarget.value)}
/>
);
case "number": {
const format = field.numberFormat;
const { prefix, suffix } = numberAffixes(format);
return (
<NumberInput
{...textInputProps}
hideControls
prefix={prefix}
suffix={suffix}
decimalScale={format?.precision}
placeholder={t("Your answer")}
value={
typeof value === "number" || typeof value === "string" ? value : ""
}
onChange={(next) => onChange(next)}
/>
);
}
case "select": {
const choices = field.choices ?? [];
const selected = typeof value === "string" ? value : null;
if (choices.length > CHOICE_LIST_LIMIT) {
return (
<Select
{...textInputProps}
searchable
selectFirstOptionOnChange
clearable={!field.required}
placeholder={t("Select an option")}
nothingFoundMessage={t("No options found")}
comboboxProps={{ shadow: "md", radius: "md" }}
data={choices.map((c) => ({ value: c.id, label: c.name }))}
value={selected}
onChange={(next) => onChange(next)}
/>
);
}
return (
<div className={classes.choiceGroup}>
<div
id={controlId}
role="radiogroup"
className={classes.optionList}
aria-required={field.required || undefined}
aria-invalid={invalid || undefined}
{...labelling}
>
{choices.map((choice) => (
<Radio.Card
key={choice.id}
name={controlId}
value={choice.id}
checked={selected === choice.id}
withBorder={false}
className={classes.option}
mod={{ invalid }}
tabIndex={rovingTabIndex(choices, choice.id, selected)}
onClick={() => onChange(choice.id)}
>
<Radio.Indicator size="sm" />
<span className={classes.optionLabel}>{choice.name}</span>
</Radio.Card>
))}
</div>
{selected && !field.required && (
<UnstyledButton
className={classes.clearChoice}
onClick={() => onChange(null)}
>
{t("Clear selection")}
</UnstyledButton>
)}
</div>
);
}
case "multiSelect": {
const choices = field.choices ?? [];
const selected = Array.isArray(value) ? (value as string[]) : [];
if (choices.length > CHOICE_LIST_LIMIT) {
return (
<MultiSelect
{...textInputProps}
searchable
selectFirstOptionOnChange
clearable
placeholder={selected.length ? undefined : t("Select options")}
nothingFoundMessage={t("No options found")}
comboboxProps={{ shadow: "md", radius: "md" }}
data={choices.map((c) => ({ value: c.id, label: c.name }))}
value={selected}
onChange={(next) => onChange(next)}
/>
);
}
return (
<div
id={controlId}
role="group"
className={classes.optionList}
aria-invalid={invalid || undefined}
{...labelling}
>
{choices.map((choice) => {
const checked = selected.includes(choice.id);
return (
<Checkbox.Card
key={choice.id}
value={choice.id}
checked={checked}
withBorder={false}
className={classes.option}
mod={{ invalid }}
onClick={() =>
onChange(
checked
? selected.filter((id) => id !== choice.id)
: [...selected, choice.id],
)
}
>
<Checkbox.Indicator size="sm" radius="sm" />
<span className={classes.optionLabel}>{choice.name}</span>
</Checkbox.Card>
);
})}
</div>
);
}
case "date":
return (
<DatePickerInput
{...inputProps}
clearable
valueFormat="MMM D, YYYY"
placeholder={t("Pick a date")}
leftSection={<IconCalendar size={16} stroke={1.75} />}
leftSectionPointerEvents="none"
popoverProps={{ shadow: "md", radius: "md" }}
value={toISODateString(typeof value === "string" ? value : null)}
onChange={(next) => onChange(next ? toDateCellValue(next) : null)}
/>
);
case "checkbox":
return (
<div className={classes.optionList}>
<Checkbox.Card
id={controlId}
checked={value === true}
withBorder={false}
className={classes.option}
mod={{ invalid }}
aria-required={field.required || undefined}
aria-invalid={invalid || undefined}
{...labelling}
onClick={() => onChange(value !== true)}
>
<Checkbox.Indicator size="sm" radius="sm" />
<span className={classes.optionLabel}>{t("Yes")}</span>
</Checkbox.Card>
</div>
);
case "person":
return (
<FormPersonPicker
value={value}
allowMultiple={field.allowMultiple === true}
onChange={onChange}
knownUsers={users}
controlId={controlId}
ariaLabelledBy={ariaLabelledBy}
ariaDescribedBy={ariaDescribedBy}
invalid={invalid}
required={field.required}
/>
);
case "url":
return (
<TextInput
{...textInputProps}
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={2048}
placeholder="https://"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.currentTarget.value)}
/>
);
case "email":
return (
<TextInput
{...textInputProps}
inputMode="email"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={320}
placeholder="name@example.com"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.currentTarget.value)}
/>
);
default:
return (
<TextInput
{...textInputProps}
maxLength={1000}
placeholder={t("Your answer")}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.currentTarget.value)}
/>
);
}
}
@@ -0,0 +1,432 @@
import {
FormEvent,
KeyboardEvent,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { Alert, Button, Group, Text, ThemeIcon } from "@mantine/core";
import {
IconAlertCircle,
IconAlertTriangle,
IconCircleCheck,
IconEye,
IconRotate2,
} from "@tabler/icons-react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { getApiErrorMessage } from "@/lib/api-error";
import {
FormFieldError,
FormRespondent,
PublicForm,
PublicFormField,
UserRef,
} from "@/ee/base/types/base.types";
import {
FormAnswers,
buildInitialAnswers,
buildSubmissionAnswers,
formFieldErrorMessage,
initialAnswer,
validateFormAnswers,
} from "@/ee/base/components/form/form-definition";
import { FormFieldControl } from "@/ee/base/components/form/form-field-control";
import { FormRichText } from "@/ee/base/components/form/form-rich-text";
import { FormRequiredMark } from "@/ee/base/components/form/form-required-mark";
import { FormSubmitButton } from "@/ee/base/components/form/form-submit-button";
import classes from "@/ee/base/styles/form.module.css";
export type FormUnavailableStatus = "closed" | "signInRequired" | "notFound";
const UNAVAILABLE_BY_STATUS: Record<number, FormUnavailableStatus> = {
401: "signInRequired",
403: "closed",
404: "notFound",
};
type FormFillProps = {
form: PublicForm;
mode: "live" | "preview" | "readOnly";
respondent?: FormRespondent | null;
users?: Record<string, UserRef>;
onSubmit?: (answers: FormAnswers) => Promise<void>;
onUnavailable?: (status: FormUnavailableStatus) => void;
onExitPreview?: () => void;
};
export function FormFill({
form,
mode,
respondent,
users,
onSubmit,
onUnavailable,
onExitPreview,
}: FormFillProps) {
const { t } = useTranslation();
const idPrefix = useId();
const rootRef = useRef<HTMLDivElement>(null);
const successTitleRef = useRef<HTMLHeadingElement>(null);
const [answers, setAnswers] = useState<FormAnswers>(() =>
buildInitialAnswers(form.fields),
);
const [attempted, setAttempted] = useState(false);
const [serverErrors, setServerErrors] = useState<
Record<string, FormFieldError>
>({});
const [formError, setFormError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [touched, setTouched] = useState(false);
const currentAnswers = useMemo<FormAnswers>(
() =>
Object.fromEntries(
form.fields.map((field) => [
field.propertyId,
field.propertyId in answers
? answers[field.propertyId]
: initialAnswer(field),
]),
),
[form.fields, answers],
);
const errors = useMemo(
() =>
attempted
? {
...validateFormAnswers(form.fields, currentAnswers),
...serverErrors,
}
: serverErrors,
[attempted, form.fields, currentAnswers, serverErrors],
);
useEffect(() => {
if (submitted) successTitleRef.current?.focus({ preventScroll: true });
}, [submitted]);
const questionId = (propertyId: string) => `${idPrefix}-${propertyId}`;
const focusFirstError = (fieldErrors: Record<string, FormFieldError>) => {
const first = form.fields.find((field) => fieldErrors[field.propertyId]);
if (!first) return;
const question = document.getElementById(questionId(first.propertyId));
question?.scrollIntoView({ block: "center" });
question
?.querySelector<HTMLElement>(
"input:not([type='hidden']):not([tabindex='-1']), textarea, button:not([tabindex='-1'])",
)
?.focus({ preventScroll: true });
};
const handleChange = (field: PublicFormField, value: unknown) => {
setTouched(true);
setAnswers((prev) => ({ ...prev, [field.propertyId]: value }));
if (serverErrors[field.propertyId]) {
setServerErrors((prev) => {
const next = { ...prev };
delete next[field.propertyId];
return next;
});
}
};
const finish = () => {
setSubmitted(true);
rootRef.current?.scrollIntoView({ block: "start" });
};
const reset = () => {
setAnswers(buildInitialAnswers(form.fields));
setTouched(false);
setAttempted(false);
setServerErrors({});
setFormError(null);
setSubmitted(false);
};
const handleSubmitError = (error: unknown) => {
const status = isAxiosError(error) ? error.response?.status : undefined;
const fieldErrors: Record<string, FormFieldError> | undefined =
status === 400 && isAxiosError(error)
? error.response?.data?.fieldErrors
: undefined;
if (fieldErrors) {
const known = Object.fromEntries(
Object.entries(fieldErrors).filter(([propertyId]) =>
form.fields.some((field) => field.propertyId === propertyId),
),
);
setServerErrors(known);
if (Object.keys(known).length < Object.keys(fieldErrors).length) {
setFormError(
t("This form has changed. Reload the page to see the latest version."),
);
}
focusFirstError(known);
return;
}
const unavailable = status ? UNAVAILABLE_BY_STATUS[status] : undefined;
if (unavailable && onUnavailable) {
onUnavailable(unavailable);
return;
}
if (status === 429) {
setFormError(
t("You're sending responses too quickly. Wait a moment and try again."),
);
return;
}
setFormError(
getApiErrorMessage(error, t("Something went wrong. Please try again.")),
);
};
const handleKeyDown = (event: KeyboardEvent<HTMLFormElement>) => {
if (
event.key === "Enter" &&
!event.nativeEvent.isComposing &&
event.target instanceof HTMLInputElement
) {
event.preventDefault();
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (submitting) return;
setAttempted(true);
setFormError(null);
const fieldErrors = validateFormAnswers(form.fields, currentAnswers);
if (Object.keys(fieldErrors).length > 0) {
focusFirstError(fieldErrors);
return;
}
if (!onSubmit) {
finish();
return;
}
setSubmitting(true);
try {
await onSubmit(buildSubmissionAnswers(form.fields, currentAnswers));
finish();
} catch (error) {
handleSubmitError(error);
} finally {
setSubmitting(false);
}
};
return (
<div ref={rootRef} className={classes.form}>
{mode === "preview" && (
<div className={classes.previewBanner}>
<IconEye size={16} stroke={1.75} />
<Text size="sm" className={classes.previewBannerText}>
{t("You're previewing this form. Submitted responses are saved.")}
</Text>
{onExitPreview && (
<Button size="compact-xs" variant="subtle" onClick={onExitPreview}>
{t("Exit preview")}
</Button>
)}
</div>
)}
{submitted ? (
<div className={classes.success} role="status">
<ThemeIcon size={56} radius="xl" variant="light" color="green">
<IconCircleCheck size={32} stroke={1.5} />
</ThemeIcon>
<h2
ref={successTitleRef}
tabIndex={-1}
className={classes.successTitle}
>
{form.successTitle || t("Thank you!")}
</h2>
<FormRichText
text={form.successMessage || t("Your response has been recorded.")}
className={classes.successMessage}
/>
{form.allowResubmit && (
<Button variant="default" onClick={reset}>
{t("Submit another response")}
</Button>
)}
{mode === "preview" && !form.allowResubmit && (
<Button variant="default" onClick={reset}>
{t("Back to form")}
</Button>
)}
</div>
) : (
<>
<header className={classes.header}>
<h1 className={classes.title}>
{form.title || t("Untitled form")}
</h1>
{form.description && (
<FormRichText
text={form.description}
className={classes.description}
/>
)}
{respondent && (
<div className={classes.respondent}>
<CustomAvatar
avatarUrl={respondent.avatarUrl ?? ""}
name={respondent.name || respondent.email}
size={24}
radius="xl"
/>
<Text size="sm" c="dimmed">
{t("Responding as {{name}}", {
name: respondent.name
? `${respondent.name} (${respondent.email})`
: respondent.email,
})}
</Text>
</div>
)}
</header>
<form
noValidate
autoComplete="off"
data-form-type="other"
onKeyDown={handleKeyDown}
onSubmit={handleSubmit}
className={classes.body}
>
{form.fields.length === 0 ? (
<Text c="dimmed">
{t("This form doesn't have any questions yet.")}
</Text>
) : (
<div className={classes.questions} inert={mode === "readOnly"}>
{form.fields.map((field) => (
<FormQuestion
key={field.propertyId}
id={questionId(field.propertyId)}
field={field}
value={currentAnswers[field.propertyId]}
error={errors[field.propertyId]}
users={users}
onChange={(value) => handleChange(field, value)}
/>
))}
</div>
)}
{formError && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
>
{formError}
</Alert>
)}
{mode !== "readOnly" && (
<Group gap="sm">
<FormSubmitButton
type="submit"
label={form.submitLabel || t("Submit")}
loading={submitting}
disabled={form.fields.length === 0}
/>
{touched && (
<Button
variant="subtle"
color="gray"
size="sm"
radius="md"
leftSection={<IconRotate2 size={16} />}
onClick={reset}
>
{t("Clear form")}
</Button>
)}
</Group>
)}
</form>
</>
)}
</div>
);
}
type FormQuestionProps = {
id: string;
field: PublicFormField;
value: unknown;
error?: FormFieldError;
users?: Record<string, UserRef>;
onChange: (value: unknown) => void;
};
function FormQuestion({
id,
field,
value,
error,
users,
onChange,
}: FormQuestionProps) {
const { t } = useTranslation();
const controlId = `${id}-control`;
const labelId = `${id}-label`;
const descriptionId = field.description ? `${id}-description` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy =
[descriptionId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div id={id} className={classes.question}>
<div className={classes.questionHeader}>
<label id={labelId} htmlFor={controlId} className={classes.questionLabel}>
{field.label}
{field.required && <FormRequiredMark />}
</label>
{field.description && (
<FormRichText
id={descriptionId}
text={field.description}
className={classes.questionDescription}
/>
)}
</div>
<FormFieldControl
field={field}
value={value}
onChange={onChange}
controlId={controlId}
ariaLabelledBy={labelId}
ariaDescribedBy={describedBy}
invalid={!!error}
users={users}
/>
{error && (
<div id={errorId} className={classes.errorText} role="alert">
<IconAlertCircle size={15} stroke={2} />
{formFieldErrorMessage(t, field, error)}
</div>
)}
</div>
);
}
@@ -0,0 +1,130 @@
import {
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import clsx from "clsx";
import classes from "@/ee/base/styles/form.module.css";
type FormInlineTextareaProps = {
value: string;
onCommit: (value: string) => void;
className: string;
ariaLabel: string;
maxLength: number;
placeholder?: string;
singleLine?: boolean;
autoFocus?: boolean;
onAutoFocused?: () => void;
onBlur?: (value: string) => void;
};
export function FormInlineTextarea({
value,
onCommit,
className,
ariaLabel,
maxLength,
placeholder,
singleLine,
autoFocus,
onAutoFocused,
onBlur,
}: FormInlineTextareaProps) {
const ref = useRef<HTMLTextAreaElement>(null);
const [draft, setDraft] = useState(value);
const focusedRef = useRef(false);
const cancelRef = useRef(false);
useEffect(() => {
if (!focusedRef.current) setDraft(value);
}, [value]);
const resize = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, []);
useLayoutEffect(resize, [draft, resize]);
useEffect(() => {
const el = ref.current;
if (!el) return;
let width = el.offsetWidth;
const observer = new ResizeObserver(() => {
if (el.offsetWidth === width) return;
width = el.offsetWidth;
resize();
});
observer.observe(el);
return () => observer.disconnect();
}, [resize]);
useEffect(() => {
if (!autoFocus) return;
const el = ref.current;
if (!el) return;
el.focus();
el.select();
onAutoFocused?.();
}, [autoFocus, onAutoFocused]);
const handleBlur = () => {
focusedRef.current = false;
if (cancelRef.current) {
cancelRef.current = false;
setDraft(value);
onBlur?.(value);
return;
}
const next = draft.trim();
setDraft(next);
if (next !== value) onCommit(next);
onBlur?.(next);
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancelRef.current = true;
event.currentTarget.blur();
} else if (
event.key === "Enter" &&
!event.nativeEvent.isComposing &&
(singleLine || event.metaKey || event.ctrlKey)
) {
event.preventDefault();
event.currentTarget.blur();
}
};
return (
<textarea
ref={ref}
rows={1}
className={clsx(classes.inlineField, className)}
aria-label={ariaLabel}
placeholder={placeholder}
maxLength={maxLength}
value={draft}
onFocus={() => {
focusedRef.current = true;
}}
onChange={(event) =>
setDraft(
singleLine
? event.currentTarget.value.replace(/\s*\n\s*/g, " ")
: event.currentTarget.value,
)
}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
}
@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import {
Combobox,
Pill,
PillsInput,
Text,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { UserRef } from "@/ee/base/types/base.types";
import {
PersonSuggestion,
usePersonSearch,
} from "@/ee/base/hooks/use-person-search";
import { AUTOFILL_OPT_OUT } from "@/ee/base/components/form/form-definition";
import classes from "@/ee/base/styles/form.module.css";
type FormPersonPickerProps = {
value: unknown;
allowMultiple: boolean;
onChange: (value: unknown) => void;
knownUsers?: Record<string, UserRef>;
controlId: string;
ariaLabelledBy: string;
ariaDescribedBy?: string;
invalid?: boolean;
required?: boolean;
};
export function FormPersonPicker({
value,
allowMultiple,
onChange,
knownUsers,
controlId,
ariaLabelledBy,
ariaDescribedBy,
invalid,
required,
}: FormPersonPickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Record<string, UserRef>>({});
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
},
});
const results = usePersonSearch(search, combobox.dropdownOpened);
const [settledSearch] = useDebouncedValue(search, 250);
const searchSettled = search.trim() === settledSearch.trim();
useEffect(() => {
if (searchSettled && search.trim() && results.length > 0) {
combobox.selectFirstOption();
}
}, [searchSettled, search, results]);
const selectedIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const select = (user: PersonSuggestion) => {
setPicked((prev) => ({
...prev,
[user.id]: { id: user.id, name: user.name, avatarUrl: user.avatarUrl },
}));
if (!allowMultiple) {
onChange(user.id);
combobox.closeDropdown();
return;
}
onChange(
selectedIds.includes(user.id)
? selectedIds.filter((id) => id !== user.id)
: [...selectedIds, user.id],
);
combobox.resetSelectedOption();
setSearch("");
};
const remove = (userId: string) =>
onChange(
allowMultiple ? selectedIds.filter((id) => id !== userId) : null,
);
return (
<Combobox
store={combobox}
withinPortal
shadow="md"
radius="md"
onOptionSubmit={(userId) => {
const user = results.find((u) => u.id === userId);
if (user) select(user);
}}
>
<Combobox.DropdownTarget>
<PillsInput
size="sm"
radius="md"
classNames={{ wrapper: classes.field, input: classes.input }}
error={invalid}
onClick={() => combobox.openDropdown()}
>
<Pill.Group>
{selectedIds.map((userId) => {
const user = picked[userId] ?? knownUsers?.[userId];
const name = user?.name || userId.substring(0, 8);
return (
<Pill
key={userId}
size="sm"
withRemoveButton
className={classes.personPill}
onRemove={() => remove(userId)}
>
<span className={classes.personPillLabel}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={18}
radius="xl"
/>
{name}
</span>
</Pill>
);
})}
<Combobox.EventsTarget>
<PillsInput.Field
{...AUTOFILL_OPT_OUT}
id={controlId}
value={search}
placeholder={
selectedIds.length > 0
? undefined
: allowMultiple
? t("Search people")
: t("Search for a person")
}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
aria-required={required || undefined}
aria-invalid={invalid || undefined}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
onChange={(event) => {
combobox.openDropdown();
combobox.resetSelectedOption();
setSearch(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (
event.key === "Backspace" &&
search.length === 0 &&
selectedIds.length > 0
) {
event.preventDefault();
remove(selectedIds[selectedIds.length - 1]);
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown hidden={results.length === 0 && !searchSettled}>
<Combobox.Options className={classes.personOptions}>
{results.length === 0 ? (
<Combobox.Empty>{t("No people found")}</Combobox.Empty>
) : (
results.map((user) => {
const selected = selectedIds.includes(user.id);
const name = user.name || user.email || "";
return (
<Combobox.Option
key={user.id}
value={user.id}
active={selected}
className={classes.personOption}
>
<CustomAvatar
avatarUrl={user.avatarUrl ?? ""}
name={name}
size={26}
radius="xl"
/>
<div className={classes.personOptionText}>
<Text size="sm" truncate>
{name}
</Text>
{user.name && user.email && (
<Text size="xs" c="dimmed" truncate>
{user.email}
</Text>
)}
</div>
{selected && <IconCheck size={16} />}
</Combobox.Option>
);
})
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
@@ -0,0 +1,10 @@
import { IconAsterisk } from "@tabler/icons-react";
import classes from "@/ee/base/styles/form.module.css";
export function FormRequiredMark() {
return (
<span className={classes.requiredMark} aria-hidden="true">
<IconAsterisk size={9} stroke={3} />
</span>
);
}
@@ -0,0 +1,78 @@
import { useState } from "react";
import { Alert, Loader, Stack } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBaseView, PublicForm } from "@/ee/base/types/base.types";
import {
useFormShareQuery,
usePublicFormQuery,
useSubmitPublicFormMutation,
} from "@/ee/base/queries/base-form-query";
import { useReferenceStore } from "@/ee/base/reference/reference-store";
import {
FormFill,
FormUnavailableStatus,
} from "@/ee/base/components/form/form-fill";
import { FormStatus } from "@/ee/base/components/form/form-status";
import classes from "@/ee/base/styles/form.module.css";
type FormRespondProps = {
view: IBaseView;
pageId: string;
definition: PublicForm;
};
export function FormRespond({ view, pageId, definition }: FormRespondProps) {
const { t } = useTranslation();
const { data: share, isLoading: shareLoading } = useFormShareQuery(
pageId,
view.id,
);
const key = share && share.access !== "none" ? share.key : null;
const { data: publicForm, isLoading: formLoading } = usePublicFormQuery(
key ?? undefined,
);
const submitMutation = useSubmitPublicFormMutation();
const { users } = useReferenceStore(pageId);
const [unavailable, setUnavailable] =
useState<FormUnavailableStatus | null>(null);
if (shareLoading || (key && formLoading)) {
return (
<Stack align="center" py="xl">
<Loader size="sm" />
</Stack>
);
}
if (unavailable) {
return <FormStatus variant={unavailable} />;
}
if (key && publicForm?.status === "open") {
return (
<FormFill
form={publicForm.form}
respondent={publicForm.respondent}
users={publicForm.users}
mode="live"
onSubmit={(answers) => submitMutation.mutateAsync({ key, answers })}
onUnavailable={setUnavailable}
/>
);
}
return (
<>
<Alert
variant="light"
color="gray"
icon={<IconLock size={16} />}
className={classes.notice}
>
{t("This form isn't accepting responses.")}
</Alert>
<FormFill form={definition} mode="readOnly" users={users} />
</>
);
}
@@ -0,0 +1,33 @@
import { Fragment } from "react";
import clsx from "clsx";
import classes from "@/ee/base/styles/form.module.css";
const URL_PATTERN = /(https?:\/\/[^\s<>"']*[^\s<>"'.,;:!?)\]}])/g;
type FormRichTextProps = {
text: string;
className?: string;
id?: string;
};
export function FormRichText({ text, className, id }: FormRichTextProps) {
const parts = text.split(URL_PATTERN);
return (
<div id={id} className={clsx(classes.richText, className)}>
{parts.map((part, index) =>
index % 2 === 1 ? (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer nofollow"
>
{part}
</a>
) : (
<Fragment key={index}>{part}</Fragment>
),
)}
</div>
);
}
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from "react";
import {
ActionIcon,
Divider,
Popover,
Stack,
Switch,
Text,
Textarea,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconSettings } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { FormConfig, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateFormConfig } from "@/ee/base/queries/base-form-query";
type TextSettingKey = "submitLabel" | "successTitle" | "successMessage";
type FormSettingsPopoverProps = {
pageId: string;
view: IBaseView;
};
export function FormSettingsPopover({ pageId, view }: FormSettingsPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const updateForm = useUpdateFormConfig(pageId, view.id);
const form: FormConfig = view.config?.form ?? {};
const commitText = (key: TextSettingKey, value: string) => {
const next = value.trim();
updateForm((current) =>
(current[key] ?? "") === next
? current
: { ...current, [key]: next || undefined },
);
};
return (
<Popover
opened={opened}
onChange={setOpened}
width={320}
position="bottom-end"
shadow="md"
withinPortal
trapFocus
>
<Popover.Target>
<Tooltip label={t("Form settings")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
aria-label={t("Form settings")}
onClick={() => setOpened((o) => !o)}
>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t("Form settings")}
</Text>
<SettingTextField
label={t("Submit button label")}
placeholder={t("Submit")}
value={form.submitLabel ?? ""}
maxLength={60}
onCommit={(value) => commitText("submitLabel", value)}
/>
<Divider
label={t("After submitting")}
labelPosition="left"
my={4}
/>
<SettingTextField
label={t("Confirmation title")}
placeholder={t("Thank you!")}
value={form.successTitle ?? ""}
maxLength={200}
onCommit={(value) => commitText("successTitle", value)}
/>
<SettingTextField
label={t("Confirmation message")}
placeholder={t("Your response has been recorded.")}
value={form.successMessage ?? ""}
maxLength={2000}
multiline
onCommit={(value) => commitText("successMessage", value)}
/>
<Switch
size="sm"
label={t("Allow submitting another response")}
checked={form.allowResubmit !== false}
onChange={(event) => {
const allowResubmit = event.currentTarget.checked;
updateForm((current) => ({
...current,
allowResubmit: allowResubmit ? undefined : false,
}));
}}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
type SettingTextFieldProps = {
label: string;
placeholder: string;
value: string;
maxLength: number;
multiline?: boolean;
onCommit: (value: string) => void;
};
function SettingTextField({
label,
placeholder,
value,
maxLength,
multiline,
onCommit,
}: SettingTextFieldProps) {
const [draft, setDraft] = useState(value);
const focusedRef = useRef(false);
const commit = useDebouncedCallback(onCommit, {
delay: 500,
flushOnUnmount: true,
});
useEffect(() => {
if (!focusedRef.current) setDraft(value);
}, [value]);
const inputProps = {
label,
placeholder,
maxLength,
size: "xs" as const,
value: draft,
onFocus: () => {
focusedRef.current = true;
},
onBlur: () => {
focusedRef.current = false;
commit.flush();
},
};
if (multiline) {
return (
<Textarea
{...inputProps}
autosize
minRows={2}
maxRows={6}
onChange={(event) => {
setDraft(event.currentTarget.value);
commit(event.currentTarget.value);
}}
/>
);
}
return (
<TextInput
{...inputProps}
onChange={(event) => {
setDraft(event.currentTarget.value);
commit(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
/>
);
}
@@ -0,0 +1,214 @@
import {
ActionIcon,
Alert,
Anchor,
Button,
Group,
Loader,
Popover,
Radio,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconExternalLink,
IconInfoCircle,
IconLock,
IconUsers,
IconWorld,
} from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy";
import { getAppUrl, isCloud } from "@/lib/config";
import useTrial from "@/ee/hooks/use-trial";
import APP_ROUTE from "@/lib/app-route";
import { FormAccess, IBaseView } from "@/ee/base/types/base.types";
import {
useFormShareQuery,
useUpdateFormShareMutation,
} from "@/ee/base/queries/base-form-query";
import classes from "@/ee/base/styles/form.module.css";
type FormSharePopoverProps = {
pageId: string;
view: IBaseView;
};
export function FormSharePopover({ pageId, view }: FormSharePopoverProps) {
const { t } = useTranslation();
const { isTrial } = useTrial();
const { data: share, isLoading } = useFormShareQuery(pageId, view.id);
const updateShare = useUpdateFormShareMutation(view.id);
const access: FormAccess =
(updateShare.isPending ? updateShare.variables?.access : undefined) ??
share?.access ??
"none";
const publicBlockedByPlan = isCloud() && isTrial;
const publicBlockedByAdmin = share ? !share.publicSharingAllowed : false;
const link = share?.key ? `${getAppUrl()}/forms/${share.key}` : null;
const handleAccessChange = (value: string) => {
const next = value as FormAccess;
if (next === access) return;
updateShare.mutate({ pageId, viewId: view.id, access: next });
};
return (
<Popover width={360} position="bottom-end" shadow="md" withinPortal>
<Popover.Target>
<Button size="compact-sm" fw={500}>
{t("Share form")}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<div>
<Text size="sm" fw={600}>
{t("Share form")}
</Text>
<Text size="xs" c="dimmed">
{t("Choose who can submit responses to this form.")}
</Text>
</div>
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : (
<Radio.Group value={access} onChange={handleAccessChange}>
<Stack gap={6}>
<AccessOption
value="none"
icon={<IconLock size={16} />}
title={t("Closed")}
description={t("Nobody can submit responses.")}
/>
<AccessOption
value="workspace"
icon={<IconUsers size={16} />}
title={t("Workspace members")}
description={t(
"Members sign in to respond. Their name is recorded with each response.",
)}
/>
<AccessOption
value="public"
icon={<IconWorld size={16} />}
title={t("Anyone with the link")}
description={t("Anyone can respond. Responses are anonymous.")}
disabled={
access !== "public" &&
(publicBlockedByPlan || publicBlockedByAdmin)
}
/>
</Stack>
</Radio.Group>
)}
{publicBlockedByPlan && access !== "public" && (
<Text size="xs" c="dimmed">
{t("Public forms are available on paid plans.")}{" "}
<Anchor
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
size="xs"
>
{t("Upgrade plan")}
</Anchor>
</Text>
)}
{publicBlockedByAdmin && (
<Alert
variant="light"
color={access === "public" ? "orange" : "gray"}
icon={<IconInfoCircle size={16} />}
p="xs"
>
<Text size="xs">
{access === "public"
? t(
"Public sharing is disabled for this workspace or space, so this form isn't accepting responses.",
)
: t(
"Public sharing is disabled for this workspace or space.",
)}
</Text>
</Alert>
)}
{link && access !== "none" && (
<Group gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={link}
readOnly
aria-label={t("Form link")}
rightSection={<CopyTextButton text={link} />}
style={{ flex: 1 }}
/>
<Tooltip label={t("Open form")} withArrow>
<ActionIcon
component="a"
href={link}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="lg"
aria-label={t("Open form")}
>
<IconExternalLink size={16} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
type AccessOptionProps = {
value: FormAccess;
icon: React.ReactNode;
title: string;
description: string;
disabled?: boolean;
};
function AccessOption({
value,
icon,
title,
description,
disabled,
}: AccessOptionProps) {
return (
<Radio.Card
value={value}
radius="md"
className={classes.accessCard}
disabled={disabled}
>
<Group wrap="nowrap" align="flex-start" gap="sm">
<Radio.Indicator size="xs" mt={2} disabled={disabled} />
<Stack gap={2} style={{ flex: 1 }}>
<Group gap={6} wrap="nowrap">
{icon}
<Text size="sm" fw={500}>
{title}
</Text>
</Group>
<Text size="xs" c="dimmed">
{description}
</Text>
</Stack>
</Group>
</Radio.Card>
);
}
@@ -0,0 +1,80 @@
import { Button, Text, ThemeIcon } from "@mantine/core";
import {
IconAlertTriangle,
IconFileOff,
IconLock,
IconLogin2,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/ee/base/styles/form.module.css";
export type FormStatusVariant =
| "closed"
| "signInRequired"
| "notFound"
| "error";
type FormStatusProps = {
variant: FormStatusVariant;
workspaceName?: string;
onSignIn?: () => void;
onRetry?: () => void;
retrying?: boolean;
};
export function FormStatus({
variant,
workspaceName,
onSignIn,
onRetry,
retrying,
}: FormStatusProps) {
const { t } = useTranslation();
const content = {
closed: {
icon: IconLock,
title: t("This form is closed"),
message: t("It's no longer accepting responses."),
},
signInRequired: {
icon: IconLogin2,
title: t("Sign in to fill out this form"),
message: workspaceName
? t("Only members of {{workspace}} can respond to this form.", {
workspace: workspaceName,
})
: t("Only workspace members can respond to this form."),
},
notFound: {
icon: IconFileOff,
title: t("Form not found"),
message: t("This form doesn't exist or is no longer available."),
},
error: {
icon: IconAlertTriangle,
title: t("Something went wrong"),
message: t("We couldn't load this form."),
},
}[variant];
const Icon = content.icon;
return (
<div className={classes.status}>
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
<Icon size={28} stroke={1.5} />
</ThemeIcon>
<h2 className={classes.statusTitle}>{content.title}</h2>
<Text c="dimmed">{content.message}</Text>
{variant === "signInRequired" && onSignIn && (
<Button onClick={onSignIn}>{t("Sign in")}</Button>
)}
{variant === "error" && onRetry && (
<Button variant="default" loading={retrying} onClick={onRetry}>
{t("Try again")}
</Button>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import { Button, useComputedColorScheme } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import classes from "@/ee/base/styles/form.module.css";
type FormSubmitButtonProps = {
label: string;
type?: "button" | "submit";
loading?: boolean;
disabled?: boolean;
};
export function FormSubmitButton({
label,
type = "button",
loading,
disabled,
}: FormSubmitButtonProps) {
const colorScheme = useComputedColorScheme("light");
return (
<Button
type={type}
size="sm"
radius="md"
color={colorScheme === "dark" ? "gray.0" : "dark.7"}
autoContrast
className={classes.submit}
rightSection={<IconArrowRight size={16} className={classes.submitArrow} />}
loading={loading}
disabled={disabled}
>
{label}
</Button>
);
}
@@ -55,8 +55,9 @@ export function CardField({ property, value, pageId }: CardFieldProps) {
return <TimestampField value={value} />;
case "person":
return <PersonField value={value} pageId={pageId} />;
case "createdBy":
case "lastEditedBy":
return <LastEditedByField value={value} pageId={pageId} />;
return <SystemUserField value={value} pageId={pageId} />;
case "file":
return <FileField value={value} />;
case "page":
@@ -170,7 +171,7 @@ function PersonField({ value, pageId }: { value: unknown; pageId: string }) {
return <PersonReadList personIds={personIds} users={store.users} />;
}
function LastEditedByField({ value, pageId }: { value: unknown; pageId: string }) {
function SystemUserField({ value, pageId }: { value: unknown; pageId: string }) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(pageId);
if (!userId) return null;
@@ -2,6 +2,7 @@ import type { BasePropertyType } from "@/ee/base/types/base.types";
export const NON_USER_TARGET_TYPES = new Set<BasePropertyType>([
"createdAt",
"createdBy",
"lastEditedAt",
"lastEditedBy",
"formula",
@@ -34,14 +34,17 @@ type CreatePropertyPopoverProps = {
onPropertyCreated?: (property: IBaseProperty) => void;
/** Custom trigger; must return a ref-forwarding element for Popover.Target.
* Defaults to the grid's + column button. */
renderTarget?: (open: () => void) => React.ReactElement;
renderTarget?: (
open: (trigger?: React.SyntheticEvent | HTMLElement) => void,
) => React.ReactElement;
excludeTypes?: Set<BasePropertyType>;
};
type Panel = "typePicker" | "configure" | "confirmDiscard";
const noop = () => {};
export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, renderTarget }: CreatePropertyPopoverProps) {
export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, renderTarget, excludeTypes }: CreatePropertyPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [panel, setPanel] = useState<Panel>("typePicker");
@@ -99,11 +102,14 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
}, []);
const handleOpen = useCallback(
(event?: React.SyntheticEvent) => {
(trigger?: React.SyntheticEvent | HTMLElement) => {
resetState();
const trigger = event?.currentTarget as HTMLElement | undefined;
if (trigger) {
const rect = trigger.getBoundingClientRect();
const anchor =
trigger instanceof HTMLElement
? trigger
: (trigger?.currentTarget as HTMLElement | undefined);
if (anchor) {
const rect = anchor.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
setPosition(spaceAbove > spaceBelow ? "top-start" : "bottom-start");
@@ -284,6 +290,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
>
<PropertyTypePicker
onSelect={handleTypeSelect}
excludeTypes={excludeTypes}
showSearch
/>
</ScrollArea.Autosize>
@@ -1,12 +1,18 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAtom } from "jotai";
import { Menu, ActionIcon, Tooltip } from "@mantine/core";
import { IconPlus, IconTable, IconLayoutKanban, IconArrowLeft } from "@tabler/icons-react";
import { IconPlus, IconArrowLeft } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBase } from "@/ee/base/types/base.types";
import { BaseViewType, IBase, ViewConfig } from "@/ee/base/types/base.types";
import { useCreateViewMutation } from "@/ee/base/queries/base-view-query";
import { activeViewIdAtomFamily } from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { VIEW_TYPE_ICONS } from "@/ee/base/components/views/view-type-icon";
import {
MAX_FORM_FIELDS,
isFormFieldProperty,
} from "@/ee/base/components/form/form-definition";
import { usePageQuery } from "@/features/page/queries/page-query";
type Panel = "types" | "groupBy";
@@ -21,6 +27,7 @@ export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
const [panel, setPanel] = useState<Panel>("types");
const dropdownRef = useRef<HTMLDivElement>(null);
const createViewMutation = useCreateViewMutation();
const { data: page } = usePageQuery({ pageId });
const [, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
@@ -35,7 +42,7 @@ export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
}, []);
const submitView = useCallback(
(input: { name: string; type: "table" | "kanban"; config?: Record<string, unknown> }) => {
(input: { name: string; type: BaseViewType; config?: ViewConfig }) => {
createViewMutation.mutate(
{ pageId, ...input },
{ onSuccess: (created) => setActiveViewId(created.id) },
@@ -61,6 +68,23 @@ export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
}
}, [groupable, submitView, t]);
const handleCreateForm = useCallback(() => {
const title = page?.title?.trim().slice(0, 300);
submitView({
name: t("Form"),
type: "form",
config: {
form: {
...(title ? { title } : {}),
fields: base.properties
.filter(isFormFieldProperty)
.slice(0, MAX_FORM_FIELDS)
.map((property) => ({ propertyId: property.id })),
},
},
});
}, [base.properties, page?.title, submitView, t]);
const handleGroupByPick = useCallback(
(propertyId: string) => {
submitView({
@@ -105,12 +129,15 @@ export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
<Menu.Dropdown ref={dropdownRef}>
{panel === "types" && (
<>
<Menu.Item leftSection={<IconTable size={14} />} onClick={handleCreateTable}>
<Menu.Item leftSection={<VIEW_TYPE_ICONS.table size={14} />} onClick={handleCreateTable}>
{t("Table")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={14} />} onClick={handleBoardClick}>
<Menu.Item leftSection={<VIEW_TYPE_ICONS.kanban size={14} />} onClick={handleBoardClick}>
{t("Kanban")}
</Menu.Item>
<Menu.Item leftSection={<VIEW_TYPE_ICONS.form size={14} />} onClick={handleCreateForm}>
{t("Form")}
</Menu.Item>
</>
)}
@@ -7,6 +7,7 @@ import {
} from "@/ee/base/types/base.types";
import { BaseTable } from "@/ee/base/components/base-table";
import { BaseKanban } from "@/ee/base/components/kanban/base-kanban";
import { BaseForm } from "@/ee/base/components/form/base-form";
type ViewRendererProps = {
base: IBase;
@@ -51,6 +52,19 @@ export function ViewRenderer(props: ViewRendererProps) {
);
}
if (viewType === "form") {
return (
<BaseForm
key={props.effectiveView!.id}
base={props.base}
view={props.effectiveView!}
pageId={props.pageId}
editable={props.editable}
embedded={props.embedded}
/>
);
}
if (viewType === "table") {
return <BaseTable {...props} />;
}
@@ -15,13 +15,7 @@ import {
Stack,
Divider,
} from "@mantine/core";
import {
IconPencil,
IconTrash,
IconTable,
IconLink,
IconLayoutKanban,
} from "@tabler/icons-react";
import { IconPencil, IconTrash, IconLink } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
@@ -36,6 +30,7 @@ import {
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { ViewCreateMenu } from "@/ee/base/components/views/view-create-menu";
import { VIEW_TYPE_ICONS } from "@/ee/base/components/views/view-type-icon";
import {
useUpdateViewMutation,
useDeleteViewMutation,
@@ -230,6 +225,7 @@ function ViewTab({
const [menuOpened, setMenuOpened] = useState(false);
const editable = useBaseEditable();
const tabRef = useRef<HTMLDivElement>(null);
const ViewIcon = VIEW_TYPE_ICONS[view.type] ?? VIEW_TYPE_ICONS.table;
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
@@ -353,11 +349,7 @@ function ViewTab({
}}
>
<Group gap={6} wrap="nowrap">
{view.type === "kanban" ? (
<IconLayoutKanban size={14} opacity={isActive ? 1 : 0.5} />
) : (
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
)}
<ViewIcon size={14} opacity={isActive ? 1 : 0.5} />
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
{view.name}
</Text>
@@ -0,0 +1,14 @@
import {
IconCalendar,
IconForms,
IconLayoutKanban,
IconTable,
} from "@tabler/icons-react";
import { BaseViewType } from "@/ee/base/types/base.types";
export const VIEW_TYPE_ICONS: Record<BaseViewType, typeof IconTable> = {
table: IconTable,
kanban: IconLayoutKanban,
calendar: IconCalendar,
form: IconForms,
};
@@ -7,16 +7,26 @@ import {
IBaseProperty,
IBaseRow,
IBaseView,
RowReferences,
} from "@/ee/base/types/base.types";
import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms";
import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
import {
mergeReferences,
referenceStoreAtomFamily,
} from "@/ee/base/atoms/reference-store-atom";
import { IPagination } from "@/lib/types";
import { invalidateBaseRows } from "@/ee/base/queries/base-row-query";
import {
formShareQueryKey,
invalidatePublicFormForView,
} from "@/ee/base/queries/base-form-query";
type BaseRowCreated = {
operation: "base:row:created";
pageId: string;
row: IBaseRow;
references?: RowReferences;
requestId?: string | null;
};
@@ -72,6 +82,13 @@ type BaseViewEvent = {
viewId?: string;
};
type BaseViewShareUpdated = {
operation: "base:view:share:updated";
pageId: string;
viewId: string;
requestId?: string | null;
};
type BaseRowsUpdated = {
operation: "base:rows:updated";
pageId: string;
@@ -121,6 +138,7 @@ type BaseInboundEvent =
| BaseSubscribed
| BasePropertyEvent
| BaseViewEvent
| BaseViewShareUpdated
| { operation: string; pageId: string };
// Module-level set of requestIds we've just sent. When the socket echoes back
@@ -162,9 +180,31 @@ export function useBaseSocket(pageId: string | undefined): void {
switch (event.operation) {
case "base:row:created": {
const e = event as BaseRowCreated;
const store = getDefaultStore();
const referencesAtom = referenceStoreAtomFamily(pageId);
if (e.references && Object.keys(e.references.users).length > 0) {
store.set(
referencesAtom,
mergeReferences(store.get(referencesAtom), e.references),
);
}
const baseForCreate = queryClient.getQueryData<IBase>(["bases", pageId]);
const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForCreate) {
const createProperties = baseForCreate?.properties ?? [];
const showsRowUsers = createProperties.some(
(p) => p.type === "createdBy" || p.type === "lastEditedBy",
);
const knownUsers = store.get(referencesAtom).users;
const referencedUserIds = [
...(showsRowUsers ? [e.row.creatorId, e.row.lastUpdatedById] : []),
...createProperties
.filter((p) => p.type === "person")
.flatMap((p) => e.row.cells?.[p.id] ?? []),
];
const usersUnresolved = referencedUserIds.some(
(id) => typeof id === "string" && !knownUsers[id],
);
if (hasKanbanForCreate || usersUnresolved) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
@@ -364,7 +404,35 @@ export function useBaseSocket(pageId: string | undefined): void {
case "base:view:updated":
case "base:view:deleted": {
// Schema/metadata events only affect properties/views, not cell data.
const e = event as BasePropertyEvent | BaseViewEvent;
const cachedBase = queryClient.getQueryData<IBase>(["bases", pageId]);
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
if (e.operation === "base:property:updated" && e.property) {
const propertyId = e.property.id;
for (const view of cachedBase?.views ?? []) {
if (
view.config?.form?.fields?.some(
(field) => field.propertyId === propertyId,
)
) {
invalidatePublicFormForView(pageId, view.id);
}
}
} else if (e.operation === "base:view:updated" && e.view?.type === "form") {
invalidatePublicFormForView(pageId, e.view.id);
} else if (e.operation === "base:view:deleted" && e.viewId) {
queryClient.invalidateQueries({
queryKey: formShareQueryKey(pageId, e.viewId),
});
}
break;
}
case "base:view:share:updated": {
const e = event as BaseViewShareUpdated;
invalidatePublicFormForView(pageId, e.viewId);
queryClient.invalidateQueries({
queryKey: formShareQueryKey(pageId, e.viewId),
});
break;
}
default:
@@ -0,0 +1,161 @@
import "@fontsource-variable/inter";
import "@/styles/public-typography.css";
import { ReactNode, useState } from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Skeleton,
Stack,
Text,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { isAxiosError } from "axios";
import clsx from "clsx";
import { DocumentTitle } from "@/components/ui/document-title";
import APP_ROUTE from "@/lib/app-route";
import {
usePublicFormQuery,
useSubmitPublicFormMutation,
} from "@/ee/base/queries/base-form-query";
import {
FormFill,
FormUnavailableStatus,
} from "@/ee/base/components/form/form-fill";
import { FormStatus } from "@/ee/base/components/form/form-status";
import classes from "@/ee/base/styles/public-form.module.css";
export default function PublicFormPage() {
const { t } = useTranslation();
const { formKey } = useParams();
const { data, isLoading, isFetching, isError, error, refetch } =
usePublicFormQuery(formKey);
const submitMutation = useSubmitPublicFormMutation();
const [unavailable, setUnavailable] =
useState<FormUnavailableStatus | null>(null);
const signIn = () => {
const params = new URLSearchParams({ redirect: `/forms/${formKey}` });
window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`;
};
const errorStatus = isAxiosError(error) ? error.response?.status : undefined;
let content: ReactNode;
if (isLoading) {
content = <PublicFormSkeleton />;
} else if (unavailable) {
content = <FormStatus variant={unavailable} onSignIn={signIn} />;
} else if (isError || !data) {
content =
errorStatus === 400 || errorStatus === 404 ? (
<FormStatus variant="notFound" />
) : errorStatus === 403 ? (
<FormStatus variant="closed" />
) : (
<FormStatus
variant="error"
onRetry={() => refetch()}
retrying={isFetching}
/>
);
} else if (data.status === "closed") {
content = <FormStatus variant="closed" />;
} else if (data.status === "signInRequired") {
content = (
<FormStatus
variant="signInRequired"
workspaceName={data.workspaceName}
onSignIn={signIn}
/>
);
} else {
content = (
<FormFill
form={data.form}
respondent={data.respondent}
users={data.users}
mode="live"
onSubmit={(answers) =>
submitMutation.mutateAsync({ key: formKey!, answers })
}
onUnavailable={setUnavailable}
/>
);
}
const title =
data?.status === "open" ? data.form.title || t("Untitled form") : t("Form");
return (
<div className={clsx(classes.page, "public-typography")}>
<DocumentTitle title={title} withAppName={false}>
<meta name="robots" content="noindex" />
</DocumentTitle>
<header className={classes.topBar}>
<ColorSchemeToggle />
</header>
<main className={classes.main}>{content}</main>
<footer className={classes.footer}>
<Text size="xs" c="dimmed">
{t("Never submit passwords or other sensitive information through this form.")}
</Text>
<a
className={classes.branding}
href="https://docmost.com?ref=public-form"
target="_blank"
rel="noreferrer"
>
Powered by Docmost
</a>
</footer>
</div>
);
}
function ColorSchemeToggle() {
const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const label = t("Toggle color scheme");
return (
<Tooltip label={label} withArrow>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label={label}
onClick={() =>
setColorScheme(computedColorScheme === "light" ? "dark" : "light")
}
>
{computedColorScheme === "light" ? (
<IconMoon size={18} stroke={1.75} />
) : (
<IconSun size={18} stroke={1.75} />
)}
</ActionIcon>
</Tooltip>
);
}
function PublicFormSkeleton() {
return (
<Stack gap="md" aria-hidden>
<Skeleton height={40} width="60%" radius="sm" />
<Skeleton height={14} width="90%" radius="sm" mt={8} />
<Skeleton height={14} width="70%" radius="sm" />
<Skeleton height={22} width="45%" radius="sm" mt={32} />
<Skeleton height={42} radius="md" />
<Skeleton height={22} width="35%" radius="sm" mt={24} />
<Skeleton height={42} radius="md" />
</Stack>
);
}
@@ -13,6 +13,7 @@ import {
IconLink,
IconMail,
IconClockPlus,
IconUserPlus,
IconClockEdit,
IconUserEdit,
IconMathFunction,
@@ -36,7 +37,7 @@ import { CellFile } from "@/ee/base/components/cells/cell-file";
import { CellPage } from "@/ee/base/components/cells/cell-page";
import { CellCreatedAt } from "@/ee/base/components/cells/cell-created-at";
import { CellLastEditedAt } from "@/ee/base/components/cells/cell-last-edited-at";
import { CellLastEditedBy } from "@/ee/base/components/cells/cell-last-edited-by";
import { CellSystemUser } from "@/ee/base/components/cells/cell-system-user";
import { CellFormula } from "@/ee/base/components/cells/cell-formula";
import { defaultStatusChoices } from "@/ee/base/components/property/choice-editor";
import type { ClientPropertyTypeDescriptor } from "./property-type.descriptor";
@@ -205,6 +206,17 @@ export const PROPERTY_TYPE_REGISTRY: Record<
hasOptions: false,
systemAccessor: (row) => row.createdAt,
},
createdBy: {
type: "createdBy",
cellComponent: CellSystemUser,
icon: IconUserPlus,
labelKey: "Created by",
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
filterInput: "person",
isSystem: true,
hasOptions: false,
systemAccessor: (row) => row.creatorId,
},
lastEditedAt: {
type: "lastEditedAt",
cellComponent: CellLastEditedAt,
@@ -218,7 +230,7 @@ export const PROPERTY_TYPE_REGISTRY: Record<
},
lastEditedBy: {
type: "lastEditedBy",
cellComponent: CellLastEditedBy,
cellComponent: CellSystemUser,
icon: IconUserEdit,
labelKey: "Last edited by",
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
@@ -246,7 +258,7 @@ export const DEFAULT_FILTER_OPERATORS = ["eq", "neq", "isEmpty", "isNotEmpty"];
export const PROPERTY_PICKER_ORDER: BasePropertyType[] = [
"text", "longText", "number", "select", "status", "multiSelect", "date",
"person", "file", "formula", "page", "checkbox", "url", "email",
"createdAt", "lastEditedAt", "lastEditedBy",
"createdAt", "createdBy", "lastEditedAt", "lastEditedBy",
];
export const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
@@ -0,0 +1,159 @@
import { useCallback } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { queryClient } from "@/main";
import { getApiErrorMessage } from "@/lib/api-error";
import {
getFormShare,
getPublicForm,
submitFormPreview,
submitPublicForm,
updateFormShare,
} from "@/ee/base/services/base-form-service";
import { updateView } from "@/ee/base/services/base-service";
import { newRequestId } from "@/ee/base/queries/base-row-query";
import {
FormConfig,
FormShareInfo,
IBase,
IBaseView,
PublicFormResponse,
SubmitFormPreviewInput,
SubmitPublicFormInput,
UpdateFormShareInput,
} from "@/ee/base/types/base.types";
export function formShareQueryKey(pageId: string, viewId: string) {
return ["base-form-share", pageId, viewId];
}
function publicFormQueryKey(key: string | undefined) {
return ["public-form", key];
}
export function invalidatePublicFormForView(pageId: string, viewId: string) {
const key = queryClient.getQueryData<FormShareInfo>(
formShareQueryKey(pageId, viewId),
)?.key;
if (key) {
queryClient.invalidateQueries({ queryKey: publicFormQueryKey(key) });
}
}
export function useFormShareQuery(pageId: string, viewId: string | undefined) {
return useQuery<FormShareInfo, Error>({
queryKey: formShareQueryKey(pageId, viewId ?? ""),
queryFn: () => getFormShare(pageId, viewId!),
enabled: !!pageId && !!viewId,
refetchOnMount: true,
});
}
export function useUpdateFormShareMutation(viewId: string) {
const { t } = useTranslation();
return useMutation<FormShareInfo, Error, UpdateFormShareInput>({
scope: { id: `base-form-share:${viewId}` },
mutationFn: (data) =>
updateFormShare({ ...data, requestId: newRequestId() }),
onSuccess: (share, variables) => {
queryClient.setQueryData(
formShareQueryKey(variables.pageId, variables.viewId),
share,
);
},
onError: (error) => {
notifications.show({
message: getApiErrorMessage(error, t("Failed to update form sharing")),
color: "red",
});
},
});
}
export function usePublicFormQuery(key: string | undefined) {
return useQuery<PublicFormResponse, Error>({
queryKey: publicFormQueryKey(key),
queryFn: () => getPublicForm(key!),
enabled: !!key,
retry: false,
staleTime: Infinity,
refetchOnMount: true,
refetchOnWindowFocus: false,
});
}
export function useSubmitPublicFormMutation() {
return useMutation<void, Error, SubmitPublicFormInput>({
mutationFn: (data) => submitPublicForm(data),
});
}
export function useSubmitFormPreviewMutation() {
return useMutation<void, Error, SubmitFormPreviewInput>({
mutationFn: (data) => submitFormPreview(data),
});
}
const pendingForms = new Map<string, FormConfig>();
export function useUpdateFormConfig(pageId: string, viewId: string) {
const { t } = useTranslation();
const { mutate } = useMutation<IBaseView, Error, FormConfig>({
scope: { id: `base-form-config:${viewId}` },
mutationFn: (form) =>
updateView({
viewId,
pageId,
config: { form },
requestId: newRequestId(),
}),
onMutate: async (form) => {
await queryClient.cancelQueries({ queryKey: ["bases", pageId] });
queryClient.setQueryData<IBase>(["bases", pageId], (old) =>
old
? {
...old,
views: old.views.map((v) =>
v.id === viewId ? { ...v, config: { ...v.config, form } } : v,
),
}
: old,
);
},
onSuccess: (view, form) => {
if (pendingForms.get(viewId) !== form) return;
pendingForms.delete(viewId);
queryClient.setQueryData<IBase>(["bases", pageId], (old) =>
old
? {
...old,
views: old.views.map((v) => (v.id === view.id ? view : v)),
}
: old,
);
},
onError: (error, form) => {
if (pendingForms.get(viewId) === form) pendingForms.delete(viewId);
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
notifications.show({
message: getApiErrorMessage(error, t("Failed to update form")),
color: "red",
});
},
});
return useCallback(
(recipe: (form: FormConfig) => FormConfig) => {
const base = queryClient.getQueryData<IBase>(["bases", pageId]);
const view = base?.views.find((v) => v.id === viewId);
if (!view) return;
const current = pendingForms.get(viewId) ?? view.config?.form ?? {};
const next = recipe(current);
if (next === current) return;
pendingForms.set(viewId, next);
mutate(next);
},
[pageId, viewId, mutate],
);
}
@@ -12,6 +12,7 @@ import {
} from "@/ee/base/services/base-service";
import {
IBase,
BaseTemplate,
CreateBaseInput,
UpdateBaseInput,
} from "@/ee/base/types/base.types";
@@ -60,7 +61,7 @@ export function useConvertPageToBaseMutation() {
const [, setTreeData] = useAtom(treeDataAtom);
const [socket] = useAtom(socketAtom);
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
return useMutation<IBase, Error, { pageId: string; template?: BaseTemplate }>({
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
onSuccess: (base) => {
const markAsBase = (old?: IPage) => (old ? { ...old, isBase: true } : old);
@@ -49,7 +49,7 @@ export function normalizeFilter(filter: FilterNode | undefined): FilterNode | un
}
// Pre-register the requestId as outbound so the socket echo is suppressed by useBaseSocket.
function newRequestId(): string {
export function newRequestId(): string {
const id = uuid7();
markRequestIdOutbound(id);
return id;
@@ -0,0 +1,45 @@
import api from "@/lib/api-client";
import {
FormShareInfo,
PublicFormResponse,
SubmitFormPreviewInput,
SubmitPublicFormInput,
UpdateFormShareInput,
} from "@/ee/base/types/base.types";
export async function getFormShare(
pageId: string,
viewId: string,
): Promise<FormShareInfo> {
const req = await api.post<FormShareInfo>("/bases/forms/share", {
pageId,
viewId,
});
return req.data;
}
export async function updateFormShare(
data: UpdateFormShareInput,
): Promise<FormShareInfo> {
const req = await api.post<FormShareInfo>("/bases/forms/share/update", data);
return req.data;
}
export async function getPublicForm(key: string): Promise<PublicFormResponse> {
const req = await api.post<PublicFormResponse>("/bases/forms/public/info", {
key,
});
return req.data;
}
export async function submitPublicForm(
data: SubmitPublicFormInput,
): Promise<void> {
await api.post("/bases/forms/public/submit", data);
}
export async function submitFormPreview(
data: SubmitFormPreviewInput,
): Promise<void> {
await api.post("/bases/forms/submit", data);
}
@@ -19,6 +19,7 @@ import {
CreateViewInput,
UpdateViewInput,
DeleteViewInput,
BaseTemplate,
UpdatePropertyResult,
FilterNode,
ViewSortConfig,
@@ -51,7 +52,7 @@ export async function deleteBase(pageId: string): Promise<void> {
export async function convertPageToBase(
pageId: string,
template?: "kanban",
template?: BaseTemplate,
): Promise<IBase> {
const req = await api.post<IBase>("/bases/convert", { pageId, template });
return req.data;
@@ -441,7 +441,7 @@
border: 2px solid var(--mantine-color-blue-3);
}
/* Inline name span inside CellLastEditedBy. */
/* Inline name span inside CellSystemUser. */
.lastEditedByName {
font-size: var(--mantine-font-size-sm);
overflow: hidden;
@@ -0,0 +1,600 @@
.canvas {
width: 100%;
max-width: 680px;
margin: 0 auto;
padding: 24px 0 64px;
}
.canvas[data-embedded] {
max-width: 640px;
margin: 0;
padding: 12px 0 24px;
}
.scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.form,
.builder {
--form-border: light-dark(rgba(15, 23, 42, 0.16), rgba(255, 255, 255, 0.14));
--form-border-hover: light-dark(rgba(15, 23, 42, 0.24), rgba(255, 255, 255, 0.2));
--form-surface: light-dark(var(--mantine-color-white), rgba(255, 255, 255, 0.03));
--form-surface-hover: light-dark(rgba(15, 23, 42, 0.025), rgba(255, 255, 255, 0.055));
--form-shadow-color: light-dark(rgba(15, 23, 42, 0.05), transparent);
--form-placeholder: light-dark(#767676, #929292);
--form-muted: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
--form-accent: var(--mantine-primary-color-filled);
--form-selected-bg: light-dark(
color-mix(in srgb, var(--mantine-primary-color-filled) 6%, var(--mantine-color-white)),
color-mix(in srgb, var(--mantine-primary-color-filled) 14%, transparent)
);
}
.form {
display: flex;
flex-direction: column;
gap: 36px;
}
.header {
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
margin: 0;
font-size: clamp(28px, 4.5vw, 38px);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.025em;
overflow-wrap: anywhere;
color: var(--mantine-color-text);
}
.description {
font-size: 16px;
line-height: 1.6;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.richText {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.richText a {
color: inherit;
text-decoration: underline;
text-decoration-color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
text-underline-offset: 3px;
}
.richText a:hover {
text-decoration-color: currentColor;
}
.respondent {
display: flex;
align-items: center;
gap: 8px;
}
.body {
display: flex;
flex-direction: column;
gap: 36px;
}
.questions {
display: flex;
flex-direction: column;
gap: 32px;
}
.question {
display: flex;
flex-direction: column;
gap: 10px;
scroll-margin: 96px;
}
.questionHeader {
display: flex;
flex-direction: column;
gap: 3px;
}
.questionLabel {
font-size: 17px;
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.011em;
overflow-wrap: anywhere;
color: var(--mantine-color-text);
}
.requiredMark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-inline-start: 6px;
border-radius: 50%;
vertical-align: middle;
transform: translateY(-1px);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-1));
}
.questionDescription {
font-size: 14px;
line-height: 1.5;
color: var(--form-muted);
}
[data-mantine-color-scheme] .field[data-variant='default'] {
--input-bd: var(--form-border);
--input-bg: var(--form-surface);
--input-placeholder-color: var(--form-placeholder);
--input-section-color: var(--form-muted);
}
[data-mantine-color-scheme] .field[data-variant='default']:not([data-error]):hover {
--input-bd: var(--form-border-hover);
}
[data-mantine-color-scheme] .input[data-variant='default'] {
border-color: var(--input-bd);
}
.input {
font-size: 15px;
box-shadow: 0 1px 2px var(--form-shadow-color);
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.input:focus,
.input:focus-within {
box-shadow: 0 0 0 1px var(--input-bd);
}
.choiceGroup {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.optionList {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
}
.option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 36px;
padding: 6px 12px;
border: 1px solid var(--form-border);
border-radius: 8px;
background-color: var(--form-surface);
box-shadow: 0 1px 2px var(--form-shadow-color);
color: var(--mantine-color-text);
font-size: 15px;
line-height: 1.4;
text-align: start;
transition:
border-color 120ms ease,
background-color 120ms ease,
box-shadow 120ms ease;
}
.option:hover {
border-color: var(--form-border-hover);
background-color: var(--form-surface-hover);
}
.option[data-checked],
.option[data-checked]:hover {
border-color: var(--form-accent);
background-color: var(--form-selected-bg);
}
.option[data-invalid]:not([data-checked]) {
border-color: var(--mantine-color-error);
}
.option:focus-visible {
outline: none;
border-color: var(--form-accent);
box-shadow: 0 0 0 1px var(--form-accent);
}
.option[data-invalid]:not([data-checked]):focus-visible {
border-color: var(--mantine-color-error);
box-shadow: 0 0 0 1px var(--mantine-color-error);
}
.optionLabel {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.option[data-checked] .optionLabel {
font-weight: 500;
}
.personPill {
padding-inline-start: 3px;
}
.personPillLabel {
display: inline-flex;
align-items: center;
gap: 6px;
}
.personOptions {
max-height: 280px;
overflow-y: auto;
}
.personOption {
display: flex;
align-items: center;
gap: 10px;
}
.personOptionText {
flex: 1;
min-width: 0;
}
.questionHint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--form-muted);
}
.clearChoice {
font-size: 13px;
color: var(--form-muted);
text-decoration: underline;
text-decoration-color: var(--form-border-hover);
text-underline-offset: 3px;
}
.clearChoice:hover {
color: var(--mantine-color-text);
}
.errorText {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
line-height: 1.4;
color: var(--mantine-color-error);
}
.submit {
padding-inline: 18px;
font-weight: 600;
box-shadow: 0 1px 2px var(--form-shadow-color);
}
.submitArrow {
transition: transform 150ms ease;
}
.submit:hover .submitArrow {
transform: translateX(2px);
}
.previewBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 6px 12px;
border-radius: 8px;
background-color: light-dark(var(--mantine-color-blue-0), rgba(34, 139, 230, 0.12));
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-blue-3));
}
.previewBannerText {
flex: 1;
}
.success,
.status {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
text-align: center;
}
.successTitle,
.statusTitle {
margin: 4px 0 0;
font-size: 24px;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.015em;
color: var(--mantine-color-text);
}
.successTitle:focus {
outline: none;
}
.successMessage {
max-width: 480px;
color: var(--form-muted);
}
.notice {
margin-bottom: 24px;
}
.builder {
display: flex;
flex-direction: column;
gap: 28px;
}
.builderHeader {
display: flex;
flex-direction: column;
gap: 6px;
}
.inlineField {
display: block;
box-sizing: border-box;
overflow: hidden;
width: calc(100% + 16px);
margin: 0 -8px;
padding: 2px 8px;
border: none;
border-radius: 6px;
outline: none;
background: transparent;
font-family: inherit;
color: var(--mantine-color-text);
resize: none;
transition: background-color 100ms ease;
}
.inlineField:hover:not(:focus) {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.inlineField:focus {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
box-shadow: 0 0 0 1px light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.inlineField::placeholder {
color: var(--form-placeholder);
}
.inlineTitle {
font-size: clamp(28px, 4.5vw, 38px);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.025em;
}
.inlineDescription {
font-size: 16px;
line-height: 1.6;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.inlineLabel {
font-size: 17px;
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.011em;
}
.inlineHelp {
font-size: 14px;
line-height: 1.5;
color: var(--form-muted);
}
.builderQuestions {
display: flex;
flex-direction: column;
gap: 8px;
}
.builderQuestion {
position: relative;
}
.builderQuestion[data-dragging] {
opacity: 0.4;
}
.dragHandle {
position: absolute;
top: 16px;
left: -28px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 24px;
border-radius: 4px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
cursor: grab;
opacity: 0;
transition: opacity 100ms ease;
}
.dragHandle:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.builderQuestion:hover .dragHandle,
.builderQuestion:focus-within .dragHandle {
opacity: 1;
}
.questionCard {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
border: 1px solid transparent;
border-radius: 10px;
transition:
border-color 100ms ease,
background-color 100ms ease;
}
.builderQuestion:hover .questionCard,
.questionCard:focus-within,
.questionCard[data-pinned] {
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.questionCard:focus-within {
background-color: light-dark(rgba(248, 249, 250, 0.6), rgba(37, 38, 43, 0.6));
}
.labelRow {
display: flex;
align-items: flex-start;
gap: 4px;
}
.labelRow .inlineField {
flex: 1;
min-width: 0;
}
.labelRow .requiredMark {
flex-shrink: 0;
margin-top: 7px;
}
.controlPreview {
user-select: none;
}
.questionToolbar {
position: absolute;
top: -16px;
right: 12px;
z-index: 2;
display: flex;
align-items: center;
gap: 6px;
max-width: calc(100% - 24px);
padding: 2px 4px 2px 6px;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: 8px;
background-color: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-xs);
opacity: 0;
pointer-events: none;
transition: opacity 100ms ease;
}
.builderQuestion:hover .questionToolbar,
.questionCard:focus-within .questionToolbar,
.questionCard[data-pinned] .questionToolbar {
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
.dragHandle {
opacity: 1;
}
.questionToolbar {
opacity: 1;
pointer-events: auto;
}
}
.propertyChip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
color: var(--mantine-color-dimmed);
cursor: pointer;
}
.propertyChip:hover,
.propertyChip[data-active] {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
color: var(--mantine-color-text);
}
.propertyChipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.questionWarning {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4));
}
.emptyBuilder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 16px;
border: 1px dashed light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: 10px;
text-align: center;
}
.builderActions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.accessCard {
padding: 10px 12px;
}
.accessCard[data-checked] {
border-color: var(--mantine-primary-color-filled);
}
@@ -0,0 +1,50 @@
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
background-color: var(--mantine-color-body);
}
.topBar {
display: flex;
justify-content: flex-end;
padding: 12px 16px;
}
.main {
flex: 1;
width: 100%;
max-width: 688px;
margin: 0 auto;
padding: 16px 24px 64px;
}
.footer {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 688px;
margin: 0 auto;
padding: 20px 24px 40px;
border-top: 1px solid var(--mantine-color-default-border);
}
.branding {
align-self: flex-start;
font-size: 13px;
color: var(--mantine-color-dimmed);
text-decoration: none;
}
.branding:hover {
color: var(--mantine-color-text);
}
@media (max-width: 600px) {
.main,
.footer {
padding-inline: 16px;
}
}
+113 -2
View File
@@ -20,12 +20,15 @@ export type BasePropertyType =
| 'url'
| 'email'
| 'createdAt'
| 'createdBy'
| 'lastEditedAt'
| 'lastEditedBy'
| 'formula'
| 'longText';
export type BaseViewType = 'table' | 'kanban' | 'calendar';
export type BaseViewType = 'table' | 'kanban' | 'calendar' | 'form';
export type BaseTemplate = 'kanban' | 'form';
export type Choice = {
id: string;
@@ -124,7 +127,7 @@ export type IBaseRow = {
pageId: string;
cells: Record<string, unknown>;
position: string;
creatorId: string;
creatorId: string | null;
lastUpdatedById: string | null;
workspaceId: string;
createdAt: string;
@@ -214,6 +217,113 @@ export type ViewConfig = {
groupByPropertyId?: string;
hiddenChoiceIds?: string[];
choiceOrder?: string[];
form?: FormConfig;
};
export type FormFieldConfig = {
propertyId: string;
label?: string;
description?: string;
required?: boolean;
};
export type FormConfig = {
title?: string;
description?: string;
fields?: FormFieldConfig[];
submitLabel?: string;
successTitle?: string;
successMessage?: string;
allowResubmit?: boolean;
};
export type FormAccess = 'none' | 'workspace' | 'public';
export type FormShareInfo = {
viewId: string;
access: FormAccess;
key: string | null;
publicSharingAllowed: boolean;
};
export type UpdateFormShareInput = {
pageId: string;
viewId: string;
access: FormAccess;
requestId?: string;
};
export type FormFieldType =
| 'text'
| 'longText'
| 'number'
| 'select'
| 'multiSelect'
| 'date'
| 'checkbox'
| 'url'
| 'email'
| 'person';
export type FormFieldError = 'required' | 'invalid';
export type PublicFormChoice = { id: string; name: string; color: string };
export type PublicFormNumberFormat = {
format?: NumberTypeOptions['format'];
precision?: number;
currencyCode?: string;
currencySymbol?: string;
};
export type PublicFormField = {
propertyId: string;
type: FormFieldType;
label: string;
description: string | null;
required: boolean;
defaultValue: unknown;
choices?: PublicFormChoice[];
numberFormat?: PublicFormNumberFormat;
allowMultiple?: boolean;
};
export type PublicForm = {
title: string | null;
description: string | null;
submitLabel: string | null;
successTitle: string | null;
successMessage: string | null;
allowResubmit: boolean;
access: Exclude<FormAccess, 'none'>;
fields: PublicFormField[];
};
export type FormRespondent = {
name: string | null;
email: string;
avatarUrl: string | null;
};
export type PublicFormResponse =
| {
status: 'open';
form: PublicForm;
respondent: FormRespondent | null;
users: Record<string, UserRef>;
}
| { status: 'closed' }
| { status: 'signInRequired'; workspaceName: string };
export type SubmitPublicFormInput = {
key: string;
answers: Record<string, unknown>;
};
export type SubmitFormPreviewInput = {
pageId: string;
viewId: string;
answers: Record<string, unknown>;
};
export type ViewConfigPatch = {
@@ -357,6 +467,7 @@ export type UpdateViewInput = {
type?: BaseViewType;
config?: ViewConfigPatch;
position?: string;
requestId?: string;
};
export type DeleteViewInput = {
@@ -0,0 +1,20 @@
const ABSOLUTE_ISO_DATE =
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?$/;
export function toISODateString(
dateStr: string | null | undefined,
): string | null {
if (!dateStr) return null;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const absolute = ABSOLUTE_ISO_DATE.test(dateStr);
const year = absolute ? date.getUTCFullYear() : date.getFullYear();
const monthIndex = absolute ? date.getUTCMonth() : date.getMonth();
const day = absolute ? date.getUTCDate() : date.getDate();
const month = String(monthIndex + 1).padStart(2, "0");
return `${year}-${month}-${String(day).padStart(2, "0")}`;
}
export function toDateCellValue(isoDate: string): string {
return `${isoDate}T00:00:00.000Z`;
}
@@ -19,6 +19,7 @@ const enterpriseFeatures = [
"PDF & DOCX Import",
"Bases",
"Kanban",
"Forms",
"Templates",
"Personal Spaces"
];
@@ -4,6 +4,7 @@ import { notifications } from "@mantine/notifications";
import api from "@/lib/api-client";
import i18n from "@/i18n.ts";
import { getApiErrorMessage } from "@/lib/api-error";
import type { BaseTemplate } from "@/ee/base/types/base.types";
function findBaseEmbedPlaceholderPos(
editor: Editor,
@@ -22,7 +23,7 @@ function findBaseEmbedPlaceholderPos(
export async function insertBaseEmbedBlock(
editor: Editor,
opts: { template?: "kanban"; range?: Range } = {},
opts: { template?: BaseTemplate; range?: Range } = {},
): Promise<void> {
// @ts-ignore
const parentPageId = editor.storage?.pageId as string | undefined;
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Button } from "@mantine/core";
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
import { IconTable, IconLayoutKanban, IconForms } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
@@ -57,6 +57,13 @@ export function EmptyPageGetStarted({
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
},
{
key: "form",
label: t("Form"),
icon: IconForms,
onClick: () => convertMutation.mutate({ pageId, template: "form" }),
disabled: convertMutation.isPending,
},
];
return (
@@ -6,6 +6,7 @@ import {
IconCalendar,
IconCaretRightFilled,
IconChevronDown,
IconForms,
IconInfoCircle,
IconLayoutKanban,
IconMath,
@@ -155,6 +156,28 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
</Menu.Item>
</Tooltip>
)}
{!templateMode && (
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
<Menu.Item
leftSection={<IconForms size={16} />}
aria-disabled={!hasBases}
closeMenuOnClick={hasBases}
style={{ opacity: hasBases ? undefined : 0.7 }}
rightSection={
!hasBases && (
<Badge size="xs" variant="light" color="gray">
{t("Upgrade")}
</Badge>
)
}
onClick={() => {
if (hasBases) insertBaseEmbedBlock(editor, { template: "form" });
}}
>
{t("Form")}
</Menu.Item>
</Tooltip>
)}
<Menu.Divider />
<Menu.Label>{t("Diagrams")}</Menu.Label>
@@ -7,6 +7,7 @@ import {
IconH2,
IconH3,
IconInfoCircle,
IconForms,
IconLayoutKanban,
IconList,
IconListNumbers,
@@ -392,6 +393,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
insertBaseEmbedBlock(editor, { range, template: "kanban" });
},
},
{
title: "Form",
description: "Insert a form that collects responses into a base",
searchTerms: ["form", "survey", "questionnaire", "responses", "database"],
icon: IconForms,
requiresBases: true,
command: ({ editor, range }: CommandProps) => {
insertBaseEmbedBlock(editor, { range, template: "form" });
},
},
{
title: "Toggle block",
description: "Insert collapsible block.",
@@ -443,7 +443,8 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
"Audio",
"Synced block",
"Base (Inline)",
"Kanban"
"Kanban",
"Form"
]);
const TemplateSlashCommand = Command.configure({
+3 -2
View File
@@ -32,10 +32,11 @@ api.interceptors.response.use(
const url = new URL(error.request.responseURL)?.pathname;
if (url === "/api/auth/collab-token") return;
if (window.location.pathname.startsWith("/share/")) return;
// public docs probe authed endpoints; reject without the login redirect
// public docs and forms probe authed endpoints; reject without the login redirect
if (
window.location.pathname === "/docs" ||
window.location.pathname.startsWith("/docs/")
window.location.pathname.startsWith("/docs/") ||
window.location.pathname.startsWith("/forms/")
) {
break;
}
@@ -37,6 +37,7 @@ export enum EventName {
BASE_VIEW_CREATED = 'base.view.created',
BASE_VIEW_UPDATED = 'base.view.updated',
BASE_VIEW_DELETED = 'base.view.deleted',
BASE_VIEW_SHARE_UPDATED = 'base.view.share.updated',
BASE_SCHEMA_BUMPED = 'base.schema.bumped',
BASE_ROWS_UPDATED = 'base.rows.updated',
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JwtType } from '../../core/auth/dto/jwt-payload';
@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any) {
if (err || !user || user.authType !== JwtType.ACCESS) return null;
return user;
}
}
@@ -11,3 +11,5 @@ const baseIdSuffix = customAlphabet(alphabet, 9);
export const generateBasePropertyId = (): string => `prp${baseIdSuffix()}`;
export const generateBaseChoiceId = (): string => `opt${baseIdSuffix()}`;
export const generateBaseViewShareKey = customAlphabet(alphabet, 16);
@@ -0,0 +1,40 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('base_view_shares')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('key', 'varchar', (col) => col.notNull().unique())
.addColumn('view_id', 'uuid', (col) =>
col.references('base_views.id').onDelete('cascade').notNull().unique(),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('access', 'varchar', (col) => col.notNull().defaultTo('none'))
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_base_view_shares_page_id')
.on('base_view_shares')
.column('page_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('base_view_shares').execute();
}
@@ -249,6 +249,10 @@ export class PageRepo {
.execute();
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
await trx
.deleteFrom('baseViewShares')
.where('pageId', 'in', pageIds)
.execute();
});
this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, {
+13
View File
@@ -158,6 +158,18 @@ export interface BaseRows {
workspaceId: string;
}
export interface BaseViewShares {
access: Generated<string>;
createdAt: Generated<Timestamp>;
creatorId: string | null;
id: Generated<string>;
key: string;
pageId: string;
updatedAt: Generated<Timestamp>;
viewId: string;
workspaceId: string;
}
export interface BaseViews {
config: Generated<Json>;
createdAt: Generated<Timestamp>;
@@ -741,6 +753,7 @@ export interface DB {
backlinks: Backlinks;
baseProperties: BaseProperties;
baseRows: BaseRows;
baseViewShares: BaseViewShares;
baseViews: BaseViews;
billing: Billing;
comments: Comments;
@@ -5,6 +5,7 @@ import {
Attachments,
BaseProperties,
BaseRows,
BaseViewShares,
BaseViews,
Comments,
Groups,
@@ -275,6 +276,11 @@ export type BaseView = Selectable<BaseViews>;
export type InsertableBaseView = Insertable<BaseViews>;
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
// Base View Share
export type BaseViewShare = Selectable<BaseViewShares>;
export type InsertableBaseViewShare = Insertable<BaseViewShares>;
export type UpdatableBaseViewShare = Updateable<Omit<BaseViewShares, 'id'>>;
// SIEM destinations
export type SiemDestination = Selectable<SiemDestinations>;
export type InsertableSiemDestination = Insertable<SiemDestinations>;
@@ -11,6 +11,8 @@ import {
OAUTH_TOKEN_THROTTLER,
OAUTH_AUTHORIZE_THROTTLER,
SIEM_TEST_THROTTLER,
BASE_FORM_SUBMIT_THROTTLER,
BASE_FORM_KEY_THROTTLER,
} from './throttler-names';
import Redis from 'ioredis';
@@ -29,6 +31,8 @@ import Redis from 'ioredis';
{ name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 },
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
{ name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
{ name: BASE_FORM_SUBMIT_THROTTLER, ttl: 60_000, limit: 30 },
{ name: BASE_FORM_KEY_THROTTLER, ttl: 60_000, limit: 600 },
],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(
@@ -4,6 +4,8 @@ export const OAUTH_REGISTER_THROTTLER = 'oauth-register';
export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
export const SIEM_TEST_THROTTLER = 'siem-test';
export const BASE_FORM_SUBMIT_THROTTLER = 'base-form-submit';
export const BASE_FORM_KEY_THROTTLER = 'base-form-key';
// Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
@@ -13,4 +15,6 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
[OAUTH_TOKEN_THROTTLER]: true,
[OAUTH_AUTHORIZE_THROTTLER]: true,
[SIEM_TEST_THROTTLER]: true,
[BASE_FORM_SUBMIT_THROTTLER]: true,
[BASE_FORM_KEY_THROTTLER]: true,
};
@@ -0,0 +1,51 @@
import { Reflector } from '@nestjs/core';
import { ThrottlerStorageService } from '@nestjs/throttler';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { UserThrottlerGuard } from './user-throttler.guard';
const guard = new UserThrottlerGuard(
[],
new ThrottlerStorageService(),
new Reflector(),
);
function getTracker(req: Record<string, any>): Promise<string> {
return guard['getTracker'](req);
}
describe('UserThrottlerGuard.getTracker', () => {
it('tracks a signed-in request by its user id', async () => {
const req = {
ip: '203.0.113.7',
user: {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
authType: JwtType.ACCESS,
},
};
await expect(getTracker(req)).resolves.toBe('user:user_1');
});
it('falls back to the client ip when nobody is signed in', async () => {
const req = { ip: '203.0.113.7', user: null };
await expect(getTracker(req)).resolves.toBe('203.0.113.7');
});
it('falls back to the socket address when the ip is empty', async () => {
const req = {
ip: '',
user: null,
socket: { remoteAddress: '198.51.100.4' },
};
await expect(getTracker(req)).resolves.toBe('198.51.100.4');
});
it('uses a constant tracker for an unidentifiable client', async () => {
const req = { ip: '', user: null, socket: {} };
await expect(getTracker(req)).resolves.toBe('unknown');
});
});
@@ -1,13 +1,19 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
type AuthedRequest = { user?: { id?: string } };
type AuthedRequest = {
user?: { user?: { id?: string } } | null;
socket?: { remoteAddress?: string };
};
@Injectable()
export class UserThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: AuthedRequest): Promise<string> {
const userId = req.user?.id;
const userId = req.user?.user?.id;
if (userId) return `user:${userId}`;
return super.getTracker(req as Parameters<ThrottlerGuard['getTracker']>[0]);
const ip = await super.getTracker(
req as Parameters<ThrottlerGuard['getTracker']>[0],
);
return ip || req.socket?.remoteAddress || 'unknown';
}
}
+7 -1
View File
@@ -74,7 +74,13 @@ async function bootstrap() {
// /api/files/ - attachment controller sets its own CSP we'd overwrite
// /share/ - public share pages are safe to embed
// /docs/ - public space pages are safe to embed
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/', '/docs/'];
// /forms/ - public forms are safe to embed
const frameHeaderSkippedPrefixes = [
'/api/files/',
'/share/',
'/docs/',
'/forms/',
];
app
.getHttpAdapter()
.getInstance()