From bf9d1fcab437a939311321ac8c244d942d2cb856 Mon Sep 17 00:00:00 2001 From: pyphilia Date: Fri, 25 Jun 2021 10:46:17 +0200 Subject: [PATCH] feat: add, edit and delete memberships from settings --- cypress/fixtures/items.js | 28 +++ cypress/fixtures/memberships.js | 4 + .../item/itemLogin/itemLogin.spec.js | 7 +- cypress/integration/item/view/utils.js | 10 +- .../memberships/create/gridShareItem.spec.js | 50 ------ .../memberships/create/listShareItem.spec.js | 57 ------ .../memberships/createItemMembership.spec.js | 32 ++++ .../memberships/deleteItemMembership.spec.js | 29 +++ .../memberships/editItemMembership.spec.js | 40 +++++ .../memberships/view/viewMemberships.spec.js | 34 ---- .../memberships/viewMemberships.spec.js | 70 ++++++++ cypress/support/commands.js | 6 + cypress/support/commands/item.js | 15 +- cypress/support/server.js | 36 ++++ package.json | 2 +- .../context/ItemLayoutModeContext.js | 28 --- src/components/context/LayoutContext.js | 55 ++++++ src/components/context/ModalProviders.js | 26 +-- .../context/ShareItemModalContext.js | 115 ++---------- src/components/item/ItemContent.js | 162 +++++++++++++++++ src/components/item/ItemMain.js | 13 +- src/components/item/ItemMemberships.js | 8 +- src/components/item/ItemMembershipsTable.js | 144 +++++++++++++++ src/components/item/ItemPanel.js | 2 - src/components/item/header/ItemHeader.js | 2 +- .../item/header/ItemHeaderActions.js | 100 +++++++---- src/components/item/header/ModeButton.js | 4 +- .../item/settings/CreateItemMembershipForm.js | 109 ++++++++++++ .../item/settings/ItemLoginSetting.js | 13 +- .../item/settings/ItemMembershipSelect.js | 68 +++++++ src/components/item/settings/ItemSettings.js | 42 ++--- .../item/settings/ItemSettingsButton.js | 50 ++++++ src/components/main/ItemScreen.js | 167 ++---------------- src/components/main/Items.js | 4 +- src/components/main/Main.js | 14 +- src/config/constants.js | 6 + src/config/messages.js | 8 + src/config/selectors.js | 14 +- src/langs/en.json | 3 +- src/langs/fr.json | 3 +- src/middlewares/notifier.js | 22 +++ yarn.lock | 4 +- 42 files changed, 1073 insertions(+), 533 deletions(-) delete mode 100644 cypress/integration/memberships/create/gridShareItem.spec.js delete mode 100644 cypress/integration/memberships/create/listShareItem.spec.js create mode 100644 cypress/integration/memberships/createItemMembership.spec.js create mode 100644 cypress/integration/memberships/deleteItemMembership.spec.js create mode 100644 cypress/integration/memberships/editItemMembership.spec.js delete mode 100644 cypress/integration/memberships/view/viewMemberships.spec.js create mode 100644 cypress/integration/memberships/viewMemberships.spec.js delete mode 100644 src/components/context/ItemLayoutModeContext.js create mode 100644 src/components/context/LayoutContext.js create mode 100644 src/components/item/ItemContent.js create mode 100644 src/components/item/ItemMembershipsTable.js create mode 100644 src/components/item/settings/CreateItemMembershipForm.js create mode 100644 src/components/item/settings/ItemMembershipSelect.js create mode 100644 src/components/item/settings/ItemSettingsButton.js diff --git a/cypress/fixtures/items.js b/cypress/fixtures/items.js index f66fb86ba..82d6a49a1 100644 --- a/cypress/fixtures/items.js +++ b/cypress/fixtures/items.js @@ -32,6 +32,13 @@ export const SAMPLE_ITEMS = { extra: { image: 'someimageurl', }, + memberships: [ + { + itemPath: 'fdf09f5a_5688_11eb_ae93_0242ac130002', + permission: PERMISSION_LEVELS.ADMIN, + memberId: MEMBERS.ANNA.id, + }, + ], }, { ...DEFAULT_FOLDER_ITEM, @@ -41,6 +48,13 @@ export const SAMPLE_ITEMS = { extra: { image: 'someimageurl', }, + memberships: [ + { + itemPath: 'fdf09f5a-5688-11eb-ae93-0242ac130002', + permission: PERMISSION_LEVELS.ADMIN, + memberId: MEMBERS.ANNA.id, + }, + ], }, { ...DEFAULT_FOLDER_ITEM, @@ -51,6 +65,13 @@ export const SAMPLE_ITEMS = { extra: { image: 'someimageurl', }, + memberships: [ + { + itemPath: 'fdf09f5a-5688-11eb-ae93-0242ac130003', + permission: PERMISSION_LEVELS.ADMIN, + memberId: MEMBERS.ANNA.id, + }, + ], }, { ...DEFAULT_FOLDER_ITEM, @@ -61,6 +82,13 @@ export const SAMPLE_ITEMS = { extra: { image: 'someimageurl', }, + memberships: [ + { + itemPath: 'fdf09f5a-5688-11eb-ae93-0242ac130004', + permission: PERMISSION_LEVELS.ADMIN, + memberId: MEMBERS.ANNA.id, + }, + ], }, ], memberships: [], diff --git a/cypress/fixtures/memberships.js b/cypress/fixtures/memberships.js index 0c9c2de3d..762e759d3 100644 --- a/cypress/fixtures/memberships.js +++ b/cypress/fixtures/memberships.js @@ -15,21 +15,25 @@ export const ITEMS_WITH_MEMBERSHIPS = { }, memberships: [ { + id: 'ecafbd2a-5688-11eb-be93-0242ac130002', itemPath: 'fdf09f5a_5688_11eb_ae93_0242ac130002', permission: PERMISSION_LEVELS.ADMIN, memberId: MEMBERS.ANNA.id, }, { + id: 'ecafbd2a-5688-11eb-be92-0242ac130002', itemPath: 'fdf09f5a_5688_11eb_ae93_0242ac130002', permission: PERMISSION_LEVELS.WRITE, memberId: MEMBERS.BOB.id, }, { + id: 'ecafbd1a-5688-11eb-be93-0242ac130002', itemPath: 'fdf09f5a_5688_11eb_ae93_0242ac130002', permission: PERMISSION_LEVELS.WRITE, memberId: MEMBERS.CEDRIC.id, }, { + id: 'ecbfbd2a-5688-11eb-be93-0242ac130002', itemPath: 'fdf09f5a_5688_11eb_ae93_0242ac130002', permission: PERMISSION_LEVELS.READ, memberId: MEMBERS.DAVID.id, diff --git a/cypress/integration/item/itemLogin/itemLogin.spec.js b/cypress/integration/item/itemLogin/itemLogin.spec.js index 8cdf4aaa8..85347adf3 100644 --- a/cypress/integration/item/itemLogin/itemLogin.spec.js +++ b/cypress/integration/item/itemLogin/itemLogin.spec.js @@ -16,6 +16,7 @@ import { ITEM_LOGIN_SIGN_IN_MODE_ID, ITEM_LOGIN_SIGN_IN_PASSWORD_ID, ITEM_LOGIN_SIGN_IN_USERNAME_ID, + ITEM_SETTINGS_BUTTON_CLASS, } from '../../../../src/config/selectors'; import { getItemLoginExtra } from '../../../../src/utils/itemExtra'; import { ITEM_LOGIN_ITEMS } from '../../../fixtures/items'; @@ -166,12 +167,14 @@ describe('Item Login', () => { }); }); - describe('Display Item Login Setting', () => { + describe.only('Display Item Login Setting', () => { it('edit item login setting', () => { cy.setUpApi(ITEM_LOGIN_ITEMS); // check item with item login enabled cy.visit(buildItemPath(ITEM_LOGIN_ITEMS.items[0].id)); + cy.get(`.${ITEM_SETTINGS_BUTTON_CLASS}`).click(); + checkItemLoginSetting({ isEnabled: true, mode: SETTINGS.ITEM_LOGIN.OPTIONS.USERNAME, @@ -179,6 +182,7 @@ describe('Item Login', () => { // allow item login cy.visit(buildItemPath(ITEM_LOGIN_ITEMS.items[1].id)); + cy.get(`.${ITEM_SETTINGS_BUTTON_CLASS}`).click(); checkItemLoginSetting({ isEnabled: false, mode: SETTINGS.ITEM_LOGIN.OPTIONS.USERNAME, @@ -190,6 +194,7 @@ describe('Item Login', () => { // disabled at child level cy.visit(buildItemPath(ITEM_LOGIN_ITEMS.items[5].id)); + cy.get(`.${ITEM_SETTINGS_BUTTON_CLASS}`).click(); checkItemLoginSetting({ isEnabled: true, mode: SETTINGS.ITEM_LOGIN.OPTIONS.USERNAME_AND_PASSWORD, diff --git a/cypress/integration/item/view/utils.js b/cypress/integration/item/view/utils.js index 49239bab9..b629e4239 100644 --- a/cypress/integration/item/view/utils.js +++ b/cypress/integration/item/view/utils.js @@ -2,6 +2,8 @@ import { buildFileItemId, buildS3FileItemId, DOCUMENT_ITEM_TEXT_EDITOR_SELECTOR, + ITEM_INFORMATION_BUTTON_ID, + ITEM_INFORMATION_ICON_IS_OPEN_CLASS, ITEM_PANEL_ID, ITEM_PANEL_NAME_ID, ITEM_PANEL_TABLE_ID, @@ -18,6 +20,12 @@ import { getMemberById } from '../../../../src/utils/member'; import { MEMBERS } from '../../../fixtures/members'; const expectPanelLayout = ({ name, extra, creator, mimetype }) => { + cy.get(`#${ITEM_PANEL_ID}`).then(($itemPanel) => { + if (!$itemPanel.hasClass(ITEM_INFORMATION_ICON_IS_OPEN_CLASS)) { + cy.get(`#${ITEM_INFORMATION_BUTTON_ID}`).click(); + } + }); + const panel = cy.get(`#${ITEM_PANEL_ID}`); panel.get(`#${ITEM_PANEL_NAME_ID}`).contains(name); @@ -97,7 +105,7 @@ export const expectLinkViewScreenLayout = ({ } // table - expectPanelLayout({ name, extra, creator}); + expectPanelLayout({ name, extra, creator }); }; export const expectFolderViewScreenLayout = ({ name, creator }) => { diff --git a/cypress/integration/memberships/create/gridShareItem.spec.js b/cypress/integration/memberships/create/gridShareItem.spec.js deleted file mode 100644 index d3bed6776..000000000 --- a/cypress/integration/memberships/create/gridShareItem.spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import { ITEM_LAYOUT_MODES, PERMISSION_LEVELS } from '../../../../src/enums'; -import { buildItemPath, HOME_PATH } from '../../../../src/config/paths'; -import { - buildItemCard, - SHARE_ITEM_BUTTON_CLASS, -} from '../../../../src/config/selectors'; -import { SAMPLE_ITEMS } from '../../../fixtures/items'; -import { MEMBERS } from '../../../fixtures/members'; - -const shareItem = ({ id, member, permission }) => { - cy.get(`#${buildItemCard(id)} .${SHARE_ITEM_BUTTON_CLASS}`).click(); - - cy.fillShareModal({ member, permission }); -}; - -describe('Create Membership in Grid', () => { - it('share item on Home', () => { - cy.setUpApi({ ...SAMPLE_ITEMS, members: Object.values(MEMBERS) }); - cy.visit(HOME_PATH); - cy.switchMode(ITEM_LAYOUT_MODES.GRID); - - // share - const { id } = SAMPLE_ITEMS.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({ ...SAMPLE_ITEMS, members: Object.values(MEMBERS) }); - - // go to children item - cy.visit(buildItemPath(SAMPLE_ITEMS.items[0].id)); - cy.switchMode(ITEM_LAYOUT_MODES.GRID); - - // share - const { id } = SAMPLE_ITEMS.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/integration/memberships/create/listShareItem.spec.js b/cypress/integration/memberships/create/listShareItem.spec.js deleted file mode 100644 index 142c47850..000000000 --- a/cypress/integration/memberships/create/listShareItem.spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { ITEM_LAYOUT_MODES, PERMISSION_LEVELS } from '../../../../src/enums'; -import { DEFAULT_ITEM_LAYOUT_MODE } from '../../../../src/config/constants'; -import { buildItemPath, HOME_PATH } from '../../../../src/config/paths'; -import { - buildItemsTableRowId, - SHARE_ITEM_BUTTON_CLASS, -} from '../../../../src/config/selectors'; -import { SAMPLE_ITEMS } from '../../../fixtures/items'; -import { MEMBERS } from '../../../fixtures/members'; - -const shareItem = ({ id, member, permission }) => { - cy.get(`#${buildItemsTableRowId(id)} .${SHARE_ITEM_BUTTON_CLASS}`).click(); - - cy.fillShareModal({ member, permission }); -}; - -describe('Create Membership in List', () => { - it('share item on Home', () => { - cy.setUpApi({ ...SAMPLE_ITEMS, members: Object.values(MEMBERS) }); - cy.visit(HOME_PATH); - - if (DEFAULT_ITEM_LAYOUT_MODE !== ITEM_LAYOUT_MODES.LIST) { - cy.switchMode(ITEM_LAYOUT_MODES.LIST); - } - - // share - const { id } = SAMPLE_ITEMS.items[0]; - const member = MEMBERS.ANNA; - shareItem({ id, member, permission: PERMISSION_LEVELS.WRITE }); - - cy.wait('@shareItem').then(() => { - cy.get(`#${buildItemsTableRowId(id)}`).should('exist'); - }); - }); - - it('share item in item', () => { - cy.setUpApi({ ...SAMPLE_ITEMS, members: Object.values(MEMBERS) }); - - // go to children item - cy.visit(buildItemPath(SAMPLE_ITEMS.items[0].id)); - - if (DEFAULT_ITEM_LAYOUT_MODE !== ITEM_LAYOUT_MODES.LIST) { - cy.switchMode(ITEM_LAYOUT_MODES.LIST); - } - - // share - const { id } = SAMPLE_ITEMS.items[2]; - const member = MEMBERS.ANNA; - shareItem({ id, member, permission: PERMISSION_LEVELS.READ }); - - cy.wait('@shareItem').then(() => { - cy.get(`#${buildItemsTableRowId(id)}`).should('exist'); - }); - }); - - // todo : check item permission for users -}); diff --git a/cypress/integration/memberships/createItemMembership.spec.js b/cypress/integration/memberships/createItemMembership.spec.js new file mode 100644 index 000000000..f94a08c3a --- /dev/null +++ b/cypress/integration/memberships/createItemMembership.spec.js @@ -0,0 +1,32 @@ +import { PERMISSION_LEVELS } from '../../../src/enums'; +import { buildItemPath } from '../../../src/config/paths'; +import { ITEM_SETTINGS_BUTTON_CLASS } from '../../../src/config/selectors'; +import { SAMPLE_ITEMS } from '../../fixtures/items'; +import { MEMBERS } from '../../fixtures/members'; + +const shareItem = ({ member, permission }) => { + cy.get(`.${ITEM_SETTINGS_BUTTON_CLASS}`).click(); + + cy.fillShareForm({ member, permission }); +}; + +describe('Create Membership', () => { + it('share item from settings', () => { + cy.setUpApi({ ...SAMPLE_ITEMS, members: Object.values(MEMBERS) }); + + // go to children item + const { id } = SAMPLE_ITEMS.items[0]; + cy.visit(buildItemPath(id)); + + // share + const member = MEMBERS.ANNA; + const permission = PERMISSION_LEVELS.READ; + shareItem({ id, member, permission }); + + cy.wait('@shareItem').then(({ request: { url, body } }) => { + expect(url).to.contain(id); + expect(body?.permission).to.equal(permission); + expect(body?.memberId).to.equal(member.id); + }); + }); +}); diff --git a/cypress/integration/memberships/deleteItemMembership.spec.js b/cypress/integration/memberships/deleteItemMembership.spec.js new file mode 100644 index 000000000..851769271 --- /dev/null +++ b/cypress/integration/memberships/deleteItemMembership.spec.js @@ -0,0 +1,29 @@ +import { buildItemPath } from '../../../src/config/paths'; +import { + buildItemMembershipRowDeleteButtonId, + ITEM_SETTINGS_BUTTON_CLASS, +} from '../../../src/config/selectors'; +import { ITEMS_WITH_MEMBERSHIPS } from '../../fixtures/memberships'; + +const deleteItemMembership = (id) => { + cy.get(`.${ITEM_SETTINGS_BUTTON_CLASS}`).click(); + cy.get(`#${buildItemMembershipRowDeleteButtonId(id)}`).click(); +}; + +describe('Delete Membership', () => { + it('delete item membership', () => { + cy.setUpApi({ ...ITEMS_WITH_MEMBERSHIPS }); + + // go to children item + const { id, memberships } = ITEMS_WITH_MEMBERSHIPS.items[0]; + cy.visit(buildItemPath(id)); + + // share + const { id: mId } = memberships[1]; + deleteItemMembership(mId); + + cy.wait('@deleteItemMembership').then(({ request: { url } }) => { + expect(url).to.contain(mId); + }); + }); +}); diff --git a/cypress/integration/memberships/editItemMembership.spec.js b/cypress/integration/memberships/editItemMembership.spec.js new file mode 100644 index 000000000..e02a0221c --- /dev/null +++ b/cypress/integration/memberships/editItemMembership.spec.js @@ -0,0 +1,40 @@ +import { PERMISSION_LEVELS } from '../../../src/enums'; +import { buildItemPath } from '../../../src/config/paths'; +import { + buildItemMembershipRowId, + buildPermissionOptionId, + ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS, + ITEM_SETTINGS_BUTTON_CLASS, +} from '../../../src/config/selectors'; +import { ITEMS_WITH_MEMBERSHIPS } from '../../fixtures/memberships'; + +const editItemMembership = ({ id, permission }) => { + cy.get(`.${ITEM_SETTINGS_BUTTON_CLASS}`).click(); + const select = cy.get( + `#${buildItemMembershipRowId( + id, + )} .${ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS}`, + ); + select.click(); + select.get(`#${buildPermissionOptionId(permission)}`).click(); +}; + +describe('Edit Membership', () => { + it('edit item membership', () => { + cy.setUpApi({ ...ITEMS_WITH_MEMBERSHIPS }); + + // go to children item + const { id, memberships } = ITEMS_WITH_MEMBERSHIPS.items[0]; + cy.visit(buildItemPath(id)); + + // update membership + const permission = PERMISSION_LEVELS.READ; + const { id: mId } = memberships[1]; + editItemMembership({ id: mId, permission }); + + cy.wait('@editItemMembership').then(({ request: { url, body } }) => { + expect(url).to.contain(mId); + expect(body?.permission).to.equal(permission); + }); + }); +}); diff --git a/cypress/integration/memberships/view/viewMemberships.spec.js b/cypress/integration/memberships/view/viewMemberships.spec.js deleted file mode 100644 index 2a0e21374..000000000 --- a/cypress/integration/memberships/view/viewMemberships.spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { buildItemPath } from '../../../../src/config/paths'; -import { - buildMemberAvatarClass, - SHARE_ITEM_BUTTON_CLASS, -} from '../../../../src/config/selectors'; -import { membershipsWithoutUser } from '../../../../src/utils/membership'; -import { CURRENT_USER } from '../../../fixtures/members'; -import { ITEMS_WITH_MEMBERSHIPS } from '../../../fixtures/memberships'; - -describe('View Memberships', () => { - beforeEach(() => { - cy.setUpApi({ ...ITEMS_WITH_MEMBERSHIPS }); - }); - - it('view membership in share item modal', () => { - const [item] = ITEMS_WITH_MEMBERSHIPS.items; - const { memberships } = item; - cy.visit(buildItemPath(item.id)); - cy.get(`.${SHARE_ITEM_BUTTON_CLASS}`).click(); - - const filteredMemberships = membershipsWithoutUser( - memberships, - CURRENT_USER.id, - ); - - // panel only contains 2 avatars: one user, one +x - // check contains member avatar - const [first, second] = filteredMemberships; - cy.get(`.${buildMemberAvatarClass(first.memberId)}`).should('exist'); - cy.get(`.${buildMemberAvatarClass(second.memberId)}`).should('exist'); - - // todo: check permission level - }); -}); diff --git a/cypress/integration/memberships/viewMemberships.spec.js b/cypress/integration/memberships/viewMemberships.spec.js new file mode 100644 index 000000000..67f197d9c --- /dev/null +++ b/cypress/integration/memberships/viewMemberships.spec.js @@ -0,0 +1,70 @@ +import { buildItemPath } from '../../../src/config/paths'; +import { + buildItemMembershipRowId, + buildMemberAvatarClass, + ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS, + ITEM_SETTINGS_BUTTON_CLASS, + SHARE_ITEM_BUTTON_CLASS, +} from '../../../src/config/selectors'; +import { membershipsWithoutUser } from '../../../src/utils/membership'; +import { CURRENT_USER, MEMBERS } from '../../fixtures/members'; +import { ITEMS_WITH_MEMBERSHIPS } from '../../fixtures/memberships'; + +describe('View Memberships', () => { + beforeEach(() => { + cy.setUpApi({ ...ITEMS_WITH_MEMBERSHIPS }); + }); + + it('view membership in share item modal', () => { + const [item] = ITEMS_WITH_MEMBERSHIPS.items; + const { memberships } = item; + cy.visit(buildItemPath(item.id)); + cy.get(`.${SHARE_ITEM_BUTTON_CLASS}`).click(); + + const filteredMemberships = membershipsWithoutUser( + memberships, + CURRENT_USER.id, + ); + + // panel only contains 2 avatars: one user, one +x + // check contains member avatar + const [first, second] = filteredMemberships; + cy.get(`.${buildMemberAvatarClass(first.memberId)}`).should('exist'); + cy.get(`.${buildMemberAvatarClass(second.memberId)}`).should('exist'); + + // todo: check permission level + }); + + it('view membership in settings', () => { + const [item] = ITEMS_WITH_MEMBERSHIPS.items; + const { memberships } = item; + cy.visit(buildItemPath(item.id)); + cy.get(`.${ITEM_SETTINGS_BUTTON_CLASS}`).click(); + + const filteredMemberships = membershipsWithoutUser( + memberships, + CURRENT_USER.id, + ); + + // panel only contains 2 avatars: one user, one +x + // check contains member avatar + for (const { permission, memberId, id } of filteredMemberships) { + const { name, email } = Object.values(MEMBERS).find( + ({ id: mId }) => mId === memberId, + ); + // check name and mail + cy.get(`#${buildItemMembershipRowId(id)}`) + .should('contain', name) + .should('contain', email); + + // check permission select + cy.get( + `#${buildItemMembershipRowId( + id, + )} .${ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS} input`, + ).should('have.value', permission); + } + + // todo: check permission level + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 649d82d8c..85b58c500 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -36,6 +36,8 @@ import { mockPutItemLogin, mockEditMember, mockGetSharedItems, + mockEditItemMembershipForItem, + mockDeleteItemMembershipForItem, } from './server'; import './commands/item'; import './commands/navigation'; @@ -128,6 +130,10 @@ Cypress.Commands.add( mockPostItemTag(items, postItemTagError); mockEditMember(members, editMemberError); + + mockEditItemMembershipForItem(items); + + mockDeleteItemMembershipForItem(items); }, ); diff --git a/cypress/support/commands/item.js b/cypress/support/commands/item.js index e7c9f9a8b..348f98cae 100644 --- a/cypress/support/commands/item.js +++ b/cypress/support/commands/item.js @@ -1,9 +1,9 @@ import { ROOT_ID } from '../../../src/config/constants'; import { - SHARE_ITEM_MODAL_PERMISSION_SELECT_ID, - SHARE_ITEM_MODAL_SHARE_BUTTON_ID, + ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS, + SHARE_ITEM_SHARE_BUTTON_ID, buildPermissionOptionId, - SHARE_ITEM_MODAL_EMAIL_INPUT_ID, + SHARE_ITEM_EMAIL_INPUT_ID, buildTreeItemClass, TREE_MODAL_CONFIRM_BUTTON_ID, TREE_MODAL_TREE_ID, @@ -14,6 +14,7 @@ import { ITEM_FORM_DOCUMENT_TEXT_SELECTOR, ITEM_FORM_APP_URL_ID, } from '../../../src/config/selectors'; + import { getAppExtra, getDocumentExtra, @@ -22,15 +23,15 @@ import { import { getParentsIdsFromPath } from '../../../src/utils/item'; import { TREE_VIEW_PAUSE } from '../constants'; -Cypress.Commands.add('fillShareModal', ({ member, permission }) => { +Cypress.Commands.add('fillShareForm', ({ member, permission }) => { // select permission - cy.get(`#${SHARE_ITEM_MODAL_PERMISSION_SELECT_ID}`).click(); + cy.get(`.${ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS}`).click(); cy.get(`#${buildPermissionOptionId(permission)}`).click(); // input mail - cy.get(`#${SHARE_ITEM_MODAL_EMAIL_INPUT_ID}`).type(member.email); + cy.get(`#${SHARE_ITEM_EMAIL_INPUT_ID}`).type(member.email); - cy.get(`#${SHARE_ITEM_MODAL_SHARE_BUTTON_ID}`).click(); + cy.get(`#${SHARE_ITEM_SHARE_BUTTON_ID}`).click('left'); }); Cypress.Commands.add('fillTreeModal', (toItemPath) => { diff --git a/cypress/support/server.js b/cypress/support/server.js index 50047886a..37c38934d 100644 --- a/cypress/support/server.js +++ b/cypress/support/server.js @@ -56,6 +56,8 @@ const { buildPostItemTagRoute, buildPatchMember, SHARE_ITEM_WITH_ROUTE, + buildEditItemMembershipRoute, + buildDeleteItemMembershipRoute, } = API_ROUTES; const API_HOST = Cypress.env('API_HOST'); @@ -600,6 +602,40 @@ export const mockGetItemMembershipsForItem = (items) => { ).as('getItemMemberships'); }; +export const mockEditItemMembershipForItem = (items) => { + cy.intercept( + { + method: DEFAULT_PATCH.method, + url: new RegExp( + `${API_HOST}/${buildEditItemMembershipRoute(ID_FORMAT)}$`, + ), + }, + ({ reply, url }) => { + // eslint-disable-next-line no-console + console.log('wiojefk'); + const mId = url.slice(API_HOST.length).split('/')[2]; + const result = items.find(({ id }) => id === mId)?.memberships || []; + reply(result?.find(({ id }) => id === mId)); + }, + ).as('editItemMembership'); +}; + +export const mockDeleteItemMembershipForItem = (items) => { + cy.intercept( + { + method: DEFAULT_DELETE.method, + url: new RegExp( + `${API_HOST}/${buildDeleteItemMembershipRoute(ID_FORMAT)}$`, + ), + }, + ({ reply, url }) => { + const mId = url.slice(API_HOST.length).split('/')[2]; + const result = items.find(({ id }) => id === mId)?.memberships || []; + reply(result?.find(({ id }) => id === mId)); + }, + ).as('deleteItemMembership'); +}; + export const mockGetItemTags = (items) => { cy.intercept( { diff --git a/package.json b/package.json index 370464138..4e5648d7b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "license": "AGPL-3.0-only", "dependencies": { - "@graasp/query-client": "git://github.com/graasp/graasp-query-client.git", + "@graasp/query-client": "git://github.com/graasp/graasp-query-client.git#12/patchDeleteMembership", "@graasp/ui": "git://github.com/graasp/graasp-ui.git#master", "@material-ui/core": "4.11.2", "@material-ui/icons": "4.11.2", diff --git a/src/components/context/ItemLayoutModeContext.js b/src/components/context/ItemLayoutModeContext.js deleted file mode 100644 index f6dc6c01b..000000000 --- a/src/components/context/ItemLayoutModeContext.js +++ /dev/null @@ -1,28 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { DEFAULT_ITEM_LAYOUT_MODE } from '../../config/constants'; - -const ItemLayoutModeContext = React.createContext(); - -const ItemLayoutModeProvider = ({ children }) => { - const [mode, setMode] = useState(DEFAULT_ITEM_LAYOUT_MODE); - const [editingItemId, setEditingItemId] = useState(null); - - return ( - - {children} - - ); -}; - -ItemLayoutModeProvider.propTypes = { - children: PropTypes.node, -}; - -ItemLayoutModeProvider.defaultProps = { - children: null, -}; - -export { ItemLayoutModeProvider, ItemLayoutModeContext }; diff --git a/src/components/context/LayoutContext.js b/src/components/context/LayoutContext.js new file mode 100644 index 000000000..6e54053f8 --- /dev/null +++ b/src/components/context/LayoutContext.js @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { DEFAULT_ITEM_LAYOUT_MODE } from '../../config/constants'; + +const LayoutContext = React.createContext(); + +const LayoutContextProvider = ({ children }) => { + // layout mode: grid or list + const [mode, setMode] = useState(DEFAULT_ITEM_LAYOUT_MODE); + + // item screen editing id + // todo: separate in item specific context + const [editingItemId, setEditingItemId] = useState(null); + + // item settings page open + // todo: separate in item specific context + const [isItemSettingsOpen, setIsItemSettingsOpen] = useState(false); + + const [isMainmenuOpen, setIsMainmenuOpen] = useState(true); + + // open item panel by default if width allows it + const isItemPanelOpen = window.innerWidth > 1000; + const [isItemMetadataMenuOpen, setIsItemMetadataMenuOpen] = useState( + isItemPanelOpen, + ); + + return ( + + {children} + + ); +}; + +LayoutContextProvider.propTypes = { + children: PropTypes.node, +}; + +LayoutContextProvider.defaultProps = { + children: null, +}; + +export { LayoutContext, LayoutContextProvider }; diff --git a/src/components/context/ModalProviders.js b/src/components/context/ModalProviders.js index 829e8794f..5f1cad650 100644 --- a/src/components/context/ModalProviders.js +++ b/src/components/context/ModalProviders.js @@ -4,21 +4,23 @@ import { EditItemModalProvider } from './EditItemModalContext'; import { CopyItemModalProvider } from './CopyItemModalContext'; import { MoveItemModalProvider } from './MoveItemModalContext'; import { ShareItemModalProvider } from './ShareItemModalContext'; -import { ItemLayoutModeProvider } from './ItemLayoutModeContext'; +import { LayoutContextProvider } from './LayoutContext'; import { CreateShortcutModalProvider } from './CreateShortcutModalContext'; const ModalProviders = ({ children }) => ( - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + ); ModalProviders.propTypes = { diff --git a/src/components/context/ShareItemModalContext.js b/src/components/context/ShareItemModalContext.js index 6a9aa750d..d77bc85b9 100644 --- a/src/components/context/ShareItemModalContext.js +++ b/src/components/context/ShareItemModalContext.js @@ -1,35 +1,15 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Button from '@material-ui/core/Button'; import Dialog from '@material-ui/core/Dialog'; -import validator from 'validator'; import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import DialogTitle from '@material-ui/core/DialogTitle'; -import { - FormControl, - Grid, - InputLabel, - makeStyles, - MenuItem, - Select, - TextField, -} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core'; import PropTypes from 'prop-types'; -import { MUTATION_KEYS } from '@graasp/query-client'; -import { useMutation } from '../../config/queryClient'; -import { - DEFAULT_PERMISSION_LEVEL, - SHARE_ITEM_MODAL_MIN_WIDTH, -} from '../../config/constants'; -import { PERMISSION_LEVELS } from '../../enums'; -import { - buildPermissionOptionId, - SHARE_ITEM_MODAL_EMAIL_INPUT_ID, - SHARE_ITEM_MODAL_PERMISSION_SELECT_ID, - SHARE_ITEM_MODAL_SHARE_BUTTON_ID, -} from '../../config/selectors'; import ItemMemberships from '../item/ItemMemberships'; +import { SHARE_ITEM_MODAL_MIN_WIDTH } from '../../config/constants'; +import { LayoutContext } from './LayoutContext'; const ShareItemModalContext = React.createContext(); @@ -57,15 +37,10 @@ const useStyles = makeStyles((theme) => ({ const ShareItemModalProvider = ({ children }) => { const { t } = useTranslation(); const classes = useStyles(); - const mutation = useMutation(MUTATION_KEYS.SHARE_ITEM); - - // refs - let email = ''; - let permission = ''; + const { setIsItemSettingsOpen } = useContext(LayoutContext); const [open, setOpen] = useState(false); const [itemId, setItemId] = useState(null); - const [isErrorMail, setIsErrorMail] = useState(false); const openModal = (newItemId) => { setOpen(true); @@ -77,88 +52,28 @@ const ShareItemModalProvider = ({ children }) => { setItemId(null); }; - const checkSubmission = () => { - // check mail validity - const mailIsValid = validator.isEmail(email.value); - setIsErrorMail(!mailIsValid); - return mailIsValid; - }; - - const submit = () => { - if (checkSubmission()) { - mutation.mutate({ - id: itemId, - email: email.value, - permission: permission.value, - }); - onClose(); - } + const onClickMemberships = () => { + onClose(); + setIsItemSettingsOpen(true); }; - const labelId = 'permission-label'; - const renderPermissionSelect = () => ( - - {t('Permission')} - - - ); - return ( {t('Share Item')} - - - { - email = c; - }} - label={t('Email')} - error={isErrorMail} - helperText={isErrorMail && t('The provided email is invalid.')} - /> - - - {renderPermissionSelect()} - - - - - + {/* todo: display perform view link */} + {itemId} + - {children} diff --git a/src/components/item/ItemContent.js b/src/components/item/ItemContent.js new file mode 100644 index 000000000..b9876cc9d --- /dev/null +++ b/src/components/item/ItemContent.js @@ -0,0 +1,162 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; +import { makeStyles } from '@material-ui/core'; +import { + FileItem, + S3FileItem, + DocumentItem, + LinkItem, + AppItem, +} from '@graasp/ui'; +import { MUTATION_KEYS } from '@graasp/query-client'; +import { hooks, useMutation } from '../../config/queryClient'; +import { + buildFileItemId, + buildS3FileItemId, + buildSaveButtonId, + DOCUMENT_ITEM_TEXT_EDITOR_ID, + ITEM_SCREEN_ERROR_ALERT_ID, +} from '../../config/selectors'; +import { ITEM_KEYS, ITEM_TYPES } from '../../enums'; +import Loader from '../common/Loader'; +import ErrorAlert from '../common/ErrorAlert'; +import { API_HOST } from '../../config/constants'; +import { LayoutContext } from '../context/LayoutContext'; +import FileUploader from '../main/FileUploader'; +import Items from '../main/Items'; + +const { + useChildren, + useFileContent, + useS3FileContent, + useCurrentMember, +} = hooks; + +const useStyles = makeStyles(() => ({ + fileWrapper: { + textAlign: 'center', + height: '80vh', + flexGrow: 1, + }, +})); + +const ItemContent = ({ item }) => { + const classes = useStyles(); + const itemId = item.get(ITEM_KEYS.ID); + const itemType = item?.get(ITEM_KEYS.TYPE); + const { mutate: editItem } = useMutation(MUTATION_KEYS.EDIT_ITEM); + const { editingItemId, setEditingItemId } = useContext(LayoutContext); + + // provide user to app + const { data: user, isLoading: isLoadingUser } = useCurrentMember(); + + // display children + const { data: children, isLoading: isLoadingChildren } = useChildren(itemId); + const id = item?.get(ITEM_KEYS.ID); + + const { data: content, isLoading: isLoadingFileContent } = useFileContent( + id, + { + enabled: item && itemType === ITEM_TYPES.FILE, + }, + ); + const { + data: s3Content, + isLoading: isLoadingS3FileContent, + } = useS3FileContent(id, { + enabled: itemType === ITEM_TYPES.S3_FILE, + }); + const isEditing = editingItemId === itemId; + + if ( + isLoadingFileContent || + isLoadingS3FileContent || + isLoadingUser || + isLoadingChildren + ) { + return ; + } + + if (!item || !itemId) { + return ; + } + + const onSaveCaption = (caption) => { + // edit item only when description has changed + if (caption !== item.get('description')) { + editItem({ id: itemId, description: caption }); + } + setEditingItemId(null); + }; + + const saveButtonId = buildSaveButtonId(itemId); + + switch (itemType) { + case ITEM_TYPES.FILE: + return ( +
+ +
+ ); + case ITEM_TYPES.S3_FILE: + return ( +
+ +
+ ); + case ITEM_TYPES.LINK: + return ( +
+ +
+ ); + case ITEM_TYPES.DOCUMENT: + return ; + case ITEM_TYPES.APP: + return ( + + ); + case ITEM_TYPES.FOLDER: + return ( + <> + + + + ); + + default: + return ; + } +}; + +ItemContent.propTypes = { + item: PropTypes.instanceOf(Map).isRequired, +}; + +export default ItemContent; diff --git a/src/components/item/ItemMain.js b/src/components/item/ItemMain.js index 520e26a3a..326934ce7 100644 --- a/src/components/item/ItemMain.js +++ b/src/components/item/ItemMain.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useContext } from 'react'; import { Map } from 'immutable'; import { makeStyles } from '@material-ui/core/styles'; import clsx from 'clsx'; @@ -7,6 +7,7 @@ import { RIGHT_MENU_WIDTH } from '../../config/constants'; import ItemHeader from './header/ItemHeader'; import ItemPanel from './ItemPanel'; import { ITEM_MAIN_CLASS } from '../../config/selectors'; +import { LayoutContext } from '../context/LayoutContext'; const useStyles = makeStyles((theme) => ({ root: {}, @@ -48,18 +49,20 @@ const useStyles = makeStyles((theme) => ({ const ItemMain = ({ id, children, item }) => { const classes = useStyles(); - const [metadataMenuOpen, setMetadataMenuOpen] = useState(true); + const { isItemMetadataMenuOpen, setIsItemMetadataMenuOpen } = useContext( + LayoutContext, + ); const handleToggleMetadataMenu = () => { - setMetadataMenuOpen(!metadataMenuOpen); + setIsItemMetadataMenuOpen(!isItemMetadataMenuOpen); }; return (
- +
diff --git a/src/components/item/ItemMemberships.js b/src/components/item/ItemMemberships.js index 671ab752c..8ca577cf8 100644 --- a/src/components/item/ItemMemberships.js +++ b/src/components/item/ItemMemberships.js @@ -15,7 +15,9 @@ import { PERMISSION_LEVELS } from '../../enums'; import { ITEM_MEMBERSHIPS_CONTENT_ID } from '../../config/selectors'; import { membershipsWithoutUser } from '../../utils/membership'; -const ItemMemberships = ({ id, maxAvatar }) => { +const ItemMemberships = ({ id, maxAvatar, onClick }) => { + // eslint-disable-next-line no-console + console.log('id: ', id); const { t } = useTranslation(); const { data: memberships, isLoading, isError } = hooks.useItemMemberships( id, @@ -64,7 +66,7 @@ const ItemMemberships = ({ id, maxAvatar }) => { )} aria-label="shared users" > - + {filteredMemberships.map(({ memberId, permission }) => { const badgeContent = permission === PERMISSION_LEVELS.READ ? ( @@ -97,11 +99,13 @@ const ItemMemberships = ({ id, maxAvatar }) => { ItemMemberships.propTypes = { maxAvatar: PropTypes.number, id: PropTypes.string, + onClick: PropTypes.func, }; ItemMemberships.defaultProps = { maxAvatar: 2, id: null, + onClick: null, }; export default ItemMemberships; diff --git a/src/components/item/ItemMembershipsTable.js b/src/components/item/ItemMembershipsTable.js new file mode 100644 index 000000000..e99043db5 --- /dev/null +++ b/src/components/item/ItemMembershipsTable.js @@ -0,0 +1,144 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import CloseIcon from '@material-ui/icons/Close'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import TableContainer from '@material-ui/core/TableContainer'; +import { useTranslation } from 'react-i18next'; +import TableRow from '@material-ui/core/TableRow'; +import { MUTATION_KEYS } from '@graasp/query-client'; +import IconButton from '@material-ui/core/IconButton'; +import { Loader } from '@graasp/ui'; +import { hooks, useMutation } from '../../config/queryClient'; +import { PERMISSION_LEVELS } from '../../enums'; +import ItemMembershipSelect from './settings/ItemMembershipSelect'; +import CreateItemMembershipForm from './settings/CreateItemMembershipForm'; +import { membershipsWithoutUser } from '../../utils/membership'; +import { + buildItemMembershipRowDeleteButtonId, + buildItemMembershipRowId, +} from '../../config/selectors'; + +const ItemMembershipRow = ({ membership, itemId }) => { + const { data: user, isLoading } = hooks.useMember(membership.memberId); + const { mutate: deleteItemMembership } = useMutation( + MUTATION_KEYS.DELETE_ITEM_MEMBERSHIP, + ); + const { mutate: editItemMembership } = useMutation( + MUTATION_KEYS.EDIT_ITEM_MEMBERSHIP, + ); + + if (isLoading) { + return ; + } + + const deleteMembership = () => { + deleteItemMembership({ itemId, id: membership.id }); + }; + + const onChangePermission = (e) => { + const { value } = e.target; + editItemMembership({ itemId, id: membership.id, permission: value }); + }; + + return ( + + + {user.get('name')} + + + {user.get('email')} + + + + + + + + + + + ); +}; + +ItemMembershipRow.propTypes = { + membership: PropTypes.shape({ + id: PropTypes.string.isRequired, + permission: PropTypes.oneOf(Object.values(PERMISSION_LEVELS)).isRequired, + memberId: PropTypes.string.isRequired, + }).isRequired, + itemId: PropTypes.string.isRequired, +}; + +const useStyles = makeStyles((theme) => ({ + emptyText: { + margin: theme.spacing(2, 0), + }, +})); + +// eslint-disable-next-line no-unused-vars +const ItemMembershipsTable = ({ id }) => { + const classes = useStyles(); + const { + data: memberships, + isLoading: isMembershipsLoading, + } = hooks.useItemMemberships(id); + const { + data: currentMember, + isLoadingCurrentMember, + } = hooks.useCurrentMember(); + const { t } = useTranslation(); + + if (isMembershipsLoading || isLoadingCurrentMember) { + return ; + } + + const membershipWithoutSelf = membershipsWithoutUser( + memberships, + currentMember.get('id'), + ); + + const content = membershipWithoutSelf.size ? ( + membershipWithoutSelf.map((row) => ( + + )) + ) : ( + + {t('No user has access to this item.')} + + ); + + return ( + <> + {t('Manage Access')} + + + + {content} +
+
+ + ); +}; + +ItemMembershipsTable.propTypes = { + id: PropTypes.string.isRequired, +}; + +export default ItemMembershipsTable; diff --git a/src/components/item/ItemPanel.js b/src/components/item/ItemPanel.js index a85b9de07..009a6ab42 100644 --- a/src/components/item/ItemPanel.js +++ b/src/components/item/ItemPanel.js @@ -20,7 +20,6 @@ import { ITEM_PANEL_TABLE_ID, } from '../../config/selectors'; import { getFileExtra, getS3FileExtra } from '../../utils/itemExtra'; -import ItemSettings from './settings/ItemSettings'; import { hooks } from '../../config/queryClient'; const { useMember } = hooks; @@ -125,7 +124,6 @@ const ItemPanel = ({ item, open }) => { - ); }; diff --git a/src/components/item/header/ItemHeader.js b/src/components/item/header/ItemHeader.js index 441e114d4..b2372e22e 100644 --- a/src/components/item/header/ItemHeader.js +++ b/src/components/item/header/ItemHeader.js @@ -36,7 +36,7 @@ const ItemHeader = ({ onClick }) => { return (
- {item && } +
); }; diff --git a/src/components/item/header/ItemHeaderActions.js b/src/components/item/header/ItemHeaderActions.js index eed701750..4c55534c9 100644 --- a/src/components/item/header/ItemHeaderActions.js +++ b/src/components/item/header/ItemHeaderActions.js @@ -1,19 +1,22 @@ import React, { useContext } from 'react'; import IconButton from '@material-ui/core/IconButton'; import PropTypes from 'prop-types'; +import clsx from 'clsx'; import EditIcon from '@material-ui/icons/Edit'; import { Map } from 'immutable'; -import SettingsIcon from '@material-ui/icons/Settings'; -import { useHistory } from 'react-router'; import InfoIcon from '@material-ui/icons/Info'; import { makeStyles } from '@material-ui/core/styles'; import ModeButton from './ModeButton'; import { ITEM_TYPES } from '../../../enums'; -import { ItemLayoutModeContext } from '../../context/ItemLayoutModeContext'; -import { VIEW_ITEM_EDIT_ITEM_BUTTON_ID } from '../../../config/selectors'; +import { LayoutContext } from '../../context/LayoutContext'; +import { + ITEM_INFORMATION_BUTTON_ID, + ITEM_INFORMATION_ICON_IS_OPEN_CLASS, + VIEW_ITEM_EDIT_ITEM_BUTTON_ID, +} from '../../../config/selectors'; import ShareButton from '../../common/ShareButton'; import { ITEM_TYPES_WITH_CAPTIONS } from '../../../config/constants'; -import { buildItemSettingsPath } from '../../../config/paths'; +import ItemSettingsButton from '../settings/ItemSettingsButton'; const useStyles = makeStyles((theme) => ({ root: { @@ -28,44 +31,71 @@ const useStyles = makeStyles((theme) => ({ })); const ItemHeaderActions = ({ onClick, item }) => { const classes = useStyles(); - const { push } = useHistory(); - const type = item?.get('type'); - const isFile = type && type !== ITEM_TYPES.FOLDER; + const { + setEditingItemId, + editingItemId, + isItemSettingsOpen, + isItemMetadataMenuOpen, + } = useContext(LayoutContext); const id = item?.get('id'); - const { setEditingItemId, editingItemId } = useContext(ItemLayoutModeContext); - const hasCaption = ITEM_TYPES_WITH_CAPTIONS.includes(type); + const type = item?.get('type'); - /* todo: factor edit button */ - const actions = isFile && !editingItemId && hasCaption && ( - { - setEditingItemId(id); - }} - id={VIEW_ITEM_EDIT_ITEM_BUTTON_ID} - > - - - ); + const renderItemActions = () => { + // if id is defined, we are looking at an item + if (id) { + // show edition only for allowed types + const showEditButton = + !editingItemId && ITEM_TYPES_WITH_CAPTIONS.includes(type); + + const activeActions = ( + <> + {showEditButton && ( + { + setEditingItemId(id); + }} + id={VIEW_ITEM_EDIT_ITEM_BUTTON_ID} + > + + + )} + + + ); - const onClickSettings = () => { - push(buildItemSettingsPath(id)); + return ( + <> + {!isItemSettingsOpen && activeActions} + + + ); + } + return null; + }; + + const renderTableActions = () => { + // show only for content with tables : root or folders + if (type === ITEM_TYPES.FOLDER || !id) { + return ; + } + return null; }; return (
- {actions} - {!isFile && } + {renderItemActions()} + {renderTableActions()} {id && ( - <> - - - - - - - - + + + )}
); diff --git a/src/components/item/header/ModeButton.js b/src/components/item/header/ModeButton.js index b3c8c1bba..f7b306cb2 100644 --- a/src/components/item/header/ModeButton.js +++ b/src/components/item/header/ModeButton.js @@ -9,11 +9,11 @@ import { MODE_GRID_BUTTON_ID, MODE_LIST_BUTTON_ID, } from '../../../config/selectors'; -import { ItemLayoutModeContext } from '../../context/ItemLayoutModeContext'; +import { LayoutContext } from '../../context/LayoutContext'; const ModeButton = () => { const { t } = useTranslation(); - const { mode, setMode } = useContext(ItemLayoutModeContext); + const { mode, setMode } = useContext(LayoutContext); const handleOnClick = (value) => { setMode(value); diff --git a/src/components/item/settings/CreateItemMembershipForm.js b/src/components/item/settings/CreateItemMembershipForm.js new file mode 100644 index 000000000..ccaee746a --- /dev/null +++ b/src/components/item/settings/CreateItemMembershipForm.js @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Grid, makeStyles, TextField } from '@material-ui/core'; +import { MUTATION_KEYS } from '@graasp/query-client'; +import { useTranslation } from 'react-i18next'; +import validator from 'validator'; +import { useMutation } from '../../../config/queryClient'; +import { + SHARE_ITEM_EMAIL_INPUT_ID, + SHARE_ITEM_SHARE_BUTTON_ID, +} from '../../../config/selectors'; +import ItemMembershipSelect from './ItemMembershipSelect'; + +const useStyles = makeStyles((theme) => ({ + formControl: { + margin: theme.spacing(1), + }, + dialogContent: { + display: 'flex', + flexDirection: 'column', + }, + shortInputField: { + width: '50%', + }, + addedMargin: { + marginTop: theme.spacing(2), + }, + emailInput: { + width: '100%', + marginTop: theme.spacing(1), + }, +})); + +const CreateItemMembershipForm = ({ id }) => { + const [mailError, setMailError] = useState(false); + const mutation = useMutation(MUTATION_KEYS.SHARE_ITEM); + const { t } = useTranslation(); + const classes = useStyles(); + + // refs + let email = ''; + let permission = ''; + + const checkSubmission = () => { + // check mail validity + if (!validator.isEmail(email.value)) { + setMailError(t('This mail is not valid')); + return false; + } + + // todo: check mail does not already exist + // but this is difficult to check as membership contains memberId != email + + setMailError(null); + return true; + }; + + const submit = () => { + if (checkSubmission()) { + mutation.mutate({ + id, + email: email.value, + permission: permission.value, + }); + email = ''; + } + }; + + return ( + + + { + email = c; + }} + label={t('Email')} + error={Boolean(mailError)} + helperText={mailError} + /> + + + { + permission = c; + }} + /> + + + + + + ); +}; + +CreateItemMembershipForm.propTypes = { + id: PropTypes.string.isRequired, +}; + +export default CreateItemMembershipForm; diff --git a/src/components/item/settings/ItemLoginSetting.js b/src/components/item/settings/ItemLoginSetting.js index 20389c52e..db526ab3a 100644 --- a/src/components/item/settings/ItemLoginSetting.js +++ b/src/components/item/settings/ItemLoginSetting.js @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { Map } from 'immutable'; +import PropTypes from 'prop-types'; import Select from '@material-ui/core/Select'; import Switch from '@material-ui/core/Switch'; import { useParams } from 'react-router'; @@ -25,9 +27,9 @@ import { getItemLoginTag } from '../../../utils/itemTag'; const { DELETE_ITEM_TAG, POST_ITEM_TAG, PUT_ITEM_LOGIN } = MUTATION_KEYS; -const { useTags, useItem, useItemTags, useCurrentMember } = hooks; +const { useTags, useItemTags, useCurrentMember } = hooks; -const ItemLoginSwitch = () => { +const ItemLoginSwitch = ({ item }) => { const { t } = useTranslation(); // user @@ -35,7 +37,6 @@ const ItemLoginSwitch = () => { // current item const { itemId } = useParams(); - const { data: item, isLoading: isItemLoading } = useItem(itemId); // mutations const { mutate: putItemLoginSchema } = useMutation(PUT_ITEM_LOGIN); @@ -63,7 +64,7 @@ const ItemLoginSwitch = () => { ); }, [tags, itemTags, item]); - if (isItemLoading || isTagsLoading || isItemTagsLoading || isMemberLoading) { + if (isTagsLoading || isItemTagsLoading || isMemberLoading) { return ; } @@ -132,4 +133,8 @@ const ItemLoginSwitch = () => { ); }; +ItemLoginSwitch.propTypes = { + item: PropTypes.instanceOf(Map).isRequired, +}; + export default ItemLoginSwitch; diff --git a/src/components/item/settings/ItemMembershipSelect.js b/src/components/item/settings/ItemMembershipSelect.js new file mode 100644 index 000000000..38dff8da2 --- /dev/null +++ b/src/components/item/settings/ItemMembershipSelect.js @@ -0,0 +1,68 @@ +import React from 'react'; +import MenuItem from '@material-ui/core/MenuItem'; +import PropTypes from 'prop-types'; +import Select from '@material-ui/core/Select'; +import { useTranslation } from 'react-i18next'; +import { FormControl, InputLabel, makeStyles } from '@material-ui/core'; +import { DEFAULT_PERMISSION_LEVEL } from '../../../config/constants'; +import { PERMISSION_LEVELS } from '../../../enums'; +import { + buildPermissionOptionId, + ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS, +} from '../../../config/selectors'; + +const useStyles = makeStyles((theme) => ({ + formControl: { + margin: theme.spacing(1), + }, +})); + +const ItemMembershipSelect = ({ value, inputRef, showLabel, onChange }) => { + const { t } = useTranslation(); + const classes = useStyles(); + const labelId = 'permission-label'; + const labelProps = showLabel + ? { + labelId, + label: t('Permission'), + } + : {}; + return ( + + {showLabel && {t('Permission')}} + + + ); +}; + +ItemMembershipSelect.propTypes = { + value: PropTypes.oneOf(Object.values(PERMISSION_LEVELS)).isRequired, + inputRef: PropTypes.instanceOf(React.Ref).isRequired, + showLabel: PropTypes.bool, + onChange: PropTypes.func, +}; + +ItemMembershipSelect.defaultProps = { + showLabel: true, + onChange: null, +}; + +export default ItemMembershipSelect; diff --git a/src/components/item/settings/ItemSettings.js b/src/components/item/settings/ItemSettings.js index d1eee38a0..5e4e3ea34 100644 --- a/src/components/item/settings/ItemSettings.js +++ b/src/components/item/settings/ItemSettings.js @@ -1,15 +1,12 @@ import React from 'react'; import Container from '@material-ui/core/Container'; import Typography from '@material-ui/core/Typography'; -import { useParams } from 'react-router'; +import PropTypes from 'prop-types'; +import { Map } from 'immutable'; import { useTranslation } from 'react-i18next'; -import { makeStyles } from '@material-ui/core'; +import { Divider, makeStyles } from '@material-ui/core'; import ItemLoginSetting from './ItemLoginSetting'; -import { isSettingsEditionAllowedForUser } from '../../../utils/membership'; -import { hooks } from '../../../config/queryClient'; -import Loader from '../../common/Loader'; - -const { useCurrentMember, useItemMemberships } = hooks; +import ItemMembershipsTable from '../ItemMembershipsTable'; const useStyles = makeStyles((theme) => ({ title: { @@ -19,36 +16,27 @@ const useStyles = makeStyles((theme) => ({ wrapper: { marginTop: theme.spacing(2), }, + divider: { + margin: theme.spacing(3, 0), + }, })); -const ItemSettings = () => { +const ItemSettings = ({ item }) => { const { t } = useTranslation(); const classes = useStyles(); - const { itemId } = useParams(); - const { - data: memberships, - isLoading: isMembershipsLoading, - } = useItemMemberships(itemId); - const { data: user, isLoading: isMemberLoading } = useCurrentMember(); - const memberId = user?.get('id'); - - if (isMembershipsLoading || isMemberLoading) { - return ; - } - - // settings are not available for user without edition membership - if (!isSettingsEditionAllowedForUser({ memberships, memberId })) { - return null; - } return ( - + {t('Settings')} - + + + ); }; - +ItemSettings.propTypes = { + item: PropTypes.instanceOf(Map).isRequired, +}; export default ItemSettings; diff --git a/src/components/item/settings/ItemSettingsButton.js b/src/components/item/settings/ItemSettingsButton.js new file mode 100644 index 000000000..fe619174b --- /dev/null +++ b/src/components/item/settings/ItemSettingsButton.js @@ -0,0 +1,50 @@ +import React, { useContext } from 'react'; +import { Loader } from '@graasp/ui'; +import PropTypes from 'prop-types'; +import IconButton from '@material-ui/core/IconButton'; +import SettingsIcon from '@material-ui/icons/Settings'; +import CloseIcon from '@material-ui/icons/Close'; +import { hooks } from '../../../config/queryClient'; +import { LayoutContext } from '../../context/LayoutContext'; +import { isSettingsEditionAllowedForUser } from '../../../utils/membership'; +import { ITEM_SETTINGS_BUTTON_CLASS } from '../../../config/selectors'; + +function ItemSettingsButton({ id }) { + const { setIsItemSettingsOpen, isItemSettingsOpen } = useContext( + LayoutContext, + ); + const { + data: memberships, + isLoading: isMembershipsLoading, + } = hooks.useItemMemberships(id); + const { data: user, isLoading: isMemberLoading } = hooks.useCurrentMember(); + const memberId = user?.get('id'); + + if (isMembershipsLoading || isMemberLoading) { + return ; + } + + // settings are not available for user without edition membership + if (!isSettingsEditionAllowedForUser({ memberships, memberId })) { + return null; + } + + const onClickSettings = () => { + setIsItemSettingsOpen(!isItemSettingsOpen); + }; + + return ( + + {isItemSettingsOpen ? : } + + ); +} + +ItemSettingsButton.propTypes = { + id: PropTypes.string.isRequired, +}; + +export default ItemSettingsButton; diff --git a/src/components/main/ItemScreen.js b/src/components/main/ItemScreen.js index 0ff4029e5..29bc4192d 100644 --- a/src/components/main/ItemScreen.js +++ b/src/components/main/ItemScreen.js @@ -1,168 +1,39 @@ import React, { useContext } from 'react'; import { useParams } from 'react-router'; -import { makeStyles } from '@material-ui/core'; -import { - FileItem, - S3FileItem, - DocumentItem, - LinkItem, - AppItem, -} from '@graasp/ui'; -import { MUTATION_KEYS } from '@graasp/query-client'; -import { hooks, useMutation } from '../../config/queryClient'; -import Items from './Items'; -import { - buildFileItemId, - buildS3FileItemId, - buildSaveButtonId, - DOCUMENT_ITEM_TEXT_EDITOR_ID, - ITEM_SCREEN_ERROR_ALERT_ID, -} from '../../config/selectors'; -import { ITEM_KEYS, ITEM_TYPES } from '../../enums'; -import FileUploader from './FileUploader'; +import { Loader } from '@graasp/ui'; +import { hooks } from '../../config/queryClient'; import ItemMain from '../item/ItemMain'; -import Loader from '../common/Loader'; -import ErrorAlert from '../common/ErrorAlert'; -import { API_HOST } from '../../config/constants'; -import { ItemLayoutModeContext } from '../context/ItemLayoutModeContext'; +import { LayoutContext } from '../context/LayoutContext'; import Main from './Main'; +import ItemContent from '../item/ItemContent'; +import ItemSettings from '../item/settings/ItemSettings'; +import ErrorAlert from '../common/ErrorAlert'; -const { - useChildren, - useItem, - useFileContent, - useS3FileContent, - useCurrentMember, -} = hooks; - -const useStyles = makeStyles(() => ({ - fileWrapper: { - textAlign: 'center', - height: '80vh', - flexGrow: 1, - }, -})); +const { useItem } = hooks; const ItemScreen = () => { - const classes = useStyles(); - const { mutate: editItem } = useMutation(MUTATION_KEYS.EDIT_ITEM); const { itemId } = useParams(); - const { editingItemId, setEditingItemId } = useContext(ItemLayoutModeContext); - const { data: item, isLoading: isLoadingItem, isError } = useItem(itemId); - const itemType = item?.get(ITEM_KEYS.TYPE); - - // provide user to app - const { data: user, isLoading: isLoadingUser } = useCurrentMember(); - - // display children - const { data: children, isLoading: isLoadingChildren } = useChildren(itemId); - const id = item?.get(ITEM_KEYS.ID); + const { data: item, isLoading } = useItem(itemId); - const { data: content, isLoading: isLoadingFileContent } = useFileContent( - id, - { - enabled: item && itemType === ITEM_TYPES.FILE, - }, - ); - const { - data: s3Content, - isLoading: isLoadingS3FileContent, - } = useS3FileContent(id, { - enabled: itemType === ITEM_TYPES.S3_FILE, - }); - const isEditing = editingItemId === itemId; + const { isItemSettingsOpen } = useContext(LayoutContext); - if ( - isLoadingItem || - isLoadingFileContent || - isLoadingS3FileContent || - isLoadingUser || - isLoadingChildren - ) { + if (isLoading) { return ; } - if (!item || !itemId || isError) { - return ; + if (!itemId || !item) { + return ; } - const onSaveCaption = (caption) => { - // edit item only when description has changed - if (caption !== item.get('description')) { - editItem({ id: itemId, description: caption }); - } - setEditingItemId(null); - }; - - const saveButtonId = buildSaveButtonId(itemId); - - const renderContent = () => { - switch (itemType) { - case ITEM_TYPES.FILE: - return ( -
- -
- ); - case ITEM_TYPES.S3_FILE: - return ( -
- -
- ); - case ITEM_TYPES.LINK: - return ( -
- -
- ); - case ITEM_TYPES.DOCUMENT: - return ; - case ITEM_TYPES.APP: - return ( - - ); - case ITEM_TYPES.FOLDER: - return ( - <> - - - - ); - - default: - return ; - } - }; - return (
- {renderContent()} + + {isItemSettingsOpen ? ( + + ) : ( + + )} +
); }; diff --git a/src/components/main/Items.js b/src/components/main/Items.js index ebbb3e8a7..fb2116fc6 100644 --- a/src/components/main/Items.js +++ b/src/components/main/Items.js @@ -4,10 +4,10 @@ import PropTypes from 'prop-types'; import { ITEM_LAYOUT_MODES } from '../../enums'; import ItemsTable from './ItemsTable'; import ItemsGrid from './ItemsGrid'; -import { ItemLayoutModeContext } from '../context/ItemLayoutModeContext'; +import { LayoutContext } from '../context/LayoutContext'; const Items = ({ items, title, id }) => { - const { mode } = useContext(ItemLayoutModeContext); + const { mode } = useContext(LayoutContext); switch (mode) { case ITEM_LAYOUT_MODES.GRID: diff --git a/src/components/main/Main.js b/src/components/main/Main.js index c48b02dc3..417a646b3 100644 --- a/src/components/main/Main.js +++ b/src/components/main/Main.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import Drawer from '@material-ui/core/Drawer'; import clsx from 'clsx'; import { Loader } from '@graasp/ui'; @@ -14,6 +14,7 @@ import { import MainMenu from './MainMenu'; import Header from '../layout/Header'; import { hooks } from '../../config/queryClient'; +import { LayoutContext } from '../context/LayoutContext'; const useStyles = makeStyles((theme) => ({ root: { @@ -64,10 +65,11 @@ const useStyles = makeStyles((theme) => ({ const Main = ({ children }) => { const { i18n } = useTranslation(); const classes = useStyles(); - const [open, setOpen] = React.useState(false); const { data: member, isLoading } = hooks.useCurrentMember(); + const { isMainmenuOpen, setIsMainmenuOpen } = useContext(LayoutContext); + useEffect(() => { i18n.changeLanguage(member?.get('extra')?.lang || DEFAULT_LANG); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -78,17 +80,17 @@ const Main = ({ children }) => { } const toggleDrawer = (isOpen) => { - setOpen(isOpen); + setIsMainmenuOpen(isOpen); }; return (
-
+
{
diff --git a/src/config/constants.js b/src/config/constants.js index ec43c27c7..14b93d2fb 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -13,6 +13,7 @@ const { SHOW_NOTIFICATIONS: ENV_SHOW_NOTIFICATIONS, AUTHENTICATION_HOST: ENV_AUTHENTICATION_HOST, NODE_ENV: ENV_NODE_ENV, + PERFORM_VIEW_HOST: ENV_PERFORM_VIEW_HOST, } = env; export const APP_NAME = 'Graasp'; @@ -43,6 +44,11 @@ export const AUTHENTICATION_HOST = process.env.REACT_APP_AUTHENTICATION_HOST || 'http://localhost:3111'; +export const PERFORM_VIEW_HOST = + ENV_PERFORM_VIEW_HOST || + process.env.REACT_APP_PERFORM_VIEW_HOST || + 'http://localhost:3111'; + export const DESCRIPTION_MAX_LENGTH = 30; export const DEFAULT_IMAGE_SRC = diff --git a/src/config/messages.js b/src/config/messages.js index 7a984e016..587aa4700 100644 --- a/src/config/messages.js +++ b/src/config/messages.js @@ -46,3 +46,11 @@ export const COPY_MEMBER_ID_TO_CLIPBOARD_SUCCESS_MESSAGE = 'Member ID is successfully copied!'; export const COPY_MEMBER_ID_TO_CLIPBOARD_ERROR_MESSAGE = 'An error occured while copying the member ID'; +export const EDIT_ITEM_MEMBERSHIP_ERROR_MESSAGE = + 'There was an error editing the item membership.'; +export const DELETE_ITEM_MEMBERSHIP_ERROR_MESSAGE = + 'There was an error deleting the item membership.'; +export const EDIT_ITEM_MEMBERSHIP_SUCCESS_MESSAGE = + 'The item membership was successfully edited.'; +export const DELETE_ITEM_MEMBERSHIP_SUCCESS_MESSAGE = + 'The item membership was successfully deleted.'; diff --git a/src/config/selectors.js b/src/config/selectors.js index 3cec1ca6e..5eedfca27 100644 --- a/src/config/selectors.js +++ b/src/config/selectors.js @@ -22,11 +22,9 @@ export const TREE_MODAL_CONFIRM_BUTTON_ID = 'treeModalConfirmButton'; export const ITEMS_GRID_NO_ITEM_ID = 'itemsGridNoItem'; export const EDIT_ITEM_BUTTON_CLASS = 'editButton'; export const SHARE_ITEM_BUTTON_CLASS = 'itemMenuShareButton'; -export const SHARE_ITEM_MODAL_EMAIL_INPUT_ID = 'shareItemModalEmailInput'; -export const SHARE_ITEM_MODAL_PERMISSION_SELECT_ID = - 'shareItemModalPermissionSelect'; +export const SHARE_ITEM_EMAIL_INPUT_ID = 'shareItemModalEmailInput'; export const buildPermissionOptionId = (id) => `permission-${id}`; -export const SHARE_ITEM_MODAL_SHARE_BUTTON_ID = 'shareItemModalShareButton'; +export const SHARE_ITEM_SHARE_BUTTON_ID = 'shareItemModalShareButton'; export const MODE_LIST_BUTTON_ID = 'modeListButton'; export const MODE_GRID_BUTTON_ID = 'modeCardButton'; @@ -94,3 +92,11 @@ export const MEMBER_PROFILE_MEMBER_ID_COPY_BUTTON_ID = export const REDIRECTION_CONTENT_ID = 'redirectionContent'; export const ITEM_MEMBERSHIPS_CONTENT_ID = 'itemMembershipsContent'; export const buildMemberAvatarClass = (id) => `memberAvatar-${id}`; +export const ITEM_SETTINGS_BUTTON_CLASS = 'itemSettingsButton'; +export const buildItemMembershipRowId = (id) => `itemMembershipRow-${id}`; +export const ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS = + 'itemMembershipPermissionSelect'; +export const buildItemMembershipRowDeleteButtonId = (id) => + `itemMembershipRowDeleteButtonId-${id}`; +export const ITEM_INFORMATION_ICON_IS_OPEN_CLASS = 'itemInformationIconIsOpen'; +export const ITEM_INFORMATION_BUTTON_ID = 'itemInformationButton'; diff --git a/src/langs/en.json b/src/langs/en.json index 5f8f428b2..f4cee5a3f 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -84,6 +84,7 @@ "Member Since": "Member Since", "Profile": "Profile", "Member ID": "Member ID", - "Size": "Size" + "Size": "Size", + "Manage Access": "Manage Access" } } diff --git a/src/langs/fr.json b/src/langs/fr.json index b170beac3..28d14e2da 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -84,6 +84,7 @@ "Member Since": "Membre depuis", "Profile": "Profil", "Member ID": "ID de Membre", - "Size": "Taille" + "Size": "Taille", + "Manage Access": "Gérer les accès" } } diff --git a/src/middlewares/notifier.js b/src/middlewares/notifier.js index 8b17dbdf1..3ed226ac3 100644 --- a/src/middlewares/notifier.js +++ b/src/middlewares/notifier.js @@ -29,6 +29,10 @@ import { EDIT_MEMBER_SUCCESS_MESSAGE, COPY_MEMBER_ID_TO_CLIPBOARD_SUCCESS_MESSAGE, COPY_MEMBER_ID_TO_CLIPBOARD_ERROR_MESSAGE, + EDIT_ITEM_MEMBERSHIP_ERROR_MESSAGE, + DELETE_ITEM_MEMBERSHIP_ERROR_MESSAGE, + EDIT_ITEM_MEMBERSHIP_SUCCESS_MESSAGE, + DELETE_ITEM_MEMBERSHIP_SUCCESS_MESSAGE, } from '../config/messages'; import { COPY_MEMBER_ID_TO_CLIPBOARD } from '../types/clipboard'; @@ -46,12 +50,22 @@ const { deleteItemTagRoutine, postItemLoginRoutine, editMemberRoutine, + editItemMembershipRoutine, + deleteItemMembershipRoutine, } = routines; export default ({ type, payload }) => { let message = null; switch (type) { // error messages + case editItemMembershipRoutine.FAILURE: { + message = EDIT_ITEM_MEMBERSHIP_ERROR_MESSAGE; + break; + } + case deleteItemMembershipRoutine.FAILURE: { + message = DELETE_ITEM_MEMBERSHIP_ERROR_MESSAGE; + break; + } case COPY_MEMBER_ID_TO_CLIPBOARD.FAILURE: { message = COPY_MEMBER_ID_TO_CLIPBOARD_ERROR_MESSAGE; break; @@ -147,6 +161,14 @@ export default ({ type, payload }) => { message = COPY_MEMBER_ID_TO_CLIPBOARD_SUCCESS_MESSAGE; break; } + case editItemMembershipRoutine.SUCCESS: { + message = EDIT_ITEM_MEMBERSHIP_SUCCESS_MESSAGE; + break; + } + case deleteItemMembershipRoutine.SUCCESS: { + message = DELETE_ITEM_MEMBERSHIP_SUCCESS_MESSAGE; + break; + } // progress messages // todo: this might be handled differently diff --git a/yarn.lock b/yarn.lock index e69fc9618..21d51e007 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1583,9 +1583,9 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@graasp/query-client@git://github.com/graasp/graasp-query-client.git": +"@graasp/query-client@git://github.com/graasp/graasp-query-client.git#12/patchDeleteMembership": version "0.1.0" - resolved "git://github.com/graasp/graasp-query-client.git#05bde46fec71ed84aa6ca4ae0e5753ba78c677ae" + resolved "git://github.com/graasp/graasp-query-client.git#e9f4d630536609f5f46bff6bf9d4f15cdfdc2cbd" dependencies: http-status-codes "2.1.4" immutable "4.0.0-rc.12"