Compare commits
9 Commits
db-postgre
...
chore/db-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fddeb621ca | ||
|
|
399e606b34 | ||
|
|
0d18822062 | ||
|
|
00fc0343da | ||
|
|
6323965c65 | ||
|
|
6d6823c3e5 | ||
|
|
ca70298436 | ||
|
|
4f565759f6 | ||
|
|
df39602758 |
2
.idea/runConfigurations/Run_Dev_Fields.xml
generated
2
.idea/runConfigurations/Run_Dev_Fields.xml
generated
@@ -1,5 +1,5 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run Dev Fields" type="NodeJSConfigurationType" application-parameters="fields" path-to-js-file="node_modules/.pnpm/nodemon@3.0.1/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
|
||||
<configuration default="false" name="Run Dev Fields" type="NodeJSConfigurationType" application-parameters="fields" path-to-js-file="node_modules/.pnpm/nodemon@3.0.3/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
2
.idea/runConfigurations/Run_Dev__community.xml
generated
2
.idea/runConfigurations/Run_Dev__community.xml
generated
@@ -1,5 +1,5 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Run Dev _community" type="NodeJSConfigurationType" application-parameters="_community" path-to-js-file="node_modules/.pnpm/nodemon@3.0.1/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
|
||||
<configuration default="false" name="Run Dev _community" type="NodeJSConfigurationType" application-parameters="_community" path-to-js-file="node_modules/.pnpm/nodemon@3.0.3/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,3 +1,10 @@
|
||||
## [2.11.0](https://github.com/payloadcms/payload/compare/v2.10.1...v2.11.0) (2024-02-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* exposes collapsible provider with more functionality ([#5043](https://github.com/payloadcms/payload/issues/5043)) ([df39602](https://github.com/payloadcms/payload/commit/df39602758ae8dc3765bb48e51f7a657babfa559))
|
||||
|
||||
## [2.10.1](https://github.com/payloadcms/payload/compare/v2.10.0...v2.10.1) (2024-02-09)
|
||||
|
||||
|
||||
|
||||
@@ -635,6 +635,37 @@ export const CustomArrayManager = () => {
|
||||
]}
|
||||
/>
|
||||
|
||||
### useCollapsible
|
||||
|
||||
The `useCollapsible` hook allows you to control parent collapsibles:
|
||||
|
||||
| Property | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| **`collapsed`** | State of the collapsible. `true` if open, `false` if collapsed |
|
||||
| **`isVisible`** | If nested, determine if the nearest collapsible is visible. `true` if no parent is closed, `false` otherwise |
|
||||
| **`toggle`** | Toggles the state of the nearest collapsible |
|
||||
| **`withinCollapsible`** | Determine when you are within another collaspible | |
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
import React from 'react'
|
||||
|
||||
import { useCollapsible } from 'payload/components/utilities'
|
||||
|
||||
const CustomComponent: React.FC = () => {
|
||||
const { collapsed, toggle } = useCollapsible()
|
||||
return (
|
||||
<div>
|
||||
<p className="field-type">I am {collapsed ? 'closed' : 'open'}</p>
|
||||
<button onClick={toggle} type="button">
|
||||
Toggle
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useDocumentInfo
|
||||
|
||||
The `useDocumentInfo` hook provides lots of information about the document currently being edited, including the following:
|
||||
@@ -774,8 +805,8 @@ const MyComponent: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<span>The current theme is {theme} and autoMode is {autoMode}</span>
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(prev => prev === "light" ? "dark" : "light")}
|
||||
>
|
||||
Toggle theme
|
||||
|
||||
16
package.json
16
package.json
@@ -77,12 +77,12 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jwt-decode": "3.1.2",
|
||||
"lexical": "0.12.5",
|
||||
"lexical": "0.13.1",
|
||||
"lint-staged": "^14.0.1",
|
||||
"minimist": "1.2.8",
|
||||
"mongodb-memory-server": "^9",
|
||||
"node-fetch": "2.6.12",
|
||||
"nodemon": "3.0.2",
|
||||
"nodemon": "3.0.3",
|
||||
"prettier": "^3.0.3",
|
||||
"prompts": "2.4.2",
|
||||
"qs": "6.11.2",
|
||||
@@ -106,12 +106,12 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "8.6.0",
|
||||
"drizzle-orm": "0.29.3",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.2.2"
|
||||
"copyfiles": "$copyfiles",
|
||||
"cross-env": "$cross-env",
|
||||
"dotenv": "$dotenv",
|
||||
"drizzle-orm": "$drizzle-orm",
|
||||
"ts-node": "$ts-node",
|
||||
"typescript": "$typescript"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"description": "Node, React and MongoDB Headless CMS and Application Framework",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
@@ -193,7 +193,7 @@
|
||||
"get-port": "5.1.1",
|
||||
"mini-css-extract-plugin": "1.6.2",
|
||||
"node-fetch": "2.6.12",
|
||||
"nodemon": "3.0.1",
|
||||
"nodemon": "3.0.3",
|
||||
"object.assign": "4.1.4",
|
||||
"object.entries": "1.1.6",
|
||||
"passport-strategy": "1.0.0",
|
||||
|
||||
@@ -24,11 +24,16 @@ export const Collapsible: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [collapsedLocal, setCollapsedLocal] = useState(Boolean(initCollapsed))
|
||||
const [hoveringToggle, setHoveringToggle] = useState(false)
|
||||
const isNested = useCollapsible()
|
||||
const { withinCollapsible } = useCollapsible()
|
||||
const { t } = useTranslation('fields')
|
||||
|
||||
const collapsed = typeof collapsedFromProps === 'boolean' ? collapsedFromProps : collapsedLocal
|
||||
|
||||
const toggleCollapsible = React.useCallback(() => {
|
||||
if (typeof onToggle === 'function') onToggle(!collapsed)
|
||||
setCollapsedLocal(!collapsed)
|
||||
}, [onToggle, collapsed])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
@@ -36,14 +41,14 @@ export const Collapsible: React.FC<Props> = ({
|
||||
className,
|
||||
dragHandleProps && `${baseClass}--has-drag-handle`,
|
||||
collapsed && `${baseClass}--collapsed`,
|
||||
isNested && `${baseClass}--nested`,
|
||||
withinCollapsible && `${baseClass}--nested`,
|
||||
hoveringToggle && `${baseClass}--hovered`,
|
||||
`${baseClass}--style-${collapsibleStyle}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<CollapsibleProvider>
|
||||
<CollapsibleProvider collapsed={collapsed} toggle={toggleCollapsible}>
|
||||
<div
|
||||
className={`${baseClass}__toggle-wrap`}
|
||||
onMouseEnter={() => setHoveringToggle(true)}
|
||||
@@ -65,10 +70,7 @@ export const Collapsible: React.FC<Props> = ({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
if (typeof onToggle === 'function') onToggle(!collapsed)
|
||||
setCollapsedLocal(!collapsed)
|
||||
}}
|
||||
onClick={toggleCollapsible}
|
||||
type="button"
|
||||
>
|
||||
<span>{t('toggleBlock')}</span>
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import React, { createContext, useContext } from 'react'
|
||||
|
||||
const Context = createContext(false)
|
||||
type ContextType = {
|
||||
collapsed: boolean
|
||||
isVisible: boolean
|
||||
toggle: () => void
|
||||
withinCollapsible: boolean
|
||||
}
|
||||
const Context = createContext({
|
||||
collapsed: false,
|
||||
isVisible: true,
|
||||
toggle: () => {},
|
||||
withinCollapsible: true,
|
||||
})
|
||||
|
||||
export const CollapsibleProvider: React.FC<{
|
||||
children?: React.ReactNode
|
||||
collapsed?: boolean
|
||||
toggle: () => void
|
||||
withinCollapsible?: boolean
|
||||
}> = ({ children, withinCollapsible = true }) => {
|
||||
return <Context.Provider value={withinCollapsible}>{children}</Context.Provider>
|
||||
}> = ({ children, collapsed, toggle, withinCollapsible = true }) => {
|
||||
const { collapsed: parentIsCollapsed, isVisible } = useCollapsible()
|
||||
|
||||
const contextValue = React.useMemo((): ContextType => {
|
||||
return {
|
||||
collapsed: Boolean(collapsed),
|
||||
isVisible: isVisible && !parentIsCollapsed,
|
||||
toggle,
|
||||
withinCollapsible,
|
||||
}
|
||||
}, [collapsed, withinCollapsible, toggle, parentIsCollapsed, isVisible])
|
||||
return <Context.Provider value={contextValue}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export const useCollapsible = (): boolean => useContext(Context)
|
||||
|
||||
export default Context
|
||||
export const useCollapsible = (): ContextType => useContext(Context)
|
||||
|
||||
@@ -33,7 +33,7 @@ const Group: React.FC<Props> = (props) => {
|
||||
permissions,
|
||||
} = props
|
||||
|
||||
const isWithinCollapsible = useCollapsible()
|
||||
const { withinCollapsible } = useCollapsible()
|
||||
const isWithinGroup = useGroup()
|
||||
const isWithinRow = useRow()
|
||||
const isWithinTab = useTabs()
|
||||
@@ -43,7 +43,7 @@ const Group: React.FC<Props> = (props) => {
|
||||
const groupHasErrors = submitted && errorCount > 0
|
||||
|
||||
const path = pathFromProps || name
|
||||
const isTopLevel = !(isWithinCollapsible || isWithinGroup || isWithinRow)
|
||||
const isTopLevel = !(withinCollapsible || isWithinGroup || isWithinRow)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -51,7 +51,7 @@ const Group: React.FC<Props> = (props) => {
|
||||
fieldBaseClass,
|
||||
baseClass,
|
||||
isTopLevel && `${baseClass}--top-level`,
|
||||
isWithinCollapsible && `${baseClass}--within-collapsible`,
|
||||
withinCollapsible && `${baseClass}--within-collapsible`,
|
||||
isWithinGroup && `${baseClass}--within-group`,
|
||||
isWithinRow && `${baseClass}--within-row`,
|
||||
isWithinTab && `${baseClass}--within-tab`,
|
||||
|
||||
@@ -83,7 +83,7 @@ const TabsField: React.FC<Props> = (props) => {
|
||||
const { preferencesKey } = useDocumentInfo()
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
const isWithinCollapsible = useCollapsible()
|
||||
const { withinCollapsible } = useCollapsible()
|
||||
const [activeTabIndex, setActiveTabIndex] = useState<number>(0)
|
||||
const tabsPrefKey = `tabs-${indexPath}`
|
||||
|
||||
@@ -138,7 +138,7 @@ const TabsField: React.FC<Props> = (props) => {
|
||||
fieldBaseClass,
|
||||
className,
|
||||
baseClass,
|
||||
isWithinCollapsible && `${baseClass}--within-collapsible`,
|
||||
withinCollapsible && `${baseClass}--within-collapsible`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const incrementLoginAttempts = async ({
|
||||
lockUntil: null,
|
||||
loginAttempts: 1,
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
})
|
||||
}
|
||||
@@ -52,6 +53,7 @@ export const incrementLoginAttempts = async ({
|
||||
id: doc.id,
|
||||
collection: collection.slug,
|
||||
data,
|
||||
depth: 0,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export const registerLocalStrategy = async ({
|
||||
const existingUser = await payload.find({
|
||||
collection: collection.slug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
where: {
|
||||
email: {
|
||||
equals: doc.email,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const resetLoginAttempts = async ({
|
||||
lockUntil: null,
|
||||
loginAttempts: 0,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useCollapsible } from '../../admin/components/elements/Collapsible/provider'
|
||||
export { default as buildStateFromSchema } from '../../admin/components/forms/Form/buildStateFromSchema'
|
||||
export { useAuth } from '../../admin/components/utilities/Auth'
|
||||
export { useConfig } from '../../admin/components/utilities/Config'
|
||||
|
||||
@@ -31,16 +31,14 @@ async function deleteOperation(args: PreferenceRequest): Promise<Document> {
|
||||
],
|
||||
}
|
||||
|
||||
const result = await payload.delete({
|
||||
const result = await payload.db.deleteOne({
|
||||
collection: 'payload-preferences',
|
||||
depth: 0,
|
||||
user,
|
||||
req,
|
||||
where,
|
||||
})
|
||||
|
||||
// @ts-expect-error // TODO: fix later
|
||||
if (result.docs.length === 1) {
|
||||
return result.docs[0]
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
throw new NotFound()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ async function findOne(
|
||||
const {
|
||||
key,
|
||||
req: { payload },
|
||||
req,
|
||||
user,
|
||||
} = args
|
||||
|
||||
@@ -21,17 +22,11 @@ async function findOne(
|
||||
],
|
||||
}
|
||||
|
||||
const { docs } = await payload.find({
|
||||
return await payload.db.findOne({
|
||||
collection: 'payload-preferences',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
user,
|
||||
req,
|
||||
where,
|
||||
})
|
||||
|
||||
if (docs.length === 0) return null
|
||||
|
||||
return docs[0]
|
||||
}
|
||||
|
||||
export default findOne
|
||||
|
||||
@@ -65,6 +65,7 @@ const replaceWithDraftIfAvailable = async <T extends TypeWithID>({
|
||||
global: entity.slug,
|
||||
limit: 1,
|
||||
locale,
|
||||
pagination: false,
|
||||
req,
|
||||
sort: '-updatedAt',
|
||||
where: combineQueries(queryToBuild, versionAccessResult),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { SanitizedCollectionConfig } from '../collections/config/types'
|
||||
import type { SanitizedGlobalConfig } from '../globals/config/types'
|
||||
import type { Payload } from '../payload'
|
||||
import type { PayloadRequest } from '../types'
|
||||
import type { Where } from '../types'
|
||||
import type { PayloadRequest, Where } from '../types'
|
||||
|
||||
type Args = {
|
||||
collection?: SanitizedCollectionConfig
|
||||
@@ -46,6 +45,7 @@ export const enforceMaxVersions = async ({
|
||||
} else if (global) {
|
||||
const query = await payload.db.findGlobalVersions({
|
||||
global: global.slug,
|
||||
pagination: false,
|
||||
req,
|
||||
skip: max,
|
||||
sort: '-updatedAt',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const getLatestCollectionVersion = async <T extends TypeWithID = any>({
|
||||
if (config.versions?.drafts) {
|
||||
const { docs } = await payload.db.findVersions<T>({
|
||||
collection: config.slug,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
req,
|
||||
sort: '-updatedAt',
|
||||
where: { parent: { equals: id } },
|
||||
|
||||
@@ -14,11 +14,11 @@ type Args = {
|
||||
}
|
||||
|
||||
export const getLatestGlobalVersion = async ({
|
||||
slug,
|
||||
config,
|
||||
locale,
|
||||
payload,
|
||||
req,
|
||||
slug,
|
||||
where,
|
||||
}: Args): Promise<{ global: Document; globalExists: boolean }> => {
|
||||
let latestVersion
|
||||
@@ -30,6 +30,7 @@ export const getLatestGlobalVersion = async ({
|
||||
global: slug,
|
||||
limit: 1,
|
||||
locale,
|
||||
pagination: false,
|
||||
req,
|
||||
sort: '-updatedAt',
|
||||
})
|
||||
@@ -37,9 +38,9 @@ export const getLatestGlobalVersion = async ({
|
||||
}
|
||||
|
||||
const global = await payload.db.findGlobal({
|
||||
slug,
|
||||
locale,
|
||||
req,
|
||||
slug,
|
||||
where,
|
||||
})
|
||||
const globalExists = Boolean(global)
|
||||
|
||||
@@ -40,6 +40,7 @@ export const saveVersion = async ({
|
||||
let docs
|
||||
const findVersionArgs = {
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
req,
|
||||
sort: '-updatedAt',
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"@types/find-node-modules": "^2.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^8.2.0",
|
||||
"nodemon": "^2.0.6",
|
||||
"nodemon": "3.0.3",
|
||||
"payload": "workspace:*",
|
||||
"rimraf": "^4.1.2",
|
||||
"ts-node": "^9.1.1",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/react": "18.2.15",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"nodemon": "^3.0.2",
|
||||
"nodemon": "3.0.3",
|
||||
"payload": "workspace:*",
|
||||
"react": "^18.0.0",
|
||||
"ts-node": "10.9.1"
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^8.2.0",
|
||||
"jest": "^29.5.0",
|
||||
"nodemon": "^2.0.6",
|
||||
"nodemon": "3.0.3",
|
||||
"payload": "workspace:*",
|
||||
"ts-jest": "^29.1.0",
|
||||
"webpack": "^5.78.0"
|
||||
|
||||
@@ -19,31 +19,31 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@faceless-ui/modal": "2.0.1",
|
||||
"@lexical/headless": "0.12.6",
|
||||
"@lexical/link": "0.12.6",
|
||||
"@lexical/list": "0.12.6",
|
||||
"@lexical/mark": "0.12.6",
|
||||
"@lexical/markdown": "0.12.6",
|
||||
"@lexical/react": "0.12.6",
|
||||
"@lexical/rich-text": "0.12.6",
|
||||
"@lexical/selection": "0.12.6",
|
||||
"@lexical/utils": "0.12.6",
|
||||
"@lexical/headless": "0.13.1",
|
||||
"@lexical/link": "0.13.1",
|
||||
"@lexical/list": "0.13.1",
|
||||
"@lexical/mark": "0.13.1",
|
||||
"@lexical/markdown": "0.13.1",
|
||||
"@lexical/react": "0.13.1",
|
||||
"@lexical/rich-text": "0.13.1",
|
||||
"@lexical/selection": "0.13.1",
|
||||
"@lexical/utils": "0.13.1",
|
||||
"bson-objectid": "2.0.4",
|
||||
"classnames": "^2.3.2",
|
||||
"deep-equal": "2.2.3",
|
||||
"i18next": "22.5.1",
|
||||
"json-schema": "^0.4.0",
|
||||
"lexical": "0.12.6",
|
||||
"lexical": "0.13.1",
|
||||
"lodash": "4.17.21",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-error-boundary": "4.0.12",
|
||||
"react-i18next": "11.18.6",
|
||||
"ts-essentials": "7.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@types/json-schema": "7.0.12",
|
||||
"@types/json-schema": "7.0.15",
|
||||
"@types/node": "20.6.2",
|
||||
"@types/react": "18.2.15",
|
||||
"payload": "workspace:*"
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SerializedQuoteNode } from '@lexical/rich-text'
|
||||
|
||||
import { $createQuoteNode, QuoteNode } from '@lexical/rich-text'
|
||||
import { $setBlocksType } from '@lexical/selection'
|
||||
import { $INTERNAL_isPointSelection, $getSelection } from 'lexical'
|
||||
import { $getSelection } from 'lexical'
|
||||
|
||||
import type { HTMLConverter } from '../converters/html/converter/types'
|
||||
import type { FeatureProvider } from '../types'
|
||||
@@ -31,9 +31,7 @@ export const BlockQuoteFeature = (): FeatureProvider => {
|
||||
onClick: ({ editor }) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createQuoteNode())
|
||||
}
|
||||
$setBlocksType(selection, () => $createQuoteNode())
|
||||
})
|
||||
},
|
||||
order: 20,
|
||||
@@ -44,6 +42,7 @@ export const BlockQuoteFeature = (): FeatureProvider => {
|
||||
markdownTransformers: [MarkdownTransformer],
|
||||
nodes: [
|
||||
{
|
||||
type: QuoteNode.getType(),
|
||||
converters: {
|
||||
html: {
|
||||
converter: async ({ converters, node, parent }) => {
|
||||
@@ -62,7 +61,6 @@ export const BlockQuoteFeature = (): FeatureProvider => {
|
||||
} as HTMLConverter<SerializedQuoteNode>,
|
||||
},
|
||||
node: QuoteNode,
|
||||
type: QuoteNode.getType(),
|
||||
},
|
||||
],
|
||||
props: null,
|
||||
@@ -82,9 +80,7 @@ export const BlockQuoteFeature = (): FeatureProvider => {
|
||||
keywords: ['quote', 'blockquote'],
|
||||
onSelect: () => {
|
||||
const selection = $getSelection()
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createQuoteNode())
|
||||
}
|
||||
$setBlocksType(selection, () => $createQuoteNode())
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -39,8 +39,16 @@ export function BlocksPlugin(): JSX.Element | null {
|
||||
const { focus } = selection
|
||||
const focusNode = focus.getNode()
|
||||
|
||||
// First, delete currently selected node if it's an empty paragraph
|
||||
if ($isParagraphNode(focusNode) && focusNode.getTextContentSize() === 0) {
|
||||
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
|
||||
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
|
||||
if (
|
||||
$isParagraphNode(focusNode) &&
|
||||
focusNode.getTextContentSize() === 0 &&
|
||||
focusNode
|
||||
.getParent()
|
||||
.getChildren()
|
||||
.filter((node) => $isParagraphNode(node)).length > 1
|
||||
) {
|
||||
focusNode.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { HeadingTagType, SerializedHeadingNode } from '@lexical/rich-text'
|
||||
|
||||
import { $createHeadingNode, HeadingNode } from '@lexical/rich-text'
|
||||
import { $setBlocksType } from '@lexical/selection'
|
||||
import { $INTERNAL_isPointSelection, $getSelection } from 'lexical'
|
||||
import { $getSelection } from 'lexical'
|
||||
|
||||
import type { HTMLConverter } from '../converters/html/converter/types'
|
||||
import type { FeatureProvider } from '../types'
|
||||
@@ -14,9 +14,7 @@ import { MarkdownTransformer } from './markdownTransformer'
|
||||
|
||||
const setHeading = (headingSize: HeadingTagType) => {
|
||||
const selection = $getSelection()
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createHeadingNode(headingSize))
|
||||
}
|
||||
$setBlocksType(selection, () => $createHeadingNode(headingSize))
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -67,6 +65,7 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
|
||||
markdownTransformers: [MarkdownTransformer(enabledHeadingSizes)],
|
||||
nodes: [
|
||||
{
|
||||
type: HeadingNode.getType(),
|
||||
converters: {
|
||||
html: {
|
||||
converter: async ({ converters, node, parent }) => {
|
||||
@@ -85,7 +84,6 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
|
||||
} as HTMLConverter<SerializedHeadingNode>,
|
||||
},
|
||||
node: HeadingNode,
|
||||
type: HeadingNode.getType(),
|
||||
},
|
||||
],
|
||||
props,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
|
||||
import type { LinkPayload } from '../plugins/floatingLinkEditor/types'
|
||||
|
||||
import { $isAutoLinkNode } from './AutoLinkNode'
|
||||
|
||||
export type LinkFields = {
|
||||
// unknown, custom fields:
|
||||
[key: string]: unknown
|
||||
@@ -140,8 +142,8 @@ export class LinkNode extends ElementNode {
|
||||
exportJSON(): SerializedLinkNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
fields: this.getFields(),
|
||||
type: this.getType(),
|
||||
fields: this.getFields(),
|
||||
version: 2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,8 @@ function handleLinkCreation(
|
||||
onChange: ChangeHandler,
|
||||
): void {
|
||||
let currentNodes = [...nodes]
|
||||
let text = currentNodes.map((node) => node.getTextContent()).join('')
|
||||
const initialText = currentNodes.map((node) => node.getTextContent()).join('')
|
||||
let text = initialText
|
||||
|
||||
let match
|
||||
let invalidMatchEnd = 0
|
||||
@@ -247,7 +248,7 @@ function handleLinkCreation(
|
||||
const isValid = isContentAroundIsValid(
|
||||
invalidMatchEnd + matchStart,
|
||||
invalidMatchEnd + matchEnd,
|
||||
text,
|
||||
initialText,
|
||||
currentNodes,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import { useEditorConfigContext } from '../../../../../lexical/config/EditorConf
|
||||
import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode'
|
||||
import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor'
|
||||
import { LinkDrawer } from '../../../drawer'
|
||||
import { $isAutoLinkNode } from '../../../nodes/AutoLinkNode'
|
||||
import { $createLinkNode } from '../../../nodes/LinkNode'
|
||||
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode'
|
||||
import { transformExtraFields } from '../utilities'
|
||||
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands'
|
||||
@@ -73,7 +75,7 @@ export function LinkEditor({
|
||||
// Sanitize custom fields here
|
||||
const validRelationships = config.collections.map((c) => c.slug) || []
|
||||
const fields = sanitizeFields({
|
||||
config: config,
|
||||
config,
|
||||
fields: fieldsUnsanitized,
|
||||
validRelationships,
|
||||
})
|
||||
@@ -84,10 +86,11 @@ export function LinkEditor({
|
||||
const { closeModal, toggleModal } = useModal()
|
||||
const editDepth = useEditDepth()
|
||||
const [isLink, setIsLink] = useState(false)
|
||||
const [isAutoLink, setIsAutoLink] = useState(false)
|
||||
|
||||
const drawerSlug = formatDrawerSlug({
|
||||
depth: editDepth,
|
||||
slug: `lexical-rich-text-link-` + uuid,
|
||||
depth: editDepth,
|
||||
})
|
||||
|
||||
const updateLinkEditor = useCallback(async () => {
|
||||
@@ -98,9 +101,10 @@ export function LinkEditor({
|
||||
if ($isRangeSelection(selection)) {
|
||||
const node = getSelectedNode(selection)
|
||||
selectedNodeDomRect = editor.getElementByKey(node.getKey())?.getBoundingClientRect()
|
||||
const linkParent: LinkNode = $findMatchingParent(node, $isLinkNode) as LinkNode
|
||||
const linkParent: LinkNode = $findMatchingParent(node, $isLinkNode)
|
||||
if (linkParent == null) {
|
||||
setIsLink(false)
|
||||
setIsAutoLink(false)
|
||||
setLinkUrl('')
|
||||
setLinkLabel('')
|
||||
return
|
||||
@@ -152,6 +156,11 @@ export function LinkEditor({
|
||||
})
|
||||
setInitialState(state)
|
||||
setIsLink(true)
|
||||
if ($isAutoLinkNode(linkParent)) {
|
||||
setIsAutoLink(true)
|
||||
} else {
|
||||
setIsAutoLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
const editorElem = editorRef.current
|
||||
@@ -265,6 +274,7 @@ export function LinkEditor({
|
||||
() => {
|
||||
if (isLink) {
|
||||
setIsLink(false)
|
||||
setIsAutoLink(false)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -301,18 +311,20 @@ export function LinkEditor({
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label="Remove link"
|
||||
className="link-trash"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
{!isAutoLink && (
|
||||
<button
|
||||
aria-label="Remove link"
|
||||
className="link-trash"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
@@ -325,6 +337,22 @@ export function LinkEditor({
|
||||
|
||||
const newLinkPayload: LinkPayload = data as LinkPayload
|
||||
|
||||
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const parent = getSelectedNode(selection).getParent()
|
||||
if ($isAutoLinkNode(parent)) {
|
||||
const linkNode = $createLinkNode({
|
||||
fields: newLinkPayload.fields,
|
||||
})
|
||||
parent.replace(linkNode, true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
|
||||
// it being applied to the auto link node instead of the link node.
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload)
|
||||
}}
|
||||
initialState={initialState}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { $setBlocksType } from '@lexical/selection'
|
||||
import { $INTERNAL_isPointSelection, $createParagraphNode, $getSelection } from 'lexical'
|
||||
import { $createParagraphNode, $getSelection } from 'lexical'
|
||||
|
||||
import type { FeatureProvider } from '../types'
|
||||
|
||||
@@ -23,9 +23,7 @@ export const ParagraphFeature = (): FeatureProvider => {
|
||||
onClick: ({ editor }) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
}
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
})
|
||||
},
|
||||
order: 1,
|
||||
@@ -49,9 +47,7 @@ export const ParagraphFeature = (): FeatureProvider => {
|
||||
onSelect: ({ editor }) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
}
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -54,8 +54,16 @@ export function RelationshipPlugin(props?: RelationshipFeatureProps): JSX.Elemen
|
||||
const { focus } = selection
|
||||
const focusNode = focus.getNode()
|
||||
|
||||
// First, delete currently selected node if it's an empty paragraph
|
||||
if ($isParagraphNode(focusNode) && focusNode.getTextContentSize() === 0) {
|
||||
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
|
||||
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
|
||||
if (
|
||||
$isParagraphNode(focusNode) &&
|
||||
focusNode.getTextContentSize() === 0 &&
|
||||
focusNode
|
||||
.getParent()
|
||||
.getChildren()
|
||||
.filter((node) => $isParagraphNode(node)).length > 1
|
||||
) {
|
||||
focusNode.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,16 @@ export function UploadPlugin(): JSX.Element | null {
|
||||
const { focus } = selection
|
||||
const focusNode = focus.getNode()
|
||||
|
||||
// First, delete currently selected node if it's an empty paragraph
|
||||
if ($isParagraphNode(focusNode) && focusNode.getTextContentSize() === 0) {
|
||||
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
|
||||
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
|
||||
if (
|
||||
$isParagraphNode(focusNode) &&
|
||||
focusNode.getTextContentSize() === 0 &&
|
||||
focusNode
|
||||
.getParent()
|
||||
.getChildren()
|
||||
.filter((node) => $isParagraphNode(node)).length > 1
|
||||
) {
|
||||
focusNode.remove()
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,22 @@ export const AlignFeature = (): FeatureProvider => {
|
||||
order: 3,
|
||||
},
|
||||
]),
|
||||
AlignDropdownSectionWithEntries([
|
||||
{
|
||||
ChildComponent: () =>
|
||||
// @ts-expect-error
|
||||
import('../../lexical/ui/icons/AlignJustify').then(
|
||||
(module) => module.AlignJustifyIcon,
|
||||
),
|
||||
isActive: () => false,
|
||||
key: 'align-justify',
|
||||
label: `Align Justify`,
|
||||
onClick: ({ editor }) => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
props: null,
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
import type { ParagraphNode } from 'lexical'
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getNodeByKey,
|
||||
type LexicalEditor,
|
||||
type LexicalNode,
|
||||
} from 'lexical'
|
||||
import { $createParagraphNode } from 'lexical'
|
||||
import { $getNodeByKey, type LexicalEditor, type LexicalNode } from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -50,14 +46,13 @@ function getBlockElement(
|
||||
horizontalOffset = 0,
|
||||
): {
|
||||
blockElem: HTMLElement | null
|
||||
shouldRemove: boolean
|
||||
blockNode: LexicalNode | null
|
||||
} {
|
||||
const anchorElementRect = anchorElem.getBoundingClientRect()
|
||||
const topLevelNodeKeys = getTopLevelNodeKeys(editor)
|
||||
|
||||
let blockElem: HTMLElement | null = null
|
||||
let blockNode: LexicalNode | null = null
|
||||
let shouldRemove = false
|
||||
|
||||
// Return null if matching block element is the first or last node
|
||||
editor.getEditorState().read(() => {
|
||||
@@ -82,7 +77,6 @@ function getBlockElement(
|
||||
if (blockElem) {
|
||||
return {
|
||||
blockElem: null,
|
||||
shouldRemove,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,16 +112,6 @@ function getBlockElement(
|
||||
blockElem = elem
|
||||
blockNode = $getNodeByKey(key)
|
||||
prevIndex = index
|
||||
|
||||
// Check if blockNode is an empty text node
|
||||
if (
|
||||
!blockNode ||
|
||||
blockNode.getType() !== 'paragraph' ||
|
||||
blockNode.getTextContent() !== ''
|
||||
) {
|
||||
blockElem = null
|
||||
shouldRemove = true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -147,8 +131,8 @@ function getBlockElement(
|
||||
})
|
||||
|
||||
return {
|
||||
blockElem: blockElem,
|
||||
shouldRemove,
|
||||
blockElem,
|
||||
blockNode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +144,10 @@ function useAddBlockHandle(
|
||||
const scrollerElem = anchorElem.parentElement
|
||||
|
||||
const menuRef = useRef<HTMLButtonElement>(null)
|
||||
const [emptyBlockElem, setEmptyBlockElem] = useState<HTMLElement | null>(null)
|
||||
const [hoveredElement, setHoveredElement] = useState<{
|
||||
elem: HTMLElement
|
||||
node: LexicalNode
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onDocumentMouseMove(event: MouseEvent) {
|
||||
@@ -185,7 +172,7 @@ function useAddBlockHandle(
|
||||
pageX < left - horizontalBuffer ||
|
||||
pageX > right + horizontalBuffer
|
||||
) {
|
||||
setEmptyBlockElem(null)
|
||||
setHoveredElement(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -199,21 +186,24 @@ function useAddBlockHandle(
|
||||
if (isOnHandleElement(target, ADD_BLOCK_MENU_CLASSNAME)) {
|
||||
return
|
||||
}
|
||||
const { blockElem: _emptyBlockElem, shouldRemove } = getBlockElement(
|
||||
const { blockElem: _emptyBlockElem, blockNode } = getBlockElement(
|
||||
anchorElem,
|
||||
editor,
|
||||
event,
|
||||
false,
|
||||
-distanceFromScrollerElem,
|
||||
)
|
||||
if (!_emptyBlockElem && !shouldRemove) {
|
||||
if (!_emptyBlockElem) {
|
||||
return
|
||||
}
|
||||
setEmptyBlockElem(_emptyBlockElem)
|
||||
setHoveredElement({
|
||||
elem: _emptyBlockElem,
|
||||
node: blockNode,
|
||||
})
|
||||
}
|
||||
|
||||
// Since the draggableBlockElem is outside the actual editor, we need to listen to the document
|
||||
// to be able to detect when the mouse is outside the editor and respect a buffer around the
|
||||
// to be able to detect when the mouse is outside the editor and respect a buffer around
|
||||
// the scrollerElem to avoid the draggableBlockElem disappearing too early.
|
||||
document?.addEventListener('mousemove', onDocumentMouseMove)
|
||||
|
||||
@@ -223,42 +213,86 @@ function useAddBlockHandle(
|
||||
}, [scrollerElem, anchorElem, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
setHandlePosition(emptyBlockElem, menuRef.current, anchorElem, SPACE)
|
||||
if (menuRef.current && hoveredElement?.node) {
|
||||
editor.getEditorState().read(() => {
|
||||
// Check if blockNode is an empty text node
|
||||
let isEmptyParagraph = true
|
||||
if (
|
||||
hoveredElement.node.getType() !== 'paragraph' ||
|
||||
hoveredElement.node.getTextContent() !== ''
|
||||
) {
|
||||
isEmptyParagraph = false
|
||||
}
|
||||
|
||||
setHandlePosition(
|
||||
hoveredElement?.elem,
|
||||
menuRef.current,
|
||||
anchorElem,
|
||||
isEmptyParagraph ? SPACE : SPACE - 20,
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [anchorElem, emptyBlockElem])
|
||||
}, [anchorElem, hoveredElement, editor])
|
||||
|
||||
const handleAddClick = useCallback(
|
||||
(event) => {
|
||||
if (!emptyBlockElem) {
|
||||
let hoveredElementToUse = hoveredElement
|
||||
if (!hoveredElementToUse?.node) {
|
||||
return
|
||||
}
|
||||
let node: ParagraphNode
|
||||
editor.update(() => {
|
||||
node = $getNearestNodeFromDOMNode(emptyBlockElem) as ParagraphNode
|
||||
if (!node || node.getType() !== 'paragraph') {
|
||||
return
|
||||
}
|
||||
editor.focus()
|
||||
|
||||
node.select()
|
||||
/*const ns = $createNodeSelection();
|
||||
ns.add(node.getKey())
|
||||
$setSelection(ns)*/
|
||||
// 1. Update hoveredElement.node to a new paragraph node if the hoveredElement.node is not a paragraph node
|
||||
editor.update(() => {
|
||||
// Check if blockNode is an empty text node
|
||||
let isEmptyParagraph = true
|
||||
if (
|
||||
hoveredElementToUse.node.getType() !== 'paragraph' ||
|
||||
hoveredElementToUse.node.getTextContent() !== ''
|
||||
) {
|
||||
isEmptyParagraph = false
|
||||
}
|
||||
|
||||
if (!isEmptyParagraph) {
|
||||
const newParagraph = $createParagraphNode()
|
||||
hoveredElementToUse.node.insertAfter(newParagraph)
|
||||
|
||||
setTimeout(() => {
|
||||
hoveredElementToUse = {
|
||||
elem: editor.getElementByKey(newParagraph.getKey()),
|
||||
node: newParagraph,
|
||||
}
|
||||
setHoveredElement(hoveredElementToUse)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
// Make sure this is called AFTER the editorfocus() event has been processed by the browser
|
||||
// 2. Focus on the new paragraph node
|
||||
setTimeout(() => {
|
||||
editor.update(() => {
|
||||
editor.focus()
|
||||
|
||||
if (
|
||||
hoveredElementToUse.node &&
|
||||
'select' in hoveredElementToUse.node &&
|
||||
typeof hoveredElementToUse.node.select === 'function'
|
||||
) {
|
||||
hoveredElementToUse.node.select()
|
||||
}
|
||||
})
|
||||
}, 1)
|
||||
|
||||
// Make sure this is called AFTER the focusing has been processed by the browser
|
||||
// Otherwise, this won't work
|
||||
setTimeout(() => {
|
||||
editor.dispatchCommand(ENABLE_SLASH_MENU_COMMAND, {
|
||||
node: node,
|
||||
node: hoveredElementToUse.node as ParagraphNode,
|
||||
})
|
||||
}, 0)
|
||||
}, 2)
|
||||
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
},
|
||||
[editor, emptyBlockElem],
|
||||
[editor, hoveredElement],
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
|
||||
@@ -56,6 +56,7 @@ export const LexicalEditorTheme: EditorThemeClasses = {
|
||||
inlineImage: 'LexicalEditor__inline-image',
|
||||
link: 'LexicalEditorTheme__link',
|
||||
list: {
|
||||
checklist: 'LexicalEditorTheme__checklist',
|
||||
listitem: 'LexicalEditorTheme__listItem',
|
||||
listitemChecked: 'LexicalEditorTheme__listItemChecked',
|
||||
listitemUnchecked: 'LexicalEditorTheme__listItemUnchecked',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
export const AlignJustifyIcon: React.FC = () => (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="icon"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M2.5 5H17.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M2.5 10H17.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M2.5 15H17.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
771
pnpm-lock.yaml
generated
771
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user