diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 4e86dec2c..74bdbb777 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -47,11 +47,12 @@ jobs: VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }} - # use the Cypress GitHub Action to run Cypress tests within the chrome browser - - name: Cypress run + # use the Cypress GitHub Action to run Cypress Component tests within the chrome browser + - name: Cypress run components uses: cypress-io/github-action@v6 with: install: false + component: true # we launch the app in preview mode to avoid issues with hmr websockets from vite polluting the mocks start: yarn preview:test browser: chrome @@ -70,6 +71,28 @@ jobs: VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }} + # use the Cypress GitHub Action to run Cypress E2E tests within the chrome browser + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + install: false + # the app preview is already run for the component tests + browser: chrome + quiet: true + config-file: cypress.config.ts + cache-key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + env: + VITE_PORT: ${{ vars.VITE_PORT }} + VITE_VERSION: ${{ vars.VITE_VERSION }} + VITE_GRAASP_DOMAIN: ${{ vars.VITE_GRAASP_DOMAIN }} + VITE_GRAASP_API_HOST: ${{ vars.VITE_GRAASP_API_HOST }} + VITE_GRAASP_AUTH_HOST: ${{ vars.VITE_GRAASP_AUTH_HOST }} + VITE_GRAASP_PLAYER_HOST: ${{ vars.VITE_GRAASP_PLAYER_HOST }} + VITE_GRAASP_LIBRARY_HOST: ${{ vars.VITE_GRAASP_LIBRARY_HOST }} + VITE_GRAASP_ANALYZER_HOST: ${{ vars.VITE_GRAASP_ANALYZER_HOST }} + VITE_SHOW_NOTIFICATIONS: ${{ vars.VITE_SHOW_NOTIFICATIONS }} + VITE_GRAASP_REDIRECTION_HOST: ${{ vars.VITE_GRAASP_REDIRECTION_HOST }} + # after the test run completes # store any screenshots # NOTE: screenshots will be generated only if E2E test failed diff --git a/cypress.config.ts b/cypress.config.ts index f90a8ebb9..b9526dcab 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,6 +1,18 @@ import setupCoverage from '@cypress/code-coverage/task'; import { defineConfig } from 'cypress'; +const ENV = { + VITE_GRAASP_REDIRECTION_HOST: process.env.VITE_GRAASP_REDIRECTION_HOST, + VITE_GRAASP_DOMAIN: process.env.VITE_GRAASP_DOMAIN, + VITE_GRAASP_API_HOST: process.env.VITE_GRAASP_API_HOST, + VITE_SHOW_NOTIFICATIONS: false, + VITE_GRAASP_AUTH_HOST: process.env.VITE_GRAASP_AUTH_HOST, + VITE_GRAASP_PLAYER_HOST: process.env.VITE_GRAASP_PLAYER_HOST, + VITE_GRAASP_ANALYZER_HOST: process.env.VITE_GRAASP_ANALYZER_HOST, + VITE_GRAASP_LIBRARY_HOST: process.env.VITE_GRAASP_LIBRARY_HOST, + VITE_GRAASP_ACCOUNT_HOST: process.env.VITE_GRAASP_ACCOUNT_HOST, +}; + export default defineConfig({ video: false, retries: { @@ -8,17 +20,7 @@ export default defineConfig({ }, chromeWebSecurity: false, e2e: { - env: { - VITE_GRAASP_REDIRECTION_HOST: process.env.VITE_GRAASP_REDIRECTION_HOST, - VITE_GRAASP_DOMAIN: process.env.VITE_GRAASP_DOMAIN, - VITE_GRAASP_API_HOST: process.env.VITE_GRAASP_API_HOST, - VITE_SHOW_NOTIFICATIONS: false, - VITE_GRAASP_AUTH_HOST: process.env.VITE_GRAASP_AUTH_HOST, - VITE_GRAASP_PLAYER_HOST: process.env.VITE_GRAASP_PLAYER_HOST, - VITE_GRAASP_ANALYZER_HOST: process.env.VITE_GRAASP_ANALYZER_HOST, - VITE_GRAASP_LIBRARY_HOST: process.env.VITE_GRAASP_LIBRARY_HOST, - VITE_GRAASP_ACCOUNT_HOST: process.env.VITE_GRAASP_ACCOUNT_HOST, - }, + env: ENV, // We've imported your old cypress plugins here. // You may want to clean this up later by importing these. setupNodeEvents(on, config) { @@ -27,4 +29,11 @@ export default defineConfig({ }, baseUrl: `http://localhost:${process.env.VITE_PORT || 3333}`, }, + component: { + devServer: { + framework: 'react', + bundler: 'vite', + }, + env: ENV, + }, }); diff --git a/cypress/components/common/DebouncedTextField.cy.tsx b/cypress/components/common/DebouncedTextField.cy.tsx new file mode 100644 index 000000000..8a45a44ba --- /dev/null +++ b/cypress/components/common/DebouncedTextField.cy.tsx @@ -0,0 +1,81 @@ +import DebouncedTextField, { + DEBOUNCE_MS, +} from '@/components/input/DebouncedTextField'; +import { DEBOUNCED_TEXT_FIELD_ID } from '@/config/selectors'; + +const ON_UPDATE_SPY = 'onUpdate'; +const getSpyOnUpdate = () => `@${ON_UPDATE_SPY}`; +const eventHandler = { + onUpdate: (_newValue: string) => {}, +}; +const LABEL = 'Label'; +const VALUE = 'My value'; + +const getTextArea = () => cy.get(`#${DEBOUNCED_TEXT_FIELD_ID}`); + +describe('', () => { + beforeEach(() => { + cy.spy(eventHandler, 'onUpdate').as(ON_UPDATE_SPY); + }); + + describe('Value is defined', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + it('Initial value should not called onUpdate', () => { + getTextArea().should('be.visible'); + cy.get(getSpyOnUpdate()).should('not.be.called'); + }); + + it('Edit value should be called onUpdate', () => { + const NEW_VALUE = 'My new value'; + getTextArea().clear().type(NEW_VALUE); + + cy.get(getSpyOnUpdate()).should('be.calledOnceWith', NEW_VALUE); + }); + + it('Edit value multiple times should debounce onUpdate', () => { + const NEW_VALUE = 'My new value'; + getTextArea().clear().type(NEW_VALUE); + + // Write again before the end of the debounce timeout + const APPEND_VALUE = ' which has been debounced'; + cy.wait(DEBOUNCE_MS - 100); + getTextArea().type(APPEND_VALUE); + cy.get(getSpyOnUpdate()).should( + 'be.calledOnceWith', + `${NEW_VALUE}${APPEND_VALUE}`, + ); + }); + }); + + describe('Can not be empty if not allowed', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + + it('Empty value should not call onUpdate if not allowed', () => { + getTextArea().clear(); + cy.get(getSpyOnUpdate()).should('not.be.called'); + }); + + it('onUpdate should be called if value is not empty', () => { + const NEW_VALUE = 'My new value'; + getTextArea().clear().type(NEW_VALUE); + cy.get(getSpyOnUpdate()).should('be.calledOnceWith', NEW_VALUE); + }); + }); +}); diff --git a/cypress/components/common/MultiSelectChipInput.cy.tsx b/cypress/components/common/MultiSelectChipInput.cy.tsx new file mode 100644 index 000000000..93345e2b2 --- /dev/null +++ b/cypress/components/common/MultiSelectChipInput.cy.tsx @@ -0,0 +1,145 @@ +import MultiSelectChipInput from '@/components/input/MultiSelectChipInput'; +import { + MULTI_SELECT_CHIP_ADD_BUTTON_ID, + MULTI_SELECT_CHIP_CONTAINER_ID, + MULTI_SELECT_CHIP_INPUT_ID, + buildDataCyWrapper, + buildMultiSelectChipsSelector, +} from '@/config/selectors'; + +const ON_SAVE_SPY = 'onSave'; +const getSpyOnSave = () => `@${ON_SAVE_SPY}`; +const eventHandler = { onSave: (_values: string[]) => {} }; +const EXISTING_VALUES = ['first', 'second', 'third']; +const NEW_VALUE = 'my new value'; +const LABEL = 'my label'; + +const getInput = () => + cy.get(`${buildDataCyWrapper(MULTI_SELECT_CHIP_INPUT_ID)} input`); + +const addANewValue = (newValue: string) => { + getInput().type(newValue); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_ADD_BUTTON_ID)).click(); +}; + +const removeAValue = (valueToRemove: string) => { + const idxOfRemovedValue = EXISTING_VALUES.findIndex( + (value) => value === valueToRemove, + ); + + if (idxOfRemovedValue === -1) { + throw new Error(`Given value to remove "${valueToRemove}" was not found!`); + } + + cy.get( + `${buildDataCyWrapper(buildMultiSelectChipsSelector(idxOfRemovedValue))} svg`, + ).click(); +}; + +describe('', () => { + beforeEach(() => { + cy.spy(eventHandler, 'onSave').as(ON_SAVE_SPY); + }); + + describe('Data is empty', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + + it('Chips container should not exist when no data', () => { + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_CONTAINER_ID)).should( + 'not.exist', + ); + }); + + it('Add a new value should add a new chip', () => { + addANewValue(NEW_VALUE); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_CONTAINER_ID)) + .children() + .should('have.length', 1) + .should('contain', NEW_VALUE); + }); + + it('Add a new value should reset current value', () => { + addANewValue(NEW_VALUE); + getInput().should('have.value', ''); + }); + + it('Add a new empty value should not be possible', () => { + getInput().should('have.value', ''); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_ADD_BUTTON_ID)).should( + 'be.disabled', + ); + }); + }); + + describe('Have some data', () => { + const valueToRemove = EXISTING_VALUES[1]; + + beforeEach(() => { + cy.mount( + , + ); + }); + + it('Chips container should contains existing chips', () => { + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_CONTAINER_ID)) + .children() + .should('have.length', EXISTING_VALUES.length); + }); + + it('Add a new value should add a new chip', () => { + addANewValue(NEW_VALUE); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_CONTAINER_ID)) + .children() + .should('have.length', EXISTING_VALUES.length + 1) + .should('contain', NEW_VALUE); + }); + + it('Add a new value should call onSave', () => { + addANewValue(NEW_VALUE); + cy.get(getSpyOnSave()).should('be.calledWith', [ + ...EXISTING_VALUES, + NEW_VALUE, + ]); + }); + + it('Add an existing value should not be possible', () => { + getInput().type(EXISTING_VALUES[0].toUpperCase()); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_ADD_BUTTON_ID)).should( + 'be.disabled', + ); + }); + + it('Add an existing value should not call onSave', () => { + getInput().type(EXISTING_VALUES[0].toUpperCase()); + cy.get(getSpyOnSave()).should('not.be.called'); + }); + + it('Remove a value should remove the chip', () => { + removeAValue(valueToRemove); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_CONTAINER_ID)) + .children() + .should('have.length', EXISTING_VALUES.length - 1) + .should('not.contain', valueToRemove); + }); + + it('Remove a value should call onSave', () => { + removeAValue(valueToRemove); + cy.get(getSpyOnSave()).should( + 'be.calledWith', + EXISTING_VALUES.filter((e) => e !== valueToRemove), + ); + }); + }); +}); diff --git a/cypress/components/common/ThumbnailCrop.cy.tsx b/cypress/components/common/ThumbnailCrop.cy.tsx new file mode 100644 index 000000000..a19f1b161 --- /dev/null +++ b/cypress/components/common/ThumbnailCrop.cy.tsx @@ -0,0 +1,104 @@ +import ThumbnailCrop from '@/components/thumbnails/ThumbnailCrop'; +import { + CROP_MODAL_CONFIRM_BUTTON_ID, + IMAGE_PLACEHOLDER_FOLDER, + IMAGE_THUMBNAIL_FOLDER, + IMAGE_THUMBNAIL_UPLOADER, + REMOVE_THUMBNAIL_BUTTON, + buildDataCyWrapper, +} from '@/config/selectors'; + +import { + THUMBNAIL_MEDIUM_PATH, + THUMBNAIL_SMALL_PATH, +} from '../../fixtures/thumbnails/links'; + +const ON_DELETE_SPY = 'onDelete'; +const ON_UPLOAD_SPY = 'onUpload'; +const getSpy = (spy: string) => `@${spy}`; +const eventHandler = { + onUpload: (_payload: { thumbnail?: Blob }) => {}, + onDelete: () => {}, +}; + +describe('', () => { + beforeEach(() => { + cy.spy(eventHandler, 'onUpload').as(ON_UPLOAD_SPY); + cy.spy(eventHandler, 'onDelete').as(ON_DELETE_SPY); + }); + + describe('Image is not set', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + + it('Image element should not exist', () => { + cy.get(buildDataCyWrapper(IMAGE_THUMBNAIL_FOLDER)).should('not.exist'); + }); + + it('Image placeholder should be visible', () => { + cy.get(buildDataCyWrapper(IMAGE_PLACEHOLDER_FOLDER)).should('be.visible'); + }); + + it('Upload a new thumbnail', () => { + // change item thumbnail + // target visually hidden input + cy.get(buildDataCyWrapper(IMAGE_THUMBNAIL_UPLOADER)).selectFile( + THUMBNAIL_MEDIUM_PATH, + // use force because the input is visually hidden + { force: true }, + ); + cy.get(`#${CROP_MODAL_CONFIRM_BUTTON_ID}`).click(); + cy.get(getSpy(ON_UPLOAD_SPY)).should('be.calledOnce'); + cy.get(buildDataCyWrapper(IMAGE_PLACEHOLDER_FOLDER)).should('not.exist'); + cy.get(buildDataCyWrapper(IMAGE_THUMBNAIL_FOLDER)).should('be.visible'); + }); + }); + + describe('Image is set', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + + it('Image element should be visible', () => { + cy.get(buildDataCyWrapper(IMAGE_THUMBNAIL_FOLDER)).should('be.visible'); + }); + + it('Image placeholder should not exist', () => { + cy.get(buildDataCyWrapper(IMAGE_PLACEHOLDER_FOLDER)).should('not.exist'); + }); + + it('Upload a new thumbnail', () => { + // change item thumbnail + // target visually hidden input + cy.get(buildDataCyWrapper(IMAGE_THUMBNAIL_UPLOADER)).selectFile( + THUMBNAIL_SMALL_PATH, + // use force because the input is visually hidden + { force: true }, + ); + cy.get(`#${CROP_MODAL_CONFIRM_BUTTON_ID}`).click(); + cy.get(getSpy(ON_UPLOAD_SPY)).should('be.calledOnce'); + cy.get(buildDataCyWrapper(IMAGE_PLACEHOLDER_FOLDER)).should('not.exist'); + cy.get(buildDataCyWrapper(IMAGE_THUMBNAIL_FOLDER)).should('be.visible'); + }); + + it('Remove a thumbnail', () => { + cy.get(buildDataCyWrapper(REMOVE_THUMBNAIL_BUTTON)).click(); + cy.get(getSpy(ON_DELETE_SPY)).should('be.calledOnce'); + cy.get(buildDataCyWrapper(IMAGE_PLACEHOLDER_FOLDER)).should('be.visible'); + cy.get(buildDataCyWrapper(IMAGE_THUMBNAIL_FOLDER)).should('not.exist'); + }); + }); +}); diff --git a/cypress/components/item/publish/PublicationAttributeContainer.cy.tsx b/cypress/components/item/publish/PublicationAttributeContainer.cy.tsx new file mode 100644 index 000000000..1c0bf7c38 --- /dev/null +++ b/cypress/components/item/publish/PublicationAttributeContainer.cy.tsx @@ -0,0 +1,134 @@ +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import { IconButton, Tooltip, Typography } from '@mui/material'; + +import { theme } from '@graasp/ui'; + +import { + buildDataCyWrapper, + buildPublishAttrContainer, + buildPublishTitleAction, + buildPublishWarningIcon, +} from '@/config/selectors'; + +import PublicationAttributeContainer from '../../../../src/components/item/publish/PublicationAttributeContainer'; + +const ON_EMPTY_SPY = 'onEmptyClick'; +const ON_ICON_BTN_SPY = 'onIconBtnClick'; +const getSpy = (spy: string) => `@${spy}`; +const eventHandler = { + onEmptyClick: () => {}, + onIconBtnClick: () => {}, +}; +const TITLE = 'Categories'; +const DATA_CY_ID = 'my-categories'; +const CONTENT = Category 1; +const TITLE_ACTION_BTN = ( + + eventHandler.onIconBtnClick()}> + + + +); + +describe('', () => { + beforeEach(() => { + cy.spy(eventHandler, 'onEmptyClick').as(ON_EMPTY_SPY); + cy.spy(eventHandler, 'onIconBtnClick').as(ON_ICON_BTN_SPY); + }); + + describe('Container is empty', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + + it('Warning should be visible', () => { + cy.get(buildDataCyWrapper(buildPublishWarningIcon(DATA_CY_ID))).should( + 'be.visible', + ); + }); + + it('Empty container should be visisble', () => { + cy.get(buildDataCyWrapper(buildPublishWarningIcon(DATA_CY_ID))).should( + 'be.visible', + ); + }); + + it('Attribute content should not exist', () => { + cy.get(buildDataCyWrapper(DATA_CY_ID)).should('not.exist'); + }); + + it('Title icon should not exist', () => { + cy.get(buildDataCyWrapper(buildPublishTitleAction(DATA_CY_ID))).should( + 'not.exist', + ); + }); + + it('Clicking on container should call onEmptyClick', () => { + cy.get(buildDataCyWrapper(buildPublishAttrContainer(DATA_CY_ID))) + .should('be.visible') + .click(); + cy.get(getSpy(ON_EMPTY_SPY)).should('be.calledOnce'); + }); + }); + + describe('Container contains data', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + + it('Warning should not exist', () => { + cy.get(buildDataCyWrapper(buildPublishWarningIcon(DATA_CY_ID))).should( + 'not.exist', + ); + }); + + it('Empty container should not exist', () => { + cy.get(buildDataCyWrapper(buildPublishWarningIcon(DATA_CY_ID))).should( + 'not.exist', + ); + }); + + it('Attribute content should be visible', () => { + cy.get(buildDataCyWrapper(DATA_CY_ID)).should('be.visible'); + }); + + it('Title icon should be visible', () => { + cy.get(buildDataCyWrapper(buildPublishTitleAction(DATA_CY_ID))).should( + 'be.visible', + ); + }); + + it('Clicking on container should not call onEmptyClick', () => { + cy.get(buildDataCyWrapper(buildPublishAttrContainer(DATA_CY_ID))) + .should('be.visible') + .click(); + cy.get(getSpy(ON_EMPTY_SPY)).should('not.be.called'); + }); + + it('Clicking on icon button should call onEmptyClick', () => { + cy.get(buildDataCyWrapper(buildPublishTitleAction(DATA_CY_ID))) + .should('be.visible') + .click(); + cy.get(getSpy(ON_ICON_BTN_SPY)).should('be.calledOnce'); + }); + }); +}); diff --git a/cypress/components/item/publish/PublicationChipContainer.cy.tsx b/cypress/components/item/publish/PublicationChipContainer.cy.tsx new file mode 100644 index 000000000..61f292a6d --- /dev/null +++ b/cypress/components/item/publish/PublicationChipContainer.cy.tsx @@ -0,0 +1,75 @@ +import { + buildDataCyWrapper, + buildLibraryAddButtonHeader, + buildPublishAttrContainer, + buildPublishChip, +} from '@/config/selectors'; + +import PublicationChipContainer from '../../../../src/components/item/publish/PublicationChipContainer'; + +const ON_ADD_SPY = 'onAddClicked'; +const ON_DELETE_SPY = 'onChipDelete'; +const getSpy = (spy: string) => `@${spy}`; +const eventHandler = { + onAddClicked: () => {}, + onChipDelete: () => {}, +}; +const TITLE = 'Categories'; +const ATTRIBUTE_DESCRIPTION = + 'Add a category to enhance your element in the library'; +const DATA = ['test1', 'test2', 'test3', 'test4', 'test5', 'test6']; +const DATA_CY_ID = 'dataCyId'; + +describe('', () => { + beforeEach(() => { + cy.spy(eventHandler, 'onAddClicked').as(ON_ADD_SPY); + cy.spy(eventHandler, 'onChipDelete').as(ON_DELETE_SPY); + }); + + describe('Container is empty', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + it('Clicking on container should call onAddClicked', () => { + cy.get(buildDataCyWrapper(buildPublishAttrContainer(DATA_CY_ID))) + .should('be.visible') + .click(); + cy.get(getSpy(ON_ADD_SPY)).should('be.calledOnce'); + }); + }); + + describe('Container is not empty', () => { + beforeEach(() => { + cy.mount( + , + ); + }); + it('Clicking on add button should call onAddClicked', () => { + cy.get( + buildDataCyWrapper(buildLibraryAddButtonHeader(DATA_CY_ID)), + ).click(); + cy.get(getSpy(ON_ADD_SPY)).should('be.calledOnce'); + }); + it('Deleting a chip should call onChipDelete', () => { + cy.get(`${buildDataCyWrapper(buildPublishChip(DATA[0]))} svg`).click(); + cy.get(getSpy(ON_DELETE_SPY)).should('be.calledOnce'); + }); + }); +}); diff --git a/cypress/e2e/item/publish/categories.cy.ts b/cypress/e2e/item/publish/categories.cy.ts index bea6e8292..f0d0f57bf 100644 --- a/cypress/e2e/item/publish/categories.cy.ts +++ b/cypress/e2e/item/publish/categories.cy.ts @@ -2,11 +2,17 @@ import { Category, CategoryType } from '@graasp/sdk'; import { buildItemPath } from '../../../../src/config/paths'; import { + CATEGORIES_ADD_BUTTON_HEADER, LIBRARY_SETTINGS_CATEGORIES_ID, + MUI_CHIP_REMOVE_BTN, buildCategoryDropdownParentSelector, buildCategorySelectionId, buildCategorySelectionOptionId, + buildDataCyWrapper, + buildDataTestIdWrapper, buildPublishButtonId, + buildPublishChip, + buildPublishChipContainer, } from '../../../../src/config/selectors'; import { ITEM_WITH_CATEGORIES, @@ -15,39 +21,44 @@ import { } from '../../../fixtures/categories'; import { PUBLISHED_ITEM } from '../../../fixtures/items'; import { MEMBERS, SIGNED_OUT_MEMBER } from '../../../fixtures/members'; -import { PUBLISH_TAB_LOADING_TIME } from '../../../support/constants'; -const openPublishItemTab = (id: string) => { +const CATEGORIES_DATA_CY = buildDataCyWrapper( + buildPublishChipContainer(LIBRARY_SETTINGS_CATEGORIES_ID), +); + +const openPublishItemTab = (id: string) => cy.get(`#${buildPublishButtonId(id)}`).click(); - cy.wait(PUBLISH_TAB_LOADING_TIME); -}; const toggleOption = ( id: string, categoryType: CategoryType | `${CategoryType}`, ) => { cy.get(`#${buildCategorySelectionId(categoryType)}`).click(); - cy.get(`#${buildCategorySelectionOptionId(categoryType, id)}`).click(); }; +const openCategoriesModal = () => { + cy.get(buildDataCyWrapper(CATEGORIES_ADD_BUTTON_HEADER)).click(); +}; + describe('Categories', () => { describe('Item without category', () => { - it('Display item without category', () => { + beforeEach(() => { const item = { ...ITEM_WITH_CATEGORIES, categories: [] as Category[] }; cy.setUpApi({ items: [item] }); cy.visit(buildItemPath(item.id)); openPublishItemTab(item.id); + }); + it('Display item without category', () => { // check for not displaying if no categories - cy.get(`#${LIBRARY_SETTINGS_CATEGORIES_ID} .MuiChip-label`).should( - 'not.exist', - ); + cy.get(CATEGORIES_DATA_CY).should('not.exist'); }); }); describe('Item with category', () => { const item = ITEM_WITH_CATEGORIES; + beforeEach(() => { cy.setUpApi(ITEM_WITH_CATEGORIES_CONTEXT); cy.visit(buildItemPath(item.id)); @@ -60,20 +71,26 @@ describe('Categories', () => { categories: [{ category }], } = item; const { name } = SAMPLE_CATEGORIES.find(({ id }) => id === category.id); - const categoryContent = cy.get(`#${LIBRARY_SETTINGS_CATEGORIES_ID}`); + const categoryContent = cy.get(CATEGORIES_DATA_CY); categoryContent.contains(name); }); describe('Delete a category', () => { - it('Using Dropdown', () => { + let id: string; + let category: Category; + let categoryType: Category['type']; + + beforeEach(() => { const { categories: [itemCategory], } = item; - const { category, id } = itemCategory; - const categoryType = SAMPLE_CATEGORIES.find( + ({ category, id } = itemCategory); + categoryType = SAMPLE_CATEGORIES.find( ({ id: cId }) => cId === category.id, )?.type; - toggleOption(category.id, categoryType); + }); + + afterEach(() => { cy.wait('@deleteItemCategory').then((data) => { const { request: { url }, @@ -82,50 +99,33 @@ describe('Categories', () => { }); }); - it('Using cross on category tag', () => { - const { - categories: [itemCategory], - } = item; - const { category, id } = itemCategory; - const categoryType = SAMPLE_CATEGORIES.find( - ({ id: cId }) => cId === category.id, - )?.type; - cy.get(`[data-cy=${buildCategoryDropdownParentSelector(categoryType)}]`) + it('Using Dropdown in modal', () => { + openCategoriesModal(); + toggleOption(category.id, categoryType); + }); + + it('Using cross on category tag in modal', () => { + openCategoriesModal(); + + cy.get( + buildDataCyWrapper(buildCategoryDropdownParentSelector(categoryType)), + ) .find(`[data-tag-index=0] > svg`) .click(); - cy.wait('@deleteItemCategory').then((data) => { - const { - request: { url }, - } = data; - expect(url.split('/')).contains(id); - }); }); - it('Using backspace in textfield', () => { - const { - categories: [itemCategory], - } = item; - const { category, id } = itemCategory; - const categoryType = SAMPLE_CATEGORIES.find( - ({ id: cId }) => cId === category.id, - )?.type; - cy.get( - `[data-cy=${buildCategoryDropdownParentSelector( - categoryType, - )}] input`, - ).type('{backspace}'); - cy.wait('@deleteItemCategory').then((data) => { - const { - request: { url }, - } = data; - expect(url.split('/')).contains(id); - }); + it('Using cross on category container', () => { + cy.get(buildDataCyWrapper(buildPublishChip(category.name))) + .find(buildDataTestIdWrapper(MUI_CHIP_REMOVE_BTN)) + .click(); }); }); it('Add a category', () => { + openCategoriesModal(); const { type, id } = SAMPLE_CATEGORIES[1]; toggleOption(id, type); + cy.wait('@postItemCategory').then((data) => { const { request: { url }, @@ -139,6 +139,7 @@ describe('Categories', () => { describe('Categories permissions', () => { it('User signed out cannot edit category level', () => { const item = PUBLISHED_ITEM; + cy.setUpApi({ items: [item], currentMember: SIGNED_OUT_MEMBER, diff --git a/cypress/e2e/item/publish/ccLicense.cy.ts b/cypress/e2e/item/publish/ccLicense.cy.ts index 7e59ec06f..2f7936335 100644 --- a/cypress/e2e/item/publish/ccLicense.cy.ts +++ b/cypress/e2e/item/publish/ccLicense.cy.ts @@ -1,34 +1,57 @@ -import { ItemTagType, PackedFolderItemFactory } from '@graasp/sdk'; +import { + CCLicenseAdaptions, + ItemTagType, + PackedFolderItemFactory, +} from '@graasp/sdk'; import { buildItemPath } from '../../../../src/config/paths'; import { CC_ALLOW_COMMERCIAL_CONTROL_ID, CC_CC0_CONTROL_ID, + CC_DELETE_BUTTON_HEADER, CC_DERIVATIVE_CONTROL_ID, CC_DISALLOW_COMMERCIAL_CONTROL_ID, + CC_EDIT_BUTTON_HEADER, CC_NO_DERIVATIVE_CONTROL_ID, CC_REQUIRE_ATTRIBUTION_CONTROL_ID, + CC_SAVE_BUTTON, CC_SHARE_ALIKE_CONTROL_ID, + LIBRARY_SETTINGS_CC_SETTINGS_ID, + buildDataCyWrapper, + buildPublishAttrContainer, buildPublishButtonId, } from '../../../../src/config/selectors'; import { MEMBERS } from '../../../fixtures/members'; import { ItemForTest } from '../../../support/types'; +// Set empty description to avoid having issue +const EMPTY_DESCRIPTION = ''; + const itemCCLicenseCCBY = PackedFolderItemFactory({ name: 'public item with cc by', settings: { ccLicenseAdaption: 'CC BY' }, + description: EMPTY_DESCRIPTION, }); const itemCCLicenseCCBYNC = PackedFolderItemFactory({ name: 'public item with cc by nc', settings: { ccLicenseAdaption: 'CC BY-NC' }, + description: EMPTY_DESCRIPTION, }); const itemCCLicenseCCBYSA = PackedFolderItemFactory({ name: 'public item with cc by sa', settings: { ccLicenseAdaption: 'CC BY-SA' }, + description: EMPTY_DESCRIPTION, }); const itemCCLicenseCCBYNCND = PackedFolderItemFactory({ name: 'public item with cc by nc nd', settings: { ccLicenseAdaption: 'CC BY-NC-ND' }, + description: EMPTY_DESCRIPTION, +}); + +const itemWithoutLicense = PackedFolderItemFactory({ + name: 'public item without license', + settings: { ccLicenseAdaption: null }, + description: EMPTY_DESCRIPTION, }); const PUBLISHED_ITEMS_WITH_CC_LICENSE: ItemForTest[] = [ @@ -114,12 +137,25 @@ const openPublishItemTab = (id: string) => { cy.get(`#${buildPublishButtonId(id)}`).click(); }; -const visitItemPage = (item: ItemForTest) => { +const setUpAndVisitItemPage = (item: ItemForTest) => { cy.setUpApi({ items: [item] }); cy.visit(buildItemPath(item.id)); openPublishItemTab(item.id); }; +const openLicenseModal = ( + { hasALicense }: { hasALicense: boolean } = { hasALicense: true }, +) => + cy + .get( + buildDataCyWrapper( + hasALicense + ? CC_EDIT_BUTTON_HEADER + : buildPublishAttrContainer(LIBRARY_SETTINGS_CC_SETTINGS_ID), + ), + ) + .click(); + const ensureRadioCheckedState = (parentId: string, shouldBeChecked: boolean) => cy .get(`#${parentId}`) @@ -132,45 +168,115 @@ const ensureRadioCheckedState = (parentId: string, shouldBeChecked: boolean) => ); describe('Creative Commons License', () => { - it('Current license is selected', () => { - for (const publishedItem of PUBLISHED_ITEMS_WITH_CC_LICENSE) { - visitItemPage(publishedItem); - - const requireAttribution = - publishedItem.settings.ccLicenseAdaption.includes('BY'); - const noncommercial = - publishedItem.settings.ccLicenseAdaption.includes('NC'); - const shareAlike = - publishedItem.settings.ccLicenseAdaption.includes('SA'); - const noDerivative = - publishedItem.settings.ccLicenseAdaption.includes('ND'); - - ensureRadioCheckedState( - CC_REQUIRE_ATTRIBUTION_CONTROL_ID, - requireAttribution, + describe('No license', () => { + beforeEach(() => { + setUpAndVisitItemPage(itemWithoutLicense); + }); + + it('License is not exist', () => { + cy.get(buildDataCyWrapper(LIBRARY_SETTINGS_CC_SETTINGS_ID)).should( + 'not.exist', ); - ensureRadioCheckedState(CC_CC0_CONTROL_ID, !requireAttribution); + }); - if (requireAttribution) { - ensureRadioCheckedState(CC_ALLOW_COMMERCIAL_CONTROL_ID, !noncommercial); - ensureRadioCheckedState( - CC_DISALLOW_COMMERCIAL_CONTROL_ID, - noncommercial, - ); + it('Set a license', () => { + openLicenseModal({ hasALicense: false }); + cy.get(buildDataCyWrapper(CC_SAVE_BUTTON)).click(); + + cy.wait('@editItem').then((data) => { + const { + request: { url, body }, + } = data; + expect(url.split('/')).contains(itemWithoutLicense.id); + expect(body.settings.ccLicenseAdaption).equals(CCLicenseAdaptions.CC0); + }); + }); + }); + + describe('Have a license', () => { + it('Delete the license', () => { + const item = PUBLISHED_ITEMS_WITH_CC_LICENSE[0]; + setUpAndVisitItemPage(item); + cy.get(buildDataCyWrapper(CC_DELETE_BUTTON_HEADER)).click(); + + cy.wait('@editItem').then((data) => { + const { + request: { url, body }, + } = data; + expect(url.split('/')).contains(item.id); + expect(body.settings.ccLicenseAdaption).equals(null); + }); + }); + + describe('Current license is selected', () => { + const setUpAndOpenLicenseModal = (publishedItem: ItemForTest) => { + setUpAndVisitItemPage(publishedItem); + openLicenseModal(); + }; + + const getLicenseAdaptations = (publishedItem: ItemForTest) => ({ + requireAttribution: + publishedItem.settings.ccLicenseAdaption.includes('BY'), + noncommercial: publishedItem.settings.ccLicenseAdaption.includes('NC'), + shareAlike: publishedItem.settings.ccLicenseAdaption.includes('SA'), + noDerivative: publishedItem.settings.ccLicenseAdaption.includes('ND'), + }); + + const ensureState = (publishedItem: ItemForTest) => { + const { requireAttribution, noncommercial, shareAlike, noDerivative } = + getLicenseAdaptations(publishedItem); - ensureRadioCheckedState(CC_NO_DERIVATIVE_CONTROL_ID, noDerivative); - ensureRadioCheckedState(CC_SHARE_ALIKE_CONTROL_ID, shareAlike); ensureRadioCheckedState( - CC_DERIVATIVE_CONTROL_ID, - !shareAlike && !noDerivative, + CC_REQUIRE_ATTRIBUTION_CONTROL_ID, + requireAttribution, ); - } else { - cy.get(`#${CC_ALLOW_COMMERCIAL_CONTROL_ID}`).should('not.exist'); - cy.get(`#${CC_DISALLOW_COMMERCIAL_CONTROL_ID}`).should('not.exist'); - cy.get(`#${CC_NO_DERIVATIVE_CONTROL_ID}`).should('not.exist'); - cy.get(`#${CC_SHARE_ALIKE_CONTROL_ID}`).should('not.exist'); - cy.get(`#${CC_DERIVATIVE_CONTROL_ID}`).should('not.exist'); - } - } + ensureRadioCheckedState(CC_CC0_CONTROL_ID, !requireAttribution); + + if (requireAttribution) { + ensureRadioCheckedState( + CC_ALLOW_COMMERCIAL_CONTROL_ID, + !noncommercial, + ); + ensureRadioCheckedState( + CC_DISALLOW_COMMERCIAL_CONTROL_ID, + noncommercial, + ); + + ensureRadioCheckedState(CC_NO_DERIVATIVE_CONTROL_ID, noDerivative); + ensureRadioCheckedState(CC_SHARE_ALIKE_CONTROL_ID, shareAlike); + ensureRadioCheckedState( + CC_DERIVATIVE_CONTROL_ID, + !shareAlike && !noDerivative, + ); + } else { + cy.get(`#${CC_ALLOW_COMMERCIAL_CONTROL_ID}`).should('not.exist'); + cy.get(`#${CC_DISALLOW_COMMERCIAL_CONTROL_ID}`).should('not.exist'); + cy.get(`#${CC_NO_DERIVATIVE_CONTROL_ID}`).should('not.exist'); + cy.get(`#${CC_SHARE_ALIKE_CONTROL_ID}`).should('not.exist'); + cy.get(`#${CC_DERIVATIVE_CONTROL_ID}`).should('not.exist'); + } + }; + + it('CCBY license is selected', () => { + const publishedItem = PUBLISHED_ITEMS_WITH_CC_LICENSE[0]; + setUpAndOpenLicenseModal(publishedItem); + ensureState(publishedItem); + }); + it('CCBYNC license is selected', () => { + const publishedItem = PUBLISHED_ITEMS_WITH_CC_LICENSE[1]; + setUpAndOpenLicenseModal(publishedItem); + ensureState(publishedItem); + }); + it('CCBYSA license is selected', () => { + const publishedItem = PUBLISHED_ITEMS_WITH_CC_LICENSE[2]; + setUpAndOpenLicenseModal(publishedItem); + ensureState(publishedItem); + }); + it('CCBYNCND license is selected', () => { + const publishedItem = PUBLISHED_ITEMS_WITH_CC_LICENSE[3]; + setUpAndOpenLicenseModal(publishedItem); + ensureState(publishedItem); + }); + }); }); }); diff --git a/cypress/e2e/item/publish/coEditorSettings.cy.ts b/cypress/e2e/item/publish/coEditorSettings.cy.ts index 790a3f260..1b16b2c08 100644 --- a/cypress/e2e/item/publish/coEditorSettings.cy.ts +++ b/cypress/e2e/item/publish/coEditorSettings.cy.ts @@ -1,15 +1,12 @@ import { ItemTagType, PackedFolderItemFactory } from '@graasp/sdk'; -import { - DISPLAY_CO_EDITORS_OPTIONS, - SETTINGS, -} from '../../../../src/config/constants'; +import { DISPLAY_CO_EDITORS_OPTIONS } from '../../../../src/config/constants'; import { buildItemPath } from '../../../../src/config/paths'; import { + CO_EDITOR_SETTINGS_CHECKBOX_ID, CO_EDITOR_SETTINGS_RADIO_GROUP_ID, ITEM_HEADER_ID, - SHARE_ITEM_VISIBILITY_SELECT_ID, - buildCoEditorSettingsRadioButtonId, + buildDataCyWrapper, buildPublishButtonId, } from '../../../../src/config/selectors'; import { ITEM_WITH_CATEGORIES_CONTEXT } from '../../../fixtures/categories'; @@ -19,12 +16,6 @@ import { EDIT_TAG_REQUEST_TIMEOUT } from '../../../support/constants'; const openPublishItemTab = (id: string) => { cy.get(`#${buildPublishButtonId(id)}`).click(); }; - -const changeVisibility = (value: string): void => { - cy.get(`#${SHARE_ITEM_VISIBILITY_SELECT_ID}`).click(); - cy.get(`li[data-value="${value}"]`, { timeout: 1000 }).click(); -}; - const visitItemPage = () => { cy.setUpApi(ITEM_WITH_CATEGORIES_CONTEXT); const item = ITEM_WITH_CATEGORIES_CONTEXT.items[0]; @@ -36,31 +27,27 @@ describe('Co-editor Setting', () => { it('Display choices', () => { visitItemPage(); - Object.values(DISPLAY_CO_EDITORS_OPTIONS).forEach((option) => { - cy.get(`#${buildCoEditorSettingsRadioButtonId(option.value)}`) - .scrollIntoView() - .should('be.visible'); - }); + cy.get(buildDataCyWrapper(CO_EDITOR_SETTINGS_CHECKBOX_ID)).should( + 'be.visible', + ); }); +}); - it('Change choice', () => { - visitItemPage(); - const item = ITEM_WITH_CATEGORIES_CONTEXT.items[0]; - - const newOptionValue = DISPLAY_CO_EDITORS_OPTIONS.NO.value; +it('Change choice', () => { + visitItemPage(); + const item = ITEM_WITH_CATEGORIES_CONTEXT.items[0]; + const newOptionValue = DISPLAY_CO_EDITORS_OPTIONS.NO.value; - changeVisibility(SETTINGS.ITEM_PUBLIC.name); - cy.wait('@getLatestValidationGroup').then(() => { - cy.get(`#${buildCoEditorSettingsRadioButtonId(newOptionValue)}`).click(); - }); + cy.wait('@getLatestValidationGroup').then(() => { + cy.get(buildDataCyWrapper(CO_EDITOR_SETTINGS_CHECKBOX_ID)).click(); + }); - cy.wait('@editItem', { timeout: EDIT_TAG_REQUEST_TIMEOUT }).then((data) => { - const { - request: { url, body }, - } = data; - expect(url.split('/')).contains(item.id); - expect(body.settings.displayCoEditors).equals(newOptionValue); - }); + cy.wait('@editItem', { timeout: EDIT_TAG_REQUEST_TIMEOUT }).then((data) => { + const { + request: { url, body }, + } = data; + expect(url.split('/')).contains(item.id); + expect(body.settings.displayCoEditors).equals(newOptionValue); }); }); diff --git a/cypress/e2e/item/publish/languages.cy.ts b/cypress/e2e/item/publish/languages.cy.ts new file mode 100644 index 000000000..bf20dd866 --- /dev/null +++ b/cypress/e2e/item/publish/languages.cy.ts @@ -0,0 +1,159 @@ +import { Category, CategoryType } from '@graasp/sdk'; + +import { buildItemPath } from '../../../../src/config/paths'; +import { + LANGUAGES_ADD_BUTTON_HEADER, + LIBRARY_SETTINGS_LANGUAGES_ID, + MUI_CHIP_REMOVE_BTN, + buildCategoryDropdownParentSelector, + buildCategorySelectionId, + buildCategorySelectionOptionId, + buildDataCyWrapper, + buildDataTestIdWrapper, + buildPublishButtonId, + buildPublishChip, + buildPublishChipContainer, +} from '../../../../src/config/selectors'; +import { + ITEM_WITH_LANGUAGE, + SAMPLE_CATEGORIES, +} from '../../../fixtures/categories'; +import { PUBLISHED_ITEM } from '../../../fixtures/items'; +import { MEMBERS, SIGNED_OUT_MEMBER } from '../../../fixtures/members'; + +const LANGUAGES_DATA_CY = buildDataCyWrapper( + buildPublishChipContainer(LIBRARY_SETTINGS_LANGUAGES_ID), +); + +const openPublishItemTab = (id: string) => + cy.get(`#${buildPublishButtonId(id)}`).click(); + +const toggleOption = ( + id: string, + categoryType: CategoryType | `${CategoryType}`, +) => { + cy.get(`#${buildCategorySelectionId(categoryType)}`).click(); + cy.get(`#${buildCategorySelectionOptionId(categoryType, id)}`).click(); +}; + +const openLanguagesModal = () => { + cy.get(buildDataCyWrapper(LANGUAGES_ADD_BUTTON_HEADER)).click(); +}; + +describe('Item without language', () => { + it('Display item without language', () => { + const item = { ...ITEM_WITH_LANGUAGE, categories: [] as Category[] }; + cy.setUpApi({ items: [item] }); + cy.visit(buildItemPath(item.id)); + openPublishItemTab(item.id); + + // check for not displaying if no categories + cy.get(LANGUAGES_DATA_CY).should('not.exist'); + }); +}); + +describe('Item with language', () => { + const item = ITEM_WITH_LANGUAGE; + + beforeEach(() => { + cy.setUpApi({ items: [ITEM_WITH_LANGUAGE] }); + cy.visit(buildItemPath(item.id)); + openPublishItemTab(item.id); + }); + + it('Display item language', () => { + // check for displaying value + const { + categories: [{ category }], + } = item; + const { name } = SAMPLE_CATEGORIES.find(({ id }) => id === category.id); + cy.get(LANGUAGES_DATA_CY).contains(name); + }); + + describe('Delete a language', () => { + let id: string; + let category: Category; + let categoryType: Category['type']; + + beforeEach(() => { + const { + categories: [itemCategory], + } = item; + ({ category, id } = itemCategory); + categoryType = SAMPLE_CATEGORIES.find( + ({ id: cId }) => cId === category.id, + )?.type; + }); + + afterEach(() => { + cy.wait('@deleteItemCategory').then((data) => { + const { + request: { url }, + } = data; + expect(url.split('/')).contains(id); + }); + }); + + it('Using Dropdown in modal', () => { + openLanguagesModal(); + toggleOption(category.id, categoryType); + }); + + it('Using cross on language tag in modal', () => { + openLanguagesModal(); + + cy.get( + buildDataCyWrapper(buildCategoryDropdownParentSelector(categoryType)), + ) + .find(`[data-tag-index=0] > svg`) + .click(); + }); + + it('Using cross on language container', () => { + cy.get(buildDataCyWrapper(buildPublishChip(category.name))) + .find(buildDataTestIdWrapper(MUI_CHIP_REMOVE_BTN)) + .click(); + }); + }); + + it('Add a language', () => { + openLanguagesModal(); + const { type, id } = SAMPLE_CATEGORIES[3]; + toggleOption(id, type); + + cy.wait('@postItemCategory').then((data) => { + const { + request: { url }, + } = data; + expect(url.split('/')).contains(item.id); + }); + }); +}); + +// users without permission will not see the sections +describe('Languages permissions', () => { + it('User signed out cannot edit language level', () => { + const item = PUBLISHED_ITEM; + + cy.setUpApi({ + items: [item], + currentMember: SIGNED_OUT_MEMBER, + }); + cy.visit(buildItemPath(item.id)); + + // signed out user should not be able to see the publish button + cy.get(`#${buildPublishButtonId(item.id)}`).should('not.exist'); + }); + + it('Read-only user cannot edit language level', () => { + const item = PUBLISHED_ITEM; + cy.setUpApi({ + items: [item], + currentMember: MEMBERS.BOB, + }); + cy.visit(buildItemPath(item.id)); + + // signed out user should not be able to see the publish button + cy.get(`#${buildPublishButtonId(item.id)}`).should('not.exist'); + }); +}); diff --git a/cypress/e2e/item/publish/publishedItem.cy.ts b/cypress/e2e/item/publish/publishedItem.cy.ts index 1bad4ca76..1f89664cc 100644 --- a/cypress/e2e/item/publish/publishedItem.cy.ts +++ b/cypress/e2e/item/publish/publishedItem.cy.ts @@ -1,149 +1,339 @@ -import { PackedFolderItemFactory, PermissionLevel } from '@graasp/sdk'; +import { + ItemTagType, + ItemValidationGroup, + ItemValidationStatus, + Member, + PackedFolderItemFactory, + PackedItem, + PermissionLevel, +} from '@graasp/sdk'; + +import { PublicationStatus } from '@/types/publication'; import { buildItemPath } from '../../../../src/config/paths'; import { - ITEM_PUBLISH_BUTTON_ID, - ITEM_UNPUBLISH_BUTTON_ID, - ITEM_VALIDATION_BUTTON_ID, + EMAIL_NOTIFICATION_CHECKBOX, + PUBLIC_VISIBILITY_MODAL_VALIDATE_BUTTON, + buildDataCyWrapper, + buildItemPublicationButton, + buildPublicationStatus, buildPublishButtonId, } from '../../../../src/config/selectors'; import { - PUBLISHED_ITEM, - PUBLISHED_ITEM_VALIDATIONS, + ItemValidationGroupFactory, + PublishedItemFactory, } from '../../../fixtures/items'; import { MEMBERS } from '../../../fixtures/members'; -import { PAGE_LOAD_WAITING_PAUSE } from '../../../support/constants'; +import { ItemForTest } from '../../../support/types'; const openPublishItemTab = (id: string) => { cy.get(`#${buildPublishButtonId(id)}`).click(); }; -// eslint-disable-next-line import/prefer-default-export -export const publishItem = (): void => { - cy.get(`#${ITEM_PUBLISH_BUTTON_ID}`).click(); +const setUpAndVisitItemPage = ( + item: PackedItem | ItemForTest, + { + itemValidationGroups, + currentMember, + }: { + itemValidationGroups?: ItemValidationGroup[]; + currentMember?: Member | null; + } = {}, +) => { + cy.setUpApi({ items: [item], itemValidationGroups, currentMember }); + cy.visit(buildItemPath(item.id)); }; -describe('Public', () => { - it('Should not view publish tab', () => { - const item = PackedFolderItemFactory( - {}, - { permission: null, publicTag: {} }, - ); - cy.setUpApi({ - items: [item], - currentMember: null, - }); - cy.visit(buildItemPath(item.id)); +const getPublicationButton = (status: PublicationStatus) => + cy.get(buildDataCyWrapper(buildItemPublicationButton(status))); + +const getPublicationStatusComponent = (status: PublicationStatus) => + cy.get(buildDataCyWrapper(buildPublicationStatus(status))); + +const confirmSetItemToPublic = () => + cy.get(buildDataCyWrapper(PUBLIC_VISIBILITY_MODAL_VALIDATE_BUTTON)).click(); + +const waitOnRequest = (request: string, item: PackedItem) => { + cy.wait(request).then((data) => { + const { + request: { url }, + } = data; + expect(url.includes(item.id)); + }); +}; + +const waitOnItemValidation = (item: PackedItem) => { + waitOnRequest('@postItemValidation', item); +}; + +const waitOnPublishItem = ( + item: PackedItem, + { shouldNotify }: { shouldNotify: boolean } = { shouldNotify: false }, +) => { + cy.wait('@publishItem').then((data) => { + const { + request: { url, query }, + } = data; + expect(url.includes(item.id)); + if (shouldNotify) { + expect(`${query.notification}`).equals(`${shouldNotify}`); + } else { + expect(query.notification).equals(undefined); + } + }); +}; + +const waitOnSetItemPublic = (item: PackedItem) => { + waitOnRequest(`@postItemTag-${ItemTagType.Public}`, item); +}; + +const waitOnUnpublishItem = (item: PackedItem) => { + waitOnRequest('@unpublishItem', item); +}; + +describe('Unauthorized members should not have access to publish tab', () => { + let item: PackedItem; - // wait for page to fully load - cy.wait(PAGE_LOAD_WAITING_PAUSE); + afterEach(() => { cy.get(`#${buildPublishButtonId(item.id)}`).should('not.exist'); }); + + it('Unlogged members should not view publish tab', () => { + item = PackedFolderItemFactory({}, { permission: null, publicTag: {} }); + setUpAndVisitItemPage(item, { currentMember: null }); + }); + + it('Readers should not view publish tab', () => { + item = PackedFolderItemFactory({}, { permission: PermissionLevel.Read }); + setUpAndVisitItemPage(item, { currentMember: MEMBERS.BOB }); + }); + + it('Writers should not view publish tab', () => { + item = PackedFolderItemFactory({}, { permission: PermissionLevel.Write }); + setUpAndVisitItemPage(item, { currentMember: MEMBERS.BOB }); + }); }); -describe('Read', () => { - it('Should not view publish tab', () => { - const item = PackedFolderItemFactory( - {}, - { permission: PermissionLevel.Read }, - ); - cy.setUpApi({ - items: [item], - currentMember: MEMBERS.BOB, +describe('Private Item', () => { + const privateItem = PackedFolderItemFactory({}, { publicTag: null }); + + describe('Unpublished Item', () => { + const status = PublicationStatus.Unpublished; + + beforeEach(() => { + setUpAndVisitItemPage(privateItem); + openPublishItemTab(privateItem.id); }); - cy.visit(buildItemPath(item.id)); - // wait for page to fully load - cy.wait(PAGE_LOAD_WAITING_PAUSE); - cy.get(`#${buildPublishButtonId(item.id)}`).should('not.exist'); + it('Publication status should be Unpublished', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('Item can be validated', () => { + getPublicationButton(status).click(); // Click on validate + waitOnItemValidation(privateItem); + }); + }); + + describe('Ready to Publish Item', () => { + const status = PublicationStatus.ReadyToPublish; + const itemValidationGroup = ItemValidationGroupFactory(privateItem); + + beforeEach(() => { + setUpAndVisitItemPage(privateItem, { + itemValidationGroups: [itemValidationGroup], + }); + openPublishItemTab(privateItem.id); + }); + + it('Publication status should be Ready to publish', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('Publishing private item should warn user before changing visibility', () => { + getPublicationButton(status).click(); // click on publish + confirmSetItemToPublic(); + waitOnSetItemPublic(privateItem); + waitOnPublishItem(privateItem); + }); + }); + + describe('Visibility of published item is private again', () => { + const itemValidationGroup = ItemValidationGroupFactory(privateItem); + + beforeEach(() => { + setUpAndVisitItemPage(PublishedItemFactory(privateItem), { + itemValidationGroups: [itemValidationGroup], + }); + openPublishItemTab(privateItem.id); + }); + + it('Publication status should be Not Public', () => { + getPublicationStatusComponent(PublicationStatus.NotPublic) + .should('exist') + .should('be.visible'); + }); + + it('Should ask before change item visility to public', () => { + getPublicationButton(PublicationStatus.NotPublic).click(); // Click on change visibility + confirmSetItemToPublic(); + waitOnSetItemPublic(privateItem); + }); + }); + + describe('Item is not valid', () => { + const status = PublicationStatus.Invalid; + const itemValidationGroup = ItemValidationGroupFactory(privateItem, { + status: ItemValidationStatus.Failure, + }); + + beforeEach(() => { + setUpAndVisitItemPage(privateItem, { + itemValidationGroups: [itemValidationGroup], + }); + openPublishItemTab(privateItem.id); + }); + + it('Publication status should be Invalid', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('Item can be validated again', () => { + getPublicationButton(status).click(); // click on retry + waitOnItemValidation(privateItem); + }); }); }); describe('Public Item', () => { - it('Validate item', () => { - const items = [ - PackedFolderItemFactory({}, { publicTag: {} }), - PackedFolderItemFactory(), - ]; - cy.setUpApi({ items }); - const item = items[0]; - cy.visit(buildItemPath(item.id)); - openPublishItemTab(item.id); - - // click validate item button - cy.get(`#${ITEM_VALIDATION_BUTTON_ID}`).click(); - - cy.wait('@postItemValidation').then((data) => { - const { - request: { url }, - } = data; - expect(url.split('/')).contains(item.id); + const publicItem = PackedFolderItemFactory({}, { publicTag: {} }); + + describe('Unpublished Item', () => { + const status = PublicationStatus.Unpublished; + + beforeEach(() => { + setUpAndVisitItemPage(publicItem); + openPublishItemTab(publicItem.id); + }); + + it('Publication status should be Unpublished', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('Item can be validated', () => { + getPublicationButton(status).click(); // Click on validate + waitOnItemValidation(publicItem); }); }); -}); -describe('Published Item', () => { - const item = PUBLISHED_ITEM; - beforeEach(() => { - cy.setUpApi({ - items: [item], - itemValidationGroups: PUBLISHED_ITEM_VALIDATIONS, + describe('Validation is Pending', () => { + const status = PublicationStatus.Pending; + const itemValidationGroup = ItemValidationGroupFactory(publicItem, { + status: ItemValidationStatus.Pending, + }); + + beforeEach(() => { + setUpAndVisitItemPage(PublishedItemFactory(publicItem), { + itemValidationGroups: [itemValidationGroup], + }); + openPublishItemTab(publicItem.id); + }); + + it('Publication status should be Pending', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('No actions are available during this state', () => { + Object.values(PublicationStatus).forEach((state) => { + getPublicationButton(state).should('not.exist'); + }); + }); + }); + + describe('Ready to Publish Item', () => { + const status = PublicationStatus.ReadyToPublish; + const itemValidationGroup = ItemValidationGroupFactory(publicItem); + + beforeEach(() => { + setUpAndVisitItemPage(publicItem, { + itemValidationGroups: [itemValidationGroup], + }); + openPublishItemTab(publicItem.id); + }); + + it('Publication status should be Ready to publish', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('Publish the item without notification', () => { + getPublicationButton(status).click(); // click on publish + waitOnPublishItem(publicItem); + }); + + it('Publish the item with notification', () => { + cy.get(buildDataCyWrapper(EMAIL_NOTIFICATION_CHECKBOX)).click(); + getPublicationButton(status).click(); // click on publish + waitOnPublishItem(publicItem, { shouldNotify: true }); }); - cy.visit(buildItemPath(item.id)); - openPublishItemTab(item.id); }); - it('Show published state on button', () => { - // click validate item button - cy.get(`#${ITEM_PUBLISH_BUTTON_ID} > span`) - .children() - .children() - .should('exist'); + + describe('Outdated Item', () => { + const status = PublicationStatus.Outdated; + const itemValidationGroup = ItemValidationGroupFactory(publicItem, { + isOutDated: true, + }); + + beforeEach(() => { + setUpAndVisitItemPage(publicItem, { + itemValidationGroups: [itemValidationGroup], + }); + openPublishItemTab(publicItem.id); + }); + + it('Publication status should be Outdated', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('Item can be validated again', () => { + getPublicationButton(status).click(); // click on validate + waitOnItemValidation(publicItem); + }); }); - it('Unpublish item', () => { - cy.get(`#${ITEM_UNPUBLISH_BUTTON_ID}`).click(); - cy.wait('@unpublishItem').then(({ request: { url } }) => { - // should contain published tag id - expect(url).to.contain(item.id); + + describe('Published Item', () => { + const status = PublicationStatus.Published; + const itemValidationGroup = ItemValidationGroupFactory(publicItem); + + beforeEach(() => { + setUpAndVisitItemPage(PublishedItemFactory(publicItem), { + itemValidationGroups: [itemValidationGroup], + }); + openPublishItemTab(publicItem.id); + }); + + it('Publication status should be Published', () => { + getPublicationStatusComponent(status) + .should('exist') + .should('be.visible'); + }); + + it('Unpublish the item', () => { + getPublicationButton(status).click(); // click on unpublish + waitOnUnpublishItem(publicItem); }); }); }); - -// BUG: does not work in ci -// describe('Validated Item', () => { -// it('Publish item', () => { -// cy.setUpApi(VALIDATED_ITEM_CONTEXT); -// const item = VALIDATED_ITEM; -// cy.visit(buildItemPath(item.id)); -// openPublishItemTab(item.id); - -// // click publish item button -// cy.get(`#${ITEM_PUBLISH_BUTTON_ID}`).click(); - -// cy.wait('@publishItem').then((data) => { -// const { -// request: { url }, -// } = data; -// expect(url.includes(VALIDATED_ITEM.id)); -// expect(!url.includes('notification')); -// }); -// }); - -// it('Publish item with notification', () => { -// cy.setUpApi(VALIDATED_ITEM_CONTEXT); -// const item = VALIDATED_ITEM; -// cy.visit(buildItemPath(item.id)); -// openPublishItemTab(item.id); - -// // click validate item button -// cy.get(`#${EMAIL_NOTIFICATION_CHECKBOX}`).check(); -// cy.get(`#${ITEM_PUBLISH_BUTTON_ID}`).click(); - -// cy.wait('@publishItem').then((data) => { -// const { -// request: { url }, -// } = data; -// expect(url.includes(VALIDATED_ITEM.id)); -// expect(url.includes('notification')); -// }); -// }); -// }); diff --git a/cypress/e2e/item/publish/tags.cy.ts b/cypress/e2e/item/publish/tags.cy.ts index db25a2dcc..4fe9b08a8 100644 --- a/cypress/e2e/item/publish/tags.cy.ts +++ b/cypress/e2e/item/publish/tags.cy.ts @@ -7,15 +7,18 @@ import { import { buildItemPath } from '../../../../src/config/paths'; import { ITEM_HEADER_ID, - ITEM_TAGS_EDIT_INPUT_ID, - ITEM_TAGS_EDIT_SUBMIT_BUTTON_ID, + ITEM_TAGS_OPEN_MODAL_BUTTON_ID, + MUI_CHIP_REMOVE_BTN, + MULTI_SELECT_CHIP_ADD_BUTTON_ID, + MULTI_SELECT_CHIP_INPUT_ID, buildCustomizedTagsSelector, + buildDataCyWrapper, + buildDataTestIdWrapper, buildPublishButtonId, } from '../../../../src/config/selectors'; import { ITEM_WITH_CATEGORIES, ITEM_WITH_CATEGORIES_CONTEXT, - NEW_CUSTOMIZED_TAG, } from '../../../fixtures/categories'; import { PUBLISHED_ITEM_NO_TAGS } from '../../../fixtures/items'; import { MEMBERS, SIGNED_OUT_MEMBER } from '../../../fixtures/members'; @@ -39,8 +42,9 @@ describe('Customized Tags', () => { cy.setUpApi({ items: [item] }); cy.visit(buildItemPath(item.id)); openPublishItemTab(item.id); - cy.get(`#${buildCustomizedTagsSelector(0)}`).should('not.exist'); - cy.get(`#${ITEM_TAGS_EDIT_INPUT_ID}`).should('have.text', ''); + cy.get(buildDataCyWrapper(buildCustomizedTagsSelector(0))).should( + 'not.exist', + ); }); it('Display tags', () => { @@ -48,25 +52,47 @@ describe('Customized Tags', () => { visitItemPage(item); expect(item.settings.tags).to.have.lengthOf.above(0); item.settings.tags!.forEach((tag, index) => { - const displayTags = cy.get(`#${buildCustomizedTagsSelector(index)}`); + const displayTags = cy.get( + buildDataCyWrapper(buildCustomizedTagsSelector(index)), + ); displayTags.contains(tag); }); }); - it('Edit tags', () => { + it('Remove tag', () => { const item = ITEM_WITH_CATEGORIES; + const removeIdx = 0; + const removedTag = item.settings.tags[removeIdx]; + visitItemPage(item); - cy.get(`#${ITEM_TAGS_EDIT_INPUT_ID}`) - .clear() - .type(NEW_CUSTOMIZED_TAG) - .should('have.text', NEW_CUSTOMIZED_TAG); - cy.get(`#${ITEM_TAGS_EDIT_SUBMIT_BUTTON_ID}`).click(); + cy.get(buildDataCyWrapper(buildCustomizedTagsSelector(removeIdx))) + .find(buildDataTestIdWrapper(MUI_CHIP_REMOVE_BTN)) + .click(); + + cy.wait('@editItem', { timeout: EDIT_TAG_REQUEST_TIMEOUT }).then((data) => { + const { + request: { url, body }, + } = data; + expect(url.split('/')).contains(item.id); + expect(body.settings.tags).not.contains(removedTag); + }); + }); + + it('Add tag', () => { + const item = ITEM_WITH_CATEGORIES; + const newTag = 'My new tag'; + + visitItemPage(item); + cy.get(buildDataCyWrapper(ITEM_TAGS_OPEN_MODAL_BUTTON_ID)).click(); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_INPUT_ID)).type(newTag); + cy.get(buildDataCyWrapper(MULTI_SELECT_CHIP_ADD_BUTTON_ID)).click(); + cy.wait('@editItem', { timeout: EDIT_TAG_REQUEST_TIMEOUT }).then((data) => { const { request: { url, body }, } = data; expect(url.split('/')).contains(item.id); - expect(body.settings.tags).contains(NEW_CUSTOMIZED_TAG); + expect(body.settings.tags).contains(newTag); }); }); }); @@ -105,7 +131,6 @@ describe('Tags permissions', () => { cy.get(`#${ITEM_HEADER_ID}`).should('be.visible'); // signed out user can not see the publish menu cy.get(`#${buildPublishButtonId(item.id)}`).should('not.exist'); - cy.get(`#${ITEM_TAGS_EDIT_INPUT_ID}`).should('not.exist'); }); it('Read-only user cannot edit tags', () => { diff --git a/cypress/fixtures/categories.ts b/cypress/fixtures/categories.ts index af5824aec..b3ad1c972 100644 --- a/cypress/fixtures/categories.ts +++ b/cypress/fixtures/categories.ts @@ -27,6 +27,11 @@ export const SAMPLE_CATEGORIES: Category[] = [ name: 'language-1', type: CategoryType.Language, }, + { + id: 'af7f7e3d-dc75-4070-b892-381fbf4759d5', + name: 'language-2', + type: CategoryType.Language, + }, ]; export const SAMPLE_ITEM_CATEGORIES: ItemCategory[] = [ @@ -39,9 +44,17 @@ export const SAMPLE_ITEM_CATEGORIES: ItemCategory[] = [ }, ]; -export const CUSTOMIZED_TAGS = ['water', 'ice', 'temperature']; +export const SAMPLE_ITEM_LANGUAGE: ItemCategory[] = [ + { + id: 'e75e1950-c5b4-4e21-95a2-c7c3bfa4072b', + item: PUBLISHED_ITEM, + category: SAMPLE_CATEGORIES[2], + createdAt: '2021-08-11T12:56:36.834Z', + creator: MEMBERS.ANNA, + }, +]; -export const NEW_CUSTOMIZED_TAG = 'newTag'; +export const CUSTOMIZED_TAGS = ['water', 'ice', 'temperature']; export const ITEM_WITH_CATEGORIES: ItemForTest = { ...PUBLISHED_ITEM, @@ -53,6 +66,16 @@ export const ITEM_WITH_CATEGORIES: ItemForTest = { categories: SAMPLE_ITEM_CATEGORIES, }; +export const ITEM_WITH_LANGUAGE: ItemForTest = { + ...PUBLISHED_ITEM, + settings: { + tags: CUSTOMIZED_TAGS, + displayCoEditors: true, + }, + // for tests + categories: SAMPLE_ITEM_LANGUAGE, +}; + export const ITEM_WITH_CATEGORIES_CONTEXT = { items: [ITEM_WITH_CATEGORIES], itemValidationGroups: [ diff --git a/cypress/fixtures/items.ts b/cypress/fixtures/items.ts index 1d5920f85..1d30da986 100644 --- a/cypress/fixtures/items.ts +++ b/cypress/fixtures/items.ts @@ -1,7 +1,9 @@ import { + DiscriminatedItem, ItemTagType, ItemType, ItemValidation, + ItemValidationGroup, ItemValidationProcess, ItemValidationStatus, PackedFolderItemFactory, @@ -19,8 +21,10 @@ export const DEFAULT_FOLDER_ITEM = PackedFolderItemFactory({ }); export const generateOwnItems = (number: number): ItemForTest[] => { - const id = (i: number) => - `cafebabe-dead-beef-1234-${`${i}`.padStart(12, '0')}`; + const id = (i: number) => { + const paddedI = `${i}`.padStart(12, '0'); + return `cafebabe-dead-beef-1234-${paddedI}`; + }; const path = (i: number) => id(i).replace(/-/g, '_'); return Array(number) @@ -33,7 +37,8 @@ export const generateOwnItems = (number: number): ItemForTest[] => { path: path(i), }; - const mId = `dafebabe-dead-beef-1234-${`${i}`.padStart(12, '0')}`; + const paddedI = `${i}`.padStart(12, '0'); + const mId = `dafebabe-dead-beef-1234-${paddedI}`; return { ...item, memberships: [ @@ -227,12 +232,15 @@ export const SAMPLE_PUBLIC_ITEMS: ApiConfig = { ], }; +const YESTERDAY_DATE = new Date(Date.now() - 24 * 60 * 60 * 1000); + // warning: admin permission on item const item = PackedFolderItemFactory( { id: 'ecafbd2a-5688-11eb-ae93-0242ac130002', name: 'parent public item', path: 'ecafbd2a_5688_11eb_ae93_0242ac130002', + updatedAt: YESTERDAY_DATE.toISOString(), }, { permission: PermissionLevel.Admin, @@ -240,6 +248,19 @@ const item = PackedFolderItemFactory( }, ); +export const PublishedItemFactory = ( + itemToPublish: PackedItem, +): ItemForTest => ({ + ...itemToPublish, + published: { + id: 'ecbfbd2a-5688-12eb-ae93-0242ac130002', + item, + createdAt: new Date().toISOString(), + creator: itemToPublish.creator, + totalViews: 0, + }, +}); + export const PUBLISHED_ITEM: ItemForTest = { ...item, tags: [ @@ -254,7 +275,7 @@ export const PUBLISHED_ITEM: ItemForTest = { published: { id: 'ecbfbd2a-5688-12eb-ae93-0242ac130002', item, - createdAt: '2021-08-11T12:56:36.834Z', + createdAt: new Date().toISOString(), creator: MEMBERS.ANNA, totalViews: 0, }, @@ -286,11 +307,48 @@ export const PUBLISHED_ITEM_NO_TAGS: ItemForTest = { tags: undefined, }, }; + +export const ItemValidationGroupFactory = ( + validatedItem: DiscriminatedItem, + { + status = ItemValidationStatus.Success, + isOutDated = false, + }: { + status?: ItemValidationStatus; + isOutDated?: boolean; + } = { status: ItemValidationStatus.Success, isOutDated: false }, +): ItemValidationGroup => { + const itemUpdateDate = new Date(validatedItem.updatedAt); + const tmp = isOutDated ? -1 : +1; + const validationDate = new Date(itemUpdateDate); + validationDate.setDate(validationDate.getDate() + tmp); + + const ivFactory = (id: string, process: ItemValidationProcess) => ({ + id, + item: validatedItem, + process, + status, + result: '', + updatedAt: validationDate, + createdAt: validationDate, + }); + + return { + id: '65c57d69-0e59-4569-a422-f330c31c995c', + item: validatedItem, + createdAt: validationDate.toISOString(), + itemValidations: [ + ivFactory('id1', ItemValidationProcess.BadWordsDetection), + ivFactory('id2', ItemValidationProcess.ImageChecking), + ] as unknown as ItemValidation[], + }; +}; + export const PUBLISHED_ITEM_VALIDATIONS = [ { id: '65c57d69-0e59-4569-a422-f330c31c995c', item: PUBLISHED_ITEM, - createdAt: '2021-08-11T12:56:36.834Z', + createdAt: new Date().toISOString(), itemValidations: [ { id: 'id1', @@ -299,8 +357,8 @@ export const PUBLISHED_ITEM_VALIDATIONS = [ process: ItemValidationProcess.BadWordsDetection, status: ItemValidationStatus.Success, result: '', - updatedAt: new Date('2021-04-13 14:56:34.749946'), - createdAt: new Date('2021-04-13 14:56:34.749946'), + updatedAt: new Date(), + createdAt: new Date(), }, { id: 'id2', @@ -309,8 +367,8 @@ export const PUBLISHED_ITEM_VALIDATIONS = [ process: ItemValidationProcess.ImageChecking, status: ItemValidationStatus.Success, result: '', - updatedAt: new Date('2021-04-13 14:56:34.749946'), - createdAt: new Date('2021-04-13 14:56:34.749946'), + updatedAt: new Date(), + createdAt: new Date(), }, // todo: fix this issue with circular types ] as unknown as ItemValidation[], diff --git a/cypress/fixtures/thumbnails/links.ts b/cypress/fixtures/thumbnails/links.ts index 11fd6a0a1..7e6e85703 100644 --- a/cypress/fixtures/thumbnails/links.ts +++ b/cypress/fixtures/thumbnails/links.ts @@ -2,3 +2,4 @@ export const ITEM_THUMBNAIL_LINK = 'https://picsum.photos/200/200'; export const AVATAR_LINK = 'https://picsum.photos/200/200'; export const THUMBNAIL_MEDIUM_PATH = 'cypress/fixtures/thumbnails/medium.jpeg'; +export const THUMBNAIL_SMALL_PATH = 'cypress/fixtures/thumbnails/small.jpeg'; diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html new file mode 100644 index 000000000..3b68097f6 --- /dev/null +++ b/cypress/support/component-index.html @@ -0,0 +1,12 @@ + + + + + + + Components App + + +
+ + \ No newline at end of file diff --git a/cypress/support/component.ts b/cypress/support/component.ts new file mode 100644 index 000000000..e0516b66d --- /dev/null +++ b/cypress/support/component.ts @@ -0,0 +1,38 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +// Import commands.js using ES2015 syntax: +// Alternatively you can use CommonJS syntax: +// require('./commands') +import { mount } from 'cypress/react18'; + +import './commands'; + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + +Cypress.Commands.add('mount', mount); + +// Example use: +// cy.mount() diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index fbb068f25..82fdf9402 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -23,4 +23,3 @@ export const ROW_HEIGHT = 48; export const TABLE_MEMBERSHIP_RENDER_TIME = 1000; export const FIXTURES_THUMBNAILS_FOLDER = './thumbnails'; export const CHATBOX_LOADING_TIME = 5000; -export const PUBLISH_TAB_LOADING_TIME = 3000; diff --git a/cypress/support/server.ts b/cypress/support/server.ts index 294fe7d40..941162bf3 100644 --- a/cypress/support/server.ts +++ b/cypress/support/server.ts @@ -42,7 +42,12 @@ import { CURRENT_USER, MEMBERS } from '../fixtures/members'; import { AVATAR_LINK, ITEM_THUMBNAIL_LINK } from '../fixtures/thumbnails/links'; import { SIGN_IN_PATH } from './paths'; import { ItemForTest, MemberForTest } from './types'; -import { ID_FORMAT, SHORTLINK_FORMAT, parseStringToRegExp } from './utils'; +import { + ID_FORMAT, + SHORTLINK_FORMAT, + extractItemIdOrThrow, + parseStringToRegExp, +} from './utils'; const { buildGetItemPublishedInformationRoute, @@ -1880,27 +1885,41 @@ export const mockUploadInvitationCSV = ( }; export const mockPublishItem = (items: ItemForTest[]): void => { + const interceptingPathFormat = buildItemPublishRoute(ID_FORMAT); cy.intercept( { method: HttpMethod.Post, - url: new RegExp(`${API_HOST}/${buildItemPublishRoute(ID_FORMAT)}`), + url: new RegExp(`${API_HOST}/${interceptingPathFormat}`), }, ({ reply, url }) => { - const itemId = url.slice(API_HOST.length).split('/')[2]; - reply(items.find((item) => item?.id === itemId)); + const itemId = extractItemIdOrThrow(interceptingPathFormat, new URL(url)); + const searchItem = items.find((item) => item?.id === itemId); + + if (!searchItem) { + return reply({ statusCode: StatusCodes.NOT_FOUND }); + } + + return reply(searchItem); }, ).as('publishItem'); }; export const mockUnpublishItem = (items: ItemForTest[]): void => { + const interceptingPathFormat = buildItemUnpublishRoute(ID_FORMAT); cy.intercept( { method: HttpMethod.Delete, - url: new RegExp(`${API_HOST}/${buildItemUnpublishRoute(ID_FORMAT)}`), + url: new RegExp(`${API_HOST}/${interceptingPathFormat}`), }, ({ reply, url }) => { - const itemId = url.slice(API_HOST.length).split('/')[3]; - reply(items.find((item) => item?.id === itemId)); + const itemId = extractItemIdOrThrow(interceptingPathFormat, new URL(url)); + const searchItem = items.find((item) => item?.id === itemId); + + if (!searchItem) { + return reply({ statusCode: StatusCodes.NOT_FOUND }); + } + + return reply(searchItem); }, ).as('unpublishItem'); }; diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index eec54d2fe..65c7a9ca0 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -1,3 +1,5 @@ +import { validate as uuidValidate, version as uuidVersion } from 'uuid'; + // use simple id format for tests export const ID_FORMAT = '(?=.*[0-9])(?=.*[a-zA-Z])([a-z0-9-]+)'; export const SHORTLINK_FORMAT = '[a-zA-Z0-9-]+'; @@ -32,3 +34,49 @@ export const parseStringToRegExp = ( }; export const EMAIL_FORMAT = '[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+'; + +export const uuidValidateV4 = (uuid: string | null | undefined): boolean => { + if (!uuid) { + return false; + } + return uuidValidate(uuid) && uuidVersion(uuid) === 4; +}; + +/** + * Try to extract the item id from a given url. + * If it fails, an error will be thrown. + * + * @param interceptedPathFormat The path's format of the intercepted url. + * @param url The complete url from the interceptor. + * @returns The item id if found. + * @throws If the item is not found. + */ +export const extractItemIdOrThrow = ( + interceptedPathFormat: string, + url: URL, +): string => { + const { protocol, host, pathname: urlPath } = url; + const filterOutEmptyString = ( + value: string, + _index: number, + _array: string[], + ) => value !== ''; + + const hostAndProtocol = `${protocol}//${host}`; + const interceptedParts = `${hostAndProtocol}/${interceptedPathFormat}` + .slice(hostAndProtocol.length) + .split('/') + .filter(filterOutEmptyString); + + const positionOfId = interceptedParts.indexOf(ID_FORMAT); + const urlParts = urlPath.split('/').filter(filterOutEmptyString); + const itemId = urlParts[positionOfId]; + + if (!uuidValidateV4(itemId)) { + throw new Error( + 'MockServer error: The item id was not extracted correctly from the url!', + ); + } + + return itemId; +}; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 6b07d54d8..e2f3b1a0b 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -8,6 +8,6 @@ "strict": true, "sourceMap": false }, - "include": ["**/*.ts", "cypress.d.ts"], + "include": ["**/*.ts", "**/*.tsx", "cypress.d.ts"], "exclude": ["coverage", ".nyc_output"] } diff --git a/package.json b/package.json index 27b5b0b39..4507830b7 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@emotion/styled": "11.11.5", "@graasp/chatbox": "3.1.0", "@graasp/map": "1.15.0", - "@graasp/query-client": "3.9.0", + "@graasp/query-client": "3.11.0", "@graasp/sdk": "4.12.0", "@graasp/translations": "1.28.0", "@graasp/ui": "4.19.2", @@ -93,6 +93,7 @@ "cypress:open": "env-cmd -f ./.env.test cypress open --browser chrome", "test": "yarn test:unit && yarn build:test && concurrently -k -s first \"yarn preview:test\" \"yarn cypress:run\"", "cypress:run": "env-cmd -f ./.env.test cypress run --browser chrome", + "cypress:run:component": "env-cmd -f ./.env.test cypress run --component --browser chrome", "postinstall": "husky install", "test:unit": "yarn vitest" }, diff --git a/src/components/item/publish/ConfirmLicenseDialogContent.tsx b/src/components/common/ConfirmLicenseDialogContent.tsx similarity index 100% rename from src/components/item/publish/ConfirmLicenseDialogContent.tsx rename to src/components/common/ConfirmLicenseDialogContent.tsx diff --git a/src/components/common/ContentLoader.tsx b/src/components/common/ContentLoader.tsx new file mode 100644 index 000000000..818141f80 --- /dev/null +++ b/src/components/common/ContentLoader.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from '@mui/material'; + +import { SomeBreakPoints } from '@/types/breakpoint'; + +const DEFAULT_WIDTH = '100%'; + +type Size = string | number; +type SizeOrBreakPoints = SomeBreakPoints | Size; + +type Props = { + children: JSX.Element | JSX.Element[]; + isLoading: boolean; + width?: SizeOrBreakPoints; + maxWidth?: SizeOrBreakPoints; + height?: SizeOrBreakPoints; +}; + +export const ContentLoader = ({ + children, + isLoading, + width = DEFAULT_WIDTH, + maxWidth = DEFAULT_WIDTH, + height, +}: Props): JSX.Element | JSX.Element[] => { + if (isLoading) { + return ( + + {children} + + ); + } + return children; +}; + +export default ContentLoader; diff --git a/src/components/common/SyncIcon.tsx b/src/components/common/SyncIcon.tsx new file mode 100644 index 000000000..c6397befe --- /dev/null +++ b/src/components/common/SyncIcon.tsx @@ -0,0 +1,59 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import CloudSyncIcon from '@mui/icons-material/CloudSync'; +import ErrorIcon from '@mui/icons-material/Error'; +import { Chip } from '@mui/material'; + +import { theme } from '@graasp/ui'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { BUILDER } from '@/langs/constants'; + +import { SyncStatus } from '../context/DataSyncContext'; + +type Props = { + syncStatus: SyncStatus; +}; + +const ICON_COLOR = '#BBB'; + +const SyncIcon = ({ syncStatus }: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + + const buildStatus = () => { + switch (syncStatus) { + case SyncStatus.SYNCHRONIZING: + return { + color: 'default' as const, + icon: , + text: t(BUILDER.ITEM_STATUS_SYNCHRONIZING), + }; + case SyncStatus.ERROR: + return { + color: 'error' as const, + icon: , + text: t(BUILDER.ITEM_STATUS_ERROR), + }; + default: { + return { + color: 'success' as const, + icon: , + text: t(BUILDER.ITEM_STATUS_SYNCHRONIZED), + }; + } + } + }; + + const { icon, text, color } = buildStatus(); + + return ( + + ); +}; + +export default SyncIcon; diff --git a/src/components/context/DataSyncContext.tsx b/src/components/context/DataSyncContext.tsx new file mode 100644 index 000000000..ba3ad10d7 --- /dev/null +++ b/src/components/context/DataSyncContext.tsx @@ -0,0 +1,131 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +export enum SyncStatus { + NO_CHANGES = 'noChanges', + SYNCHRONIZED = 'synchronized', + ERROR = 'error', + SYNCHRONIZING = 'synchronizing', +} + +type ComputeStatusParam = { + isLoading: boolean; + isSuccess: boolean; + isError: boolean; +}; + +type DataSyncContextType = { + status: SyncStatus; + computeStatusFor: ( + requestKey: string, + requestStatus: ComputeStatusParam, + ) => void; +}; + +const DataSyncContext = createContext({ + status: SyncStatus.NO_CHANGES, + computeStatusFor: (_requestKey, _requestStatus) => { + console.error( + 'No Provider found for "DataSyncContext". Check that this Provider is accessible.', + ); + }, +}); + +type Props = { + children: JSX.Element | JSX.Element[]; +}; + +const containsStatus = (allStatus: SyncStatus[], status: SyncStatus) => + allStatus.some((s) => s === status); + +/** + * DataSyncContext manages a set of queries states and compute an aggregated final state. + * + * This can be useful when we want to display to the user the current state + * of a synchronization. This can be one or multiple mutations states for example. + * + */ +export const DataSyncContextProvider = ({ children }: Props): JSX.Element => { + const [status, setStatus] = useState(SyncStatus.NO_CHANGES); + const [mapStatus, setMapStatus] = useState>( + new Map(), + ); + + const updateStatus = (newMapStatus: Map) => { + const allStatus = Array.from(newMapStatus.values()); + + switch (true) { + case containsStatus(allStatus, SyncStatus.ERROR): { + setStatus(SyncStatus.ERROR); + return; + } + case containsStatus(allStatus, SyncStatus.SYNCHRONIZING): { + setStatus(SyncStatus.SYNCHRONIZING); + return; + } + case containsStatus(allStatus, SyncStatus.SYNCHRONIZED): { + setStatus(SyncStatus.SYNCHRONIZED); + return; + } + default: + setStatus(SyncStatus.NO_CHANGES); + } + }; + + useEffect(() => updateStatus(mapStatus), [mapStatus]); + + /** + * Compute the current status of the given request. + * The three status parameters are considered in the order: Error, Loading and then Success. + * + * @param requestKey The key of the request. + * @param isLoading Indicates if the current request is loading. + * @param isSuccess Indicates if the current request has succeeded. + * @param isError Indicates if the current request has failed. + * + */ + const computeStatusFor = useCallback( + ( + requestKey: string, + { isLoading, isSuccess, isError }: ComputeStatusParam, + ) => { + let statusSync: SyncStatus | undefined; + + if (isSuccess) { + statusSync = SyncStatus.SYNCHRONIZED; + } else if (isLoading) { + statusSync = SyncStatus.SYNCHRONIZING; + } else if (isError) { + statusSync = SyncStatus.ERROR; + } + + if (statusSync) { + setMapStatus((curr) => new Map([...curr, [requestKey, statusSync]])); + } + }, + [], + ); + + const value = useMemo( + () => ({ + status, + computeStatusFor, + }), + [status, computeStatusFor], + ); + + return ( + + {children} + + ); +}; + +export const useDataSyncContext = (): DataSyncContextType => + useContext(DataSyncContext); diff --git a/src/components/hooks/useItemCategories.tsx b/src/components/hooks/useItemCategories.tsx new file mode 100644 index 000000000..35ddf099a --- /dev/null +++ b/src/components/hooks/useItemCategories.tsx @@ -0,0 +1,89 @@ +import { Category } from '@graasp/sdk'; + +import { hooks, mutations } from '@/config/queryClient'; +import { Filter } from '@/types/array'; + +const { useItemCategories: useCategories } = hooks; +const { usePostItemCategory, useDeleteItemCategory } = mutations; + +type Props = { + itemId: string; + filterCategories?: Filter; +}; + +type UseItemCategories = { + isLoading: boolean; + isMutationLoading: boolean; + isMutationError: boolean; + isMutationSuccess: boolean; + categories?: string[]; + addCategory: (categoryId: string) => void; + deleteCategory: (categoryId: string) => void; + deleteCategoryByName: (name: string) => void; +}; + +export const useItemCategories = ({ + itemId, + filterCategories = () => true, +}: Props): UseItemCategories => { + const { data: itemCategories, isLoading } = useCategories(itemId); + + const filteredCategories = itemCategories?.filter(({ category }) => + filterCategories(category), + ); + + const categories = filteredCategories?.map(({ category }) => category.name); + + const { + mutate: createItemCategory, + isLoading: isPostLoading, + isSuccess: isPostSuccess, + isError: isPostError, + } = usePostItemCategory(); + const { + mutate: deleteItemCategory, + isLoading: isDeleteLoading, + isSuccess: isDeleteSuccess, + isError: isDeleteError, + } = useDeleteItemCategory(); + + const isMutationLoading = isPostLoading || isDeleteLoading; + const isMutationSuccess = isPostSuccess || isDeleteSuccess; + const isMutationError = isPostError || isDeleteError; + + const deleteCategory = (itemCategoryId: string) => + deleteItemCategory({ + itemId, + itemCategoryId, + }); + + const deleteCategoryByName = (categoryName: string) => { + const removedItemCategory = filteredCategories?.find( + ({ category }) => + category.name.toLowerCase() === categoryName.toLowerCase(), + ); + + if (!removedItemCategory) { + console.error('The given category was not found !', categoryName); + return; + } + + deleteCategory(removedItemCategory.id); + }; + + const addCategory = (categoryId: string) => + createItemCategory({ itemId, categoryId }); + + return { + isLoading, + isMutationError, + isMutationLoading, + isMutationSuccess, + categories, + addCategory, + deleteCategory, + deleteCategoryByName, + }; +}; + +export default useItemCategories; diff --git a/src/components/item/publish/useItemLicense.tsx b/src/components/hooks/useItemLicense.tsx similarity index 71% rename from src/components/item/publish/useItemLicense.tsx rename to src/components/hooks/useItemLicense.tsx index 1eef41ba0..d1b12d3d9 100644 --- a/src/components/item/publish/useItemLicense.tsx +++ b/src/components/hooks/useItemLicense.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { SxProps } from '@mui/material'; @@ -8,37 +8,56 @@ import { CCSharingVariant, CreativeCommons } from '@graasp/ui'; import { mutations } from '@/config/queryClient'; import { convertLicense, convertSelectionToLicense } from '@/utils/itemLicense'; -import LicenseForm from './LicenseForm'; -import { CCLicenseChoice, CCSharingLicenseChoice } from './type'; +import LicenseForm from '../item/publish/LicenseForm'; +import { CCLicenseChoice, CCSharingLicenseChoice } from '../item/publish/type'; + +const { useEditItem } = mutations; const licensePreviewStyle = { border: '1px solid #eee', borderRadius: 2, - minWidth: 300, + width: '100%', + display: 'flex', + alignItems: 'center', +}; + +export type UseItemLicense = { + handleSubmit: () => void; + removeLicense: () => void; + licenseForm: JSX.Element; + creativeCommons: JSX.Element; + requireAttributionValue: CCLicenseChoice; + isLoading: boolean; + isSuccess: boolean; + isError: boolean; }; const useItemLicense = ({ item, + iconSize, disabled, commonsSx, + enableNotifications = true, }: { item: DiscriminatedItem; + iconSize?: number; disabled?: boolean; commonsSx?: SxProps; -}): { - handleSubmit: () => void; - licenseForm: JSX.Element; - creativeCommons: JSX.Element; - requireAttributionValue: CCLicenseChoice; -} => { + enableNotifications?: boolean; +}): UseItemLicense => { const [requireAttributionValue, setRequireAttributionValue] = - useState(''); + useState('no'); const [allowCommercialValue, setAllowCommercialValue] = - useState(''); + useState('yes'); const [allowSharingValue, setAllowSharingValue] = - useState(''); + useState('yes'); - const { mutate: updateCCLicense } = mutations.useEditItem(); + const { + mutate: updateItem, + isLoading, + isError, + isSuccess, + } = useEditItem({ enableNotifications }); const { id, settings } = item; @@ -54,7 +73,7 @@ const useItemLicense = ({ const handleSubmit = () => { if (requireAttributionValue) { - updateCCLicense({ + updateItem({ id, settings: { ccLicenseAdaption: convertSelectionToLicense({ @@ -69,6 +88,9 @@ const useItemLicense = ({ } }; + const removeLicense = () => + updateItem({ id, settings: { ccLicenseAdaption: null } }); + const licenseForm = ( ); return { handleSubmit, + removeLicense, licenseForm, creativeCommons, requireAttributionValue, + isError, + isLoading, + isSuccess, }; }; diff --git a/src/components/hooks/useModalStatus.tsx b/src/components/hooks/useModalStatus.tsx new file mode 100644 index 000000000..e54aacb2f --- /dev/null +++ b/src/components/hooks/useModalStatus.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; + +type Props = { + isInitiallyOpen?: boolean; +}; + +export type UseModalStatus = { + isOpen: boolean; + openModal: () => void; + closeModal: () => void; +}; + +export const useModalStatus = ({ + isInitiallyOpen = false, +}: Props = {}): UseModalStatus => { + const [isOpen, setIsOpen] = useState(isInitiallyOpen); + + const openModal = () => setIsOpen(true); + const closeModal = () => setIsOpen(false); + + return { + isOpen, + openModal, + closeModal, + }; +}; + +export default useModalStatus; diff --git a/src/components/hooks/usePublicationStatus.tsx b/src/components/hooks/usePublicationStatus.tsx new file mode 100644 index 000000000..0ede81896 --- /dev/null +++ b/src/components/hooks/usePublicationStatus.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; + +import { + ItemPublished, + ItemValidation, + ItemValidationGroup, + ItemValidationStatus, + PackedItem, +} from '@graasp/sdk'; + +import groupBy from 'lodash.groupby'; + +import { hooks } from '@/config/queryClient'; +import { PublicationStatus } from '@/types/publication'; + +const { useLastItemValidationGroup, useItemPublishedInformation } = hooks; + +const containValidationStatus = ( + mapByStatus: { [status: string]: ItemValidation[] }, + status: ItemValidationStatus, +) => (mapByStatus[status] ?? []).length > 0; + +const isValidationOutdated = ({ + item, + validationGroup, +}: { + item: PackedItem; + validationGroup?: ItemValidationGroup; +}) => validationGroup && validationGroup.createdAt <= item.updatedAt; + +const computeValidationSuccess = ({ + publishedEntry, +}: { + publishedEntry?: ItemPublished; +}) => { + if (publishedEntry) { + return PublicationStatus.Published; + } + + return PublicationStatus.ReadyToPublish; +}; + +const isUnpublished = (validationGroup?: ItemValidationGroup) => + !validationGroup; + +const isNotPublic = ({ + item, + publishedEntry, +}: { + item: PackedItem; + publishedEntry?: ItemPublished; +}) => !item.public && Boolean(publishedEntry); + +const isPublishedChildren = ({ + item, + publishedEntry, +}: { + item: PackedItem; + publishedEntry?: ItemPublished; +}) => Boolean(publishedEntry) && publishedEntry?.item?.path !== item?.path; + +type Props = { item: PackedItem }; +type UsePublicationStatus = { + status: PublicationStatus; + isLoading: boolean; + isinitialLoading: boolean; +}; + +const computePublicationStatus = ({ + item, + publishedEntry, + validationGroup, +}: { + item: PackedItem; + publishedEntry?: ItemPublished; + validationGroup?: ItemValidationGroup; +}): PublicationStatus => { + const mapByStatus = groupBy( + validationGroup?.itemValidations, + ({ status }) => status, + ); + + switch (true) { + case isPublishedChildren({ item, publishedEntry }): + return PublicationStatus.PublishedChildren; + case isUnpublished(validationGroup): + return PublicationStatus.Unpublished; + case isValidationOutdated({ item, validationGroup }): + return PublicationStatus.Outdated; + case containValidationStatus(mapByStatus, ItemValidationStatus.Failure): + return PublicationStatus.Invalid; + case containValidationStatus(mapByStatus, ItemValidationStatus.Pending): + return PublicationStatus.Pending; + case isNotPublic({ item, publishedEntry }): + return PublicationStatus.NotPublic; + case containValidationStatus(mapByStatus, ItemValidationStatus.Success): + return computeValidationSuccess({ publishedEntry }); + default: + return PublicationStatus.Invalid; + } +}; + +export const usePublicationStatus = ({ item }: Props): UsePublicationStatus => { + const { data: itemPublishedEntry, isLoading: isPublishedLoading } = + useItemPublishedInformation({ + itemId: item.id, + }); + const { data: lastItemValidationGroup, isLoading: isValidationLoading } = + useLastItemValidationGroup(item.id); + const [status, setStatus] = useState(); + const isLoading = isPublishedLoading || isValidationLoading; + const isinitialLoading = isLoading && !status; + + useEffect(() => { + if (!isLoading) { + setStatus( + computePublicationStatus({ + item, + publishedEntry: itemPublishedEntry ?? undefined, + validationGroup: lastItemValidationGroup, + }), + ); + } + }, [itemPublishedEntry, lastItemValidationGroup, item, isLoading]); + + return { + status: status ?? PublicationStatus.Unpublished, + isLoading, + isinitialLoading, + }; +}; + +export default usePublicationStatus; diff --git a/src/components/hooks/useVisibility.tsx b/src/components/hooks/useVisibility.tsx new file mode 100644 index 000000000..8331d4e85 --- /dev/null +++ b/src/components/hooks/useVisibility.tsx @@ -0,0 +1,175 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { + ItemLoginSchema, + ItemLoginSchemaType, + ItemPublished, + ItemTag, + ItemTagType, + PackedItem, +} from '@graasp/sdk'; + +import { SETTINGS } from '@/config/constants'; +import { hooks, mutations } from '@/config/queryClient'; + +const { useItemLoginSchema, useItemPublishedInformation } = hooks; +const { + useDeleteItemTag, + usePostItemTag, + useUnpublishItem, + useDeleteItemLoginSchema, + usePutItemLoginSchema, +} = mutations; + +type UseVisibility = { + isLoading: boolean; + isError: boolean; + isDisabled: boolean; + itemPublishEntry: ItemPublished | null | undefined; + itemLoginSchema: ItemLoginSchema | undefined; + publicTag: ItemTag | undefined; + visibility: string | undefined; + updateVisibility: (newTag: string) => Promise; +}; + +export const useVisibility = (item: PackedItem): UseVisibility => { + const { mutateAsync: postItemTag } = usePostItemTag(); + const { mutate: deleteItemTag } = useDeleteItemTag(); + + // get item published + const { data: itemPublishEntry, isLoading: isItemPublishEntryLoading } = + useItemPublishedInformation( + { itemId: item.id }, + { enabled: Boolean(item.public) }, + ); + const { mutate: unpublish } = useUnpublishItem(); + + // item login tag and item extra value + const { data: itemLoginSchema, isLoading: isItemLoginLoading } = + useItemLoginSchema({ itemId: item.id }); + const { mutate: deleteItemLoginSchema } = useDeleteItemLoginSchema(); + const { mutate: putItemLoginSchema } = usePutItemLoginSchema(); + + // is disabled + const [isDisabled, setIsDisabled] = useState(false); + useEffect(() => { + // disable setting if any visiblity is set on any parent items + setIsDisabled( + Boolean( + (itemLoginSchema && itemLoginSchema?.item?.path !== item?.path) || + (item?.public && item?.public?.item?.path !== item?.path), + ), + ); + }, [itemLoginSchema, item]); + + // is loading + const [isLoading, setIsLoading] = useState(false); + useEffect(() => { + setIsLoading(isItemPublishEntryLoading || isItemLoginLoading); + }, [isItemPublishEntryLoading, isItemLoginLoading]); + + // is error + const [isError] = useState(false); + + // visibility + const [visibility, setVisibility] = useState(); + useEffect(() => { + switch (true) { + case Boolean(item.public): { + setVisibility(SETTINGS.ITEM_PUBLIC.name); + break; + } + case Boolean(itemLoginSchema?.id): { + setVisibility(SETTINGS.ITEM_LOGIN.name); + break; + } + default: + setVisibility(SETTINGS.ITEM_PRIVATE.name); + } + }, [itemPublishEntry, item, itemLoginSchema]); + + const updateVisibility = useMemo( + () => async (newTag: string) => { + // deletes both public and published tags if they exists + const deletePublishedAndPublic = () => { + if (itemPublishEntry) { + unpublish({ id: item.id }); + } + + if (item.public) { + deleteItemTag({ itemId: item.id, type: ItemTagType.Public }); + } + }; + + const deleteLoginSchema = () => { + if (itemLoginSchema) { + deleteItemLoginSchema({ + itemId: item.id, + }); + } + }; + + switch (newTag) { + case SETTINGS.ITEM_PRIVATE.name: { + deletePublishedAndPublic(); + deleteLoginSchema(); + break; + } + case SETTINGS.ITEM_LOGIN.name: { + deletePublishedAndPublic(); + putItemLoginSchema({ + itemId: item.id, + type: ItemLoginSchemaType.Username, + }); + break; + } + case SETTINGS.ITEM_PUBLIC.name: { + await postItemTag({ + itemId: item.id, + type: ItemTagType.Public, + }); + deleteLoginSchema(); + break; + } + default: + break; + } + }, + [ + deleteItemLoginSchema, + deleteItemTag, + item.id, + itemLoginSchema, + itemPublishEntry, + postItemTag, + item.public, + putItemLoginSchema, + unpublish, + ], + ); + + return useMemo( + () => ({ + isLoading, + isError, + isDisabled, + itemPublishEntry, + itemLoginSchema, + publicTag: item.public, + visibility, + updateVisibility, + }), + [ + isLoading, + isError, + isDisabled, + itemPublishEntry, + itemLoginSchema, + item.public, + visibility, + updateVisibility, + ], + ); +}; + +export default useVisibility; diff --git a/src/components/input/DebouncedTextEditor.tsx b/src/components/input/DebouncedTextEditor.tsx new file mode 100644 index 000000000..05b931cad --- /dev/null +++ b/src/components/input/DebouncedTextEditor.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; + +import TextEditor from '@graasp/ui/text-editor'; + +import { hooks } from '@/config/queryClient'; +import { stripHtml } from '@/utils/item'; + +const { useDebounce } = hooks; + +export const DEBOUNCE_MS = 1000; + +type Props = { + id?: string; + initialValue?: string; + showActions: boolean; + onUpdate: (newValue?: string) => void; +}; + +export const DebouncedTextEditor = ({ + id, + initialValue, + showActions = false, + onUpdate, +}: Props): JSX.Element => { + // prevent to call onUpdate when initialValue changed + const [startDebounce, setStartDebounce] = useState(false); + const [value, setValue] = useState(initialValue); + const debouncedValue = useDebounce(value, DEBOUNCE_MS); + + useEffect(() => { + if (startDebounce) { + onUpdate(debouncedValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedValue]); + + const isHTMLValueEmpty = (html: string) => + stripHtml(html).trim().length === 0; + + const handleValueUpdated = (newValue: string) => { + // ReactQuill Textarea return


