diff --git a/CHANGELOG.md b/CHANGELOG.md index 17279ab201..3b4704efb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ +## [1.6.21](https://github.com/payloadcms/payload/compare/v1.6.20...v1.6.21) (2023-03-15) + + +### Bug Fixes + +* hidden fields being mutated on patch ([#2317](https://github.com/payloadcms/payload/issues/2317)) ([8d65ba1](https://github.com/payloadcms/payload/commit/8d65ba1efd8744042bbaf669c10b6837a6b972f8)) + +## [1.6.20](https://github.com/payloadcms/payload/compare/v1.6.19...v1.6.20) (2023-03-13) + + +### Bug Fixes + +* allow thumbnails in upload gallery to show useAsTitle value ([aae6d71](https://github.com/payloadcms/payload/commit/aae6d716e5608270ca142f2f4df214f9e271deb4)) +* allows useListDrawer to work without collectionSlugs defined ([e1553c2](https://github.com/payloadcms/payload/commit/e1553c2fc88ac582744cd72d15c9e9ef3b8ec549)) +* cancels existing fetches if new fetches are started ([ccc92fd](https://github.com/payloadcms/payload/commit/ccc92fdb7519e14ff1092f19ae4e7060fa413aab)) +* check relationships indexed access for undefined ([959f017](https://github.com/payloadcms/payload/commit/959f01739c30450f3a6d052dd6083fdacf1527a4)) +* ensures documentID exists in doc documentDrawers ([#2304](https://github.com/payloadcms/payload/issues/2304)) ([566c45b](https://github.com/payloadcms/payload/commit/566c45b0b436a9a3ea8eff27de2ea829dd6a2f0c)) +* flattens title fields to allow seaching by title if title inside Row field ([75e776d](https://github.com/payloadcms/payload/commit/75e776ddb43b292eae6c1204589d9dc22deab50c)) +* keep drop zone active when hovering inner elements ([#2295](https://github.com/payloadcms/payload/issues/2295)) ([39e303a](https://github.com/payloadcms/payload/commit/39e303add62d2dbd3e72d17e64e1ea5d940b0298)) +* Prevent browser initial favicon request ([fd8ea88](https://github.com/payloadcms/payload/commit/fd8ea88488c80627346733e0595a2ef34c964a87)) +* removes forced require on array, block, group ts ([657aa65](https://github.com/payloadcms/payload/commit/657aa65e993d13e9a294456b73adcd57f20d7c87)) +* removes pagination type from top level admin config types ([bf9929e](https://github.com/payloadcms/payload/commit/bf9929e9a9919488f6de0e172909fa27719ecb04)) +* renders presentational table columns ([4e1748f](https://github.com/payloadcms/payload/commit/4e1748fb8a3554586b377e60738130d03ec12f38)) +* undefined point fields saving as empty object ([#2313](https://github.com/payloadcms/payload/issues/2313)) ([af16415](https://github.com/payloadcms/payload/commit/af164159fb52f4b0ef97e2fa34b881f97bc07310)) + + +### Features + +* [#2280](https://github.com/payloadcms/payload/issues/2280) Improve UX of paginator ([#2293](https://github.com/payloadcms/payload/issues/2293)) ([1df3d14](https://github.com/payloadcms/payload/commit/1df3d149e06cc955a61c4371371b601c0d9aad2b)) +* exposes useTheme hook ([abebde6](https://github.com/payloadcms/payload/commit/abebde6b120a9dddc9971325b616b9cb31bcba90)) +* provide refresh permissions for auth context ([e9c796e](https://github.com/payloadcms/payload/commit/e9c796e42c1bb1e0ce72d057ee88dee624b94c24)) + ## [1.6.19](https://github.com/payloadcms/payload/compare/v1.6.18...v1.6.19) (2023-03-09) diff --git a/components/utilities.js b/components/utilities.js index f19df00f9f..0fd9792631 100644 --- a/components/utilities.js +++ b/components/utilities.js @@ -4,3 +4,4 @@ exports.useDocumentInfo = require('../dist/admin/components/utilities/DocumentIn exports.useConfig = require('../dist/admin/components/utilities/Config').useConfig; exports.useAuth = require('../dist/admin/components/utilities/Auth').useAuth; exports.useEditDepth = require('../dist/admin/components/utilities/EditDepth').useEditDepth; +exports.useTheme = require('../dist/admin/components/utilities/Theme').useTheme; diff --git a/docs/admin/hooks.mdx b/docs/admin/hooks.mdx index 019a081a49..c955a0cd2a 100644 --- a/docs/admin/hooks.mdx +++ b/docs/admin/hooks.mdx @@ -226,14 +226,15 @@ const Greeting: React.FC = () => { Useful to retrieve info about the currently logged in user as well as methods for interacting with it. It sends back an object with the following properties: -| Property | Description | -|---------------------|-----------------------------------------------------------------------------------------| -| **`user`** | The currently logged in user | -| **`logOut`** | A method to log out the currently logged in user | -| **`refreshCookie`** | A method to trigger the silent refreshing of a user's auth token | -| **`setToken`** | Set the token of the user, to be decoded and used to reset the user and token in memory | -| **`token`** | The logged in user's token (useful for creating preview links, etc.) | -| **`permissions`** | The permissions of the current user | +| Property | Description | +|--------------------------|-----------------------------------------------------------------------------------------| +| **`user`** | The currently logged in user | +| **`logOut`** | A method to log out the currently logged in user | +| **`refreshCookie`** | A method to trigger the silent refreshing of a user's auth token | +| **`setToken`** | Set the token of the user, to be decoded and used to reset the user and token in memory | +| **`token`** | The logged in user's token (useful for creating preview links, etc.) | +| **`refreshPermissions`** | Load new permissions (useful when content that effects permissions has been changed) | +| **`permissions`** | The permissions of the current user | ```tsx import { useAuth } from 'payload/components/utilities'; diff --git a/examples/preview/cms/src/collections/Pages/index.tsx b/examples/preview/cms/src/collections/Pages/index.ts similarity index 100% rename from examples/preview/cms/src/collections/Pages/index.tsx rename to examples/preview/cms/src/collections/Pages/index.ts diff --git a/package.json b/package.json index 84d7e8d2b6..3695bbaf0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "1.6.19", + "version": "1.6.21", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "MIT", "engines": { diff --git a/src/admin/api.ts b/src/admin/api.ts index 6e0a5ad54a..3c6b3ff2e0 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -12,7 +12,7 @@ export const requests = { } return fetch(`${url}${query}`, { credentials: 'include', - headers: options.headers, + ...options, }); }, diff --git a/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx b/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx index 38213dd3f8..82cf3e4e6c 100644 --- a/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx +++ b/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx @@ -93,7 +93,10 @@ export const DocumentDrawerContent: React.FC = ({ if (isError) return null; return ( - + = (props) => { const params = useSearchParams(); const shouldInitializeWhereOpened = validateWhereQuery(params?.where); - const [titleField] = useState(() => fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle)); + const [titleField] = useState(() => { + const topLevelFields = flattenFields(fields); + return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle); + }); const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields)); const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined); const { t, i18n } = useTranslation('general'); diff --git a/src/admin/components/elements/ListDrawer/index.tsx b/src/admin/components/elements/ListDrawer/index.tsx index 0b275f1392..193a75e951 100644 --- a/src/admin/components/elements/ListDrawer/index.tsx +++ b/src/admin/components/elements/ListDrawer/index.tsx @@ -4,6 +4,7 @@ import { ListDrawerProps, ListTogglerProps, UseListDrawer } from './types'; import { Drawer, DrawerToggler } from '../Drawer'; import { useEditDepth } from '../../utilities/EditDepth'; import { ListDrawerContent } from './DrawerContent'; +import { useConfig } from '../../utilities/Config'; import './index.scss'; @@ -49,21 +50,26 @@ export const ListDrawer: React.FC = (props) => { header={false} gutter={false} > - + ); }; export const useListDrawer: UseListDrawer = ({ - collectionSlugs, + collectionSlugs: collectionSlugsFromProps, uploads, selectedCollection, filterOptions, }) => { + const { collections } = useConfig(); const drawerDepth = useEditDepth(); const uuid = useId(); const { modalState, toggleModal, closeModal, openModal } = useModal(); const [isOpen, setIsOpen] = useState(false); + const [collectionSlugs, setCollectionSlugs] = useState(collectionSlugsFromProps); + const drawerSlug = formatListDrawerSlug({ depth: drawerDepth, uuid, @@ -73,6 +79,18 @@ export const useListDrawer: UseListDrawer = ({ setIsOpen(Boolean(modalState[drawerSlug]?.isOpen)); }, [modalState, drawerSlug]); + useEffect(() => { + if (!collectionSlugs || collectionSlugs.length === 0) { + const filteredCollectionSlugs = collections.filter(({ upload }) => { + if (uploads) { + return Boolean(upload) === true; + } + return true; + }); + + setCollectionSlugs(filteredCollectionSlugs.map(({ slug }) => slug)); + } + }, [collectionSlugs, uploads, collections]); const toggleDrawer = useCallback(() => { toggleModal(drawerSlug); }, [toggleModal, drawerSlug]); diff --git a/src/admin/components/elements/ListDrawer/types.ts b/src/admin/components/elements/ListDrawer/types.ts index bbc0c4c7f4..d5d5052e4e 100644 --- a/src/admin/components/elements/ListDrawer/types.ts +++ b/src/admin/components/elements/ListDrawer/types.ts @@ -22,7 +22,7 @@ export type ListTogglerProps = HTMLAttributes & { } export type UseListDrawer = (args: { - collectionSlugs: string[] + collectionSlugs?: string[] selectedCollection?: string uploads?: boolean // finds all collections with upload: true filterOptions?: FilterOptionsResult diff --git a/src/admin/components/elements/Paginator/ClickableArrow/index.scss b/src/admin/components/elements/Paginator/ClickableArrow/index.scss index 487496c06f..01166e6c23 100644 --- a/src/admin/components/elements/Paginator/ClickableArrow/index.scss +++ b/src/admin/components/elements/Paginator/ClickableArrow/index.scss @@ -2,9 +2,14 @@ .clickable-arrow { cursor: pointer; - transform: rotate(-90deg); - &--left { + &--right { + .icon { + transform: rotate(-90deg); + } + } + + &--left .icon { transform: rotate(90deg); } diff --git a/src/admin/components/elements/Paginator/index.scss b/src/admin/components/elements/Paginator/index.scss index 45d3ef4be7..637f6cdc3f 100644 --- a/src/admin/components/elements/Paginator/index.scss +++ b/src/admin/components/elements/Paginator/index.scss @@ -18,6 +18,10 @@ } } + .clickable-arrow--right { + margin-right: base(.25); + } + .clickable-arrow, &__page { @extend %btn-reset; diff --git a/src/admin/components/elements/Paginator/index.tsx b/src/admin/components/elements/Paginator/index.tsx index 6d9f1448c3..f723fab665 100644 --- a/src/admin/components/elements/Paginator/index.tsx +++ b/src/admin/components/elements/Paginator/index.tsx @@ -93,16 +93,7 @@ const Pagination: React.FC = (props) => { } // Add prev and next arrows based on necessity - nodes.push({ - type: 'ClickableArrow', - props: { - updatePage: () => updatePage(prevPage), - isDisabled: !hasPrevPage, - direction: 'left', - }, - }); - - nodes.push({ + nodes.unshift({ type: 'ClickableArrow', props: { updatePage: () => updatePage(nextPage), @@ -111,6 +102,15 @@ const Pagination: React.FC = (props) => { }, }); + nodes.unshift({ + type: 'ClickableArrow', + props: { + updatePage: () => updatePage(prevPage), + isDisabled: !hasPrevPage, + direction: 'left', + }, + }); + return (
{nodes.map((node, i) => { diff --git a/src/admin/components/elements/TableColumns/buildColumns.tsx b/src/admin/components/elements/TableColumns/buildColumns.tsx index 612389733b..a86d58f013 100644 --- a/src/admin/components/elements/TableColumns/buildColumns.tsx +++ b/src/admin/components/elements/TableColumns/buildColumns.tsx @@ -44,7 +44,7 @@ const buildColumns = ({ return [...acc, field]; }, collection.fields); - const flattenedFields = flattenFields(combinedFields); + const flattenedFields = flattenFields(combinedFields, true); // sort the fields to the order of activeColumns const sortedFields = flattenedFields.sort((a, b) => { diff --git a/src/admin/components/elements/ThumbnailCard/index.tsx b/src/admin/components/elements/ThumbnailCard/index.tsx index 92e28769a9..19d5ea15b2 100644 --- a/src/admin/components/elements/ThumbnailCard/index.tsx +++ b/src/admin/components/elements/ThumbnailCard/index.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import { Props } from './types'; import Thumbnail from '../Thumbnail'; @@ -28,6 +28,8 @@ export const ThumbnailCard: React.FC = (props) => { alignLabel && `${baseClass}--align-label-${alignLabel}`, ].filter(Boolean).join(' '); + const title: any = doc?.[collection?.admin?.useAsTitle] || doc?.filename || `[${t('untitled')}]`; + return (
= (props) => { )}
- {label && label} - {!label && doc && ( - - {typeof doc?.filename === 'string' ? doc?.filename : `[${t('untitled')}]`} - - )} + {label || title}
); diff --git a/src/admin/components/elements/Tooltip/index.scss b/src/admin/components/elements/Tooltip/index.scss index 090169dad7..3bb195df2d 100644 --- a/src/admin/components/elements/Tooltip/index.scss +++ b/src/admin/components/elements/Tooltip/index.scss @@ -1,15 +1,13 @@ @import '../../../scss/styles.scss'; -$caretSize: 6; - .tooltip { + --caret-size: 6px; + opacity: 0; background-color: var(--theme-elevation-800); position: absolute; z-index: 2; - bottom: 100%; left: 50%; - transform: translate3d(-50%, calc(#{$caretSize}px * -1), 0); padding: base(.2) base(.4); color: var(--theme-elevation-0); line-height: base(.75); @@ -22,14 +20,12 @@ $caretSize: 6; content: ' '; display: block; position: absolute; - bottom: 0; left: 50%; transform: translate3d(-50%, 100%, 0); width: 0; height: 0; - border-left: #{$caretSize}px solid transparent; - border-right: #{$caretSize}px solid transparent; - border-top: #{$caretSize}px solid var(--theme-elevation-800); + border-left: var(--caret-size) solid transparent; + border-right: var(--caret-size) solid transparent; } &--show { @@ -39,6 +35,26 @@ $caretSize: 6; cursor: default; } + &--position-top { + bottom: 100%; + transform: translate3d(-50%, calc(var(--caret-size) * -1), 0); + + &::after { + bottom: 1px; + border-top: var(--caret-size) solid var(--theme-elevation-800); + } + } + + &--position-bottom { + top: 100%; + transform: translate3d(-50%, var(--caret-size), 0); + + &::after { + bottom: calc(100% + var(--caret-size) - 1px); + border-bottom: var(--caret-size) solid var(--theme-elevation-800); + } + } + @include mid-break { display: none; } diff --git a/src/admin/components/elements/Tooltip/index.tsx b/src/admin/components/elements/Tooltip/index.tsx index 3df38483fd..a119f6f3d8 100644 --- a/src/admin/components/elements/Tooltip/index.tsx +++ b/src/admin/components/elements/Tooltip/index.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; import { Props } from './types'; +import useIntersect from '../../../hooks/useIntersect'; import './index.scss'; @@ -9,9 +10,18 @@ const Tooltip: React.FC = (props) => { children, show: showFromProps = true, delay = 350, + boundingRef, } = props; const [show, setShow] = React.useState(showFromProps); + const [position, setPosition] = React.useState<'top' | 'bottom'>('top'); + + const [ref, intersectionEntry] = useIntersect({ + threshold: 0, + rootMargin: '-145px 0px 0px 100px', + root: boundingRef?.current || null, + }); + useEffect(() => { let timerId: NodeJS.Timeout; @@ -30,16 +40,35 @@ const Tooltip: React.FC = (props) => { }; }, [showFromProps, delay]); + useEffect(() => { + setPosition(intersectionEntry?.isIntersecting ? 'top' : 'bottom'); + }, [intersectionEntry]); + return ( - + + + + + ); }; diff --git a/src/admin/components/elements/Tooltip/types.ts b/src/admin/components/elements/Tooltip/types.ts index 484476de89..49603ff4f6 100644 --- a/src/admin/components/elements/Tooltip/types.ts +++ b/src/admin/components/elements/Tooltip/types.ts @@ -3,4 +3,5 @@ export type Props = { children: React.ReactNode show?: boolean delay?: number + boundingRef?: React.RefObject } diff --git a/src/admin/components/forms/field-types/Relationship/createRelationMap.ts b/src/admin/components/forms/field-types/Relationship/createRelationMap.ts index 91b925bd1c..e12ca5f1e8 100644 --- a/src/admin/components/forms/field-types/Relationship/createRelationMap.ts +++ b/src/admin/components/forms/field-types/Relationship/createRelationMap.ts @@ -31,7 +31,11 @@ export const createRelationMap: CreateRelationMap = ({ const add = (relation: string, id: unknown) => { if (((typeof id === 'string') || typeof id === 'number') && typeof relation === 'string') { - relationMap[relation].push(id); + if (relationMap[relation]) { + relationMap[relation].push(id); + } else { + relationMap[relation] = [id]; + } } }; diff --git a/src/admin/components/utilities/Auth/index.tsx b/src/admin/components/utilities/Auth/index.tsx index 30af6c4280..85257fb12f 100644 --- a/src/admin/components/utilities/Auth/index.tsx +++ b/src/admin/components/utilities/Auth/index.tsx @@ -81,6 +81,21 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children requests.post(`${serverURL}${api}/${userSlug}/logout`); }, [serverURL, api, userSlug]); + const refreshPermissions = useCallback(async () => { + const request = await requests.get(`${serverURL}${api}/access`, { + headers: { + 'Accept-Language': i18n.language, + }, + }); + + if (request.status === 200) { + const json: Permissions = await request.json(); + setPermissions(json); + } else { + throw new Error("Fetching permissions failed with status code " + request.status); + } + }, [serverURL, api, i18n]); + // On mount, get user and set useEffect(() => { const fetchMe = async () => { @@ -117,21 +132,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // When user changes, get new access useEffect(() => { - async function getPermissions() { - const request = await requests.get(`${serverURL}${api}/access`, { - headers: { - 'Accept-Language': i18n.language, - }, - }); - - if (request.status === 200) { - const json: Permissions = await request.json(); - setPermissions(json); - } - } - if (id) { - getPermissions(); + refreshPermissions(); } }, [i18n, id, api, serverURL]); @@ -174,6 +176,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children user, logOut, refreshCookie, + refreshPermissions, permissions, setToken, token: tokenInMemory, diff --git a/src/admin/components/utilities/Auth/types.ts b/src/admin/components/utilities/Auth/types.ts index e083a63916..a9562e653e 100644 --- a/src/admin/components/utilities/Auth/types.ts +++ b/src/admin/components/utilities/Auth/types.ts @@ -6,5 +6,6 @@ export type AuthContext = { refreshCookie: () => void setToken: (token: string) => void token?: string + refreshPermissions: () => Promise permissions?: Permissions } diff --git a/src/admin/components/utilities/Meta/index.tsx b/src/admin/components/utilities/Meta/index.tsx index 67f422a629..57561fbf54 100644 --- a/src/admin/components/utilities/Meta/index.tsx +++ b/src/admin/components/utilities/Meta/index.tsx @@ -4,6 +4,7 @@ import { useConfig } from '../Config'; import { Props } from './types'; import payloadFavicon from '../../../assets/images/favicon.svg'; import payloadOgImage from '../../../assets/images/og-image.png'; +import useMountEffect from '../../../hooks/useMountEffect'; const Meta: React.FC = ({ description, @@ -17,6 +18,13 @@ const Meta: React.FC = ({ const favicon = config.admin.meta.favicon ?? payloadFavicon; const ogImage = config.admin.meta.ogImage ?? payloadOgImage; + useMountEffect(() => { + const faviconElement = document.querySelector('link[data-placeholder-favicon]'); + if (faviconElement) { + faviconElement.remove(); + } + }); + return ( { }); useEffect(() => { + const abortController = new AbortController(); + const fetchData = async () => { setIsError(false); setIsLoading(true); try { const response = await requests.get(`${url}${search}`, { + signal: abortController.signal, headers: { 'Accept-Language': i18n.language, }, @@ -62,8 +65,10 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => { setData(json); setIsLoading(false); } catch (error) { - setIsError(true); - setIsLoading(false); + if (!abortController.signal.aborted) { + setIsError(true); + setIsLoading(false); + } } }; @@ -73,6 +78,10 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => { setIsError(false); setIsLoading(false); } + + return () => { + abortController.abort(); + }; }, [url, locale, search, i18n.language]); return [{ data, isLoading, isError }, { setParams }]; diff --git a/src/admin/index.html b/src/admin/index.html index eda5423871..51655b91c6 100644 --- a/src/admin/index.html +++ b/src/admin/index.html @@ -4,6 +4,7 @@ + diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index fd6da5dd52..e761887378 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -140,7 +140,7 @@ async function update( entityConfig: collectionConfig, req, overrideAccess: true, - showHiddenFields, + showHiddenFields: true, }); // ///////////////////////////////////// diff --git a/src/config/types.ts b/src/config/types.ts index b433f960fd..e4e78786d1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -331,25 +331,6 @@ export type Config = { Dashboard?: React.ComponentType; }; }; - /** - * Control pagination when querying collections. - * - * @see https://payloadcms.com/docs/queries/overview - */ - pagination?: { - /** - * Limit the number of documents that are displayed on 1 page in the list view - * - * @default 10 - */ - defaultLimit?: number; - /** - * Suggest alternative options for the limit of documents on the list view - * - * @default [5, 10, 25, 50, 100] - */ - limits?: number[] - }; /** Customize the Webpack config that's used to generate the Admin panel. */ webpack?: (config: Configuration) => Configuration; }; diff --git a/src/fields/hooks/afterRead/promise.ts b/src/fields/hooks/afterRead/promise.ts index 1f6390d6d4..5aaa02e19f 100644 --- a/src/fields/hooks/afterRead/promise.ts +++ b/src/fields/hooks/afterRead/promise.ts @@ -130,6 +130,8 @@ export const promise = async ({ const pointDoc = siblingDoc[field.name] as Record; if (Array.isArray(pointDoc?.coordinates) && pointDoc.coordinates.length === 2) { siblingDoc[field.name] = pointDoc.coordinates; + } else { + siblingDoc[field.name] = undefined; } break; diff --git a/src/fields/hooks/beforeValidate/promise.ts b/src/fields/hooks/beforeValidate/promise.ts index 0d5d7cf816..929c05daf9 100644 --- a/src/fields/hooks/beforeValidate/promise.ts +++ b/src/fields/hooks/beforeValidate/promise.ts @@ -56,6 +56,21 @@ export const promise = async ({ break; } + case 'point': { + if (Array.isArray(siblingData[field.name])) { + siblingData[field.name] = (siblingData[field.name] as string[]).map((coordinate, i) => { + if (typeof coordinate === 'string') { + const value = siblingData[field.name][i] as string; + const trimmed = value.trim(); + return (trimmed.length === 0) ? null : parseFloat(trimmed); + } + return coordinate; + }); + } + + break; + } + case 'checkbox': { if (siblingData[field.name] === 'true') siblingData[field.name] = true; if (siblingData[field.name] === 'false') siblingData[field.name] = false; diff --git a/src/utilities/entityToJSONSchema.ts b/src/utilities/entityToJSONSchema.ts index 307d90114f..af0379c396 100644 --- a/src/utilities/entityToJSONSchema.ts +++ b/src/utilities/entityToJSONSchema.ts @@ -8,10 +8,9 @@ import deepCopyObject from './deepCopyObject'; import { toWords } from './formatLabels'; import { SanitizedConfig } from '../config/types'; -const nonOptionalFieldTypes = ['group', 'array', 'blocks']; const propertyIsOptional = (field: Field) => { - return fieldAffectsData(field) && (('required' in field && field.required === true) || nonOptionalFieldTypes.includes(field.type)); + return fieldAffectsData(field) && (('required' in field && field.required === true)); }; function getCollectionIDType(collections: SanitizedCollectionConfig[], slug: string): 'string' | 'number' { diff --git a/test/access-control/config.ts b/test/access-control/config.ts index 9ada7ce107..31e9ca53e9 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -11,6 +11,7 @@ export const restrictedVersionsSlug = 'restricted-versions'; export const siblingDataSlug = 'sibling-data'; export const relyOnRequestHeadersSlug = 'rely-on-request-headers'; export const docLevelAccessSlug = 'doc-level-access'; +export const hiddenFieldsSlug = 'hidden-fields'; const openAccess = { create: () => true, @@ -242,6 +243,46 @@ export default buildConfig({ }, ], }, + { + slug: hiddenFieldsSlug, + access: openAccess, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'partiallyHiddenGroup', + type: 'group', + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'value', + type: 'text', + hidden: true, + }, + ], + }, + { + name: 'partiallyHiddenArray', + type: 'array', + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'value', + type: 'text', + hidden: true, + }, + ], + }, + ], + }, ], onInit: async (payload) => { await payload.create({ diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index fd337dfa86..6599acace7 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -1,10 +1,9 @@ import mongoose from 'mongoose'; import payload from '../../src'; -import type { Options as CreateOptions } from '../../src/collections/operations/local/create'; import { Forbidden } from '../../src/errors'; import type { PayloadRequest } from '../../src/types'; import { initPayloadTest } from '../helpers/configHelpers'; -import { relyOnRequestHeadersSlug, requestHeaders, restrictedSlug, siblingDataSlug, slug } from './config'; +import { hiddenFieldsSlug, relyOnRequestHeadersSlug, requestHeaders, restrictedSlug, siblingDataSlug, slug } from './config'; import type { Restricted, Post, RelyOnRequestHeader } from './payload-types'; import { firstArrayText, secondArrayText } from './shared'; @@ -34,7 +33,38 @@ describe('Access Control', () => { await payload.mongoMemoryServer.stop(); }); - it.todo('should properly prevent / allow public users from reading a restricted field'); + it('should not affect hidden fields when patching data', async () => { + const doc = await payload.create({ + collection: hiddenFieldsSlug, + data: { + partiallyHiddenArray: [{ + name: 'public_name', + value: 'private_value', + }], + partiallyHiddenGroup: { + name: 'public_name', + value: 'private_value', + }, + }, + }); + + await payload.update({ + collection: hiddenFieldsSlug, + id: doc.id, + data: { + title: 'Doc Title', + }, + }); + + const updatedDoc = await payload.findByID({ + collection: hiddenFieldsSlug, + id: doc.id, + showHiddenFields: true, + }); + + expect(updatedDoc.partiallyHiddenGroup.value).toEqual('private_value'); + expect(updatedDoc.partiallyHiddenArray[0].value).toEqual('private_value'); + }); it('should be able to restrict access based upon siblingData', async () => { const { id } = await payload.create({ @@ -220,7 +250,7 @@ describe('Access Control', () => { }); }); -async function createDoc(data: Partial, overrideSlug = slug, options?: Partial>): Promise { +async function createDoc(data: Partial, overrideSlug = slug, options?: Partial): Promise { return payload.create({ ...options, collection: overrideSlug, diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 5cc6bf23db..b4df1d4b89 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -5,11 +5,20 @@ * and re-run `payload generate:types` to regenerate this file. */ -export interface Config {} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users". - */ +export interface Config { + collections: { + users: User; + posts: Post; + restricted: Restricted; + 'read-only-collection': ReadOnlyCollection; + 'restricted-versions': RestrictedVersion; + 'sibling-data': SiblingDatum; + 'rely-on-request-headers': RelyOnRequestHeader; + 'doc-level-access': DocLevelAccess; + 'hidden-fields': HiddenField; + }; + globals: {}; +} export interface User { id: string; email?: string; @@ -19,15 +28,12 @@ export interface User { lockUntil?: string; createdAt: string; updatedAt: string; + password?: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts". - */ export interface Post { id: string; restrictedField?: string; - group: { + group?: { restrictedGroupText?: string; }; restrictedRowText?: string; @@ -35,43 +41,27 @@ export interface Post { createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "restricted". - */ export interface Restricted { id: string; name?: string; createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "read-only-collection". - */ export interface ReadOnlyCollection { id: string; name?: string; createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "restricted-versions". - */ export interface RestrictedVersion { id: string; name?: string; createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "sibling-data". - */ export interface SiblingDatum { id: string; - array: { + array?: { allowPublicReadability?: boolean; text?: string; id?: string; @@ -79,20 +69,12 @@ export interface SiblingDatum { createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "rely-on-request-headers". - */ export interface RelyOnRequestHeader { id: string; name?: string; createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "doc-level-access". - */ export interface DocLevelAccess { id: string; approvedForRemoval?: boolean; @@ -101,3 +83,18 @@ export interface DocLevelAccess { createdAt: string; updatedAt: string; } +export interface HiddenField { + id: string; + title?: string; + partiallyHiddenGroup?: { + name?: string; + value?: string; + }; + partiallyHiddenArray?: { + name?: string; + value?: string; + id?: string; + }[]; + createdAt: string; + updatedAt: string; +} diff --git a/test/admin/config.ts b/test/admin/config.ts index c10cc9b199..9dcb5e7d1b 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -9,6 +9,8 @@ import BeforeLogin from './components/BeforeLogin'; import AfterNavLinks from './components/AfterNavLinks'; import { slug, globalSlug } from './shared'; import Logout from './components/Logout'; +import DemoUIFieldField from './components/DemoUIField/Field'; +import DemoUIFieldCell from './components/DemoUIField/Cell'; export interface Post { id: string; @@ -83,7 +85,7 @@ export default buildConfig({ listSearchableFields: ['title', 'description', 'number'], group: { en: 'One', es: 'Una' }, useAsTitle: 'title', - defaultColumns: ['id', 'number', 'title', 'description'], + defaultColumns: ['id', 'number', 'title', 'description', 'demoUIField'], }, fields: [ { @@ -111,6 +113,17 @@ export default buildConfig({ ], }, }, + { + type: 'ui', + name: 'demoUIField', + label: 'Demo UI Field', + admin: { + components: { + Field: DemoUIFieldField, + Cell: DemoUIFieldCell, + }, + }, + }, ], }, { diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index 2d478052b2..69ecdf4020 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -409,6 +409,12 @@ describe('admin', () => { // ensure that the "number" column is still deselected await expect(await page.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column').first()).not.toHaveClass('column-selector__column--active'); }); + + test('should render custom table cell component', async () => { + await createPost(); + await page.goto(url.list); + await expect(await page.locator('table >> thead >> tr >> th >> text=Demo UI Field')).toBeVisible(); + }); }); describe('pagination', () => { diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 551531e56c..f478459f74 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -1,6 +1,7 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import path from 'path'; +import payload from '../../src'; import { AdminUrlUtil } from '../helpers/adminUrlUtil'; import { initPayloadE2E } from '../helpers/configHelpers'; import { login, saveDocAndAssert } from '../helpers'; @@ -136,8 +137,26 @@ describe('fields', () => { describe('point', () => { let url: AdminUrlUtil; - beforeAll(() => { + let filledGroupPoint; + let emptyGroupPoint; + beforeAll(async () => { url = new AdminUrlUtil(serverURL, pointFieldsSlug); + filledGroupPoint = await payload.create({ + collection: pointFieldsSlug, + data: { + point: [5, 5], + localized: [4, 2], + group: { point: [4, 2] }, + }, + }); + emptyGroupPoint = await payload.create({ + collection: pointFieldsSlug, + data: { + point: [5, 5], + localized: [3, -2], + group: {}, + }, + }); }); test('should save point', async () => { @@ -161,6 +180,57 @@ describe('fields', () => { await groupLatField.fill('-8'); await saveDocAndAssert(page); + await expect(await longField.getAttribute('value')).toEqual('9'); + await expect(await latField.getAttribute('value')).toEqual('-2'); + await expect(await localizedLongField.getAttribute('value')).toEqual('1'); + await expect(await localizedLatField.getAttribute('value')).toEqual('-1'); + await expect(await groupLongitude.getAttribute('value')).toEqual('3'); + await expect(await groupLatField.getAttribute('value')).toEqual('-8'); + }); + + test('should update point', async () => { + await page.goto(url.edit(emptyGroupPoint.id)); + const longField = page.locator('#field-longitude-point'); + await longField.fill('9'); + + const latField = page.locator('#field-latitude-point'); + await latField.fill('-2'); + + const localizedLongField = page.locator('#field-longitude-localized'); + await localizedLongField.fill('2'); + + const localizedLatField = page.locator('#field-latitude-localized'); + await localizedLatField.fill('-2'); + + const groupLongitude = page.locator('#field-longitude-group__point'); + await groupLongitude.fill('3'); + + const groupLatField = page.locator('#field-latitude-group__point'); + await groupLatField.fill('-8'); + + await saveDocAndAssert(page); + + await expect(await longField.getAttribute('value')).toEqual('9'); + await expect(await latField.getAttribute('value')).toEqual('-2'); + await expect(await localizedLongField.getAttribute('value')).toEqual('2'); + await expect(await localizedLatField.getAttribute('value')).toEqual('-2'); + await expect(await groupLongitude.getAttribute('value')).toEqual('3'); + await expect(await groupLatField.getAttribute('value')).toEqual('-8'); + }); + + test('should be able to clear a value point', async () => { + await page.goto(url.edit(filledGroupPoint.id)); + + const groupLongitude = page.locator('#field-longitude-group__point'); + await groupLongitude.fill(''); + + const groupLatField = page.locator('#field-latitude-group__point'); + await groupLatField.fill(''); + + await saveDocAndAssert(page); + + await expect(await groupLongitude.getAttribute('value')).toEqual(''); + await expect(await groupLatField.getAttribute('value')).toEqual(''); }); }); diff --git a/test/refresh-permissions/GlobalViewWithRefresh.tsx b/test/refresh-permissions/GlobalViewWithRefresh.tsx new file mode 100644 index 0000000000..9248d3123b --- /dev/null +++ b/test/refresh-permissions/GlobalViewWithRefresh.tsx @@ -0,0 +1,22 @@ +import React, { useCallback } from 'react'; +import { useAuth } from '../../src/admin/components/utilities/Auth'; +import { Props } from '../../src/admin/components/views/Global/types'; +import DefaultGlobalView from '../../src/admin/components/views/Global/Default'; + +const GlobalView: React.FC = (props) => { + const { onSave } = props; + const { refreshPermissions } = useAuth(); + const modifiedOnSave = useCallback((...args) => { + onSave.call(null, ...args); + refreshPermissions(); + }, [onSave, refreshPermissions]); + + return ( + + ); +}; + +export default GlobalView; diff --git a/test/refresh-permissions/config.ts b/test/refresh-permissions/config.ts new file mode 100644 index 0000000000..b7a2bb3ce8 --- /dev/null +++ b/test/refresh-permissions/config.ts @@ -0,0 +1,53 @@ +import { buildConfig } from '../buildConfig'; +import { devUser } from '../credentials'; +import GlobalViewWithRefresh from './GlobalViewWithRefresh'; + +export const pagesSlug = 'pages'; + +export default buildConfig({ + globals: [ + { + slug: 'settings', + fields: [ + { + type: 'checkbox', + name: 'test', + label: 'Allow access to test global', + }, + ], + admin: { + components: { + views: { + Edit: GlobalViewWithRefresh, + }, + }, + }, + }, + { + slug: 'test', + fields: [], + access: { + read: async ({ req: { payload } }) => { + const access = await payload.findGlobal({ slug: 'settings' }); + return access.test; + }, + }, + }, + ], + collections: [ + { + slug: 'users', + auth: true, + fields: [], + }, + ], + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }); + }, +}); diff --git a/test/refresh-permissions/e2e.spec.ts b/test/refresh-permissions/e2e.spec.ts new file mode 100644 index 0000000000..c61cd1a054 --- /dev/null +++ b/test/refresh-permissions/e2e.spec.ts @@ -0,0 +1,35 @@ +import { expect, Page, test } from '@playwright/test'; +import { login } from '../helpers'; +import { initPayloadE2E } from '../helpers/configHelpers'; + +const { beforeAll, describe } = test; + +describe('refresh-permissions', () => { + let serverURL: string; + let page: Page; + + beforeAll(async ({ browser }) => { + ({ serverURL } = await initPayloadE2E(__dirname)); + const context = await browser.newContext(); + page = await context.newPage(); + await login({ page, serverURL }); + }); + + test('should show test global immediately after allowing access', async () => { + await page.goto(`${serverURL}/admin/globals/settings`); + + // Ensure that we have loaded accesses by checking that settings collection + // at least is visible in the menu. + await expect(page.locator('#nav-global-settings')).toBeVisible(); + + // Test collection should be hidden at first. + await expect(page.locator('#nav-global-test')).toBeHidden(); + + // Allow access to test global. + await page.locator('.custom-checkbox:has(#field-test) button').click(); + await page.locator('#action-save').click(); + + // Now test collection should appear in the menu. + await expect(page.locator('#nav-global-test')).toBeVisible(); + }); +});