mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-06-16 21:06:35 +08:00
feat(client): independent lower jaw ab test (#61085)
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
parent
ecd5582ed9
commit
b7b02ee159
@ -9,6 +9,7 @@
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"view": "View",
|
||||
"submit-continue": "Submit and continue",
|
||||
"view-code": "View Code",
|
||||
"view-project": "View Project",
|
||||
"view-cert-title": "View {{certTitle}}",
|
||||
@ -60,6 +61,8 @@
|
||||
"check-code": "Check Your Code",
|
||||
"check-code-ctrl": "Check Your Code (Ctrl + Enter)",
|
||||
"check-code-cmd": "Check Your Code (Command + Enter)",
|
||||
"command-enter": "⌘ + Enter",
|
||||
"ctrl-enter": "Ctrl + Enter",
|
||||
"reset": "Reset",
|
||||
"reset-step": "Reset This Step",
|
||||
"help": "Help",
|
||||
@ -427,6 +430,7 @@
|
||||
"ms-link": "Microsoft Link",
|
||||
"submit-and-go": "Submit and go to my next challenge",
|
||||
"congratulations": "Congratulations, your code passes. Submit your code to continue.",
|
||||
"congratulations-code-passes": "✔ Congratulations. Your code passes.",
|
||||
"i-completed": "I've completed this challenge",
|
||||
"example-code": "Example Code",
|
||||
"test-output": "Your test output will go here",
|
||||
|
||||
@ -7,7 +7,7 @@ import EditorTabs from './editor-tabs';
|
||||
interface ActionRowProps {
|
||||
hasNotes: boolean;
|
||||
hasPreview: boolean;
|
||||
isProjectBasedChallenge: boolean;
|
||||
areInstructionsDisplayable: boolean;
|
||||
showConsole: boolean;
|
||||
showNotes: boolean;
|
||||
showInstructions: boolean;
|
||||
@ -25,7 +25,7 @@ const ActionRow = ({
|
||||
showPreviewPortal,
|
||||
showConsole,
|
||||
showInstructions,
|
||||
isProjectBasedChallenge
|
||||
areInstructionsDisplayable
|
||||
}: ActionRowProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -54,7 +54,7 @@ const ActionRow = ({
|
||||
return (
|
||||
<div className='action-row' data-playwright-test-label='action-row'>
|
||||
<div className='tabs-row' data-playwright-test-label='tabs-row'>
|
||||
{!isProjectBasedChallenge && (
|
||||
{areInstructionsDisplayable && (
|
||||
<button
|
||||
data-playwright-test-label='instructions-button'
|
||||
aria-expanded={!!showInstructions}
|
||||
|
||||
@ -53,6 +53,7 @@ interface DesktopLayoutProps {
|
||||
setShowPreviewPortal: (arg: boolean) => void;
|
||||
setShowPreviewPane: (arg: boolean) => void;
|
||||
portalWindow: null | Window;
|
||||
showIndependentLowerJaw: boolean;
|
||||
}
|
||||
|
||||
const reflexProps = {
|
||||
@ -92,7 +93,8 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
||||
setShowPreviewPane,
|
||||
setShowPreviewPortal,
|
||||
portalWindow,
|
||||
startWithConsoleShown
|
||||
startWithConsoleShown,
|
||||
showIndependentLowerJaw
|
||||
} = props;
|
||||
|
||||
const initialShowState = (key: string, defaultValue: boolean): boolean => {
|
||||
@ -227,6 +229,8 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
||||
}, []);
|
||||
|
||||
const projectBasedChallenge = hasEditableBoundaries;
|
||||
const areInstructionsDisplayable =
|
||||
!projectBasedChallenge || showIndependentLowerJaw;
|
||||
const isMultifileProject =
|
||||
challengeType === challengeTypes.multifileCertProject ||
|
||||
challengeType === challengeTypes.multifilePythonCertProject ||
|
||||
@ -260,7 +264,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
||||
<ActionRow
|
||||
hasPreview={hasPreview}
|
||||
hasNotes={!!notes}
|
||||
isProjectBasedChallenge={projectBasedChallenge}
|
||||
areInstructionsDisplayable={areInstructionsDisplayable}
|
||||
showConsole={showConsole}
|
||||
showNotes={showNotes}
|
||||
showInstructions={showInstructions}
|
||||
@ -274,7 +278,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
||||
orientation='vertical'
|
||||
data-playwright-test-label='main-container'
|
||||
>
|
||||
{!projectBasedChallenge && showInstructions && (
|
||||
{areInstructionsDisplayable && showInstructions && (
|
||||
<ReflexElement
|
||||
flex={instructionPane.flex}
|
||||
{...resizeProps}
|
||||
@ -284,7 +288,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
|
||||
{instructions}
|
||||
</ReflexElement>
|
||||
)}
|
||||
{!projectBasedChallenge && showInstructions && (
|
||||
{areInstructionsDisplayable && showInstructions && (
|
||||
<ReflexSplitter propagate={true} {...resizeProps} />
|
||||
)}
|
||||
|
||||
|
||||
@ -113,6 +113,7 @@ export interface EditorProps {
|
||||
}) => void;
|
||||
usesMultifileEditor: boolean;
|
||||
isChallengeCompleted: boolean;
|
||||
showIndependentLowerJaw?: boolean;
|
||||
}
|
||||
|
||||
// TODO: this is grab bag of unrelated properties. There's no need for them to
|
||||
@ -413,7 +414,10 @@ const Editor = (props: EditorProps): JSX.Element => {
|
||||
dataRef.current.editor = editor;
|
||||
|
||||
if (hasEditableRegion()) {
|
||||
initializeDescriptionAndOutputWidgets();
|
||||
initializeRegions();
|
||||
if (!props.showIndependentLowerJaw) {
|
||||
addWidgetsToRegions();
|
||||
}
|
||||
addContentChangeListener();
|
||||
resetAttempts();
|
||||
showEditableRegion(editor);
|
||||
@ -1021,14 +1025,6 @@ const Editor = (props: EditorProps): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
function initializeDescriptionAndOutputWidgets() {
|
||||
const editor = dataRef.current.editor;
|
||||
if (editor) {
|
||||
initializeRegions(getEditableRegionFromRedux());
|
||||
addWidgetsToRegions(editor);
|
||||
}
|
||||
}
|
||||
|
||||
// Currently, only practice project parts have editable region markers
|
||||
// This function is used to enable multiple editor tabs, jaws, etc.
|
||||
function hasEditableRegion() {
|
||||
@ -1050,11 +1046,11 @@ const Editor = (props: EditorProps): JSX.Element => {
|
||||
}
|
||||
}
|
||||
|
||||
function initializeRegions(editableRegion: number[]) {
|
||||
function initializeRegions() {
|
||||
const { model, editor } = dataRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
if (!model || !monaco || !editor) return;
|
||||
|
||||
const editableRegion = getEditableRegionFromRedux();
|
||||
const editableRange = positionsToRange(monaco, model, [
|
||||
editableRegion[0] + 1,
|
||||
editableRegion[1] - 1
|
||||
@ -1100,7 +1096,10 @@ const Editor = (props: EditorProps): JSX.Element => {
|
||||
};
|
||||
};
|
||||
|
||||
function addWidgetsToRegions(editor: editor.IStandaloneCodeEditor) {
|
||||
function addWidgetsToRegions() {
|
||||
const editor = dataRef.current.editor;
|
||||
if (!editor) return;
|
||||
|
||||
const descriptionNode = createDescription(editor);
|
||||
|
||||
const lowerJawNode = createLowerJawContainer(editor);
|
||||
@ -1238,14 +1237,17 @@ const Editor = (props: EditorProps): JSX.Element => {
|
||||
|
||||
if (hasEditableRegion() && editor) {
|
||||
if (props.isResetting) {
|
||||
initializeDescriptionAndOutputWidgets();
|
||||
initializeRegions();
|
||||
if (!props.showIndependentLowerJaw) {
|
||||
addWidgetsToRegions();
|
||||
}
|
||||
updateDescriptionZone();
|
||||
showEditableRegion(editor);
|
||||
resetMarginDecorations();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.challengeFiles, props.isResetting]);
|
||||
}, [props.challengeFiles, props.isResetting, props.showIndependentLowerJaw]);
|
||||
|
||||
useEffect(() => {
|
||||
const { showProjectPreview, previewOpen } = props;
|
||||
|
||||
@ -36,6 +36,7 @@ type MultifileEditorProps = Pick<
|
||||
| 'description'
|
||||
// We use dimensions to trigger a re-render of the editor
|
||||
| 'dimensions'
|
||||
| 'showIndependentLowerJaw'
|
||||
> & {
|
||||
visibleEditors: VisibleEditors;
|
||||
};
|
||||
@ -72,7 +73,8 @@ const MultifileEditor = (props: MultifileEditorProps) => {
|
||||
mainpy
|
||||
},
|
||||
usesMultifileEditor,
|
||||
showProjectPreview
|
||||
showProjectPreview,
|
||||
showIndependentLowerJaw
|
||||
} = props;
|
||||
// TODO: the tabs mess up the rendering (scroll doesn't work properly and
|
||||
// the in-editor description)
|
||||
@ -146,6 +148,7 @@ const MultifileEditor = (props: MultifileEditorProps) => {
|
||||
title={title}
|
||||
usesMultifileEditor={usesMultifileEditor}
|
||||
showProjectPreview={showProjectPreview}
|
||||
showIndependentLowerJaw={showIndependentLowerJaw}
|
||||
/>
|
||||
</ReflexElement>
|
||||
);
|
||||
|
||||
@ -306,6 +306,12 @@ function ShowClassic({
|
||||
|
||||
// AB testing Pre-fetch in the Spanish locale
|
||||
const isPreFetchEnabled = useFeature('prefetch_ab_test').on;
|
||||
const isIndependentLowerJawEnabled = useFeature('independent-lower-jaw').on;
|
||||
|
||||
// Independent lower jaw is only enabled for the urriculum outline workshop
|
||||
const showIndependentLowerJaw =
|
||||
blockName === 'workshop-curriculum-outline' && isIndependentLowerJawEnabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreFetchEnabled && envData.clientLocale === 'espanol') {
|
||||
preloadPage(nextChallengePath);
|
||||
@ -416,6 +422,7 @@ function ShowClassic({
|
||||
instructionsPanelRef={instructionsPanelRef}
|
||||
toolPanel={toolPanel}
|
||||
hasDemo={hasDemo}
|
||||
showIndependentLowerJaw={showIndependentLowerJaw}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -440,6 +447,7 @@ function ShowClassic({
|
||||
title={title}
|
||||
usesMultifileEditor={usesMultifileEditor}
|
||||
showProjectPreview={demoType === 'onLoad'}
|
||||
showIndependentLowerJaw={showIndependentLowerJaw}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@ -521,6 +529,7 @@ function ShowClassic({
|
||||
}
|
||||
windowTitle={windowTitle}
|
||||
startWithConsoleShown={openConsole}
|
||||
showIndependentLowerJaw={showIndependentLowerJaw}
|
||||
/>
|
||||
)}
|
||||
<CompletionModal />
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
.independent-lower-jaw {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.independent-lower-jaw .hint-container {
|
||||
background-color: var(--background-quaternary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.independent-lower-jaw .btn-cta {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.independent-lower-jaw .buttons-row-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
background-color: var(--background-secondary);
|
||||
border: var(--background-quaternary) 1px solid;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.action-row-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.independent-lower-jaw .hint-container button {
|
||||
height: 30px;
|
||||
font-size: 1.5rem;
|
||||
min-width: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.independent-lower-jaw .action-row-right button {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: none;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.independent-lower-jaw .tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Tooltip text */
|
||||
.independent-lower-jaw .tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
background-color: var(--background-quaternary);
|
||||
color: var(--foreground-primary);
|
||||
border: 1px solid var(--foreground-secondary);
|
||||
padding: 5px 10px;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Show the tooltip text when you mouse over the tooltip container */
|
||||
.independent-lower-jaw .tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease 0.5s;
|
||||
}
|
||||
|
||||
.tooltiptext::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-style: solid;
|
||||
border-width: 0px 1px 1px 0px;
|
||||
border-color: var(--color-border-primary);
|
||||
transform: rotate(45deg);
|
||||
bottom: -5px;
|
||||
left: calc(50% - 0.25rem);
|
||||
background-image: linear-gradient(
|
||||
to top left,
|
||||
var(--background-quaternary) 55%,
|
||||
rgba(0, 0, 0, 0) 20%
|
||||
);
|
||||
}
|
||||
|
||||
.independent-lower-jaw .tooltip .tooltiptext.left-tooltip {
|
||||
left: 0;
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@freecodecamp/ui';
|
||||
import { Test } from '../../../redux/prop-types';
|
||||
import { challengeTestsSelector } from '../redux/selectors';
|
||||
import { isSignedInSelector } from '../../../redux/selectors';
|
||||
import { apiLocation } from '../../../../config/env.json';
|
||||
import { openModal, submitChallenge, executeChallenge } from '../redux/actions';
|
||||
import Help from '../../../assets/icons/help';
|
||||
import callGA from '../../../analytics/call-ga';
|
||||
|
||||
import './independent-lower-jaw.css';
|
||||
import Reset from '../../../assets/icons/reset';
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
challengeTestsSelector,
|
||||
isSignedInSelector,
|
||||
(tests: Test[], isSignedIn: boolean) => ({
|
||||
tests,
|
||||
isSignedIn
|
||||
})
|
||||
);
|
||||
|
||||
const mapDispatchToProps = {
|
||||
openHelpModal: () => openModal('help'),
|
||||
openResetModal: () => openModal('reset'),
|
||||
executeChallenge,
|
||||
submitChallenge
|
||||
};
|
||||
|
||||
interface IndependentLowerJawProps {
|
||||
openHelpModal: () => void;
|
||||
openResetModal: () => void;
|
||||
executeChallenge: () => void;
|
||||
submitChallenge: () => void;
|
||||
tests: Test[];
|
||||
isSignedIn: boolean;
|
||||
}
|
||||
export function IndependentLowerJaw({
|
||||
openHelpModal,
|
||||
openResetModal,
|
||||
executeChallenge,
|
||||
submitChallenge,
|
||||
tests,
|
||||
isSignedIn
|
||||
}: IndependentLowerJawProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const firstFailedTest = tests.find(test => !!test.err);
|
||||
const hint = firstFailedTest?.message;
|
||||
const [showHint, setShowHint] = React.useState(false);
|
||||
const [showSubmissionHint, setShowSubmissionHint] = React.useState(true);
|
||||
|
||||
const isChallengeComplete = tests.every(test => test.pass);
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowHint(!!hint);
|
||||
}, [hint]);
|
||||
|
||||
const isMacOS = navigator.userAgent.includes('Mac OS');
|
||||
const checkButtonText = isMacOS ? t('command-enter') : t('ctrl-enter');
|
||||
|
||||
return (
|
||||
<div className='independent-lower-jaw' tabIndex={-1}>
|
||||
{showHint && hint && (
|
||||
<div className='hint-container'>
|
||||
<div dangerouslySetInnerHTML={{ __html: hint }} />
|
||||
<button className={'tooltip'} onClick={() => setShowHint(false)}>
|
||||
×<span className='tooltiptext'> {t('buttons.close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isChallengeComplete && showSubmissionHint && (
|
||||
<div className='hint-container'>
|
||||
<div>
|
||||
<p>{t('learn.congratulations-code-passes')}</p>
|
||||
{!isSignedIn && (
|
||||
<a
|
||||
href={`${apiLocation}/signin`}
|
||||
className='btn-cta btn btn-block'
|
||||
onClick={() => {
|
||||
callGA({
|
||||
event: 'sign_in'
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('learn.sign-in-save')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={'tooltip'}
|
||||
onClick={() => setShowSubmissionHint(false)}
|
||||
>
|
||||
×<span className='tooltiptext'> {t('buttons.close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='buttons-row-container'>
|
||||
<div className='action-row-left'>
|
||||
{isChallengeComplete ? (
|
||||
<Button
|
||||
block
|
||||
className={`${isSignedIn && 'btn-cta'} tooltip`}
|
||||
onClick={() => submitChallenge()}
|
||||
>
|
||||
{t('buttons.submit-continue')}
|
||||
<span className='tooltiptext left-tooltip '>
|
||||
{checkButtonText}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
className='btn-cta tooltip'
|
||||
onClick={() => executeChallenge()}
|
||||
>
|
||||
{t('buttons.check-code')}
|
||||
<span className='tooltiptext left-tooltip '>
|
||||
{checkButtonText}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='action-row-right'>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-botton tooltip'
|
||||
onClick={openResetModal}
|
||||
>
|
||||
<Reset />
|
||||
<span className='tooltiptext'> {t('buttons.reset')}</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='icon-botton tooltip'
|
||||
onClick={openHelpModal}
|
||||
>
|
||||
<Help />
|
||||
<span className='tooltiptext'> {t('buttons.help')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
IndependentLowerJaw.displayName = 'IndependentLowerJaw';
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(IndependentLowerJaw);
|
||||
@ -8,6 +8,7 @@ import { Test } from '../../../redux/prop-types';
|
||||
import { challengeTestsSelector } from '../redux/selectors';
|
||||
import { openModal } from '../redux/actions';
|
||||
import TestSuite from './test-suite';
|
||||
import IndependentLowerJaw from './independent-lower-jaw';
|
||||
|
||||
import './side-panel.css';
|
||||
|
||||
@ -34,6 +35,7 @@ interface SidePanelProps extends DispatchProps, StateProps {
|
||||
hasDemo: boolean;
|
||||
toolPanel: ReactNode;
|
||||
tests: Test[];
|
||||
showIndependentLowerJaw: boolean;
|
||||
}
|
||||
|
||||
export function SidePanel({
|
||||
@ -43,37 +45,45 @@ export function SidePanel({
|
||||
hasDemo,
|
||||
toolPanel,
|
||||
tests,
|
||||
openModal
|
||||
openModal,
|
||||
showIndependentLowerJaw
|
||||
}: SidePanelProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className='instructions-panel'
|
||||
ref={instructionsPanelRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{challengeTitle}
|
||||
{hasDemo && (
|
||||
<p>
|
||||
<Trans i18nKey='learn.example-app'>
|
||||
<span
|
||||
className='example-app-link'
|
||||
onClick={() => openModal('projectPreview')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
openModal('projectPreview');
|
||||
}
|
||||
}}
|
||||
></span>
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
{challengeDescription}
|
||||
<Spacer size='m' />
|
||||
{toolPanel}
|
||||
<TestSuite tests={tests} />
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className='instructions-panel'
|
||||
ref={instructionsPanelRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{challengeTitle}
|
||||
{hasDemo && (
|
||||
<p>
|
||||
<Trans i18nKey='learn.example-app'>
|
||||
<span
|
||||
className='example-app-link'
|
||||
onClick={() => openModal('projectPreview')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
openModal('projectPreview');
|
||||
}
|
||||
}}
|
||||
></span>
|
||||
</Trans>
|
||||
</p>
|
||||
)}{' '}
|
||||
{challengeDescription}
|
||||
{!showIndependentLowerJaw && (
|
||||
<>
|
||||
<Spacer size='m' />
|
||||
{toolPanel}
|
||||
<TestSuite tests={tests} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showIndependentLowerJaw && <IndependentLowerJaw />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user