if empty. + // To store empty, we have to check if html contains text or not. + setValue(isHTMLValueEmpty(newValue) ? '' : newValue); + setStartDebounce(true); + }; + + return ( + handleValueUpdated(newValue)} + showActions={showActions} + /> + ); +}; + +export default DebouncedTextEditor; diff --git a/src/components/input/DebouncedTextField.tsx b/src/components/input/DebouncedTextField.tsx new file mode 100644 index 000000000..1ce03e338 --- /dev/null +++ b/src/components/input/DebouncedTextField.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; + +import { TextField } from '@mui/material'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { hooks } from '@/config/queryClient'; +import { DEBOUNCED_TEXT_FIELD_ID } from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +const { useDebounce } = hooks; + +export const DEBOUNCE_MS = 1000; + +type Props = { + initialValue?: string; + placeholder?: string; + label: string; + mt?: number; + required?: boolean; + emptyValueError?: string; + onUpdate: (newValue?: string) => void; +}; + +export const DebouncedTextField = ({ + initialValue, + placeholder, + label, + mt, + required = false, + emptyValueError, + onUpdate, +}: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + // prevent to call onUpdate when initialValue changed + const [startDebounce, setStartDebounce] = useState(false); + const [value, setValue] = useState(initialValue); + const [error, setError] = useState(); + const debouncedValue = useDebounce(value, DEBOUNCE_MS); + + const isValid = (newValue?: string) => { + if (!required) { + return true; + } + + return Boolean(newValue); + }; + + useEffect(() => { + if (startDebounce && isValid(debouncedValue)) { + onUpdate(debouncedValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedValue]); + + const updateError = (newValue: string) => { + if (isValid(newValue)) { + setError(undefined); + } else { + setError(emptyValueError ?? t(BUILDER.DEBOUNCED_TEXTFIELD_EMPTY_ERROR)); + } + }; + + const handleValueUpdated = (newValue: string) => { + setValue(newValue); + setStartDebounce(true); + updateError(newValue); + }; + + return ( + handleValueUpdated(e.target.value)} + sx={{ bgcolor: 'transparent', mt }} + error={Boolean(error)} + helperText={error} + /> + ); +}; + +export default DebouncedTextField; diff --git a/src/components/input/MultiSelectChipInput.hook.tsx b/src/components/input/MultiSelectChipInput.hook.tsx new file mode 100644 index 000000000..2965c2a0f --- /dev/null +++ b/src/components/input/MultiSelectChipInput.hook.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { BUILDER } from '@/langs/constants'; +import CaseInsensitiveSet from '@/types/set'; + +const EMPTY_STRING = ''; +const EMPTY_SET = new CaseInsensitiveSet(); + +type SetOfString = CaseInsensitiveSet; + +type Props = { + data?: string[]; + onChange?: (newValues: string[]) => void; +}; + +type UseMultiSelectChipInput = { + values: string[]; + currentValue: string; + error: string | undefined; + hasError: boolean; + hasData: boolean; + hasChanged: boolean; + + updateValues: (newValues: string[]) => void; + handleCurrentValueChanged: (newValue: string) => void; + addValue: () => string[]; + deleteValue: (valueToDelete: string) => string[]; +}; + +export const useMultiSelectChipInput = ({ + data, + onChange, +}: Props): UseMultiSelectChipInput => { + const { t } = useBuilderTranslation(); + const originalData = useRef(EMPTY_SET); + const [newData, setNewData] = useState(EMPTY_SET); + const [currentValue, setCurrentValue] = useState(EMPTY_STRING); + const [error, setError] = useState(); + + const hasError = Boolean(error); + const hasData = newData.size() > 0; + const hasChanged = !originalData.current.isEqual(newData); + + // sync the props with the component's state + useEffect(() => { + const newSet = new CaseInsensitiveSet(data); + setNewData(newSet); + originalData.current = newSet; + }, [data]); + + const valueIsValid = ( + dataToValidate: string | undefined, + ): dataToValidate is string => Boolean(dataToValidate); + + const valueExist = (newValue: string) => newData.has(newValue); + + const validateData = (newValue: string) => { + if (valueExist(newValue)) { + setError(t(BUILDER.CHIPS_ALREADY_EXIST, { element: newValue })); + return false; + } + setError(undefined); + return true; + }; + + const notifyOnChange = (newValues: string[]) => onChange?.(newValues); + + const addValue = () => { + if (valueIsValid(currentValue) && !valueExist(currentValue)) { + const newMapValues = newData.copy([currentValue]); + setNewData(newMapValues); + setCurrentValue(EMPTY_STRING); + notifyOnChange(newMapValues.values()); + return newMapValues.values(); + } + + return newData.values(); + }; + + const deleteValue = (valueToDelete: string) => { + const newMapValues = newData.copy(); + newMapValues.delete(valueToDelete); + setNewData(newMapValues); + notifyOnChange(newMapValues.values()); + return newMapValues.values(); + }; + + const updateValues = (newValues: string[]) => { + const newMap = new CaseInsensitiveSet(newValues); + setNewData(newMap); + notifyOnChange(newMap.values()); + }; + + const handleCurrentValueChanged = (newValue: string) => { + validateData(newValue); + setCurrentValue(newValue); + }; + + return { + values: newData.values(), + currentValue, + error, + hasError, + hasData, + hasChanged, + updateValues, + handleCurrentValueChanged, + addValue, + deleteValue, + }; +}; + +export default useMultiSelectChipInput; diff --git a/src/components/input/MultiSelectChipInput.tsx b/src/components/input/MultiSelectChipInput.tsx new file mode 100644 index 000000000..8345ebe04 --- /dev/null +++ b/src/components/input/MultiSelectChipInput.tsx @@ -0,0 +1,125 @@ +import AddIcon from '@mui/icons-material/Add'; +import { + Autocomplete, + AutocompleteRenderGetTagProps, + AutocompleteRenderInputParams, + Box, + Chip, + Fab, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { + MULTI_SELECT_CHIP_ADD_BUTTON_ID, + MULTI_SELECT_CHIP_CONTAINER_ID, + MULTI_SELECT_CHIP_INPUT_ID, + buildMultiSelectChipsSelector, +} from '@/config/selectors'; + +import { useMultiSelectChipInput } from './MultiSelectChipInput.hook'; + +type Props = { + data?: string[]; + label: string; + onSave: (newValues: string[]) => void; +}; + +export const MultiSelectChipInput = ({ + data, + label, + onSave, +}: Props): JSX.Element | null => { + const { + values, + currentValue, + error, + hasError, + updateValues, + handleCurrentValueChanged, + addValue, + } = useMultiSelectChipInput({ + data, + onChange: onSave, + }); + + const renderTags = ( + value: readonly string[], + getTagProps: AutocompleteRenderGetTagProps, + ) => ( + + {value.map((option: string, index: number) => ( + + ))} + + ); + + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); + + return ( + + + updateValues(v)} + inputValue={currentValue} + onInputChange={(_e, v) => handleCurrentValueChanged(v)} + renderTags={renderTags} + renderInput={renderInput} + /> + + + + + {error && ( + + {error} + + )} + + ); +}; + +export default MultiSelectChipInput; diff --git a/src/components/item/form/FolderForm.tsx b/src/components/item/form/FolderForm.tsx index ab3132262..4d46d66cc 100644 --- a/src/components/item/form/FolderForm.tsx +++ b/src/components/item/form/FolderForm.tsx @@ -3,8 +3,8 @@ import { Stack } from '@mui/material'; import { DiscriminatedItem } from '@graasp/sdk'; import { FOLDER_FORM_DESCRIPTION_ID } from '../../../config/selectors'; +import ThumbnailCrop from '../../thumbnails/ThumbnailCrop'; import DescriptionForm from './DescriptionForm'; -import FolderThumbnail from './FolderThumbnail'; import NameForm from './NameForm'; export type FolderFormProps = { @@ -27,7 +27,7 @@ const FolderForm = ({ alignItems="flex-end" gap={3} > - + & { thumbnail?: Blob }, - ) => void; -}; - -const FolderThumbnail = ({ setChanges }: FolderThumbnailProps): JSX.Element => { - const inputRef = useRef(null); - const [showCropModal, setShowCropModal] = useState(false); - const [newAvatar, setNewAvatar] = useState(); - const [fileSource, setFileSource] = useState(); - const theme = useTheme(); - - const onSelectFile: FormEventHandler = (e) => { - const t = e.target as HTMLInputElement; - if (t.files && t.files?.length > 0) { - const reader = new FileReader(); - reader.addEventListener('load', () => - setFileSource(reader.result as string), - ); - reader.readAsDataURL(t.files?.[0]); - setShowCropModal(true); - } - }; - - const onClose = () => { - setShowCropModal(false); - if (inputRef.current) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - inputRef.current.value = null; - } - }; - - const onConfirmCrop = (croppedImage: Blob | null) => { - onClose(); - - if (!croppedImage) { - return console.error('croppedImage is not defined'); - } - // submit cropped image - try { - setChanges({ thumbnail: croppedImage }); - // replace img src with croppedImage - const url = URL.createObjectURL(croppedImage); - setNewAvatar(url); - } catch (error) { - console.error(error); - } - - return true; - }; - - const onEdit = () => { - inputRef.current?.click(); - }; - - return ( - - { - if (['Enter', ' '].includes(event.key)) { - onEdit(); - } - }} - aria-label="change folder avatar" - role="button" - tabIndex={0} - height={THUMBNAIL_DIMENSION} - width={THUMBNAIL_DIMENSION} - borderRadius={2} - bgcolor={DEFAULT_LIGHT_PRIMARY_COLOR} - alignItems="center" - justifyContent="center" - overflow="hidden" - position="relative" - sx={{ cursor: 'pointer' }} - > - {newAvatar ? ( - folder thumbnail - ) : ( - - )} - - - {fileSource && ( - - - - )} - - ); -}; - -export default FolderThumbnail; diff --git a/src/components/item/publish/CCLicenseDialog.tsx b/src/components/item/publish/CCLicenseDialog.tsx deleted file mode 100644 index 85ffae2cd..000000000 --- a/src/components/item/publish/CCLicenseDialog.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from '@mui/material'; - -import { Button } from '@graasp/ui'; - -import { useBuilderTranslation } from '../../../config/i18n'; -import { BUILDER } from '../../../langs/constants'; -import CancelButton from '../../common/CancelButton'; - -type Props = { - open: boolean; - setOpen: (state: boolean) => void; - disabled?: boolean; - buttonName: string; - handleSubmit: () => void; -}; - -const CCLicenseDialog = ({ - open, - setOpen, - disabled = false, - buttonName, - handleSubmit, -}: Props): JSX.Element => { - const { t: translateBuilder } = useBuilderTranslation(); - - const handleClickOpen = () => { - setOpen(true); - }; - - const handleClose = () => { - setOpen(false); - }; - - return ( - <> - - - - {translateBuilder(BUILDER.ITEM_SETTINGS_CC_LICENSE_MODAL_TITLE)} - - - - {translateBuilder(BUILDER.ITEM_SETTINGS_CC_LICENSE_MODAL_CONTENT)} - - - - - - - - - ); -}; - -export default CCLicenseDialog; diff --git a/src/components/item/publish/CCLicenseSelection.tsx b/src/components/item/publish/CCLicenseSelection.tsx deleted file mode 100644 index 84e3a7029..000000000 --- a/src/components/item/publish/CCLicenseSelection.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from 'react'; - -import { Box, Typography } from '@mui/material'; - -import { DiscriminatedItem } from '@graasp/sdk'; -import { Loader } from '@graasp/ui'; - -import { useBuilderTranslation } from '../../../config/i18n'; -import { BUILDER } from '../../../langs/constants'; -import { useCurrentUserContext } from '../../context/CurrentUserContext'; -import CCLicenseDialog from './CCLicenseDialog'; -import useItemLicense from './useItemLicense'; - -type Props = { - item: DiscriminatedItem; - disabled: boolean; -}; - -const CCLicenseSelection = ({ item, disabled }: Props): JSX.Element => { - const { t: translateBuilder } = useBuilderTranslation(); - const { handleSubmit, licenseForm, creativeCommons } = useItemLicense({ - item, - }); - - // user - const { isLoading: isMemberLoading } = useCurrentUserContext(); - - const [open, setOpen] = useState(false); - - const settings = item?.settings; - - if (isMemberLoading) return ; - - const onSubmit = () => { - handleSubmit(); - setOpen(false); - }; - return ( - - {licenseForm} - - {settings?.ccLicenseAdaption && ( - <> - - {translateBuilder(BUILDER.ITEM_SETTINGS_CC_LICENSE_PREVIEW_TITLE)} - - - {creativeCommons} - - - )} - - ); -}; - -export default CCLicenseSelection; diff --git a/src/components/item/publish/CategoriesContainer.tsx b/src/components/item/publish/CategoriesContainer.tsx new file mode 100644 index 000000000..9a5185a78 --- /dev/null +++ b/src/components/item/publish/CategoriesContainer.tsx @@ -0,0 +1,48 @@ +import { Typography } from '@mui/material'; + +import { Category, CategoryType } from '@graasp/sdk'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { LIBRARY_SETTINGS_CATEGORIES_ID } from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +import ItemCategoryContainer from './ItemCategoryContainer'; + +type Props = { + itemId: string; +}; + +const CHIP_COLOR = '#5050d2'; +const SYNC_STATUS_KEY = 'PublishCategories'; + +export const CategoriesContainer = ({ itemId }: Props): JSX.Element | null => { + const { t } = useBuilderTranslation(); + + const filterOutLanguage = ({ type }: Category) => + type !== CategoryType.Language; + + const title = t(BUILDER.ITEM_CATEGORIES_CONTAINER_TITLE); + const description = t(BUILDER.ITEM_CATEGORIES_CONTAINER_MISSING_WARNING); + const emptyMessage = t(BUILDER.ITEM_CATEGORIES_CONTAINER_EMPTY_BUTTON); + + const modalTitle = ( + + {t(BUILDER.ITEM_CATEGORIES_CONTAINER_TITLE)} + + ); + + return ( + + ); +}; +export default CategoriesContainer; diff --git a/src/components/item/publish/CategorySelection.tsx b/src/components/item/publish/CategorySelection.tsx index 662f10891..7c7830cfa 100644 --- a/src/components/item/publish/CategorySelection.tsx +++ b/src/components/item/publish/CategorySelection.tsx @@ -1,65 +1,56 @@ import { SyntheticEvent } from 'react'; -import { useParams } from 'react-router-dom'; -import { AutocompleteChangeReason, Box, Typography } from '@mui/material'; +import { AutocompleteChangeReason, Stack } from '@mui/material'; -import { routines } from '@graasp/query-client'; import { Category, CategoryType } from '@graasp/sdk'; -import { FAILURE_MESSAGES } from '@graasp/translations'; import { Loader } from '@graasp/ui'; import groupBy from 'lodash.groupby'; -import { - useBuilderTranslation, - useCategoriesTranslation, -} from '../../../config/i18n'; -import notifier from '../../../config/notifier'; -import { hooks, mutations } from '../../../config/queryClient'; -import { LIBRARY_SETTINGS_CATEGORIES_ID } from '../../../config/selectors'; -import { BUILDER } from '../../../langs/constants'; -import { sortByName } from '../../../utils/item'; -import { useCurrentUserContext } from '../../context/CurrentUserContext'; -import DropdownMenu from './DropdownMenu'; +import { useCategoriesTranslation } from '@/config/i18n'; +import { hooks } from '@/config/queryClient'; +import { Filter } from '@/types/array'; +import { sortByName } from '@/utils/item'; -const { postItemCategoryRoutine } = routines; +import DropdownMenu from './DropdownMenu'; const { useCategories, useItemCategories } = hooks; -const { usePostItemCategory, useDeleteItemCategory } = mutations; const SELECT_OPTION = 'selectOption'; const REMOVE_OPTION = 'removeOption'; type Props = { - disabled: boolean; + itemId: string; + titleContent?: JSX.Element; + filterCategories?: Filter; + onCreate: (categoryId: string) => void; + onDelete: (itemCategoryId: string) => void; }; - -const CategorySelection = ({ disabled }: Props): JSX.Element | null => { - const { t: translateBuilder } = useBuilderTranslation(); +const CategorySelection = ({ + itemId, + titleContent, + filterCategories = () => true, + onCreate, + onDelete, +}: Props): JSX.Element | null => { const { t: translateCategories } = useCategoriesTranslation(); - const { mutate: createItemCategory } = usePostItemCategory(); - const { mutate: deleteItemCategory } = useDeleteItemCategory(); - - // user - const { isLoading: isMemberLoading } = useCurrentUserContext(); - - // current item - const { itemId } = useParams(); - - // get itemCategories, categoryTypes and allCategories const { data: itemCategories, isLoading: isItemCategoriesLoading } = useItemCategories(itemId); const { data: allCategories, isLoading: isCategoriesLoading } = useCategories(); - - // process data - const categoriesMap = groupBy(allCategories, (entry) => entry.type); - - if (isMemberLoading || isItemCategoriesLoading || isCategoriesLoading) { - return ; + const isLoading = isItemCategoriesLoading || isCategoriesLoading; + const filteredCategories = allCategories?.filter(filterCategories); + const categoriesByType = groupBy(filteredCategories, (entry) => entry.type); + + if (isLoading) { + return ( + + + + ); } - if (!Object.values(categoriesMap).length) { + if (!Object.values(categoriesByType).length) { return null; } @@ -77,40 +68,31 @@ const CategorySelection = ({ disabled }: Props): JSX.Element | null => { if (reason === SELECT_OPTION) { // post new category const newCategoryId = details?.option.id; - if (!newCategoryId) { - notifier({ - type: postItemCategoryRoutine.FAILURE, - payload: { error: new Error(FAILURE_MESSAGES.UNEXPECTED_ERROR) }, - }); + if (newCategoryId) { + onCreate(newCategoryId); } else { - createItemCategory({ - itemId, - categoryId: newCategoryId, - }); + console.error('Unable to create the category!'); } } if (reason === REMOVE_OPTION) { const deletedCategoryId = details?.option.id; - const itemCategoryIdToDelete = itemCategories?.find( + const itemCategoryIdToRemove = itemCategories?.find( ({ category }) => category.id === deletedCategoryId, )?.id; - if (itemCategoryIdToDelete) { - deleteItemCategory({ - itemId, - itemCategoryId: itemCategoryIdToDelete, - }); + if (itemCategoryIdToRemove) { + onDelete(itemCategoryIdToRemove); + } else { + console.error('Unable to delete the category!'); } } }; return ( - - - {translateBuilder(BUILDER.ITEM_CATEGORIES_SELECTION_TITLE)} - + + {titleContent} {Object.values(CategoryType)?.map((type) => { const values = - categoriesMap[type] + categoriesByType[type] ?.map((c: Category) => ({ ...c, name: translateCategories(c.name), @@ -120,7 +102,6 @@ const CategorySelection = ({ disabled }: Props): JSX.Element | null => { return ( { /> ); })} - + ); }; diff --git a/src/components/item/publish/CoEditorSettings.tsx b/src/components/item/publish/CoEditorSettings.tsx deleted file mode 100644 index b6a4f09ae..000000000 --- a/src/components/item/publish/CoEditorSettings.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { ChangeEvent, useEffect, useState } from 'react'; - -import { FormControlLabel, Radio, RadioGroup, Typography } from '@mui/material'; - -import { DiscriminatedItem } from '@graasp/sdk'; -import { Loader } from '@graasp/ui'; - -import { DISPLAY_CO_EDITORS_OPTIONS } from '../../../config/constants'; -import { useBuilderTranslation } from '../../../config/i18n'; -import { mutations } from '../../../config/queryClient'; -import { - CO_EDITOR_SETTINGS_RADIO_GROUP_ID, - buildCoEditorSettingsRadioButtonId, -} from '../../../config/selectors'; -import { BUILDER } from '../../../langs/constants'; -import { useCurrentUserContext } from '../../context/CurrentUserContext'; - -type Props = { - item: DiscriminatedItem; - disabled: boolean; -}; - -const CoEditorSettings = ({ item, disabled }: Props): JSX.Element => { - const { t: translateBuilder } = useBuilderTranslation(); - const { mutate: updateDisplayCoEditors } = mutations.useEditItem(); - - // user - const { isLoading: isMemberLoading } = useCurrentUserContext(); - - // current item - const itemId = item?.id; - const settings = item?.settings; - const itemName = item?.name; - - // by default, co editors will not be displayed - const [optionValue, setOptionValue] = useState( - DISPLAY_CO_EDITORS_OPTIONS.NO.value, - ); - - useEffect(() => { - if (settings?.displayCoEditors) { - setOptionValue(settings.displayCoEditors); - } - }, [settings]); - - if (isMemberLoading) { - return ; - } - - const handleChange = (event: ChangeEvent): void => { - // value from radio button is string, convert to boolean - const newValue = event.target.value === 'true'; - setOptionValue(newValue); - updateDisplayCoEditors({ - id: itemId, - name: itemName, - settings: { displayCoEditors: newValue }, - }); - }; - - return ( - <> - - {translateBuilder(BUILDER.ITEM_SETTINGS_CO_EDITORS_TITLE)} - - - {translateBuilder(BUILDER.ITEM_SETTINGS_CO_EDITORS_INFORMATIONS)} - - - {Object.values(DISPLAY_CO_EDITORS_OPTIONS).map((option) => ( - } - label={translateBuilder(option.label)} - disabled={disabled} - /> - ))} - - - ); -}; - -export default CoEditorSettings; diff --git a/src/components/item/publish/CoEditorsContainer.tsx b/src/components/item/publish/CoEditorsContainer.tsx new file mode 100644 index 000000000..4b395ca6e --- /dev/null +++ b/src/components/item/publish/CoEditorsContainer.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react'; + +import { Checkbox, FormControlLabel, Stack, Typography } from '@mui/material'; + +import { PackedItem } from '@graasp/sdk'; + +import { useDataSyncContext } from '@/components/context/DataSyncContext'; +import usePublicationStatus from '@/components/hooks/usePublicationStatus'; +import { useBuilderTranslation } from '@/config/i18n'; +import { mutations } from '@/config/queryClient'; +import { + CO_EDITOR_SETTINGS_CHECKBOX_ID, + EMAIL_NOTIFICATION_CHECKBOX, +} from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; +import { PublicationStatus } from '@/types/publication'; + +const SYNC_STATUS_KEY = 'PublishCoEditors'; + +type Props = { + item: PackedItem; + notifyCoEditors: boolean; + onNotificationChanged: (enabled: boolean) => void; +}; + +export const CoEditorsContainer = ({ + item, + notifyCoEditors, + onNotificationChanged, +}: Props): JSX.Element | null => { + const { t } = useBuilderTranslation(); + const { computeStatusFor } = useDataSyncContext(); + const { status } = usePublicationStatus({ item }); + const { settings, id: itemId, name: itemName } = item; + const [displayCoEditors, setDisplayCoEditors] = useState( + settings.displayCoEditors ?? false, + ); + + const { + mutate: updateDisplayCoEditors, + isLoading, + isSuccess, + isError, + } = mutations.useEditItem({ + enableNotifications: false, + }); + + useEffect( + () => computeStatusFor(SYNC_STATUS_KEY, { isLoading, isSuccess, isError }), + [isLoading, isSuccess, isError, computeStatusFor], + ); + + useEffect(() => { + if (settings.displayCoEditors) { + setDisplayCoEditors(settings.displayCoEditors); + } + }, [settings]); + + const handleDisplayCoEditorsChange = (isChecked: boolean): void => { + setDisplayCoEditors(isChecked); + updateDisplayCoEditors({ + id: itemId, + name: itemName, + settings: { displayCoEditors: isChecked }, + }); + }; + + const handleNotifyCoEditorsChange = (isChecked: boolean): void => + onNotificationChanged(isChecked); + + // The publication is managed by the parent + if (status === PublicationStatus.PublishedChildren) { + return null; + } + + return ( + + + {t(BUILDER.ITEM_SETTINGS_CO_EDITORS_TITLE)} + + handleDisplayCoEditorsChange(checked)} + control={} + label={t(BUILDER.ITEM_SETTINGS_CO_EDITORS_INFORMATIONS)} + sx={{ maxWidth: 'max-content' }} + /> + handleNotifyCoEditorsChange(checked)} + control={} + label={t(BUILDER.LIBRARY_SETTINGS_PUBLISH_NOTIFICATIONS_LABEL)} + sx={{ maxWidth: 'max-content' }} + /> + + ); +}; + +export default CoEditorsContainer; diff --git a/src/components/item/publish/CustomizedTagsEdit.tsx b/src/components/item/publish/CustomizedTagsEdit.tsx deleted file mode 100644 index de111fe9f..000000000 --- a/src/components/item/publish/CustomizedTagsEdit.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { Box, Chip, Stack, TextField, Typography } from '@mui/material'; -import type { TextFieldProps } from '@mui/material'; - -import { DiscriminatedItem } from '@graasp/sdk'; -import { COMMON } from '@graasp/translations'; -import { SaveButton } from '@graasp/ui'; - -import { - useBuilderTranslation, - useCommonTranslation, -} from '../../../config/i18n'; -import { mutations } from '../../../config/queryClient'; -import { - ITEM_TAGS_EDIT_INPUT_ID, - ITEM_TAGS_EDIT_SUBMIT_BUTTON_ID, - buildCustomizedTagsSelector, -} from '../../../config/selectors'; -import { BUILDER } from '../../../langs/constants'; - -type Props = { item: DiscriminatedItem; disabled?: boolean }; - -const CustomizedTagsEdit = ({ item, disabled }: Props): JSX.Element => { - const { t: translateBuilder } = useBuilderTranslation(); - const { t: translateCommon } = useCommonTranslation(); - const { mutate: updateCustomizedTags } = mutations.useEditItem(); - - const { settings, id: itemId } = item; - - const [displayValues, setDisplayValues] = useState(); - - useEffect(() => { - if (settings) { - setDisplayValues(settings.tags?.join(', ')); - } - }, [settings]); - - const handleChange: TextFieldProps['onChange'] = (event) => { - setDisplayValues(event.target.value); - }; - - const handleSubmit = () => { - const tagsList = - displayValues - ?.split(', ') - ?.map((entry) => entry.trim()) - ?.filter(Boolean) || []; - updateCustomizedTags({ - id: itemId, - settings: { tags: tagsList }, - }); - }; - - return ( - - - - {translateBuilder(BUILDER.ITEM_TAGS_TITLE)} - - - {translateBuilder(BUILDER.ITEM_TAGS_INFORMATION)} - - - - - - - - - - - {settings?.tags?.length && ( - <> - - {translateBuilder(BUILDER.ITEM_TAGS_PREVIEW_TITLE)} - -
- - {settings?.tags?.map((tag, index) => ( - - ))} - - - )} -
- ); -}; - -export default CustomizedTagsEdit; diff --git a/src/components/item/publish/DropdownMenu.tsx b/src/components/item/publish/DropdownMenu.tsx index b85c7277b..977fea79b 100644 --- a/src/components/item/publish/DropdownMenu.tsx +++ b/src/components/item/publish/DropdownMenu.tsx @@ -20,7 +20,7 @@ import { import { BUILDER } from '../../../langs/constants'; type Props = { - disabled: boolean; + disabled?: boolean; type: CategoryType; title: string; values: Category[]; @@ -34,7 +34,7 @@ type Props = { }; const DropdownMenu = ({ - disabled, + disabled = false, type, title, handleChange, @@ -58,7 +58,7 @@ const DropdownMenu = ({ { + const { description, id: itemId } = item; + const { t } = useBuilderTranslation(); + const { computeStatusFor } = useDataSyncContext(); + const { isOpen, openModal, closeModal } = useModalStatus(); + + const { + mutate: updateItem, + isSuccess, + isLoading, + isError, + } = mutations.useEditItem({ + enableNotifications: false, + }); + + useEffect( + () => computeStatusFor(SYNC_STATUS_KEY, { isError, isLoading, isSuccess }), + [computeStatusFor, isError, isLoading, isSuccess], + ); + + const handleDescriptionUpdated = (newDescription?: string) => { + updateItem({ + id: itemId, + description: newDescription ?? '', + }); + }; + + const modal = ( + + } + handleOnClose={closeModal} + /> + ); + + const noDescriptionToolTip = !description ? ( + + + + ) : null; + + const descriptionHeader = ( + + {t(BUILDER.DESCRIPTION_LABEL)} + + + + + ); + + const descriptionContent = ( + + + {noDescriptionToolTip} + + ); + + return ( + <> + {modal} + + {descriptionHeader} + {descriptionContent} + + + ); +}; + +export default EditItemDescription; diff --git a/src/components/item/publish/EditItemName.tsx b/src/components/item/publish/EditItemName.tsx new file mode 100644 index 000000000..571cece27 --- /dev/null +++ b/src/components/item/publish/EditItemName.tsx @@ -0,0 +1,92 @@ +import { useEffect } from 'react'; + +import EditIcon from '@mui/icons-material/Edit'; +import { IconButton, Stack, Typography } from '@mui/material'; + +import { DiscriminatedItem } from '@graasp/sdk'; + +import { useDataSyncContext } from '@/components/context/DataSyncContext'; +import useModalStatus from '@/components/hooks/useModalStatus'; +import DebouncedTextField from '@/components/input/DebouncedTextField'; +import { useBuilderTranslation } from '@/config/i18n'; +import { mutations } from '@/config/queryClient'; +import { BUILDER } from '@/langs/constants'; + +import PublicationModal from './PublicationModal'; + +const SYNC_STATUS_KEY = 'PublishItemTitle'; + +type Props = { + item: DiscriminatedItem; +}; + +export const EditItemName = ({ item }: Props): JSX.Element => { + const { name, id: itemId } = item; + const { t } = useBuilderTranslation(); + const { computeStatusFor } = useDataSyncContext(); + const { isOpen, openModal, closeModal } = useModalStatus(); + + const { + mutate: updateItem, + isSuccess, + isLoading, + isError, + } = mutations.useEditItem({ + enableNotifications: false, + }); + + useEffect( + () => computeStatusFor(SYNC_STATUS_KEY, { isError, isLoading, isSuccess }), + [computeStatusFor, isError, isLoading, isSuccess], + ); + + const handleNameUpdated = (newName?: string) => { + if (newName) { + updateItem({ + id: itemId, + name: newName, + }); + } + }; + + const modal = ( + + } + handleOnClose={closeModal} + /> + ); + + return ( + <> + {modal} + + + {item.name} + + + + + + + ); +}; + +export default EditItemName; diff --git a/src/components/item/publish/ItemCategoryContainer.tsx b/src/components/item/publish/ItemCategoryContainer.tsx new file mode 100644 index 000000000..41a7f4992 --- /dev/null +++ b/src/components/item/publish/ItemCategoryContainer.tsx @@ -0,0 +1,96 @@ +import { useEffect } from 'react'; + +import { Category } from '@graasp/sdk'; + +import { useDataSyncContext } from '@/components/context/DataSyncContext'; +import { Filter } from '@/types/array'; + +import useItemCategories from '../../hooks/useItemCategories'; +import useModalStatus from '../../hooks/useModalStatus'; +import CategorySelection from './CategorySelection'; +import PublicationChipContainer from './PublicationChipContainer'; +import PublicationModal from './PublicationModal'; + +type Props = { + itemId: string; + title: string; + description: string; + emptyMessage: string; + modalTitle?: JSX.Element; + chipColor?: string; + dataTestId: string; + dataSyncKey: string; + filterCategories?: Filter; +}; + +export const ItemCategoryContainer = ({ + itemId, + title, + description, + emptyMessage, + modalTitle, + chipColor, + dataTestId, + dataSyncKey, + filterCategories, +}: Props): JSX.Element | null => { + const { computeStatusFor } = useDataSyncContext(); + const { + isLoading, + isMutationLoading, + isMutationSuccess, + isMutationError, + categories, + addCategory, + deleteCategory, + deleteCategoryByName, + } = useItemCategories({ itemId, filterCategories }); + + useEffect( + () => + computeStatusFor(dataSyncKey, { + isLoading: isMutationLoading, + isSuccess: isMutationSuccess, + isError: isMutationError, + }), + [ + isMutationLoading, + isMutationSuccess, + isMutationError, + computeStatusFor, + dataSyncKey, + ], + ); + + const { isOpen, openModal, closeModal } = useModalStatus(); + + return ( + <> + + } + isOpen={isOpen} + handleOnClose={closeModal} + /> + + + ); +}; +export default ItemCategoryContainer; diff --git a/src/components/item/publish/ItemPublishButton.tsx b/src/components/item/publish/ItemPublishButton.tsx deleted file mode 100644 index ee29860c6..000000000 --- a/src/components/item/publish/ItemPublishButton.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useState } from 'react'; - -import { CheckCircle, InfoRounded } from '@mui/icons-material'; -import { LoadingButton } from '@mui/lab'; -import { Checkbox, FormControlLabel, Stack, Typography } from '@mui/material'; - -import { DiscriminatedItem } from '@graasp/sdk'; - -import { useBuilderTranslation } from '../../../config/i18n'; -import { hooks, mutations } from '../../../config/queryClient'; -import { - EMAIL_NOTIFICATION_CHECKBOX, - ITEM_PUBLISH_BUTTON_ID, - ITEM_UNPUBLISH_BUTTON_ID, -} from '../../../config/selectors'; -import { BUILDER } from '../../../langs/constants'; - -const { useItemPublishedInformation } = hooks; -const { useUnpublishItem, usePublishItem } = mutations; - -type Props = { - item: DiscriminatedItem; - isValidated: boolean; - disabled: boolean; -}; - -const ItemPublishButton = ({ - item, - isValidated, - disabled, -}: Props): JSX.Element | null => { - const { t: translateBuilder } = useBuilderTranslation(); - - const { mutate: unpublish, isLoading: isUnPublishing } = useUnpublishItem(); - const { mutate: publishItem, isLoading: isPublishing } = usePublishItem(); - - const { data: itemPublishedEntry, isFetching } = useItemPublishedInformation({ - itemId: item.id, - }); - - const [emailNotification, setEmailNotification] = useState(false); - - const isPublished = Boolean(itemPublishedEntry); - const isDisabled = - itemPublishedEntry && itemPublishedEntry?.item?.path !== item?.path; - - if (!isValidated) { - return null; - } - - const handlePublish = () => { - // Prevent resend request if item is already published - if (!isPublished) { - publishItem({ - id: item.id, - notification: emailNotification, - }); - } - }; - - const handleUnpublish = () => { - if (itemPublishedEntry) { - unpublish({ id: item.id }); - } - }; - - const toggleEmailNotification = () => { - setEmailNotification(!emailNotification); - }; - - return ( - <> - {isDisabled && ( - - - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_CHILD_PUBLISHED_STATUS)} - - - )} - - - } - id={ITEM_PUBLISH_BUTTON_ID} - // show a loading state when we are fetching the entry and when we are publishing - loading={isFetching || isPublishing} - > - {isPublished - ? translateBuilder(BUILDER.LIBRARY_SETTINGS_PUBLISH_BUTTON_DISABLED) - : translateBuilder(BUILDER.LIBRARY_SETTINGS_PUBLISH_BUTTON)} - - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_UNPUBLISH_BUTTON)} - - -
- - } - label={translateBuilder( - BUILDER.LIBRARY_SETTINGS_PUBLISH_NOTIFICATIONS_LABEL, - )} - /> -
- {isPublished && ( - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_PUBLISHED_STATUS)} - - )} - - ); -}; - -export default ItemPublishButton; diff --git a/src/components/item/publish/ItemPublishTab.tsx b/src/components/item/publish/ItemPublishTab.tsx index 5ab000cec..101764269 100644 --- a/src/components/item/publish/ItemPublishTab.tsx +++ b/src/components/item/publish/ItemPublishTab.tsx @@ -1,303 +1,143 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useOutletContext } from 'react-router-dom'; -import { - Cancel, - CheckCircle, - Help, - Looks3, - Looks4, - Looks5, - LooksOne, - LooksTwo, - Update, -} from '@mui/icons-material'; -import { - Box, - Button, - IconButton, - Stack, - Tooltip, - Typography, -} from '@mui/material'; - -import { ItemValidationStatus, redirect } from '@graasp/sdk'; +import { Container, Stack, Typography, useMediaQuery } from '@mui/material'; -import groupBy from 'lodash.groupby'; - -import { OutletType } from '@/components/pages/item/type'; +import { Loader, theme } from '@graasp/ui'; -import { ADMIN_CONTACT, CC_LICENSE_ABOUT_URL } from '../../../config/constants'; -import { useBuilderTranslation } from '../../../config/i18n'; -import { hooks, mutations } from '../../../config/queryClient'; +import SyncIcon from '@/components/common/SyncIcon'; +import { useCurrentUserContext } from '@/components/context/CurrentUserContext'; import { - ITEM_PUBLISH_SECTION_TITLE_ID, - ITEM_VALIDATION_BUTTON_ID, - ITEM_VALIDATION_REFRESH_BUTTON_ID, -} from '../../../config/selectors'; -import { BUILDER } from '../../../langs/constants'; -import VisibilitySelect from '../sharing/VisibilitySelect'; -import CCLicenseSelection from './CCLicenseSelection'; -import CategorySelection from './CategorySelection'; -import CoEditorSettings from './CoEditorSettings'; -import CustomizedTagsEdit from './CustomizedTagsEdit'; -import ItemPublishButton from './ItemPublishButton'; - -const { useLastItemValidationGroup } = hooks; + DataSyncContextProvider, + useDataSyncContext, +} from '@/components/context/DataSyncContext'; +import CategoriesContainer from '@/components/item/publish/CategoriesContainer'; +import CoEditorsContainer from '@/components/item/publish/CoEditorsContainer'; +import EditItemDescription from '@/components/item/publish/EditItemDescription'; +import LanguagesContainer from '@/components/item/publish/LanguagesContainer'; +import LicenseContainer from '@/components/item/publish/LicenseContainer'; +import PublicationButton from '@/components/item/publish/PublicationButton'; +import PublicationStatusComponent from '@/components/item/publish/PublicationStatusComponent'; +import PublicationThumbnail from '@/components/item/publish/PublicationThumbnail'; +import { OutletType } from '@/components/pages/item/type'; +import { useBuilderTranslation } from '@/config/i18n'; +import { BUILDER } from '@/langs/constants'; +import { SomeBreakPoints } from '@/types/breakpoint'; -const { usePostItemValidation } = mutations; +import EditItemName from './EditItemName'; +import CustomizedTags from './customizedTags/CustomizedTags'; -const enum PublishFlow { - SET_ITEM_VISIBILITY_PUBLIC_STEP, - VALIDATE_ITEM_STEP, - PUBLISH_STEP, -} +type StackOrder = { order?: number | SomeBreakPoints }; const ItemPublishTab = (): JSX.Element => { - const { t: translateBuilder } = useBuilderTranslation(); - const { item, canWrite, canAdmin } = useOutletContext(); - - const [validationStatus, setValidationStatus] = - useState(null); - - const isPublic = item.public; - - // item validation - const { mutate: validateItem } = usePostItemValidation(); + const { t } = useBuilderTranslation(); + const { item, canAdmin } = useOutletContext(); + const { isLoading: isMemberLoading } = useCurrentUserContext(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const { status } = useDataSyncContext(); - const { data: lastItemValidationGroup, refetch } = useLastItemValidationGroup( - item?.id, - ); - - useEffect(() => { - // check if validation is still valid - const isOutdated = - Boolean(!lastItemValidationGroup) || - Boolean(!lastItemValidationGroup?.createdAt) || - (lastItemValidationGroup?.createdAt - ? lastItemValidationGroup.createdAt <= item?.updatedAt - : true); - // QUESTION: should this be null instead? - if (isOutdated) { - setValidationStatus(ItemValidationStatus.Failure); - } + const [notifyCoEditors, setNotifyCoEditors] = useState(false); - const mapByStatus = groupBy( - lastItemValidationGroup?.itemValidations, - ({ status }) => status, - ); - let status = null; - if (mapByStatus[ItemValidationStatus.Failure]) { - status = ItemValidationStatus.Failure; - } else if (mapByStatus[ItemValidationStatus.Pending]) { - status = ItemValidationStatus.Pending; - } else if (mapByStatus[ItemValidationStatus.Success]) { - status = ItemValidationStatus.Success; - } - setValidationStatus(status); - }, [lastItemValidationGroup, item?.updatedAt]); - - const step = (() => { - if (!isPublic) { - return PublishFlow.SET_ITEM_VISIBILITY_PUBLIC_STEP; - } - if (validationStatus !== ItemValidationStatus.Success) { - return PublishFlow.VALIDATE_ITEM_STEP; - } - return PublishFlow.PUBLISH_STEP; - })(); + if (isMemberLoading) { + return ; + } - if (!canWrite || !canAdmin) { + if (!canAdmin) { return ( - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_CONFIGURATION_INFORMATIONS, - )} + + {t(BUILDER.LIBRARY_SETTINGS_UNAUTHORIZED)} ); } - const handleValidate = () => { - // prevent re-send request if the item is already successfully validated - if (!(validationStatus === ItemValidationStatus.Success)) { - validateItem({ itemId: item.id }); - } - setValidationStatus(ItemValidationStatus.Pending); - }; - - // display icon indicating current status of given item - const displayItemValidationIcon = () => { - switch (validationStatus) { - case ItemValidationStatus.Success: - return ; - case ItemValidationStatus.Pending: - return ; - case ItemValidationStatus.PendingManual: - return ; - case ItemValidationStatus.Failure: - return ; - default: - } - return null; - }; + const customizedTags = ; - const handleClick = () => { - const url = CC_LICENSE_ABOUT_URL; - redirect(window, url, { openInNewTab: true }); - }; + const buildPreviewHeader = (): JSX.Element => ( + + + + + + {!isMobile && customizedTags} + + + {isMobile && customizedTags} + + + ); - const displayItemValidationMessage = () => { - switch (validationStatus) { - case ItemValidationStatus.Pending: - return ( - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_STATUS_PENDING_AUTOMATIC, - )} - - ); - case ItemValidationStatus.PendingManual: - return ( - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_STATUS_PENDING_MANUAL, - )} - - ); - case ItemValidationStatus.Failure: - return ( - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_STATUS_FAILURE, - { - contact: ADMIN_CONTACT, - }, - )} - - ); - default: - } - return null; - }; + const buildPreviewContent = (): JSX.Element => ( + + + + + + + + ); - return ( - - <> - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_TITLE)} - - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_INFORMATION)} - - - - {translateBuilder(BUILDER.ITEM_SETTINGS_VISIBILITY_TITLE)} - - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_VISIBILITY_INFORMATIONS)} - - - - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_VALIDATION_TITLE)} + const buildPreviewSection = ({ order }: StackOrder): JSX.Element => ( + + + + {t(BUILDER.LIBRARY_SETTINGS_PREVIEW_TITLE)} - - {translateBuilder(BUILDER.LIBRARY_SETTINGS_VALIDATION_INFORMATIONS)} - - - - - - {displayItemValidationMessage()} - - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_CONFIGURATION_TITLE, - )} - - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_CONFIGURATION_INFORMATIONS, - )} - - - - - - + + + {t(BUILDER.LIBRARY_SETTINGS_PREVIEW_DESCRIPTION)} - - - {translateBuilder(BUILDER.ITEM_SETTINGS_CC_LICENSE_TITLE)} - - - - - - - - - + {buildPreviewHeader()} + {buildPreviewContent()} + + ); - - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_PUBLICATION_TITLE, - )} - - - {translateBuilder( - BUILDER.LIBRARY_SETTINGS_VALIDATION_PUBLICATION_INFORMATIONS, - )} + const buildPublicationHeader = ({ order }: StackOrder = {}): JSX.Element => ( + + + + {t(BUILDER.LIBRARY_SETTINGS_TITLE)} - - - + + + {t(BUILDER.LIBRARY_SETTINGS_INFORMATION)} + + ); + + const buildPublicationSection = ({ order }: StackOrder = {}): JSX.Element => ( + + setNotifyCoEditors(enabled)} + /> + + + ); + + return ( + + + {buildPreviewSection({ order: { xs: 1, md: 0 } })} + {isMobile ? ( + <> + {buildPublicationHeader({ order: { xs: 0 } })} + {buildPublicationSection({ order: { xs: 2 } })} + + ) : ( + + {buildPublicationHeader()} + {buildPublicationSection()} + + )} + + ); }; -export default ItemPublishTab; +const ItemPublishWithContext = (): JSX.Element => ( + + + +); + +export default ItemPublishWithContext; diff --git a/src/components/item/publish/LanguagesContainer.tsx b/src/components/item/publish/LanguagesContainer.tsx new file mode 100644 index 000000000..29b92770d --- /dev/null +++ b/src/components/item/publish/LanguagesContainer.tsx @@ -0,0 +1,39 @@ +import { Category, CategoryType } from '@graasp/sdk'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { LIBRARY_SETTINGS_LANGUAGES_ID } from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +import ItemCategoryContainer from './ItemCategoryContainer'; + +type Props = { + itemId: string; +}; + +const CHIP_COLOR = '#9a49de'; +const SYNC_STATUS_KEY = 'PublishLanguage'; + +export const LanguagesContainer = ({ itemId }: Props): JSX.Element | null => { + const keepLanguageOnly = ({ type }: Category) => + type === CategoryType.Language; + + const { t } = useBuilderTranslation(); + + const title = t(BUILDER.ITEM_LANGUAGES_CONTAINER_TITLE); + const description = t(BUILDER.ITEM_LANGUAGES_CONTAINER_MISSING_WARNING); + const emptyMessage = t(BUILDER.ITEM_LANGUAGES_CONTAINER_EMPTY_BUTTON); + + return ( + + ); +}; +export default LanguagesContainer; diff --git a/src/components/item/publish/LicenseContainer.tsx b/src/components/item/publish/LicenseContainer.tsx new file mode 100644 index 000000000..2d4056987 --- /dev/null +++ b/src/components/item/publish/LicenseContainer.tsx @@ -0,0 +1,156 @@ +import { useEffect } from 'react'; + +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import HelpIcon from '@mui/icons-material/Help'; +import { Alert, Box, Button, IconButton, Stack, Tooltip } from '@mui/material'; + +import { DiscriminatedItem } from '@graasp/sdk'; +import { theme } from '@graasp/ui'; + +import { useDataSyncContext } from '@/components/context/DataSyncContext'; +import { CC_LICENSE_ABOUT_URL } from '@/config/constants'; +import { useBuilderTranslation } from '@/config/i18n'; +import { + CC_DELETE_BUTTON_HEADER, + CC_EDIT_BUTTON_HEADER, + CC_SAVE_BUTTON, + LIBRARY_SETTINGS_CC_SETTINGS_ID, +} from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +import useItemLicense from '../../hooks/useItemLicense'; +import useModalStatus from '../../hooks/useModalStatus'; +import PublicationAttributeContainer from './PublicationAttributeContainer'; +import PublicationModal from './PublicationModal'; + +const SYNC_STATUS_KEY = 'PublishLicense'; + +type Props = { + item: DiscriminatedItem; +}; + +export const LicenseContainer = ({ item }: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + const { computeStatusFor } = useDataSyncContext(); + const { isOpen, openModal, closeModal } = useModalStatus(); + const { creativeCommons } = useItemLicense({ + item, + iconSize: 30, + }); + // This hook is use in the modal to temporary edit the license without affecting the current item's license. + // If the user validate the modifications, the current item's license will be updated after the POST. + const tmpItemLicense = useItemLicense({ + item, + enableNotifications: false, + }); + + useEffect( + () => + computeStatusFor(SYNC_STATUS_KEY, { + isLoading: tmpItemLicense.isLoading, + isError: tmpItemLicense.isError, + isSuccess: tmpItemLicense.isSuccess, + }), + [ + computeStatusFor, + tmpItemLicense.isError, + tmpItemLicense.isLoading, + tmpItemLicense.isSuccess, + ], + ); + + const { settings } = item; + const licenseContent = settings?.ccLicenseAdaption ? ( + {creativeCommons} + ) : undefined; + + const containerTitle = t(BUILDER.ITEM_LICENSE_CONTAINER_TITLE); + const description = t(BUILDER.ITEM_LICENSE_CONTAINER_MISSING_WARNING); + const emptyMessage = t(BUILDER.ITEM_LICENSE_CONTAINER_EMPTY_BUTTON); + + const onSubmit = () => { + tmpItemLicense.handleSubmit(); + closeModal(); + }; + + const computeKey = (id: string) => `license-${id}`; + + const modalActions = [ + , + , + ]; + + const buildLicenseModal = (): JSX.Element => ( + + {licenseContent && ( + + {t(BUILDER.ITEM_SETTINGS_CC_LICENSE_MODAL_CONTENT)} + + )} + + {tmpItemLicense.licenseForm} + + {tmpItemLicense.creativeCommons} + + + + ); + + const titleHelp = ( + + + + + + ); + + return ( + <> + + + + + + + + + + + + } + attributeDescription={description} + emptyDataMessage={emptyMessage} + content={licenseContent} + onEmptyClick={openModal} + /> + + ); +}; + +export default LicenseContainer; diff --git a/src/components/item/publish/PublicVisibilityModal.tsx b/src/components/item/publish/PublicVisibilityModal.tsx new file mode 100644 index 000000000..c6dab8a93 --- /dev/null +++ b/src/components/item/publish/PublicVisibilityModal.tsx @@ -0,0 +1,67 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@mui/material'; + +import { PackedItem } from '@graasp/sdk'; + +import { SETTINGS } from '@/config/constants'; +import { useBuilderTranslation } from '@/config/i18n'; +import { PUBLIC_VISIBILITY_MODAL_VALIDATE_BUTTON } from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +import useVisibility from '../../hooks/useVisibility'; + +type Props = { + item: PackedItem; + isOpen: boolean; + onClose: () => void; + onValidate: () => void; +}; +export const PublicVisibilityModal = ({ + item, + isOpen, + onClose, + onValidate, +}: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + const { updateVisibility } = useVisibility(item); + + const handleValidate = async () => { + await updateVisibility(SETTINGS.ITEM_PUBLIC.name); + onValidate(); + }; + + return ( + + + + {t(BUILDER.PUBLIC_VISIBILITY_MODAL_TITLE)} + + + + + {t(BUILDER.PUBLIC_VISIBILITY_MODAL_DESCRIPTION)} + + + + + + + + ); +}; + +export default PublicVisibilityModal; diff --git a/src/components/item/publish/PublicationAttributeContainer.tsx b/src/components/item/publish/PublicationAttributeContainer.tsx new file mode 100644 index 000000000..e24a2c0f1 --- /dev/null +++ b/src/components/item/publish/PublicationAttributeContainer.tsx @@ -0,0 +1,140 @@ +import { MouseEvent } from 'react'; + +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import WarningIcon from '@mui/icons-material/Warning'; +import { Box, Stack, SxProps, Tooltip, Typography } from '@mui/material'; + +import { theme } from '@graasp/ui'; + +import { WARNING_COLOR } from '@/config/constants'; +import { + buildPublishAttrContainer, + buildPublishAttrEmptyContainer, + buildPublishTitleAction, + buildPublishWarningIcon, +} from '@/config/selectors'; + +import ContentLoader from '../../common/ContentLoader'; + +const clickableBoxSx: SxProps = { + cursor: 'pointer', + ':hover': { + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)', + transform: 'scale(1.01)', + }, + ':active': { + transform: 'scale(1.0)', + }, +}; + +const CONTAINER_BORDER = { + radius: '8px', + border: '1px #ddd solid', +}; + +type Props = { + dataTestId: string; + title: string; + titleIcon?: JSX.Element; + titleActionBtn: JSX.Element; + content?: JSX.Element | JSX.Element[]; + isLoading?: boolean; + emptyDataMessage: string; + attributeDescription: string; + onEmptyClick: () => void; +}; + +export const PublicationAttributeContainer = ({ + dataTestId, + title, + titleIcon, + titleActionBtn, + content, + emptyDataMessage, + attributeDescription, + isLoading = false, + onEmptyClick, +}: Props): JSX.Element => { + const hasNoData = + content === undefined || (Array.isArray(content) && content.length === 0); + const hasData = !hasNoData; + + const buildEmptyPlaceHolder = () => ( + + + + {emptyDataMessage} + + + ); + + const handleOnContainerClick = () => { + if (hasNoData) { + onEmptyClick(); + } + }; + + // Stop propagation to prevent executing handleOnContainerClick. + const handleTitleIconClick = (e: MouseEvent) => e.stopPropagation(); + + return ( + + + + + + {/* The padding allow to align all container titles when buttons are display or not */} + + {title} + + {titleIcon} + + {hasData && ( + + {titleActionBtn} + + )} + + {hasNoData && ( + + + + )} + + + {hasNoData ? buildEmptyPlaceHolder() : content} + + + ); +}; + +export default PublicationAttributeContainer; diff --git a/src/components/item/publish/PublicationButton.tsx b/src/components/item/publish/PublicationButton.tsx new file mode 100644 index 000000000..f97668c46 --- /dev/null +++ b/src/components/item/publish/PublicationButton.tsx @@ -0,0 +1,214 @@ +import LinkIcon from '@mui/icons-material/Link'; +import { LoadingButton } from '@mui/lab'; +import { Alert, Button, Stack, Typography } from '@mui/material'; + +import { ClientHostManager, PackedItem, ShortLinkPlatform } from '@graasp/sdk'; + +import { CheckIcon } from 'lucide-react'; + +import { ADMIN_CONTACT } from '@/config/constants'; +import { useBuilderTranslation } from '@/config/i18n'; +import { mutations } from '@/config/queryClient'; +import { buildItemPublicationButton } from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; +import { PublicationStatus, PublicationStatusMap } from '@/types/publication'; + +import ContentLoader from '../../common/ContentLoader'; +import useModalStatus from '../../hooks/useModalStatus'; +import usePublicationStatus from '../../hooks/usePublicationStatus'; +import PublicVisibilityModal from './PublicVisibilityModal'; + +type Props = { + item: PackedItem; + notifyCoEditors: boolean; +}; + +type PublicationButtonMap = PublicationStatusMap<{ + description: JSX.Element | string; + elements: JSX.Element | JSX.Element[]; +}>; + +const { useUnpublishItem, usePublishItem, usePostItemValidation } = mutations; + +export const PublicationButton = ({ + item, + notifyCoEditors, +}: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + const { id: itemId, public: isPublic } = item; + const { status, isinitialLoading: isStatusFirstLoading } = + usePublicationStatus({ item }); + const { isOpen, openModal, closeModal } = useModalStatus(); + + const { mutate: validateItem, isLoading: isValidating } = + usePostItemValidation(); + const { mutate: unpublish, isLoading: isUnPublishing } = useUnpublishItem(); + const { mutate: publish, isLoading: isPublishing } = usePublishItem(); + + const publishItem = () => + publish({ id: itemId, notification: notifyCoEditors }); + + const handlePublishItem = () => { + if (isPublic) { + publishItem(); + } else { + openModal(); + } + }; + + const handleValidateItem = () => validateItem({ itemId }); + const handleUnPublishItem = () => unpublish({ id: itemId }); + + const handleModalValidate = () => { + publishItem(); + closeModal(); + }; + + const getLibraryLink = () => { + const clientHostManager = ClientHostManager.getInstance(); + return clientHostManager.getItemLink(ShortLinkPlatform.library, itemId); + }; + + const publicationButtonMap: PublicationButtonMap = { + [PublicationStatus.Unpublished]: { + description: t(BUILDER.LIBRARY_SETTINGS_VALIDATION_INFORMATIONS), + elements: ( + + {t(BUILDER.LIBRARY_SETTINGS_VALIDATION_VALIDATE_BUTTON)} + + ), + }, + [PublicationStatus.Pending]: { + description: t( + BUILDER.LIBRARY_SETTINGS_VALIDATION_STATUS_PENDING_AUTOMATIC, + ), + elements: [], + }, + [PublicationStatus.ReadyToPublish]: { + description: ( + } severity="info"> + {t(BUILDER.LIBRARY_SETTINGS_VALIDATION_STATUS_READY_TO_PUBLISH)} + + ), + elements: ( + + {t(BUILDER.LIBRARY_SETTINGS_PUBLISH_BUTTON)} + + ), + }, + [PublicationStatus.NotPublic]: { + description: t(BUILDER.LIBRARY_SETTINGS_VISIBILITY_INFORMATIONS), + elements: ( + + {t(BUILDER.LIBRARY_SETTINGS_VISIBILITY_CHANGE_BUTTON)} + + ), + }, + [PublicationStatus.Published]: { + description: t(BUILDER.LIBRARY_SETTINGS_PUBLISHED_STATUS), + elements: [ + + {t(BUILDER.LIBRARY_SETTINGS_UNPUBLISH_BUTTON)} + , + , + ], + }, + [PublicationStatus.PublishedChildren]: { + description: t(BUILDER.LIBRARY_SETTINGS_CHILD_PUBLISHED_STATUS), + elements: [], + }, + [PublicationStatus.Invalid]: { + description: t(BUILDER.LIBRARY_SETTINGS_VALIDATION_STATUS_FAILURE, { + contact: ADMIN_CONTACT, + }), + elements: ( + + {t(BUILDER.LIBRARY_SETTINGS_RETRY_BUTTON)} + + ), + }, + [PublicationStatus.Outdated]: { + description: t(BUILDER.LIBRARY_SETTINGS_VALIDATION_STATUS_OUTDATED), + elements: ( + + {t(BUILDER.LIBRARY_SETTINGS_VALIDATION_VALIDATE_BUTTON)} + + ), + }, + }; + + const getDescriptionElement = ( + description: string | JSX.Element, + ): JSX.Element => { + if (typeof description === 'string') { + return {description}; + } + + return description; + }; + + const { description, elements } = publicationButtonMap[status]; + + return ( + <> + {!isPublic && ( + + )} + + + {getDescriptionElement(description)} + + + {elements} + + + + + ); +}; + +export default PublicationButton; diff --git a/src/components/item/publish/PublicationChipContainer.tsx b/src/components/item/publish/PublicationChipContainer.tsx new file mode 100644 index 000000000..e1490bd55 --- /dev/null +++ b/src/components/item/publish/PublicationChipContainer.tsx @@ -0,0 +1,126 @@ +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import { Chip, IconButton, Stack, Tooltip, styled } from '@mui/material'; + +import { theme } from '@graasp/ui'; + +import { + buildLibraryAddButtonHeader, + buildPublishChip, + buildPublishChipContainer, +} from '@/config/selectors'; + +import PublicationAttributeContainer from './PublicationAttributeContainer'; + +const options = { + shouldForwardProp: (prop: string) => + prop !== 'deleteIconColor' && prop !== 'chipColor', +}; +const StyledChip = styled( + Chip, + options, +)(({ chipColor }: { chipColor?: string }) => ({ + color: chipColor, + '& .MuiChip-deleteIcon': { + color: chipColor, + opacity: chipColor ? 0.8 : 1.0, + }, + ':hover': { + '& .MuiChip-deleteIcon': { + color: chipColor, + transform: 'scale(1.05)', + opacity: 1.0, + }, + }, + ':active': { + '& .MuiChip-deleteIcon': { + transform: 'scale(0.99)', + }, + }, +})); + +const computeChipColor = ( + chipColor: string | string[] | undefined, + idx: number, +) => { + if (!chipColor) { + return undefined; + } + if (Array.isArray(chipColor)) { + return chipColor[idx]; + } + return chipColor; +}; + +type Props = { + dataTestId: string; + title: string; + attributeDescription: string; + emptyDataMessage: string; + data?: string[]; + isLoading?: boolean; + color?: string | string[]; + onChipDelete: (chipValue: string) => void; + onAddClicked: () => void; +}; + +export const PublicationChipContainer = ({ + dataTestId, + title, + attributeDescription, + emptyDataMessage, + data, + isLoading = false, + color, + onChipDelete, + onAddClicked, +}: Props): JSX.Element => { + const chips = (data ?? []).map((d, idx) => ( + onChipDelete(d)} + data-cy={buildPublishChip(d)} + chipColor={computeChipColor(color, idx)} + /> + )); + + const content = + chips?.length > 0 ? ( + + {chips} + + ) : undefined; + + return ( + + + + + + } + isLoading={isLoading} + emptyDataMessage={emptyDataMessage} + attributeDescription={attributeDescription} + content={content} + onEmptyClick={onAddClicked} + /> + ); +}; + +export default PublicationChipContainer; diff --git a/src/components/item/publish/PublicationModal.tsx b/src/components/item/publish/PublicationModal.tsx new file mode 100644 index 000000000..a9de2e165 --- /dev/null +++ b/src/components/item/publish/PublicationModal.tsx @@ -0,0 +1,45 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; + +import { DIALOG_CONTENT_WIDTH } from '@/config/constants'; +import { useBuilderTranslation } from '@/config/i18n'; +import { BUILDER } from '@/langs/constants'; + +type Props = { + title?: string; + isOpen: boolean; + modalContent: JSX.Element; + dialogActions?: JSX.Element[] | JSX.Element; + handleOnClose: () => void; +}; + +export const PublicationModal = ({ + title, + isOpen, + modalContent, + dialogActions, + handleOnClose, +}: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + + return ( + + {title && {title}} + + {modalContent} + + + {dialogActions ?? ( + + )} + + + ); +}; + +export default PublicationModal; diff --git a/src/components/item/publish/PublicationStatusComponent.tsx b/src/components/item/publish/PublicationStatusComponent.tsx new file mode 100644 index 000000000..342a7a671 --- /dev/null +++ b/src/components/item/publish/PublicationStatusComponent.tsx @@ -0,0 +1,100 @@ +import CloudDoneIcon from '@mui/icons-material/CloudDone'; +import CloudOffIcon from '@mui/icons-material/CloudOff'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import ErrorIcon from '@mui/icons-material/Error'; +import EventBusyIcon from '@mui/icons-material/EventBusy'; +import PendingActionsIcon from '@mui/icons-material/PendingActions'; +import PublicOffIcon from '@mui/icons-material/PublicOff'; +import { Chip, ChipProps, CircularProgress } from '@mui/material'; + +import { PackedItem } from '@graasp/sdk'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { buildPublicationStatus } from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; +import { PublicationStatus, PublicationStatusMap } from '@/types/publication'; + +import usePublicationStatus from '../../hooks/usePublicationStatus'; + +type PublicationComponentMap = PublicationStatusMap<{ + icon: JSX.Element; + label: string; + color: ChipProps['color'] | undefined; +}>; + +type Props = { + item: PackedItem; +}; + +export const PublicationStatusComponent = ({ item }: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + const { status, isinitialLoading } = usePublicationStatus({ item }); + + if (isinitialLoading) { + return ( + } + label={t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_LOADING)} + variant="outlined" + color="info" + /> + ); + } + + const chipMap: PublicationComponentMap = { + [PublicationStatus.Published]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_PUBLISHED), + color: 'success', + }, + [PublicationStatus.PublishedChildren]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_PUBLISHED_CHILDREN), + color: 'success', + }, + [PublicationStatus.Pending]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_PENDING), + color: 'warning', + }, + [PublicationStatus.ReadyToPublish]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_READY_TO_PUBLISH), + color: 'info', + }, + [PublicationStatus.NotPublic]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_NOT_PUBLIC), + color: 'error', + }, + [PublicationStatus.Invalid]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_INVALID), + color: 'error', + }, + [PublicationStatus.Outdated]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_OUTDATED), + color: 'warning', + }, + [PublicationStatus.Unpublished]: { + icon: , + label: t(BUILDER.LIBRARY_SETTINGS_PUBLICATION_STATUS_UNPUBLISHED), + color: undefined, + }, + } as const; + + const { icon, label, color } = chipMap[status]; + + return ( + + ); +}; + +export default PublicationStatusComponent; diff --git a/src/components/item/publish/PublicationThumbnail.tsx b/src/components/item/publish/PublicationThumbnail.tsx new file mode 100644 index 000000000..5632f1ce2 --- /dev/null +++ b/src/components/item/publish/PublicationThumbnail.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; + +import WarningIcon from '@mui/icons-material/Warning'; +import { Tooltip } from '@mui/material'; + +import { DiscriminatedItem } from '@graasp/sdk'; + +import { title } from 'process'; + +import ThumbnailUploader, { + EventChanges, +} from '@/components/thumbnails/ThumbnailUploader'; +import { WARNING_COLOR } from '@/config/constants'; +import { useBuilderTranslation } from '@/config/i18n'; +import { buildPublishWarningIcon } from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +const THUMBNAIL_SIZE = 150; +const SYNC_STATUS_KEY = 'PublicationThumbnail'; + +type Props = { + item: DiscriminatedItem; + thumbnailSize?: number; + fullWidth?: boolean; +}; +export const PublicationThumbnail = ({ + item, + thumbnailSize = THUMBNAIL_SIZE, + fullWidth = false, +}: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + const [showWarning, setShowWarning] = useState(false); + + const handleChange = (e: EventChanges) => { + switch (e) { + case EventChanges.ON_UPLOADING: + case EventChanges.ON_HAS_THUMBNAIL: + setShowWarning(false); + break; + case EventChanges.ON_NO_THUMBNAIL: + setShowWarning(true); + break; + default: + // nothing to do + } + }; + + const warningTooltip = showWarning ? ( + + + + ) : undefined; + + return ( + + ); +}; + +export default PublicationThumbnail; diff --git a/src/components/item/publish/customizedTags/CustomizedTags.hook.tsx b/src/components/item/publish/customizedTags/CustomizedTags.hook.tsx new file mode 100644 index 000000000..4d72f6195 --- /dev/null +++ b/src/components/item/publish/customizedTags/CustomizedTags.hook.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; + +import { DiscriminatedItem } from '@graasp/sdk'; + +import { mutations } from '@/config/queryClient'; + +import { useDataSyncContext } from '../../../context/DataSyncContext'; + +const SYNC_STATUS_KEY = 'CustomizedTags'; + +type Props = { + item: DiscriminatedItem; + enableNotifications?: boolean; +}; + +type UseCustomizedTags = { + tags: string[]; + hasTags: boolean; + deleteTag: (removedTag: string) => void; + saveTags: (newTags: string[]) => void; +}; + +export const useCustomizedTags = ({ + item, + enableNotifications = true, +}: Props): UseCustomizedTags => { + const { computeStatusFor } = useDataSyncContext(); + const { settings, id: itemId } = item; + const tags = settings?.tags ?? []; + const hasTags = tags.length > 0; + + const { + mutate: updateCustomizedTags, + isSuccess, + isLoading, + isError, + } = mutations.useEditItem({ + enableNotifications, + }); + + useEffect( + () => computeStatusFor(SYNC_STATUS_KEY, { isLoading, isSuccess, isError }), + [isLoading, isSuccess, isError, computeStatusFor], + ); + + const saveTags = (newTags: string[]) => { + updateCustomizedTags({ + id: itemId, + settings: { tags: newTags }, + }); + }; + + const deleteTag = (tagToDelete: string) => { + saveTags( + tags?.filter((t) => t.toLowerCase() !== tagToDelete.toLowerCase()), + ); + }; + + return { + tags, + hasTags, + deleteTag, + saveTags, + }; +}; + +export default useCustomizedTags; diff --git a/src/components/item/publish/customizedTags/CustomizedTags.tsx b/src/components/item/publish/customizedTags/CustomizedTags.tsx new file mode 100644 index 000000000..4af969eb7 --- /dev/null +++ b/src/components/item/publish/customizedTags/CustomizedTags.tsx @@ -0,0 +1,83 @@ +import EditIcon from '@mui/icons-material/Edit'; +import WarningIcon from '@mui/icons-material/Warning'; +import { Chip, Stack, Tooltip } from '@mui/material'; + +import { DiscriminatedItem } from '@graasp/sdk'; + +import MultiSelectChipInput from '@/components/input/MultiSelectChipInput'; +import { WARNING_COLOR } from '@/config/constants'; +import { useBuilderTranslation } from '@/config/i18n'; +import { + ITEM_TAGS_OPEN_MODAL_BUTTON_ID, + buildCustomizedTagsSelector, +} from '@/config/selectors'; +import { BUILDER } from '@/langs/constants'; + +import { useModalStatus } from '../../../hooks/useModalStatus'; +import PublicationModal from '../PublicationModal'; +import useCustomizedTags from './CustomizedTags.hook'; + +type Props = { + item: DiscriminatedItem; + warningWhenNoTags?: boolean; +}; + +export const CustomizedTags = ({ + item, + warningWhenNoTags = false, +}: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + const { isOpen, openModal, closeModal } = useModalStatus(); + const { tags, hasTags, saveTags, deleteTag } = useCustomizedTags({ + item, + enableNotifications: false, + }); + const showWarning = warningWhenNoTags && !hasTags; + + const onSave = (newTags: string[]) => { + saveTags(newTags); + }; + + const chipTags = tags.map((tag, idx) => ( + deleteTag(tag)} + data-cy={buildCustomizedTagsSelector(idx)} + /> + )); + + return ( + <> + + } + /> + + } + label={t(BUILDER.ITEM_TAGS_CHIP_BUTTON_EDIT)} + variant="outlined" + onClick={openModal} + data-cy={ITEM_TAGS_OPEN_MODAL_BUTTON_ID} + /> + {chipTags} + {showWarning && ( + + + + )} + + + ); +}; + +export default CustomizedTags; diff --git a/src/components/item/settings/CustomizedTagsSettings.tsx b/src/components/item/settings/CustomizedTagsSettings.tsx new file mode 100644 index 000000000..f9559f47b --- /dev/null +++ b/src/components/item/settings/CustomizedTagsSettings.tsx @@ -0,0 +1,26 @@ +import { Stack, Typography } from '@mui/material'; + +import { DiscriminatedItem } from '@graasp/sdk'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { BUILDER } from '@/langs/constants'; + +import CustomizedTags from '../publish/customizedTags/CustomizedTags'; + +type Props = { + item: DiscriminatedItem; +}; + +export const CustomizedTagsSettings = ({ item }: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + + return ( + + {t(BUILDER.ITEM_TAGS_TITLE)} + {t(BUILDER.ITEM_TAGS_PLACEHOLDER)} + + + ); +}; + +export default CustomizedTagsSettings; diff --git a/src/components/item/settings/ItemSettingProperty.tsx b/src/components/item/settings/ItemSettingProperty.tsx index 124d2ae90..1668c47a4 100644 --- a/src/components/item/settings/ItemSettingProperty.tsx +++ b/src/components/item/settings/ItemSettingProperty.tsx @@ -5,7 +5,7 @@ type Props = { valueText?: string | JSX.Element; inputSetting: JSX.Element; icon?: JSX.Element; - additionalInfo?: JSX.Element; + additionalInfo?: JSX.Element | null; }; const ItemSettingProperty = ({ diff --git a/src/components/item/settings/ItemSettings.tsx b/src/components/item/settings/ItemSettings.tsx index a211b87ca..cbf990ee7 100644 --- a/src/components/item/settings/ItemSettings.tsx +++ b/src/components/item/settings/ItemSettings.tsx @@ -5,8 +5,8 @@ import Container from '@mui/material/Container'; import { OutletType } from '@/components/pages/item/type'; -import CustomizedTagsEdit from '../publish/CustomizedTagsEdit'; import AdminChatSettings from './AdminChatSettings'; +import CustomizedTagsSettings from './CustomizedTagsSettings'; import GeolocationPicker from './GeolocationPicker'; import ItemLicenseSettings from './ItemLicenseSettings'; import ItemMetadataContent from './ItemMetadataContent'; @@ -21,7 +21,7 @@ const ItemSettings = (): JSX.Element => { - + diff --git a/src/components/item/settings/UpdateLicenseDialog.tsx b/src/components/item/settings/UpdateLicenseDialog.tsx index 82b893438..e629237e8 100644 --- a/src/components/item/settings/UpdateLicenseDialog.tsx +++ b/src/components/item/settings/UpdateLicenseDialog.tsx @@ -14,8 +14,8 @@ import { DiscriminatedItem } from '@graasp/sdk'; import { useBuilderTranslation } from '@/config/i18n'; import { BUILDER } from '@/langs/constants'; -import ConfirmLicenseDialogContent from '../publish/ConfirmLicenseDialogContent'; -import useItemLicense from '../publish/useItemLicense'; +import ConfirmLicenseDialogContent from '../../common/ConfirmLicenseDialogContent'; +import useItemLicense from '../../hooks/useItemLicense'; type Props = { open: boolean; diff --git a/src/components/item/sharing/VisibilitySelect.tsx b/src/components/item/sharing/VisibilitySelect.tsx index 162628b78..c931f7a7b 100644 --- a/src/components/item/sharing/VisibilitySelect.tsx +++ b/src/components/item/sharing/VisibilitySelect.tsx @@ -1,173 +1,21 @@ -import { useEffect, useMemo, useState } from 'react'; +import { MenuItem, Select, Typography } from '@mui/material'; -import { MenuItem, Select, SelectChangeEvent, Typography } from '@mui/material'; - -import { ItemLoginSchemaType, ItemTagType, PackedItem } from '@graasp/sdk'; +import { PackedItem } from '@graasp/sdk'; import { Loader } from '@graasp/ui'; +import useVisibility from '@/components/hooks/useVisibility'; + import { SETTINGS } from '../../../config/constants'; import { useBuilderTranslation } from '../../../config/i18n'; -import { hooks, mutations } from '../../../config/queryClient'; import { SHARE_ITEM_VISIBILITY_SELECT_ID } from '../../../config/selectors'; import { BUILDER } from '../../../langs/constants'; import ItemLoginSchemaSelect from './ItemLoginSchemaSelect'; -const { useItemLoginSchema, useItemPublishedInformation } = hooks; -const { - useDeleteItemTag, - usePostItemTag, - useUnpublishItem, - useDeleteItemLoginSchema, - usePutItemLoginSchema, -} = mutations; - type Props = { item: PackedItem; edit?: boolean; }; -const useVisibility = (item: PackedItem) => { - const { mutate: postItemTag } = usePostItemTag(); - const { mutate: deleteItemTag } = useDeleteItemTag(); - - // get item published - const { data: itemPublishEntry, isLoading: isItemPublishEntryLoading } = - useItemPublishedInformation( - { itemId: item.id }, - { enabled: Boolean(item.public) }, - ); - const { mutate: unpublish } = useUnpublishItem(); - - // item login tag and item extra value - const { data: itemLoginSchema, isLoading: isItemLoginLoading } = - useItemLoginSchema({ itemId: item.id }); - const { mutate: deleteItemLoginSchema } = useDeleteItemLoginSchema(); - const { mutate: putItemLoginSchema } = usePutItemLoginSchema(); - - // is disabled - const [isDisabled, setIsDisabled] = useState(false); - useEffect(() => { - // disable setting if any visiblity is set on any parent items - setIsDisabled( - Boolean( - (itemLoginSchema && itemLoginSchema?.item?.path !== item?.path) || - (item?.public && item?.public?.item?.path !== item?.path), - ), - ); - }, [itemLoginSchema, item]); - - // is loading - const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - setIsLoading(isItemPublishEntryLoading || isItemLoginLoading); - }, [isItemPublishEntryLoading, isItemLoginLoading]); - - // is error - const [isError] = useState(false); - - // visibility - const [visibility, setVisibility] = useState(); - useEffect(() => { - switch (true) { - case Boolean(item.public): { - setVisibility(SETTINGS.ITEM_PUBLIC.name); - break; - } - case Boolean(itemLoginSchema?.id): { - setVisibility(SETTINGS.ITEM_LOGIN.name); - break; - } - default: - setVisibility(SETTINGS.ITEM_PRIVATE.name); - } - }, [itemPublishEntry, item, itemLoginSchema]); - - const handleChange = useMemo( - () => (event: SelectChangeEvent) => { - const newTag = event.target.value; - - // deletes both public and published tags if they exists - const deletePublishedAndPublic = () => { - if (itemPublishEntry) { - unpublish({ id: item.id }); - } - - if (item.public) { - deleteItemTag({ itemId: item.id, type: ItemTagType.Public }); - } - }; - - const deleteLoginSchema = () => { - if (itemLoginSchema) { - deleteItemLoginSchema({ - itemId: item.id, - }); - } - }; - - switch (newTag) { - case SETTINGS.ITEM_PRIVATE.name: { - deletePublishedAndPublic(); - deleteLoginSchema(); - break; - } - case SETTINGS.ITEM_LOGIN.name: { - deletePublishedAndPublic(); - putItemLoginSchema({ - itemId: item.id, - type: ItemLoginSchemaType.Username, - }); - break; - } - case SETTINGS.ITEM_PUBLIC.name: { - postItemTag({ - itemId: item.id, - type: ItemTagType.Public, - }); - deleteLoginSchema(); - break; - } - default: - break; - } - }, - [ - deleteItemLoginSchema, - deleteItemTag, - item.id, - itemLoginSchema, - itemPublishEntry, - postItemTag, - item.public, - putItemLoginSchema, - unpublish, - ], - ); - - return useMemo( - () => ({ - isLoading, - isError, - isDisabled, - itemPublishEntry, - itemLoginSchema, - publicTag: item.public, - visibility, - handleChange, - }), - [ - isLoading, - isError, - isDisabled, - itemPublishEntry, - itemLoginSchema, - item.public, - visibility, - handleChange, - ], - ); -}; - const VisibilitySelect = ({ item, edit }: Props): JSX.Element | null => { const { t: translateBuilder } = useBuilderTranslation(); @@ -177,7 +25,7 @@ const VisibilitySelect = ({ item, edit }: Props): JSX.Element | null => { isDisabled, itemLoginSchema, isLoading, - handleChange, + updateVisibility, } = useVisibility(item); if (isLoading) { @@ -223,7 +71,7 @@ const VisibilitySelect = ({ item, edit }: Props): JSX.Element | null => { {edit && (