mirror of
https://github.com/docmost/docmost.git
synced 2026-10-05 06:13:52 +10:00
feat: forms
This commit is contained in:
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
+23
@@ -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({
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 8190e2182c...7be22fe2b5
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user