Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Review workflow UI improvements. #319

Merged
merged 10 commits into from
Nov 17, 2016
82 changes: 63 additions & 19 deletions src/plugins/signoff/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,44 @@ import AdminLink from "../../components/AdminLink";
import { ProgressBar, ProgressStep } from "./ProgressBar.js";


function isMember(groupkey, source, sessionState, bucketState) {
const {serverInfo: {user={}, capabilities}} = sessionState;
if (!user.id) {
return false;
}
const {signer={}} = capabilities;
const {[groupkey]: defaultGroupName} = signer;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could even do this in one line without intermediary capabilities and signer variables :) #PerlFTW

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean

const {serverInfo: {capabilities: {signer: {[groupKey]: defaultGroupName}={}}}} = sessionState;

LOLNOPE 😬

const {[groupkey]: groupName=defaultGroupName} = source;
const {id: userId} = user;
console.log(bucketState);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leftover

const {groups} = bucketState;
const editorGroup = groups.find(g => g.id === groupName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: editorGroupgroup

if (editorGroup == null) {
// XXX for now if we can't access the group it's probably because the user
// doesn't have the permission to read it, so we mark the user has a member
// of the group.
// Later when https://github.com/Kinto/kinto/pull/891/files lands, we'll
// have access to the principals attached to a given authentication, so we'll
// be able to properly check for membership.
return true;
}
return editorGroup.members.includes(userId);
}

function isEditor(source, sessionState, bucketState) {
return isMember("editors_group", source, sessionState, bucketState);
}

function isReviewer(source, sessionState, bucketState) {
return isMember("reviewers_group", source, sessionState, bucketState);
}

function isLastEditor(source, sessionState) {
const {serverInfo: {user={}}} = sessionState;
const {lastEditor} = source;
return user.id === lastEditor;
}

