|
| 1 | +'use client' |
| 2 | + |
| 3 | +import type { FieldWithPathClient, FormState } from 'payload' |
| 4 | + |
| 5 | +import { useModal } from '@faceless-ui/modal' |
| 6 | +import { getTranslation } from '@payloadcms/translations' |
| 7 | +import { useRouter, useSearchParams } from 'next/navigation.js' |
| 8 | +import * as qs from 'qs-esm' |
| 9 | +import React, { useCallback, useEffect, useMemo, useState } from 'react' |
| 10 | + |
| 11 | +import type { FormProps } from '../../forms/Form/index.js' |
| 12 | + |
| 13 | +import { useForm } from '../../forms/Form/context.js' |
| 14 | +import { Form } from '../../forms/Form/index.js' |
| 15 | +import { RenderFields } from '../../forms/RenderFields/index.js' |
| 16 | +import { FormSubmit } from '../../forms/Submit/index.js' |
| 17 | +import { XIcon } from '../../icons/X/index.js' |
| 18 | +import { useAuth } from '../../providers/Auth/index.js' |
| 19 | +import { useConfig } from '../../providers/Config/index.js' |
| 20 | +import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js' |
| 21 | +import { OperationContext } from '../../providers/Operation/index.js' |
| 22 | +import { useRouteCache } from '../../providers/RouteCache/index.js' |
| 23 | +import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' |
| 24 | +import { useServerFunctions } from '../../providers/ServerFunctions/index.js' |
| 25 | +import { useTranslation } from '../../providers/Translation/index.js' |
| 26 | +import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js' |
| 27 | +import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js' |
| 28 | +import { parseSearchParams } from '../../utilities/parseSearchParams.js' |
| 29 | +import './index.scss' |
| 30 | +import { FieldSelect } from '../FieldSelect/index.js' |
| 31 | +import { baseClass, type EditManyProps } from './index.js' |
| 32 | + |
| 33 | +const sanitizeUnselectedFields = (formState: FormState, selected: FieldWithPathClient[]) => { |
| 34 | + const filteredData = selected.reduce((acc, field) => { |
| 35 | + const foundState = formState?.[field.path] |
| 36 | + |
| 37 | + if (foundState) { |
| 38 | + acc[field.path] = formState?.[field.path]?.value |
| 39 | + } |
| 40 | + |
| 41 | + return acc |
| 42 | + }, {} as FormData) |
| 43 | + |
| 44 | + return filteredData |
| 45 | +} |
| 46 | + |
| 47 | +const Submit: React.FC<{ |
| 48 | + readonly action: string |
| 49 | + readonly disabled: boolean |
| 50 | + readonly selected?: FieldWithPathClient[] |
| 51 | +}> = ({ action, disabled, selected }) => { |
| 52 | + const { submit } = useForm() |
| 53 | + const { t } = useTranslation() |
| 54 | + |
| 55 | + const save = useCallback(() => { |
| 56 | + void submit({ |
| 57 | + action, |
| 58 | + method: 'PATCH', |
| 59 | + overrides: (formState) => sanitizeUnselectedFields(formState, selected), |
| 60 | + skipValidation: true, |
| 61 | + }) |
| 62 | + }, [action, submit, selected]) |
| 63 | + |
| 64 | + return ( |
| 65 | + <FormSubmit className={`${baseClass}__save`} disabled={disabled} onClick={save}> |
| 66 | + {t('general:save')} |
| 67 | + </FormSubmit> |
| 68 | + ) |
| 69 | +} |
| 70 | + |
| 71 | +const PublishButton: React.FC<{ |
| 72 | + action: string |
| 73 | + disabled: boolean |
| 74 | + selected?: FieldWithPathClient[] |
| 75 | +}> = ({ action, disabled, selected }) => { |
| 76 | + const { submit } = useForm() |
| 77 | + const { t } = useTranslation() |
| 78 | + |
| 79 | + const save = useCallback(() => { |
| 80 | + void submit({ |
| 81 | + action, |
| 82 | + method: 'PATCH', |
| 83 | + overrides: (formState) => ({ |
| 84 | + ...sanitizeUnselectedFields(formState, selected), |
| 85 | + _status: 'published', |
| 86 | + }), |
| 87 | + skipValidation: true, |
| 88 | + }) |
| 89 | + }, [action, submit, selected]) |
| 90 | + |
| 91 | + return ( |
| 92 | + <FormSubmit className={`${baseClass}__publish`} disabled={disabled} onClick={save}> |
| 93 | + {t('version:publishChanges')} |
| 94 | + </FormSubmit> |
| 95 | + ) |
| 96 | +} |
| 97 | + |
| 98 | +const SaveDraftButton: React.FC<{ |
| 99 | + action: string |
| 100 | + disabled: boolean |
| 101 | + selected?: FieldWithPathClient[] |
| 102 | +}> = ({ action, disabled, selected }) => { |
| 103 | + const { submit } = useForm() |
| 104 | + const { t } = useTranslation() |
| 105 | + |
| 106 | + const save = useCallback(() => { |
| 107 | + void submit({ |
| 108 | + action, |
| 109 | + method: 'PATCH', |
| 110 | + overrides: (formState) => ({ |
| 111 | + ...sanitizeUnselectedFields(formState, selected), |
| 112 | + _status: 'draft', |
| 113 | + }), |
| 114 | + skipValidation: true, |
| 115 | + }) |
| 116 | + }, [action, submit, selected]) |
| 117 | + |
| 118 | + return ( |
| 119 | + <FormSubmit |
| 120 | + buttonStyle="secondary" |
| 121 | + className={`${baseClass}__draft`} |
| 122 | + disabled={disabled} |
| 123 | + onClick={save} |
| 124 | + > |
| 125 | + {t('version:saveDraft')} |
| 126 | + </FormSubmit> |
| 127 | + ) |
| 128 | +} |
| 129 | + |
| 130 | +export const EditManyDrawerContent: React.FC< |
| 131 | + { |
| 132 | + drawerSlug: string |
| 133 | + selected: FieldWithPathClient[] |
| 134 | + } & EditManyProps |
| 135 | +> = (props) => { |
| 136 | + const { |
| 137 | + collection: { slug, fields, labels: { plural } } = {}, |
| 138 | + collection, |
| 139 | + drawerSlug, |
| 140 | + selected: selectedFromProps, |
| 141 | + } = props |
| 142 | + |
| 143 | + const { permissions, user } = useAuth() |
| 144 | + |
| 145 | + const { closeModal } = useModal() |
| 146 | + |
| 147 | + const { |
| 148 | + config: { |
| 149 | + routes: { api: apiRoute }, |
| 150 | + serverURL, |
| 151 | + }, |
| 152 | + } = useConfig() |
| 153 | + |
| 154 | + const { getFormState } = useServerFunctions() |
| 155 | + |
| 156 | + const { count, getQueryParams, selectAll } = useSelection() |
| 157 | + const { i18n, t } = useTranslation() |
| 158 | + const [selected, setSelected] = useState<FieldWithPathClient[]>([]) |
| 159 | + |
| 160 | + useEffect(() => { |
| 161 | + setSelected(selectedFromProps) |
| 162 | + }, [selectedFromProps]) |
| 163 | + |
| 164 | + const router = useRouter() |
| 165 | + const [initialState, setInitialState] = useState<FormState>() |
| 166 | + const hasInitializedState = React.useRef(false) |
| 167 | + const abortFormStateRef = React.useRef<AbortController>(null) |
| 168 | + const { clearRouteCache } = useRouteCache() |
| 169 | + const collectionPermissions = permissions?.collections?.[slug] |
| 170 | + const searchParams = useSearchParams() |
| 171 | + |
| 172 | + React.useEffect(() => { |
| 173 | + const controller = new AbortController() |
| 174 | + |
| 175 | + if (!hasInitializedState.current) { |
| 176 | + const getInitialState = async () => { |
| 177 | + const { state: result } = await getFormState({ |
| 178 | + collectionSlug: slug, |
| 179 | + data: {}, |
| 180 | + docPermissions: collectionPermissions, |
| 181 | + docPreferences: null, |
| 182 | + operation: 'update', |
| 183 | + schemaPath: slug, |
| 184 | + signal: controller.signal, |
| 185 | + }) |
| 186 | + |
| 187 | + setInitialState(result) |
| 188 | + hasInitializedState.current = true |
| 189 | + } |
| 190 | + |
| 191 | + void getInitialState() |
| 192 | + } |
| 193 | + |
| 194 | + return () => { |
| 195 | + abortAndIgnore(controller) |
| 196 | + } |
| 197 | + }, [apiRoute, hasInitializedState, serverURL, slug, getFormState, user, collectionPermissions]) |
| 198 | + |
| 199 | + const onChange: FormProps['onChange'][0] = useCallback( |
| 200 | + async ({ formState: prevFormState }) => { |
| 201 | + const controller = handleAbortRef(abortFormStateRef) |
| 202 | + |
| 203 | + const { state } = await getFormState({ |
| 204 | + collectionSlug: slug, |
| 205 | + docPermissions: collectionPermissions, |
| 206 | + docPreferences: null, |
| 207 | + formState: prevFormState, |
| 208 | + operation: 'update', |
| 209 | + schemaPath: slug, |
| 210 | + signal: controller.signal, |
| 211 | + }) |
| 212 | + |
| 213 | + abortFormStateRef.current = null |
| 214 | + |
| 215 | + return state |
| 216 | + }, |
| 217 | + [getFormState, slug, collectionPermissions], |
| 218 | + ) |
| 219 | + |
| 220 | + useEffect(() => { |
| 221 | + const abortFormState = abortFormStateRef.current |
| 222 | + |
| 223 | + return () => { |
| 224 | + abortAndIgnore(abortFormState) |
| 225 | + } |
| 226 | + }, []) |
| 227 | + |
| 228 | + const queryString = useMemo(() => { |
| 229 | + const queryWithSearch = mergeListSearchAndWhere({ |
| 230 | + collectionConfig: collection, |
| 231 | + search: searchParams.get('search'), |
| 232 | + }) |
| 233 | + |
| 234 | + return getQueryParams(queryWithSearch) |
| 235 | + }, [collection, searchParams, getQueryParams]) |
| 236 | + |
| 237 | + const onSuccess = () => { |
| 238 | + router.replace( |
| 239 | + qs.stringify( |
| 240 | + { |
| 241 | + ...parseSearchParams(searchParams), |
| 242 | + page: selectAll === SelectAllStatus.AllAvailable ? '1' : undefined, |
| 243 | + }, |
| 244 | + { addQueryPrefix: true }, |
| 245 | + ), |
| 246 | + ) |
| 247 | + 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 |
| 248 | + closeModal(drawerSlug) |
| 249 | + } |
| 250 | + |
| 251 | + return ( |
| 252 | + <DocumentInfoProvider |
| 253 | + collectionSlug={slug} |
| 254 | + currentEditor={user} |
| 255 | + hasPublishedDoc={false} |
| 256 | + id={null} |
| 257 | + initialData={{}} |
| 258 | + initialState={initialState} |
| 259 | + isLocked={false} |
| 260 | + lastUpdateTime={0} |
| 261 | + mostRecentVersionIsAutosaved={false} |
| 262 | + unpublishedVersionCount={0} |
| 263 | + versionCount={0} |
| 264 | + > |
| 265 | + <OperationContext.Provider value="update"> |
| 266 | + <div className={`${baseClass}__main`}> |
| 267 | + <div className={`${baseClass}__header`}> |
| 268 | + <h2 className={`${baseClass}__header__title`}> |
| 269 | + {t('general:editingLabel', { count, label: getTranslation(plural, i18n) })} |
| 270 | + </h2> |
| 271 | + <button |
| 272 | + aria-label={t('general:close')} |
| 273 | + className={`${baseClass}__header__close`} |
| 274 | + id={`close-drawer__${drawerSlug}`} |
| 275 | + onClick={() => closeModal(drawerSlug)} |
| 276 | + type="button" |
| 277 | + > |
| 278 | + <XIcon /> |
| 279 | + </button> |
| 280 | + </div> |
| 281 | + <Form |
| 282 | + className={`${baseClass}__form`} |
| 283 | + initialState={initialState} |
| 284 | + onChange={[onChange]} |
| 285 | + onSuccess={onSuccess} |
| 286 | + > |
| 287 | + <FieldSelect fields={fields} setSelected={setSelected} /> |
| 288 | + {selected.length === 0 ? null : ( |
| 289 | + <RenderFields |
| 290 | + fields={selected} |
| 291 | + parentIndexPath="" |
| 292 | + parentPath="" |
| 293 | + parentSchemaPath={slug} |
| 294 | + permissions={collectionPermissions?.fields} |
| 295 | + readOnly={false} |
| 296 | + /> |
| 297 | + )} |
| 298 | + <div className={`${baseClass}__sidebar-wrap`}> |
| 299 | + <div className={`${baseClass}__sidebar`}> |
| 300 | + <div className={`${baseClass}__sidebar-sticky-wrap`}> |
| 301 | + <div className={`${baseClass}__document-actions`}> |
| 302 | + {collection?.versions?.drafts ? ( |
| 303 | + <React.Fragment> |
| 304 | + <SaveDraftButton |
| 305 | + action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`} |
| 306 | + disabled={selected.length === 0} |
| 307 | + selected={selected} |
| 308 | + /> |
| 309 | + <PublishButton |
| 310 | + action={`${serverURL}${apiRoute}/${slug}${queryString}&draft=true`} |
| 311 | + disabled={selected.length === 0} |
| 312 | + selected={selected} |
| 313 | + /> |
| 314 | + </React.Fragment> |
| 315 | + ) : ( |
| 316 | + <Submit |
| 317 | + action={`${serverURL}${apiRoute}/${slug}${queryString}`} |
| 318 | + disabled={selected.length === 0} |
| 319 | + selected={selected} |
| 320 | + /> |
| 321 | + )} |
| 322 | + </div> |
| 323 | + </div> |
| 324 | + </div> |
| 325 | + </div> |
| 326 | + </Form> |
| 327 | + </div> |
| 328 | + </OperationContext.Provider> |
| 329 | + </DocumentInfoProvider> |
| 330 | + ) |
| 331 | +} |
0 commit comments