diff --git a/src/admin/components/elements/Button/index.tsx b/src/admin/components/elements/Button/index.tsx index 1859b7db00..3dbc9b3edc 100644 --- a/src/admin/components/elements/Button/index.tsx +++ b/src/admin/components/elements/Button/index.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, isValidElement } from 'react'; +import React, { forwardRef, Fragment, isValidElement } from 'react'; import { Link } from 'react-router-dom'; import { Props } from './types'; @@ -53,7 +53,7 @@ const ButtonContents = ({ children, icon, tooltip, showTooltip }) => { ); }; -const Button: React.FC = (props) => { +const Button = forwardRef((props, ref) => { const { className, id, @@ -129,6 +129,7 @@ const Button: React.FC = (props) => { return ( } href={url} > = (props) => { return ( = (props) => { ); } -}; +}); export default Button; diff --git a/src/admin/components/elements/ReactSelect/Control/index.tsx b/src/admin/components/elements/ReactSelect/Control/index.tsx index fe45d832ae..7905cc3a60 100644 --- a/src/admin/components/elements/ReactSelect/Control/index.tsx +++ b/src/admin/components/elements/ReactSelect/Control/index.tsx @@ -32,6 +32,9 @@ export const Control: React.FC> = (props) => { onKeyDown: (e) => { if (disableKeyDown) { e.stopPropagation(); + // Create event for keydown listeners which specifically want to bypass this stopPropagation + const bypassEvent = new CustomEvent('bypassKeyDown', { detail: e }); + document.dispatchEvent(bypassEvent); } }, }} diff --git a/src/admin/components/elements/Save/index.tsx b/src/admin/components/elements/Save/index.tsx index 4ffa4f5a95..f06b1f9468 100644 --- a/src/admin/components/elements/Save/index.tsx +++ b/src/admin/components/elements/Save/index.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import FormSubmit from '../../forms/Submit'; +import useHotkey from '../../../hooks/useHotkey'; import RenderCustomComponent from '../../utilities/RenderCustomComponent'; +import { useEditDepth } from '../../utilities/EditDepth'; import { useForm } from '../../forms/Form/context'; export type CustomSaveButtonProps = React.ComponentType void; }; + const DefaultSaveButton: React.FC = ({ label, save }) => { + const ref = useRef(null); + const editDepth = useEditDepth(); + + useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => { + e.preventDefault(); + e.stopPropagation(); + if (ref?.current) { + ref.current.click(); + } + }); + return ( {label} diff --git a/src/admin/components/elements/SaveDraft/index.tsx b/src/admin/components/elements/SaveDraft/index.tsx index 2c47fa3aba..f912078007 100644 --- a/src/admin/components/elements/SaveDraft/index.tsx +++ b/src/admin/components/elements/SaveDraft/index.tsx @@ -1,11 +1,13 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useConfig } from '../../utilities/Config'; import FormSubmit from '../../forms/Submit'; import { useForm, useFormModified } from '../../forms/Form/context'; import { useDocumentInfo } from '../../utilities/DocumentInfo'; import { useLocale } from '../../utilities/Locale'; +import useHotkey from '../../../hooks/useHotkey'; import RenderCustomComponent from '../../utilities/RenderCustomComponent'; +import { useEditDepth } from '../../utilities/EditDepth'; const baseClass = 'save-draft'; @@ -19,6 +21,21 @@ export type DefaultSaveDraftButtonProps = { label: string; }; const DefaultSaveDraftButton: React.FC = ({ disabled, saveDraft, label }) => { + const ref = useRef(null); + const editDepth = useEditDepth(); + + useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => { + if (disabled) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + if (ref?.current) { + ref.current.click(); + } + }); + return ( = ({ disable buttonStyle="secondary" onClick={saveDraft} disabled={disabled} + ref={ref} > {label} diff --git a/src/admin/components/forms/Submit/index.tsx b/src/admin/components/forms/Submit/index.tsx index e4f968e55e..e9c9391163 100644 --- a/src/admin/components/forms/Submit/index.tsx +++ b/src/admin/components/forms/Submit/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { useForm, useFormProcessing } from '../Form/context'; import Button from '../../elements/Button'; import { Props } from '../../elements/Button/types'; @@ -7,23 +7,25 @@ import './index.scss'; const baseClass = 'form-submit'; -const FormSubmit: React.FC = (props) => { +const FormSubmit = forwardRef((props, ref) => { const { children, buttonId: id, disabled: disabledFromProps, type = 'submit' } = props; const processing = useFormProcessing(); const { disabled } = useForm(); + const canSave = !(disabledFromProps || processing || disabled); return (
); -}; +}); export default FormSubmit; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx index 32c3af3151..6a6195c099 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Drawer } from '../../../../../../elements/Drawer'; import Form from '../../../../../Form'; @@ -8,6 +8,8 @@ import fieldTypes from '../../../..'; import RenderFields from '../../../../../RenderFields'; import './index.scss'; +import useHotkey from '../../../../../../../hooks/useHotkey'; +import { useEditDepth } from '../../../../../../utilities/EditDepth'; const baseClass = 'rich-text-link-edit-modal'; @@ -35,10 +37,32 @@ export const LinkDrawer: React.FC = ({ fieldSchema={fieldSchema} forceRender /> - - {t('general:submit')} - + ); }; + + +const LinkSubmit: React.FC = () => { + const { t } = useTranslation('fields'); + const ref = useRef(null); + const editDepth = useEditDepth(); + + useHotkey({ keyCodes: ['s'], cmdCtrlKey: true, editDepth }, (e) => { + e.preventDefault(); + e.stopPropagation(); + if (ref?.current) { + ref.current.click(); + } + }); + + + return ( + + {t('general:submit')} + + ); +}; diff --git a/src/admin/hooks/useHotkey.tsx b/src/admin/hooks/useHotkey.tsx new file mode 100644 index 0000000000..2484a7ada7 --- /dev/null +++ b/src/admin/hooks/useHotkey.tsx @@ -0,0 +1,117 @@ +/* eslint-disable no-shadow */ +import { useCallback, useEffect } from 'react'; +import { useModal } from '@faceless-ui/modal'; +import { setsAreEqual } from '../utilities/setsAreEqual'; + +// Required to be outside of hook, else debounce would be necessary +// and then one could not prevent the default behaviour. +const pressedKeys = new Set([]); + +const map = { + metaleft: 'meta', + metaright: 'meta', + osleft: 'meta', + osright: 'meta', + shiftleft: 'shift', + shiftright: 'shift', + ctrlleft: 'ctrl', + ctrlright: 'ctrl', + controlleft: 'ctrl', + controlright: 'ctrl', + altleft: 'alt', + altright: 'alt', + escape: 'esc', +}; + +const stripKey = (key: string) => { + return (map[key.toLowerCase()] || key).trim() + .toLowerCase() + .replace('key', ''); +}; + +const pushToKeys = (code: string) => { + const key = stripKey(code); + + // There is a weird bug with macos that if the keys are not cleared they remain in the + // pressed keys set. + if (key === 'meta') { + pressedKeys.forEach((pressedKey) => pressedKey !== 'meta' && pressedKeys.delete(pressedKey)); + } + + pressedKeys.add(key); +}; + +const removeFromKeys = (code: string) => { + const key = stripKey(code); + // There is a weird bug with macos that if the keys are not cleared they remain in the + // pressed keys set. + if (key === 'meta') { + pressedKeys.clear(); + } + pressedKeys.delete(key); +}; + +/** + * Hook function to work with hotkeys. + * @param param0.keyCode {string[]} The keys to listen for (`Event.code` without `'Key'` and lowercased) + * @param param0.cmdCtrlKey {boolean} Whether Ctrl on windows or Cmd on mac must be pressed + * @param param0.editDepth {boolean} This ensures that the hotkey is only triggered for the most top-level drawer in case there are nested drawers + * @param func The callback function + */ +const useHotkey = (options: { + keyCodes: string[] + cmdCtrlKey: boolean + editDepth: number +}, func: (e: KeyboardEvent) => void): void => { + const { keyCodes, cmdCtrlKey, editDepth } = options; + + const { modalState } = useModal(); + + + const keydown = useCallback((event: KeyboardEvent | CustomEvent) => { + const e: KeyboardEvent = event.detail?.key ? event.detail : event; + if (e.key === undefined) { + // Autofill events, or other synthetic events, can be ignored + return; + } + if (e.code) pushToKeys(e.code); + + // Check for Mac and iPad + const hasCmd = window.navigator.userAgent.includes('Mac OS X'); + const pressedWithoutModifier = [...pressedKeys].filter((key) => !['meta', 'ctrl', 'alt', 'shift'].includes(key)); + + // Check whether arrays contain the same values (regardless of number of occurrences) + if ( + setsAreEqual(new Set(pressedWithoutModifier), new Set(keyCodes)) + && (!cmdCtrlKey || (hasCmd && pressedKeys.has('meta')) || (!hasCmd && e.ctrlKey)) + ) { + // get the maximum edit depth by counting the number of open drawers. modalState is and object which contains the state of all drawers. + const maxEditDepth = Object.keys(modalState).filter((key) => modalState[key]?.isOpen)?.length + 1 ?? 1; + + if (maxEditDepth !== editDepth) { + // We only want to execute the hotkey from the most top-level drawer / edit depth. + return; + } + // execute the function associated with the maximum edit depth + func(e); + } + }, [keyCodes, cmdCtrlKey, editDepth, modalState, func]); + + const keyup = useCallback((e: KeyboardEvent) => { + if (e.code) removeFromKeys(e.code); + }, []); + + useEffect(() => { + document.addEventListener('keydown', keydown, false); + document.addEventListener('bypassKeyDown', keydown, false); // this is called if the keydown event's propagation is stopped by react-select + document.addEventListener('keyup', keyup, false); + + return () => { + document.removeEventListener('keydown', keydown); + document.removeEventListener('bypassKeyDown', keydown); + document.removeEventListener('keyup', keyup); + }; + }, [keydown, keyup]); +}; + +export default useHotkey; diff --git a/src/admin/utilities/setsAreEqual.ts b/src/admin/utilities/setsAreEqual.ts new file mode 100644 index 0000000000..774d54751d --- /dev/null +++ b/src/admin/utilities/setsAreEqual.ts @@ -0,0 +1,6 @@ +/** + * Function to determine whether two sets are equal or not. + */ +export const setsAreEqual = (lhs: Set, rhs: Set) => { + return lhs.size === rhs.size && Array.from(lhs).every((value) => rhs.has(value)); +}; diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index e071870d31..fb6ac90b28 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import payload from '../../src'; import { AdminUrlUtil } from '../helpers/adminUrlUtil'; import { initPayloadE2E } from '../helpers/configHelpers'; -import { saveDocAndAssert } from '../helpers'; +import { saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers'; import type { Post } from './config'; import { globalSlug, slug } from './shared'; import { mapAsync } from '../../src/utilities/mapAsync'; @@ -168,6 +168,18 @@ describe('admin', () => { await expect(page.locator('#field-description')).toHaveValue(newDesc); }); + test('should save using hotkey', async () => { + const { id } = await createPost(); + await page.goto(url.edit(id)); + + const newTitle = 'new title'; + await page.locator('#field-title').fill(newTitle); + + await saveDocHotkeyAndAssert(page); + + await expect(page.locator('#field-title')).toHaveValue(newTitle); + }); + test('should delete existing', async () => { const { id, ...post } = await createPost(); diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 4882d61032..2a0af1566e 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -4,17 +4,18 @@ import path from 'path'; import payload from '../../src'; import { AdminUrlUtil } from '../helpers/adminUrlUtil'; import { initPayloadE2E } from '../helpers/configHelpers'; -import { saveDocAndAssert } from '../helpers'; -import { textDoc } from './collections/Text'; -import { arrayFieldsSlug } from './collections/Array'; +import { saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers'; +import { textDoc, textFieldsSlug } from './collections/Text'; import { pointFieldsSlug } from './collections/Point'; import { tabsSlug } from './collections/Tabs'; import { collapsibleFieldsSlug } from './collections/Collapsible'; import wait from '../../src/utilities/wait'; import { jsonDoc } from './collections/JSON'; import { numberDoc } from './collections/Number'; +import { relationshipFieldsSlug } from './collections/Relationship'; +import { mapAsync } from '../../src/utilities/mapAsync'; -const { beforeAll, describe } = test; +const { afterEach, beforeAll, describe } = test; let page: Page; let serverURL; @@ -934,10 +935,20 @@ describe('fields', () => { describe('relationship', () => { let url: AdminUrlUtil; - beforeAll(() => { + beforeAll(async () => { url = new AdminUrlUtil(serverURL, 'relationship-fields'); }); + + afterEach(async () => { + // delete all existing relationship documents + const allRelationshipDocs = await payload.find({ collection: relationshipFieldsSlug, limit: 100 }); + const relationshipIDs = allRelationshipDocs.docs.map((doc) => doc.id); + await mapAsync(relationshipIDs, async (id) => { + await payload.delete({ collection: relationshipFieldsSlug, id }); + }); + }); + test('should create inline relationship within field with many relations', async () => { await page.goto(url.create); @@ -1085,6 +1096,45 @@ describe('fields', () => { // check if the value is saved await expect(page.locator('#field-relationshipHasMany .relationship--multi-value-label__text')).toHaveText(`${value}123456`); }); + + // Drawers opened through the edit button are prone to issues due to the use of stopPropagation for certain + // events - specifically for drawers opened through the edit button. This test is to ensure that drawers + // opened through the edit button can be saved using the hotkey. + test('should save using hotkey in edit document drawer', async () => { + await page.goto(url.create); + + // First fill out the relationship field, as it's required + await page.locator('#relationship-add-new .relationship-add-new__add-button').click(); + await page.locator('#field-relationship .value-container').click(); + // Select "Seeded text document" relationship + await page.getByText('Seeded text document', { exact: true }).click(); + + // Click edit button which opens drawer + await page.getByRole('button', { name: 'Edit Seeded text document' }).click(); + + // Fill 'text' field of 'Seeded text document' + await page.locator('#field-text').fill('some updated text value'); + + // Save drawer (not parent page) with hotkey + await saveDocHotkeyAndAssert(page); + + const seededTextDocument = await payload.find({ + collection: textFieldsSlug, + where: { + text: { + equals: 'some updated text value', + }, + }, + }); + const relationshipDocuments = await payload.find({ + collection: relationshipFieldsSlug, + }); + + // The Seeded text document should now have a text field with value 'some updated text value', + expect(seededTextDocument.docs.length).toEqual(1); + // but the relationship document should NOT exist, as the hotkey should have saved the drawer and not the parent page + expect(relationshipDocuments.docs.length).toEqual(0); + }); }); describe('upload', () => { diff --git a/test/helpers.ts b/test/helpers.ts index 40afe7d681..406c73542b 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -36,6 +36,18 @@ export async function login(args: LoginArgs): Promise { await page.waitForURL(`${serverURL}/admin`); } +export async function saveDocHotkeyAndAssert(page: Page): Promise { + const ua = page.evaluate(() => navigator.userAgent); + const isMac = (await ua).includes('Mac OS X'); + if (isMac) { + await page.keyboard.down('Meta'); + } else { + await page.keyboard.down('Control'); + } + await page.keyboard.down('s'); + await expect(page.locator('.Toastify')).toContainText('successfully'); +} + export async function saveDocAndAssert(page: Page, selector = '#action-save'): Promise { await page.click(selector, { delay: 100 }); await expect(page.locator('.Toastify')).toContainText('successfully');