export default class SignoffToolBar extends React.Component {
props: {
sessionState: SessionState,
Expand Down Expand Up @@ -59,32 +97,38 @@ export default class SignoffToolBar extends React.Component {
}

const {source, preview, destination} = resource;
const canRequestReview = canEdit && isEditor(source, sessionState, bucketState);
const canReview = canEdit &&
isReviewer(source, sessionState, bucketState) &&
!isLastEditor(source, sessionState);
const canSign = canEdit && isReviewer(source, sessionState, bucketState);

// Default status is request review
const step = status == "to-review" ? 1 : status == "signed" ? 2 : 0;
return (
<ProgressBar>
<WorkInProgress label="Work in progress"
step={0}
currentStep={step}
canEdit={canEdit}
requestReview={requestReview}
source={source} />
<WorkInProgress
label="Work in progress"
step={0}
currentStep={step}
canEdit={canRequestReview}
requestReview={requestReview}
source={source} />
<Review label="Waiting review"
step={1}
currentStep={step}
canEdit={canEdit}
approveChanges={approveChanges}
declineChanges={declineChanges}
source={source}
preview={preview} />
step={1}
currentStep={step}
canEdit={canReview}
approveChanges={approveChanges}
declineChanges={declineChanges}
source={source}
preview={preview} />
<Signed label="Signed"
step={2}
currentStep={step}
canEdit={canEdit}
reSign={approveChanges}
source={source}
destination={destination} />
step={2}
currentStep={step}
canEdit={canSign}
reSign={approveChanges}
source={source}
destination={destination} />
</ProgressBar>
);
}
Expand Down
11 changes: 7 additions & 4 deletions src/plugins/signoff/sagas.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function* fetchWorkflowInfo(source, preview, destination) {
export function* handleRequestReview(getState, action) {
const {bucket} = getState();
try {
const collection = yield call([this, _updateCollectionAttributes], getState, {status: "to-review"});
const collection = yield call(_updateCollectionAttributes, getState, {status: "to-review"});
yield put(routeLoadSuccess({bucket, collection}));
yield put(notifySuccess("Review requested."));
} catch(e) {
Expand All @@ -118,7 +118,7 @@ export function* handleRequestReview(getState, action) {
export function* handleDeclineChanges(getState, action) {
const {bucket} = getState();
try {
const collection = yield call([this, _updateCollectionAttributes], getState, {status: "work-in-progress"});
const collection = yield call(_updateCollectionAttributes, getState, {status: "work-in-progress"});
yield put(routeLoadSuccess({bucket, collection}));
yield put(notifySuccess("Changes declined."));
} catch(e) {
Expand All @@ -129,7 +129,7 @@ export function* handleDeclineChanges(getState, action) {
export function* handleApproveChanges(getState, action) {
const {bucket} = getState();
try {
const collection = yield call([this, _updateCollectionAttributes], getState, {status: "to-sign"});
const collection = yield call(_updateCollectionAttributes, getState, {status: "to-sign"});
yield put(routeLoadSuccess({bucket, collection}));
yield put(notifySuccess("Signature requested."));
} catch(e) {
Expand All @@ -144,5 +144,8 @@ function _updateCollectionAttributes(getState, data) {
collection: {data: {id: cid, last_modified}}
} = getState();
const coll = client.bucket(bid).collection(cid);
return coll.setData(data, {safe: true, patch: true, last_modified});
return coll.setData(data, {safe: true, patch: true, last_modified})
.then(() => coll.getData())
// FIXME: https://github.com/Kinto/kinto-http.js/issues/150
.then(attributes => ({data: attributes}));
}
2 changes: 2 additions & 0 deletions src/plugins/signoff/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type SourceInfo = {
lastEditor: string,
lastReviewer: string,
lastStatusChanged: number,
editors_group?: string,
reviewers_group?: string,
};

export type PreviewInfo = {
Expand Down
1 change: 0 additions & 1 deletion src/reducers/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ const DEFAULT: SessionState = {
buckets: [],
serverInfo: {
capabilities: {},
user: {},
},
redirectURL: null,
};
Expand Down
3 changes: 2 additions & 1 deletion src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type Capabilities = {
history?: Object,
permissions_endpoint?: Object,
schema?: Object,
signer?: Object,
};

export type ClientError = {
Expand Down Expand Up @@ -253,7 +254,7 @@ export type SessionState = {
export type ServerInfo = {
capabilities: Capabilities,
user?: {
id?: string,
id: string,
bucket?: string,
}
};
Expand Down
128 changes: 128 additions & 0 deletions test/plugins/signoff/sagas_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { expect } from "chai";
import { put } from "redux-saga/effects";

import { notifySuccess } from "../../../src/actions/notifications";
import { routeLoadSuccess } from "../../../src/actions/route";
import { setClient } from "../../../src/client";
import * as actions from "../../../src/plugins/signoff/actions";
import * as saga from "../../../src/plugins/signoff/sagas";


describe("signoff plugin sagas", () => {
describe("handleRequestReview()", () => {
let bucket, collection, getState, handleRequestReview;

before(() => {
collection = {
getData() {},
setData() {},
};
bucket = {collection() {return collection;}};
setClient({bucket() {return bucket;}});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe put all that in a fakeClient variable shared with all tests?

const action = actions.requestReview();
getState = () => ({
bucket: {data: {id: "buck"}},
collection: {data: {id: "coll", last_modified: 42}}
});
handleRequestReview = saga.handleRequestReview(getState, action);
});

it("should update the collection status as 'to-review'", () => {
expect(handleRequestReview.next({id: "coll"}).value)
.to.have.property("CALL")
.to.have.property("args")
.to.include({status: "to-review"});
});

it("should dispatch the routeLoadSuccess action", () => {
expect(handleRequestReview.next({data: {id: "coll", status: "to-review"}}).value)
.eql(put(routeLoadSuccess({
bucket: {data: {id: "buck"}},
collection: {data: {id: "coll", status: "to-review"}},
})));
});

it("should dispatch a success notification", () => {
expect(handleRequestReview.next().value)
.eql(put(notifySuccess("Review requested.")));
});
});

describe("handleDeclineChanges()", () => {
let bucket, collection, getState, handleDeclineChanges;

before(() => {
collection = {
getData() {},
setData() {},
};
bucket = {collection() {return collection;}};
setClient({bucket() {return bucket;}});
const action = actions.requestReview();
getState = () => ({
bucket: {data: {id: "buck"}},
collection: {data: {id: "coll", last_modified: 42}}
});
handleDeclineChanges = saga.handleDeclineChanges(getState, action);
});

it("should update the collection status as 'work-in-progress'", () => {
expect(handleDeclineChanges.next({id: "coll"}).value)
.to.have.property("CALL")
.to.have.property("args")
.to.include({status: "work-in-progress"});
});

it("should dispatch the routeLoadSuccess action", () => {
expect(handleDeclineChanges.next({data: {id: "coll", status: "work-in-progress"}}).value)
.eql(put(routeLoadSuccess({
bucket: {data: {id: "buck"}},
collection: {data: {id: "coll", status: "work-in-progress"}},
})));
});

it("should dispatch a success notification", () => {
expect(handleDeclineChanges.next().value)
.eql(put(notifySuccess("Changes declined.")));
});
});

describe("handleApproveChanges()", () => {
let bucket, collection, getState, handleApproveChanges;

before(() => {
collection = {
getData() {},
setData() {},
};
bucket = {collection() {return collection;}};
setClient({bucket() {return bucket;}});
const action = actions.requestReview();
getState = () => ({
bucket: {data: {id: "buck"}},
collection: {data: {id: "coll", last_modified: 42}}
});
handleApproveChanges = saga.handleApproveChanges(getState, action);
});

it("should update the collection status as 'to-sign'", () => {
expect(handleApproveChanges.next({id: "coll"}).value)
.to.have.property("CALL")
.to.have.property("args")
.to.include({status: "to-sign"});
});

it("should dispatch the routeLoadSuccess action", () => {
expect(handleApproveChanges.next({data: {id: "coll", status: "to-sign"}}).value)
.eql(put(routeLoadSuccess({
bucket: {data: {id: "buck"}},
collection: {data: {id: "coll", status: "to-sign"}},
})));
});

it("should dispatch a success notification", () => {
expect(handleApproveChanges.next().value)
.eql(put(notifySuccess("Signature requested.")));
});
});
});
1 change: 0 additions & 1 deletion test/reducers/sessions_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ describe("session reducer", () => {
redirectURL: null,
serverInfo: {
capabilities: {},
user: {},
},
});
});
Expand Down