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