Skip to content
Snippets Groups Projects
Commit 8a2c52da authored by Paul Kaplan's avatar Paul Kaplan
Browse files

Custom procedures reducer, modal and block workspace.

parent 143b2a03
No related branches found
No related tags found
No related merge requests found
@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;
}
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;
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
...@@ -9,11 +9,13 @@ import VM from 'scratch-vm'; ...@@ -9,11 +9,13 @@ import VM from 'scratch-vm';
import Prompt from './prompt.jsx'; import Prompt from './prompt.jsx';
import BlocksComponent from '../components/blocks/blocks.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx';
import ExtensionLibrary from './extension-library.jsx'; import ExtensionLibrary from './extension-library.jsx';
import CustomProcedures from './custom-procedures.jsx';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import {updateToolbox} from '../reducers/toolbox'; import {updateToolbox} from '../reducers/toolbox';
import {activateColorPicker} from '../reducers/color-picker'; import {activateColorPicker} from '../reducers/color-picker';
import {closeExtensionLibrary} from '../reducers/modals'; import {closeExtensionLibrary} from '../reducers/modals';
import {activateCustomProcedures, deactivateCustomProcedures} from '../reducers/custom-procedures';
const addFunctionListener = (object, property, callback) => { const addFunctionListener = (object, property, callback) => {
const oldFn = object[property]; const oldFn = object[property];
...@@ -35,6 +37,7 @@ class Blocks extends React.Component { ...@@ -35,6 +37,7 @@ class Blocks extends React.Component {
'handlePromptStart', 'handlePromptStart',
'handlePromptCallback', 'handlePromptCallback',
'handlePromptClose', 'handlePromptClose',
'handleCustomProceduresClose',
'onScriptGlowOn', 'onScriptGlowOn',
'onScriptGlowOff', 'onScriptGlowOff',
'onBlockGlowOn', 'onBlockGlowOn',
...@@ -55,6 +58,7 @@ class Blocks extends React.Component { ...@@ -55,6 +58,7 @@ class Blocks extends React.Component {
} }
componentDidMount () { componentDidMount () {
this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker;
this.ScratchBlocks.Procedures.externalProcedureDefCallback = this.props.onActivateCustomProcedures;
const workspaceConfig = defaultsDeep({}, const workspaceConfig = defaultsDeep({},
Blocks.defaultOptions, Blocks.defaultOptions,
...@@ -74,7 +78,8 @@ class Blocks extends React.Component { ...@@ -74,7 +78,8 @@ class Blocks extends React.Component {
this.state.prompt !== nextState.prompt || this.state.prompt !== nextState.prompt ||
this.props.isVisible !== nextProps.isVisible || this.props.isVisible !== nextProps.isVisible ||
this.props.toolboxXML !== nextProps.toolboxXML || this.props.toolboxXML !== nextProps.toolboxXML ||
this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible ||
this.props.customProceduresVisible !== nextProps.customProceduresVisible
); );
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
...@@ -221,9 +226,14 @@ class Blocks extends React.Component { ...@@ -221,9 +226,14 @@ class Blocks extends React.Component {
handlePromptClose () { handlePromptClose () {
this.setState({prompt: null}); this.setState({prompt: null});
} }
handleCustomProceduresClose (data) {
this.props.onRequestCloseCustomProcedures(data);
this.workspace.refreshToolboxSelection_();
}
render () { render () {
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
const { const {
customProceduresVisible,
extensionLibraryVisible, extensionLibraryVisible,
options, options,
vm, vm,
...@@ -231,6 +241,7 @@ class Blocks extends React.Component { ...@@ -231,6 +241,7 @@ class Blocks extends React.Component {
onActivateColorPicker, onActivateColorPicker,
updateToolboxState, updateToolboxState,
onRequestCloseExtensionLibrary, onRequestCloseExtensionLibrary,
onRequestCloseCustomProcedures,
toolboxXML, toolboxXML,
...props ...props
} = this.props; } = this.props;
...@@ -257,15 +268,26 @@ class Blocks extends React.Component { ...@@ -257,15 +268,26 @@ class Blocks extends React.Component {
onRequestClose={onRequestCloseExtensionLibrary} onRequestClose={onRequestCloseExtensionLibrary}
/> />
) : null} ) : null}
{customProceduresVisible ? (
<CustomProcedures
options={{
media: options.media
}}
onRequestClose={this.handleCustomProceduresClose}
/>
) : null}
</div> </div>
); );
} }
} }
Blocks.propTypes = { Blocks.propTypes = {
customProceduresVisible: PropTypes.bool,
extensionLibraryVisible: PropTypes.bool, extensionLibraryVisible: PropTypes.bool,
isVisible: PropTypes.bool, isVisible: PropTypes.bool,
onActivateColorPicker: PropTypes.func, onActivateColorPicker: PropTypes.func,
onActivateCustomProcedures: PropTypes.func,
onRequestCloseCustomProcedures: PropTypes.func,
onRequestCloseExtensionLibrary: PropTypes.func, onRequestCloseExtensionLibrary: PropTypes.func,
options: PropTypes.shape({ options: PropTypes.shape({
media: PropTypes.string, media: PropTypes.string,
...@@ -326,14 +348,19 @@ Blocks.defaultProps = { ...@@ -326,14 +348,19 @@ Blocks.defaultProps = {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
extensionLibraryVisible: state.modals.extensionLibrary, extensionLibraryVisible: state.modals.extensionLibrary,
toolboxXML: state.toolbox.toolboxXML toolboxXML: state.toolbox.toolboxXML,
customProceduresVisible: state.customProcedures.active
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), onActivateColorPicker: callback => dispatch(activateColorPicker(callback)),
onActivateCustomProcedures: (data, callback) => dispatch(activateCustomProcedures(data, callback)),
onRequestCloseExtensionLibrary: () => { onRequestCloseExtensionLibrary: () => {
dispatch(closeExtensionLibrary()); dispatch(closeExtensionLibrary());
}, },
onRequestCloseCustomProcedures: data => {
dispatch(deactivateCustomProcedures(data));
},
updateToolboxState: toolboxXML => { updateToolboxState: toolboxXML => {
dispatch(updateToolbox(toolboxXML)); dispatch(updateToolbox(toolboxXML));
} }
......
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);
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
};
import {combineReducers} from 'redux'; import {combineReducers} from 'redux';
import colorPickerReducer from './color-picker'; import colorPickerReducer from './color-picker';
import customProceduresReducer from './custom-procedures';
import intlReducer from './intl'; import intlReducer from './intl';
import modalReducer from './modals'; import modalReducer from './modals';
import monitorReducer from './monitors'; import monitorReducer from './monitors';
...@@ -10,6 +11,7 @@ import {ScratchPaintReducer} from 'scratch-paint'; ...@@ -10,6 +11,7 @@ import {ScratchPaintReducer} from 'scratch-paint';
export default combineReducers({ export default combineReducers({
colorPicker: colorPickerReducer, colorPicker: colorPickerReducer,
customProcedures: customProceduresReducer,
intl: intlReducer, intl: intlReducer,
modals: modalReducer, modals: modalReducer,
monitors: monitorReducer, monitors: monitorReducer,
......
...@@ -157,6 +157,24 @@ describe('costumes, sounds and variables', () => { ...@@ -157,6 +157,24 @@ describe('costumes, sounds and variables', () => {
await clickText('stamp', blocksTabScope); // Would fail if didn't scroll back 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); const logs = await getLogs(errorWhitelist);
await expect(logs).toEqual([]); await expect(logs).toEqual([]);
}); });
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment