From 8a2c52da526c93d3c5d9780532743a2d30e358a2 Mon Sep 17 00:00:00 2001
From: Paul Kaplan <pkaplan@media.mit.edu>
Date: Wed, 8 Nov 2017 12:13:28 -0500
Subject: [PATCH] Custom procedures reducer, modal and block workspace.

---
 .../custom-procedures/custom-procedures.css   |  91 ++++++++++++
 .../custom-procedures/custom-procedures.jsx   | 133 ++++++++++++++++++
 .../custom-procedures/icon--boolean-input.svg | Bin 0 -> 523 bytes
 .../custom-procedures/icon--label.svg         | Bin 0 -> 559 bytes
 .../custom-procedures/icon--text-input.svg    | Bin 0 -> 534 bytes
 src/containers/blocks.jsx                     |  31 +++-
 src/containers/custom-procedures.jsx          | 125 ++++++++++++++++
 src/reducers/custom-procedures.js             |  65 +++++++++
 src/reducers/gui.js                           |   2 +
 test/integration/test.js                      |  18 +++
 10 files changed, 463 insertions(+), 2 deletions(-)
 create mode 100644 src/components/custom-procedures/custom-procedures.css
 create mode 100644 src/components/custom-procedures/custom-procedures.jsx
 create mode 100644 src/components/custom-procedures/icon--boolean-input.svg
 create mode 100644 src/components/custom-procedures/icon--label.svg
 create mode 100644 src/components/custom-procedures/icon--text-input.svg
 create mode 100644 src/containers/custom-procedures.jsx
 create mode 100644 src/reducers/custom-procedures.js

