feat(ui): confirmation modal (#11271)

There are nearly a dozen independent implementations of the same modal
spread throughout the admin panel and various plugins. These modals are
used to confirm or cancel an action, such as deleting a document, bulk
publishing, etc. Each of these instances is nearly identical, leading to
unnecessary development efforts when creating them, inconsistent UI, and
duplicative stylesheets.

Everything is now standardized behind a new `ConfirmationModal`
component. This modal comes with a standard API that is flexible enough
to replace nearly every instance. This component has also been exported
for reuse.

Here is a basic example of how to use it:

```tsx
'use client'
import { ConfirmationModal, useModal } from '@payloadcms/ui'
import React, { Fragment } from 'react'

const modalSlug = 'my-confirmation-modal'

export function MyComponent() {
  const { openModal } = useModal()

  return (
    <Fragment>
      <button
        onClick={() => {
          openModal(modalSlug)
        }}
        type="button"
      >
        Do something
      </button>
      <ConfirmationModal
        heading="Are you sure?"
        body="Confirm or cancel before proceeding."
        modalSlug={modalSlug}
        onConfirm={({ closeConfirmationModal, setConfirming }) => {
          // do something
          setConfirming(false)
          closeConfirmationModal()
        }}
      />
    </Fragment>
  )
}
```
This commit is contained in:
Jacob Fletcher
2025-02-19 02:27:03 -05:00
committed by GitHub
parent 132852290a
commit bd8ced1b60
41 changed files with 819 additions and 1364 deletions

View File

@@ -1,40 +0,0 @@
@import '../../../../scss/styles.scss';
@layer payload-default {
.reset-preferences-modal {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(2);
padding: base(2);
}
&__content {
display: flex;
flex-direction: column;
gap: base(1);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
margin-block: 0;
}
}
}
}

View File

