first commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* WordPress dependencies
|
||||
*/
|
||||
const {
|
||||
__,
|
||||
} = wp.i18n;
|
||||
|
||||
const {
|
||||
Component,
|
||||
Fragment,
|
||||
} = wp.element;
|
||||
|
||||
const {
|
||||
TextControl,
|
||||
Notice,
|
||||
} = wp.components;
|
||||
|
||||
/**
|
||||
* Component
|
||||
*/
|
||||
export default class ComponentDimensionControl extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.isValidValue = this.isValidValue.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value valid.
|
||||
*
|
||||
* @param {Mixed} value value to check.
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
isValidValue(value) {
|
||||
const validUnits = ['fr', 'rem', 'em', 'ex', '%', 'px', 'cm', 'mm', 'in', 'pt', 'pc', 'ch', 'vh', 'vw', 'vmin', 'vmax'];
|
||||
|
||||
// Whitelist values.
|
||||
if (!value || '' === value || 0 === value || '0' === value || 'auto' === value || 'inherit' === value || 'initial' === value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip checking if calc().
|
||||
if (0 <= value.indexOf('calc(') && 0 <= value.indexOf(')')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the numeric value.
|
||||
const numericValue = parseFloat(value);
|
||||
|
||||
// Get the unit
|
||||
const unit = value.replace(numericValue, '');
|
||||
|
||||
// Allow unitless.
|
||||
if (!unit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check the validity of the numeric value and units.
|
||||
return (!isNaN(numericValue) && -1 !== validUnits.indexOf(unit));
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
label,
|
||||
help,
|
||||
onChange,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TextControl
|
||||
label={label}
|
||||
help={help}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{ !this.isValidValue(value) ? (
|
||||
<Notice status="warning" isDismissible={false}>
|
||||
{ __('Invalid dimension value.')}
|
||||
</Notice>
|
||||
) : ''}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
|
||||
const { __ } = wp.i18n;
|
||||
|
||||
const {
|
||||
Component,
|
||||
} = wp.element;
|
||||
|
||||
const {
|
||||
BaseControl,
|
||||
DropZone,
|
||||
Button,
|
||||
} = wp.components;
|
||||
|
||||
const {
|
||||
MediaPlaceholder,
|
||||
MediaUpload,
|
||||
mediaUpload,
|
||||
} = wp.editor;
|
||||
|
||||
/**
|
||||
* Component
|
||||
*/
|
||||
export default class ComponentGalleryControl extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
render() {
|
||||
const ALLOWED_MEDIA_TYPES = [ 'image' ];
|
||||
|
||||
const {
|
||||
val,
|
||||
label,
|
||||
help,
|
||||
onChange,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<BaseControl
|
||||
label={ label || false }
|
||||
help={ help || false }
|
||||
>
|
||||
{ ! val || ! val.length ? (
|
||||
<MediaPlaceholder
|
||||
icon="format-gallery"
|
||||
labels={ {
|
||||
title: label,
|
||||
name: __( 'images' ),
|
||||
} }
|
||||
onSelect={ ( images ) => {
|
||||
const result = images.map( ( image ) => {
|
||||
return image.id;
|
||||
} );
|
||||
|
||||
onChange( result );
|
||||
} }
|
||||
accept="image/*"
|
||||
allowedTypes={ ALLOWED_MEDIA_TYPES }
|
||||
disableMaxUploadErrorMessages
|
||||
multiple
|
||||
onError={ ( e ) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log( e );
|
||||
} }
|
||||
/>
|
||||
) : '' }
|
||||
{ val && val.length ? (
|
||||
<MediaUpload
|
||||
onSelect={ ( images ) => {
|
||||
const result = images.map( ( image ) => {
|
||||
return image.id;
|
||||
} );
|
||||
|
||||
onChange( result );
|
||||
} }
|
||||
allowedTypes={ ALLOWED_MEDIA_TYPES }
|
||||
multiple
|
||||
gallery
|
||||
value={ val }
|
||||
render={ ( { open } ) => (
|
||||
<div
|
||||
className="sight-gutenberg-component-gallery"
|
||||
onClick={ open }
|
||||
role="presentation"
|
||||
>
|
||||
<DropZone
|
||||
onFilesDrop={ ( files ) => {
|
||||
const currentImages = val || [];
|
||||
mediaUpload( {
|
||||
allowedTypes: ALLOWED_MEDIA_TYPES,
|
||||
filesList: files,
|
||||
onFileChange: ( images ) => {
|
||||
const result = images.map( ( image ) => {
|
||||
return image.id;
|
||||
} );
|
||||
|
||||
onChange( currentImages.concat( result ) );
|
||||
},
|
||||
onError( e ) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log( e );
|
||||
},
|
||||
} );
|
||||
} }
|
||||
/>
|
||||
|
||||
{ val ? (
|
||||
<div className="sight-gutenberg-component-gallery-list">
|
||||
{val.map(imageId => {
|
||||
return (
|
||||
<img src={ sightBlockConfig.ajax_url + '?action=sight_render_thumbnail&image_id=' + imageId } />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : '' }
|
||||
|
||||
<div className="sight-gutenberg-component-gallery-button">
|
||||
<Button isDefault={ true }>{ __( 'Edit Gallery' ) }</Button>
|
||||
</div>
|
||||
</div>
|
||||
) }
|
||||
/>
|
||||
) : '' }
|
||||
</BaseControl>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* WordPress dependencies
|
||||
*/
|
||||
const {
|
||||
Component,
|
||||
RawHTML,
|
||||
} = wp.element;
|
||||
|
||||
const {
|
||||
Button,
|
||||
Popover,
|
||||
} = wp.components;
|
||||
|
||||
/**
|
||||
* Component
|
||||
*/
|
||||
export default class ComponentImageSelector extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
render() {
|
||||
let {
|
||||
className,
|
||||
value,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
items,
|
||||
onChange,
|
||||
} = this.props;
|
||||
|
||||
className = classnames(
|
||||
'sight-component-image-selector',
|
||||
className
|
||||
);
|
||||
|
||||
const layouts = Object.keys(items).map((key) => {
|
||||
return {
|
||||
content: <RawHTML>{items[key].icon}</RawHTML>,
|
||||
value: key,
|
||||
label: items[key].name,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{ layouts && layouts.length ? (
|
||||
layouts.map((itemData, i) => {
|
||||
const itemClassName = classnames(
|
||||
'sight-component-image-selector-item',
|
||||
{
|
||||
'sight-component-image-selector-item-active': value === itemData.value,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`sight-component-image-selector-item-${itemData.value}`}
|
||||
className={itemClassName}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange(itemData.value);
|
||||
}}
|
||||
>
|
||||
{itemData.content}
|
||||
</Button>
|
||||
<span>{itemData.label}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : null }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import ReactSelect from 'react-select';
|
||||
import { debounce } from 'throttle-debounce';
|
||||
|
||||
/**
|
||||
* WordPress dependencies
|
||||
*/
|
||||
const {
|
||||
Component,
|
||||
} = wp.element;
|
||||
|
||||
const {
|
||||
withSelect,
|
||||
} = wp.data;
|
||||
|
||||
const {
|
||||
BaseControl,
|
||||
} = wp.components;
|
||||
|
||||
const cachedPosts = {};
|
||||
|
||||
function maybeCache( postType, newPosts ) {
|
||||
if ( ! newPosts || ! newPosts.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! cachedPosts[ postType ] ) {
|
||||
cachedPosts[ postType ] = {};
|
||||
}
|
||||
|
||||
newPosts.forEach( ( postData ) => {
|
||||
if ( ! cachedPosts[ postType ][ postData.id ] ) {
|
||||
cachedPosts[ postType ][ postData.id ] = postData;
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Component
|
||||
*/
|
||||
class ComponentPostsSelectorControl extends Component {
|
||||
constructor() {
|
||||
super( ...arguments );
|
||||
|
||||
this.state = {
|
||||
searchTerm: '',
|
||||
};
|
||||
|
||||
this.updateSearchTerm = debounce( 300, this.updateSearchTerm.bind( this ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search term with debounce.
|
||||
*
|
||||
* @param {String} search - search term.
|
||||
*/
|
||||
updateSearchTerm( search ) {
|
||||
this.setState( {
|
||||
searchTerm: search,
|
||||
} );
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
label,
|
||||
help,
|
||||
isMulti,
|
||||
onChange,
|
||||
allPosts,
|
||||
findPosts,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
} = this.state;
|
||||
|
||||
const foundPosts = findPosts( searchTerm ) || [];
|
||||
|
||||
return (
|
||||
<BaseControl
|
||||
label={ label }
|
||||
help={ help }
|
||||
>
|
||||
<ReactSelect
|
||||
options={
|
||||
foundPosts.map( ( postData ) => {
|
||||
return {
|
||||
value: postData.id,
|
||||
label: postData.title.raw,
|
||||
};
|
||||
} )
|
||||
}
|
||||
value={ ( () => {
|
||||
let result = value ? value.split(',') : [];
|
||||
if ( result && result.length ) {
|
||||
result = result.map( ( id ) => {
|
||||
let thisData = {
|
||||
value: id,
|
||||
label: id,
|
||||
};
|
||||
|
||||
// get label from categories list
|
||||
if ( allPosts[ id ] ) {
|
||||
thisData.label = allPosts[ id ].title.raw;
|
||||
}
|
||||
|
||||
return thisData;
|
||||
} );
|
||||
return result;
|
||||
}
|
||||
return [];
|
||||
} )() }
|
||||
isMulti={ isMulti }
|
||||
name="filter-by-categories"
|
||||
className="basic-multi-select"
|
||||
classNamePrefix="select"
|
||||
components={ {
|
||||
IndicatorSeparator: () => null,
|
||||
ClearIndicator: () => null,
|
||||
} }
|
||||
onInputChange={ ( val ) => {
|
||||
this.updateSearchTerm( val );
|
||||
} }
|
||||
onChange={ ( val ) => {
|
||||
let result = '';
|
||||
let slug = '';
|
||||
|
||||
if ( val ) {
|
||||
val.forEach( ( catData ) => {
|
||||
result += slug + catData.value;
|
||||
slug = ',';
|
||||
} );
|
||||
}
|
||||
|
||||
onChange( result );
|
||||
} }
|
||||
/>
|
||||
</BaseControl>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withSelect( ( select, props, a, b, c ) => {
|
||||
const {
|
||||
getEntityRecords,
|
||||
} = select( 'core' );
|
||||
|
||||
const {
|
||||
value,
|
||||
postType = 'post',
|
||||
} = props;
|
||||
|
||||
let ids = value ? value.split(',') : [];
|
||||
|
||||
// find non-cached posts and try to retrieve.
|
||||
ids = ids.filter( ( id ) => {
|
||||
return ! cachedPosts[ postType ] || ! cachedPosts[ postType ][ id ];
|
||||
} );
|
||||
|
||||
if ( ids && ids.length ) {
|
||||
const newPosts = getEntityRecords( 'postType', postType, {
|
||||
include: ids,
|
||||
per_page: 100,
|
||||
} );
|
||||
|
||||
maybeCache( postType, newPosts );
|
||||
}
|
||||
|
||||
return {
|
||||
findPosts( search = '' ) {
|
||||
const searchPosts = getEntityRecords( 'postType', postType, {
|
||||
search,
|
||||
per_page: 20,
|
||||
} );
|
||||
|
||||
maybeCache( postType, searchPosts );
|
||||
|
||||
return searchPosts;
|
||||
},
|
||||
allPosts: cachedPosts[ postType ] || {},
|
||||
};
|
||||
})( ComponentPostsSelectorControl );
|
||||
@@ -0,0 +1,77 @@
|
||||
import ReactSelect from 'react-select';
|
||||
|
||||
const { __ } = wp.i18n;
|
||||
|
||||
const {
|
||||
Component,
|
||||
} = wp.element;
|
||||
|
||||
const {
|
||||
BaseControl,
|
||||
} = wp.components;
|
||||
|
||||
/**
|
||||
* Component
|
||||
*/
|
||||
export default class ReactSelectControl extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
val,
|
||||
label,
|
||||
isMulti,
|
||||
onChange,
|
||||
options,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<BaseControl
|
||||
label={ label || false }
|
||||
>
|
||||
<ReactSelect
|
||||
label={ label }
|
||||
isMulti={ isMulti }
|
||||
options={ options }
|
||||
value={ ( () => {
|
||||
var list = val;
|
||||
|
||||
if ( ! Array.isArray( val ) ) {
|
||||
list = [];
|
||||
}
|
||||
|
||||
const result = list.map( ( val ) => {
|
||||
const el = options.find(function(el){
|
||||
if ( val === el['value'] ) {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
|
||||
if ( el ) {
|
||||
return {
|
||||
value: val,
|
||||
label: el['label'],
|
||||
};
|
||||
}
|
||||
} );
|
||||
|
||||
return result;
|
||||
} )() }
|
||||
onChange={ ( val ) => {
|
||||
if ( val ) {
|
||||
const result = val.map( ( opt ) => {
|
||||
return opt.value;
|
||||
} );
|
||||
|
||||
onChange( result );
|
||||
} else {
|
||||
onChange( [] );
|
||||
}
|
||||
} }
|
||||
/>
|
||||
</BaseControl>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* WordPress dependencies
|
||||
*/
|
||||
const { useMemo } = wp.element;
|
||||
const { withSelect } = wp.data;
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ServerSideRender from './server-side-render';
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
export default withSelect(
|
||||
( select ) => {
|
||||
const coreEditorSelect = select( 'core/editor' );
|
||||
if ( coreEditorSelect ) {
|
||||
const currentPostId = coreEditorSelect.getCurrentPostId();
|
||||
if ( currentPostId ) {
|
||||
return {
|
||||
currentPostId,
|
||||
};
|
||||
}
|
||||
}
|
||||
return EMPTY_OBJECT;
|
||||
}
|
||||
)(
|
||||
( { urlQueryArgs = EMPTY_OBJECT, currentPostId, ...props } ) => {
|
||||
const newUrlQueryArgs = useMemo( () => {
|
||||
if ( ! currentPostId ) {
|
||||
return urlQueryArgs;
|
||||
}
|
||||
return {
|
||||
post_id: currentPostId,
|
||||
...urlQueryArgs,
|
||||
};
|
||||
}, [ currentPostId, urlQueryArgs ] );
|
||||
|
||||
return (
|
||||
<ServerSideRender urlQueryArgs={ newUrlQueryArgs } { ...props } />
|
||||
);
|
||||
}
|
||||
);
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* This is a copy of Gutenberg component https://github.com/WordPress/gutenberg/blob/master/packages/server-side-render/src/server-side-render.js
|
||||
* With the changes in our component:
|
||||
* - callbacks before and after new content render
|
||||
* - slightly different rendering process - don't remove previously rendered content. So, we will not see page jumps after every attribute change.
|
||||
*/
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
const { isEqual, debounce } = window.lodash;
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* WordPress dependencies
|
||||
*/
|
||||
const {
|
||||
Component,
|
||||
RawHTML,
|
||||
} = wp.element;
|
||||
|
||||
const { __, sprintf } = wp.i18n;
|
||||
|
||||
const apiFetch = wp.apiFetch;
|
||||
|
||||
const { addQueryArgs } = wp.url;
|
||||
|
||||
const {
|
||||
Placeholder,
|
||||
Spinner,
|
||||
} = wp.components;
|
||||
|
||||
const {
|
||||
doAction,
|
||||
} = wp.hooks;
|
||||
|
||||
export function rendererPath( block, attributes = null, urlQueryArgs = {} ) {
|
||||
return addQueryArgs( `/wp/v2/sight-renderer/${ block }`, {
|
||||
context: 'edit',
|
||||
...( null !== attributes ? { attributes } : {} ),
|
||||
...urlQueryArgs,
|
||||
} );
|
||||
}
|
||||
|
||||
export class ServerSideRender extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.state = {
|
||||
response: null,
|
||||
prevResponse: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isStillMounted = true;
|
||||
this.fetch( this.props );
|
||||
// Only debounce once the initial fetch occurs to ensure that the first
|
||||
// renders show data as soon as possible.
|
||||
this.fetch = debounce( this.fetch, 500 );
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isStillMounted = false;
|
||||
}
|
||||
|
||||
componentDidUpdate( prevProps ) {
|
||||
const prevAttributes = prevProps.attributes;
|
||||
const curAttributes = this.props.attributes;
|
||||
|
||||
if ( ! isEqual( prevAttributes, curAttributes ) ) {
|
||||
this.fetch( this.props );
|
||||
}
|
||||
}
|
||||
|
||||
fetch( props ) {
|
||||
if ( ! this.isStillMounted ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
block,
|
||||
attributes = null,
|
||||
urlQueryArgs = {},
|
||||
onBeforeChange = () => {},
|
||||
onChange = () => {},
|
||||
} = props;
|
||||
|
||||
if ( null !== this.state.response ) {
|
||||
this.setState( {
|
||||
response: null,
|
||||
prevResponse: this.state.response,
|
||||
} );
|
||||
}
|
||||
|
||||
const path = rendererPath( block );
|
||||
|
||||
// Store the latest fetch request so that when we process it, we can
|
||||
// check if it is the current request, to avoid race conditions on slow networks.
|
||||
const fetchRequest = this.currentFetchRequest = apiFetch( { method: 'POST', path, data: { attributes: attributes } } )
|
||||
.then( ( response ) => {
|
||||
if ( this.isStillMounted && fetchRequest === this.currentFetchRequest && response ) {
|
||||
onBeforeChange();
|
||||
doAction( 'sight.components.serverSideRender.onBeforeChange', this.props );
|
||||
|
||||
this.setState( {
|
||||
response: response.rendered,
|
||||
prevResponse: null,
|
||||
}, () => {
|
||||
onChange();
|
||||
doAction( 'sight.components.serverSideRender.onChange', this.props );
|
||||
} );
|
||||
}
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
if ( this.isStillMounted && fetchRequest === this.currentFetchRequest ) {
|
||||
onBeforeChange();
|
||||
doAction( 'sight.components.serverSideRender.onBeforeChange', this.props );
|
||||
|
||||
this.setState( {
|
||||
response: {
|
||||
error: true,
|
||||
errorMsg: error.message,
|
||||
},
|
||||
prevResponse: null,
|
||||
}, () => {
|
||||
onChange();
|
||||
doAction( 'sight.components.serverSideRender.onChange', this.props );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
return fetchRequest;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
response,
|
||||
prevResponse,
|
||||
} = this.state;
|
||||
|
||||
let { className } = this.props;
|
||||
|
||||
className = classnames(
|
||||
className,
|
||||
'sight-component-server-side-render'
|
||||
);
|
||||
|
||||
if ( response === '' ) {
|
||||
return (
|
||||
<Placeholder
|
||||
className={ className }
|
||||
>
|
||||
{ __( 'Block rendered as empty.' ) }
|
||||
</Placeholder>
|
||||
);
|
||||
} else if ( ! response && prevResponse ) {
|
||||
className = classnames(
|
||||
className,
|
||||
'sight-component-server-side-render-loading'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={ className }>
|
||||
<Spinner />
|
||||
<RawHTML
|
||||
key="html"
|
||||
className="sight-component-server-side-render-content"
|
||||
>
|
||||
{ prevResponse }
|
||||
</RawHTML>
|
||||
</div>
|
||||
);
|
||||
} else if ( ! response ) {
|
||||
return (
|
||||
<Placeholder
|
||||
className={ className }
|
||||
>
|
||||
<Spinner />
|
||||
</Placeholder>
|
||||
);
|
||||
} else if ( response.error ) {
|
||||
// translators: %s: error message describing the problem
|
||||
const errorMessage = sprintf( __( 'Error loading block: %s' ), response.errorMsg );
|
||||
return (
|
||||
<Placeholder
|
||||
className={ className }
|
||||
>
|
||||
{ errorMessage }
|
||||
</Placeholder>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ className }>
|
||||
<RawHTML
|
||||
key="html"
|
||||
className="sight-component-server-side-render-content"
|
||||
>
|
||||
{ response }
|
||||
</RawHTML>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerSideRender;
|
||||
Reference in New Issue
Block a user