diff --git a/src/components/custom-procedures/custom-procedures.css b/src/components/custom-procedures/custom-procedures.css
new file mode 100644
index 000000000..f60fab88e
--- /dev/null
+++ b/src/components/custom-procedures/custom-procedures.css
@@ -0,0 +1,91 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.modal-content {
+    width: 700px;
+}
+
+.body {
+    background: $ui-pane-gray;
+    padding: 1.5rem 2.25rem;
+}
+
+/* Blocks workspace for custom procedure declaration editor */
+.workspace {
+    min-height: 200px;
+    position: relative;
+}
+
+.workspace :global(.injectionDiv){
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+}
+
+.workspace :global(.blocklySvg) {
+    background-color: #E7EDF2;
+}
+
+/* Row of "card" buttons for modifying custom procedures */
+.options-row {
+    display: flex;
+    justify-content: space-between;
+}
+
+.option-card {
+    background: white;
+    border: 2px solid $form-border;
+    border-radius: $space;
+    padding: calc($space * 2);
+    text-align: center;
+    flex-grow: 1;
+    cursor: pointer;
+    transition: all 0.2s;
+    flex-basis: 100px;
+}
+
+.option-card:hover {
+    border: 2px solid $motion-primary;
+    box-shadow: 0px 0px 0px 3px $motion-transparent;
+}
+
+.option-card + .option-card {
+    margin-left: 1rem;
+}
+
+.option-icon {
+    padding: 0 1rem 1rem;
+    width: 100%;
+}
+
+.option-title {
+    font-weight: bold;
+}
+
+/* Confirmation buttons at the bottom of the modal */
+.button-row {
+    margin-top: 1rem;
+    font-weight: bolder;
+    text-align: right;
+}
+
+.button-row button {
+    border: 1px solid $ui-pane-border;
+    border-radius: 0.25rem;
+    padding: 0.75rem 1rem;
+    background: white;
+    font-weight: bold;
+    font-size: 0.85rem;
+}
+
+.button-row button.ok-button {
+    background: $motion-primary;
+    border: $motion-primary;
+    color: white;
+}
+
+.button-row button + button {
+    margin-left: 0.5rem;
+}
diff --git a/src/components/custom-procedures/custom-procedures.jsx b/src/components/custom-procedures/custom-procedures.jsx
new file mode 100644
index 000000000..e8e0e18a7
--- /dev/null
+++ b/src/components/custom-procedures/custom-procedures.jsx
@@ -0,0 +1,133 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from '../modal/modal.jsx';
+import Box from '../box/box.jsx';
+import {FormattedMessage} from 'react-intl';
+
+import booleanInputIcon from './icon--boolean-input.svg';
+import textInputIcon from './icon--text-input.svg';
+import labelIcon from './icon--label.svg';
+
+import styles from './custom-procedures.css';
+
+const CustomProcedures = props => (
+    <Modal
+        className={styles.modalContent}
+        contentLabel="Create new block"
+        onRequestClose={props.onCancel}
+    >
+        <Box
+            className={styles.workspace}
+            componentRef={props.componentRef}
+        />
+        <Box className={styles.body}>
+            <div className={styles.optionsRow}>
+                <div
+                    className={styles.optionCard}
+                    role="button"
+                    tabIndex="0"
+                    onClick={props.onAddTextNumber}
+                >
+                    <img
+                        className={styles.optionIcon}
+                        src={textInputIcon}
+                    />
+                    <div className={styles.optionTitle}>
+                        <FormattedMessage
+                            defaultMessage="Add an input"
+                            description="Label for button to add a numeric"
+                            id="gui.customProcedures.addAnInputNumberText"
+                        />
+                    </div>
+                    <div className={styles.optionDescription}>
+                        <FormattedMessage
+                            defaultMessage="number or text"
+                            description="Description of the number/text input type"
+                            id="gui.customProcedures.numberTextType"
+                        />
+                    </div>
+                </div>
+                <div
+                    className={styles.optionCard}
+                    role="button"
+                    tabIndex="0"
+                    onClick={props.onAddBoolean}
+                >
+                    <img
+                        className={styles.optionIcon}
+                        src={booleanInputIcon}
+                    />
+                    <div className={styles.optionTitle}>
+                        <FormattedMessage
+                            defaultMessage="Add an input"
+                            description="Label for button to add a boolean input"
+                            id="gui.customProcedures.addAnInputBoolean"
+                        />
+                    </div>
+                    <div className={styles.optionDescription}>
+                        <FormattedMessage
+                            defaultMessage="boolean"
+                            description="Description of the boolean input type"
+                            id="gui.customProcedures.booleanType"
+                        />
+                    </div>
+                </div>
+                <div
+                    className={styles.optionCard}
+                    role="button"
+                    tabIndex="0"
+                    onClick={props.onAddLabel}
+                >
+                    <img
+                        className={styles.optionIcon}
+                        src={labelIcon}
+                    />
+                    <div className={styles.optionTitle}>
+                        <FormattedMessage
+                            defaultMessage="Add a label"
+                            description="Label for button to add a label"
+                            id="gui.customProcedures.addALabel"
+                        />
+                    </div>
+                </div>
+            </div>
+            <div className={styles.checkboxRow}>
+                {/* @todo Implement "run without screen refresh" */}
+                {/* <label><input type="checkbox" />Run without screen refresh</label> */}
+            </div>
+            <Box className={styles.buttonRow}>
+                <button
+                    className={styles.cancelButton}
+                    onClick={props.onCancel}
+                >
+                    <FormattedMessage
+                        defaultMessage="Cancel"
+                        description="Label for button to cancel custom procedure edits"
+                        id="gui.customProcedures.cancel"
+                    />
+                </button>
+                <button
+                    className={styles.okButton}
+                    onClick={props.onOk}
+                >
+                    <FormattedMessage
+                        defaultMessage="OK"
+                        description="Label for button to save new custom procedure"
+                        id="gui.customProcedures.ok"
+                    />
+                </button>
+            </Box>
+        </Box>
+    </Modal>
+);
+
+CustomProcedures.propTypes = {
+    componentRef: PropTypes.func.isRequired,
+    onAddBoolean: PropTypes.func.isRequired,
+    onAddLabel: PropTypes.func.isRequired,
+    onAddTextNumber: PropTypes.func.isRequired,
+    onCancel: PropTypes.func.isRequired,
+    onOk: PropTypes.func.isRequired
+};
+
+export default CustomProcedures;
diff --git a/src/components/custom-procedures/icon--boolean-input.svg b/src/components/custom-procedures/icon--boolean-input.svg
new file mode 100644
index 0000000000000000000000000000000000000000..49a4d919fb321786383d313d6b529c64a9033ca9
GIT binary patch
literal 523
zcmYk3&2GaW5QJZ4MURaQj(_Sp*oRg<^iZklv6qM#Y||2B0f`euefN^si6VhU3?HN2
zh1z`!hpp;ui8|+o0^@NU!!ZuEY4MT}f<1v?FU9zx4<#Z%Abo=58C8^v##Yq2qY_nE
zD;q>7J+NB3V?n}r^>^`>cV0Am@u`tY6%S35WjR?{XY{WKw$cfsq?aNhS8{O)Nlp{v
zpTy~WmE1xSp0R6)lD(+eP#C@I`Mvq0Wk0)r6i-D{JhkAY^DTTuYj_PmjjjdX83Aux
zx{VU>-VZxxDV`|Wg5)LIXsrb6*H@Jn2mxn~1wGq}8v>6oLoh=XHKKM<D`x%J&x4B*
zoM#)R2#&LklDn=%X@;N^vh7@nk{p5YT;j!J3BnZ}nCtG7{GG4vBVLB-BJqJ;kgFid
W{OX9BH21nVpTExmxb@rrarF<yKB=kz

literal 0
HcmV?d00001

diff --git a/src/components/custom-procedures/icon--label.svg b/src/components/custom-procedures/icon--label.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2c2d52a66da350abe5947ffdc139a1eacb2dbbf8
GIT binary patch
literal 559
zcmY+C!EWOq5Qbl6L@%wjHaK>Y*2X?`wNm%8TI~x|)E+yi*hDaila2cB#g1F;9+-ij
zkAWYM{@B5(H-j(Pj%c5AK8|BNrm=M$UkM?2AQ&7qj!*VfG64eCTgV<+B^zvgB|V)D
zR&i~7lw9my)tb(^h|}VI)>i*$$)cr~RvVMwwC!f|APP^;zF`=q>!ly{n$Q^?Ql5xq
zRd#9ZK#|vfF;7<eQ}J1(E#`?<_1c{CXEaA7U8`Ss!05@E<{rK@n8YhSqV9H-Z-pof
zQrIr?)8U6M7uXl?l3$HUezi%Og3Uk43w)1%J6mIOa0ovdO|J&w=deGJm;6e}4s=tp
zS8ENb;pH>y8Dh{VQqiD#{6gRxED$VEB^}lTwNXAi&2+gbgX#X`HG}h#N8Qk_Wa|xs
z-R!1h*#iR?xGbcAWsFyn@bq7$Fh@=ezO}9|+1xdX@HW}T;b~VP-mz+06#Rr(N#2DP
H{z3Ht<I=j0

literal 0
HcmV?d00001

diff --git a/src/components/custom-procedures/icon--text-input.svg b/src/components/custom-procedures/icon--text-input.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a6dfbe5ebb8bed01a8cc9482e69f50c14fef62be
GIT binary patch
literal 534
zcmZXR(Qd*Z6oy~rgk5eHYO!;b_F~H}cDL*W3>Mn5VQC6ljq%-wEjp83kU#nP_yPxJ
z_ca{0y0tlKoa-`-hhd0@Br<)C=Y$aK2?Tp3hgWmR5di{=XP_^rU_w^5VAdVAETU4|
zS$r}bFO@rHBuZB2v76s@ap;m$rL@i-swz#FWMy4%e&lScob0vok;UXnK8+H3nizj1
zS%fWmlTeZJt3<0-mb}Y)vunkD`E67i+B3t`r5T={;FR-E_=?x?7Jc@nlw#LQco)iT
zw1khg+d0edM6nu_$kE0aEqS}XrWzpxf;&EI`BvT#c!U5!fC|>j(m|=Y^<%#bE<tda
zZL~me3^^#_nj9@s1dUX6<8nlo2zt*^A0DF!|2O$PPb`CJ%wVGA&y>t3Q|#>*l~3=&
JwO{_bi(f<UtvCPx

literal 0
HcmV?d00001

diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx
index 47d12661c..378dccf6d 100644
--- a/src/containers/blocks.jsx
+++ b/src/containers/blocks.jsx
@@ -9,11 +9,13 @@ import VM from 'scratch-vm';
 import Prompt from './prompt.jsx';
 import BlocksComponent from '../components/blocks/blocks.jsx';
 import ExtensionLibrary from './extension-library.jsx';
+import CustomProcedures from './custom-procedures.jsx';
 
 import {connect} from 'react-redux';
 import {updateToolbox} from '../reducers/toolbox';
 import {activateColorPicker} from '../reducers/color-picker';
 import {closeExtensionLibrary} from '../reducers/modals';
+import {activateCustomProcedures, deactivateCustomProcedures} from '../reducers/custom-procedures';
 
 const addFunctionListener = (object, property, callback) => {
     const oldFn = object[property];
@@ -35,6 +37,7 @@ class Blocks extends React.Component {
             'handlePromptStart',
             'handlePromptCallback',
             'handlePromptClose',
+            'handleCustomProceduresClose',
             'onScriptGlowOn',
             'onScriptGlowOff',
             'onBlockGlowOn',
@@ -55,6 +58,7 @@ class Blocks extends React.Component {
     }
     componentDidMount () {
         this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker;
+        this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures;
 
         const workspaceConfig = defaultsDeep({},
             Blocks.defaultOptions,
@@ -74,7 +78,8 @@ class Blocks extends React.Component {
             this.state.prompt !== nextState.prompt ||
             this.props.isVisible !== nextProps.isVisible ||
             this.props.toolboxXML !== nextProps.toolboxXML ||
-            this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible
+            this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible ||
+            this.props.customProceduresVisible !== nextProps.customProceduresVisible
         );
     }
     componentDidUpdate (prevProps) {
@@ -221,9 +226,14 @@ class Blocks extends React.Component {
     handlePromptClose () {
         this.setState({prompt: null});
     }
+    handleCustomProceduresClose (data) {
+        this.props.onRequestCloseCustomProcedures(data);
+        this.workspace.refreshToolboxSelection_();
+    }
     render () {
         /* eslint-disable no-unused-vars */
         const {
+            customProceduresVisible,
             extensionLibraryVisible,
             options,
             vm,
@@ -231,6 +241,7 @@ class Blocks extends React.Component {
             onActivateColorPicker,
             updateToolboxState,
             onRequestCloseExtensionLibrary,
+            onRequestCloseCustomProcedures,
             toolboxXML,
             ...props
         } = this.props;
@@ -257,15 +268,26 @@ class Blocks extends React.Component {
                         onRequestClose={onRequestCloseExtensionLibrary}
                     />
                 ) : null}
+                {customProceduresVisible ? (
+                    <CustomProcedures
+                        options={{
+                            media: options.media
+                        }}
+                        onRequestClose={this.handleCustomProceduresClose}
+                    />
+                ) : null}
             </div>
         );
     }
 }
 
 Blocks.propTypes = {
+    customProceduresVisible: PropTypes.bool,
     extensionLibraryVisible: PropTypes.bool,
     isVisible: PropTypes.bool,
     onActivateColorPicker: PropTypes.func,
+    onActivateCustomProcedures: PropTypes.func,
+    onRequestCloseCustomProcedures: PropTypes.func,
     onRequestCloseExtensionLibrary: PropTypes.func,
     options: PropTypes.shape({
         media: PropTypes.string,
@@ -326,14 +348,19 @@ Blocks.defaultProps = {
 
 const mapStateToProps = state => ({
     extensionLibraryVisible: state.modals.extensionLibrary,
-    toolboxXML: state.toolbox.toolboxXML
+    toolboxXML: state.toolbox.toolboxXML,
+    customProceduresVisible: state.customProcedures.active
 });
 
 const mapDispatchToProps = dispatch => ({
     onActivateColorPicker: callback => dispatch(activateColorPicker(callback)),
+    onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)),
     onRequestCloseExtensionLibrary: () => {
         dispatch(closeExtensionLibrary());
     },
+    onRequestCloseCustomProcedures: data => {
+        dispatch(deactivateCustomProcedures(data));
+    },
     updateToolboxState: toolboxXML => {
         dispatch(updateToolbox(toolboxXML));
     }
diff --git a/src/containers/custom-procedures.jsx b/src/containers/custom-procedures.jsx
new file mode 100644
index 000000000..d594aba2b
--- /dev/null
+++ b/src/containers/custom-procedures.jsx
@@ -0,0 +1,125 @@
+import bindAll from 'lodash.bindall';
+import defaultsDeep from 'lodash.defaultsdeep';
+import PropTypes from 'prop-types';
+import React from 'react';
+import CustomProceduresComponent from '../components/custom-procedures/custom-procedures.jsx';
+import ScratchBlocks from 'scratch-blocks';
+import {connect} from 'react-redux';
+
+class CustomProcedures extends React.Component {
+    constructor (props) {
+        super(props);
+        bindAll(this, [
+            'handleAddLabel',
+            'handleAddBoolean',
+            'handleAddTextNumber',
+            'handleCancel',
+            'handleOk',
+            'setBlocks'
+        ]);
+    }
+    componentWillUnmount () {
+        if (this.workspace) {
+            this.workspace.dispose();
+        }
+    }
+    setBlocks (blocksRef) {
+        if (!blocksRef) return;
+        this.blocks = blocksRef;
+        const workspaceConfig = defaultsDeep({},
+            CustomProcedures.defaultOptions,
+            this.props.options
+        );
+
+        // @todo This is a hack to make there be no toolbox.
+        const oldDefaultToolbox = ScratchBlocks.Blocks.defaultToolbox;
+        ScratchBlocks.Blocks.defaultToolbox = null;
+        this.workspace = ScratchBlocks.inject(this.blocks, workspaceConfig);
+        ScratchBlocks.Blocks.defaultToolbox = oldDefaultToolbox;
+
+        // Create the procedure declaration block for editing the mutation.
+        this.mutationRoot = this.workspace.newBlock('procedures_declaration');
+        this.workspace.addChangeListener(() => this.mutationRoot.onChangeFn());
+        this.mutationRoot.domToMutation(this.props.mutator);
+        this.mutationRoot.initSvg();
+        this.mutationRoot.render();
+
+        // Center the procedure declaration block.
+        const metrics = this.workspace.getMetrics();
+        const dx = (metrics.viewWidth / 2) - (this.mutationRoot.width / 2);
+        const dy = (metrics.viewHeight / 2) - (this.mutationRoot.height / 2);
+        this.mutationRoot.moveBy(dx, dy);
+    }
+    handleCancel () {
+        this.props.onRequestClose();
+    }
+    handleOk () {
+        const newMutation = this.mutationRoot ? this.mutationRoot.mutationToDom() : null;
+        this.props.onRequestClose(newMutation);
+    }
+    handleAddLabel () {
+        if (this.mutationRoot) {
+            this.mutationRoot.addLabelExternal();
+        }
+    }
+    handleAddBoolean () {
+        if (this.mutationRoot) {
+            this.mutationRoot.addBooleanExternal();
+        }
+    }
+    handleAddTextNumber () {
+        if (this.mutationRoot) {
+            this.mutationRoot.addStringNumberExternal();
+        }
+    }
+    render () {
+        return (
+            <CustomProceduresComponent
+                componentRef={this.setBlocks}
+                onAddBoolean={this.handleAddBoolean}
+                onAddLabel={this.handleAddLabel}
+                onAddTextNumber={this.handleAddTextNumber}
+                onCancel={this.handleCancel}
+                onOk={this.handleOk}
+            />
+        );
+    }
+}
+
+CustomProcedures.propTypes = {
+    mutator: PropTypes.instanceOf(Element),
+    onRequestClose: PropTypes.func.isRequired,
+    options: PropTypes.shape({
+        media: PropTypes.string,
+        zoom: PropTypes.shape({
+            controls: PropTypes.bool,
+            wheel: PropTypes.bool,
+            startScale: PropTypes.number
+        }),
+        comments: PropTypes.bool
+    })
+};
+
+CustomProcedures.defaultOptions = {
+    zoom: {
+        controls: false,
+        wheel: false,
+        startScale: 0.9
+    },
+    comments: false
+};
+
+CustomProcedures.defaultProps = {
+    options: CustomProcedures.defaultOptions
+};
+
+const mapStateToProps = state => ({
+    mutator: state.customProcedures.mutator
+});
+
+const mapDispatchToProps = () => ({});
+
+export default connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(CustomProcedures);
diff --git a/src/reducers/custom-procedures.js b/src/reducers/custom-procedures.js
new file mode 100644
index 000000000..2f4f63d98
--- /dev/null
+++ b/src/reducers/custom-procedures.js
@@ -0,0 +1,65 @@
+const ACTIVATE_CUSTOM_PROCEDURES = 'scratch-gui/custom-procedures/ACTIVATE_CUSTOM_PROCEDURES';
+const DEACTIVATE_CUSTOM_PROCEDURES = 'scratch-gui/custom-procedures/DEACTIVATE_CUSTOM_PROCEDURES';
+const SET_CALLBACK = 'scratch-gui/custom-procedures/SET_CALLBACK';
+
+const initialState = {
+    active: false,
+    mutator: null,
+    callback: null
+};
+
+const reducer = function (state, action) {
+    if (typeof state === 'undefined') state = initialState;
+    switch (action.type) {
+    case ACTIVATE_CUSTOM_PROCEDURES:
+        return Object.assign({}, state, {
+            active: true,
+            mutator: action.mutator,
+            callback: action.callback
+        });
+    case DEACTIVATE_CUSTOM_PROCEDURES:
+        // Can be called without a mutator to deactivate without new procedure
+        // i.e. when clicking on the modal background
+        if (action.mutator) {
+            state.callback(action.mutator);
+        }
+        return Object.assign({}, state, {
+            active: false,
+            mutator: null,
+            callback: null
+        });
+    case SET_CALLBACK:
+        return Object.assign({}, state, {callback: action.callback});
+    default:
+        return state;
+    }
+};
+
+/**
+ * Action creator to open the custom procedures modal.
+ * @param {!Element} mutator The XML node of the mutator for the procedure.
+ * @param {!function(!Element)} callback The function to call when done editing procedure.
+ *     Expect the callback to be a function that takes a new XML mutator node.
+ * @returns {object} An action object with type ACTIVATE_CUSTOM_PROCEDURES.
+ */
+const activateCustomProcedures = (mutator, callback) => ({
+    type: ACTIVATE_CUSTOM_PROCEDURES,
+    mutator: mutator,
+    callback: callback
+});
+
+/**
+ * Action creator to close the custom procedures modal.
+ * @param {?Element} mutator The new mutator, or null if the callback should not be called.
+ * @returns {object} An action object with type ACTIVATE_CUSTOM_PROCEDURES.
+ */
+const deactivateCustomProcedures = mutator => ({
+    type: DEACTIVATE_CUSTOM_PROCEDURES,
+    mutator: mutator
+});
+
+export {
+    reducer as default,
+    activateCustomProcedures,
+    deactivateCustomProcedures
+};
diff --git a/src/reducers/gui.js b/src/reducers/gui.js
index c9e5e4192..1dfa4ea76 100644
--- a/src/reducers/gui.js
+++ b/src/reducers/gui.js
@@ -1,5 +1,6 @@
 import {combineReducers} from 'redux';
 import colorPickerReducer from './color-picker';
+import customProceduresReducer from './custom-procedures';
 import intlReducer from './intl';
 import modalReducer from './modals';
 import monitorReducer from './monitors';
@@ -10,6 +11,7 @@ import {ScratchPaintReducer} from 'scratch-paint';
 
 export default combineReducers({
     colorPicker: colorPickerReducer,
+    customProcedures: customProceduresReducer,
     intl: intlReducer,
     modals: modalReducer,
     monitors: monitorReducer,
diff --git a/test/integration/test.js b/test/integration/test.js
index 4096c2d90..ad3659180 100644
--- a/test/integration/test.js
+++ b/test/integration/test.js
@@ -157,6 +157,24 @@ describe('costumes, sounds and variables', () => {
         await clickText('stamp', blocksTabScope); // Would fail if didn't scroll back
 
 
+        const logs = await getLogs(errorWhitelist);
+        await expect(logs).toEqual([]);
+    });
+
+    test('Custom procedures', async () => {
+        await loadUri(uri);
+        await clickText('More');
+        await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation
+        await clickText('Make a Block...');
+        // Click on the "add an input" buttons
+        await clickText('number or text', modalScope);
+        await clickText('boolean', modalScope);
+        await clickText('Add a label', modalScope);
+        await clickText('OK', modalScope);
+
+        // Make sure a "define" block has been added to the workspace
+        await findByText('define', blocksTabScope);
+
         const logs = await getLogs(errorWhitelist);
         await expect(logs).toEqual([]);
     });
-- 
GitLab