diff --git a/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx b/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx index 54f0f698d..a085c7628 100644 --- a/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx @@ -259,7 +259,7 @@ export default async function FormPage(props: {

{capitalize(form.name)} for {community?.name}

- +
diff --git a/core/app/components/DataTable/DataTable.tsx b/core/app/components/DataTable/DataTable.tsx index bbdd119c9..e9bed1035 100644 --- a/core/app/components/DataTable/DataTable.tsx +++ b/core/app/components/DataTable/DataTable.tsx @@ -3,7 +3,13 @@ * updated designs */ -import type { ColumnDef, ColumnFiltersState, Row, SortingState } from "@tanstack/react-table"; +import type { + ColumnDef, + ColumnFiltersState, + Row, + RowSelectionState, + SortingState, +} from "@tanstack/react-table"; import * as React from "react"; import { @@ -31,6 +37,10 @@ export interface DataTableProps { className?: string; striped?: boolean; emptyState?: React.ReactNode; + /** Control row selection */ + selectedRows?: RowSelectionState; + setSelectedRows?: React.Dispatch>; + getRowId?: (data: TData) => string; } const STRIPED_ROW_STYLING = "hover:bg-gray-100 data-[state=selected]:bg-sky-50"; @@ -44,6 +54,9 @@ export function DataTable({ className, striped, emptyState, + selectedRows, + setSelectedRows, + getRowId, }: DataTableProps) { const [sorting, setSorting] = React.useState([]); const [columnFilters, setColumnFilters] = React.useState([]); @@ -58,11 +71,12 @@ export function DataTable({ getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onColumnFiltersChange: setColumnFilters, - onRowSelectionChange: setRowSelection, + onRowSelectionChange: setSelectedRows ?? setRowSelection, + getRowId: getRowId, state: { sorting, columnFilters, - rowSelection, + rowSelection: selectedRows ?? rowSelection, }, }); diff --git a/core/app/components/DataTable/v2/DataTable.tsx b/core/app/components/DataTable/v2/DataTable.tsx index 743b98c89..27ae21aa0 100644 --- a/core/app/components/DataTable/v2/DataTable.tsx +++ b/core/app/components/DataTable/v2/DataTable.tsx @@ -4,16 +4,15 @@ import { DataTable as DataTableV1 } from "../DataTable"; /** * Wrapper around DataTable so that some fields can use updated designs */ -export function DataTable({ - columns, - data, - onRowClick, -}: Pick, "columns" | "data" | "onRowClick">) { +export function DataTable( + props: Pick< + DataTableProps, + "columns" | "data" | "onRowClick" | "selectedRows" | "setSelectedRows" | "getRowId" + > +) { return ( } hidePaginationWhenSinglePage diff --git a/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx b/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx index b5cecdfee..66e455ea1 100644 --- a/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx +++ b/core/app/components/FormBuilder/ElementPanel/InputComponentConfigurationForm.tsx @@ -61,14 +61,15 @@ const componentInfo: Record = { [InputComponent.textInput]: { name: "Input", placeholder: "For short text", - demoComponent: ({ element }) => ( - - ), + demoComponent: ({ element }) => { + const isNumber = element.schemaName === CoreSchemaType.Number; + return ( + + ); + }, }, [InputComponent.checkbox]: { name: "Checkbox", demoComponent: () => }, [InputComponent.datePicker]: { @@ -183,7 +184,7 @@ const componentInfo: Record = { return (
Label
- + {}} />
); }, diff --git a/core/app/components/FormBuilder/FormBuilder.tsx b/core/app/components/FormBuilder/FormBuilder.tsx index 5a718375f..d3c4205bd 100644 --- a/core/app/components/FormBuilder/FormBuilder.tsx +++ b/core/app/components/FormBuilder/FormBuilder.tsx @@ -24,6 +24,7 @@ import type { FormBuilderSchema, FormElementData, PanelEvent, PanelState } from import type { Form as PubForm } from "~/lib/server/form"; import { renderWithPubTokens } from "~/lib/server/render/pub/renderWithPubTokens"; import { didSucceed, useServerAction } from "~/lib/serverActions"; +import { PanelHeader, PanelWrapper, SidePanel } from "../SidePanel"; import { saveForm } from "./actions"; import { ElementPanel } from "./ElementPanel"; import { FormBuilderProvider, useFormBuilder } from "./FormBuilderContext"; @@ -95,49 +96,12 @@ const elementPanelTitles: Record = { editingButton: "Edit Submission Button", }; -const PanelHeader = ({ state }: { state: PanelState["state"] }) => { - const { dispatch } = useFormBuilder(); - return ( - <> -
-
{elementPanelTitles[state]}
- {state !== "initial" && ( - - )} -
-
- - ); -}; - type Props = { pubForm: PubForm; id: string; stages: Stages[]; }; -// Render children in a portal so they can safely use
components -function PanelWrapper({ - children, - sidebar, -}: { - children: React.ReactNode; - sidebar: Element | null; -}) { - if (!sidebar) { - return null; - } - return createPortal(children, sidebar); -} - export function FormBuilder({ pubForm, id, stages }: Props) { const router = useRouter(); const pathname = usePathname(); @@ -336,7 +300,17 @@ export function FormBuilder({ pubForm, id, stages }: Props) {
- + + dispatch({ eventName: "cancel" }) + } + /> @@ -351,10 +325,7 @@ export function FormBuilder({ pubForm, id, stages }: Props) { Preview your form here -
+ ); diff --git a/core/app/components/SidePanel.tsx b/core/app/components/SidePanel.tsx new file mode 100644 index 000000000..3bdcba73a --- /dev/null +++ b/core/app/components/SidePanel.tsx @@ -0,0 +1,67 @@ +import { forwardRef } from "react"; +import { createPortal } from "react-dom"; + +import { Button } from "ui/button"; +import { X } from "ui/icon"; +import { cn } from "utils"; + +// Render children in a portal so they can safely use components +export const PanelWrapper = ({ + children, + sidebar, +}: { + children: React.ReactNode; + sidebar: Element | null; +}) => { + if (!sidebar) { + return null; + } + return createPortal(children, sidebar); +}; + +export const PanelHeader = ({ + title, + showCancel, + onCancel, +}: { + title: string; + showCancel: boolean; + onCancel: () => void; +}) => { + return ( + <> +
+
{title}
+ {showCancel && ( + + )} +
+
+ + ); +}; + +export const SidePanel = forwardRef>( + ({ children, className, ...rest }, ref) => { + return ( +
+ {children} +
+ ); + } +); diff --git a/core/app/components/forms/AddRelatedPubsPanel.tsx b/core/app/components/forms/AddRelatedPubsPanel.tsx new file mode 100644 index 000000000..8f6bb257e --- /dev/null +++ b/core/app/components/forms/AddRelatedPubsPanel.tsx @@ -0,0 +1,106 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; + +import { useRef, useState } from "react"; + +import type { PubsId } from "db/public"; +import { pubFieldsIdSchema, pubsIdSchema } from "db/public"; +import { Button } from "ui/button"; +import { Checkbox } from "ui/checkbox"; +import { DataTableColumnHeader } from "ui/data-table"; + +import type { GetPubsResult } from "~/lib/server"; +import { PanelHeader, SidePanel } from "~/app/components/SidePanel"; +import { getPubTitle } from "~/lib/pubs"; +import { DataTable } from "../DataTable/v2/DataTable"; + +const getColumns = () => + [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="mr-2 h-4 w-4" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + header: ({ column }) => , + accessorKey: "name", + cell: ({ row }) => { + return ( +
+ {getPubTitle(row.original)} +
+ ); + }, + }, + ] as const satisfies ColumnDef[]; + +export const AddRelatedPubsPanel = ({ + title, + onCancel, + onAdd, + pubs, +}: { + title: string; + onCancel: () => void; + onAdd: (pubs: GetPubsResult) => void; + pubs: GetPubsResult; +}) => { + const sidebarRef = useRef(null); + const [selected, setSelected] = useState>({}); + + const handleAdd = () => { + const selectedPubIds = Object.entries(selected) + .filter(([pubId, selected]) => selected) + .map((selection) => selection[0] as PubsId); + const selectedPubs = pubs.filter((p) => selectedPubIds.includes(p.id)); + onAdd(selectedPubs); + onCancel(); + }; + + return ( + +
+ + d.id} + /> +
+
+ + +
+
+ ); +}; diff --git a/core/app/components/forms/FormElement.tsx b/core/app/components/forms/FormElement.tsx index ce7bda4cd..2aafa09ab 100644 --- a/core/app/components/forms/FormElement.tsx +++ b/core/app/components/forms/FormElement.tsx @@ -1,48 +1,24 @@ -import { defaultComponent } from "schemas"; - -import type { ProcessedPub } from "contracts"; -import type { CommunityMembershipsId, PubsId } from "db/public"; -import { CoreSchemaType, ElementType, InputComponent } from "db/public"; +import { ElementType } from "db/public"; import { logger } from "logger"; import { expect } from "utils"; +import type { PubFieldFormElementProps } from "./PubFieldFormElement"; import type { FormElements } from "./types"; -import { CheckboxElement } from "./elements/CheckboxElement"; -import { CheckboxGroupElement } from "./elements/CheckboxGroupElement"; -import { ConfidenceElement } from "./elements/ConfidenceElement"; -import { ContextEditorElement } from "./elements/ContextEditorElement"; -import { DateElement } from "./elements/DateElement"; -import { FileUploadElement } from "./elements/FileUploadElement"; -import { MemberSelectElement } from "./elements/MemberSelectElement"; -import { MultivalueInputElement } from "./elements/MultivalueInputElement"; -import { RadioGroupElement } from "./elements/RadioGroupElement"; -import { SelectDropdownElement } from "./elements/SelectDropdownElement"; -import { TextAreaElement } from "./elements/TextAreaElement"; -import { TextInputElement } from "./elements/TextInputElement"; +import { RelatedPubsElement } from "./elements/RelatedPubsElement"; import { FormElementToggle } from "./FormElementToggle"; +import { PubFieldFormElement } from "./PubFieldFormElement"; -export type FormElementProps = { - pubId: PubsId; +export type FormElementProps = Omit & { element: FormElements; - searchParams: Record; - communitySlug: string; - values: ProcessedPub["values"]; }; export const FormElement = ({ pubId, - element: propElement, + element, searchParams, communitySlug, values, }: FormElementProps) => { - const element = { - ...propElement, - component: - propElement.component ?? - (propElement.schemaName ? defaultComponent(propElement.schemaName) : null), - } as typeof propElement; - if (!element.slug) { if (element.type === ElementType.structural) { return ( @@ -56,7 +32,7 @@ export const FormElement = ({ return null; } - if (!element.schemaName || !element.component) { + if (!element.schemaName) { return null; } @@ -70,107 +46,30 @@ export const FormElement = ({ slug: element.slug, }; - let input: React.ReactNode | undefined; + let input = ( + + ); - if (element.component === InputComponent.textInput) { - input = ( - - ); - } else if (element.component === InputComponent.textArea) { - input = ( - - ); - } else if (element.component === InputComponent.checkbox) { - input = ( - - ); - } else if (element.component === InputComponent.fileUpload) { - input = ( - - ); - } else if (element.component === InputComponent.confidenceInterval) { - input = ( - - ); - } else if (element.component === InputComponent.datePicker) { - input = ( - - ); - } else if (element.component === InputComponent.memberSelect) { - const userId = values.find((v) => v.fieldSlug === element.slug)?.value as - | CommunityMembershipsId - | undefined; - input = ( - - ); - } else if (element.component === InputComponent.radioGroup) { - input = ( - - ); - } else if (element.component === InputComponent.checkboxGroup) { - input = ( - - ); - } else if (element.component === InputComponent.selectDropdown) { - input = ( - - ); - } else if (element.component === InputComponent.multivalueInput) { - input = ( - - ); - } else if (element.component === InputComponent.richText) { + if (element.isRelation && "relationshipConfig" in element.config) { input = ( - ); } diff --git a/core/app/components/forms/FormElementToggleContext.tsx b/core/app/components/forms/FormElementToggleContext.tsx index 00a221196..1b13d9b0a 100644 --- a/core/app/components/forms/FormElementToggleContext.tsx +++ b/core/app/components/forms/FormElementToggleContext.tsx @@ -23,6 +23,17 @@ export const FormElementToggleProvider = (props: Props) => { const isEnabled = useCallback( (fieldSlug: string) => { + /** + * Array fields in forms will have inner values like 'croccroc:author.0.value' + * In this case, we'd want to see if the parent, 'croccroc:author' is enabled, not the exact inner value slug + */ + const arrayFieldRegex = /^(.+)\.\d+\./; + const arrayMatch = fieldSlug.match(arrayFieldRegex); + if (arrayMatch) { + const arrayFieldSlug = arrayMatch[1]; + return enabledFields.has(arrayFieldSlug); + } + return enabledFields.has(fieldSlug); }, [enabledFields] diff --git a/core/app/components/forms/PubFieldFormElement.tsx b/core/app/components/forms/PubFieldFormElement.tsx new file mode 100644 index 000000000..1f62cd21b --- /dev/null +++ b/core/app/components/forms/PubFieldFormElement.tsx @@ -0,0 +1,172 @@ +import { defaultComponent } from "schemas"; + +import type { ProcessedPub } from "contracts"; +import type { CommunityMembershipsId, PubsId } from "db/public"; +import { CoreSchemaType, InputComponent } from "db/public"; +import { logger } from "logger"; + +import type { PubFieldElement } from "./types"; +import { CheckboxElement } from "./elements/CheckboxElement"; +import { CheckboxGroupElement } from "./elements/CheckboxGroupElement"; +import { ConfidenceElement } from "./elements/ConfidenceElement"; +import { ContextEditorElement } from "./elements/ContextEditorElement"; +import { DateElement } from "./elements/DateElement"; +import { FileUploadElement } from "./elements/FileUploadElement"; +import { MemberSelectElement } from "./elements/MemberSelectElement"; +import { MultivalueInputElement } from "./elements/MultivalueInputElement"; +import { RadioGroupElement } from "./elements/RadioGroupElement"; +import { SelectDropdownElement } from "./elements/SelectDropdownElement"; +import { TextAreaElement } from "./elements/TextAreaElement"; +import { TextInputElement } from "./elements/TextInputElement"; + +export type PubFieldFormElementProps = { + pubId: PubsId; + element: PubFieldElement; + searchParams: Record; + communitySlug: string; + values: ProcessedPub["values"]; +}; + +export const PubFieldFormElement = ({ + pubId, + element: propElement, + searchParams, + communitySlug, + values, + label, + slug, +}: PubFieldFormElementProps & { label: string; slug: string }) => { + const element = { + ...propElement, + component: + propElement.component ?? + (propElement.schemaName ? defaultComponent(propElement.schemaName) : null), + } as typeof propElement; + + const basicProps = { + label, + slug, + }; + + if (element.component === InputComponent.textInput) { + return ( + + ); + } + if (element.component === InputComponent.textArea) { + return ( + + ); + } + if (element.component === InputComponent.checkbox) { + return ( + + ); + } + if (element.component === InputComponent.fileUpload) { + return ( + + ); + } + if (element.component === InputComponent.confidenceInterval) { + return ( + + ); + } + if (element.component === InputComponent.datePicker) { + return ( + + ); + } + if (element.component === InputComponent.memberSelect) { + const userId = values.find((v) => v.fieldSlug === element.slug)?.value as + | CommunityMembershipsId + | undefined; + return ( + + ); + } + if (element.component === InputComponent.radioGroup) { + return ( + + ); + } + if (element.component === InputComponent.checkboxGroup) { + return ( + + ); + } + if (element.component === InputComponent.selectDropdown) { + return ( + + ); + } + if (element.component === InputComponent.multivalueInput) { + return ( + + ); + } + if (element.component === InputComponent.richText) { + return ( + + ); + } + + logger.error({ + msg: `Encountered unknown component when rendering form element`, + component: element.component, + element, + pubId, + }); + return null; +}; diff --git a/core/app/components/forms/elements/RelatedPubsElement.tsx b/core/app/components/forms/elements/RelatedPubsElement.tsx new file mode 100644 index 000000000..08707412b --- /dev/null +++ b/core/app/components/forms/elements/RelatedPubsElement.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Value } from "@sinclair/typebox/value"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { relationBlockConfigSchema } from "schemas"; + +import type { JsonValue } from "contracts"; +import type { InputComponent, PubsId } from "db/public"; +import { Button } from "ui/button"; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; +import { Plus, Trash, TriangleAlert } from "ui/icon"; +import { MultiBlock } from "ui/multiblock"; +import { Popover, PopoverContent, PopoverTrigger } from "ui/popover"; +import { cn } from "utils"; + +import type { PubFieldFormElementProps } from "../PubFieldFormElement"; +import type { ElementProps } from "../types"; +import type { GetPubsResult } from "~/lib/server"; +import { AddRelatedPubsPanel } from "~/app/components/forms/AddRelatedPubsPanel"; +import { getPubTitle } from "~/lib/pubs"; +import { useContextEditorContext } from "../../ContextEditor/ContextEditorContext"; +import { useFormElementToggleContext } from "../FormElementToggleContext"; +import { PubFieldFormElement } from "../PubFieldFormElement"; + +type RelatedPubValueSlug = `${string}.${number}.value`; + +const RelatedPubBlock = ({ + pub, + onRemove, + valueComponentProps, + slug, +}: { + pub: GetPubsResult[number]; + onRemove: () => void; + valueComponentProps: PubFieldFormElementProps; + slug: RelatedPubValueSlug; +}) => { + return ( +
+
+ {getPubTitle(pub)} + +
+
+ +
+
+ ); +}; + +type FieldValue = { value: JsonValue; relatedPubId: PubsId }; +type FormValue = { + [slug: string]: FieldValue[]; +}; + +export const ConfigureRelatedValue = ({ + slug, + element, + ...props +}: PubFieldFormElementProps & { slug: RelatedPubValueSlug }) => { + const configLabel = "label" in element.config ? element.config.label : undefined; + const label = configLabel || element.label || slug; + + const { watch, formState } = useFormContext(); + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const value = watch(slug); + const showValue = value != null && value !== "" && !isPopoverOpen; + + const [baseSlug, index] = slug.split("."); + const valueError = formState.errors[baseSlug]?.[parseInt(index)]?.value; + + if (element.component === null) { + return null; + } + + return showValue ? ( + // TODO: this should be more sophisticated for the more complex fields + value.toString() + ) : ( + + + + + + + + + ); +}; + +export const RelatedPubsElement = ({ + slug, + label, + config, + valueComponentProps, +}: ElementProps & { + valueComponentProps: PubFieldFormElementProps; +}) => { + const { pubs, pubId } = useContextEditorContext(); + const [showPanel, setShowPanel] = useState(false); + const { control } = useFormContext(); + const formElementToggle = useFormElementToggleContext(); + const isEnabled = formElementToggle.isEnabled(slug); + + const { fields, append, remove } = useFieldArray({ control, name: slug }); + + Value.Default(relationBlockConfigSchema, config); + if (!Value.Check(relationBlockConfigSchema, config)) { + return null; + } + + const pubsById = useMemo(() => { + return pubs.reduce( + (acc, pub) => { + acc[pub.id] = pub; + return acc; + }, + {} as Record + ); + }, [pubs]); + + const linkedPubs = fields.map((f) => f.relatedPubId); + const linkablePubs = pubs + // do not allow linking to itself or any pubs it is already linked to + .filter((p) => p.id !== pubId && !linkedPubs.includes(p.id)); + + return ( + <> + { + const handleAddPubs = (newPubs: GetPubsResult) => { + const values = newPubs.map((p) => ({ relatedPubId: p.id, value: null })); + for (const value of values) { + append(value); + } + }; + return ( + + {showPanel && ( + setShowPanel(false)} + pubs={linkablePubs} + onAdd={handleAddPubs} + /> + )} + {label} +
+ + setShowPanel(true)} + > + {fields.length ? ( +
+ {fields.map((item, index) => { + const handleRemovePub = () => { + remove(index); + }; + const innerSlug = + `${slug}.${index}.value` as const; + return ( + + ); + })} +
+ ) : null} +
+
+
+ {config.relationshipConfig.help} + +
+ ); + }} + /> + + ); +}; diff --git a/core/app/components/forms/types.ts b/core/app/components/forms/types.ts index 6c25bfec7..f349d817f 100644 --- a/core/app/components/forms/types.ts +++ b/core/app/components/forms/types.ts @@ -6,7 +6,6 @@ import type { FormElementsId, InputComponent, PubFieldsId, - StagesId, StructuralFormElement, } from "db/public"; @@ -33,6 +32,7 @@ type BasePubFieldElement = { element: null; order: number | null; slug: string; + isRelation: boolean; }; export type BasicPubFieldElement = BasePubFieldElement & { @@ -63,6 +63,7 @@ export type ButtonElement = { component: null; schemaName: null; slug: null; + isRelation: false; }; export type StructuralElement = { @@ -79,6 +80,7 @@ export type StructuralElement = { component: null; schemaName: null; slug: null; + isRelation: false; }; export type FormElements = PubFieldElement | StructuralElement | ButtonElement; diff --git a/core/app/components/pubs/PubEditor/PubEditorClient.tsx b/core/app/components/pubs/PubEditor/PubEditorClient.tsx index def2b5fb7..c73e5b2ba 100644 --- a/core/app/components/pubs/PubEditor/PubEditorClient.tsx +++ b/core/app/components/pubs/PubEditor/PubEditorClient.tsx @@ -73,14 +73,25 @@ const buildDefaultValues = (elements: BasicFormElements[], pubValues: ProcessedP for (const element of elements) { if (element.slug && element.schemaName) { const pubValue = pubValues.find((v) => v.fieldSlug === element.slug)?.value; + defaultValues[element.slug] = pubValue ?? getDefaultValueByCoreSchemaType(element.schemaName); if (element.schemaName === CoreSchemaType.DateTime && pubValue) { defaultValues[element.slug] = new Date(pubValue as string); } + // There can be multiple relations for a single slug + if (element.isRelation) { + const relatedPubValues = pubValues.filter((v) => v.fieldSlug === element.slug); + defaultValues[element.slug] = relatedPubValues.map((pv) => ({ + value: + pv.schemaName === CoreSchemaType.DateTime + ? new Date(pv.value as string) + : pv.value, + relatedPubId: pv.relatedPubId, + })); + } } } - return defaultValues; }; @@ -96,7 +107,7 @@ const createSchemaFromElements = ( (e) => e.type === ElementType.pubfield && e.slug && toggleContext.isEnabled(e.slug) ) - .map(({ slug, schemaName, config }) => { + .map(({ slug, schemaName, config, isRelation }) => { if (!schemaName) { return [slug, undefined]; } @@ -106,17 +117,33 @@ const createSchemaFromElements = ( return [slug, undefined]; } - if (schema.type !== "string") { - return [slug, Type.Optional(schema)]; + // Allow fields to be empty or optional. Special case for empty strings, + // which happens when you enter something in an input field and then delete it + // TODO: reevaluate whether this should be "" or undefined + const schemaAllowEmpty = + schema.type === "string" + ? Type.Union([schema, Type.Literal("")], { + error: schema.error ?? "Invalid value", + }) + : Type.Optional(schema); + + if (isRelation) { + return [ + slug, + Type.Array( + Type.Object( + { + relatedPubId: Type.String(), + value: schemaAllowEmpty, + }, + { additionalProperties: true, error: "object error" } + ), + { error: "array error" } + ), + ]; } - // this allows for empty strings, which happens when you enter something - // in an input field and then delete it - // TODO: reevaluate whether this should be "" or undefined - const schemaWithAllowedEmpty = Type.Union([schema, Type.Literal("")], { - error: schema.error ?? "Invalid value", - }); - return [slug, schemaWithAllowedEmpty]; + return [slug, schemaAllowEmpty]; }) ) ); @@ -231,6 +258,7 @@ export const PubEditorClient = ({ formState: formInstance.formState, toggleContext, }); + const { stageId: stageIdFromButtonConfig, submitButtonId } = getButtonConfig({ evt, withButtonElements, diff --git a/core/app/components/pubs/PubEditor/SaveStatus.tsx b/core/app/components/pubs/PubEditor/SaveStatus.tsx index 5ca109566..fdb0f33e2 100644 --- a/core/app/components/pubs/PubEditor/SaveStatus.tsx +++ b/core/app/components/pubs/PubEditor/SaveStatus.tsx @@ -30,23 +30,28 @@ export const useSaveStatus = ({ defaultMessage }: { defaultMessage?: string }) = return status; }; -export const SaveStatus = () => { - const status = useSaveStatus({ defaultMessage: "Progress will be automatically saved" }); +export const SaveStatus = ({ autosave }: { autosave?: boolean }) => { + const defaultMessage = autosave + ? "Progress will be automatically saved" + : "Form will save when you click submit"; + const status = useSaveStatus({ defaultMessage }); return (
{status} - - - Bookmark this page to return to your saved progress anytime. - - -
- More info - -
-
-
+ {autosave ? ( + + + Bookmark this page to return to your saved progress anytime. + + +
+ More info + +
+
+
+ ) : null}
); }; diff --git a/core/app/components/pubs/PubEditor/actions.ts b/core/app/components/pubs/PubEditor/actions.ts index 462ce5320..812c82661 100644 --- a/core/app/components/pubs/PubEditor/actions.ts +++ b/core/app/components/pubs/PubEditor/actions.ts @@ -1,5 +1,6 @@ "use server"; +import type { Json } from "contracts"; import type { PubsId, StagesId, UsersId } from "db/public"; import { Capabilities, MembershipType } from "db/public"; import { logger } from "logger"; @@ -78,7 +79,7 @@ export const updatePub = defineServerAction(async function updatePub({ continueOnValidationError, }: { pubId: PubsId; - pubValues: PubValues; + pubValues: Record; stageId?: StagesId; formSlug?: string; continueOnValidationError: boolean; diff --git a/core/lib/fields/richText.ts b/core/lib/fields/richText.ts index c35e9e58d..8753d168e 100644 --- a/core/lib/fields/richText.ts +++ b/core/lib/fields/richText.ts @@ -46,14 +46,14 @@ interface PubCreate { * TODO: what to do about multiple rich text fields? * TODO: we can't fill out the Submission pub type without ContextAtom rendering first */ -export const parseRichTextForPubFieldsAndRelatedPubs = ({ +export const parseRichTextForPubFieldsAndRelatedPubs = ({ pubId, values, }: { pubId: PubsId; - values: Record; + values: Record; }) => { - const newValues = structuredClone(values); + const newValues: Record = structuredClone(values); const pubs: PubCreate[] = []; // Find a rich text value if one exists diff --git a/core/lib/server/pub.db.test.ts b/core/lib/server/pub.db.test.ts index 3634f7c67..4810bd932 100644 --- a/core/lib/server/pub.db.test.ts +++ b/core/lib/server/pub.db.test.ts @@ -353,7 +353,7 @@ describe("updatePub", () => { expect(updatedPub[0].value as string).toBe("Updated title"); }); - it("should error if trying to update relationship values with updatePub", async () => { + it("should be able to update multiple relationship values", async () => { const trx = getTrx(); const { createPubRecursiveNew, updatePub } = await import("./pub"); @@ -369,19 +369,30 @@ describe("updatePub", () => { lastModifiedBy: createLastModifiedBy("system"), }); - await expect( - updatePub({ - pubId: pub.id, - pubValues: { - [pubFields["Some relation"].slug]: "test relation value", - }, - communityId: community.id, - continueOnValidationError: false, - lastModifiedBy: createLastModifiedBy("system"), - }) - ).rejects.toThrow( - /Pub values contain fields that do not exist in the community: .*?:some-relation/ - ); + await updatePub({ + pubId: pub.id, + pubValues: { + [pubFields["Some relation"].slug]: [ + { value: "new value", relatedPubId: pubs[0].id }, + { value: "another new value", relatedPubId: pubs[1].id }, + ], + }, + communityId: community.id, + continueOnValidationError: false, + lastModifiedBy: createLastModifiedBy("system"), + }); + + const updatedPub = await trx + .selectFrom("pub_values") + .select(["value", "relatedPubId"]) + .where("pubId", "=", pub.id) + .execute(); + + expect(updatedPub[1].value as string).toBe("new value"); + expect(updatedPub[1].relatedPubId).toBe(pubs[0].id); + + expect(updatedPub[2].value as string).toBe("another new value"); + expect(updatedPub[2].relatedPubId).toBe(pubs[1].id); }); }); diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index bb248af78..53ef833fa 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -9,6 +9,7 @@ import type { import { sql, Transaction } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres"; +import partition from "lodash.partition"; import type { CreatePubRequestBodyWithNullsNew, @@ -431,6 +432,32 @@ const isRelatedPubInit = (value: unknown): value is { value: unknown; relatedPub Array.isArray(value) && value.every((v) => typeof v === "object" && "value" in v && "relatedPubId" in v); +/** + * Transform pub values which can either be + * { + * field: 'example', + * authors: [ + * { value: 'admin', relatedPubId: X }, + * { value: 'editor', relatedPubId: Y }, + * ] + * } + * to a more standardized + * [ { slug, value, relatedPubId } ] + */ +const normalizePubValues = ( + pubValues: Record +) => { + return Object.entries(pubValues).flatMap(([slug, value]) => + isRelatedPubInit(value) + ? value.map((v) => ({ slug, value: v.value, relatedPubId: v.relatedPubId })) + : ([{ slug, value, relatedPubId: undefined }] as { + slug: string; + value: unknown; + relatedPubId: PubsId | undefined; + }[]) + ); +}; + /** * @throws */ @@ -471,15 +498,7 @@ export const createPubRecursiveNew = async - isRelatedPubInit(value) - ? value.map((v) => ({ slug, value: v.value, relatedPubId: v.relatedPubId })) - : ([{ slug, value, relatedPubId: undefined }] as { - slug: string; - value: unknown; - relatedPubId: PubsId | undefined; - }[]) - ); + const normalizedValues = normalizePubValues(values); const valuesWithFieldIds = await validatePubValues({ pubValues: normalizedValues, @@ -1072,7 +1091,7 @@ export const updatePub = async ({ lastModifiedBy, }: { pubId: PubsId; - pubValues: Record; + pubValues: Record; communityId: CommunitiesId; lastModifiedBy: LastModifiedBy; stageId?: StagesId; @@ -1095,17 +1114,12 @@ export const updatePub = async ({ values: pubValues, }); - const vals = Object.entries(processedVals).flatMap(([slug, value]) => ({ - slug, - value, - })); + const normalizedValues = normalizePubValues(processedVals); const pubValuesWithSchemaNameAndFieldId = await validatePubValues({ - pubValues: vals, + pubValues: normalizedValues, communityId, continueOnValidationError, - // do not update relations, and error if a relation slug is included - includeRelations: false, }); if (!pubValuesWithSchemaNameAndFieldId.length) { @@ -1115,31 +1129,56 @@ export const updatePub = async ({ }; } - const result = await autoRevalidate( - trx - .insertInto("pub_values") - .values( - pubValuesWithSchemaNameAndFieldId.map(({ value, fieldId }) => ({ - pubId, - fieldId, - value: JSON.stringify(value), - lastModifiedBy, - })) - ) - .onConflict((oc) => - oc - // we have a unique index on pubId and fieldId where relatedPubId is null - .columns(["pubId", "fieldId"]) - .where("relatedPubId", "is", null) - .doUpdateSet((eb) => ({ - value: eb.ref("excluded.value"), - lastModifiedBy: eb.ref("excluded.lastModifiedBy"), + // Separate into fields with relationships and those without + const [pubValuesWithRelations, pubValuesWithoutRelations] = partition( + pubValuesWithSchemaNameAndFieldId, + (pv) => pv.relatedPubId + ); + + if (pubValuesWithRelations.length) { + await replacePubRelationsBySlug({ + pubId, + relations: pubValuesWithRelations.map( + (pv) => + ({ + value: pv.value, + slug: pv.slug, + relatedPubId: pv.relatedPubId, + }) as AddPubRelationsInput + ), + communityId, + lastModifiedBy, + trx, + }); + } + + if (pubValuesWithoutRelations.length) { + const result = await autoRevalidate( + trx + .insertInto("pub_values") + .values( + pubValuesWithoutRelations.map(({ value, fieldId }) => ({ + pubId, + fieldId, + value: JSON.stringify(value), + lastModifiedBy, })) - ) - .returningAll() - ).execute(); + ) + .onConflict((oc) => + oc + // we have a unique index on pubId and fieldId where relatedPubId is null + .columns(["pubId", "fieldId"]) + .where("relatedPubId", "is", null) + .doUpdateSet((eb) => ({ + value: eb.ref("excluded.value"), + lastModifiedBy: eb.ref("excluded.lastModifiedBy"), + })) + ) + .returningAll() + ).execute(); - return result; + return result; + } }); return result; diff --git a/core/playwright/externalFormCreatePub.spec.ts b/core/playwright/externalFormCreatePub.spec.ts index a8819288b..8ce75c1a1 100644 --- a/core/playwright/externalFormCreatePub.spec.ts +++ b/core/playwright/externalFormCreatePub.spec.ts @@ -8,6 +8,7 @@ import { FieldsPage } from "./fixtures/fields-page"; import { FormsEditPage } from "./fixtures/forms-edit-page"; import { FormsPage } from "./fixtures/forms-page"; import { LoginPage } from "./fixtures/login-page"; +import { PubsPage } from "./fixtures/pubs-page"; import { createCommunity } from "./helpers"; const now = new Date().getTime(); @@ -230,3 +231,113 @@ test.describe("Rich text editor", () => { await expect(page.getByRole("link", { name: actualTitle })).toHaveCount(1); }); }); + +test.describe("Related pubs", () => { + test("Can add related pubs", async () => { + // Create a related pub we can link to + const relatedPubTitle = "related pub"; + const pubsPage = new PubsPage(page, COMMUNITY_SLUG); + await pubsPage.goTo(); + await pubsPage.createPub({ + values: { title: relatedPubTitle, content: "my content" }, + }); + + const fieldsPage = new FieldsPage(page, COMMUNITY_SLUG); + await fieldsPage.goto(); + // Add a string, string array, and null related field + const relatedFields = [ + { name: "string", schemaName: CoreSchemaType.String }, + { name: "array", schemaName: CoreSchemaType.StringArray }, + { name: "null", schemaName: CoreSchemaType.Null }, + ]; + for (const relatedField of relatedFields) { + await fieldsPage.addField(relatedField.name, relatedField.schemaName, true); + } + // Add these to a new form + const formSlug = "relationship-form"; + const formsPage = new FormsPage(page, COMMUNITY_SLUG); + await formsPage.goto(); + await formsPage.addForm("relationship form", formSlug); + const formEditPage = new FormsEditPage(page, COMMUNITY_SLUG, formSlug); + await formEditPage.goto(); + + // Configure these 3 fields + for (const relatedField of relatedFields) { + await formEditPage.openAddForm(); + await formEditPage.openFormElementPanel(`${COMMUNITY_SLUG}:${relatedField.name}`); + await page.getByRole("textbox", { name: "Label" }).first().fill(relatedField.name); + await formEditPage.saveFormElementConfiguration(); + } + + // Save the form builder and go to external form + await formEditPage.saveForm(); + await formEditPage.goToExternalForm(); + for (const element of relatedFields) { + await expect(page.getByText(element.name)).toHaveCount(1); + } + + // Fill out the form + const title = "pub with related fields"; + await page.getByTestId(`${COMMUNITY_SLUG}:title`).fill(title); + await page.getByTestId(`${COMMUNITY_SLUG}:content`).fill("content"); + // string related field + const stringRelated = page.getByTestId("related-pubs-string"); + await stringRelated.getByRole("button", { name: "Add" }).click(); + await page + .getByRole("button", { name: `Select row ${relatedPubTitle}` }) + .getByLabel("Select row") + .click(); + await page.getByTestId("add-related-pub-button").click(); + await expect(stringRelated.getByText(relatedPubTitle)).toHaveCount(1); + await stringRelated.getByRole("button", { name: "Add string" }).click(); + await page.getByTestId(`${COMMUNITY_SLUG}:string.0.value`).fill("admin"); + // Click the button again to 'exit' the popover + await stringRelated.getByRole("button", { name: "Add string" }).click(); + await expect(stringRelated.getByText("admin")).toHaveCount(1); + + // array related field + const arrayRelated = page.getByTestId("related-pubs-array"); + await arrayRelated.getByRole("button", { name: "Add" }).click(); + await page + .getByRole("button", { name: `Select row ${relatedPubTitle}` }) + .getByLabel("Select row") + .click(); + await page.getByTestId("add-related-pub-button").click(); + await expect(arrayRelated.getByText(relatedPubTitle)).toHaveCount(1); + await arrayRelated.getByRole("button", { name: "Add array" }).click(); + const locator = page.getByTestId(`multivalue-input`); + /** + * Use 'press' to trigger the ',' keyboard event, which "adds" the value + * Could also use 'Enter', but this seems to trigger submitting the form when run thru playwright + */ + await locator.fill("one"); + await locator.press(","); + await locator.fill("two"); + await locator.press(","); + // Click the button again to 'exit' the popover + await arrayRelated.getByRole("button", { name: "Add array" }).click(); + await expect(arrayRelated.getByText("one,two")).toHaveCount(1); + + // null related field + const nullRelated = page.getByTestId("related-pubs-null"); + await nullRelated.getByRole("button", { name: "Add" }).click(); + await page + .getByRole("button", { name: `Select row ${relatedPubTitle}` }) + .getByLabel("Select row") + .click(); + await page.getByTestId("add-related-pub-button").click(); + await expect(nullRelated.getByText(relatedPubTitle)).toHaveCount(1); + // Can't add a value to a null related field + await expect(nullRelated.getByTestId("add-related-value")).toHaveCount(0); + await page.getByRole("button", { name: "Submit" }).click(); + await page.getByText("Form Successfully Submitted").waitFor(); + + // Check the pub page to make sure the values we expect are there + await page.goto(`/c/${COMMUNITY_SLUG}/pubs`); + await page.getByRole("link", { name: title }).click(); + // Make sure pub details page has loaded before making assertions + await page.getByText("Assignee").waitFor(); + await expect(page.getByText("admin:related pub")).toHaveCount(1); + await expect(page.getByText("nullrelated pub")).toHaveCount(1); + }); +}); diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx index 3927e10a6..ef6103cbe 100644 --- a/packages/ui/src/form.tsx +++ b/packages/ui/src/form.tsx @@ -146,7 +146,9 @@ const FormMessage = React.forwardRef< body = String(error.message); } if (Array.isArray(error)) { - body = error[0]?.message; + const firstErrorIndex = error.findIndex((e) => !!e); + const firstError = error[firstErrorIndex]; + body = firstError?.message ?? `Error with value at index ${firstErrorIndex}`; } } diff --git a/packages/ui/src/icon.tsx b/packages/ui/src/icon.tsx index 8b9ecbede..22652f634 100644 --- a/packages/ui/src/icon.tsx +++ b/packages/ui/src/icon.tsx @@ -75,6 +75,7 @@ export { Terminal, ToyBrick, Trash, + TriangleAlert, Type, Undo2, User, diff --git a/packages/ui/src/multiblock.tsx b/packages/ui/src/multiblock.tsx index 575ed0802..2fce3a9f2 100644 --- a/packages/ui/src/multiblock.tsx +++ b/packages/ui/src/multiblock.tsx @@ -7,36 +7,61 @@ import { cn } from "utils"; import { Button } from "./button"; -// TODO (#791): support a form array field export const MultiBlock = ({ title, children, disabled, + compact, + onAdd, }: { title: string; children?: ReactNode; disabled?: boolean; + compact?: boolean; + onAdd: () => void; }) => { return (
-
- -
{title}
+
+ +
{title}
-
- {children} + {children ? ( + <> +
+ {children} + + ) : null}
); };