diff --git a/cypress/fixtures/items.js b/cypress/fixtures/items.js index a603a5a81..64f2c786d 100644 --- a/cypress/fixtures/items.js +++ b/cypress/fixtures/items.js @@ -23,6 +23,11 @@ export const EDITED_FIELDS = { description: 'new description', }; +export const MEMBERS = { + ANNA: { id: 'anna-id', name: 'anna', email: 'anna@email.com' }, + BOB: { id: 'bob-id', name: 'bob', email: 'bob@email.com' }, +}; + export const SIMPLE_ITEMS = [ { ...DEFAULT_ITEM, diff --git a/cypress/integration/shareItem.spec.js b/cypress/integration/shareItem.spec.js new file mode 100644 index 000000000..1bac99970 --- /dev/null +++ b/cypress/integration/shareItem.spec.js @@ -0,0 +1,62 @@ +import { PERMISSION_LEVELS } from '../../src/config/constants'; +import { buildItemPath } from '../../src/config/paths'; +import { + buildItemCard, + buildItemMenu, + ITEM_MENU_BUTTON_CLASS, + ITEM_MENU_SHARE_BUTTON_CLASS, + SHARE_ITEM_MODAL_PERMISSION_SELECT_ID, + SHARE_ITEM_MODAL_SHARE_BUTTON_ID, + buildPermissionOptionId, + SHARE_ITEM_MODAL_EMAIL_INPUT_ID, +} from '../../src/config/selectors'; +import { MEMBERS, SIMPLE_ITEMS } from '../fixtures/items'; + +const shareItem = ({ id, member, permission }) => { + const menuSelector = `#${buildItemCard(id)} .${ITEM_MENU_BUTTON_CLASS}`; + cy.get(menuSelector).click(); + cy.get(`#${buildItemMenu(id)} .${ITEM_MENU_SHARE_BUTTON_CLASS}`).click(); + + // select permission + cy.get(`#${SHARE_ITEM_MODAL_PERMISSION_SELECT_ID}`).click(); + cy.get(`#${buildPermissionOptionId(permission)}`).click(); + + // input mail + cy.get(`#${SHARE_ITEM_MODAL_EMAIL_INPUT_ID}`).type(member.email); + + cy.get(`#${SHARE_ITEM_MODAL_SHARE_BUTTON_ID}`).click(); +}; + +describe('Share Item', () => { + it('share item on Home', () => { + cy.setUpApi({ items: SIMPLE_ITEMS, members: Object.values(MEMBERS) }); + cy.visit('/'); + + // share + const { id } = SIMPLE_ITEMS[0]; + const member = MEMBERS.ANNA; + shareItem({ id, member, permission: PERMISSION_LEVELS.WRITE }); + + cy.wait('@shareItem').then(() => { + cy.get(`#${buildItemCard(id)}`).should('exist'); + }); + }); + + it('share item in item', () => { + cy.setUpApi({ items: SIMPLE_ITEMS, members: Object.values(MEMBERS) }); + + // go to children item + cy.visit(buildItemPath(SIMPLE_ITEMS[0].id)); + + // share + const { id } = SIMPLE_ITEMS[2]; + const member = MEMBERS.ANNA; + shareItem({ id, member, permission: PERMISSION_LEVELS.READ }); + + cy.wait('@shareItem').then(() => { + cy.get(`#${buildItemCard(id)}`).should('exist'); + }); + }); + + // todo : check item permission for users +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 49626442f..8f538ce4e 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -13,20 +13,26 @@ import { mockMoveItem, mockPostItem, mockEditItem, + mockShareItem, + mockGetMember, } from './server'; Cypress.Commands.add( 'setUpApi', ({ items = [], + members = [], deleteItemError = false, postItemError = false, moveItemError = false, copyItemError = false, getItemError = false, editItemError = false, + shareItemError = false, + getMemberError = false, } = {}) => { const cachedItems = JSON.parse(JSON.stringify(items)); + const cachedMembers = JSON.parse(JSON.stringify(members)); mockGetOwnItems(cachedItems); @@ -43,6 +49,10 @@ Cypress.Commands.add( mockCopyItem(cachedItems, copyItemError); mockEditItem(cachedItems, editItemError); + + mockShareItem(cachedItems, shareItemError); + + mockGetMember(cachedMembers, getMemberError); }, ); diff --git a/cypress/support/server.js b/cypress/support/server.js index 15c812839..aee7280dc 100644 --- a/cypress/support/server.js +++ b/cypress/support/server.js @@ -1,4 +1,5 @@ import { v4 as uuidv4 } from 'uuid'; +import { StatusCodes } from 'http-status-codes'; import { buildCopyItemRoute, buildDeleteItemRoute, @@ -8,6 +9,8 @@ import { buildMoveItemRoute, buildPostItemRoute, GET_OWN_ITEMS_ROUTE, + buildShareItemWithRoute, + MEMBERS_ROUTE, } from '../../src/api/routes'; import { getItemById, @@ -16,12 +19,7 @@ import { transformIdForPath, } from '../../src/utils/item'; import { CURRENT_USER_ID } from '../fixtures/items'; -import { - ERROR_CODE, - ID_FORMAT, - parseStringToRegExp, - SUCCESS_CODE, -} from './utils'; +import { ID_FORMAT, parseStringToRegExp, EMAIL_FORMAT } from './utils'; import { DEFAULT_PATCH, DEFAULT_GET, @@ -56,7 +54,7 @@ export const mockPostItem = (items, shouldThrowError) => { }, ({ body, reply }) => { if (shouldThrowError) { - return reply({ statusCode: ERROR_CODE }); + return reply({ statusCode: StatusCodes.BAD_REQUEST }); } // add necessary properties id, path and creator @@ -79,12 +77,12 @@ export const mockDeleteItem = (items, shouldThrowError) => { }, ({ url, reply }) => { if (shouldThrowError) { - return reply({ statusCode: ERROR_CODE, body: null }); + return reply({ statusCode: StatusCodes.BAD_REQUEST, body: null }); } const id = url.slice(API_HOST.length).split('/')[2]; return reply({ - statusCode: SUCCESS_CODE, + statusCode: StatusCodes.OK, body: getItemById(items, id), }); }, @@ -99,14 +97,14 @@ export const mockGetItem = (items, shouldThrowError) => { }, ({ url, reply }) => { if (shouldThrowError) { - return reply({ statusCode: ERROR_CODE, body: null }); + return reply({ statusCode: StatusCodes.BAD_REQUEST, body: null }); } const id = url.slice(API_HOST.length).split('/')[2]; const item = getItemById(items, id); return reply({ body: item, - statusCode: SUCCESS_CODE, + statusCode: StatusCodes.OK, }); }, ).as('getItem'); @@ -134,7 +132,7 @@ export const mockMoveItem = (items, shouldThrowError) => { }, ({ url, reply, body }) => { if (shouldThrowError) { - return reply({ statusCode: ERROR_CODE, body: null }); + return reply({ statusCode: StatusCodes.BAD_REQUEST, body: null }); } const id = url.slice(API_HOST.length).split('/')[2]; @@ -149,7 +147,7 @@ export const mockMoveItem = (items, shouldThrowError) => { // todo: do for all children return reply({ - statusCode: SUCCESS_CODE, + statusCode: StatusCodes.OK, body: item, // this might not be accurate }); }, @@ -164,7 +162,7 @@ export const mockCopyItem = (items, shouldThrowError) => { }, ({ url, reply, body }) => { if (shouldThrowError) { - return reply({ statusCode: ERROR_CODE, body: null }); + return reply({ statusCode: StatusCodes.BAD_REQUEST, body: null }); } const id = url.slice(API_HOST.length).split('/')[2]; @@ -181,7 +179,7 @@ export const mockCopyItem = (items, shouldThrowError) => { items.push(newItem); // todo: do for all children return reply({ - statusCode: SUCCESS_CODE, + statusCode: StatusCodes.OK, body: newItem, }); }, @@ -196,10 +194,53 @@ export const mockEditItem = (items, shouldThrowError) => { }, ({ reply, body }) => { if (shouldThrowError) { - return reply({ statusCode: ERROR_CODE }); + return reply({ statusCode: StatusCodes.BAD_REQUEST }); } return reply(body); }, ).as('editItem'); }; + +export const mockShareItem = (items, shouldThrowError) => { + cy.intercept( + { + method: DEFAULT_POST.method, + url: new RegExp( + `${API_HOST}/${parseStringToRegExp( + buildShareItemWithRoute(ID_FORMAT), + )}`, + ), + }, + ({ reply, body }) => { + if (shouldThrowError) { + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + + return reply(body); + }, + ).as('shareItem'); +}; + +export const mockGetMember = (members, shouldThrowError) => { + const emailReg = new RegExp(EMAIL_FORMAT); + cy.intercept( + { + method: DEFAULT_GET.method, + pathname: `/${MEMBERS_ROUTE}`, + query: { + email: emailReg, + }, + }, + ({ reply, url }) => { + if (shouldThrowError) { + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + + const mail = emailReg.exec(url)[0]; + const member = members.find(({ email }) => email === mail); + + return reply([member]); + }, + ).as('getMember'); +}; diff --git a/cypress/support/utils.js b/cypress/support/utils.js index 05b5fcf8f..03d933c94 100644 --- a/cypress/support/utils.js +++ b/cypress/support/utils.js @@ -1,7 +1,6 @@ // use simple id format for tests export const ID_FORMAT = '[a-z0-9-]*'; -export const ERROR_CODE = 400; -export const SUCCESS_CODE = 200; - export const parseStringToRegExp = (string) => string.replaceAll('?', '\\?'); + +export const EMAIL_FORMAT = '[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+'; diff --git a/package.json b/package.json index 5f5fefbc2..fccbe0d41 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "connected-react-router": "6.8.0", "dexie": "3.0.3", "history": "5.0.0", + "http-status-codes": "2.1.4", "i18next": "19.8.4", "immutable": "4.0.0-rc.12", "js-cookie": "2.2.1", diff --git a/src/actions/item.js b/src/actions/item.js index bf86c744d..d059dba73 100644 --- a/src/actions/item.js +++ b/src/actions/item.js @@ -21,6 +21,7 @@ import { EDIT_ITEM_SUCCESS, FLAG_SETTING_ITEM, FLAG_EDITING_ITEM, + GET_SHARED_ITEMS_SUCCESS, } from '../types/item'; import { getParentsIdsFromPath } from '../utils/item'; import { createFlag } from './utils'; @@ -40,19 +41,15 @@ export const setItem = (id) => async (dispatch) => { const item = await Api.getItem(id); const { children, parents } = item; - - // get children let newChildren = []; if (!children) { newChildren = await Api.getChildren(id); } - // get parents let newParents = []; if (!parents) { newParents = await buildParentsLine(item.path); } - dispatch({ type: SET_ITEM_SUCCESS, payload: { item, children: newChildren, parents: newParents }, @@ -132,14 +129,13 @@ export const createItem = (props) => async (dispatch) => { } }; -export const deleteItem = (id) => async (dispatch) => { +export const deleteItem = (item) => async (dispatch) => { try { dispatch(createFlag(FLAG_DELETING_ITEM, true)); - await Api.deleteItem(id); - + await Api.deleteItem(item.id); dispatch({ type: DELETE_ITEM_SUCCESS, - payload: id, + payload: item, }); } catch (e) { console.error(e); @@ -168,7 +164,7 @@ export const moveItem = (payload) => async (dispatch, getState) => { dispatch({ type: MOVE_ITEM_SUCCESS, - payload: payload.id, + payload, }); } catch (e) { console.error(e); @@ -226,3 +222,33 @@ export const editItem = (item) => async (dispatch) => { dispatch(createFlag(FLAG_EDITING_ITEM, false)); } }; + +export const getSharedItems = () => async (dispatch) => { + try { + const sharedItems = await Api.getSharedItems(); + + let childrenItems = []; + for (const item of sharedItems) { + const { children, id } = item; + // get children + let newChildren = []; + + // an item does not come automatically with children + // thus we need to fetch them if no children is specified + // we don't need to fetch again if they already exists (cache) + if (!children) { + // eslint-disable-next-line no-await-in-loop + newChildren = await Api.getChildren(id); + childrenItems = childrenItems.concat(newChildren); + item.children = newChildren.map(({ id: childId }) => childId); + } + } + + dispatch({ + type: GET_SHARED_ITEMS_SUCCESS, + payload: [...sharedItems, ...childrenItems], + }); + } catch (e) { + console.error(e); + } +}; diff --git a/src/actions/layout.js b/src/actions/layout.js index bd810deb3..df7007a12 100644 --- a/src/actions/layout.js +++ b/src/actions/layout.js @@ -2,6 +2,7 @@ import { SET_COPY_MODAL_SETTINGS, SET_EDIT_MODAL_SETTINGS, SET_MOVE_MODAL_SETTINGS, + SET_SHARE_MODAL_SETTINGS, } from '../types/layout'; export const setMoveModalSettings = (payload) => (dispatch) => { @@ -24,3 +25,10 @@ export const setEditModalSettings = (payload) => (dispatch) => { payload, }); }; + +export const setShareModalSettings = (payload) => (dispatch) => { + dispatch({ + type: SET_SHARE_MODAL_SETTINGS, + payload, + }); +}; diff --git a/src/actions/member.js b/src/actions/member.js new file mode 100644 index 000000000..e69de29bb diff --git a/src/actions/membership.js b/src/actions/membership.js new file mode 100644 index 000000000..3cef2e75d --- /dev/null +++ b/src/actions/membership.js @@ -0,0 +1,10 @@ +import * as Api from '../api/membership'; + +// eslint-disable-next-line import/prefer-default-export +export const shareItemWith = async ({ id, email, permission }) => { + try { + await Api.shareItemWith({ id, email, permission }); + } catch (e) { + console.error(e); + } +}; diff --git a/src/api/item.js b/src/api/item.js index c34586165..72ef9edb6 100644 --- a/src/api/item.js +++ b/src/api/item.js @@ -8,6 +8,7 @@ import { buildMoveItemRoute, buildPostItemRoute, GET_OWN_ITEMS_ROUTE, + SHARE_ITEM_WITH_ROUTE, } from './routes'; import { DEFAULT_DELETE, @@ -161,3 +162,15 @@ export const copyItem = async ({ id, to }) => { return newItem; }; + +export const getSharedItems = async () => { + const res = await fetch(`${API_HOST}/${SHARE_ITEM_WITH_ROUTE}`, { + ...DEFAULT_GET, + }); + + if (!res.ok) { + throw new Error(res); + } + + return res.json(); +}; diff --git a/src/api/member.js b/src/api/member.js new file mode 100644 index 000000000..326848846 --- /dev/null +++ b/src/api/member.js @@ -0,0 +1,25 @@ +import fetch from 'node-fetch'; +import { API_HOST } from '../config/constants'; +import { DEFAULT_GET } from './utils'; +import { buildGetMemberBy, buildGetMember } from './routes'; + +export const getMemberBy = async ({ email }) => { + const res = await fetch(`${API_HOST}/${buildGetMemberBy(email)}`, { + ...DEFAULT_GET, + }); + if (!res.ok) { + throw new Error((await res.json()).message); + } + + return res.json(); +}; + +export const getMember = async ({ id }) => { + const res = await fetch(`${API_HOST}/${buildGetMember(id)}`, { + ...DEFAULT_GET, + }); + if (!res.ok) { + throw new Error((await res.json()).message); + } + return res.json(); +}; diff --git a/src/api/membership.js b/src/api/membership.js new file mode 100644 index 000000000..7221c78cc --- /dev/null +++ b/src/api/membership.js @@ -0,0 +1,34 @@ +import { getMemberBy } from './member'; +import { API_HOST } from '../config/constants'; +import { DEFAULT_GET, DEFAULT_POST } from './utils'; +import { + buildShareItemWithRoute, + buildGetItemMembershipForItemRoute, +} from './routes'; +import { MEMBER_NOT_FOUND_ERROR } from '../config/errors'; + +export const getMembershipsForItem = async ({ id }) => { + const res = await fetch( + `${API_HOST}/${buildGetItemMembershipForItemRoute(id)}`, + DEFAULT_GET, + ); + + if (!res.ok) { + throw new Error(res); + } + + return res.json(); +}; + +export const shareItemWith = async ({ id, email, permission }) => { + const member = await getMemberBy({ email }); + if (!member) { + throw new Error(MEMBER_NOT_FOUND_ERROR); + } + const res = await fetch(`${API_HOST}/${buildShareItemWithRoute(id)}`, { + ...DEFAULT_POST, + body: JSON.stringify({ memberId: member[0].id, permission }), // supposed to have only one member for this mail + }); + + return res.ok; +}; diff --git a/src/api/routes.js b/src/api/routes.js index a282319d1..ea7ec8d5b 100644 --- a/src/api/routes.js +++ b/src/api/routes.js @@ -1,4 +1,5 @@ export const GET_OWN_ITEMS_ROUTE = `items/own`; +export const SHARE_ITEM_WITH_ROUTE = 'items/shared-with'; export const buildPostItemRoute = (parentId) => { let url = `items`; if (parentId) { @@ -12,3 +13,10 @@ export const buildGetItemRoute = (id) => `items/${id}`; export const buildMoveItemRoute = (id) => `items/${id}/move`; export const buildCopyItemRoute = (id) => `items/${id}/copy`; export const buildEditItemRoute = (id) => `items/${id}`; +export const buildShareItemWithRoute = (id) => `item-memberships?itemId=${id}`; +export const buildGetItemMembershipForItemRoute = (id) => + `item-memberships?itemId=${id}`; + +export const MEMBERS_ROUTE = `members`; +export const buildGetMemberBy = (email) => `${MEMBERS_ROUTE}?email=${email}`; +export const buildGetMember = (id) => `${MEMBERS_ROUTE}/${id}`; diff --git a/src/components/App.js b/src/components/App.js index b386500e4..8c1bb4d1f 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -1,11 +1,12 @@ -import React from 'react'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; import { BrowserRouter as Router, Switch, Route, Redirect, } from 'react-router-dom'; -import { makeStyles } from '@material-ui/core/styles'; +import { withStyles } from '@material-ui/core'; import Header from './layout/Header'; import items from '../data/sample'; import SignUp from './SignUp'; @@ -18,42 +19,62 @@ import { import SignIn from './SignIn'; import Home from './main/Home'; import ItemScreen from './main/ItemScreen'; +import MoveItemModal from './main/MoveItemModal'; +import EditItemModal from './main/EditItemModal'; +import CopyItemModal from './main/CopyItemModal'; +import ShareItemModal from './main/ShareItemModal'; -const useStyles = makeStyles((theme) => ({ +const styles = (theme) => ({ root: { padding: theme.spacing(3), }, -})); +}); +// eslint-disable-next-line react/prefer-stateless-function +class App extends Component { + static propTypes = { + classes: PropTypes.shape({ + root: PropTypes.string.isRequired, + }).isRequired, + }; -function App() { - const classes = useStyles(); - return ( - -
-
-
- - - - - - - - - - - - - - - - - - -
-
-
- ); + render() { + const { classes } = this.props; + return ( + <> + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ + ); + } } -export default App; +const StyledComponent = withStyles(styles)(App); + +export default StyledComponent; diff --git a/src/components/main/Home.js b/src/components/main/Home.js index 3d693e700..709bb8008 100644 --- a/src/components/main/Home.js +++ b/src/components/main/Home.js @@ -1,10 +1,13 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import Divider from '@material-ui/core/Divider'; +import Typography from '@material-ui/core/Typography'; import { connect } from 'react-redux'; +import { withTranslation } from 'react-i18next'; import { withRouter } from 'react-router'; import ItemsHeader from './ItemsHeader'; import NewItemButton from './NewItemButton'; -import { getOwnItems } from '../../actions/item'; +import { setItem, getOwnItems, getSharedItems } from '../../actions/item'; import ItemsGrid from './ItemsGrid'; class Home extends Component { @@ -14,7 +17,10 @@ class Home extends Component { params: PropTypes.shape({ itemId: PropTypes.string }).isRequired, }).isRequired, activity: PropTypes.bool.isRequired, - rootItems: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + ownItems: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + sharedItems: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + t: PropTypes.func.isRequired, + dispatchGetSharedItems: PropTypes.func.isRequired, }; async componentDidMount() { @@ -23,23 +29,38 @@ class Home extends Component { } async componentDidUpdate() { - const { dispatchGetOwnItems, activity, rootItems } = this.props; + const { + dispatchGetOwnItems, + dispatchGetSharedItems, + activity, + ownItems, + sharedItems, + } = this.props; if (!activity) { // update dirty items - if (rootItems.some(({ dirty }) => dirty)) { + if (ownItems.some(({ dirty }) => dirty)) { dispatchGetOwnItems(); } + // update dirty items + if (sharedItems.some(({ dirty }) => dirty)) { + dispatchGetSharedItems(); + } } } render() { - const { rootItems } = this.props; + const { ownItems, sharedItems, t } = this.props; + return ( <> - + {t('My Items')} + + + {t('Items Shared With Me')} + ); } @@ -47,12 +68,16 @@ class Home extends Component { const mapStateToProps = ({ item }) => ({ activity: Object.values(item.get('activity').toJS()).flat().length, - rootItems: item.get('rootItems'), + ownItems: item.get('own'), + sharedItems: item.get('shared'), }); const mapDispatchToProps = { dispatchGetOwnItems: getOwnItems, + dispatchSetItem: setItem, + dispatchGetSharedItems: getSharedItems, }; const ConnectedComponent = connect(mapStateToProps, mapDispatchToProps)(Home); -export default withRouter(ConnectedComponent); +const TranslatedComponent = withTranslation()(ConnectedComponent); +export default withRouter(TranslatedComponent); diff --git a/src/components/main/Item.js b/src/components/main/Item.js index 131b8a95c..d3cd6470e 100644 --- a/src/components/main/Item.js +++ b/src/components/main/Item.js @@ -52,7 +52,7 @@ const Item = ({ item, dispatchDeleteItem }) => { dispatchDeleteItem(id)} + onClick={() => dispatchDeleteItem(item)} > diff --git a/src/components/main/ItemMenu.js b/src/components/main/ItemMenu.js index 461bf9e2b..a47f7923c 100644 --- a/src/components/main/ItemMenu.js +++ b/src/components/main/ItemMenu.js @@ -10,6 +10,7 @@ import { setMoveModalSettings, setCopyModalSettings, setEditModalSettings, + setShareModalSettings, } from '../../actions/layout'; import { buildItemMenu, @@ -17,6 +18,7 @@ import { ITEM_MENU_COPY_BUTTON_CLASS, ITEM_MENU_EDIT_BUTTON_CLASS, ITEM_MENU_MOVE_BUTTON_CLASS, + ITEM_MENU_SHARE_BUTTON_CLASS, } from '../../config/selectors'; import { editItem } from '../../actions/item'; @@ -25,6 +27,7 @@ const ItemMenu = ({ dispatchSetMoveModalSettings, dispatchSetCopyModalSettings, dispatchSetEditModalSettings, + dispatchSetShareModalSettings, }) => { const [anchorEl, setAnchorEl] = React.useState(null); const { t } = useTranslation(); @@ -52,6 +55,11 @@ const ItemMenu = ({ handleClose(); }; + const handleShare = () => { + dispatchSetShareModalSettings({ open: true, itemId: item.id }); + handleClose(); + }; + return ( <> @@ -73,6 +81,12 @@ const ItemMenu = ({ {t('Copy')} + + {t('Share')} + ); @@ -85,12 +99,14 @@ ItemMenu.propTypes = { }).isRequired, dispatchSetMoveModalSettings: PropTypes.func.isRequired, dispatchSetCopyModalSettings: PropTypes.func.isRequired, + dispatchSetShareModalSettings: PropTypes.func.isRequired, }; const mapDispatchToProps = { dispatchSetMoveModalSettings: setMoveModalSettings, dispatchSetCopyModalSettings: setCopyModalSettings, dispatchSetEditModalSettings: setEditModalSettings, + dispatchSetShareModalSettings: setShareModalSettings, dispatchEditItem: editItem, }; diff --git a/src/components/main/ItemScreen.js b/src/components/main/ItemScreen.js index a45b7f2e2..61f187c36 100644 --- a/src/components/main/ItemScreen.js +++ b/src/components/main/ItemScreen.js @@ -8,7 +8,12 @@ import CircularProgress from '@material-ui/core/CircularProgress'; import { withRouter } from 'react-router'; import ItemsHeader from './ItemsHeader'; import NewItemButton from './NewItemButton'; -import { clearItem, setItem } from '../../actions/item'; +import { + clearItem, + getOwnItems, + setItem, + getSharedItems, +} from '../../actions/item'; import ItemsGrid from './ItemsGrid'; import { ITEM_SCREEN_ERROR_ALERT_ID } from '../../config/selectors'; import { areItemsEqual } from '../../utils/item'; @@ -23,6 +28,8 @@ class ItemScreen extends Component { t: PropTypes.func.isRequired, activity: PropTypes.bool, item: PropTypes.instanceOf(Map), + dispatchGetSharedItems: PropTypes.func.isRequired, + dispatchGetOwnItems: PropTypes.func.isRequired, }; static defaultProps = { @@ -41,6 +48,12 @@ class ItemScreen extends Component { dispatchSetItem(itemId); } + componentDidMount() { + const { dispatchGetOwnItems, dispatchGetSharedItems } = this.props; + dispatchGetOwnItems(); + dispatchGetSharedItems(); + } + shouldComponentUpdate({ item: nextItem, match: { @@ -125,6 +138,8 @@ const mapStateToProps = ({ item }) => ({ const mapDispatchToProps = { dispatchSetItem: setItem, dispatchClearItem: clearItem, + dispatchGetOwnItems: getOwnItems, + dispatchGetSharedItems: getSharedItems, }; const ConnectedComponent = connect( diff --git a/src/components/main/ItemsGrid.js b/src/components/main/ItemsGrid.js index eebb25c55..713306d9d 100644 --- a/src/components/main/ItemsGrid.js +++ b/src/components/main/ItemsGrid.js @@ -6,10 +6,7 @@ import { withTranslation } from 'react-i18next'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; import Item from './Item'; -import MoveItemModal from './MoveItemModal'; -import CopyItemModal from './CopyItemModal'; import { ITEMS_GRID_NO_ITEM_ID } from '../../config/selectors'; -import EditItemModal from './EditItemModal'; class ItemsGrid extends Component { static propTypes = { @@ -49,10 +46,6 @@ class ItemsGrid extends Component { {this.renderItems()} - - - - ); } diff --git a/src/components/main/ShareItemModal.js b/src/components/main/ShareItemModal.js new file mode 100644 index 000000000..e34ef7b40 --- /dev/null +++ b/src/components/main/ShareItemModal.js @@ -0,0 +1,162 @@ +import React from 'react'; +import { Map } from 'immutable'; +import { useTranslation } from 'react-i18next'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import TextField from '@material-ui/core/TextField'; +import Grid from '@material-ui/core/Grid'; +import Dialog from '@material-ui/core/Dialog'; +import InputLabel from '@material-ui/core/InputLabel'; +import MenuItem from '@material-ui/core/MenuItem'; +import Button from '@material-ui/core/Button'; +import { withStyles } from '@material-ui/core'; +import DialogActions from '@material-ui/core/DialogActions'; +import FormControl from '@material-ui/core/FormControl'; +import Select from '@material-ui/core/Select'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import { shareItemWith } from '../../actions/membership'; +import { setShareModalSettings } from '../../actions/layout'; +import { + DEFAULT_PERMISSION_LEVEL, + PERMISSION_LEVELS, +} from '../../config/constants'; +import { + buildPermissionOptionId, + SHARE_ITEM_MODAL_EMAIL_INPUT_ID, + SHARE_ITEM_MODAL_PERMISSION_SELECT_ID, + SHARE_ITEM_MODAL_SHARE_BUTTON_ID, +} from '../../config/selectors'; + +const styles = (theme) => ({ + formControl: { + margin: theme.spacing(1), + minWidth: 120, + }, + dialogContent: { + display: 'flex', + flexDirection: 'column', + }, + shortInputField: { + width: '50%', + }, + addedMargin: { + marginTop: theme.spacing(2), + }, + emailInput: { + width: '100%', + }, +}); + +const ShareItemModal = ({ + dispatchSetShareModalSettings, + settings, + classes, +}) => { + const { t } = useTranslation(); + // refs + let email = ''; + let permission = ''; + + const handleClose = () => { + dispatchSetShareModalSettings({ open: false, itemId: null }); + }; + + const submit = () => { + // todo: check mail + const id = settings.get('itemId'); + shareItemWith({ + id, + email: email.value, + permission: permission.value, + }); + + handleClose(); + }; + + const labelId = 'permission-label'; + const renderPermissionSelect = () => ( + + {t('Permission')} + + + ); + + const open = settings.get('open'); + + return ( + + {t('Share Item')} + + + + { + email = c; + }} + label={t('Email')} + /> + + + {renderPermissionSelect()} + + + + + + + + + ); +}; + +ShareItemModal.propTypes = { + settings: PropTypes.instanceOf(Map).isRequired, + classes: PropTypes.shape({ + dialogContent: PropTypes.string.isRequired, + formControl: PropTypes.string.isRequired, + emailInput: PropTypes.string.isRequired, + }).isRequired, + dispatchSetShareModalSettings: PropTypes.func.isRequired, +}; + +const mapStateToProps = ({ layout }) => ({ + settings: layout.get('shareModal'), +}); + +const mapDispatchToProps = { + dispatchSetShareModalSettings: setShareModalSettings, +}; + +const ConnectedComponent = connect( + mapStateToProps, + mapDispatchToProps, +)(ShareItemModal); + +const StyledComponent = withStyles(styles)(ConnectedComponent); +export default StyledComponent; diff --git a/src/components/main/TreeModal.js b/src/components/main/TreeModal.js index 61003c4d3..33ec071b0 100644 --- a/src/components/main/TreeModal.js +++ b/src/components/main/TreeModal.js @@ -17,6 +17,7 @@ import { getItem, getOwnItems, getItems, + getSharedItems, } from '../../actions/item'; import { ROOT_ID, @@ -66,6 +67,7 @@ class TreeModal extends Component { items: PropTypes.instanceOf(List).isRequired, dispatchGetItems: PropTypes.func.isRequired, dispatchGetOwnItems: PropTypes.func.isRequired, + dispatchGetSharedItems: PropTypes.func.isRequired, }; static defaultProps = { @@ -75,8 +77,9 @@ class TreeModal extends Component { state = { selectedId: null, expandedItems: [ROOT_ID] }; componentDidMount() { - const { dispatchGetOwnItems } = this.props; + const { dispatchGetOwnItems, dispatchGetSharedItems } = this.props; dispatchGetOwnItems(); + dispatchGetSharedItems(); this.updateExpandedElements(); } @@ -246,7 +249,7 @@ class TreeModal extends Component { const mapStateToProps = ({ item }) => ({ items: item.get('items'), - rootItems: item.get('rootItems'), + rootItems: item.get('own'), }); const mapDispatchToProps = { @@ -254,6 +257,7 @@ const mapDispatchToProps = { dispatchGetItem: getItem, dispatchGetOwnItems: getOwnItems, dispatchGetChildren: getChildren, + dispatchGetSharedItems: getSharedItems, }; const ConnectedComponent = connect( diff --git a/src/config/constants.js b/src/config/constants.js index ac0b51dfa..93fee866d 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -23,3 +23,13 @@ export const ITEM_TYPES = { APPLICATION: 'Application', EXERCISE: 'Exercise', }; +export const DRAWER_WIDTH = 300; +export const DEFAULT_LOCALE = 'en-US'; + +export const PERMISSION_LEVELS = { + WRITE: 'write', + READ: 'read', + ADMIN: 'admin', +}; + +export const DEFAULT_PERMISSION_LEVEL = PERMISSION_LEVELS.WRITE; diff --git a/src/config/errors.js b/src/config/errors.js new file mode 100644 index 000000000..5f7739c98 --- /dev/null +++ b/src/config/errors.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const MEMBER_NOT_FOUND_ERROR = 'MEMBER_NOT_FOUND_ERROR'; diff --git a/src/config/selectors.js b/src/config/selectors.js index 37f0e3f29..65e7645d9 100644 --- a/src/config/selectors.js +++ b/src/config/selectors.js @@ -19,3 +19,9 @@ export const buildTreeItemClass = (id) => `treeItem-${id}`; export const TREE_MODAL_CONFIRM_BUTTON_ID = 'treeModalConfirmButton'; export const ITEMS_GRID_NO_ITEM_ID = 'itemsGridNoItem'; export const ITEM_MENU_EDIT_BUTTON_CLASS = 'itemMenuEditButton'; +export const ITEM_MENU_SHARE_BUTTON_CLASS = 'itemMenuShareButton'; +export const SHARE_ITEM_MODAL_EMAIL_INPUT_ID = 'shareItemModalEmailInput'; +export const SHARE_ITEM_MODAL_PERMISSION_SELECT_ID = + 'shareItemModalPermissionSelect'; +export const buildPermissionOptionId = (id) => `permission-${id}`; +export const SHARE_ITEM_MODAL_SHARE_BUTTON_ID = 'shareItemModalShareButton'; diff --git a/src/langs/en.json b/src/langs/en.json index 2ca0b5de8..c28c8f257 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -35,6 +35,15 @@ "Edit Item": "Edit Item", "Edit": "Edit", "Add New Item": "Add New Item", - "Add Item": "Add Item" + "Add Item": "Add Item", + "Add new Item": "Add new Item", + "No Item Selected": "No Item Selected", + "Children": "Children", + "Extra": "Extra", + "Creator": "Creator", + "Created At": "Created At", + "Updated At": "Updated At", + "My Items": "My Items", + "Items Shared With Me": "Items Shared With Me" } } diff --git a/src/langs/fr.json b/src/langs/fr.json index 28de4d114..7d6c26046 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -28,13 +28,22 @@ "Owned Items": "Mes Éléments", "Type by author": "{{type}} par {{author}}", "Unknown": "Inconnu", - "Home": "Home", + "Home": "Accueil", "Add item": "Ajouter un élément", "Copy": "Copier", + "Add new Item": "Ajouter un nouvel élément", + "No Item Selected": "Aucun élément sélectionné", "Where do you want to copy this item?": "Où copier cet élément?", "Edit Item": "Modifier l'élément", "Edit": "Modifier", "Add New Item": "Ajouter Un Nouvel Elément", - "Add Item": "Ajouter Un Elément" + "Add Item": "Ajouter Un Elément", + "Children": "Enfants", + "Extra": "Extra", + "Creator": "Créateur", + "Created At": "Créé à", + "Updated At": "Mis à jour à", + "My Items": "Mes éléments", + "Items Shared With Me": "Eléments partagés avec moi" } } diff --git a/src/reducers/item.js b/src/reducers/item.js index 6522b1cbf..23bea23ad 100644 --- a/src/reducers/item.js +++ b/src/reducers/item.js @@ -22,6 +22,7 @@ import { FLAG_SETTING_ITEM, EDIT_ITEM_SUCCESS, FLAG_EDITING_ITEM, + GET_SHARED_ITEMS_SUCCESS, } from '../types/item'; const DEFAULT_ITEM = Map({ @@ -32,7 +33,8 @@ const DEFAULT_ITEM = Map({ const INITIAL_STATE = Map({ item: DEFAULT_ITEM, items: List(), - rootItems: List(), // items + shared: List(), + own: List(), // items activity: Map({ [FLAG_GETTING_ITEM]: [], [FLAG_CREATING_ITEM]: [], @@ -74,7 +76,7 @@ const updateInList = (els) => (list) => { return updateItemInList(els, list); }; -const removeFromList = (deletedItemId) => (list) => +const removeFromList = ({ id: deletedItemId }) => (list) => list.filter(({ id }) => id !== deletedItemId); export default (state = INITIAL_STATE, { type, payload }) => { @@ -110,7 +112,7 @@ export default (state = INITIAL_STATE, { type, payload }) => { const from = state.getIn(['item', 'id']); // add item in children or in root items if (!from) { - return state.update('rootItems', updateInList(payload)); + return state.update('own', updateInList(payload)); } return state.updateIn(['item', 'children'], updateInList(payload)); } @@ -119,7 +121,7 @@ export default (state = INITIAL_STATE, { type, payload }) => { const from = state.getIn(['item', 'id']); // delete item in children or in root items if (!from) { - return state.update('rootItems', removeFromList(payload)); + return state.update('own', removeFromList(payload)); } return state.updateIn(['item', 'children'], removeFromList(payload)); } @@ -130,18 +132,22 @@ export default (state = INITIAL_STATE, { type, payload }) => { return state.updateIn(['item', 'children'], updateInList(item)); } if (to === ROOT_ID) { - return state.updateIn(['rootItems'], updateInList(item)); + return state.updateIn(['own'], updateInList(item)); } return state; } case GET_CHILDREN_SUCCESS: { return state.updateIn(['items'], updateInList(payload.children)); } - case GET_OWN_ITEMS_SUCCESS: { + case GET_SHARED_ITEMS_SUCCESS: return state - .setIn(['rootItems'], List(payload)) + .setIn(['shared'], List(payload)) .updateIn(['items'], updateInList(payload)); - } + case GET_OWN_ITEMS_SUCCESS: + return state + .setIn(['own'], List(payload)) + .updateIn(['items'], updateInList(payload)); + case EDIT_ITEM_SUCCESS: { // update current elements if (state.getIn(['item', 'id'])) { @@ -149,7 +155,7 @@ export default (state = INITIAL_STATE, { type, payload }) => { } // update home elements - return state.updateIn(['rootItems'], updateInList(payload)); + return state.updateIn(['own'], updateInList(payload)); } default: return state; diff --git a/src/reducers/layout.js b/src/reducers/layout.js index 14607a311..d4eade73f 100644 --- a/src/reducers/layout.js +++ b/src/reducers/layout.js @@ -3,6 +3,7 @@ import { SET_MOVE_MODAL_SETTINGS, SET_COPY_MODAL_SETTINGS, SET_EDIT_MODAL_SETTINGS, + SET_SHARE_MODAL_SETTINGS, } from '../types/layout'; const INITIAL_STATE = Map({ @@ -18,6 +19,10 @@ const INITIAL_STATE = Map({ open: false, itemId: null, }), + shareModal: Map({ + open: false, + itemId: null, + }), }); export default (state = INITIAL_STATE, { type, payload }) => { @@ -28,6 +33,8 @@ export default (state = INITIAL_STATE, { type, payload }) => { return state.setIn(['moveModal'], Map(payload)); case SET_EDIT_MODAL_SETTINGS: return state.setIn(['editModal'], Map(payload)); + case SET_SHARE_MODAL_SETTINGS: + return state.setIn(['shareModal'], Map(payload)); default: return state; } diff --git a/src/types/item.js b/src/types/item.js index 3f4b68bb5..1c29edc94 100644 --- a/src/types/item.js +++ b/src/types/item.js @@ -19,3 +19,5 @@ export const FLAG_COPYING_ITEM = 'FLAG_COPYING_ITEM'; export const FLAG_SETTING_ITEM = 'FLAG_SETTING_ITEM'; export const EDIT_ITEM_SUCCESS = 'EDIT_ITEM_SUCCESS'; export const FLAG_EDITING_ITEM = 'FLAG_EDITING_ITEM'; +export const SET_SELECTED_ITEM_SUCCESS = 'SET_SELECTED_ITEM_SUCCESS'; +export const GET_SHARED_ITEMS_SUCCESS = 'GET_SHARED_ITEMS_SUCCESS'; diff --git a/src/types/layout.js b/src/types/layout.js index bbb2d3ec5..b5afb361b 100644 --- a/src/types/layout.js +++ b/src/types/layout.js @@ -1,3 +1,4 @@ export const SET_MOVE_MODAL_SETTINGS = 'SET_MOVE_MODAL_SETTINGS'; export const SET_COPY_MODAL_SETTINGS = 'SET_COPY_MODAL_SETTINGS'; export const SET_EDIT_MODAL_SETTINGS = 'SET_EDIT_MODAL_SETTINGS'; +export const SET_SHARE_MODAL_SETTINGS = 'SET_SHARE_MODAL_SETTINGS'; diff --git a/yarn.lock b/yarn.lock index 56453fe07..9da9ea7d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6639,6 +6639,11 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +http-status-codes@2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.1.4.tgz#453d99b4bd9424254c4f6a9a3a03715923052798" + integrity sha512-MZVIsLKGVOVE1KEnldppe6Ij+vmemMuApDfjhVSLzyYP+td0bREEYyAoIw9yFePoBXManCuBqmiNP5FqJS5Xkg== + https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"