From f62bf15a2f961574fc34a38a0b0002eac7eb7a72 Mon Sep 17 00:00:00 2001 From: Leonardo Giacone Date: Wed, 15 May 2024 14:18:44 +0200 Subject: [PATCH 1/2] fix: various fixes (#4129) Co-authored-by: Pavel Denisjuk --- .../QueryBuilder/QueryBuilder.tsx | 5 +- .../QueryBuilder/components/Filter.tsx | 10 ++- .../components/GroupOperationLabel.tsx | 4 +- .../QuerySaverDialog/QuerySaverDialog.tsx | 5 +- .../fields/DateWithTimezone.tsx | 6 +- .../fields/DateWithoutTimezone.tsx | 4 +- .../app-aco/src/dialogs/useCreateDialog.tsx | 41 ++++++------ .../src/dialogs/useSetPermissionsDialog.tsx | 2 +- .../src/views/Logs/Filters/Filters.tsx | 4 +- .../FileManagerView/components/Filters.tsx | 9 +-- .../ContentEntries/Filters/Filters.tsx | 7 +- .../RefFieldRenderer/components/Ref.tsx | 22 +++++-- .../components/ContentEntryForm/useBind.tsx | 6 +- .../EditFieldDialog/PredefinedValues.tsx | 8 +-- .../EditFieldDialog/functions/getValue.ts | 5 +- .../EditFieldDialog/functions/setValue.ts | 15 ++--- .../plugins/fieldRenderers/DynamicSection.tsx | 6 +- .../lexicalText/lexicalTextInput.tsx | 9 +-- .../lexicalText/lexicalTextInputs.tsx | 15 ++--- .../components/NewReferencedEntryDialog.tsx | 64 +++++++++++++------ .../plugins/fieldRenderers/ref/refInputs.tsx | 17 ++--- .../fieldRenderers/richText/richTextInput.tsx | 11 ++-- .../richText/richTextInputs.tsx | 14 ++-- .../PredefinedValuesDynamicFieldset.tsx | 10 ++- .../ContentEntry/ContentEntryContext.tsx | 13 +++- .../views/contentEntries/Table/index.tsx | 2 +- .../ContentEntryLocker.tsx | 20 +++++- .../app-trash-bin/src/Presentation/index.tsx | 19 ++++-- packages/form/__tests__/form.test.tsx | 8 +++ packages/form/__tests__/setupEnv.ts | 2 + packages/form/jest.config.js | 3 +- packages/form/src/FormApi.ts | 24 +++++-- packages/form/src/FormField.ts | 51 +++++++++------ packages/form/src/FormPresenter.ts | 29 +++++---- packages/form/src/types.ts | 1 - packages/react-router/src/Prompt.tsx | 6 +- 36 files changed, 289 insertions(+), 188 deletions(-) create mode 100644 packages/form/__tests__/setupEnv.ts diff --git a/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/QueryBuilder.tsx b/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/QueryBuilder.tsx index cb15c3381a4..b52c0ebb4af 100644 --- a/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/QueryBuilder.tsx +++ b/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/QueryBuilder.tsx @@ -1,5 +1,4 @@ import React, { Fragment, useEffect } from "react"; -import { observer } from "mobx-react-lite"; import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/outlined/delete_outline.svg"; import { Accordion, AccordionItem } from "@webiny/ui/Accordion"; @@ -33,7 +32,7 @@ export interface QueryBuilderProps { vm: QueryBuilderViewModel; } -export const QueryBuilder = observer((props: QueryBuilderProps) => { +export const QueryBuilder = (props: QueryBuilderProps) => { const formRef = React.createRef(); useEffect(() => { @@ -123,4 +122,4 @@ export const QueryBuilder = observer((props: QueryBuilderProps) => { )} ); -}); +}; diff --git a/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/Filter.tsx b/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/Filter.tsx index 6e825a4a0c5..85628e2b39e 100644 --- a/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/Filter.tsx +++ b/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/Filter.tsx @@ -37,7 +37,15 @@ export const Filter = ({ name, onDelete, onFieldSelectChange, fields, filter }: label={"Field"} options={options} value={options.find(option => option.id === value)} - onChange={data => onFieldSelectChange(data)} + onChange={selected => { + /** + * Update the selected value only if it's different from the current value. + * When the value is populated from data, onChange might trigger re-rendering of the form and clear related fields. + */ + if (selected !== value) { + onFieldSelectChange(selected); + } + }} validation={validation} /> ); diff --git a/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/GroupOperationLabel.tsx b/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/GroupOperationLabel.tsx index 7d79e110b47..174d8a53038 100644 --- a/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/GroupOperationLabel.tsx +++ b/packages/app-aco/src/components/AdvancedSearch/QueryBuilderDrawer/QueryBuilder/components/GroupOperationLabel.tsx @@ -15,7 +15,9 @@ export const GroupOperationLabel = ({ operation, show }: GroupOperationLabelProp return ( - {operation} + + {operation} + ); }; diff --git a/packages/app-aco/src/components/AdvancedSearch/QuerySaverDialog/QuerySaverDialog.tsx b/packages/app-aco/src/components/AdvancedSearch/QuerySaverDialog/QuerySaverDialog.tsx index 849e9872272..a26026984e7 100644 --- a/packages/app-aco/src/components/AdvancedSearch/QuerySaverDialog/QuerySaverDialog.tsx +++ b/packages/app-aco/src/components/AdvancedSearch/QuerySaverDialog/QuerySaverDialog.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo } from "react"; -import { observer } from "mobx-react-lite"; import { Form } from "@webiny/form"; import { ButtonDefault, ButtonPrimary } from "@webiny/ui/Button"; @@ -25,7 +24,7 @@ interface QuerySaverDialogProps { }; } -export const QuerySaverDialog = observer(({ filter, ...props }: QuerySaverDialogProps) => { +export const QuerySaverDialog = ({ filter, ...props }: QuerySaverDialogProps) => { const presenter = useMemo(() => { return new QuerySaverDialogPresenter(); }, []); @@ -85,4 +84,4 @@ export const QuerySaverDialog = observer(({ filter, ...props }: QuerySaverDialog ) : null} ); -}); +}; diff --git a/packages/app-aco/src/components/AdvancedSearch/fields/DateWithTimezone.tsx b/packages/app-aco/src/components/AdvancedSearch/fields/DateWithTimezone.tsx index f9528efce2c..edc514f930f 100644 --- a/packages/app-aco/src/components/AdvancedSearch/fields/DateWithTimezone.tsx +++ b/packages/app-aco/src/components/AdvancedSearch/fields/DateWithTimezone.tsx @@ -31,8 +31,10 @@ export const DateWithTimezone = () => { }); useEffect(() => { - setDateTime(value.slice(0, -6)); - setTimeZone(value.slice(-6) || "+00:00"); + if (value) { + setDateTime(value.slice(0, -6)); + setTimeZone(value.slice(-6) || "+00:00"); + } }, [value]); const handleDateTimeChange = (value: string) => { diff --git a/packages/app-aco/src/components/AdvancedSearch/fields/DateWithoutTimezone.tsx b/packages/app-aco/src/components/AdvancedSearch/fields/DateWithoutTimezone.tsx index dc758d1774d..12936176636 100644 --- a/packages/app-aco/src/components/AdvancedSearch/fields/DateWithoutTimezone.tsx +++ b/packages/app-aco/src/components/AdvancedSearch/fields/DateWithoutTimezone.tsx @@ -14,7 +14,9 @@ export const DateWithoutTimezone = () => { }); useEffect(() => { - setDateTime(value.slice(0, -5)); + if (value) { + setDateTime(value.slice(0, -5)); + } }, [value]); const handleOnChange = (value: string) => { diff --git a/packages/app-aco/src/dialogs/useCreateDialog.tsx b/packages/app-aco/src/dialogs/useCreateDialog.tsx index 4c09e16e913..1920abe3593 100644 --- a/packages/app-aco/src/dialogs/useCreateDialog.tsx +++ b/packages/app-aco/src/dialogs/useCreateDialog.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from "react"; import slugify from "slugify"; import { useSnackbar } from "@webiny/app-admin"; -import { Bind, FormAPI, GenericFormData } from "@webiny/form"; +import { Bind, GenericFormData, useForm } from "@webiny/form"; import { Cell, Grid } from "@webiny/ui/Grid"; import { Input } from "@webiny/ui/Input"; import { Typography } from "@webiny/ui/Typography"; @@ -28,37 +28,34 @@ interface FormComponentProps { const FormComponent = ({ currentParentId = null }: FormComponentProps) => { const [parentId, setParentId] = useState(currentParentId); + const form = useForm(); - const generateSlug = (form: FormAPI) => { - return () => { - if (form.data.slug || !form.data.title) { - return; - } + const generateSlug = () => { + if (form.data.slug || !form.data.title) { + return; + } - // We want to update slug only when the folder is first being created. - form.setValue( - "slug", - slugify(form.data.title, { - replacement: "-", - lower: true, - remove: /[*#\?<>_\{\}\[\]+~.()'"!:;@]/g, - trim: false - }) - ); - }; + // We want to update slug only when the folder is first being created. + form.setValue( + "slug", + slugify(form.data.title, { + replacement: "-", + lower: true, + remove: /[*#\?<>_\{\}\[\]+~.()'"!:;@]/g, + trim: false + }) + ); }; return ( - - {({ form, ...bind }) => ( - - )} + + {bind => } - + diff --git a/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx b/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx index c8766004129..352414caa24 100644 --- a/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx +++ b/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx @@ -51,7 +51,7 @@ const FormComponent = ({ folder }: FormComponentProps) => { }); useEffect(() => { - bind.form.setValue("permissions", permissions); + bind.onChange(permissions); }, [permissions]); const addPermission = useCallback( diff --git a/packages/app-audit-logs/src/views/Logs/Filters/Filters.tsx b/packages/app-audit-logs/src/views/Logs/Filters/Filters.tsx index 9dfc59389c4..c1de92d4dd3 100644 --- a/packages/app-audit-logs/src/views/Logs/Filters/Filters.tsx +++ b/packages/app-audit-logs/src/views/Logs/Filters/Filters.tsx @@ -32,7 +32,5 @@ export const Filters = ({ showingFilters, setFilters, hasAccessToUsers }: Filter return browser.filters.filter(filter => filter.name !== "initiator"); }, [browser, hasAccessToUsers]); - return ( - - ); + return ; }; diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/Filters.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/Filters.tsx index 15027d939c1..b325e3aaf8e 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/Filters.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/components/Filters.tsx @@ -18,12 +18,5 @@ export const Filters = () => { setFilters(convertedFilters); }; - return ( - - ); + return ; }; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/Filters/Filters.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/Filters/Filters.tsx index 73f904880bf..76d61a827f8 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/Filters/Filters.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/Filters/Filters.tsx @@ -48,12 +48,7 @@ export const Filters = () => { } return ( - + { - const { useInputField } = ContentEntryListConfig.Browser.AdvancedSearch.FieldRenderer; +const { useInputField } = ContentEntryListConfig.Browser.AdvancedSearch.FieldRenderer; + +export const Ref = () => { + const { name } = useInputField(); + const { value } = useBind({ name }); + + return ; +}; + +interface RefProps { + value: string | undefined; +} + +const RefWithValue = observer(({ value }: RefProps) => { const { name, field } = useInputField(); const client = useApolloClient(); @@ -20,13 +32,9 @@ export const Ref = observer(() => { return new RefPresenter(repository); }, [client, field.settings.modelIds]); - const { value } = useBind({ - name - }); - useEffect(() => { presenter.load(value); - }, []); + }, [value]); const onInput = useCallback( debounce(value => presenter.search(value), 250), diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx index 15459e28e6d..320b369982e 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx @@ -1,7 +1,8 @@ import React, { useRef, useCallback, cloneElement } from "react"; +import { Validator } from "@webiny/validation/types"; +import { useForm } from "@webiny/form"; import { createValidators } from "~/utils/createValidators"; import { BindComponent, CmsModelField } from "~/types"; -import { Validator } from "@webiny/validation/types"; interface UseBindProps { field: CmsModelField; @@ -30,6 +31,7 @@ export interface GetBindCallable { export function useBind({ Bind, field }: UseBindProps) { const memoizedBindComponents = useRef>({}); const cacheKey = createFieldCacheKey(field); + const form = useForm(); return useCallback( (index = -1) => { @@ -93,7 +95,7 @@ export function useBind({ Bind, field }: UseBindProps) { bind.onChange(value); // To make sure the field is still valid, we must trigger validation. - bind.form.validateInput(field.fieldId); + form.validateInput(field.fieldId); }; props.moveValueUp = (index: number) => { diff --git a/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/PredefinedValues.tsx b/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/PredefinedValues.tsx index e90a5e70ee2..8a334e71fa6 100644 --- a/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/PredefinedValues.tsx +++ b/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/PredefinedValues.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useRef, cloneElement } from "react"; -import getValue from "./functions/getValue"; -import setValue from "./functions/setValue"; +import { getValue } from "./functions/getValue"; +import { setValue } from "./functions/setValue"; import { BindComponent, Bind as BaseFormBind } from "@webiny/form"; import { useModelField } from "~/admin/hooks"; @@ -25,8 +25,8 @@ const PredefinedValues = () => { const props = { ...bind, value: getValue({ bind, index, name }), - onChange: async (value: string[]) => { - await setValue({ value, bind, index, name }); + onChange: (value: string[]) => { + setValue({ value, bind, index, name }); } }; diff --git a/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/getValue.ts b/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/getValue.ts index 0f0734c4b56..ba11968ddf3 100644 --- a/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/getValue.ts +++ b/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/getValue.ts @@ -6,7 +6,8 @@ interface Params { index: number; name: string; } -const getValue = (params: Params): string[] | undefined => { + +export const getValue = (params: Params): string[] | undefined => { const { bind, index, name } = params; const value = bind.value || []; @@ -16,5 +17,3 @@ const getValue = (params: Params): string[] | undefined => { return value; }; - -export default getValue; diff --git a/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/setValue.ts b/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/setValue.ts index c51da874d76..900c90969c5 100644 --- a/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/setValue.ts +++ b/packages/app-headless-cms/src/admin/components/FieldEditor/EditFieldDialog/functions/setValue.ts @@ -1,4 +1,4 @@ -import set from "lodash/set"; +import { set } from "dot-prop-immutable"; import { BindComponentRenderProp } from "@webiny/form"; interface Params { @@ -7,16 +7,13 @@ interface Params { index: number; name: string; } -const setValue = (params: Params): void => { + +export const setValue = (params: Params): void => { const { value, bind, index, name } = params; - let newValue = [...(bind.value || [])]; + const currentValue = [...(bind.value || [])]; if (index >= 0) { - set(newValue, `${index}.${name}`, value); + bind.onChange(set(currentValue, `${index}.${name}`, value)); } else { - newValue = value; + bind.onChange(value); } - - bind.onChange(newValue); }; - -export default setValue; diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx index 987a54f1af4..b4070af96b3 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx @@ -1,4 +1,5 @@ import React from "react"; +import classSet from "classnames"; import { css } from "emotion"; import { i18n } from "@webiny/app/i18n"; import { Cell, Grid } from "@webiny/ui/Grid"; @@ -13,6 +14,9 @@ import { ParentValueIndexProvider } from "~/admin/components/ModelFieldProvider" const t = i18n.ns("app-headless-cms/admin/fields/text"); const style = { + gridContainer: css` + padding: 0 !important; + `, addButton: css({ width: "100%", borderTop: "1px solid var(--mdc-theme-background)", @@ -67,7 +71,7 @@ const DynamicSection = ({ const bindFieldValue: string[] = value || []; return ( - + {typeof renderTitle === "function" && renderTitle(bindFieldValue)} {/* We always render the first item, for better UX */} diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInput.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInput.tsx index cfe69829164..798f3523a6c 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInput.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInput.tsx @@ -2,15 +2,15 @@ import React from "react"; import get from "lodash/get"; import { i18n } from "@webiny/app/i18n"; import { CmsModelFieldRendererPlugin, CmsModelField } from "~/types"; -import { BindComponentRenderProp } from "@webiny/form"; +import { useForm } from "@webiny/form"; import { LexicalCmsEditor } from "~/admin/components/LexicalCmsEditor/LexicalCmsEditor"; import { modelHasLegacyRteField } from "~/admin/plugins/fieldRenderers/richText/utils"; import { FormElementMessage } from "@webiny/ui/FormElementMessage"; const t = i18n.ns("app-headless-cms/admin/fields/rich-text"); -const getKey = (field: CmsModelField, bind: BindComponentRenderProp): string => { - const formId = bind.form.data.id || "new"; +const getKey = (id: string | undefined, field: CmsModelField): string => { + const formId = id || "new"; return `${formId}.${field.fieldId}`; }; @@ -34,6 +34,7 @@ const plugin: CmsModelFieldRendererPlugin = { return canUse; }, render({ field, getBind, Label }) { + const form = useForm(); const Bind = getBind(); return ( @@ -44,7 +45,7 @@ const plugin: CmsModelFieldRendererPlugin = { diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInputs.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInputs.tsx index f51ad5b207b..1ba804bc8e2 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInputs.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/lexicalText/lexicalTextInputs.tsx @@ -3,21 +3,18 @@ import get from "lodash/get"; import { i18n } from "@webiny/app/i18n"; import { CmsModelField, CmsModelFieldRendererPlugin } from "~/types"; import { ReactComponent as DeleteIcon } from "~/admin/icons/close.svg"; -import DynamicSection, { DynamicSectionPropsChildrenParams } from "../DynamicSection"; +import DynamicSection from "../DynamicSection"; import { IconButton } from "@webiny/ui/Button"; import styled from "@emotion/styled"; import { LexicalCmsEditor } from "~/admin/components/LexicalCmsEditor/LexicalCmsEditor"; import { modelHasLegacyRteField } from "~/admin/plugins/fieldRenderers/richText/utils"; import { FormElementMessage } from "@webiny/ui/FormElementMessage"; +import { useForm } from "@webiny/form"; const t = i18n.ns("app-headless-cms/admin/fields/rich-text"); -const getKey = ( - field: CmsModelField, - bind: DynamicSectionPropsChildrenParams["bind"], - index: number -): string => { - const formId = bind.index.form?.data?.id || "new"; +const getKey = (id: string | undefined, field: CmsModelField, index: number): string => { + const formId = id || "new"; return `${formId}.${field.fieldId}.${index}`; }; @@ -52,6 +49,8 @@ const plugin: CmsModelFieldRendererPlugin = { }, render(props) { const { field } = props; + const form = useForm(); + return ( {({ bind, index }) => ( @@ -59,7 +58,7 @@ const plugin: CmsModelFieldRendererPlugin = { {field.helpText} diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx index 9b4bf784c91..3b77d0ba06b 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/components/NewReferencedEntryDialog.tsx @@ -122,7 +122,6 @@ export const NewReferencedEntryDialog = ({ }: NewReferencedEntryDialogProps) => { const { apolloClient } = useCms(); const [model, setModel] = useState(undefined); - const saveEntryRef = useRef(); useEffect(() => { (async () => { @@ -166,25 +165,11 @@ export const NewReferencedEntryDialog = ({ - true} getContentId={() => null}> - - - {t`New {modelName} Entry`({ modelName: model.name })} - - - (saveEntryRef.current = cb)} - /> - - - {t`Cancel`} - saveEntryRef.current && saveEntryRef.current()} - >{t`Create Entry`} - - - + @@ -220,3 +205,42 @@ const NavigateFolderProvider = ({ ); }; + +interface ContentEntryProviderWithCurrentFolderIdProps { + model: CmsModel; + onClose: () => void; + onCreate: (entry: CmsContentEntry) => void; +} + +const ContentEntryProviderWithCurrentFolderId = ({ + model, + onClose, + onCreate +}: ContentEntryProviderWithCurrentFolderIdProps) => { + const saveEntryRef = useRef(); + const { currentFolderId } = useNavigateFolder(); + + return ( + true} + getContentId={() => null} + currentFolderId={currentFolderId} + > + + {t`New {modelName} Entry`({ modelName: model.name })} + + (saveEntryRef.current = cb)} + /> + + + {t`Cancel`} + saveEntryRef.current && saveEntryRef.current()} + >{t`Create Entry`} + + + + ); +}; diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/refInputs.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/refInputs.tsx index aeb4670f5be..9196cc938ef 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/refInputs.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/refInputs.tsx @@ -1,18 +1,13 @@ import React from "react"; +import { i18n } from "@webiny/app/i18n"; +import { useForm } from "@webiny/form"; import { CmsModelField, CmsModelFieldRendererPlugin } from "~/types"; import ContentEntriesMultiAutocomplete from "./components/ContentEntriesMultiAutoComplete"; -import { i18n } from "@webiny/app/i18n"; - const t = i18n.ns("app-headless-cms/admin/fields/ref"); -const getKey = ( - field: CmsModelField, - data?: { - id?: string; - } -): string => { - return (data?.id || "unknown") + "." + field.fieldId; +const getKey = (field: CmsModelField, id: string | undefined): string => { + return (id || "unknown") + "." + field.fieldId; }; const plugin: CmsModelFieldRendererPlugin = { @@ -27,11 +22,13 @@ const plugin: CmsModelFieldRendererPlugin = { }, render(props) { const Bind = props.getBind(); + const form = useForm(); + return ( {bind => ( diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/richText/richTextInput.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/richText/richTextInput.tsx index 88e8c56533c..ec142ab2fe5 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/richText/richTextInput.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/richText/richTextInput.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from "react"; import get from "lodash/get"; import { i18n } from "@webiny/app/i18n"; -import { CmsModelFieldRendererPlugin, CmsModelField } from "~/types"; import { createPropsFromConfig, RichTextEditor } from "@webiny/app-admin/components/RichTextEditor"; import { plugins } from "@webiny/plugins"; -import { BindComponentRenderProp } from "@webiny/form"; +import { useForm } from "@webiny/form"; +import { CmsModelFieldRendererPlugin, CmsModelField } from "~/types"; import { allowCmsLegacyRichTextInput } from "~/utils/allowCmsLegacyRichTextInput"; import { modelHasLexicalField } from "~/admin/plugins/fieldRenderers/lexicalText/utils"; import { @@ -14,8 +14,8 @@ import { const t = i18n.ns("app-headless-cms/admin/fields/rich-text"); -const getKey = (field: CmsModelField, bind: BindComponentRenderProp): string => { - const formId = bind.form.data.id || "new"; +const getKey = (field: CmsModelField, id: string | undefined): string => { + const formId = id || "new"; return `${formId}.${field.fieldId}`; }; @@ -50,6 +50,7 @@ const plugin: CmsModelFieldRendererPlugin = { return canUse; }, render({ field, getBind }) { + const form = useForm(); const Bind = getBind(); const rteProps = useMemo(() => { @@ -61,7 +62,7 @@ const plugin: CmsModelFieldRendererPlugin = { {bind => { return ( { - const formId = bind.index.form?.data?.id || "new"; +const getKey = (field: CmsModelField, id: string | undefined, index: number): string => { + const formId = id || "new"; return `${formId}.${field.fieldId}.${index}`; }; @@ -77,6 +74,7 @@ const plugin: CmsModelFieldRendererPlugin = { return canUse; }, render(props) { + const form = useForm(); const { field } = props; const rteProps = useMemo(() => { @@ -94,7 +92,7 @@ const plugin: CmsModelFieldRendererPlugin = { /> )} { - const { bind, field, index: targetIndex, value: setToValue } = params; - bind.form.setValue( + const { form, bind, field, index: targetIndex, value: setToValue } = params; + + form.setValue( "predefinedValues.values", bind.value.map((value: PredefinedValue, index: number) => { const defaultValue = field.multipleValues ? value.selected : false; @@ -79,6 +81,7 @@ const PredefinedValuesDynamicFieldset = ({ }: PredefinedValuesDynamicFieldsetProps) => { const Bind = getBind(); const { field } = useModelField(); + const form = useForm(); return ( @@ -126,6 +129,7 @@ const PredefinedValuesDynamicFieldset = ({ } onChange={value => { onSelectedChange({ + form, bind, field, index, diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx index 0a55baee7fb..9469209531e 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx @@ -19,6 +19,7 @@ import { getFetchPolicy } from "~/utils/getFetchPolicy"; import { useRecords } from "@webiny/app-aco"; import * as Cms from "~/admin/contexts/Cms"; import { useMockRecords } from "./useMockRecords"; +import { ROOT_FOLDER } from "~/admin/constants"; interface UpdateListCacheOptions { options?: { @@ -71,6 +72,7 @@ export interface ContentEntryContextProviderProps extends Partial { const { isMounted } = useIsMounted(); const [activeTab, setActiveTab] = useState(0); @@ -113,7 +116,6 @@ export const ContentEntryProvider = ({ ? useMockRecords() : useRecords(); const [isLoading, setLoading] = useState(false); - const contentEntryProviderProps = useContentEntryProviderProps(); const newEntry = @@ -212,7 +214,12 @@ export const ContentEntryProvider = ({ setLoading(true); const response = await cms.createEntry({ model, - entry, + entry: { + ...entry, + wbyAco_location: { + folderId: currentFolderId || ROOT_FOLDER + } + }, options: { skipValidators: options?.skipValidators } }); setLoading(false); diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/Table/index.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/Table/index.tsx index 7db9c07cf1f..7f8973757b0 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/Table/index.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/Table/index.tsx @@ -17,7 +17,7 @@ const View = () => { - +
diff --git a/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx b/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx index b5c9e6afd04..6e7944059aa 100644 --- a/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx +++ b/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx @@ -1,5 +1,5 @@ import { useContentEntriesList, useContentEntry } from "@webiny/app-headless-cms"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; import { useRecordLocking } from "~/hooks"; import { IIsRecordLockedParams, IRecordLockingIdentity, IRecordLockingLockRecord } from "~/types"; import { @@ -9,6 +9,7 @@ import { } from "@webiny/app-websockets"; import { parseIdentifier } from "@webiny/utils"; import { useDialogs } from "@webiny/app-admin"; +import { Prompt } from "@webiny/react-router"; export interface IContentEntryLockerProps { children: React.ReactElement; @@ -46,6 +47,16 @@ export const ContentEntryLocker = ({ children }: IContentEntryLockerProps) => { const { showDialog } = useDialogs(); + const PromptDecorator = useMemo(() => { + return Prompt.createDecorator(Original => { + return function Prompt(props) { + return ; + // const when = disablePrompt.current === true ? false : props.when; + // return ; + }; + }); + }, []); + useEffect(() => { if (!entry.id) { return; @@ -104,5 +115,10 @@ export const ContentEntryLocker = ({ children }: IContentEntryLockerProps) => { }; }, [entry.id]); - return children; + return ( + <> + + {children} + + ); }; diff --git a/packages/app-trash-bin/src/Presentation/index.tsx b/packages/app-trash-bin/src/Presentation/index.tsx index 1ff52134ed6..a37a7d4e5dd 100644 --- a/packages/app-trash-bin/src/Presentation/index.tsx +++ b/packages/app-trash-bin/src/Presentation/index.tsx @@ -59,12 +59,21 @@ export const TrashBin = ({ render, ...rest }: TrashBinProps) => { ); const retentionPeriod = useMemo(() => { - // Retrieve the retention period from the environment variable WEBINY_ADMIN_TRASH_BIN_RETENTION_PERIOD_DAYS, - // or default to 90 days if not set or set to 0. + // Default retention period if no valid environment variable is found + const defaultRetentionPeriod = 90; + + // Retrieve the retention period from the environment variable const retentionPeriodFromEnv = process.env["WEBINY_ADMIN_TRASH_BIN_RETENTION_PERIOD_DAYS"]; - return retentionPeriodFromEnv && Number(retentionPeriodFromEnv) !== 0 - ? Number(retentionPeriodFromEnv) - : 90; + + // Parse the environment variable value to an integer (or use default if not valid or not set) + const parsedRetentionPeriod = retentionPeriodFromEnv + ? parseInt(retentionPeriodFromEnv, 10) + : defaultRetentionPeriod; + + // Return the parsed retention period if valid and not zero, otherwise return the default + return isNaN(parsedRetentionPeriod) || parsedRetentionPeriod === 0 + ? defaultRetentionPeriod + : parsedRetentionPeriod; }, []); return ( diff --git a/packages/form/__tests__/form.test.tsx b/packages/form/__tests__/form.test.tsx index 962d04f0d16..4efd5d35b50 100644 --- a/packages/form/__tests__/form.test.tsx +++ b/packages/form/__tests__/form.test.tsx @@ -147,6 +147,14 @@ const assert = async (view: React.ReactElement, onSubmit: jest.MockedFunction { + // beforeEach(() => { + // jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb()); + // }); + // + // afterEach(() => { + // window.requestAnimationFrame.mockRestore(); + // }); + test("should call `onSubmit` callback with correct field values using ``", async () => { const onSubmit = jest.fn(); await assert(, onSubmit); diff --git a/packages/form/__tests__/setupEnv.ts b/packages/form/__tests__/setupEnv.ts new file mode 100644 index 00000000000..acc39c345b6 --- /dev/null +++ b/packages/form/__tests__/setupEnv.ts @@ -0,0 +1,2 @@ +// @ts-expect-error Mock `requestAnimationFrame`. +global.requestAnimationFrame = (fn: () => void) => fn(); diff --git a/packages/form/jest.config.js b/packages/form/jest.config.js index cc5ac2bb64f..ef1281b54dc 100644 --- a/packages/form/jest.config.js +++ b/packages/form/jest.config.js @@ -1,5 +1,6 @@ const base = require("../../jest.config.base"); module.exports = { - ...base({ path: __dirname }) + ...base({ path: __dirname }), + setupFilesAfterEnv: [require.resolve("./__tests__/setupEnv.ts")] }; diff --git a/packages/form/src/FormApi.ts b/packages/form/src/FormApi.ts index dab87062ad4..7ccd6139aba 100644 --- a/packages/form/src/FormApi.ts +++ b/packages/form/src/FormApi.ts @@ -7,6 +7,8 @@ export interface FormApiOptions { validateOnFirstSubmit: boolean; } +const emptyValues: unknown[] = [undefined, null]; + export class FormAPI { private presenter: FormPresenter; private readonly options: FormApiOptions; @@ -44,14 +46,11 @@ export class FormAPI { registerField = (props: BindComponentProps) => { this.presenter.registerField(props); - const validation = this.presenter.getFieldValidation(props.name); - return { disabled: this.isDisabled(), validate: () => this.validateInput(props.name), - validation, - value: this.presenter.getFieldValue(props.name), - form: this as FormAPI, + validation: this.presenter.getFieldValidation(props.name), + value: this.valueOrDefault(props), onChange: async (value: unknown) => { this.presenter.setFieldValue(props.name, value); @@ -104,4 +103,19 @@ export class FormAPI { return !validateOnFirstSubmit || (validateOnFirstSubmit && this.wasSubmitted); } + + /** + * We need to use the `defaultValue` from props on the first render, because default value is only available in the + * form data on the next render cycle (we set it in the `requestAnimationFrame()`). This one render cycle is enough + * to cause problems, so to avoid issues, we use the immediate props to ensure the correct value is returned. + * On the second render cycle, the `getFieldValue` will contain the default value, and that's what will be returned. + * @private + */ + private valueOrDefault(props: BindComponentProps) { + const value = this.presenter.getFieldValue(props.name); + if (emptyValues.includes(value) && props.defaultValue !== undefined) { + return props.defaultValue; + } + return value; + } } diff --git a/packages/form/src/FormField.ts b/packages/form/src/FormField.ts index d6a8be2375b..8b36016b109 100644 --- a/packages/form/src/FormField.ts +++ b/packages/form/src/FormField.ts @@ -7,6 +7,10 @@ interface BeforeChange { (value: unknown, cb: (value: unknown) => void): void; } +interface AfterChange { + (value: unknown, form: FormAPI): void; +} + const defaultBeforeChange: BeforeChange = (value, cb) => cb(value); const defaultAfterChange = lodashNoop; @@ -14,9 +18,9 @@ const defaultAfterChange = lodashNoop; export class FormField { private readonly name: string; private readonly defaultValue: unknown; - private validator: FormFieldValidator; - private readonly beforeChange?: BeforeChange; - private readonly afterChange?: (value: unknown, form: FormAPI) => void; + private validator: FormFieldValidator | undefined; + private beforeChange?: BeforeChange; + private afterChange?: AfterChange; private validation: FieldValidationResult | undefined = undefined; private constructor(props: BindComponentProps) { @@ -24,23 +28,7 @@ export class FormField { this.defaultValue = props.defaultValue; this.beforeChange = props.beforeChange; this.afterChange = props.afterChange; - - let validators: Validator[] = []; - if (!props.validators) { - validators = []; - } else if (!Array.isArray(props.validators)) { - validators = [props.validators as Validator]; - } else { - validators = props.validators; - } - - this.validator = new FormFieldValidator(validators); - } - - static createFrom(field: FormField, props: BindComponentProps) { - const newField = new FormField(props); - newField.validation = field.validation; - return newField; + this.setValidators(props.validators); } static create(props: BindComponentProps) { @@ -51,7 +39,7 @@ export class FormField { value: unknown, options?: FormValidationOptions ): Promise { - this.validation = await this.validator.validate(value, options || { skipValidators: [] }); + this.validation = await this.validator!.validate(value, options || { skipValidators: [] }); return this.validation; } @@ -79,6 +67,27 @@ export class FormField { return this.validation; } + setBeforeChange(cb: BeforeChange | undefined) { + this.beforeChange = cb; + } + + setAfterChange(cb: AfterChange | undefined) { + this.afterChange = cb; + } + + setValidators(validators: Validator | Validator[] | undefined) { + let normalized: Validator[] = []; + if (!validators) { + normalized = []; + } else if (!Array.isArray(validators)) { + normalized = [validators as Validator]; + } else { + normalized = validators; + } + + this.validator = new FormFieldValidator(normalized); + } + setValue(value: unknown, cb: (value: unknown) => void) { const beforeChange = this.getBeforeChange(); const afterChange = this.getAfterChange(); diff --git a/packages/form/src/FormPresenter.ts b/packages/form/src/FormPresenter.ts index 7007750271e..5053301a2d8 100644 --- a/packages/form/src/FormPresenter.ts +++ b/packages/form/src/FormPresenter.ts @@ -29,6 +29,8 @@ export interface InvalidFormFields { [name: string]: FieldValidationResult; } +const emptyValues = [undefined, null]; + export class FormPresenter { /* Holds the current form data. */ private data: T; @@ -71,7 +73,7 @@ export class FormPresenter { } getFieldValue(name: string) { - return lodashGet(this.data, name); + return lodashGet(this.data, name) as unknown; } getFieldValidation(name: string): FieldValidationResult { @@ -143,24 +145,27 @@ export class FormPresenter { registerField(props: BindComponentProps) { const existingField = this.formFields.get(props.name); - let field: FormField; if (existingField) { - field = FormField.createFrom(existingField, props); - } else { - field = FormField.create(props); + existingField.setValidators(props.validators); + existingField.setBeforeChange(props.beforeChange); + existingField.setAfterChange(props.afterChange); + return; } + const field = FormField.create(props); + // We only want to handle default field value for new fields. - if (!existingField) { - const fieldName = field.getName(); - const currentFieldValue = lodashGet(this.data, fieldName); - const defaultValue = field.getDefaultValue(); - if (currentFieldValue === undefined && defaultValue !== undefined) { + const fieldName = field.getName(); + const currentFieldValue = lodashGet(this.data, fieldName); + const defaultValue = field.getDefaultValue(); + + requestAnimationFrame(() => { + if (emptyValues.includes(currentFieldValue) && defaultValue !== undefined) { lodashSet(this.data, fieldName, defaultValue); } - } - this.formFields.set(props.name, field); + this.formFields.set(props.name, field); + }); } unregisterField(name: string) { diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts index ae5f5998081..d666fc6a92f 100644 --- a/packages/form/src/types.ts +++ b/packages/form/src/types.ts @@ -35,7 +35,6 @@ export interface UseBindHook extends BindComponentRenderProp { } export interface BindComponentRenderProp { - form: FormAPI; onChange: BindComponentRenderPropOnChange; value: T; validate: () => Promise; diff --git a/packages/react-router/src/Prompt.tsx b/packages/react-router/src/Prompt.tsx index e8446cf51a5..7d45af35b06 100644 --- a/packages/react-router/src/Prompt.tsx +++ b/packages/react-router/src/Prompt.tsx @@ -1,3 +1,4 @@ +import { makeDecoratable } from "@webiny/react-composition"; /** * https://github.com/remix-run/react-router/blob/v5/packages/react-router/modules/Prompt.js */ @@ -7,7 +8,8 @@ export interface PromptProps { when: boolean; message: string; } -export const Prompt = ({ when, message }: PromptProps) => { + +export const Prompt = makeDecoratable("Prompt", ({ when, message }: PromptProps) => { usePrompt(message, when); return null; -}; +}); From e84baf8354af76eeec6f2b93d8a45dc9185ecf23 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 15 May 2024 14:26:43 +0200 Subject: [PATCH 2/2] fix(lexical-editor): hide floating toolbar on blur [skip ci] --- .../src/components/Toolbar/Toolbar.css | 145 +++++++++--------- .../src/components/Toolbar/Toolbar.tsx | 24 ++- .../src/utils/isChildOfFloatingToolbar.ts | 13 ++ 3 files changed, 108 insertions(+), 74 deletions(-) create mode 100644 packages/lexical-editor/src/utils/isChildOfFloatingToolbar.ts diff --git a/packages/lexical-editor/src/components/Toolbar/Toolbar.css b/packages/lexical-editor/src/components/Toolbar/Toolbar.css index 472fcc42eca..8bc676633ab 100644 --- a/packages/lexical-editor/src/components/Toolbar/Toolbar.css +++ b/packages/lexical-editor/src/components/Toolbar/Toolbar.css @@ -1,4 +1,4 @@ -.floating-text-format-popup { +.floating-toolbar { display: flex; padding: 4px; vertical-align: middle; @@ -15,7 +15,7 @@ will-change: transform; } -.floating-text-format-popup button.popup-item { +.floating-toolbar button.popup-item { border: 0; display: flex; background: none; @@ -25,15 +25,15 @@ vertical-align: middle; } -.floating-text-format-popup button.popup-item:disabled { +.floating-toolbar button.popup-item:disabled { cursor: not-allowed; } -.floating-text-format-popup button.popup-item.spaced { +.floating-toolbar button.popup-item.spaced { margin-right: 2px; } -.floating-text-format-popup button.popup-item i.format { +.floating-toolbar button.popup-item i.format { background-size: contain; display: inline-block; height: 18px; @@ -44,23 +44,23 @@ opacity: 0.6; } -.floating-text-format-popup button.popup-item:disabled i.format { +.floating-toolbar button.popup-item:disabled i.format { opacity: 0.2; } -.floating-text-format-popup button.popup-item.active { +.floating-toolbar button.popup-item.active { background-color: rgba(223, 232, 250, 0.3); } -.floating-text-format-popup button.popup-item.active i { +.floating-toolbar button.popup-item.active i { opacity: 1; } -.floating-text-format-popup .popup-item:hover:not([disabled]) { +.floating-toolbar .popup-item:hover:not([disabled]) { background-color: #eee; } -.floating-text-format-popup select.popup-item { +.floating-toolbar select.popup-item { border: 0; display: flex; background: none; @@ -75,12 +75,12 @@ text-overflow: ellipsis; } -.floating-text-format-popup select.code-language { +.floating-toolbar select.code-language { text-transform: capitalize; width: 130px; } -.floating-text-format-popup .popup-item .text { +.floating-toolbar .popup-item .text { display: flex; line-height: 20px; width: 200px; @@ -94,7 +94,7 @@ text-align: left; } -.floating-text-format-popup .popup-item .icon { +.floating-toolbar .popup-item .icon { display: flex; width: 20px; height: 20px; @@ -104,7 +104,7 @@ background-size: contain; } -.floating-text-format-popup i.chevron-down { +.floating-toolbar i.chevron-down { margin-top: 3px; width: 16px; height: 16px; @@ -112,7 +112,7 @@ user-select: none; } -.floating-text-format-popup i.chevron-down.inside { +.floating-toolbar i.chevron-down.inside { width: 16px; height: 16px; display: flex; @@ -122,7 +122,7 @@ pointer-events: none; } -.floating-text-format-popup .divider { +.floating-toolbar .divider { width: 1px; background-color: #eee; margin: 0 4px; @@ -190,8 +190,8 @@ i.chevron-down { background-image: url("../../images/icons/chevron-down.svg"); } - -i.insert-image, .icon.insert-image { +i.insert-image, +.icon.insert-image { background-color: transparent; background-size: contain; display: inline-block; @@ -210,7 +210,8 @@ i.insert-image, .icon.insert-image { background-image: url("../../images/icons/list-ol.svg"); } -i.font-color, .icon.font-color { +i.font-color, +.icon.font-color { background-image: url("../../images/icons/font-color.svg"); } @@ -219,7 +220,7 @@ i.font-color, .icon.font-color { background-color: rgb(223, 232, 250); } -.floating-text-format-popup button.toolbar-item { +.floating-toolbar button.toolbar-item { border: 0; display: flex; background: none; @@ -232,15 +233,15 @@ i.font-color, .icon.font-color { justify-content: space-between; } -.floating-text-format-popup button.toolbar-item:disabled { +.floating-toolbar button.toolbar-item:disabled { cursor: not-allowed; } -.floating-text-format-popup button.toolbar-item.spaced { +.floating-toolbar button.toolbar-item.spaced { margin-right: 2px; } -.floating-text-format-popup button.toolbar-item i.format { +.floating-toolbar button.toolbar-item i.format { background-size: contain; display: inline-block; height: 18px; @@ -250,35 +251,35 @@ i.font-color, .icon.font-color { opacity: 0.6; } -.floating-text-format-popup button.toolbar-item:disabled .icon, -.floating-text-format-popup button.toolbar-item:disabled .text, -.floating-text-format-popup button.toolbar-item:disabled i.format, -.floating-text-format-popup button.toolbar-item:disabled .chevron-down { +.floating-toolbar button.toolbar-item:disabled .icon, +.floating-toolbar button.toolbar-item:disabled .text, +.floating-toolbar button.toolbar-item:disabled i.format, +.floating-toolbar button.toolbar-item:disabled .chevron-down { opacity: 0.2; } -.floating-text-format-popup button.toolbar-item.active { +.floating-toolbar button.toolbar-item.active { background-color: rgba(223, 232, 250, 0.3); } -.floating-text-format-popup button.toolbar-item.active i { +.floating-toolbar button.toolbar-item.active i { opacity: 1; } -.floating-text-format-popup .toolbar-item:hover:not([disabled]) { +.floating-toolbar .toolbar-item:hover:not([disabled]) { background-color: #eee; } -.floating-text-format-popup .toolbar-item.font-family .text { +.floating-toolbar .toolbar-item.font-family .text { display: block; max-width: 40px; } -.floating-text-format-popup .code-language { +.floating-toolbar .code-language { width: 150px; } -.floating-text-format-popup .toolbar-item .text { +.floating-toolbar .toolbar-item .text { display: flex; line-height: 20px; vertical-align: middle; @@ -291,7 +292,7 @@ i.font-color, .icon.font-color { padding-right: 10px; } -.floating-text-format-popup .toolbar-item .icon { +.floating-toolbar .toolbar-item .icon { display: flex; width: 20px; height: 20px; @@ -301,7 +302,7 @@ i.font-color, .icon.font-color { background-size: contain; } -.floating-text-format-popup i.chevron-down { +.floating-toolbar i.chevron-down { margin-top: 3px; width: 16px; height: 16px; @@ -309,7 +310,7 @@ i.font-color, .icon.font-color { user-select: none; } -.floating-text-format-popup i.chevron-down.inside { +.floating-toolbar i.chevron-down.inside { width: 16px; height: 16px; display: flex; @@ -325,7 +326,7 @@ i.font-color, .icon.font-color { margin: 0 4px; } -.toolbar-item.font-size{ +.toolbar-item.font-size { width: 70px; } @@ -340,7 +341,7 @@ i.font-color, .icon.font-color { display: block; position: fixed; box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1), - inset 0 0 0 1px rgba(255, 255, 255, 0.5); + inset 0 0 0 1px rgba(255, 255, 255, 0.5); border-radius: 8px; min-height: 40px; background-color: #fff; @@ -436,7 +437,7 @@ i.font-color, .icon.font-color { } } - button.item i { +button.item i { opacity: 0.6; } @@ -452,30 +453,30 @@ button.item.dropdown-item-active i { cursor: default; display: inline-block; position: relative; - user-select: none + user-select: none; } .editor-shell .editor-image img { max-width: 100%; - cursor: default + cursor: default; } .editor-shell .editor-image img.focused { - outline: 2px solid rgb(60,132,244); - user-select: none + outline: 2px solid rgb(60, 132, 244); + user-select: none; } .editor-shell .editor-image img.focused.draggable { - cursor: grab + cursor: grab; } .editor-shell .editor-image img.focused.draggable:active { - cursor: grabbing + cursor: grabbing; } .editor-shell .editor-image .image-caption-container .tree-view-output { margin: 0; - border-radius: 0 + border-radius: 0; } .editor-shell .editor-image .image-caption-container { @@ -490,7 +491,7 @@ button.item.dropdown-item-active i { background-color: #ffffffe6; min-width: 100px; color: #000; - overflow: hidden + overflow: hidden; } .editor-shell .editor-image .image-caption-button { @@ -502,17 +503,17 @@ button.item.dropdown-item-active i { width: 30%; padding: 10px; margin: 0 auto; - border: 1px solid rgba(255,255,255,.3); + border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 5px; background-color: #00000080; min-width: 100px; color: #fff; cursor: pointer; - user-select: none + user-select: none; } .editor-shell .editor-image .image-caption-button:hover { - background-color: #3c84f480 + background-color: #3c84f480; } .editor-shell .editor-image .image-resizer { @@ -521,100 +522,100 @@ button.item.dropdown-item-active i { height: 7px; position: absolute; background-color: #3c84f4; - border: 1px solid #fff + border: 1px solid #fff; } .editor-shell .editor-image .image-resizer.image-resizer-n { top: -6px; left: 48%; - cursor: n-resize + cursor: n-resize; } .editor-shell .editor-image .image-resizer.image-resizer-ne { top: -6px; right: -6px; - cursor: ne-resize + cursor: ne-resize; } .editor-shell .editor-image .image-resizer.image-resizer-e { bottom: 48%; right: -6px; - cursor: e-resize + cursor: e-resize; } .editor-shell .editor-image .image-resizer.image-resizer-se { bottom: -2px; right: -6px; - cursor: nwse-resize + cursor: nwse-resize; } .editor-shell .editor-image .image-resizer.image-resizer-s { bottom: -2px; left: 48%; - cursor: s-resize + cursor: s-resize; } .editor-shell .editor-image .image-resizer.image-resizer-sw { bottom: -2px; left: -6px; - cursor: sw-resize + cursor: sw-resize; } .editor-shell .editor-image .image-resizer.image-resizer-w { bottom: 48%; left: -6px; - cursor: w-resize + cursor: w-resize; } .editor-shell .editor-image .image-resizer.image-resizer-nw { top: -6px; left: -6px; - cursor: nw-resize + cursor: nw-resize; } .editor-shell span.inline-editor-image { cursor: default; display: inline-block; position: relative; - z-index: 1 + z-index: 1; } .editor-shell .inline-editor-image img { max-width: 100%; - cursor: default + cursor: default; } .editor-shell .inline-editor-image img.focused { - outline: 2px solid rgb(60,132,244) + outline: 2px solid rgb(60, 132, 244); } .editor-shell .inline-editor-image img.focused.draggable { - cursor: grab + cursor: grab; } .editor-shell .inline-editor-image img.focused.draggable:active { - cursor: grabbing + cursor: grabbing; } .editor-shell .inline-editor-image .image-caption-container .tree-view-output { margin: 0; - border-radius: 0 + border-radius: 0; } .editor-shell .inline-editor-image.position-full { - margin: 1em 0 + margin: 1em 0; } .editor-shell .inline-editor-image.position-left { float: left; width: 50%; - margin: 1em 1em 0 0 + margin: 1em 1em 0 0; } .editor-shell .inline-editor-image.position-right { float: right; width: 50%; - margin: 1em 0 0 1em + margin: 1em 0 0 1em; } .editor-shell .inline-editor-image .image-edit-button { @@ -624,17 +625,17 @@ button.item.dropdown-item-active i { right: 12px; padding: 6px 8px; margin: 0 auto; - border: 1px solid rgba(255,255,255,.3); + border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 5px; background-color: #00000080; min-width: 60px; color: #fff; cursor: pointer; - user-select: none + user-select: none; } .editor-shell .inline-editor-image .image-edit-button:hover { - background-color: #3c84f480 + background-color: #3c84f480; } .editor-shell .inline-editor-image .image-caption-container { @@ -642,5 +643,5 @@ button.item.dropdown-item-active i { background-color: #f4f4f4; min-width: 100%; color: #000; - overflow: hidden + overflow: hidden; } diff --git a/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx b/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx index f1587347867..70d77b1f91a 100644 --- a/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx +++ b/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx @@ -1,6 +1,7 @@ -import React, { FC, Fragment, useCallback, useEffect, useRef } from "react"; +import React, { FC, Fragment, useCallback, useEffect, useRef, useState } from "react"; import { $getSelection, + BLUR_COMMAND, COMMAND_PRIORITY_LOW, LexicalEditor, RangeSelection, @@ -16,6 +17,7 @@ import { useLexicalEditorConfig } from "~/components/LexicalEditorConfig/Lexical import { useDeriveValueFromSelection } from "~/hooks/useCurrentSelection"; import { getSelectedNode } from "~/utils/getSelectedNode"; import { useRichTextEditor } from "~/hooks"; +import { isChildOfFloatingToolbar } from "~/utils/isChildOfFloatingToolbar"; interface FloatingToolbarProps { anchorElem: HTMLElement; @@ -23,6 +25,7 @@ interface FloatingToolbarProps { } const FloatingToolbar: FC = ({ anchorElem, editor }) => { + const [isVisible, setIsVisible] = useState(true); const popupCharStylesEditorRef = useRef(null); const { toolbarElements } = useLexicalEditorConfig(); @@ -130,16 +133,33 @@ const FloatingToolbar: FC = ({ anchorElem, editor }) => { editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { + setIsVisible(true); updateTextFormatFloatingToolbar(); return false; }, COMMAND_PRIORITY_LOW + ), + + editor.registerCommand( + BLUR_COMMAND, + payload => { + if (!isChildOfFloatingToolbar(payload.relatedTarget as HTMLElement)) { + setIsVisible(false); + } + + return false; + }, + COMMAND_PRIORITY_LOW ) ); }, [editor, updateTextFormatFloatingToolbar]); + if (!isVisible) { + return null; + } + return ( -
+
{editor.isEditable() && ( <> {toolbarElements.map(action => ( diff --git a/packages/lexical-editor/src/utils/isChildOfFloatingToolbar.ts b/packages/lexical-editor/src/utils/isChildOfFloatingToolbar.ts new file mode 100644 index 00000000000..e42926bcb79 --- /dev/null +++ b/packages/lexical-editor/src/utils/isChildOfFloatingToolbar.ts @@ -0,0 +1,13 @@ +export const isChildOfFloatingToolbar = (element: HTMLElement | null): boolean => { + const parent = element ? element.parentElement : null; + + if (!parent) { + return false; + } + + if (parent.classList.contains("floating-toolbar")) { + return true; + } + + return isChildOfFloatingToolbar(parent); +};