@@ -1,42 +0,0 @@
'use client'
import { Button, Modal, useModal, useTranslation } from '@payloadcms/ui'
import './index.scss'
const baseClass = 'reset-preferences-modal'
export const ConfirmResetModal: React.FC<{
readonly onConfirm: () => void
readonly slug: string
}> = ({ slug, onConfirm }) => {
const { closeModal } = useModal()
const { t } = useTranslation()
const handleClose = () => closeModal(slug)
const handleConfirm = () => {
handleClose()
if (typeof onConfirm === 'function') {
onConfirm()
}
}
return (
<Modal className={baseClass} slug={slug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:resetPreferences')}?</h1>
<p>{t('general:resetPreferencesDescription')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button buttonStyle="secondary" onClick={handleClose} size="large">
{t('general:cancel')}
</Button>
<Button onClick={handleConfirm} size="large">
{t('general:confirm')}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -1,11 +1,10 @@
'use client'
import type { OnConfirm } from '@payloadcms/ui'
import type { User } from 'payload'
import { Button, LoadingOverlay, toast, useModal, useTranslation } from '@payloadcms/ui'
import { Button, ConfirmationModal, toast, useModal, useTranslation } from '@payloadcms/ui'
import * as qs from 'qs-esm'
import { Fragment, useCallback, useState } from 'react'
import { ConfirmResetModal } from './ConfirmResetModal/index.js'
import { Fragment, useCallback } from 'react'
const confirmResetModalSlug = 'confirm-reset-modal'
@@ -16,51 +15,54 @@ export const ResetPreferences: React.FC<{
const { openModal } = useModal()
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const handleResetPreferences: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
if (!user) {
setConfirming(false)
closeConfirmationModal()
return
}
const handleResetPreferences = useCallback(async () => {
if (!user || loading) {
return
}
setLoading(true)
const stringifiedQuery = qs.stringify(
{
depth: 0,
where: {
user: {
id: {
equals: user.id,
const stringifiedQuery = qs.stringify(
{
depth: 0,
where: {
user: {
id: {
equals: user.id,
},
},
},
},
},
{ addQueryPrefix: true },
)
{ addQueryPrefix: true },
)
try {
const res = await fetch(`${apiRoute}/payload-preferences${stringifiedQuery}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'DELETE',
})
try {
const res = await fetch(`${apiRoute}/payload-preferences${stringifiedQuery}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'DELETE',
})
const json = await res.json()
const message = json.message
const json = await res.json()
const message = json.message
if (res.ok) {
toast.success(message)
} else {
toast.error(message)
if (res.ok) {
toast.success(message)
} else {
toast.error(message)
}
} catch (_err) {
// swallow error
} finally {
setConfirming(false)
closeConfirmationModal()
}
} catch (e) {
// swallow error
} finally {
setLoading(false)
}
}, [apiRoute, loading, user])
},
[apiRoute, user],
)
return (
<Fragment>
@@ -69,8 +71,13 @@ export const ResetPreferences: React.FC<{
{t('general:resetPreferences')}
</Button>
</div>
<ConfirmResetModal onConfirm={handleResetPreferences} slug={confirmResetModalSlug} />
{loading && <LoadingOverlay loadingText={t('general:resettingPreferences')} />}
<ConfirmationModal
body={t('general:resetPreferencesDescription')}
confirmingLabel={t('general:resettingPreferences')}
heading={t('general:resetPreferences')}
modalSlug={confirmResetModalSlug}
onConfirm={handleResetPreferences}
/>
</Fragment>
)
}

View File

@@ -1,11 +1,10 @@
'use client'
import type { OnConfirm } from '@payloadcms/ui'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
ChevronIcon,
Modal,
Pill,
Popup,
ConfirmationModal,
PopupList,
useConfig,
useModal,
@@ -45,7 +44,6 @@ const Restore: React.FC<Props> = ({
const collectionConfig = getEntityConfig({ collectionSlug })
const { toggleModal } = useModal()
const [processing, setProcessing] = useState(false)
const router = useRouter()
const { i18n, t } = useTranslation()
const [draft, setDraft] = useState(false)
@@ -77,23 +75,27 @@ const Restore: React.FC<Props> = ({
})
}
const handleRestore = useCallback(async () => {
setProcessing(true)
const handleRestore: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
const res = await requests.post(fetchURL, {
headers: {
'Accept-Language': i18n.language,
},
})
const res = await requests.post(fetchURL, {
headers: {
'Accept-Language': i18n.language,
},
})
setConfirming(false)
closeConfirmationModal()
if (res.status === 200) {
const json = await res.json()
toast.success(json.message)
startRouteTransition(() => router.push(redirectURL))
} else {
toast.error(t('version:problemRestoringVersion'))
}
}, [fetchURL, redirectURL, t, i18n, router, startRouteTransition])
if (res.status === 200) {
const json = await res.json()
toast.success(json.message)
startRouteTransition(() => router.push(redirectURL))
} else {
toast.error(t('version:problemRestoringVersion'))
}
},
[fetchURL, redirectURL, t, i18n, router, startRouteTransition],
)
return (
<Fragment>
@@ -118,27 +120,13 @@ const Restore: React.FC<Props> = ({
{t('version:restoreThisVersion')}
</Button>
</div>
<Modal className={`${baseClass}__modal`} slug={modalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmVersionRestoration')}</h1>
<p>{restoreMessage}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button onClick={processing ? undefined : () => void handleRestore()}>
{processing ? t('version:restoring') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={restoreMessage}
confirmingLabel={t('version:restoring')}
heading={t('version:confirmVersionRestoration')}
modalSlug={modalSlug}
onConfirm={handleRestore}
/>
</Fragment>
)
}

View File

@@ -1,39 +0,0 @@
@import '~@payloadcms/ui/scss';
@layer payload-default {
.reindex-confirm-modal {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(2);
padding: base(2);
}
&__content {
display: flex;
flex-direction: column;
gap: base(1);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,37 +0,0 @@
import { Button, Modal, useTranslation } from '@payloadcms/ui'
import './index.scss'
type Props = {
description: string
onCancel: () => void
onConfirm: () => void
slug: string
title: string
}
const baseClass = 'reindex-confirm-modal'
export const ReindexConfirmModal = ({ slug, description, onCancel, onConfirm, title }: Props) => {
const {
i18n: { t },
} = useTranslation()
return (
<Modal className={baseClass} slug={slug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{title}</h1>
<p>{description}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button buttonStyle="secondary" onClick={onCancel} size="large">
{t('general:cancel')}
</Button>
<Button onClick={onConfirm} size="large">
{t('general:confirm')}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -1,7 +1,9 @@
'use client'
import type { OnConfirm } from '@payloadcms/ui'
import {
LoadingOverlay,
ConfirmationModal,
Popup,
PopupList,
toast,
@@ -16,7 +18,6 @@ import React, { useCallback, useMemo, useState } from 'react'
import type { ReindexButtonProps } from './types.js'
import { ReindexButtonLabel } from './ReindexButtonLabel/index.js'
import { ReindexConfirmModal } from './ReindexConfirmModal/index.js'
const confirmReindexModalSlug = 'confirm-reindex-modal'
@@ -37,45 +38,49 @@ export const ReindexButtonClient: React.FC<ReindexButtonProps> = ({
const router = useRouter()
const [reindexCollections, setReindexCollections] = useState<string[]>([])
const [isLoading, setLoading] = useState<boolean>(false)
const openConfirmModal = useCallback(() => openModal(confirmReindexModalSlug), [openModal])
const closeConfirmModal = useCallback(() => closeModal(confirmReindexModalSlug), [closeModal])
const handleReindexSubmit = useCallback(async () => {
if (isLoading || !reindexCollections.length) {
return
}
closeConfirmModal()
setLoading(true)
try {
const endpointRes = await fetch(
`${config.routes.api}/${searchSlug}/reindex?locale=${locale.code}`,
{
body: JSON.stringify({
collections: reindexCollections,
}),
method: 'POST',
},
)
const { message } = (await endpointRes.json()) as { message: string }
if (!endpointRes.ok) {
toast.error(message)
} else {
toast.success(message)
router.refresh()
const handleReindexSubmit: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
if (!reindexCollections.length) {
setConfirming(false)
closeConfirmationModal()
return
}
} catch (_err: unknown) {
// swallow error, toast shown above
} finally {
setReindexCollections([])
setLoading(false)
}
}, [closeConfirmModal, isLoading, reindexCollections, router, searchSlug, locale, config])
try {
const res = await fetch(
`${config.routes.api}/${searchSlug}/reindex?locale=${locale.code}`,
{
body: JSON.stringify({
collections: reindexCollections,
}),
method: 'POST',
},
)
setConfirming(false)
closeConfirmationModal()
const { message } = (await res.json()) as { message: string }
if (!res.ok) {
toast.error(message)
} else {
toast.success(message)
router.refresh()
}
} catch (_err: unknown) {
// swallow error, toast shown above
} finally {
setConfirming(false)
closeConfirmationModal()
setReindexCollections([])
}
},
[reindexCollections, router, searchSlug, locale, config],
)
const handleShowConfirmModal = useCallback(
(collections: string | string[] = searchCollections) => {
@@ -148,14 +153,12 @@ export const ReindexButtonClient: React.FC<ReindexButtonProps> = ({
size="large"
verticalAlign="bottom"
/>
<ReindexConfirmModal
description={modalDescription}
onCancel={closeConfirmModal}
<ConfirmationModal
body={modalDescription}
heading={modalTitle}
modalSlug={confirmReindexModalSlug}
onConfirm={handleReindexSubmit}
slug={confirmReindexModalSlug}
title={modalTitle}
/>
{isLoading && <LoadingOverlay loadingText={loadingText} />}
</div>
)
}

View File

@@ -1,7 +1,7 @@
@import '../../scss/styles.scss';
@layer payload-default {
.generate-confirmation {
.confirmation-modal {
@include blur-bg;
display: flex;
align-items: center;

View File

@@ -0,0 +1,104 @@
'use client'
import { Modal, useModal } from '@faceless-ui/modal'
import React from 'react'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { drawerZBase } from '../Drawer/index.js'
import './index.scss'
const baseClass = 'confirmation-modal'
export type OnConfirm = (args: {
closeConfirmationModal: () => void
setConfirming: (state: boolean) => void
}) => Promise<void> | void
export type OnCancel = () => void
export type ConfirmationModalProps = {
body: React.ReactNode
cancelLabel?: string
confirmingLabel?: string
confirmLabel?: string
heading: React.ReactNode
modalSlug: string
onCancel?: OnCancel
onConfirm: OnConfirm
}
export function ConfirmationModal(props: ConfirmationModalProps) {
const {
body,
cancelLabel,
confirmingLabel,
confirmLabel,
heading,
modalSlug,
onCancel,
onConfirm,
} = props
const editDepth = useEditDepth()
const [confirming, setConfirming] = React.useState(false)
const { closeModal } = useModal()
const { t } = useTranslation()
return (
<Modal
className={baseClass}
slug={modalSlug}
style={{
zIndex: drawerZBase + editDepth,
}}
>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{heading}</h1>
<p>{body}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
disabled={confirming}
id="confirm-cancel"
onClick={
confirming
? undefined
: () => {
closeModal(modalSlug)
if (typeof onCancel === 'function') {
onCancel()
}
}
}
size="large"
type="button"
>
{cancelLabel || t('general:cancel')}
</Button>
<Button
id="confirm-action"
onClick={() => {
if (!confirming) {
setConfirming(true)
void onConfirm({
closeConfirmationModal: () => closeModal(modalSlug),
setConfirming: (state) => setConfirming(state),
})
}
}}
size="large"
>
{confirming
? confirmingLabel || t('general:loading')
: confirmLabel || t('general:confirm')}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -11,34 +11,5 @@
&__toggle {
@extend %btn-reset;
}
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,30 +1,27 @@
'use client'
import type { SanitizedCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import type { OnConfirm } from '../ConfirmationModal/index.js'
import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js'
import { useForm } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Button } from '../Button/index.js'
import { drawerZBase } from '../Drawer/index.js'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { PopupList } from '../Popup/index.js'
import { Translation } from '../Translation/index.js'
import './index.scss'
const baseClass = 'delete-document'
export type Props = {
readonly buttonId?: string
readonly collectionSlug: SanitizedCollectionConfig['slug']
@@ -58,109 +55,100 @@ export const DeleteDocument: React.FC<Props> = (props) => {
const collectionConfig = getEntityConfig({ collectionSlug })
const { setModified } = useForm()
const [deleting, setDeleting] = useState(false)
const { closeModal, toggleModal } = useModal()
const router = useRouter()
const { i18n, t } = useTranslation()
const { title } = useDocumentInfo()
const editDepth = useEditDepth()
const { startRouteTransition } = useRouteTransition()
const { openModal } = useModal()
const titleToRender = titleFromProps || title || id
const modalSlug = `delete-${id}`
const addDefaultError = useCallback(() => {
setDeleting(false)
toast.error(t('error:deletingTitle', { title }))
}, [t, title])
useEffect(() => {
return () => {
closeModal(modalSlug)
}
}, [closeModal, modalSlug])
const handleDelete: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
setModified(false)
const handleDelete = useCallback(async () => {
setDeleting(true)
setModified(false)
try {
await requests
.delete(`${serverURL}${api}/${collectionSlug}/${id}`, {
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
})
.then(async (res) => {
try {
const json = await res.json()
setConfirming(false)
closeConfirmationModal()
try {
await requests
.delete(`${serverURL}${api}/${collectionSlug}/${id}`, {
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
})
.then(async (res) => {
try {
const json = await res.json()
if (res.status < 400) {
setDeleting(false)
toggleModal(modalSlug)
toast.success(
t('general:titleDeleted', { label: getTranslation(singularLabel, i18n), title }) ||
json.message,
)
if (redirectAfterDelete) {
return startRouteTransition(() =>
router.push(
formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}`,
}),
),
if (res.status < 400) {
toast.success(
t('general:titleDeleted', {
label: getTranslation(singularLabel, i18n),
title,
}) || json.message,
)
if (redirectAfterDelete) {
return startRouteTransition(() =>
router.push(
formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}`,
}),
),
)
}
if (typeof onDelete === 'function') {
await onDelete({ id, collectionConfig })
}
return
}
if (typeof onDelete === 'function') {
await onDelete({ id, collectionConfig })
if (json.errors) {
json.errors.forEach((error) => toast.error(error.message))
} else {
addDefaultError()
}
toggleModal(modalSlug)
return
return false
} catch (_err) {
return addDefaultError()
}
toggleModal(modalSlug)
if (json.errors) {
json.errors.forEach((error) => toast.error(error.message))
} else {
addDefaultError()
}
return false
} catch (e) {
return addDefaultError()
}
})
} catch (e) {
addDefaultError()
}
}, [
setModified,
serverURL,
api,
collectionSlug,
id,
toggleModal,
modalSlug,
t,
singularLabel,
i18n,
title,
router,
adminRoute,
addDefaultError,
redirectAfterDelete,
onDelete,
collectionConfig,
startRouteTransition,
])
})
} catch (_err) {
setConfirming(false)
closeConfirmationModal()
return addDefaultError()
}
},
[
setModified,
serverURL,
api,
collectionSlug,
id,
t,
singularLabel,
addDefaultError,
i18n,
title,
router,
adminRoute,
redirectAfterDelete,
onDelete,
collectionConfig,
startRouteTransition,
],
)
if (id) {
return (
@@ -168,60 +156,30 @@ export const DeleteDocument: React.FC<Props> = (props) => {
<PopupList.Button
id={buttonId}
onClick={() => {
setDeleting(false)
toggleModal(modalSlug)
openModal(modalSlug)
}}
>
{t('general:delete')}
</PopupList.Button>
<Modal
className={baseClass}
slug={modalSlug}
style={{
zIndex: drawerZBase + editDepth,
}}
>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:confirmDeletion')}</h1>
<p>
<Translation
elements={{
'1': ({ children }) => <strong>{children}</strong>,
}}
i18nKey="general:aboutToDelete"
t={t}
variables={{
label: getTranslation(singularLabel, i18n),
title: titleToRender,
}}
/>
</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="confirm-delete"
onClick={() => {
if (!deleting) {
void handleDelete()
}
}}
size="large"
>
{deleting ? t('general:deleting') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={
<Translation
elements={{
'1': ({ children }) => <strong>{children}</strong>,
}}
i18nKey="general:aboutToDelete"
t={t}
variables={{
label: getTranslation(singularLabel, i18n),
title: titleToRender,
}}
/>
}
confirmingLabel={t('general:deleting')}
heading={t('general:confirmDeletion')}
modalSlug={modalSlug}
onConfirm={handleDelete}
/>
</React.Fragment>
)
}

View File

@@ -7,34 +7,5 @@
align-items: center;
justify-content: center;
height: 100%;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,13 +1,15 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter, useSearchParams } from 'next/navigation.js'
import * as qs from 'qs-esm'
import React, { useCallback, useState } from 'react'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import type { OnConfirm } from '../ConfirmationModal/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useRouteCache } from '../../providers/RouteCache/index.js'
@@ -15,7 +17,7 @@ import { SelectAllStatus, useSelection } from '../../providers/Selection/index.j
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js'
import { Button } from '../Button/index.js'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { Pill } from '../Pill/index.js'
import './index.scss'
@@ -36,10 +38,9 @@ export const DeleteMany: React.FC<Props> = (props) => {
serverURL,
},
} = useConfig()
const { toggleModal } = useModal()
const { openModal } = useModal()
const { count, getQueryParams, selectAll, toggleAll } = useSelection()
const { i18n, t } = useTranslation()
const [deleting, setDeleting] = useState(false)
const router = useRouter()
const searchParams = useSearchParams()
const { clearRouteCache } = useRouteCache()
@@ -53,92 +54,94 @@ export const DeleteMany: React.FC<Props> = (props) => {
toast.error(t('error:unknown'))
}, [t])
const handleDelete = useCallback(async () => {
setDeleting(true)
const queryWithSearch = mergeListSearchAndWhere({
collectionConfig: collection,
search: searchParams.get('search'),
})
const queryString = getQueryParams(queryWithSearch)
await requests
.delete(`${serverURL}${api}/${slug}${queryString}`, {
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
const handleDelete: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
const queryWithSearch = mergeListSearchAndWhere({
collectionConfig: collection,
search: searchParams.get('search'),
})
.then(async (res) => {
try {
const json = await res.json()
toggleModal(modalSlug)
const deletedDocs = json?.docs.length || 0
const successLabel = deletedDocs > 1 ? plural : singular
const queryString = getQueryParams(queryWithSearch)
if (res.status < 400 || deletedDocs > 0) {
toast.success(
t('general:deletedCountSuccessfully', {
count: deletedDocs,
label: getTranslation(successLabel, i18n),
}),
)
await requests
.delete(`${serverURL}${api}/${slug}${queryString}`, {
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
})
.then(async (res) => {
try {
const json = await res.json()
setConfirming(false)
closeConfirmationModal()
if (json?.errors.length > 0) {
const deletedDocs = json?.docs.length || 0
const successLabel = deletedDocs > 1 ? plural : singular
if (res.status < 400 || deletedDocs > 0) {
toast.success(
t('general:deletedCountSuccessfully', {
count: deletedDocs,
label: getTranslation(successLabel, i18n),
}),
)
if (json?.errors.length > 0) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
}
toggleAll()
router.replace(
qs.stringify(
{
page: selectAll ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache()
return null
}
if (json.errors) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
} else {
return addDefaultError()
}
toggleAll()
router.replace(
qs.stringify(
{
page: selectAll ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache()
return null
return false
} catch (_err) {
setConfirming(false)
closeConfirmationModal()
return addDefaultError()
}
if (json.errors) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
} else {
addDefaultError()
}
return false
} catch (_err) {
return addDefaultError()
}
})
}, [
searchParams,
addDefaultError,
api,
getQueryParams,
i18n,
modalSlug,
plural,
router,
selectAll,
serverURL,
singular,
slug,
t,
toggleAll,
toggleModal,
clearRouteCache,
collection,
])
})
},
[
searchParams,
addDefaultError,
api,
getQueryParams,
i18n,
plural,
router,
selectAll,
serverURL,
singular,
slug,
t,
toggleAll,
clearRouteCache,
collection,
],
)
if (selectAll === SelectAllStatus.None || !hasDeletePermission) {
return null
@@ -149,39 +152,21 @@ export const DeleteMany: React.FC<Props> = (props) => {
<Pill
className={`${baseClass}__toggle`}
onClick={() => {
setDeleting(false)
toggleModal(modalSlug)
openModal(modalSlug)
}}
>
{t('general:delete')}
</Pill>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:confirmDeletion')}</h1>
<p>
{t('general:aboutToDeleteCount', {
count,
label: getTranslation(count > 1 ? plural : singular, i18n),
})}
</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-delete" onClick={deleting ? undefined : handleDelete} size="large">
{deleting ? t('general:deleting') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={t('general:aboutToDeleteCount', {
count,
label: getTranslation(count > 1 ? plural : singular, i18n),
})}
confirmingLabel={t('general:deleting')}
heading={t('general:confirmDeletion')}
modalSlug={modalSlug}
onConfirm={handleDelete}
/>
</React.Fragment>
)
}

View File

@@ -1,42 +0,0 @@
@import '../../scss/styles.scss';
@layer payload-default {
.duplicate {
&__modal {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -2,29 +2,25 @@
import type { SanitizedCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js'
import React, { useCallback, useState } from 'react'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import type { OnConfirm } from '../ConfirmationModal/index.js'
import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js'
import { useForm, useFormModified } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import { Button } from '../Button/index.js'
import { drawerZBase } from '../Drawer/index.js'
import './index.scss'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { PopupList } from '../Popup/index.js'
const baseClass = 'duplicate'
export type Props = {
readonly id: string
readonly onDuplicate?: DocumentDrawerContextType['onDuplicate']
@@ -42,7 +38,7 @@ export const DuplicateDocument: React.FC<Props> = ({
}) => {
const router = useRouter()
const modified = useFormModified()
const { toggleModal } = useModal()
const { openModal } = useModal()
const locale = useLocale()
const { setModified } = useForm()
const { startRouteTransition } = useRouteTransition()
@@ -57,21 +53,15 @@ export const DuplicateDocument: React.FC<Props> = ({
const collectionConfig = getEntityConfig({ collectionSlug: slug })
const [hasClicked, setHasClicked] = useState<boolean>(false)
const [renderModal, setRenderModal] = React.useState(false)
const { i18n, t } = useTranslation()
const modalSlug = `duplicate-${id}`
const editDepth = useEditDepth()
const duplicate = useCallback(
async ({ onResponse }: { onResponse?: () => void } = {}) => {
setRenderModal(true)
const handleClick = useCallback(
async (override = false) => {
setHasClicked(true)
if (modified && !override) {
toggleModal(modalSlug)
return
}
await requests
.post(
`${serverURL}${apiRoute}/${slug}/${id}/duplicate${locale?.code ? `?locale=${locale.code}` : ''}`,
@@ -86,6 +76,10 @@ export const DuplicateDocument: React.FC<Props> = ({
)
.then(async (res) => {
const { doc, errors, message } = await res.json()
if (typeof onResponse === 'function') {
onResponse()
}
if (res.status < 400) {
toast.success(
message ||
@@ -119,14 +113,11 @@ export const DuplicateDocument: React.FC<Props> = ({
},
[
locale,
modified,
serverURL,
apiRoute,
slug,
id,
i18n,
toggleModal,
modalSlug,
t,
singularLabel,
onDuplicate,
@@ -139,45 +130,43 @@ export const DuplicateDocument: React.FC<Props> = ({
],
)
const confirm = useCallback(async () => {
setHasClicked(false)
await handleClick(true)
}, [handleClick])
const onConfirm: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
setRenderModal(false)
await duplicate({
onResponse: () => {
setConfirming(false)
closeConfirmationModal()
},
})
},
[duplicate],
)
return (
<React.Fragment>
<PopupList.Button id="action-duplicate" onClick={() => void handleClick(false)}>
<PopupList.Button
id="action-duplicate"
onClick={() => {
if (modified) {
setRenderModal(true)
return openModal(modalSlug)
}
return duplicate()
}}
>
{t('general:duplicate')}
</PopupList.Button>
{modified && hasClicked && (
<Modal
className={`${baseClass}__modal`}
slug={modalSlug}
style={{
zIndex: drawerZBase + editDepth,
}}
>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:confirmDuplication')}</h1>
<p>{t('general:unsavedChangesDuplicate')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={() => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-duplicate" onClick={() => void confirm()} size="large">
{t('general:duplicateWithoutSaving')}
</Button>
</div>
</div>
</Modal>
{renderModal && (
<ConfirmationModal
body={t('general:unsavedChangesDuplicate')}
confirmLabel={t('general:duplicateWithoutSaving')}
heading={t('general:unsavedChanges')}
modalSlug={modalSlug}
onConfirm={onConfirm}
/>
)}
</React.Fragment>
)

View File

@@ -1,22 +1,22 @@
'use client'
import { Modal, useModal } from '@faceless-ui/modal'
import React from 'react'
import { useModal } from '@faceless-ui/modal'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import type { OnConfirm } from '../ConfirmationModal/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { Translation } from '../Translation/index.js'
import './index.scss'
const baseClass = 'generate-confirmation'
export type GenerateConfirmationProps = {
highlightField: (Boolean) => void
setKey: () => void
}
export const GenerateConfirmation: React.FC<GenerateConfirmationProps> = (props) => {
export function GenerateConfirmation(props: GenerateConfirmationProps) {
const { highlightField, setKey } = props
const { id } = useDocumentInfo()
@@ -25,12 +25,16 @@ export const GenerateConfirmation: React.FC<GenerateConfirmationProps> = (props)
const modalSlug = `generate-confirmation-${id}`
const handleGenerate = () => {
setKey()
toggleModal(modalSlug)
toast.success(t('authentication:newAPIKeyGenerated'))
highlightField(true)
}
const handleGenerate: OnConfirm = useCallback(
({ closeConfirmationModal, setConfirming }) => {
setKey()
toast.success(t('authentication:newAPIKeyGenerated'))
highlightField(true)
setConfirming(false)
closeConfirmationModal()
},
[highlightField, setKey, t],
)
return (
<React.Fragment>
@@ -43,35 +47,21 @@ export const GenerateConfirmation: React.FC<GenerateConfirmationProps> = (props)
>
{t('authentication:generateNewAPIKey')}
</Button>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('authentication:confirmGeneration')}</h1>
<p>
<Translation
elements={{
1: ({ children }) => <strong>{children}</strong>,
}}
i18nKey="authentication:generatingNewAPIKeyWillInvalidate"
t={t}
/>
</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={() => {
toggleModal(modalSlug)
}}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button onClick={handleGenerate}>{t('authentication:generate')}</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={
<Translation
elements={{
1: ({ children }) => <strong>{children}</strong>,
}}
i18nKey="authentication:generatingNewAPIKeyWillInvalidate"
t={t}
/>
}
confirmLabel={t('authentication:generate')}
heading={t('authentication:confirmGeneration')}
modalSlug={modalSlug}
onConfirm={handleGenerate}
/>
</React.Fragment>
)
}

View File

@@ -1,39 +0,0 @@
@import '../../scss/styles.scss';
@layer payload-default {
.leave-without-saving {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,74 +1,30 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import React, { useCallback } from 'react'
import type { OnCancel, OnConfirm } from '../ConfirmationModal/index.js'
import { useForm, useFormModified } from '../../forms/Form/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { Modal, useModal } from '../Modal/index.js'
import './index.scss'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { useModal } from '../Modal/index.js'
import { usePreventLeave } from './usePreventLeave.js'
const modalSlug = 'leave-without-saving'
const baseClass = 'leave-without-saving'
const Component: React.FC<{
isActive: boolean
onCancel: () => void
onConfirm: () => void
}> = ({ isActive, onCancel, onConfirm }) => {
const { closeModal, openModal } = useModal()
const { t } = useTranslation()
// Manually check for modal state as 'esc' key will not trigger the nav inactivity
// useEffect(() => {
// if (!modalState?.[modalSlug]?.isOpen && isActive) {
// onCancel()
// }
// }, [modalState, isActive, onCancel])
useEffect(() => {
if (isActive) {
openModal(modalSlug)
} else {
closeModal(modalSlug)
}
}, [isActive, openModal, closeModal])
return (
<Modal className={baseClass} onClose={onCancel} slug={modalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:leaveWithoutSaving')}</h1>
<p>{t('general:changesNotSaved')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button buttonStyle="secondary" onClick={onCancel} size="large">
{t('general:stayOnThisPage')}
</Button>
<Button onClick={onConfirm} size="large">
{t('general:leaveAnyway')}
</Button>
</div>
</div>
</Modal>
)
}
export const LeaveWithoutSaving: React.FC = () => {
const { closeModal } = useModal()
const { closeModal, openModal } = useModal()
const modified = useFormModified()
const { isValid } = useForm()
const { user } = useAuth()
const [show, setShow] = React.useState(false)
const [hasAccepted, setHasAccepted] = React.useState(false)
const { t } = useTranslation()
const prevent = Boolean((modified || !isValid) && user)
const onPrevent = useCallback(() => {
setShow(true)
}, [])
openModal(modalSlug)
}, [openModal])
const handleAccept = useCallback(() => {
closeModal(modalSlug)
@@ -76,15 +32,25 @@ export const LeaveWithoutSaving: React.FC = () => {
usePreventLeave({ hasAccepted, onAccept: handleAccept, onPrevent, prevent })
const onCancel: OnCancel = useCallback(() => {
closeModal(modalSlug)
}, [closeModal])
const onConfirm: OnConfirm = useCallback(({ closeConfirmationModal, setConfirming }) => {
setHasAccepted(true)
setConfirming(false)
closeConfirmationModal()
}, [])
return (
<Component
isActive={show}
onCancel={() => {
setShow(false)
}}
onConfirm={() => {
setHasAccepted(true)
}}
<ConfirmationModal
body={t('general:changesNotSaved')}
cancelLabel={t('general:stayOnThisPage')}
confirmLabel={t('general:leaveAnyway')}
heading={t('general:leaveWithoutSaving')}
modalSlug={modalSlug}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)
}

View File

@@ -1,39 +0,0 @@
@import '../../scss/styles.scss';
@layer payload-default {
.publish-many {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,13 +1,15 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import { Modal, useModal } from '@faceless-ui/modal'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter, useSearchParams } from 'next/navigation.js'
import * as qs from 'qs-esm'
import React, { useCallback, useState } from 'react'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import type { OnConfirm } from '../ConfirmationModal/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useRouteCache } from '../../providers/RouteCache/index.js'
@@ -15,16 +17,15 @@ import { SelectAllStatus, useSelection } from '../../providers/Selection/index.j
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { Button } from '../Button/index.js'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { Pill } from '../Pill/index.js'
import './index.scss'
const baseClass = 'publish-many'
export type PublishManyProps = {
collection: ClientCollectionConfig
}
const baseClass = 'publish-many'
export const PublishMany: React.FC<PublishManyProps> = (props) => {
const { clearRouteCache } = useRouteCache()
@@ -36,13 +37,13 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
serverURL,
},
} = useConfig()
const { permissions } = useAuth()
const { toggleModal } = useModal()
const { i18n, t } = useTranslation()
const { getQueryParams, selectAll } = useSelection()
const [submitted, setSubmitted] = useState(false)
const router = useRouter()
const searchParams = useSearchParams()
const { openModal } = useModal()
const collectionPermissions = permissions?.collections?.[slug]
const hasPermission = collectionPermissions?.update
@@ -53,84 +54,87 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
toast.error(t('error:unknown'))
}, [t])
const handlePublish = useCallback(async () => {
setSubmitted(true)
await requests
.patch(
`${serverURL}${api}/${slug}${getQueryParams({ _status: { not_equals: 'published' } })}&draft=true`,
{
body: JSON.stringify({
_status: 'published',
}),
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
const handlePublish: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
await requests
.patch(
`${serverURL}${api}/${slug}${getQueryParams({ _status: { not_equals: 'published' } })}&draft=true`,
{
body: JSON.stringify({
_status: 'published',
}),
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
},
},
)
.then(async (res) => {
try {
const json = await res.json()
toggleModal(modalSlug)
)
.then(async (res) => {
try {
const json = await res.json()
setConfirming(false)
closeConfirmationModal()
const deletedDocs = json?.docs.length || 0
const successLabel = deletedDocs > 1 ? plural : singular
const deletedDocs = json?.docs.length || 0
const successLabel = deletedDocs > 1 ? plural : singular
if (res.status < 400 || deletedDocs > 0) {
toast.success(
t('general:updatedCountSuccessfully', {
count: deletedDocs,
label: getTranslation(successLabel, i18n),
}),
)
if (res.status < 400 || deletedDocs > 0) {
toast.success(
t('general:updatedCountSuccessfully', {
count: deletedDocs,
label: getTranslation(successLabel, i18n),
}),
)
if (json?.errors.length > 0) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
if (json?.errors.length > 0) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
}
router.replace(
qs.stringify(
{
...parseSearchParams(searchParams),
page: selectAll ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache() // Use clearRouteCache instead of router.refresh, as we only need to clear the cache if the user has route caching enabled - clearRouteCache checks for this
return null
}
router.replace(
qs.stringify(
{
...parseSearchParams(searchParams),
page: selectAll ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache() // Use clearRouteCache instead of router.refresh, as we only need to clear the cache if the user has route caching enabled - clearRouteCache checks for this
return null
if (json.errors) {
json.errors.forEach((error) => toast.error(error.message))
} else {
addDefaultError()
}
return false
} catch (_err) {
setConfirming(false)
closeConfirmationModal()
return addDefaultError()
}
if (json.errors) {
json.errors.forEach((error) => toast.error(error.message))
} else {
addDefaultError()
}
return false
} catch (e) {
return addDefaultError()
}
})
}, [
serverURL,
api,
slug,
getQueryParams,
i18n,
toggleModal,
modalSlug,
plural,
singular,
t,
router,
searchParams,
selectAll,
clearRouteCache,
addDefaultError,
])
})
},
[
serverURL,
api,
slug,
getQueryParams,
i18n,
plural,
singular,
t,
router,
searchParams,
selectAll,
clearRouteCache,
addDefaultError,
],
)
if (!versions?.drafts || selectAll === SelectAllStatus.None || !hasPermission) {
return null
@@ -141,38 +145,20 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
<Pill
className={`${baseClass}__toggle`}
onClick={() => {
setSubmitted(false)
toggleModal(modalSlug)
openModal(modalSlug)
}}
>
{t('version:publish')}
</Pill>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmPublish')}</h1>
<p>{t('version:aboutToPublishSelection', { label: getTranslation(plural, i18n) })}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="confirm-publish"
onClick={submitted ? undefined : handlePublish}
size="large"
>
{submitted ? t('version:publishing') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={t('version:aboutToPublishSelection', { label: getTranslation(plural, i18n) })}
cancelLabel={t('general:cancel')}
confirmingLabel={t('version:publishing')}
confirmLabel={t('general:confirm')}
heading={t('version:confirmPublish')}
modalSlug={modalSlug}
onConfirm={handlePublish}
/>
</React.Fragment>
)
}

View File

@@ -17,46 +17,5 @@
&__action {
text-decoration: underline;
}
&__modal {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__toggle {
@extend %btn-reset;
}
}
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,17 +1,18 @@
'use client'
import { Modal, useModal } from '@faceless-ui/modal'
import React, { useCallback, useState } from 'react'
import { useModal } from '@faceless-ui/modal'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import type { OnConfirm } from '../ConfirmationModal/index.js'
import { useForm } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { Button } from '../Button/index.js'
import { drawerZBase } from '../Drawer/index.js'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import './index.scss'
const baseClass = 'status'
@@ -36,13 +37,10 @@ export const Status: React.FC = () => {
serverURL,
},
} = useConfig()
const [processing, setProcessing] = useState(false)
const { reset: resetForm } = useForm()
const { code: locale } = useLocale()
const { i18n, t } = useTranslation()
const editDepth = useEditDepth()
const unPublishModalSlug = `confirm-un-publish-${id}`
const revertModalSlug = `confirm-revert-${id}`
@@ -57,13 +55,14 @@ export const Status: React.FC = () => {
}
const performAction = useCallback(
async (action: 'revert' | 'unpublish') => {
async (
action: 'revert' | 'unpublish',
{ closeConfirmationModal, setConfirming }: Parameters<OnConfirm>[0],
) => {
let url
let method
let body
setProcessing(true)
if (action === 'unpublish') {
body = {
_status: 'draft',
@@ -74,6 +73,7 @@ export const Status: React.FC = () => {
url = `${serverURL}${api}/${collectionSlug}/${id}?locale=${locale}&fallback-locale=null&depth=0`
method = 'patch'
}
if (globalSlug) {
url = `${serverURL}${api}/globals/${globalSlug}?locale=${locale}&fallback-locale=null&depth=0`
method = 'post'
@@ -100,6 +100,9 @@ export const Status: React.FC = () => {
},
})
setConfirming(false)
closeConfirmationModal()
if (res.status === 200) {
let data
const json = await res.json()
@@ -124,15 +127,6 @@ export const Status: React.FC = () => {
} else {
toast.error(t('error:unPublishingDocument'))
}
setProcessing(false)
if (action === 'revert') {
toggleModal(revertModalSlug)
}
if (action === 'unpublish') {
toggleModal(unPublishModalSlug)
}
},
[
api,
@@ -145,10 +139,8 @@ export const Status: React.FC = () => {
resetForm,
serverURL,
setUnpublishedVersionCount,
setMostRecentVersionIsAutosaved,
t,
toggleModal,
revertModalSlug,
unPublishModalSlug,
setHasPublishedDoc,
],
)
@@ -174,34 +166,13 @@ export const Status: React.FC = () => {
>
{t('version:unpublish')}
</Button>
<Modal
className={`${baseClass}__modal`}
slug={unPublishModalSlug}
style={{ zIndex: drawerZBase + editDepth }}
>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmUnpublish')}</h1>
<p>{t('version:aboutToUnpublish')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(unPublishModalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
onClick={processing ? undefined : () => performAction('unpublish')}
size="large"
>
{t(processing ? 'version:unpublishing' : 'general:confirm')}
</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={t('version:aboutToUnpublish')}
confirmingLabel={t('version:unpublishing')}
heading={t('version:confirmUnpublish')}
modalSlug={unPublishModalSlug}
onConfirm={(args) => performAction('unpublish', args)}
/>
</React.Fragment>
)}
{canUpdate && statusToRender === 'changed' && (
@@ -215,31 +186,13 @@ export const Status: React.FC = () => {
>
{t('version:revertToPublished')}
</Button>
<Modal className={`${baseClass}__modal`} slug={revertModalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmRevertToSaved')}</h1>
<p>{t('version:aboutToRevertToPublished')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(revertModalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="action-revert-to-published-confirm"
onClick={processing ? undefined : () => performAction('revert')}
size="large"
>
{t(processing ? 'version:reverting' : 'general:confirm')}
</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={t('version:aboutToRevertToPublished')}
confirmingLabel={t('version:reverting')}
heading={t('version:confirmRevertToSaved')}
modalSlug={revertModalSlug}
onConfirm={(args) => performAction('revert', args)}
/>
</React.Fragment>
)}
</div>

View File

@@ -1,40 +0,0 @@
@import '../../scss/styles.scss';
@layer payload-default {
.stay-logged-in {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: var(--base);
padding: base(2);
}
&__content {
display: flex;
flex-direction: column;
gap: var(--base);
max-width: base(36);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: var(--base);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,17 +1,15 @@
'use client'
import { Modal, useModal } from '@faceless-ui/modal'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import React, { useCallback } from 'react'
import type { OnCancel, OnConfirm } from '../ConfirmationModal/index.js'
import { Button } from '../../elements/Button/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
import './index.scss'
const baseClass = 'stay-logged-in'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
export const stayLoggedInModalSlug = 'stay-logged-in'
@@ -28,47 +26,39 @@ export const StayLoggedInModal: React.FC = () => {
routes: { admin: adminRoute },
} = config
const { closeModal } = useModal()
const { t } = useTranslation()
const { startRouteTransition } = useRouteTransition()
return (
<Modal className={baseClass} slug={stayLoggedInModalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('authentication:stayLoggedIn')}</h1>
<p>{t('authentication:youAreInactive')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={() => {
closeModal(stayLoggedInModalSlug)
const onConfirm: OnConfirm = useCallback(
({ closeConfirmationModal, setConfirming }) => {
setConfirming(false)
closeConfirmationModal()
startRouteTransition(() =>
router.push(
formatAdminURL({
adminRoute,
path: logoutRoute,
}),
),
)
}}
size="large"
>
{t('authentication:logOut')}
</Button>
<Button
onClick={() => {
refreshCookie()
closeModal(stayLoggedInModalSlug)
}}
size="large"
>
{t('authentication:stayLoggedIn')}
</Button>
</div>
</div>
</Modal>
startRouteTransition(() =>
router.push(
formatAdminURL({
adminRoute,
path: logoutRoute,
}),
),
)
},
[router, startRouteTransition, adminRoute, logoutRoute],
)
const onCancel: OnCancel = useCallback(() => {
refreshCookie()
}, [refreshCookie])
return (
<ConfirmationModal
body={t('authentication:youAreInactive')}
cancelLabel={t('authentication:stayLoggedIn')}
confirmLabel={t('authentication:logOut')}
heading={t('authentication:stayLoggedIn')}
modalSlug={stayLoggedInModalSlug}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)
}

View File

@@ -1,39 +0,0 @@
@import '../../scss/styles.scss';
@layer payload-default {
.unpublish-many {
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}
}

View File

@@ -1,9 +1,14 @@
'use client'
import { Modal, useModal } from '@faceless-ui/modal'
import type { ClientCollectionConfig } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import { useRouter, useSearchParams } from 'next/navigation.js'
import * as qs from 'qs-esm'
import React, { useCallback, useState } from 'react'
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import type { OnConfirm } from '../ConfirmationModal/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
@@ -11,22 +16,16 @@ import { useRouteCache } from '../../providers/RouteCache/index.js'
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { Button } from '../Button/index.js'
import { Pill } from '../Pill/index.js'
import './index.scss'
const baseClass = 'unpublish-many'
import type { ClientCollectionConfig } from 'payload'
import { toast } from 'sonner'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { Pill } from '../Pill/index.js'
export type UnpublishManyProps = {
collection: ClientCollectionConfig
}
const baseClass = 'unpublish-many'
export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
const { collection: { slug, labels: { plural, singular }, versions } = {} } = props
@@ -42,7 +41,6 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
const { i18n, t } = useTranslation()
const searchParams = useSearchParams()
const { getQueryParams, selectAll } = useSelection()
const [submitted, setSubmitted] = useState(false)
const router = useRouter()
const { clearRouteCache } = useRouteCache()
@@ -55,81 +53,87 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
toast.error(t('error:unknown'))
}, [t])
const handleUnpublish = useCallback(async () => {
setSubmitted(true)
await requests
.patch(`${serverURL}${api}/${slug}${getQueryParams({ _status: { not_equals: 'draft' } })}`, {
body: JSON.stringify({
_status: 'draft',
}),
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
})
.then(async (res) => {
try {
const json = await res.json()
toggleModal(modalSlug)
const handleUnpublish: OnConfirm = useCallback(
async ({ closeConfirmationModal, setConfirming }) => {
await requests
.patch(
`${serverURL}${api}/${slug}${getQueryParams({ _status: { not_equals: 'draft' } })}`,
{
body: JSON.stringify({
_status: 'draft',
}),
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
},
)
.then(async (res) => {
try {
const json = await res.json()
setConfirming(false)
closeConfirmationModal()
const deletedDocs = json?.docs.length || 0
const successLabel = deletedDocs > 1 ? plural : singular
const deletedDocs = json?.docs.length || 0
const successLabel = deletedDocs > 1 ? plural : singular
if (res.status < 400 || deletedDocs > 0) {
toast.success(
t('general:updatedCountSuccessfully', {
count: deletedDocs,
label: getTranslation(successLabel, i18n),
}),
)
if (res.status < 400 || deletedDocs > 0) {
toast.success(
t('general:updatedCountSuccessfully', {
count: deletedDocs,
label: getTranslation(successLabel, i18n),
}),
)
if (json?.errors.length > 0) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
if (json?.errors.length > 0) {
toast.error(json.message, {
description: json.errors.map((error) => error.message).join('\n'),
})
}
router.replace(
qs.stringify(
{
...parseSearchParams(searchParams),
page: selectAll ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache() // Use clearRouteCache instead of router.refresh, as we only need to clear the cache if the user has route caching enabled - clearRouteCache checks for this
return null
}
router.replace(
qs.stringify(
{
...parseSearchParams(searchParams),
page: selectAll ? '1' : undefined,
},
{ addQueryPrefix: true },
),
)
clearRouteCache() // Use clearRouteCache instead of router.refresh, as we only need to clear the cache if the user has route caching enabled - clearRouteCache checks for this
return null
if (json.errors) {
json.errors.forEach((error) => toast.error(error.message))
} else {
addDefaultError()
}
return false
} catch (_err) {
setConfirming(false)
closeConfirmationModal()
return addDefaultError()
}
if (json.errors) {
json.errors.forEach((error) => toast.error(error.message))
} else {
addDefaultError()
}
return false
} catch (_err) {
return addDefaultError()
}
})
}, [
serverURL,
api,
slug,
getQueryParams,
i18n,
toggleModal,
modalSlug,
plural,
singular,
t,
router,
searchParams,
selectAll,
clearRouteCache,
addDefaultError,
])
})
},
[
serverURL,
api,
slug,
getQueryParams,
i18n,
plural,
singular,
t,
router,
searchParams,
selectAll,
clearRouteCache,
addDefaultError,
],
)
if (!versions?.drafts || selectAll === SelectAllStatus.None || !hasPermission) {
return null
@@ -140,38 +144,18 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
<Pill
className={`${baseClass}__toggle`}
onClick={() => {
setSubmitted(false)
toggleModal(modalSlug)
}}
>
{t('version:unpublish')}
</Pill>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmUnpublish')}</h1>
<p>{t('version:aboutToUnpublishSelection', { label: getTranslation(plural, i18n) })}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="confirm-unpublish"
onClick={submitted ? undefined : handleUnpublish}
size="large"
>
{submitted ? t('version:unpublishing') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
<ConfirmationModal
body={t('version:aboutToUnpublishSelection', { label: getTranslation(plural, i18n) })}
confirmingLabel={t('version:unpublishing')}
heading={t('version:confirmUnpublish')}
modalSlug={modalSlug}
onConfirm={handleUnpublish}
/>
</React.Fragment>
)
}

View File

@@ -23,6 +23,8 @@ export { useEffectEvent } from '../../hooks/useEffectEvent.js'
export { useUseTitleField } from '../../hooks/useUseAsTitle.js'
// elements
export { ConfirmationModal } from '../../elements/ConfirmationModal/index.js'
export type { OnCancel, OnConfirm } from '../../elements/ConfirmationModal/index.js'
export { Link } from '../../elements/Link/index.js'
export { LeaveWithoutSaving } from '../../elements/LeaveWithoutSaving/index.js'
export { DocumentTakeOver } from '../../elements/DocumentTakeOver/index.js'

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import type { Props } from './types.js'
@@ -73,7 +73,6 @@ export const Auth: React.FC<Props> = (props) => {
readOnly || (apiKeyPermissions !== true && !apiKeyPermissions?.update)
const canReadApiKey = apiKeyPermissions === true || apiKeyPermissions?.read
const canReadEnableAPIKey = apiKeyPermissions === true || apiKeyPermissions?.read
const handleChangePassword = useCallback(
(showPasswordFields: boolean) => {
@@ -202,18 +201,20 @@ export const Auth: React.FC<Props> = (props) => {
)}
{useAPIKey && (
<div className={`${baseClass}__api-key`}>
{canReadEnableAPIKey && (
<CheckboxField
field={{
name: 'enableAPIKey',
admin: { disabled, readOnly: enableAPIKeyReadOnly },
label: t('authentication:enableAPIKey'),
}}
path="enableAPIKey"
schemaPath={`${collectionSlug}.enableAPIKey`}
/>
{canReadApiKey && (
<Fragment>
<CheckboxField
field={{
name: 'enableAPIKey',
admin: { disabled, readOnly: enableAPIKeyReadOnly },
label: t('authentication:enableAPIKey'),
}}
path="enableAPIKey"
schemaPath={`${collectionSlug}.enableAPIKey`}
/>
<APIKey enabled={!!enableAPIKey?.value} readOnly={apiKeyReadOnly} />
</Fragment>
)}
{canReadApiKey && <APIKey enabled={!!enableAPIKey?.value} readOnly={apiKeyReadOnly} />}
</div>
)}
{verify && isEditing && (

View File

@@ -704,7 +704,7 @@ describe('General', () => {
await page.goto(postsUrl.edit(id))
await openDocControls(page)
await page.locator('#action-delete').click()
await page.locator('#confirm-delete').click()
await page.locator(`[id=delete-${id}] #confirm-action`).click()
await expect(page.locator(`text=Post "${title}" successfully deleted.`)).toBeVisible()
expect(page.url()).toContain(postsUrl.list)
})
@@ -715,7 +715,7 @@ describe('General', () => {
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.delete-documents__toggle').click()
await page.locator('#confirm-delete').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 3 Posts successfully.',
@@ -733,7 +733,7 @@ describe('General', () => {
await page.locator('input#select-all').check()
await page.locator('button.list-selection__button').click()
await page.locator('.delete-documents__toggle').click()
await page.locator('#confirm-delete').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Deleted 1 Post successfully.',
@@ -917,7 +917,9 @@ describe('General', () => {
await expect(modalContainer).toBeVisible()
// Click the "Leave anyway" button
await page.locator('.leave-without-saving__controls .btn--style-primary').click()
await page
.locator('#leave-without-saving .confirmation-modal__controls .btn--style-primary')
.click()
// Assert that the class on the modal container changes to 'payload__modal-container--exitDone'
await expect(modalContainer).toHaveClass(/payload__modal-container--exitDone/)

View File

@@ -1006,19 +1006,18 @@ describe('List View', () => {
test('should delete many', async () => {
await page.goto(postsUrl.list)
await page.waitForURL(new RegExp(postsUrl.list))
// delete should not appear without selection
await expect(page.locator('#confirm-delete')).toHaveCount(0)
await expect(page.locator('#delete-posts #confirm-action')).toHaveCount(0)
// select one row
await page.locator('.row-1 .cell-_select input').check()
// delete button should be present
await expect(page.locator('#confirm-delete')).toHaveCount(1)
await expect(page.locator('#delete-posts #confirm-action')).toHaveCount(1)
await page.locator('.row-2 .cell-_select input').check()
await page.locator('.delete-documents__toggle').click()
await page.locator('#confirm-delete').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.cell-_select')).toHaveCount(1)
})
})

View File

@@ -64,6 +64,7 @@ export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
uploads: Upload;
posts: Post;

View File

@@ -533,7 +533,7 @@ describe('relationship', () => {
await drawer1Content.locator('#action-delete').click()
await page
.locator('[id^=delete-].payload__modal-item.delete-document[open] button#confirm-delete')
.locator('[id^=delete-].payload__modal-item.confirmation-modal[open] button#confirm-action')
.click()
await expect(drawer1Content).toBeHidden()

View File

@@ -357,7 +357,7 @@ describe('Join Field', () => {
await deleteButton.click()
const deleteConfirmModal = page.locator('dialog[id^="delete-"][open]')
await expect(deleteConfirmModal).toBeVisible()
const confirmDeleteButton = deleteConfirmModal.locator('button#confirm-delete')
const confirmDeleteButton = deleteConfirmModal.locator('button#confirm-action')
await expect(confirmDeleteButton).toBeVisible()
await confirmDeleteButton.click()
await expect(drawer).toBeHidden()

View File

@@ -223,7 +223,7 @@ describe('Locked Documents', () => {
await page.goto(postsUrl.list)
await page.locator('input#select-all').check()
await page.locator('.delete-documents__toggle').click()
await expect(page.locator('.delete-documents__content p')).toHaveText(
await expect(page.locator('#delete-posts .confirmation-modal__content p')).toHaveText(
'You are about to delete 2 Posts',
)
})
@@ -243,7 +243,7 @@ describe('Locked Documents', () => {
await page.locator('input#select-all').check()
await page.locator('.list-selection .list-selection__button').click()
await page.locator('.delete-documents__toggle').click()
await page.locator('#confirm-delete').click()
await page.locator('#delete-posts #confirm-action').click()
await expect(page.locator('.cell-_select')).toHaveCount(1)
})
@@ -257,12 +257,11 @@ describe('Locked Documents', () => {
await page.reload()
await page.goto(postsUrl.list)
await page.waitForURL(new RegExp(postsUrl.list))
await page.locator('input#select-all').check()
await page.locator('.list-selection .list-selection__button').click()
await page.locator('.publish-many__toggle').click()
await page.locator('#confirm-publish').click()
await page.locator('#publish-posts #confirm-action').click()
const paginator = page.locator('.paginator')
@@ -273,12 +272,11 @@ describe('Locked Documents', () => {
test('should only allow bulk unpublish on unlocked documents on all pages', async () => {
await page.goto(postsUrl.list)
await page.waitForURL(new RegExp(postsUrl.list))
await page.locator('input#select-all').check()
await page.locator('.list-selection .list-selection__button').click()
await page.locator('.unpublish-many__toggle').click()
await page.locator('#confirm-unpublish').click()
await page.locator('#unpublish-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toHaveText(
'Updated 10 Posts successfully.',
)
@@ -568,7 +566,9 @@ describe('Locked Documents', () => {
await expect(modalContainer).toBeVisible()
// Click the "Leave anyway" button
await page.locator('.leave-without-saving__controls .btn--style-primary').click()
await page
.locator('#leave-without-saving .confirmation-modal__controls .btn--style-primary')
.click()
// eslint-disable-next-line payload/no-wait-function
await wait(500)
@@ -620,7 +620,9 @@ describe('Locked Documents', () => {
await expect(modalContainer).toBeVisible()
// Click the "Leave anyway" button
await page.locator('.leave-without-saving__controls .btn--style-primary').click()
await page
.locator('#leave-without-saving .confirmation-modal__controls .btn--style-primary')
.click()
// eslint-disable-next-line payload/no-wait-function
await wait(500)

1
test/versions/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
uploads

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -134,7 +134,7 @@ describe('Versions', () => {
await rowToDelete.locator('.cell-_select input').check()
await page.locator('.delete-documents__toggle').click()
await page.locator('#confirm-delete').click()
await page.locator('#delete-draft-posts #confirm-action').click()
await expect(page.locator('.payload-toast-container .toast-success')).toContainText(
'Deleted 1 Draft Post successfully.',
@@ -152,7 +152,7 @@ describe('Versions', () => {
// Bulk edit the selected rows
await page.locator('.publish-many__toggle').click()
await page.locator('#confirm-publish').click()
await page.locator('#publish-draft-posts #confirm-action').click()
// Check that the statuses for each row has been updated to `published`
await expect(findTableCell(page, '_status', 'Published Title')).toContainText('Published')
@@ -176,7 +176,7 @@ describe('Versions', () => {
await expect(findTableCell(page, '_status', title)).toContainText('Draft')
await selectTableRow(page, title)
await page.locator('.publish-many__toggle').click()
await page.locator('#confirm-publish').click()
await page.locator('#publish-autosave-posts #confirm-action').click()
await expect(findTableCell(page, '_status', title)).toContainText('Published')
})
@@ -189,7 +189,7 @@ describe('Versions', () => {
// Bulk edit the selected rows
await page.locator('.unpublish-many__toggle').click()
await page.locator('#confirm-unpublish').click()
await page.locator('#unpublish-draft-posts #confirm-action').click()
// Check that the statuses for each row has been updated to `draft`
await expect(findTableCell(page, '_status', 'Published Title')).toContainText('Draft')
@@ -565,7 +565,7 @@ describe('Versions', () => {
// revert to last published version
await page.locator('#action-revert-to-published').click()
await saveDocAndAssert(page, '#action-revert-to-published-confirm')
await saveDocAndAssert(page, '[id^=confirm-revert-] #confirm-action')
// verify that spanish content is reverted correctly
await expect(page.locator('#field-title')).toHaveValue(spanishTitle)

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/fields/config.ts"],
"@payload-config": ["./test/plugin-search/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],