From a1c9c203dfd935664d725cb24332de9f920fa52f Mon Sep 17 00:00:00 2001
From: Paul Kaplan <pkaplan@media.mit.edu>
Date: Sat, 5 Aug 2017 13:17:40 -0400
Subject: [PATCH] Add undo/redo functionality and tests

---
 src/components/sound-editor/icon--redo.svg    | Bin 0 -> 2891 bytes
 src/components/sound-editor/icon--undo.svg    | Bin 0 -> 2848 bytes
 src/components/sound-editor/sound-editor.css  |  28 +++++++++++++-
 src/components/sound-editor/sound-editor.jsx  |  34 +++++++++++++++++
 src/containers/sound-editor.jsx               |  34 ++++++++++++++++-
 .../__snapshots__/sound-editor.test.jsx.snap  |  24 ++++++++++++
 test/unit/components/sound-editor.test.jsx    |  26 ++++++++++++-
 test/unit/containers/sound-editor.test.jsx    |  35 ++++++++++++++++++
 8 files changed, 177 insertions(+), 4 deletions(-)
 create mode 100644 src/components/sound-editor/icon--redo.svg
 create mode 100644 src/components/sound-editor/icon--undo.svg

diff --git a/src/components/sound-editor/icon--redo.svg b/src/components/sound-editor/icon--redo.svg
new file mode 100644
index 0000000000000000000000000000000000000000..960785c7b6707bda17c4b2365f3b08abda42cd7c
GIT binary patch
literal 2891
zcmZ{mTW=ay6oudOE2h(zSYi(6eu==5N|nC2Qq-hBfSQSoj2R22w)^k<?E~0Ya^}Ub
zI9_}0wJ&F8pP%nH&11jYuZQitHA=Tlzr7w->+Q|F{rmfuF1O8oTy9s(&9Lp~?RIEC
zUw)YFA8(qc^=iE3fBeI9+uZi+o7-{TX8H2C?w@`ep69J@bYpx2$5{x|HU!=7=k4t{
zK75)^pPru7lT*X)W@@$8Q^=1;K0R;N+q?G;no`QN{@PxCXn1D7bY1gDzwLL+aoBxo
zzTNfX^=;#wvdu?VbSxK5*PKQCXSnU}*UMwF>Uy}J?hpUdbw|o^J#PBTUEl5c>tVN=
zO%K2!-m2eUUoLk2a_m=lG2R|Ss@b&ubUO88e<=um4#-WjUd`LD%bUK#iT$`6?y9fb
z{`9RoIomcr)|-u(E<eiWZujqo)BR(=9agKh8F$O={>QMppSSz#<);7GnMq?V4)_LX
z0DjWwi=#``se^*w`~BTGJak=WY}U`oQu;Hx`tiWveN@ziXVP>&_~Vz0le}O3qNFch
z&O)4p`8M_sohQoI&GJ9Gs{6iM-~S|%ehK64OOS(Y!gzXShXnpQ)#WE_-kp?A8m~{>
z=T4jm<?rlaNeQXUpVp~Zf=S6uOjbJQXp?QOtX4tiV6~rEqp&PyZ<+<Ljm4N~1rEl?
zoLyszDn1x<I2?V9MJGWc6xvsc6S@Gl$?1~cz{D=T=wjm=;;fR+o*3sUu1rwDI_*q2
z*Sr8WA!!SmRI)Z&Q4y4e*pgmSCdFuU5zu;0#yByN5H|8(jSzSrw9`glYjY@G&K7O7
z%XT6ktqpk8@Vhrj`{d8jD`JG`bWG(OUI^?2MnzCq5we3LDIbCs5kWcUL&`=#M{dzo
zRBKRck(MZeiP1vr5tK<Z$YCqfZV~KNHa1WXK}|6_8Wek#Objtq27cF3lJm7PG1PFz
z!K>?86a>@>gZ4&;j0le>FjNsy<wD$wA~J-GKNuK;4kDNqj5+WKO7Srhy-ww9LpU;6
zr1?NNr-IkPM?{Fl35&6Z4MsSxNrP&XLX{Eh6jcdM(x9F+m33)iEGc`8g@KF$bwh(y
zk!Vf`yfmxU1p-j=u|=wk9yDl6=`0!$BRwo~Y$<fMppM?pR#4}1DbiD~s8CoJ5m!Y7
zXE}@rr%0`2I$I8h2#OP;J(rvg#8J*hR9ID*6I!eu#x|SjR!|Ft_ya`}Z6xw8Dij<o
zDIKz=&bClHtBmoKscLiLHAP~>BwK)t$`si|UnOX=hy~kan1SUW{+HzN%+N+u&|?Ey
zBAmGrXk=;2Hh3Qfja)#f;2BpeHfU3Gh$zS+3lz(=ag`#`Br;r}Qdt>K3Trv*5T?Z@
z&?*hbOO6NBWV%BbC=q2YGU1wr5S`WagdIGB$`UEkm0r<Kgs3QO<_q1v0ESf?@kfhz
zUuZQt4F>uTdqE|hLmP_$$a@TeI8v=BO%+vP!h;Mmk_~Xdx1?fX5=(yLW>${5KjbW%
z#~5MAoMo_6Ju9<p0t`iIn{=ry#XtrlC1!<EU^-2@1f$r71%@I!$;g0P<ua(G%oet?
znro8;I@yY_qLK%VWNcXxIXK>2gdrzpqty1}DhSRu?0l`!$ki&ntQj;i24!(1BXdaK
zQ)LIQTo4h|%5XWAfH6W2)27_K2xgzqlaZ9=Vu4nw%t}KU7taFF`C?pvfUU>`r7R~I
zQ(}?Y>;9}a5DH}8UN`fVrF%42l!5mZUU1QgUe?z+49eAKYG=Q!e87^7p$Few57sNg
z<%gp4y9AU~YqBG^*K6n9YqhN}LoaVwKe@@}8jiWp=F)5=&82wJym{f-4P$-V@hrpo
lynP3HCFsr=THeg$+JEO2V|w!zoSg$NU+RaMys%z=_#a&si;n;R

literal 0
HcmV?d00001

diff --git a/src/components/sound-editor/icon--undo.svg b/src/components/sound-editor/icon--undo.svg
new file mode 100644
index 0000000000000000000000000000000000000000..af3d7ba4239d630cdc6c5a21344887285354b8bc
GIT binary patch
literal 2848
zcmZ{m+fE}#6h+_XD>|K*AkpQzUq&_~#nI%2NTbZ;1037~7RHV24v@dkI@K6V#PkdH
zcCpVs`&_E$pPufv%|pLGY=+&UHA=Tlzq=aNo89%I{p;K3F1O8LT<zAY?Xc???QUp4
zU3{1y9<H0m&3e4yfBgMZ+uZb<>zi@WX8H24=^uX`o))ccbYpx2$4Lm&HU!-r7VXVA
z-hZ6U9v>goqf^8FdS<oOGsvHhe0<t&cDL^xG^LbT{k6UL(D2NE>bmCle%J3;<FNnO
ze7)_*tDD9<Wt(4I(XpI0U2_ug@8PDu+pM0GRae8^>@fXL*F95?n{nG;?E7xtUk&^9
zd^Q17ymf!Lx>)Y})!47`V!U|{sphl#)A7{z!-XLHnUL#dvtG1cR@Z%p6Nhm>+*V(A
z{qb9Obhd52Z?;=8U4E3$-Tt2~r~8L~H>}sssn_%;+TZ%a?Ks?bU1x08PsvixalhIf
zz7PAmMO!~^QT$8ieN>pubKZ15_~$QY$Cy9+Wl5hupM*FG^L6a+J5Q7^+tt6cO80HQ
zx%)vR{SwC8mmrfD!gze<Qv!b;&He$KcSoh?jn_x+v;WC_A-!Kw7wrDSIu%PWDY<i#
zmCiZZWSdK?RnR$D?a!@ISQfK4%@WwgVobCG2jgSTt}#UwAB>p}M;~L+Nze#|_Lbs@
zE`e=wy5u)7v5POd*!YGxtE96h#;J-+6I8HHI}=VdFM&-++JYvPtc_Mw1f?Oiq?eRQ
zF&bS2w4ReOo|{Mr8~LwB2)qy4X(O<;ITSBvi#FP2doCZX4S3Y>yEjSu<WJE{Vua{)
zOyv|_3hV?%MNn7~vV$WjAA%MUK{@9`%0@s(ZqZd#Yfx*EmMDUW(L(GIlu0zmVJjnS
z5$shqHc$^iO))we6nm9S3^7#(e%Dcw^R+TD)NsbZtLs@51k?$G_C|+{2#+T)R1s0-
zLfncXGK7pj7#M>NBA6D8Iq(Qd@i7y<PUUPvcxJFj^MP<q1+RmTh!Bet7Gn=<iEv(%
z2GuBqDkIn_suG-}K|N_I>(aTgr0g*k1~LlN4GmUBqB$Y((yUq+2tdil7O66N(4Z})
zvuHq!^svaWrO?@eI(k1_L7mH`NKd_@LSbD*Ton<V<uD?gBDIp~Y&jevC{Bp>TymO-
zqnwSXu&OX8v{*fiZ8p=bpcV@82Z|)xNaS5qC^%YDnzE+Owop5(jPaDIYIEW>MPkDw
zTY!wp6xl>yC1|pU1>0qqf#o3nm*nuw&_-0yV*^?uoVgNcWNFJbcpnCh+$yQy8TTeO
zXj5~DD99oU6w9=6l_Jq3GF+fiSs70XYdPx>ro|@EDh<a=juUD!-60H=h%y(Ma7{yq
z&gy!?4jw^ei4^HduV^PiRFpRJg>GL0!>Wz=qeZ+gv>KfT1O11+pc2oajl}@uJqAG>
zsaBMximEW-L53O02Dso`QZX@!CBJbqE63bVIm_lTMi??@8SGTg$}F1zLs8l$U201)
zkikfaS)mk|PLnRdD7ImNp~y}$GT>IZ3@RzJg{`dS+9ZKawj!*k<Uu1DTUJC4j@J=k
z$cfn~wf(pXg7XbKUu!fPG|<bMK_g>O7DqBNhx9#FcJRst5kajCms1HCBjhk`%FT;l
z_6a>1Nm(uyXr;=mG?a1iECHP_#svu2icC<-a*{D67OB1N&w2x)K<4drGhbS|M{`9P
zcwgZq7oF&3eVxLfTz#f?_RGo#EXf#p@XhsLy)s;WC_24MKv}gWJ92xycHX^K+xmv{
z^4j!+n_RBpm<w$#%|_B(if7H?YPIF<!~mW(`(d2kRal1gIh$T)rZ<=qg6@Q&<#kK0
Z{dZn5X4h}Q`6=-7rGA*pTj<4y{{XQPevtqG

literal 0
HcmV?d00001

diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css
index c209d507a..88fb5773f 100644
--- a/src/components/sound-editor/sound-editor.css
+++ b/src/components/sound-editor/sound-editor.css
@@ -35,12 +35,14 @@
     padding: 3px;
 }
 
+$border-radius: 0.25rem;
+
 .button {
     height: 2rem;
     padding: 0.25rem;
     outline: none;
     background: white;
-    border-radius: 0.25rem;
+    border-radius: $border-radius;
     border: 1px solid #ddd;
     cursor: pointer;
     font-size: 0.85rem;
@@ -83,3 +85,27 @@
     width: 60px;
     height: 60px;
 }
+
+.button-group {
+    margin: 0 1rem;
+}
+
+.button-group .button {
+    border-radius: 0;
+    border-left: none;
+}
+
+.button-group .button:last-of-type {
+    border-top-right-radius: $border-radius;
+    border-bottom-right-radius: $border-radius;
+}
+
+.button-group .button:first-of-type {
+    border-left: 1px solid #ddd;
+    border-top-left-radius: $border-radius;
+    border-bottom-left-radius: $border-radius;
+}
+
+.button:disabled > img {
+    opacity: 0.25;
+}
diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx
index 240e42719..21c2613bf 100644
--- a/src/components/sound-editor/sound-editor.jsx
+++ b/src/components/sound-editor/sound-editor.jsx
@@ -16,6 +16,8 @@ import styles from './sound-editor.css';
 import playIcon from '../record-modal/icon--play.svg';
 import stopIcon from '../record-modal/icon--stop-playback.svg';
 import trimIcon from './icon--trim.svg';
+import redoIcon from './icon--redo.svg';
+import undoIcon from './icon--undo.svg';
 import echoIcon from './icon--echo.svg';
 import higherIcon from './icon--higher.svg';
 import lowerIcon from './icon--lower.svg';
@@ -52,6 +54,16 @@ const messages = defineMessages({
         description: 'Title of the button to save trimmed sound',
         defaultMessage: 'Save'
     },
+    undo: {
+        id: 'soundEditor.undo',
+        description: 'Title of the button to undo',
+        defaultMessage: 'Undo'
+    },
+    redo: {
+        id: 'soundEditor.redo',
+        description: 'Title of the button to redo',
+        defaultMessage: 'Redo'
+    },
     faster: {
         id: 'soundEditor.faster',
         description: 'Title of the button to apply the faster effect',
@@ -140,6 +152,24 @@ const SoundEditor = props => (
                         <FormattedMessage {...messages.save} />
                     )}
                 </button>
+                <div className={styles.buttonGroup}>
+                    <button
+                        className={styles.button}
+                        disabled={!props.canUndo}
+                        title={props.intl.formatMessage(messages.undo)}
+                        onClick={props.onUndo}
+                    >
+                        <img src={undoIcon} />
+                    </button>
+                    <button
+                        className={styles.button}
+                        disabled={!props.canRedo}
+                        title={props.intl.formatMessage(messages.redo)}
+                        onClick={props.onRedo}
+                    >
+                        <img src={redoIcon} />
+                    </button>
+                </div>
             </div>
         </div>
         <div className={styles.row}>
@@ -206,6 +236,8 @@ const SoundEditor = props => (
 );
 
 SoundEditor.propTypes = {
+    canRedo: PropTypes.bool.isRequired,
+    canUndo: PropTypes.bool.isRequired,
     chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired,
     intl: intlShape,
     name: PropTypes.string.isRequired,
@@ -215,6 +247,7 @@ SoundEditor.propTypes = {
     onFaster: PropTypes.func.isRequired,
     onLouder: PropTypes.func.isRequired,
     onPlay: PropTypes.func.isRequired,
+    onRedo: PropTypes.func.isRequired,
     onReverse: PropTypes.func.isRequired,
     onRobot: PropTypes.func.isRequired,
     onSetTrimEnd: PropTypes.func,
@@ -222,6 +255,7 @@ SoundEditor.propTypes = {
     onSlower: PropTypes.func.isRequired,
     onSofter: PropTypes.func.isRequired,
     onStop: PropTypes.func.isRequired,
+    onUndo: PropTypes.func.isRequired,
     playhead: PropTypes.number,
     trimEnd: PropTypes.number,
     trimStart: PropTypes.number
diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx
index 6f3bb4841..7170b645a 100644
--- a/src/containers/sound-editor.jsx
+++ b/src/containers/sound-editor.jsx
@@ -21,7 +21,10 @@ class SoundEditor extends React.Component {
             'handleActivateTrim',
             'handleUpdateTrimEnd',
             'handleUpdateTrimStart',
-            'handleEffect'
+            'handleEffect',
+            'handleUndo',
+            'handleRedo',
+            'submitNewSamples'
         ]);
         this.state = {
             chunkLevels: computeChunkedRMS(this.props.samples),
@@ -29,12 +32,17 @@ class SoundEditor extends React.Component {
             trimStart: null,
             trimEnd: null
         };
+
+        this.redoStack = [];
+        this.undoStack = [];
     }
     componentDidMount () {
         this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
     }
     componentWillReceiveProps (newProps) {
         if (newProps.soundId !== this.props.soundId) { // A different sound has been selected
+            this.redoStack = [];
+            this.undoStack = [];
             this.resetState(newProps.samples, newProps.sampleRate);
         }
     }
@@ -51,7 +59,11 @@ class SoundEditor extends React.Component {
             trimEnd: null
         });
     }
-    submitNewSamples (samples, sampleRate) {
+    submitNewSamples (samples, sampleRate, skipUndo) {
+        if (!skipUndo) {
+            this.redoStack = [];
+            this.undoStack.push(this.props.samples.slice(0));
+        }
         this.resetState(samples, sampleRate);
         this.props.onUpdateSoundBuffer(
             this.props.soundIndex,
@@ -101,9 +113,25 @@ class SoundEditor extends React.Component {
     handleEffect (/* name */) {
         // @todo implement effects
     }
+    handleUndo () {
+        this.redoStack.push(this.props.samples.slice(0));
+        const samples = this.undoStack.pop();
+        if (samples) {
+            this.submitNewSamples(samples, this.props.sampleRate, true);
+        }
+    }
+    handleRedo () {
+        const samples = this.redoStack.pop();
+        if (samples) {
+            this.undoStack.push(this.props.samples.slice(0));
+            this.submitNewSamples(samples, this.props.sampleRate, true);
+        }
+    }
     render () {
         return (
             <SoundEditorComponent
+                canRedo={this.redoStack.length > 0}
+                canUndo={this.undoStack.length > 0}
                 chunkLevels={this.state.chunkLevels}
                 name={this.props.name}
                 playhead={this.state.playhead}
@@ -115,6 +143,7 @@ class SoundEditor extends React.Component {
                 onFaster={this.effectFactory('faster')}
                 onLouder={this.effectFactory('louder')}
                 onPlay={this.handlePlay}
+                onRedo={this.handleRedo}
                 onReverse={this.effectFactory('reverse')}
                 onRobot={this.effectFactory('robot')}
                 onSetTrimEnd={this.handleUpdateTrimEnd}
@@ -122,6 +151,7 @@ class SoundEditor extends React.Component {
                 onSlower={this.effectFactory('slower')}
                 onSofter={this.effectFactory('softer')}
                 onStop={this.handleStopPlaying}
+                onUndo={this.handleUndo}
             />
         );
     }
diff --git a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
index 076dd860b..644d330d0 100644
--- a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
+++ b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
@@ -58,6 +58,30 @@ exports[`Sound Editor Component matches snapshot 1`] = `
           Save
         </span>
       </button>
+      <div
+        className={undefined}
+      >
+        <button
+          className={undefined}
+          disabled={false}
+          onClick={[Function]}
+          title="Undo"
+        >
+          <img
+            src="test-file-stub"
+          />
+        </button>
+        <button
+          className={undefined}
+          disabled={true}
+          onClick={[Function]}
+          title="Redo"
+        >
+          <img
+            src="test-file-stub"
+          />
+        </button>
+      </div>
     </div>
   </div>
   <div
diff --git a/test/unit/components/sound-editor.test.jsx b/test/unit/components/sound-editor.test.jsx
index b3b744235..6c12f4730 100644
--- a/test/unit/components/sound-editor.test.jsx
+++ b/test/unit/components/sound-editor.test.jsx
@@ -7,6 +7,8 @@ describe('Sound Editor Component', () => {
     let props;
     beforeEach(() => {
         props = {
+            canUndo: true,
+            canRedo: false,
             chunkLevels: [1, 2, 3],
             name: 'sound name',
             playhead: 0.5,
@@ -15,6 +17,7 @@ describe('Sound Editor Component', () => {
             onActivateTrim: jest.fn(),
             onChangeName: jest.fn(),
             onPlay: jest.fn(),
+            onRedo: jest.fn(),
             onReverse: jest.fn(),
             onSofter: jest.fn(),
             onLouder: jest.fn(),
@@ -24,7 +27,8 @@ describe('Sound Editor Component', () => {
             onSlower: jest.fn(),
             onSetTrimEnd: jest.fn(),
             onSetTrimStart: jest.fn(),
-            onStop: jest.fn()
+            onStop: jest.fn(),
+            onUndo: jest.fn()
         };
     });
 
@@ -64,6 +68,7 @@ describe('Sound Editor Component', () => {
             .simulate('blur');
         expect(props.onChangeName).toHaveBeenCalled();
     });
+
     test('effect buttons call the correct callbacks', () => {
         const wrapper = mountWithIntl(<SoundEditor {...props} />);
 
@@ -88,4 +93,23 @@ describe('Sound Editor Component', () => {
         wrapper.find('[children="Softer"]').simulate('click');
         expect(props.onSofter).toHaveBeenCalled();
     });
+
+    test('undo and redo buttons can be disabled by canUndo/canRedo', () => {
+        let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={false} />);
+        expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(false);
+        expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(true);
+
+        wrapper = mountWithIntl(<SoundEditor {...props} canUndo={false} canRedo={true} />);
+        expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(true);
+        expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(false);
+    });
+
+    test.skip('undo/redo buttons call the correct callback', () => {
+        let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={true} />);
+        wrapper.find('button[title="Undo"]').simulate('click');
+        expect(props.onUndo).toHaveBeenCalled();
+
+        wrapper.find('button[title="Redo"]').simulate('click');
+        expect(props.onRedo).toHaveBeenCalled();
+    });
 });
diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx
index cca22eea9..2d1920063 100644
--- a/test/unit/containers/sound-editor.test.jsx
+++ b/test/unit/containers/sound-editor.test.jsx
@@ -89,4 +89,39 @@ describe('Sound Editor Container', () => {
         component.props().onChangeName('hello');
         expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello');
     });
+
+    test('undo/redo functionality', () => {
+        const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />);
+        const component = wrapper.find(SoundEditorComponent);
+        // Undo and redo should be disabled initially
+        expect(component.prop('canUndo')).toEqual(false);
+        expect(component.prop('canRedo')).toEqual(false);
+
+        // Submitting new samples should make it possible to undo
+        component.props().onActivateTrim(); // Activate trimming
+        component.props().onActivateTrim(); // Submit new samples by calling again
+        wrapper.update();
+        expect(component.prop('canUndo')).toEqual(true);
+        expect(component.prop('canRedo')).toEqual(false);
+
+        // Undoing should make it possible to redo and not possible to undo again
+        component.props().onUndo();
+        wrapper.update();
+        expect(component.prop('canUndo')).toEqual(false);
+        expect(component.prop('canRedo')).toEqual(true);
+
+        // Redoing should make it possible to undo and not possible to redo again
+        component.props().onRedo();
+        wrapper.update();
+        expect(component.prop('canUndo')).toEqual(true);
+        expect(component.prop('canRedo')).toEqual(false);
+
+        // New submission should clear the redo stack
+        component.props().onUndo(); // Undo to go back to a state where redo is enabled
+        wrapper.update();
+        expect(component.prop('canRedo')).toEqual(true);
+        component.props().onActivateTrim(); // Activate trimming
+        component.props().onActivateTrim(); // Submit new samples by calling again
+        expect(component.prop('canRedo')).toEqual(false);
+    });
 });
-- 
GitLab