diff --git a/src/admin/components/elements/ColumnSelector/index.tsx b/src/admin/components/elements/ColumnSelector/index.tsx index 75e3fa6f80..5e50863f16 100644 --- a/src/admin/components/elements/ColumnSelector/index.tsx +++ b/src/admin/components/elements/ColumnSelector/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields'; import Pill from '../Pill'; @@ -6,7 +6,7 @@ import Plus from '../../icons/Plus'; import X from '../../icons/X'; import { Props } from './types'; import { getTranslation } from '../../../../utilities/getTranslation'; - +import { useEditDepth } from '../../utilities/EditDepth'; import './index.scss'; const baseClass = 'column-selector'; @@ -18,8 +18,15 @@ const ColumnSelector: React.FC = (props) => { setColumns, } = props; - const [fields] = useState(() => flattenTopLevelFields(collection.fields, true)); + const [fields, setFields] = useState(() => flattenTopLevelFields(collection.fields, true)); + + useEffect(() => { + setFields(flattenTopLevelFields(collection.fields, true)); + }, [collection.fields]); + const { i18n } = useTranslation(); + const uuid = useId(); + const editDepth = useEditDepth(); return (
@@ -38,7 +45,7 @@ const ColumnSelector: React.FC = (props) => { setColumns(newState); }} alignIcon="left" - key={field.name || i} + key={`${field.name || i}${editDepth ? `-${editDepth}-` : ''}${uuid}`} icon={isEnabled ? : } className={[ `${baseClass}__column`, diff --git a/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx b/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx new file mode 100644 index 0000000000..8537397c53 --- /dev/null +++ b/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useModal } from '@faceless-ui/modal'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { DocumentDrawerProps } from './types'; +import DefaultEdit from '../../views/collections/Edit/Default'; +import X from '../../icons/X'; +import { Fields } from '../../forms/Form/types'; +import buildStateFromSchema from '../../forms/Form/buildStateFromSchema'; +import { getTranslation } from '../../../../utilities/getTranslation'; +import Button from '../Button'; +import { useConfig } from '../../utilities/Config'; +import { useLocale } from '../../utilities/Locale'; +import { useAuth } from '../../utilities/Auth'; +import { DocumentInfoProvider } from '../../utilities/DocumentInfo'; +import RenderCustomComponent from '../../utilities/RenderCustomComponent'; +import usePayloadAPI from '../../../hooks/usePayloadAPI'; +import formatFields from '../../views/collections/Edit/formatFields'; +import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections'; +import IDLabel from '../IDLabel'; +import { baseClass } from '.'; + +export const DocumentDrawerContent: React.FC = ({ + collectionSlug, + id, + drawerSlug, + onSave: onSaveFromProps, + customHeader, +}) => { + const { serverURL, routes: { api } } = useConfig(); + const { toggleModal, modalState, closeModal } = useModal(); + const locale = useLocale(); + const { permissions, user } = useAuth(); + const [initialState, setInitialState] = useState(); + const { t, i18n } = useTranslation(['fields', 'general']); + const hasInitializedState = useRef(false); + const [isOpen, setIsOpen] = useState(false); + const [collectionConfig] = useRelatedCollections(collectionSlug); + + const [fields, setFields] = useState(() => formatFields(collectionConfig, true)); + + useEffect(() => { + setFields(formatFields(collectionConfig, true)); + }, [collectionSlug, collectionConfig]); + + const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI( + (id ? `${serverURL}${api}/${collectionSlug}/${id}` : null), + { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, + ); + + useEffect(() => { + if (isLoadingDocument) { + return; + } + + const awaitInitialState = async () => { + const state = await buildStateFromSchema({ + fieldSchema: fields, + data, + user, + operation: id ? 'update' : 'create', + id, + locale, + t, + }); + setInitialState(state); + }; + + awaitInitialState(); + hasInitializedState.current = true; + }, [data, fields, id, user, locale, isLoadingDocument, t]); + + useEffect(() => { + setIsOpen(Boolean(modalState[drawerSlug]?.isOpen)); + }, [modalState, drawerSlug]); + + useEffect(() => { + if (isOpen && !isLoadingDocument && isError) { + closeModal(drawerSlug); + toast.error(data.errors?.[0].message || t('error:unspecific')); + } + }, [isError, t, isOpen, data, drawerSlug, closeModal, isLoadingDocument]); + + const onSave = useCallback((args) => { + if (typeof onSaveFromProps === 'function') { + onSaveFromProps({ + ...args, + collectionConfig, + }); + } + }, [collectionConfig, onSaveFromProps]); + + if (isError) return null; + + return ( + + +
+

+ {!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader} +

+ +
+ {id && ( + + )} +
+ ), + }} + /> + + ); +}; diff --git a/src/admin/components/elements/DocumentDrawer/index.scss b/src/admin/components/elements/DocumentDrawer/index.scss index 61cb02f0cf..3429ec342f 100644 --- a/src/admin/components/elements/DocumentDrawer/index.scss +++ b/src/admin/components/elements/DocumentDrawer/index.scss @@ -2,34 +2,60 @@ .doc-drawer { &__header { + margin-top: base(2.5); margin-bottom: base(1); + + @include mid-break { + margin-top: base(1.5); + } } &__header-content { display: flex; justify-content: space-between; align-items: flex-start; - margin-top: base(2.5); + margin-bottom: base(1); } - &__header-close { - svg { - width: base(2.5); - height: base(2.5); - position: relative; - top: base(-.5); - right: base(-.75); + &__header-text { + margin: 0; + } - .stroke { - stroke-width: .5px; - } + &__toggler { + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + + &:focus, + &:focus-within { + outline: none; + } + + &:disabled { + pointer-events: none; } } - @include mid-break { - &__header-close { - svg { - top: base(-.75); + &__header-close { + border: 0; + background-color: transparent; + padding: 0; + cursor: pointer; + overflow: hidden; + width: base(1); + height: base(1); + + svg { + width: base(2.75); + height: base(2.75); + position: relative; + left: base(-.825); + top: base(-.825); + + .stroke { + stroke-width: 2px; + vector-effect: non-scaling-stroke; } } } diff --git a/src/admin/components/elements/DocumentDrawer/index.tsx b/src/admin/components/elements/DocumentDrawer/index.tsx index 5205d3f0f1..fb2ca87800 100644 --- a/src/admin/components/elements/DocumentDrawer/index.tsx +++ b/src/admin/components/elements/DocumentDrawer/index.tsx @@ -1,29 +1,16 @@ import React, { useCallback, useEffect, useId, useMemo, useState } from 'react'; import { useModal } from '@faceless-ui/modal'; import { useTranslation } from 'react-i18next'; -import { toast } from 'react-toastify'; import { DocumentDrawerProps, DocumentTogglerProps, UseDocumentDrawer } from './types'; -import DefaultEdit from '../../views/collections/Edit/Default'; -import X from '../../icons/X'; -import { Fields } from '../../forms/Form/types'; -import buildStateFromSchema from '../../forms/Form/buildStateFromSchema'; import { getTranslation } from '../../../../utilities/getTranslation'; import { Drawer, DrawerToggler } from '../Drawer'; -import Button from '../Button'; -import { useConfig } from '../../utilities/Config'; -import { useLocale } from '../../utilities/Locale'; -import { useAuth } from '../../utilities/Auth'; -import { DocumentInfoProvider } from '../../utilities/DocumentInfo'; -import RenderCustomComponent from '../../utilities/RenderCustomComponent'; -import usePayloadAPI from '../../../hooks/usePayloadAPI'; -import formatFields from '../../views/collections/Edit/formatFields'; import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections'; -import IDLabel from '../IDLabel'; import { useEditDepth } from '../../utilities/EditDepth'; +import { DocumentDrawerContent } from './DrawerContent'; import './index.scss'; -const baseClass = 'doc-drawer'; +export const baseClass = 'doc-drawer'; const formatDocumentDrawerSlug = ({ collectionSlug, @@ -43,6 +30,7 @@ export const DocumentDrawerToggler: React.FC = ({ drawerSlug, id, collectionSlug, + disabled, ...rest }) => { const { t, i18n } = useTranslation(['fields', 'general']); @@ -52,7 +40,11 @@ export const DocumentDrawerToggler: React.FC = ({ @@ -61,138 +53,24 @@ export const DocumentDrawerToggler: React.FC = ({ ); }; -export const DocumentDrawer: React.FC = ({ - collectionSlug, - id, - drawerSlug, - onSave: onSaveFromProps, - customHeader, -}) => { - const { serverURL, routes: { api } } = useConfig(); - const { toggleModal, modalState, closeModal } = useModal(); - const locale = useLocale(); - const { permissions, user } = useAuth(); - const [initialState, setInitialState] = useState(); - const { t, i18n } = useTranslation(['fields', 'general']); - const [isOpen, setIsOpen] = useState(false); - const [collectionConfig] = useRelatedCollections(collectionSlug); +export const DocumentDrawer: React.FC = (props) => { + const { drawerSlug } = props; - const [fields, setFields] = useState(() => formatFields(collectionConfig, true)); - - useEffect(() => { - setFields(formatFields(collectionConfig, true)); - }, [collectionSlug, collectionConfig]); - - const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI( - (id ? `${serverURL}${api}/${collectionSlug}/${id}` : null), - { initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } }, + return ( + + + ); - - useEffect(() => { - if (isLoadingDocument) { - return; - } - - const awaitInitialState = async () => { - const state = await buildStateFromSchema({ - fieldSchema: fields, - data, - user, - operation: id ? 'update' : 'create', - id, - locale, - t, - }); - setInitialState(state); - }; - - awaitInitialState(); - }, [data, fields, id, user, locale, isLoadingDocument, t]); - - useEffect(() => { - setIsOpen(Boolean(modalState[drawerSlug]?.isOpen)); - }, [modalState, drawerSlug]); - - useEffect(() => { - if (isOpen && !isLoadingDocument && isError) { - closeModal(drawerSlug); - toast.error(data.errors?.[0].message || t('error:unspecific')); - } - }, [isError, t, isOpen, data, drawerSlug, closeModal, isLoadingDocument]); - - const onSave = useCallback((args) => { - if (typeof onSaveFromProps === 'function') { - onSaveFromProps({ - ...args, - collectionConfig, - }); - } - }, [collectionConfig, onSaveFromProps]); - - if (isError) return null; - - if (isOpen) { - // IMPORTANT: we must ensure that modals are not recursively rendered - // to do this, do not render the drawer until it is open - return ( - - - -
-

- {!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader} -

- -
- {id && ( - - )} - - ), - }} - /> -
-
- ); - } - return null; }; export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) => { const drawerDepth = useEditDepth(); const uuid = useId(); - const { modalState, toggleModal } = useModal(); + const { modalState, toggleModal, closeModal, openModal } = useModal(); const [isOpen, setIsOpen] = useState(false); const drawerSlug = formatDocumentDrawerSlug({ collectionSlug, @@ -209,6 +87,14 @@ export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) => toggleModal(drawerSlug); }, [toggleModal, drawerSlug]); + const closeDrawer = useCallback(() => { + closeModal(drawerSlug); + }, [closeModal, drawerSlug]); + + const openDrawer = useCallback(() => { + openModal(drawerSlug); + }, [openModal, drawerSlug]); + const MemoizedDrawer = useMemo(() => { return ((props) => ( drawerDepth, isDrawerOpen: isOpen, toggleDrawer, - }), [drawerDepth, drawerSlug, isOpen, toggleDrawer]); + closeDrawer, + openDrawer, + }), [drawerDepth, drawerSlug, isOpen, toggleDrawer, closeDrawer, openDrawer]); return [ MemoizedDrawer, diff --git a/src/admin/components/elements/DocumentDrawer/types.ts b/src/admin/components/elements/DocumentDrawer/types.ts index db9a030211..6c88e4379c 100644 --- a/src/admin/components/elements/DocumentDrawer/types.ts +++ b/src/admin/components/elements/DocumentDrawer/types.ts @@ -4,10 +4,10 @@ import { SanitizedCollectionConfig } from '../../../../collections/config/types' export type DocumentDrawerProps = { collectionSlug: string id?: string - onSave?: (args: { + onSave?: (json: { doc: Record + message: string collectionConfig: SanitizedCollectionConfig - message: string, }) => void customHeader?: React.ReactNode drawerSlug?: string @@ -19,6 +19,7 @@ export type DocumentTogglerProps = HTMLAttributes & { drawerSlug?: string id?: string collectionSlug: string + disabled?: boolean } export type UseDocumentDrawer = (args: { @@ -32,5 +33,7 @@ export type UseDocumentDrawer = (args: { drawerDepth: number isDrawerOpen: boolean toggleDrawer: () => void + closeDrawer: () => void + openDrawer: () => void } ] diff --git a/src/admin/components/elements/Drawer/index.scss b/src/admin/components/elements/Drawer/index.scss index 3a564e8fd8..cedaa7983a 100644 --- a/src/admin/components/elements/Drawer/index.scss +++ b/src/admin/components/elements/Drawer/index.scss @@ -26,6 +26,7 @@ z-index: 2; width: 100%; transition: all 300ms ease-out; + overflow: hidden; } &__content-children { diff --git a/src/admin/components/elements/Drawer/index.tsx b/src/admin/components/elements/Drawer/index.tsx index af487a1632..0016c2c663 100644 --- a/src/admin/components/elements/Drawer/index.tsx +++ b/src/admin/components/elements/Drawer/index.tsx @@ -10,13 +10,13 @@ const baseClass = 'drawer'; const zBase = 100; -const formatDrawerSlug = ({ +export const formatDrawerSlug = ({ slug, depth, }: { slug: string, depth: number, -}) => `drawer_${depth}_${slug}`; +}): string => `drawer_${depth}_${slug}`; export const DrawerToggler: React.FC = ({ slug, @@ -24,6 +24,7 @@ export const DrawerToggler: React.FC = ({ children, className, onClick, + disabled, ...rest }) => { const { openModal } = useModal(); @@ -39,6 +40,7 @@ export const DrawerToggler: React.FC = ({ onClick={handleClick} type="button" className={className} + disabled={disabled} {...rest} > {children} @@ -57,44 +59,55 @@ export const Drawer: React.FC = ({ const { breakpoints: { m: midBreak } } = useWindowInfo(); const drawerDepth = useEditDepth(); const [isOpen, setIsOpen] = useState(false); + const [animateIn, setAnimateIn] = useState(false); const [modalSlug] = useState(() => (formatSlug !== false ? formatDrawerSlug({ slug, depth: drawerDepth }) : slug)); useEffect(() => { - setIsOpen(modalState[modalSlug].isOpen); + setIsOpen(modalState[modalSlug]?.isOpen); }, [modalSlug, modalState]); - return ( - - {drawerDepth === 1 && ( -
- )} -
-
- ); + + ); + } + + return null; }; diff --git a/src/admin/components/elements/Drawer/types.ts b/src/admin/components/elements/Drawer/types.ts index b137f08a84..0bf04ccade 100644 --- a/src/admin/components/elements/Drawer/types.ts +++ b/src/admin/components/elements/Drawer/types.ts @@ -12,4 +12,5 @@ export type TogglerProps = HTMLAttributes & { formatSlug?: boolean children: React.ReactNode className?: string + disabled?: boolean } diff --git a/src/admin/components/elements/ListControls/index.scss b/src/admin/components/elements/ListControls/index.scss index 3baa6e346c..0b6e05eba9 100644 --- a/src/admin/components/elements/ListControls/index.scss +++ b/src/admin/components/elements/ListControls/index.scss @@ -23,7 +23,11 @@ display: flex; margin-left: - base(.5); margin-right: - base(.5); - width: calc(100% + #{$baseline}); + width: calc(100% + #{base(1)}); + + .btn { + margin: 0 base(.5); + } } &__toggle-columns, @@ -31,10 +35,6 @@ &__toggle-sort { min-width: 140px; - &.btn { - margin: 0 base(.5); - } - &.btn--style-primary { svg { transform: rotate(180deg); @@ -52,6 +52,16 @@ &__buttons { margin-left: base(.5); } + + &__buttons-wrap { + margin-left: - base(.25); + margin-right: - base(.25); + width: calc(100% + #{base(0.5)}); + + .btn { + margin: 0 base(.25); + } + } } @include small-break { diff --git a/src/admin/components/elements/ListDrawer/DrawerContent.tsx b/src/admin/components/elements/ListDrawer/DrawerContent.tsx new file mode 100644 index 0000000000..665a5fa1d8 --- /dev/null +++ b/src/admin/components/elements/ListDrawer/DrawerContent.tsx @@ -0,0 +1,310 @@ +import React, { Fragment, useCallback, useEffect, useReducer, useState } from 'react'; +import { useModal } from '@faceless-ui/modal'; +import { useTranslation } from 'react-i18next'; +import { ListDrawerProps } from './types'; +import { getTranslation } from '../../../../utilities/getTranslation'; +import { useConfig } from '../../utilities/Config'; +import { useAuth } from '../../utilities/Auth'; +import { DocumentInfoProvider } from '../../utilities/DocumentInfo'; +import RenderCustomComponent from '../../utilities/RenderCustomComponent'; +import usePayloadAPI from '../../../hooks/usePayloadAPI'; +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; +import DefaultList from '../../views/collections/List/Default'; +import Label from '../../forms/Label'; +import ReactSelect from '../ReactSelect'; +import { useDocumentDrawer } from '../DocumentDrawer'; +import Pill from '../Pill'; +import X from '../../icons/X'; +import ViewDescription from '../ViewDescription'; +import { Column } from '../Table/types'; +import getInitialColumnState from '../../views/collections/List/getInitialColumns'; +import buildListColumns from '../../views/collections/List/buildColumns'; +import formatFields from '../../views/collections/List/formatFields'; +import { ListPreferences } from '../../views/collections/List/types'; +import { usePreferences } from '../../utilities/Preferences'; +import { Field } from '../../../../fields/config/types'; +import { baseClass } from '.'; + +const buildColumns = ({ + collectionConfig, + columns, + onSelect, + t, +}) => buildListColumns({ + collection: collectionConfig, + columns, + t, + cellProps: [{ + link: false, + onClick: ({ collection, rowData }) => { + if (typeof onSelect === 'function') { + onSelect({ + docID: rowData.id, + collectionConfig: collection, + }); + } + }, + className: `${baseClass}__first-cell`, + }], +}); + +const shouldIncludeCollection = ({ + coll: { + admin: { enableRichTextRelationship }, + upload, + slug, + }, + uploads, + collectionSlugs, +}) => (enableRichTextRelationship && ((uploads && Boolean(upload)) || collectionSlugs?.includes(slug))); + +export const ListDrawerContent: React.FC = ({ + drawerSlug, + onSelect, + customHeader, + collectionSlugs, + uploads, + selectedCollection, +}) => { + const { t, i18n } = useTranslation(['upload', 'general']); + const { permissions } = useAuth(); + const { getPreference, setPreference } = usePreferences(); + const { isModalOpen, closeModal } = useModal(); + const [limit, setLimit] = useState(); + const [sort, setSort] = useState(null); + const [page, setPage] = useState(1); + const [where, setWhere] = useState(null); + const { serverURL, routes: { api }, collections } = useConfig(); + const [enabledCollectionConfigs] = useState(() => collections.filter((coll) => shouldIncludeCollection({ coll, uploads, collectionSlugs }))); + const [selectedCollectionConfig, setSelectedCollectionConfig] = useState(() => { + let initialSelection: SanitizedCollectionConfig; + if (selectedCollection) { + // if passed a selection, find it and check if it's enabled + const foundSelection = collections.find(({ slug }) => slug === selectedCollection); + if (foundSelection && shouldIncludeCollection({ coll: foundSelection, uploads, collectionSlugs })) { + initialSelection = foundSelection; + } + } else { + // return the first one that is enabled + initialSelection = collections.find((coll) => shouldIncludeCollection({ coll, uploads, collectionSlugs })); + } + return initialSelection; + }); + + const [selectedOption, setSelectedOption] = useState<{ label: string, value: string }>(() => (selectedCollectionConfig ? { label: getTranslation(selectedCollectionConfig.labels.singular, i18n), value: selectedCollectionConfig.slug } : undefined)); + const [fields, setFields] = useState(() => formatFields(selectedCollectionConfig, t)); + const [tableColumns, setTableColumns] = useState(() => { + const initialColumns = getInitialColumnState(fields, selectedCollectionConfig.admin.useAsTitle, selectedCollectionConfig.admin.defaultColumns); + return buildColumns({ + collectionConfig: selectedCollectionConfig, + columns: initialColumns, + t, + onSelect, + }); + }); + + // allow external control of selected collection, same as the initial state logic above + useEffect(() => { + let newSelection: SanitizedCollectionConfig; + if (selectedCollection) { + // if passed a selection, find it and check if it's enabled + const foundSelection = collections.find(({ slug }) => slug === selectedCollection); + if (foundSelection && shouldIncludeCollection({ coll: foundSelection, uploads, collectionSlugs })) { + newSelection = foundSelection; + } + } else { + // return the first one that is enabled + newSelection = collections.find((coll) => shouldIncludeCollection({ coll, uploads, collectionSlugs })); + } + setSelectedCollectionConfig(newSelection); + }, [selectedCollection, collectionSlugs, uploads, collections, onSelect, t]); + + const activeColumnNames = tableColumns.map(({ accessor }) => accessor); + const stringifiedActiveColumns = JSON.stringify(activeColumnNames); + const preferenceKey = `${selectedCollectionConfig.slug}-list`; + + // this is the 'create new' drawer + const [ + DocumentDrawer, + DocumentDrawerToggler, + { + drawerSlug: documentDrawerSlug, + }, + ] = useDocumentDrawer({ + collectionSlug: selectedCollectionConfig.slug, + }); + + useEffect(() => { + if (selectedOption) { + setSelectedCollectionConfig(collections.find(({ slug }) => selectedOption.value === slug)); + } + }, [selectedOption, collections]); + + const collectionPermissions = permissions?.collections?.[selectedCollectionConfig?.slug]; + const hasCreatePermission = collectionPermissions?.create?.permission; + + // If modal is open, get active page of upload gallery + const isOpen = isModalOpen(drawerSlug); + const apiURL = isOpen ? `${serverURL}${api}/${selectedCollectionConfig.slug}` : null; + const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0); // used to force a re-fetch even when apiURL is unchanged + const [{ data, isError }, { setParams }] = usePayloadAPI(apiURL, {}); + const moreThanOneAvailableCollection = enabledCollectionConfigs.length > 1; + + useEffect(() => { + const params: { + page?: number + sort?: string + where?: unknown + limit?: number + cacheBust?: number + } = {}; + + if (page) params.page = page; + if (where) params.where = where; + if (sort) params.sort = sort; + if (limit) params.limit = limit; + if (cacheBust) params.cacheBust = cacheBust; + + setParams(params); + }, [setParams, page, sort, where, limit, cacheBust]); + + useEffect(() => { + const syncColumnsFromPrefs = async () => { + const currentPreferences = await getPreference(preferenceKey); + const newFields = formatFields(selectedCollectionConfig, t); + setFields(newFields); + const initialColumns = getInitialColumnState(newFields, selectedCollectionConfig.admin.useAsTitle, selectedCollectionConfig.admin.defaultColumns); + setTableColumns(buildColumns({ + collectionConfig: selectedCollectionConfig, + columns: currentPreferences?.columns || initialColumns, + t, + onSelect, + })); + }; + + syncColumnsFromPrefs(); + }, [t, getPreference, preferenceKey, onSelect, selectedCollectionConfig]); + + useEffect(() => { + const newPreferences = { + limit, + sort, + columns: JSON.parse(stringifiedActiveColumns), + }; + + setPreference(preferenceKey, newPreferences); + }, [sort, limit, stringifiedActiveColumns, setPreference, preferenceKey]); + + const setActiveColumns = useCallback((columns: string[]) => { + setTableColumns(buildColumns({ + collectionConfig: selectedCollectionConfig, + columns, + t, + onSelect, + })); + }, [selectedCollectionConfig, t, onSelect]); + + const onCreateNew = useCallback(({ doc }) => { + if (typeof onSelect === 'function') { + onSelect({ + docID: doc.id, + collectionConfig: selectedCollectionConfig, + }); + } + dispatchCacheBust(); + closeModal(documentDrawerSlug); + closeModal(drawerSlug); + }, [closeModal, documentDrawerSlug, drawerSlug, onSelect, selectedCollectionConfig]); + + if (!selectedCollectionConfig || isError) { + return null; + } + + return ( + + + +
+
+

+ {!customHeader ? getTranslation(selectedCollectionConfig?.labels?.plural, i18n) : customHeader} +

+ {hasCreatePermission && ( + + + {t('general:createNew')} + + + )} +
+ +
+ {selectedCollectionConfig?.admin?.description && ( +
+ +
+ )} + {moreThanOneAvailableCollection && ( +
+
+ )} + + ), + data, + limit: limit || selectedCollectionConfig?.admin?.pagination?.defaultLimit, + setLimit, + tableColumns, + setColumns: setActiveColumns, + setSort, + newDocumentURL: null, + hasCreatePermission, + columnNames: activeColumnNames, + disableEyebrow: true, + modifySearchParams: false, + onCardClick: (doc) => { + if (typeof onSelect === 'function') { + onSelect({ + docID: doc.id, + collectionConfig: selectedCollectionConfig, + }); + } + closeModal(drawerSlug); + }, + disableCardLink: true, + handleSortChange: setSort, + handleWhereChange: setWhere, + handlePageChange: setPage, + handlePerPageChange: setLimit, + }} + /> +
+ +
+ ); +}; diff --git a/src/admin/components/elements/ListDrawer/index.scss b/src/admin/components/elements/ListDrawer/index.scss new file mode 100644 index 0000000000..d4f14946b7 --- /dev/null +++ b/src/admin/components/elements/ListDrawer/index.scss @@ -0,0 +1,97 @@ +@import '../../../scss/styles.scss'; + +.list-drawer { + &__header { + margin-top: base(2.5); + width: 100%; + + @include mid-break { + margin-top: base(1.5); + } + } + + &__header-wrap { + display: flex; + } + + &__header-content { + display: flex; + flex-wrap: wrap; + flex-grow: 1; + align-items: flex-start; + + .pill { + pointer-events: none; + margin: 0; + margin-top: base(0.25); + margin-left: base(0.5); + + @include mid-break { + margin-top: 0; + } + } + } + + &__header-text { + margin: 0; + } + + &__toggler { + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + + &:focus, + &:focus-within { + outline: none; + } + + &:disabled { + pointer-events: none; + } + } + + &__header-close { + border: 0; + background-color: transparent; + padding: 0; + cursor: pointer; + overflow: hidden; + width: base(1); + height: base(1); + + svg { + width: base(2.75); + height: base(2.75); + position: relative; + left: base(-.825); + top: base(-.825); + + .stroke { + stroke-width: 2px; + vector-effect: non-scaling-stroke; + } + } + } + + &__select-collection-wrap { + margin-top: base(1); + } + + &__first-cell { + border: 0; + background-color: transparent; + padding: 0; + cursor: pointer; + text-decoration: underline; + text-align: left; + white-space: nowrap; + } + + @include mid-break { + .collection-list__header { + margin-bottom: base(0.5); + } + } +} diff --git a/src/admin/components/elements/ListDrawer/index.tsx b/src/admin/components/elements/ListDrawer/index.tsx new file mode 100644 index 0000000000..b4e585bfc8 --- /dev/null +++ b/src/admin/components/elements/ListDrawer/index.tsx @@ -0,0 +1,120 @@ +import React, { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { useModal } from '@faceless-ui/modal'; +import { ListDrawerProps, ListTogglerProps, UseListDrawer } from './types'; +import { Drawer, DrawerToggler } from '../Drawer'; +import { useEditDepth } from '../../utilities/EditDepth'; +import { ListDrawerContent } from './DrawerContent'; + +import './index.scss'; + +export const baseClass = 'list-drawer'; + +const formatListDrawerSlug = ({ + depth, + uuid, +}: { + depth: number, + uuid: string, // supply when creating a new document and no id is available +}) => `list-drawer_${depth}_${uuid}`; + +export const ListDrawerToggler: React.FC = ({ + children, + className, + drawerSlug, + disabled, + ...rest +}) => { + return ( + + {children} + + ); +}; + +export const ListDrawer: React.FC = (props) => { + const { drawerSlug } = props; + + return ( + + + + ); +}; + +export const useListDrawer: UseListDrawer = ({ collectionSlugs, uploads, selectedCollection }) => { + const drawerDepth = useEditDepth(); + const uuid = useId(); + const { modalState, toggleModal, closeModal, openModal } = useModal(); + const [isOpen, setIsOpen] = useState(false); + const drawerSlug = formatListDrawerSlug({ + depth: drawerDepth, + uuid, + }); + + useEffect(() => { + setIsOpen(Boolean(modalState[drawerSlug]?.isOpen)); + }, [modalState, drawerSlug]); + + const toggleDrawer = useCallback(() => { + toggleModal(drawerSlug); + }, [toggleModal, drawerSlug]); + + const closeDrawer = useCallback(() => { + closeModal(drawerSlug); + }, [drawerSlug, closeModal]); + + const openDrawer = useCallback(() => { + openModal(drawerSlug); + }, [drawerSlug, openModal]); + + const MemoizedDrawer = useMemo(() => { + return ((props) => ( + + )); + }, [drawerSlug, collectionSlugs, uploads, closeDrawer, selectedCollection]); + + const MemoizedDrawerToggler = useMemo(() => { + return ((props) => ( + + )); + }, [drawerSlug]); + + const MemoizedDrawerState = useMemo(() => ({ + drawerSlug, + drawerDepth, + isDrawerOpen: isOpen, + toggleDrawer, + closeDrawer, + openDrawer, + }), [drawerDepth, drawerSlug, isOpen, toggleDrawer, closeDrawer, openDrawer]); + + return [ + MemoizedDrawer, + MemoizedDrawerToggler, + MemoizedDrawerState, + ]; +}; diff --git a/src/admin/components/elements/ListDrawer/types.ts b/src/admin/components/elements/ListDrawer/types.ts new file mode 100644 index 0000000000..d210e019bf --- /dev/null +++ b/src/admin/components/elements/ListDrawer/types.ts @@ -0,0 +1,38 @@ +import React, { HTMLAttributes } from 'react'; +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; + +export type ListDrawerProps = { + onSelect?: (args: { + docID: string + collectionConfig: SanitizedCollectionConfig + }) => void + customHeader?: React.ReactNode + drawerSlug?: string + collectionSlugs?: string[] + uploads?: boolean + selectedCollection?: string +} + +export type ListTogglerProps = HTMLAttributes & { + children?: React.ReactNode + className?: string + drawerSlug?: string + disabled?: boolean +} + +export type UseListDrawer = (args: { + collectionSlugs?: string[] + selectedCollection?: string + uploads?: boolean // finds all collections with upload: true +}) => [ + React.FC>, // drawer + React.FC>, // toggler + { + drawerSlug: string, + drawerDepth: number + isDrawerOpen: boolean + toggleDrawer: () => void + closeDrawer: () => void + openDrawer: () => void + } +] diff --git a/src/admin/components/elements/PerPage/index.tsx b/src/admin/components/elements/PerPage/index.tsx index 70bf805a99..be7830f734 100644 --- a/src/admin/components/elements/PerPage/index.tsx +++ b/src/admin/components/elements/PerPage/index.tsx @@ -13,7 +13,7 @@ const baseClass = 'per-page'; const defaultLimits = defaults.admin.pagination.limits; -type Props = { +export type Props = { limits: number[] limit: number handleChange?: (limit: number) => void diff --git a/src/admin/components/elements/ReactSelect/MultiValueLabel/index.scss b/src/admin/components/elements/ReactSelect/MultiValueLabel/index.scss index 87113ecffb..643ccdb8e2 100644 --- a/src/admin/components/elements/ReactSelect/MultiValueLabel/index.scss +++ b/src/admin/components/elements/ReactSelect/MultiValueLabel/index.scss @@ -11,5 +11,6 @@ &__text { text-overflow: ellipsis; overflow: hidden; + white-space: nowrap; } } diff --git a/src/admin/components/elements/StepNav/index.scss b/src/admin/components/elements/StepNav/index.scss index 98421cd25f..c4a02cd059 100644 --- a/src/admin/components/elements/StepNav/index.scss +++ b/src/admin/components/elements/StepNav/index.scss @@ -2,6 +2,7 @@ .step-nav { display: flex; + overflow: auto; * { display: block; diff --git a/src/admin/components/elements/Tooltip/index.scss b/src/admin/components/elements/Tooltip/index.scss index 8187bc990d..090169dad7 100644 --- a/src/admin/components/elements/Tooltip/index.scss +++ b/src/admin/components/elements/Tooltip/index.scss @@ -38,4 +38,8 @@ $caretSize: 6; transition: opacity .2s ease-in-out; cursor: default; } + + @include mid-break { + display: none; + } } diff --git a/src/admin/components/elements/WhereBuilder/index.tsx b/src/admin/components/elements/WhereBuilder/index.tsx index 05262681c9..e18dcb19c8 100644 --- a/src/admin/components/elements/WhereBuilder/index.tsx +++ b/src/admin/components/elements/WhereBuilder/index.tsx @@ -145,7 +145,9 @@ const WhereBuilder: React.FC = (props) => { buttonStyle="icon-label" iconPosition="left" iconStyle="with-border" - onClick={() => dispatchConditions({ type: 'add', field: reducedFields[0].value })} + onClick={() => { + if (reducedFields.length > 0) dispatchConditions({ type: 'add', field: reducedFields[0].value }); + }} > {t('or')} @@ -160,7 +162,9 @@ const WhereBuilder: React.FC = (props) => { buttonStyle="icon-label" iconPosition="left" iconStyle="with-border" - onClick={() => dispatchConditions({ type: 'add', field: reducedFields[0].value })} + onClick={() => { + if (reducedFields.length > 0) dispatchConditions({ type: 'add', field: reducedFields[0].value }); + }} > {t('addFilter')} diff --git a/src/admin/components/forms/field-types/Relationship/AddNew/index.scss b/src/admin/components/forms/field-types/Relationship/AddNew/index.scss index ea75d441b8..fc2ebf0787 100644 --- a/src/admin/components/forms/field-types/Relationship/AddNew/index.scss +++ b/src/admin/components/forms/field-types/Relationship/AddNew/index.scss @@ -10,7 +10,8 @@ height: 100%; } - &__add-button { + &__add-button, + &__add-button.doc-drawer__toggler { @include formInput; position: relative; height: 100%; @@ -35,7 +36,6 @@ &__relation-button { @extend %btn-reset; cursor: pointer; - @extend %btn-reset; display: block; padding: base(.125) 0; text-align: center; diff --git a/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.scss b/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.scss index 0ab236ee20..66a9470d55 100644 --- a/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.scss +++ b/src/admin/components/forms/field-types/Relationship/select-components/MultiValueLabel/index.scss @@ -15,14 +15,11 @@ &__text { text-overflow: ellipsis; overflow: hidden; + white-space: nowrap; } &__drawer-toggler { position: relative; - background: none; - border: none; - cursor: pointer; - padding: 0; display: flex; align-items: center; justify-content: center; diff --git a/src/admin/components/forms/field-types/Relationship/select-components/SingleValue/index.scss b/src/admin/components/forms/field-types/Relationship/select-components/SingleValue/index.scss index f33e1a630a..0b29ca4f03 100644 --- a/src/admin/components/forms/field-types/Relationship/select-components/SingleValue/index.scss +++ b/src/admin/components/forms/field-types/Relationship/select-components/SingleValue/index.scss @@ -10,14 +10,17 @@ display: flex; align-items: center; overflow: visible; + width: 100%; + flex-shrink: 1; + } + + &__text { + overflow: hidden; + text-overflow: ellipsis; } &__drawer-toggler { position: relative; - background: none; - border: none; - cursor: pointer; - padding: 0; display: inline-flex; align-items: center; justify-content: center; diff --git a/src/admin/components/forms/field-types/RichText/buttons.scss b/src/admin/components/forms/field-types/RichText/buttons.scss index 15086b7e3b..c8fa81e6f1 100644 --- a/src/admin/components/forms/field-types/RichText/buttons.scss +++ b/src/admin/components/forms/field-types/RichText/buttons.scss @@ -1,6 +1,9 @@ @import '../../../../scss/styles.scss'; .rich-text__button { + position: relative; + cursor: pointer; + svg { width: base(.75); height: base(.75); diff --git a/src/admin/components/forms/field-types/RichText/elements/Button.tsx b/src/admin/components/forms/field-types/RichText/elements/Button.tsx index dbdf8d83b6..06c1fa2ada 100644 --- a/src/admin/components/forms/field-types/RichText/elements/Button.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/Button.tsx @@ -1,33 +1,54 @@ -import React, { useCallback } from 'react'; +import React, { ElementType, useCallback, useState } from 'react'; import { useSlate } from 'slate-react'; import isElementActive from './isActive'; import toggleElement from './toggle'; import { ButtonProps } from './types'; +import Tooltip from '../../../../elements/Tooltip'; import '../buttons.scss'; export const baseClass = 'rich-text__button'; -const ElementButton: React.FC = ({ format, children, onClick, className }) => { +const ElementButton: React.FC = (props) => { + const { + format, + children, + onClick, + className, + tooltip, + el = 'button', + } = props; + const editor = useSlate(); + const [showTooltip, setShowTooltip] = useState(false); const defaultOnClick = useCallback((event) => { event.preventDefault(); + setShowTooltip(false); toggleElement(editor, format); }, [editor, format]); + const Tag: ElementType = el; + return ( - + ); }; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx deleted file mode 100644 index a213f27303..0000000000 --- a/src/admin/components/forms/field-types/RichText/elements/link/Button.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { Fragment, useState } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; -import { Transforms, Editor, Range } from 'slate'; -import { useModal } from '@faceless-ui/modal'; -import { useTranslation } from 'react-i18next'; -import ElementButton from '../Button'; -import { unwrapLink } from './utilities'; -import LinkIcon from '../../../../../icons/Link'; -import { EditModal } from './Modal'; -import { modalSlug as baseModalSlug } from './shared'; -import isElementActive from '../isActive'; -import { Fields } from '../../../../Form/types'; -import buildStateFromSchema from '../../../../Form/buildStateFromSchema'; -import { useAuth } from '../../../../../utilities/Auth'; -import { useLocale } from '../../../../../utilities/Locale'; -import { useConfig } from '../../../../../utilities/Config'; -import { getBaseFields } from './Modal/baseFields'; -import { Field } from '../../../../../../../fields/config/types'; -import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues'; - -export const LinkButton = ({ fieldProps }) => { - const customFieldSchema = fieldProps?.admin?.link?.fields; - - const modalSlug = `${baseModalSlug}-${fieldProps.path}`; - - const { t } = useTranslation(); - const config = useConfig(); - const editor = useSlate(); - const { user } = useAuth(); - const locale = useLocale(); - const { toggleModal } = useModal(); - const [renderModal, setRenderModal] = useState(false); - const [initialState, setInitialState] = useState({}); - const [fieldSchema] = useState(() => { - const fields: Field[] = [ - ...getBaseFields(config), - ]; - - if (customFieldSchema) { - fields.push({ - name: 'fields', - type: 'group', - admin: { - style: { - margin: 0, - padding: 0, - borderTop: 0, - borderBottom: 0, - }, - }, - fields: customFieldSchema, - }); - } - - return fields; - }); - - return ( - - { - if (isElementActive(editor, 'link')) { - unwrapLink(editor); - } else { - toggleModal(modalSlug); - setRenderModal(true); - - const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); - - if (!isCollapsed) { - const data = { - text: editor.selection ? Editor.string(editor, editor.selection) : '', - }; - - const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'create', locale, t }); - setInitialState(state); - } - } - }} - > - - - {renderModal && ( - { - toggleModal(modalSlug); - setRenderModal(false); - }} - handleModalSubmit={(fields) => { - const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); - const data = reduceFieldsToValues(fields, true); - - const newLink = { - type: 'link', - linkType: data.linkType, - url: data.url, - doc: data.doc, - newTab: data.newTab, - fields: data.fields, - children: [], - }; - - if (isCollapsed || !editor.selection) { - // If selection anchor and focus are the same, - // Just inject a new node with children already set - Transforms.insertNodes(editor, { - ...newLink, - children: [{ text: String(data.text) }], - }); - } else if (editor.selection) { - // Otherwise we need to wrap the selected node in a link, - // Delete its old text, - // Move the selection one position forward into the link, - // And insert the text back into the new link - Transforms.wrapNodes(editor, newLink, { split: true }); - Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' }); - Transforms.move(editor, { distance: 1, unit: 'offset' }); - Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); - } - - toggleModal(modalSlug); - setRenderModal(false); - - ReactEditor.focus(editor); - }} - /> - )} - - ); -}; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Button/index.tsx new file mode 100644 index 0000000000..c1d97f20f0 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/Button/index.tsx @@ -0,0 +1,125 @@ +import React, { Fragment, useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactEditor, useSlate } from 'slate-react'; +import { Transforms, Range } from 'slate'; +import { useModal } from '@faceless-ui/modal'; +import ElementButton from '../../Button'; +import LinkIcon from '../../../../../../icons/Link'; +import reduceFieldsToValues from '../../../../../Form/reduceFieldsToValues'; +import { useConfig } from '../../../../../../utilities/Config'; +import isElementActive from '../../isActive'; +import { unwrapLink } from '../utilities'; +import { useEditDepth } from '../../../../../../utilities/EditDepth'; +import { formatDrawerSlug } from '../../../../../../elements/Drawer'; +import { getBaseFields } from '../LinkDrawer/baseFields'; +import { LinkDrawer } from '../LinkDrawer'; +import { Field } from '../../../../../../../../fields/config/types'; +import { Props as RichTextFieldProps } from '../../../types'; + +const insertLink = (editor, fields) => { + const isCollapsed = editor.selection && Range.isCollapsed(editor.selection); + const data = reduceFieldsToValues(fields, true); + + const newLink = { + type: 'link', + linkType: data.linkType, + url: data.url, + doc: data.doc, + newTab: data.newTab, + fields: data.fields, + children: [], + }; + + if (isCollapsed || !editor.selection) { + // If selection anchor and focus are the same, + // Just inject a new node with children already set + Transforms.insertNodes(editor, { + ...newLink, + children: [{ text: String(data.text) }], + }); + } else if (editor.selection) { + // Otherwise we need to wrap the selected node in a link, + // Delete its old text, + // Move the selection one position forward into the link, + // And insert the text back into the new link + Transforms.wrapNodes(editor, newLink, { split: true }); + Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' }); + Transforms.move(editor, { distance: 1, unit: 'offset' }); + Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); + } + + ReactEditor.focus(editor); +}; + +export const LinkButton: React.FC<{ + path: string + fieldProps: RichTextFieldProps +}> = ({ fieldProps }) => { + const customFieldSchema = fieldProps?.admin?.link?.fields; + + const { t } = useTranslation(['upload', 'general']); + const editor = useSlate(); + const config = useConfig(); + + const [fieldSchema] = useState(() => { + const fields: Field[] = [ + ...getBaseFields(config), + ]; + + if (customFieldSchema) { + fields.push({ + name: 'fields', + type: 'group', + admin: { + style: { + margin: 0, + padding: 0, + borderTop: 0, + borderBottom: 0, + }, + }, + fields: customFieldSchema, + }); + } + + return fields; + }); + + const { openModal, closeModal } = useModal(); + const uuid = useId(); + const editDepth = useEditDepth(); + const drawerSlug = formatDrawerSlug({ + slug: `rich-text-link-${uuid}`, + depth: editDepth, + }); + + return ( + + { + if (isElementActive(editor, 'link')) { + unwrapLink(editor); + } else { + openModal(drawerSlug); + } + }} + > + + + { + insertLink(editor, fields); + closeModal(drawerSlug); + }} + fieldSchema={fieldSchema} + handleClose={() => { + closeModal(drawerSlug); + }} + /> + + ); +}; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/Element/index.scss similarity index 79% rename from src/admin/components/forms/field-types/RichText/elements/link/index.scss rename to src/admin/components/forms/field-types/RichText/elements/link/Element/index.scss index 9644067cd7..5954bf8fac 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/link/Element/index.scss @@ -1,4 +1,4 @@ -@import '../../../../../../scss/styles.scss'; +@import '../../../../../../../scss/styles.scss'; .rich-text-link { position: relative; @@ -11,7 +11,6 @@ bottom: 0; left: 0; - .popup__scroll, .popup__wrap { overflow: visible; @@ -52,18 +51,19 @@ } } -.rich-text-link__button { - @extend %btn-reset; - font-family: inherit; - font-size: inherit; - font-weight: inherit; - color: inherit; - letter-spacing: inherit; - line-height: inherit; +.rich-text-link__popup-toggler { position: relative; + border: 0; + background-color: transparent; + padding: 0; text-decoration: underline; cursor: text; + &:focus, + &:focus-within { + outline: none; + } + &--open { z-index: var(--z-popup); } diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx b/src/admin/components/forms/field-types/RichText/elements/link/Element/index.tsx similarity index 63% rename from src/admin/components/forms/field-types/RichText/elements/link/Element.tsx rename to src/admin/components/forms/field-types/RichText/elements/link/Element/index.tsx index 93f23c5af5..a17614cfd4 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Element.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/Element/index.tsx @@ -1,31 +1,73 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { HTMLAttributes, useCallback, useEffect, useId, useState } from 'react'; import { ReactEditor, useSlate } from 'slate-react'; import { Transforms, Node, Editor } from 'slate'; import { useModal } from '@faceless-ui/modal'; import { Trans, useTranslation } from 'react-i18next'; -import { unwrapLink } from './utilities'; -import Popup from '../../../../../elements/Popup'; -import { EditModal } from './Modal'; -import { modalSlug as baseModalSlug } from './shared'; -import { Fields } from '../../../../Form/types'; -import buildStateFromSchema from '../../../../Form/buildStateFromSchema'; -import { useAuth } from '../../../../../utilities/Auth'; -import { useLocale } from '../../../../../utilities/Locale'; -import { useConfig } from '../../../../../utilities/Config'; -import { getBaseFields } from './Modal/baseFields'; -import { Field } from '../../../../../../../fields/config/types'; -import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues'; -import deepCopyObject from '../../../../../../../utilities/deepCopyObject'; -import Button from '../../../../../elements/Button'; -import { getTranslation } from '../../../../../../../utilities/getTranslation'; - +import { unwrapLink } from '../utilities'; +import Popup from '../../../../../../elements/Popup'; +import { LinkDrawer } from '../LinkDrawer'; +import { Fields } from '../../../../../Form/types'; +import buildStateFromSchema from '../../../../../Form/buildStateFromSchema'; +import { useAuth } from '../../../../../../utilities/Auth'; +import { useLocale } from '../../../../../../utilities/Locale'; +import { useConfig } from '../../../../../../utilities/Config'; +import { getBaseFields } from '../LinkDrawer/baseFields'; +import { Field } from '../../../../../../../../fields/config/types'; +import reduceFieldsToValues from '../../../../../Form/reduceFieldsToValues'; +import deepCopyObject from '../../../../../../../../utilities/deepCopyObject'; +import Button from '../../../../../../elements/Button'; +import { getTranslation } from '../../../../../../../../utilities/getTranslation'; +import { useEditDepth } from '../../../../../../utilities/EditDepth'; +import { formatDrawerSlug } from '../../../../../../elements/Drawer'; +import { Props as RichTextFieldProps } from '../../../types'; import './index.scss'; const baseClass = 'rich-text-link'; -// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text +const insertChange = (editor, fields, customFieldSchema) => { + const data = reduceFieldsToValues(fields, true); + + const [, parentPath] = Editor.above(editor); + + const newNode: Record = { + newTab: data.newTab, + url: data.url, + linkType: data.linkType, + doc: data.doc, + }; + + if (customFieldSchema) { + newNode.fields = data.fields; + } + + Transforms.setNodes( + editor, + newNode, + { at: parentPath }, + ); + + Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' }); + Transforms.move(editor, { distance: 1, unit: 'offset' }); + Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); + + ReactEditor.focus(editor); +}; + +export const LinkElement: React.FC<{ + attributes: HTMLAttributes + children: React.ReactNode + element: any + fieldProps: RichTextFieldProps + editorRef: React.RefObject +}> = (props) => { + const { + attributes, + children, + element, + editorRef, + fieldProps, + } = props; -export const LinkElement = ({ attributes, children, element, editorRef, fieldProps }) => { const customFieldSchema = fieldProps?.admin?.link?.fields; const editor = useSlate(); @@ -33,7 +75,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro const { user } = useAuth(); const locale = useLocale(); const { t, i18n } = useTranslation('fields'); - const { openModal, toggleModal } = useModal(); + const { openModal, toggleModal, closeModal } = useModal(); const [renderModal, setRenderModal] = useState(false); const [renderPopup, setRenderPopup] = useState(false); const [initialState, setInitialState] = useState({}); @@ -61,7 +103,13 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro return fields; }); - const modalSlug = `${baseModalSlug}-${fieldProps.path}`; + const uuid = useId(); + const editDepth = useEditDepth(); + + const drawerSlug = formatDrawerSlug({ + slug: `rich-text-link-${uuid}`, + depth: editDepth, + }); const handleTogglePopup = useCallback((render) => { if (!render) { @@ -97,43 +145,16 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro contentEditable={false} > {renderModal && ( - { - toggleModal(modalSlug); + handleClose={() => { + toggleModal(drawerSlug); setRenderModal(false); }} handleModalSubmit={(fields) => { - toggleModal(modalSlug); - setRenderModal(false); - - const data = reduceFieldsToValues(fields, true); - - const [, parentPath] = Editor.above(editor); - - const newNode: Record = { - newTab: data.newTab, - url: data.url, - linkType: data.linkType, - doc: data.doc, - }; - - if (customFieldSchema) { - newNode.fields = data.fields; - } - - Transforms.setNodes( - editor, - newNode, - { at: parentPath }, - ); - - Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' }); - Transforms.move(editor, { distance: 1, unit: 'offset' }); - Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path }); - - ReactEditor.focus(editor); + insertChange(editor, fields, customFieldSchema); + closeModal(drawerSlug); }} initialState={initialState} /> @@ -181,7 +202,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro onClick={(e) => { e.preventDefault(); setRenderPopup(false); - openModal(modalSlug); + openModal(drawerSlug); setRenderModal(true); }} tooltip={t('general:edit')} @@ -205,7 +226,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro tabIndex={0} role="button" className={[ - `${baseClass}__button`, + `${baseClass}__popup-toggler`, ].filter(Boolean).join(' ')} onKeyDown={(e) => { if (e.key === 'Enter') setRenderPopup(true); }} onClick={() => setRenderPopup(true)} diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/baseFields.ts similarity index 100% rename from src/admin/components/forms/field-types/RichText/elements/link/Modal/baseFields.ts rename to src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/baseFields.ts diff --git a/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.scss new file mode 100644 index 0000000000..44ce6af65c --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.scss @@ -0,0 +1,50 @@ +@import '../../../../../../../scss/styles.scss'; + +.rich-text-link-edit-modal { + &__template { + position: relative; + z-index: 1; + padding-top: base(1); + padding-bottom: base(2); + } + + &__header { + width: 100%; + margin-bottom: $baseline; + display: flex; + justify-content: space-between; + margin-top: base(2.5); + margin-bottom: base(1); + + @include mid-break { + margin-top: base(1.5); + } + } + + &__header-text { + margin: 0; + } + + &__header-close { + border: 0; + background-color: transparent; + padding: 0; + cursor: pointer; + overflow: hidden; + width: base(1); + height: base(1); + + svg { + width: base(2.75); + height: base(2.75); + position: relative; + left: base(-.825); + top: base(-.825); + + .stroke { + stroke-width: 2px; + vector-effect: non-scaling-stroke; + } + } + } +} diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx similarity index 68% rename from src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx rename to src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx index 44f5f5bcc6..85bbe74f0a 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/index.tsx @@ -1,7 +1,6 @@ -import { Modal } from '@faceless-ui/modal'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { MinimalTemplate } from '../../../../../..'; +import { Drawer } from '../../../../../../elements/Drawer'; import Button from '../../../../../../elements/Button'; import X from '../../../../../../icons/X'; import Form from '../../../../../Form'; @@ -11,29 +10,34 @@ import fieldTypes from '../../../..'; import RenderFields from '../../../../../RenderFields'; import './index.scss'; +import { Gutter } from '../../../../../../elements/Gutter'; const baseClass = 'rich-text-link-edit-modal'; -export const EditModal: React.FC = ({ - close, +export const LinkDrawer: React.FC = ({ + handleClose, handleModalSubmit, initialState, fieldSchema, - modalSlug, + drawerSlug, }) => { const { t } = useTranslation('fields'); return ( - - +
-

{t('editLink')}

+

+ {t('editLink')} +

@@ -52,7 +56,7 @@ export const EditModal: React.FC = ({ {t('general:submit')} - - + + ); }; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/types.ts similarity index 85% rename from src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts rename to src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/types.ts index b0b9398a2e..4b019cecfe 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/types.ts +++ b/src/admin/components/forms/field-types/RichText/elements/link/LinkDrawer/types.ts @@ -2,8 +2,8 @@ import { Field } from '../../../../../../../../fields/config/types'; import { Fields } from '../../../../../Form/types'; export type Props = { - modalSlug: string - close: () => void + drawerSlug: string + handleClose: () => void handleModalSubmit: (fields: Fields, data: Record) => void initialState?: Fields fieldSchema: Field[] diff --git a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss b/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss deleted file mode 100644 index 8d2e1d4c9f..0000000000 --- a/src/admin/components/forms/field-types/RichText/elements/link/Modal/index.scss +++ /dev/null @@ -1,29 +0,0 @@ -@import '../../../../../../../scss/styles.scss'; - -.rich-text-link-edit-modal { - @include blur-bg; - display: flex; - align-items: center; - height: 100%; - - &__template { - position: relative; - z-index: 1; - } - - &__header { - width: 100%; - margin-bottom: $baseline; - display: flex; - justify-content: space-between; - - h3 { - margin: 0; - } - - svg { - width: base(1.5); - height: base(1.5); - } - } -} \ No newline at end of file diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.scss b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.scss index 0208156288..480ac0bc15 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.scss @@ -1,35 +1,7 @@ @import '../../../../../../../scss/styles.scss'; .relationship-rich-text-button { - .btn { - margin-right: base(1); - } - - &__modal { - @include blur-bg; - display: flex; - align-items: center; - height: 100%; - } - - &__modal-template { - position: relative; - z-index: 1; - } - - &__header { - width: 100%; - margin-bottom: $baseline; - display: flex; - justify-content: space-between; - - h3 { - margin: 0; - } - - svg { - width: base(1.5); - height: base(1.5); - } - } + display: flex; + align-items: center; + height: 100%; } diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx index 961f2e6c02..b6a7ab342d 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx @@ -1,23 +1,14 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'; -import { Modal, useModal } from '@faceless-ui/modal'; import { ReactEditor, useSlate } from 'slate-react'; import { useTranslation } from 'react-i18next'; import { useConfig } from '../../../../../../utilities/Config'; import ElementButton from '../../Button'; import RelationshipIcon from '../../../../../../icons/Relationship'; -import Form from '../../../../../Form'; -import MinimalTemplate from '../../../../../../templates/Minimal'; -import Button from '../../../../../../elements/Button'; -import Submit from '../../../../../Submit'; -import X from '../../../../../../icons/X'; -import Fields from './Fields'; -import { requests } from '../../../../../../../api'; import { injectVoidElement } from '../../injectVoid'; +import { useListDrawer } from '../../../../../../elements/ListDrawer'; import './index.scss'; -const initialFormData = {}; - const baseClass = 'relationship-rich-text-button'; const insertRelationship = (editor, { value, relationTo }) => { @@ -37,80 +28,58 @@ const insertRelationship = (editor, { value, relationTo }) => { ReactEditor.focus(editor); }; -const RelationshipButton: React.FC<{ path: string }> = ({ path }) => { - const { toggleModal } = useModal(); +const RelationshipButton: React.FC<{ path: string }> = () => { + const { collections } = useConfig(); + const { t } = useTranslation('fields'); const editor = useSlate(); - const { serverURL, routes: { api }, collections } = useConfig(); - const [renderModal, setRenderModal] = useState(false); - const [loading, setLoading] = useState(false); - const { t, i18n } = useTranslation('fields'); - const [hasEnabledCollections] = useState(() => collections.find(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship)); - const modalSlug = `${path}-add-relationship`; + const [enabledCollectionSlugs] = useState(() => collections.filter(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship).map(({ slug }) => slug)); + const [selectedCollectionSlug, setSelectedCollectionSlug] = useState(() => enabledCollectionSlugs[0]); + const [ + ListDrawer, + ListDrawerToggler, + { + closeDrawer, + isDrawerOpen, + }, + ] = useListDrawer({ + collectionSlugs: enabledCollectionSlugs, + selectedCollection: selectedCollectionSlug, + }); - const handleAddRelationship = useCallback(async (_, { relationTo, value }) => { - setLoading(true); - - const res = await requests.get(`${serverURL}${api}/${relationTo}/${value}?depth=0`, { - headers: { - 'Accept-Language': i18n.language, + const onSelect = useCallback(({ docID, collectionConfig }) => { + insertRelationship(editor, { + value: { + id: docID, }, + relationTo: collectionConfig.slug, }); - const json = await res.json(); - - insertRelationship(editor, { value: { id: json.id }, relationTo }); - toggleModal(modalSlug); - setRenderModal(false); - setLoading(false); - }, [i18n.language, editor, toggleModal, modalSlug, api, serverURL]); + closeDrawer(); + }, [editor, closeDrawer]); useEffect(() => { - if (renderModal) { - toggleModal(modalSlug); - } - }, [renderModal, toggleModal, modalSlug]); + // always reset back to first option + // TODO: this is not working, see the ListDrawer component + setSelectedCollectionSlug(enabledCollectionSlugs[0]); + }, [isDrawerOpen, enabledCollectionSlugs]); - if (!hasEnabledCollections) return null; + if (!enabledCollectionSlugs || enabledCollectionSlugs.length === 0) return null; return ( - setRenderModal(true)} - > - - - {renderModal && ( - + { + // do nothing + }} > - -
-

{t('addRelationship')}

- -
-
- - - {t('addRelationship')} - - -
-
- )} + + + +
); }; diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.scss b/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.scss index 5b60526afb..02c1fa02e3 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.scss @@ -3,9 +3,9 @@ .rich-text-relationship { @extend %body; @include shadow-sm; - padding: base(.5); + padding: base(.75); display: flex; - align-items: flex-start; + align-items: center; background: var(--theme-input-bg); border: 1px solid var(--theme-elevation-100); max-width: base(15); @@ -19,20 +19,77 @@ margin: base(.625) 0; } - svg { - width: base(1.25); - height: base(1.25); - margin-right: base(.5); + &__label { + margin-bottom: base(0.25); } - h5 { + &__title { + margin: 0; + } + + &__label, + &__title { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; + line-height: 1 !important; + } + + &__title { + font-weight: bold; + } + + &__wrap { + flex-grow: 1; + overflow: hidden; } &--selected { box-shadow: $focus-box-shadow; outline: none; } + + &__toggler { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + + &:focus, + &:focus-within { + outline: none; + } + + &:disabled { + pointer-events: none; + } + } + + &__actions { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: base(0.5); + + & > *:not(:last-child) { + margin-right: base(.25); + } + } + + &__actionButton { + margin: 0; + border-radius: 0; + flex-shrink: 0; + width: base(1); + height: base(1); + + line { + stroke-width: $style-stroke-width-m; + } + + &:disabled { + opacity: 0.5; + pointer-events: none; + } + } } diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.tsx b/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.tsx index d8054962fe..3caf46e457 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Element/index.tsx @@ -1,9 +1,13 @@ -import React, { useState } from 'react'; -import { useFocused, useSelected } from 'slate-react'; +import React, { HTMLAttributes, useCallback, useReducer, useState } from 'react'; +import { ReactEditor, useFocused, useSelected, useSlateStatic } from 'slate-react'; import { useTranslation } from 'react-i18next'; +import { Transforms } from 'slate'; import { useConfig } from '../../../../../../utilities/Config'; -import RelationshipIcon from '../../../../../../icons/Relationship'; import usePayloadAPI from '../../../../../../../hooks/usePayloadAPI'; +import { useDocumentDrawer } from '../../../../../../elements/DocumentDrawer'; +import Button from '../../../../../../elements/Button'; +import { useListDrawer } from '../../../../../../elements/ListDrawer'; +import { Props as RichTextProps } from '../../../types'; import { getTranslation } from '../../../../../../../../utilities/getTranslation'; import './index.scss'; @@ -14,20 +18,115 @@ const initialParams = { depth: 0, }; -const Element = (props) => { - const { attributes, children, element } = props; +const Element: React.FC<{ + attributes: HTMLAttributes + children: React.ReactNode + element: any + fieldProps: RichTextProps +}> = (props) => { + const { + attributes, + children, + element, + fieldProps, + } = props; const { relationTo, value } = element; const { collections, serverURL, routes: { api } } = useConfig(); - const [relatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo)); + const [enabledCollectionSlugs] = useState(() => collections.filter(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship).map(({ slug }) => slug)); + const [relatedCollection, setRelatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo)); const selected = useSelected(); const focused = useFocused(); - const { t, i18n } = useTranslation('fields'); - - const [{ data }] = usePayloadAPI( + const { t, i18n } = useTranslation(['fields', 'general']); + const editor = useSlateStatic(); + const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0); + const [{ data }, { setParams }] = usePayloadAPI( `${serverURL}${api}/${relatedCollection.slug}/${value?.id}`, { initialParams }, ); + const [ + DocumentDrawer, + DocumentDrawerToggler, + { + closeDrawer, + }, + ] = useDocumentDrawer({ + collectionSlug: relatedCollection.slug, + id: value?.id, + }); + + const [ + ListDrawer, + ListDrawerToggler, + { + closeDrawer: closeListDrawer, + }, + ] = useListDrawer({ + collectionSlugs: enabledCollectionSlugs, + selectedCollection: relatedCollection.slug, + }); + + const removeRelationship = useCallback(() => { + const elementPath = ReactEditor.findPath(editor, element); + + Transforms.removeNodes( + editor, + { at: elementPath }, + ); + }, [editor, element]); + + const updateRelationship = React.useCallback(({ doc }) => { + const elementPath = ReactEditor.findPath(editor, element); + + Transforms.setNodes( + editor, + { + type: 'relationship', + value: { id: doc.id }, + relationTo: relatedCollection.slug, + children: [ + { text: ' ' }, + ], + }, + { at: elementPath }, + ); + + setParams({ + ...initialParams, + cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed + }); + + closeDrawer(); + dispatchCacheBust(); + }, [editor, element, relatedCollection, cacheBust, setParams, closeDrawer]); + + const swapRelationship = React.useCallback(({ docID, collectionConfig }) => { + const elementPath = ReactEditor.findPath(editor, element); + + Transforms.setNodes( + editor, + { + type: 'relationship', + value: { id: docID }, + relationTo: collectionConfig.slug, + children: [ + { text: ' ' }, + ], + }, + { at: elementPath }, + ); + + setRelatedCollection(collections.find((coll) => coll.slug === collectionConfig.slug)); + + setParams({ + ...initialParams, + cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed + }); + + closeListDrawer(); + dispatchCacheBust(); + }, [closeListDrawer, editor, element, cacheBust, setParams, collections]); + return (
{ contentEditable={false} {...attributes} > -
-
+

{t('labelRelationship', { label: getTranslation(relatedCollection.labels.singular, i18n) })} -

-
{data[relatedCollection?.admin?.useAsTitle || 'id']}
+

+

+ {data[relatedCollection?.admin?.useAsTitle || 'id']} +

+
+ {value?.id && ( + +
+ {value?.id && ( + + )} + {children}
); diff --git a/src/admin/components/forms/field-types/RichText/elements/types.ts b/src/admin/components/forms/field-types/RichText/elements/types.ts index a1cff922fa..b8a351577f 100644 --- a/src/admin/components/forms/field-types/RichText/elements/types.ts +++ b/src/admin/components/forms/field-types/RichText/elements/types.ts @@ -1,6 +1,10 @@ +import { ElementType } from 'react'; + export type ButtonProps = { format: string onClick?: (e: React.MouseEvent) => void className?: string children?: React.ReactNode + tooltip?: string + el?: ElementType } diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.scss b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.scss index 83950cb931..4b44e277ac 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.scss @@ -1,7 +1,7 @@ @import '../../../../../../../scss/styles.scss'; .upload-rich-text-button { - .btn { - margin-right: base(1); - } + display: flex; + align-items: center; + height: 100%; } diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx index 2bf6abed1f..bae8944d41 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Button/index.tsx @@ -1,29 +1,14 @@ -import React, { Fragment, useEffect, useState } from 'react'; -import { Modal, useModal } from '@faceless-ui/modal'; -import { ReactEditor, useSlate } from 'slate-react'; +import React, { Fragment, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '../../../../../../utilities/Config'; +import { ReactEditor, useSlate } from 'slate-react'; import ElementButton from '../../Button'; import UploadIcon from '../../../../../../icons/Upload'; -import usePayloadAPI from '../../../../../../../hooks/usePayloadAPI'; -import UploadGallery from '../../../../../../elements/UploadGallery'; -import ListControls from '../../../../../../elements/ListControls'; -import ReactSelect from '../../../../../../elements/ReactSelect'; -import Paginator from '../../../../../../elements/Paginator'; -import formatFields from '../../../../../../views/collections/List/formatFields'; -import Label from '../../../../../Label'; -import MinimalTemplate from '../../../../../../templates/Minimal'; -import Button from '../../../../../../elements/Button'; -import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types'; -import PerPage from '../../../../../../elements/PerPage'; +import { useListDrawer } from '../../../../../../elements/ListDrawer'; import { injectVoidElement } from '../../injectVoid'; -import { getTranslation } from '../../../../../../../../utilities/getTranslation'; import './index.scss'; -import '../addSwapModals.scss'; const baseClass = 'upload-rich-text-button'; -const baseModalClass = 'rich-text-upload-modal'; const insertUpload = (editor, { value, relationTo }) => { const text = { text: ' ' }; @@ -42,178 +27,48 @@ const insertUpload = (editor, { value, relationTo }) => { ReactEditor.focus(editor); }; -const UploadButton: React.FC<{ path: string }> = ({ path }) => { - const { t, i18n } = useTranslation('upload'); - const { toggleModal, isModalOpen } = useModal(); +const UploadButton: React.FC<{ + path: string +}> = () => { + const { t } = useTranslation(['upload', 'general']); const editor = useSlate(); - const { serverURL, routes: { api }, collections } = useConfig(); - const [availableCollections] = useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship))); - const [renderModal, setRenderModal] = useState(false); - const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string }>(() => { - const firstAvailableCollection = collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)); - if (firstAvailableCollection) { - return { label: getTranslation(firstAvailableCollection.labels.singular, i18n), value: firstAvailableCollection.slug }; - } - return undefined; + const [ + ListDrawer, + ListDrawerToggler, + { + closeDrawer, + }, + ] = useListDrawer({ + uploads: true, }); - const [modalCollection, setModalCollection] = useState(() => collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship))); - const [fields, setFields] = useState(() => (modalCollection ? formatFields(modalCollection, t) : undefined)); - const [limit, setLimit] = useState(); - const [sort, setSort] = useState(null); - const [where, setWhere] = useState(null); - const [page, setPage] = useState(null); - const modalSlug = `${path}-add-upload`; - const moreThanOneAvailableCollection = availableCollections.length > 1; - const isOpen = isModalOpen(modalSlug); - - // If modal is open, get active page of upload gallery - const apiURL = isOpen ? `${serverURL}${api}/${modalCollection.slug}` : null; - const [{ data }, { setParams }] = usePayloadAPI(apiURL, {}); - - useEffect(() => { - if (modalCollection) { - setFields(formatFields(modalCollection, t)); - } - }, [modalCollection, t]); - - useEffect(() => { - if (renderModal) { - toggleModal(modalSlug); - } - }, [renderModal, toggleModal, modalSlug]); - - useEffect(() => { - const params: { - page?: number - sort?: string - where?: unknown - limit?: number - } = {}; - - if (page) params.page = page; - if (where) params.where = where; - if (sort) params.sort = sort; - if (limit) params.limit = limit; - - setParams(params); - }, [setParams, page, sort, where, limit]); - - useEffect(() => { - if (modalCollectionOption) { - setModalCollection(collections.find(({ slug }) => modalCollectionOption.value === slug)); - } - }, [modalCollectionOption, collections]); - - if (!modalCollection) { - return null; - } + const onSelect = useCallback(({ docID, collectionConfig }) => { + insertUpload(editor, { + value: { + id: docID, + }, + relationTo: collectionConfig.slug, + }); + closeDrawer(); + }, [editor, closeDrawer]); return ( - setRenderModal(true)} - > - - - {renderModal && ( - + { + // do nothing + }} > - {isOpen && ( - -
-

- {t('fields:addLabel', { label: getTranslation(modalCollection.labels.singular, i18n) })} -

-
- {moreThanOneAvailableCollection && ( -
-
- )} - - { - insertUpload(editor, { - value: { - id: doc.id, - }, - relationTo: modalCollection.slug, - }); - setRenderModal(false); - toggleModal(modalSlug); - }} - /> -
- - {data?.totalDocs > 0 && ( - -
- {data.page} - - - {data.totalPages > 1 ? data.limit : data.totalDocs} - {' '} - {t('general:of')} - {' '} - {data.totalDocs} -
- -
- )} -
-
- )} -
- )} + + + +
); }; diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.scss b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.scss deleted file mode 100644 index 5004533bd1..0000000000 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.scss +++ /dev/null @@ -1,31 +0,0 @@ -@import '../../../../../../../../scss/styles.scss'; - -.edit-upload-modal { - @include blur-bg; - display: flex; - align-items: center; - - .template-minimal { - padding-top: base(4); - align-items: flex-start; - position: relative; - z-index: 1; - } - - &__header { - margin-bottom: $baseline; - display: flex; - - h1 { - margin: 0 auto 0 0; - } - - .btn { - margin: 0 0 0 $baseline; - } - } - - .rich-text__toolbar { - top: base(1); - } -} diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx deleted file mode 100644 index 4b38f9945c..0000000000 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Transforms, Element } from 'slate'; -import { ReactEditor, useSlateStatic } from 'slate-react'; -import { Modal } from '@faceless-ui/modal'; -import { useTranslation } from 'react-i18next'; -import { useAuth } from '../../../../../../../utilities/Auth'; -import { SanitizedCollectionConfig } from '../../../../../../../../../collections/config/types'; -import buildStateFromSchema from '../../../../../../Form/buildStateFromSchema'; -import MinimalTemplate from '../../../../../../../templates/Minimal'; -import Button from '../../../../../../../elements/Button'; -import RenderFields from '../../../../../../RenderFields'; -import fieldTypes from '../../../../..'; -import Form from '../../../../../../Form'; -import Submit from '../../../../../../Submit'; -import { Field } from '../../../../../../../../../fields/config/types'; -import { useLocale } from '../../../../../../../utilities/Locale'; -import { getTranslation } from '../../../../../../../../../utilities/getTranslation'; - -import './index.scss'; - -const baseClass = 'edit-upload-modal'; - -type Props = { - slug: string - closeModal: () => void - relatedCollectionConfig: SanitizedCollectionConfig - fieldSchema: Field[] - element: Element & { - fields: Field[] - } -} -export const EditModal: React.FC = ({ slug, closeModal, relatedCollectionConfig, fieldSchema, element }) => { - const editor = useSlateStatic(); - const [initialState, setInitialState] = useState({}); - const { user } = useAuth(); - const locale = useLocale(); - const { t, i18n } = useTranslation('fields'); - - const handleUpdateEditData = useCallback((_, data) => { - const newNode = { - fields: data, - }; - - const elementPath = ReactEditor.findPath(editor, element); - - Transforms.setNodes( - editor, - newNode, - { at: elementPath }, - ); - closeModal(); - }, [closeModal, editor, element]); - - useEffect(() => { - const awaitInitialState = async () => { - const state = await buildStateFromSchema({ fieldSchema, data: { ...element?.fields || {} }, user, operation: 'update', locale, t }); - setInitialState(state); - }; - - awaitInitialState(); - }, [fieldSchema, element.fields, user, locale, t]); - - return ( - - -
-

- { t('editLabelData', { label: getTranslation(relatedCollectionConfig.labels.singular, i18n) }) } -

-
- -
-
- - - {t('saveChanges')} - - -
-
-
- ); -}; diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.tsx deleted file mode 100644 index f37973b2bf..0000000000 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/SwapUploadModal/index.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import * as React from 'react'; -import { Modal } from '@faceless-ui/modal'; -import { Element, Transforms } from 'slate'; -import { ReactEditor, useSlateStatic } from 'slate-react'; -import { useTranslation } from 'react-i18next'; -import { useConfig } from '../../../../../../../utilities/Config'; -import { SanitizedCollectionConfig } from '../../../../../../../../../collections/config/types'; -import usePayloadAPI from '../../../../../../../../hooks/usePayloadAPI'; -import MinimalTemplate from '../../../../../../../templates/Minimal'; -import Button from '../../../../../../../elements/Button'; -import Label from '../../../../../../Label'; -import ReactSelect from '../../../../../../../elements/ReactSelect'; -import ListControls from '../../../../../../../elements/ListControls'; -import UploadGallery from '../../../../../../../elements/UploadGallery'; -import Paginator from '../../../../../../../elements/Paginator'; -import PerPage from '../../../../../../../elements/PerPage'; -import formatFields from '../../../../../../../views/collections/List/formatFields'; -import { getTranslation } from '../../../../../../../../../utilities/getTranslation'; - -import '../../addSwapModals.scss'; - -const baseClass = 'rich-text-upload-modal'; - -type Props = { - slug: string - element: Element - closeModal: () => void - setRelatedCollectionConfig: (collectionConfig: SanitizedCollectionConfig) => void - relatedCollectionConfig: SanitizedCollectionConfig -} -export const SwapUploadModal: React.FC = ({ closeModal, element, setRelatedCollectionConfig, relatedCollectionConfig, slug }) => { - const { collections, serverURL, routes: { api } } = useConfig(); - const editor = useSlateStatic(); - const { t, i18n } = useTranslation('upload'); - - const [modalCollection, setModalCollection] = React.useState(relatedCollectionConfig); - const [modalCollectionOption, setModalCollectionOption] = React.useState<{ label: string, value: string }>({ label: getTranslation(relatedCollectionConfig.labels.singular, i18n), value: relatedCollectionConfig.slug }); - const [availableCollections] = React.useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship))); - const [fields, setFields] = React.useState(() => formatFields(modalCollection, t)); - - const [limit, setLimit] = React.useState(); - const [sort, setSort] = React.useState(null); - const [where, setWhere] = React.useState(null); - const [page, setPage] = React.useState(null); - - const moreThanOneAvailableCollection = availableCollections.length > 1; - - const apiURL = `${serverURL}${api}/${modalCollection.slug}`; - const [{ data }, { setParams }] = usePayloadAPI(apiURL, {}); - - const handleUpdateUpload = React.useCallback((doc) => { - const newNode = { - type: 'upload', - value: { id: doc.id }, - relationTo: modalCollection.slug, - children: [ - { text: ' ' }, - ], - }; - - const elementPath = ReactEditor.findPath(editor, element); - - Transforms.setNodes( - editor, - newNode, - { at: elementPath }, - ); - closeModal(); - }, [closeModal, editor, element, modalCollection]); - - React.useEffect(() => { - const params: { - page?: number - sort?: string - where?: unknown - limit?: number - } = {}; - - if (page) params.page = page; - if (where) params.where = where; - if (sort) params.sort = sort; - if (limit) params.limit = limit; - - setParams(params); - }, [setParams, page, sort, where, limit]); - - React.useEffect(() => { - setFields(formatFields(modalCollection, t)); - setLimit(modalCollection.admin.pagination.defaultLimit); - }, [modalCollection, t]); - - React.useEffect(() => { - setModalCollection(collections.find(({ slug: collectionSlug }) => modalCollectionOption.value === collectionSlug)); - }, [modalCollectionOption, collections]); - - return ( - - -
-

- {t('chooseLabel', { label: getTranslation(modalCollection.labels.singular, i18n) })} -

-
- { - moreThanOneAvailableCollection && ( -
-
- ) - } - - { - handleUpdateUpload(doc); - setRelatedCollectionConfig(modalCollection); - closeModal(); - }} - /> -
- - {data?.totalDocs > 0 && ( - -
- {data.page} - - - {data.totalPages > 1 ? data.limit : data.totalDocs} - {' '} - {t('general:of')} - {' '} - {data.totalDocs} -
- -
- )} -
-
-
- ); -}; diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss index 914b6af429..408202a9ad 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.scss @@ -51,7 +51,7 @@ flex-grow: 1; display: flex; align-items: center; - padding: base(.75) base(1); + padding: base(.75); justify-content: space-between; max-width: calc(100% - #{base(3.25)}); } @@ -60,19 +60,35 @@ display: flex; align-items: center; flex-shrink: 0; + margin-left: base(0.5); + + & > *:not(:last-child) { + margin-right: base(.25); + } } &__actionButton { margin: 0; - margin-right: base(.5); border-radius: 0; + flex-shrink: 0; line { stroke-width: $style-stroke-width-m; } - &:last-child { - margin-right: 0; + &:disabled { + opacity: 0.5; + pointer-events: none; + } + } + + &__toggler { + background: transparent; + border: none; + padding: 0; + &:focus, + &:focus-within { + outline: none; } } diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx index 2e8a597164..b8d24ffbb1 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/index.tsx @@ -1,5 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { useModal } from '@faceless-ui/modal'; +import React, { HTMLAttributes, useCallback, useReducer, useState } from 'react'; import { Transforms } from 'slate'; import { ReactEditor, useSlateStatic, useFocused, useSelected } from 'slate-react'; import { useTranslation } from 'react-i18next'; @@ -8,10 +7,11 @@ import usePayloadAPI from '../../../../../../../hooks/usePayloadAPI'; import FileGraphic from '../../../../../../graphics/File'; import useThumbnail from '../../../../../../../hooks/useThumbnail'; import Button from '../../../../../../elements/Button'; -import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types'; -import { SwapUploadModal } from './SwapUploadModal'; -import { EditModal } from './EditModal'; import { getTranslation } from '../../../../../../../../utilities/getTranslation'; +import { useDocumentDrawer } from '../../../../../../elements/DocumentDrawer'; +import { useListDrawer } from '../../../../../../elements/ListDrawer'; +import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types'; +import { Props as RichTextProps } from '../../../types'; import './index.scss'; @@ -21,27 +21,56 @@ const initialParams = { depth: 0, }; -const Element = ({ attributes, children, element, path, fieldProps }) => { - const { relationTo, value } = element; - const { toggleModal } = useModal(); +const Element: React.FC<{ + attributes: HTMLAttributes + children: React.ReactNode + element: any + fieldProps: RichTextProps +}> = ({ attributes, children, element }) => { + const { + relationTo, + value, + fieldProps, + } = element; + const { collections, serverURL, routes: { api } } = useConfig(); - const [modalToRender, setModalToRender] = useState(undefined); - const [relatedCollection, setRelatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo)); const { t, i18n } = useTranslation('fields'); + const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0); + const [relatedCollection, setRelatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo)); + + const [ + ListDrawer, + ListDrawerToggler, + { + closeDrawer: closeListDrawer, + }, + ] = useListDrawer({ + uploads: true, + selectedCollection: relatedCollection.slug, + }); + + const [ + DocumentDrawer, + DocumentDrawerToggler, + { + closeDrawer, + }, + ] = useDocumentDrawer({ + collectionSlug: relatedCollection.slug, + id: value?.id, + }); const editor = useSlateStatic(); const selected = useSelected(); const focused = useFocused(); - const modalSlug = `${path}-edit-upload-${modalToRender}`; - // Get the referenced document - const [{ data: upload }] = usePayloadAPI( + const [{ data }, { setParams }] = usePayloadAPI( `${serverURL}${api}/${relatedCollection.slug}/${value?.id}`, { initialParams }, ); - const thumbnailSRC = useThumbnail(relatedCollection, upload); + const thumbnailSRC = useThumbnail(relatedCollection, data); const removeUpload = useCallback(() => { const elementPath = ReactEditor.findPath(editor, element); @@ -52,18 +81,56 @@ const Element = ({ attributes, children, element, path, fieldProps }) => { ); }, [editor, element]); - const closeModal = useCallback(() => { - toggleModal(modalSlug); - setModalToRender(null); - }, [toggleModal, modalSlug]); - useEffect(() => { - if (modalToRender) { - toggleModal(modalSlug); - } - }, [modalToRender, toggleModal, modalSlug]); + const updateUpload = useCallback((json) => { + const { doc } = json; - const fieldSchema = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields; + const newNode = { + fields: doc, + }; + + const elementPath = ReactEditor.findPath(editor, element); + + Transforms.setNodes( + editor, + newNode, + { at: elementPath }, + ); + + // setRelatedCollection(collections.find((coll) => coll.slug === collectionConfig.slug)); + + setParams({ + ...initialParams, + cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed + }); + + dispatchCacheBust(); + closeDrawer(); + }, [editor, element, setParams, cacheBust, closeDrawer]); + + const swapUpload = React.useCallback(({ docID, collectionConfig }) => { + const newNode = { + type: 'upload', + value: { id: docID }, + relationTo: collectionConfig.slug, + children: [ + { text: ' ' }, + ], + }; + + const elementPath = ReactEditor.findPath(editor, element); + + setRelatedCollection(collections.find((coll) => coll.slug === collectionConfig.slug)); + + Transforms.setNodes( + editor, + newNode, + { at: elementPath }, + ); + + dispatchCacheBust(); + closeListDrawer(); + }, [closeListDrawer, editor, element, collections]); return (
{ {thumbnailSRC ? ( {upload?.filename} ) : ( @@ -91,30 +158,36 @@ const Element = ({ attributes, children, element, path, fieldProps }) => { {getTranslation(relatedCollection.labels.singular, i18n)}
- {fieldSchema && ( + {value?.id && ( + +
-
- {upload?.filename} + + {data?.filename} +
{children} - - {modalToRender === 'swap' && ( - - )} - - {(modalToRender === 'edit' && fieldSchema) && ( - + {value?.id && ( + )} + ); }; diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/addSwapModals.scss b/src/admin/components/forms/field-types/RichText/elements/upload/addSwapModals.scss deleted file mode 100644 index d4ade3754a..0000000000 --- a/src/admin/components/forms/field-types/RichText/elements/upload/addSwapModals.scss +++ /dev/null @@ -1,57 +0,0 @@ -@import '../../../../../../scss/styles.scss'; - -.rich-text-upload-modal { - @include blur-bg; - display: flex; - align-items: center; - - .template-minimal { - padding-top: base(4); - align-items: flex-start; - position: relative; - z-index: 1; - } - - &__header { - margin-bottom: $baseline; - display: flex; - - h1 { - margin: 0 auto 0 0; - } - - .btn { - margin: 0 0 0 $baseline; - } - } - - &__select-collection-wrap { - margin-bottom: base(1); - } - - &__page-info { - margin-right: base(1); - margin-left: auto; - } - - &__page-controls { - width: 100%; - display: flex; - align-items: center; - } - - @include mid-break { - .paginator { - width: 100%; - margin-bottom: $baseline; - } - - &__page-controls { - flex-wrap: wrap; - } - - &__page-info { - margin-left: 0; - } - } -} diff --git a/src/admin/components/forms/field-types/Upload/Add/index.scss b/src/admin/components/forms/field-types/Upload/Add/index.scss deleted file mode 100644 index 7f6913773d..0000000000 --- a/src/admin/components/forms/field-types/Upload/Add/index.scss +++ /dev/null @@ -1,34 +0,0 @@ -@import '../../../../../scss/styles.scss'; - -.add-upload-modal { - @include blur-bg; - display: flex; - align-items: center; - - .template-minimal { - padding-top: base(6); - align-items: flex-start; - position: relative; - z-index: 1; - } - - &__header { - margin-bottom: $baseline; - - div { - display: flex; - } - - h1 { - margin: 0 auto 0 0; - } - - .btn { - margin: 0 0 0 $baseline; - } - } - - &__sub-header { - margin-top: base(.25); - } -} diff --git a/src/admin/components/forms/field-types/Upload/Add/index.tsx b/src/admin/components/forms/field-types/Upload/Add/index.tsx deleted file mode 100644 index 7e7862b08b..0000000000 --- a/src/admin/components/forms/field-types/Upload/Add/index.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useCallback } from 'react'; -import { Modal, useModal } from '@faceless-ui/modal'; -import { useTranslation } from 'react-i18next'; -import { useConfig } from '../../../../utilities/Config'; -import { useAuth } from '../../../../utilities/Auth'; -import MinimalTemplate from '../../../../templates/Minimal'; -import Form from '../../../Form'; -import Button from '../../../../elements/Button'; -import RenderFields from '../../../RenderFields'; -import FormSubmit from '../../../Submit'; -import Upload from '../../../../views/collections/Edit/Upload'; -import ViewDescription from '../../../../elements/ViewDescription'; -import { getTranslation } from '../../../../../../utilities/getTranslation'; -import { Props } from './types'; - -import './index.scss'; - -const baseClass = 'add-upload-modal'; - -const AddUploadModal: React.FC = (props) => { - const { - collection, - collection: { - admin: { - description, - } = {}, - } = {}, - slug, - fieldTypes, - setValue, - } = props; - - const { permissions } = useAuth(); - const { serverURL, routes: { api } } = useConfig(); - const { toggleModal } = useModal(); - const { t, i18n } = useTranslation('fields'); - - const onSuccess = useCallback((json) => { - toggleModal(slug); - setValue(json.doc); - }, [toggleModal, slug, setValue]); - - const classes = [ - baseClass, - ].filter(Boolean).join(' '); - - const collectionPermissions = permissions?.collections?.[collection.slug]?.fields; - - return ( - - -
-
-
-

- {t('newLabel', { label: getTranslation(collection.labels.singular, i18n) })} -

- {t('general:save')} -
- {description && ( -
- -
- )} -
- - - -
-
- ); -}; - -export default AddUploadModal; diff --git a/src/admin/components/forms/field-types/Upload/Add/types.ts b/src/admin/components/forms/field-types/Upload/Add/types.ts deleted file mode 100644 index 3e71e1c981..0000000000 --- a/src/admin/components/forms/field-types/Upload/Add/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SanitizedCollectionConfig } from '../../../../../../collections/config/types'; -import { FieldTypes } from '../..'; - -export type Props = { - setValue: (val: { id: string } | null) => void - collection: SanitizedCollectionConfig - slug: string - fieldTypes: FieldTypes -} diff --git a/src/admin/components/forms/field-types/Upload/Input.tsx b/src/admin/components/forms/field-types/Upload/Input.tsx index 92402c627e..c1a18f2f52 100644 --- a/src/admin/components/forms/field-types/Upload/Input.tsx +++ b/src/admin/components/forms/field-types/Upload/Input.tsx @@ -1,7 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { useModal } from '@faceless-ui/modal'; +import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import Button from '../../../elements/Button'; import Label from '../../Label'; import Error from '../../Error'; import FileDetails from '../../../elements/FileDetails'; @@ -9,11 +7,13 @@ import FieldDescription from '../../FieldDescription'; import { FilterOptions, UploadField } from '../../../../../fields/config/types'; import { Description } from '../../FieldDescription/types'; import { FieldTypes } from '..'; -import AddModal from './Add'; -import SelectExistingModal from './SelectExisting'; import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; -import { useEditDepth, EditDepthContext } from '../../../utilities/EditDepth'; import { getTranslation } from '../../../../../utilities/getTranslation'; +import { useDocumentDrawer } from '../../../elements/DocumentDrawer'; +import { useListDrawer } from '../../../elements/ListDrawer'; +import Button from '../../../elements/Button'; +import { DocumentDrawerProps } from '../../../elements/DocumentDrawer/types'; +import { ListDrawerProps } from '../../../elements/ListDrawer/types'; import './index.scss'; @@ -50,7 +50,6 @@ const UploadInput: React.FC = (props) => { description, label, relationTo, - fieldTypes, value, onChange, showError, @@ -58,19 +57,33 @@ const UploadInput: React.FC = (props) => { api = '/api', collection, errorMessage, - filterOptions, } = props; - const { toggleModal, modalState } = useModal(); const { t, i18n } = useTranslation('fields'); - const editDepth = useEditDepth(); - - const addModalSlug = `${path}-add-depth-${editDepth}`; - const selectExistingModalSlug = `${path}-select-existing-depth-${editDepth}`; const [file, setFile] = useState(undefined); const [missingFile, setMissingFile] = useState(false); - const [modalToRender, setModalToRender] = useState(); + const [collectionSlugs] = useState([collection?.slug]); + + const [ + DocumentDrawer, + DocumentDrawerToggler, + { + closeDrawer, + }, + ] = useDocumentDrawer({ + collectionSlug: collectionSlugs[0], + }); + + const [ + ListDrawer, + ListDrawerToggler, + { + closeDrawer: closeListDrawer, + }, + ] = useListDrawer({ + collectionSlugs, + }); const classes = [ 'field-type', @@ -110,11 +123,19 @@ const UploadInput: React.FC = (props) => { i18n, ]); - useEffect(() => { - if (!modalState[addModalSlug]?.isOpen && !modalState[selectExistingModalSlug]?.isOpen) { - setModalToRender(undefined); - } - }, [modalState, addModalSlug, selectExistingModalSlug]); + const onSave = useCallback((args) => { + setMissingFile(false); + onChange(args.doc); + closeDrawer(); + }, [onChange, closeDrawer]); + + const onSelect = useCallback((args) => { + setMissingFile(false); + onChange({ + id: args.docID, + }); + closeListDrawer(); + }, [onChange, closeListDrawer]); return (
= (props) => { )} {(!file || missingFile) && (
- - +
+ + + + + + +
)} - - {modalToRender === addModalSlug && ( - { - setMissingFile(false); - onChange(e); - }, - }} - /> - )} - {modalToRender === selectExistingModalSlug && ( - { - setMissingFile(false); - onChange(e); - }, - addModalSlug, - filterOptions, - path, - }} - /> - )} - )} + +
); }; diff --git a/src/admin/components/forms/field-types/Upload/SelectExisting/index.scss b/src/admin/components/forms/field-types/Upload/SelectExisting/index.scss deleted file mode 100644 index df99eb706c..0000000000 --- a/src/admin/components/forms/field-types/Upload/SelectExisting/index.scss +++ /dev/null @@ -1,60 +0,0 @@ -@import '../../../../../scss/styles.scss'; - -.select-existing-upload-modal { - @include blur-bg; - display: flex; - align-items: flex-start; - - .template-minimal { - padding-top: base(6); - align-items: flex-start; - position: relative; - z-index: 1; - } - - &__header { - margin-bottom: $baseline; - - div { - display: flex; - } - - h1 { - margin: 0 auto 0 0; - } - - .btn { - margin: 0 0 0 $baseline; - } - } - - &__sub-header { - margin-top: base(.25); - } - - &__page-info { - margin-right: base(1); - margin-left: auto; - } - - &__page-controls { - width: 100%; - display: flex; - align-items: center; - } - - @include mid-break { - .paginator { - width: 100%; - margin-bottom: $baseline; - } - - &__page-controls { - flex-wrap: wrap; - } - - &__page-info { - margin-left: 0; - } - } -} diff --git a/src/admin/components/forms/field-types/Upload/SelectExisting/index.tsx b/src/admin/components/forms/field-types/Upload/SelectExisting/index.tsx deleted file mode 100644 index e0560d9952..0000000000 --- a/src/admin/components/forms/field-types/Upload/SelectExisting/index.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React, { Fragment, useState, useEffect } from 'react'; -import equal from 'deep-equal'; -import { Modal, useModal } from '@faceless-ui/modal'; -import { useTranslation } from 'react-i18next'; -import { useConfig } from '../../../../utilities/Config'; -import { useAuth } from '../../../../utilities/Auth'; -import { Where } from '../../../../../../types'; -import MinimalTemplate from '../../../../templates/Minimal'; -import Button from '../../../../elements/Button'; -import usePayloadAPI from '../../../../../hooks/usePayloadAPI'; -import ListControls from '../../../../elements/ListControls'; -import Paginator from '../../../../elements/Paginator'; -import UploadGallery from '../../../../elements/UploadGallery'; -import { Props } from './types'; -import PerPage from '../../../../elements/PerPage'; -import formatFields from '../../../../views/collections/List/formatFields'; -import { getFilterOptionsQuery } from '../../getFilterOptionsQuery'; -import { useDocumentInfo } from '../../../../utilities/DocumentInfo'; -import { useForm } from '../../../Form/context'; -import ViewDescription from '../../../../elements/ViewDescription'; -import { getTranslation } from '../../../../../../utilities/getTranslation'; - -import './index.scss'; - -const baseClass = 'select-existing-upload-modal'; - -const SelectExistingUploadModal: React.FC = (props) => { - const { - setValue, - collection, - collection: { - slug: collectionSlug, - admin: { - description, - pagination: { - defaultLimit, - }, - } = {}, - } = {}, - slug: modalSlug, - path, - filterOptions, - } = props; - - const { serverURL, routes: { api } } = useConfig(); - const { id } = useDocumentInfo(); - const { user } = useAuth(); - const { getData, getSiblingData } = useForm(); - const { toggleModal, isModalOpen } = useModal(); - const { t, i18n } = useTranslation('fields'); - const [fields] = useState(() => formatFields(collection, t)); - const [limit, setLimit] = useState(defaultLimit); - const [sort, setSort] = useState(null); - const [where, setWhere] = useState(null); - const [page, setPage] = useState(null); - const [optionFilters, setOptionFilters] = useState(); - - const classes = [ - baseClass, - ].filter(Boolean).join(' '); - - const isOpen = isModalOpen(modalSlug); - - const apiURL = isOpen ? `${serverURL}${api}/${collectionSlug}` : null; - - const [{ data }, { setParams }] = usePayloadAPI(apiURL, {}); - - useEffect(() => { - const params: { - page?: number - sort?: string - where?: unknown - limit?: number - } = {}; - - if (page) params.page = page; - if (where) params.where = { and: [where, optionFilters] }; - if (sort) params.sort = sort; - if (limit) params.limit = limit; - - setParams(params); - }, [setParams, page, sort, where, limit, optionFilters]); - - useEffect(() => { - if (!filterOptions || !isOpen) { - return; - } - - const newOptionFilters = getFilterOptionsQuery(filterOptions, { - id, - relationTo: collectionSlug, - data: getData(), - siblingData: getSiblingData(path), - user, - })[collectionSlug]; - if (!equal(newOptionFilters, optionFilters)) { - setOptionFilters(newOptionFilters); - } - }, [collectionSlug, filterOptions, optionFilters, id, getData, getSiblingData, path, user, isOpen]); - - return ( - - {isOpen && ( - -
-
-

- {t('selectExistingLabel', { label: getTranslation(collection.labels.singular, i18n) })} -

-
- {description && ( -
- -
- )} -
- - { - setValue(doc); - toggleModal(modalSlug); - }} - /> -
- - {data?.totalDocs > 0 && ( - -
- {data.page} - - - {data.totalPages > 1 ? data.limit : data.totalDocs} - {' '} - {t('general:of')} - {' '} - {data.totalDocs} -
- -
- )} -
-
- )} -
- ); -}; - -export default SelectExistingUploadModal; diff --git a/src/admin/components/forms/field-types/Upload/SelectExisting/types.ts b/src/admin/components/forms/field-types/Upload/SelectExisting/types.ts deleted file mode 100644 index 9cd6d15738..0000000000 --- a/src/admin/components/forms/field-types/Upload/SelectExisting/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { SanitizedCollectionConfig } from '../../../../../../collections/config/types'; -import { FilterOptions } from '../../../../../../fields/config/types'; - -export type Props = { - setValue: (val: { id: string } | null) => void - collection: SanitizedCollectionConfig - slug: string - path - filterOptions: FilterOptions -} diff --git a/src/admin/components/forms/field-types/Upload/index.scss b/src/admin/components/forms/field-types/Upload/index.scss index 1bec03bb78..9a2d948f26 100644 --- a/src/admin/components/forms/field-types/Upload/index.scss +++ b/src/admin/components/forms/field-types/Upload/index.scss @@ -5,26 +5,38 @@ margin-bottom: $baseline; &__wrap { - display: flex; - padding: base(1.5) base(1.5) $baseline; background: var(--theme-elevation-50); + padding: base(1); + } + + &__buttons { + margin: base(-0.25); + width: calc(100% + #{base(0.5)}); flex-wrap: wrap; + display: flex; + align-items: center; .btn { - margin: 0 $baseline base(.5) 0; - min-width: base(7); + margin: 0 } } + &__toggler { + margin: base(0.25); + min-width: base(7); + } + @include mid-break { &__wrap { - display: block; - padding: $baseline $baseline base(.5); + padding: base(0.75); + } - .btn { - width: 100%; - min-width: initial; - } + &__buttons { + display: block; + } + + &__toggler { + width: calc(100% - #{base(0.5)}); } } -} \ No newline at end of file +} diff --git a/src/admin/components/utilities/DocumentInfo/index.tsx b/src/admin/components/utilities/DocumentInfo/index.tsx index b170b67b22..5183cd6009 100644 --- a/src/admin/components/utilities/DocumentInfo/index.tsx +++ b/src/admin/components/utilities/DocumentInfo/index.tsx @@ -199,11 +199,13 @@ export const DocumentInfoProvider: React.FC = ({ }, [getVersions]); useEffect(() => { - const getDocPreferences = async () => { - await getPreference(preferencesKey); - }; + if (preferencesKey) { + const getDocPreferences = async () => { + await getPreference(preferencesKey); + }; - getDocPreferences(); + getDocPreferences(); + } }, [getPreference, preferencesKey]); useEffect(() => { diff --git a/src/admin/components/views/collections/List/Cell/index.tsx b/src/admin/components/views/collections/List/Cell/index.tsx index e24d63bb6d..76e47fc3fb 100644 --- a/src/admin/components/views/collections/List/Cell/index.tsx +++ b/src/admin/components/views/collections/List/Cell/index.tsx @@ -10,7 +10,6 @@ import { getTranslation } from '../../../../../../utilities/getTranslation'; const DefaultCell: React.FC = (props) => { const { field, - colIndex, collection: { slug, }, @@ -18,6 +17,9 @@ const DefaultCell: React.FC = (props) => { rowData: { id, } = {}, + link = true, + onClick, + className, } = props; const { routes: { admin } } = useConfig(); @@ -27,13 +29,26 @@ const DefaultCell: React.FC = (props) => { const wrapElementProps: { to?: string - } = {}; + onClick?: () => void + type?: 'button' + className?: string + } = { + className, + }; - if (colIndex === 0) { + if (link) { WrapElement = Link; wrapElementProps.to = `${admin}/collections/${slug}/${id}`; } + if (typeof onClick === 'function') { + WrapElement = 'button'; + wrapElementProps.type = 'button'; + wrapElementProps.onClick = () => { + onClick(props); + }; + } + const CellComponent = cellData && cellComponents[field.type]; if (!CellComponent) { @@ -71,6 +86,9 @@ const Cell: React.FC = (props) => { } = {}, } = {}, }, + link, + onClick, + className, } = props; return ( @@ -81,6 +99,9 @@ const Cell: React.FC = (props) => { cellData, collection, field, + link, + onClick, + className, }} CustomComponent={CustomCell} DefaultComponent={DefaultCell} diff --git a/src/admin/components/views/collections/List/Cell/types.ts b/src/admin/components/views/collections/List/Cell/types.ts index 55c63c1404..d015777bba 100644 --- a/src/admin/components/views/collections/List/Cell/types.ts +++ b/src/admin/components/views/collections/List/Cell/types.ts @@ -9,4 +9,7 @@ export type Props = { rowData: { [path: string]: unknown } + link?: boolean + onClick?: (Props) => void + className?: string } diff --git a/src/admin/components/views/collections/List/Default.tsx b/src/admin/components/views/collections/List/Default.tsx index 287979afab..e288a78cb6 100644 --- a/src/admin/components/views/collections/List/Default.tsx +++ b/src/admin/components/views/collections/List/Default.tsx @@ -42,6 +42,15 @@ const DefaultList: React.FC = (props) => { columnNames, setColumns, hasCreatePermission, + disableEyebrow, + modifySearchParams, + disableCardLink, + onCardClick, + handleSortChange, + handleWhereChange, + handlePageChange, + handlePerPageChange, + customHeader, } = props; const { routes: { admin } } = useConfig(); @@ -53,19 +62,28 @@ const DefaultList: React.FC = (props) => { - + {!disableEyebrow && ( + + )}
-

{getTranslation(pluralLabel, i18n)}

- {hasCreatePermission && ( - - {t('createNew')} - - )} - {description && ( -
- -
+ {customHeader && customHeader} + {!customHeader && ( + +

+ {getTranslation(pluralLabel, i18n)} +

+ {hasCreatePermission && ( + + {t('createNew')} + + )} + {description && ( +
+ +
+ )} +
)}
= (props) => { setColumns={setColumns} enableColumns={Boolean(!upload)} enableSort={Boolean(upload)} + modifySearchQuery={modifySearchParams} + handleSortChange={handleSortChange} + handleWhereChange={handleWhereChange} /> {(data.docs && data.docs.length > 0) && ( {!upload && ( - - - + +
+ )} {upload && ( history.push(`${admin}/collections/${slug}/${doc.id}`)} + onCardClick={(doc) => { + if (typeof onCardClick === 'function') onCardClick(doc); + if (!disableCardLink) history.push(`${admin}/collections/${slug}/${doc.id}`); + }} /> )} @@ -99,7 +123,7 @@ const DefaultList: React.FC = (props) => {

{t('noResults', { label: getTranslation(pluralLabel, i18n) })}

- {hasCreatePermission && ( + {hasCreatePermission && newDocumentURL && (