Merge branch 'master' of https://github.com/payloadcms/payload into feat/form-builder-example
This commit is contained in:
32
CHANGELOG.md
32
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)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const requests = {
|
||||
}
|
||||
return fetch(`${url}${query}`, {
|
||||
credentials: 'include',
|
||||
headers: options.headers,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -93,7 +93,10 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
|
||||
if (isError) return null;
|
||||
|
||||
return (
|
||||
<DocumentInfoProvider collection={collectionConfig}>
|
||||
<DocumentInfoProvider
|
||||
collection={collectionConfig}
|
||||
id={id}
|
||||
>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultEdit}
|
||||
CustomComponent={collectionConfig.admin?.components?.views?.Edit}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from '../Button';
|
||||
import { Props } from './types';
|
||||
import { useSearchParams } from '../../utilities/SearchParams';
|
||||
import validateWhereQuery from '../WhereBuilder/validateWhereQuery';
|
||||
import flattenFields from '../../../../utilities/flattenTopLevelFields';
|
||||
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched';
|
||||
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||
|
||||
@@ -37,7 +38,10 @@ const ListControls: React.FC<Props> = (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');
|
||||
|
||||
@@ -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<ListDrawerProps> = (props) => {
|
||||
header={false}
|
||||
gutter={false}
|
||||
>
|
||||
<ListDrawerContent {...props} />
|
||||
<ListDrawerContent
|
||||
{...props}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
@@ -22,7 +22,7 @@ export type ListTogglerProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
}
|
||||
|
||||
export type UseListDrawer = (args: {
|
||||
collectionSlugs: string[]
|
||||
collectionSlugs?: string[]
|
||||
selectedCollection?: string
|
||||
uploads?: boolean // finds all collections with upload: true
|
||||
filterOptions?: FilterOptionsResult
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
.clickable-arrow {
|
||||
cursor: pointer;
|
||||
transform: rotate(-90deg);
|
||||
|
||||
&--left {
|
||||
&--right {
|
||||
.icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&--left .icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.clickable-arrow--right {
|
||||
margin-right: base(.25);
|
||||
}
|
||||
|
||||
.clickable-arrow,
|
||||
&__page {
|
||||
@extend %btn-reset;
|
||||
|
||||
@@ -93,16 +93,7 @@ const Pagination: React.FC<Props> = (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> = (props) => {
|
||||
},
|
||||
});
|
||||
|
||||
nodes.unshift({
|
||||
type: 'ClickableArrow',
|
||||
props: {
|
||||
updatePage: () => updatePage(prevPage),
|
||||
isDisabled: !hasPrevPage,
|
||||
direction: 'left',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{nodes.map((node, i) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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> = (props) => {
|
||||
alignLabel && `${baseClass}--align-label-${alignLabel}`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const title: any = doc?.[collection?.admin?.useAsTitle] || doc?.filename || `[${t('untitled')}]`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
@@ -45,12 +47,7 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className={`${baseClass}__label`}>
|
||||
{label && label}
|
||||
{!label && doc && (
|
||||
<Fragment>
|
||||
{typeof doc?.filename === 'string' ? doc?.filename : `[${t('untitled')}]`}
|
||||
</Fragment>
|
||||
)}
|
||||
{label || title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> = (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> = (props) => {
|
||||
};
|
||||
}, [showFromProps, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
setPosition(intersectionEntry?.isIntersecting ? 'top' : 'bottom');
|
||||
}, [intersectionEntry]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
'tooltip',
|
||||
className,
|
||||
show && 'tooltip--show',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
<React.Fragment>
|
||||
<aside
|
||||
ref={ref}
|
||||
className={[
|
||||
'tooltip',
|
||||
className,
|
||||
'tooltip--position-top',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
className={[
|
||||
'tooltip',
|
||||
className,
|
||||
show && 'tooltip--show',
|
||||
`tooltip--position-${position}`,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ export type Props = {
|
||||
children: React.ReactNode
|
||||
show?: boolean
|
||||
delay?: number
|
||||
boundingRef?: React.RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,5 +6,6 @@ export type AuthContext<T = User> = {
|
||||
refreshCookie: () => void
|
||||
setToken: (token: string) => void
|
||||
token?: string
|
||||
refreshPermissions: () => Promise<void>
|
||||
permissions?: Permissions
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({
|
||||
description,
|
||||
@@ -17,6 +18,13 @@ const Meta: React.FC<Props> = ({
|
||||
const favicon = config.admin.meta.favicon ?? payloadFavicon;
|
||||
const ogImage = config.admin.meta.ogImage ?? payloadOgImage;
|
||||
|
||||
useMountEffect(() => {
|
||||
const faviconElement = document.querySelector<HTMLLinkElement>('link[data-placeholder-favicon]');
|
||||
if (faviconElement) {
|
||||
faviconElement.remove();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Helmet
|
||||
htmlAttributes={{
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
.file-field__drop-zone {
|
||||
border-color: var(--theme-success-500);
|
||||
background: var(--theme-success-150);
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,12 +43,15 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
|
||||
});
|
||||
|
||||
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 }];
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<link rel="icon" href="data:," data-placeholder-favicon/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -140,7 +140,7 @@ async function update<TSlug extends keyof GeneratedTypes['collections']>(
|
||||
entityConfig: collectionConfig,
|
||||
req,
|
||||
overrideAccess: true,
|
||||
showHiddenFields,
|
||||
showHiddenFields: true,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -331,25 +331,6 @@ export type Config = {
|
||||
Dashboard?: React.ComponentType<any>;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
@@ -130,6 +130,8 @@ export const promise = async ({
|
||||
const pointDoc = siblingDoc[field.name] as Record<string, unknown>;
|
||||
if (Array.isArray(pointDoc?.coordinates) && pointDoc.coordinates.length === 2) {
|
||||
siblingDoc[field.name] = pointDoc.coordinates;
|
||||
} else {
|
||||
siblingDoc[field.name] = undefined;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -56,6 +56,21 @@ export const promise = async <T>({
|
||||
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;
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Collection>(data: Partial<Collection>, overrideSlug = slug, options?: Partial<CreateOptions<Collection>>): Promise<Collection> {
|
||||
async function createDoc<Collection>(data: Partial<Collection>, overrideSlug = slug, options?: Partial<Collection>): Promise<Collection> {
|
||||
return payload.create({
|
||||
...options,
|
||||
collection: overrideSlug,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
22
test/refresh-permissions/GlobalViewWithRefresh.tsx
Normal file
22
test/refresh-permissions/GlobalViewWithRefresh.tsx
Normal file
@@ -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> = (props) => {
|
||||
const { onSave } = props;
|
||||
const { refreshPermissions } = useAuth();
|
||||
const modifiedOnSave = useCallback((...args) => {
|
||||
onSave.call(null, ...args);
|
||||
refreshPermissions();
|
||||
}, [onSave, refreshPermissions]);
|
||||
|
||||
return (
|
||||
<DefaultGlobalView
|
||||
{...props}
|
||||
onSave={modifiedOnSave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalView;
|
||||
53
test/refresh-permissions/config.ts
Normal file
53
test/refresh-permissions/config.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
35
test/refresh-permissions/e2e.spec.ts
Normal file
35
test/refresh-permissions/e2e.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user