diff --git a/cypress/components/common/MultiSelectChipInput.cy.tsx b/cypress/components/common/MultiSelectChipInput.cy.tsx deleted file mode 100644 index 93345e2b2..000000000 --- a/cypress/components/common/MultiSelectChipInput.cy.tsx +++ /dev/null @@ -1,145 +0,0 @@ -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/e2e/item/publish/categories.cy.ts b/cypress/e2e/item/publish/categories.cy.ts deleted file mode 100644 index f0d0f57bf..000000000 --- a/cypress/e2e/item/publish/categories.cy.ts +++ /dev/null @@ -1,171 +0,0 @@ -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, - ITEM_WITH_CATEGORIES_CONTEXT, - SAMPLE_CATEGORIES, -} from '../../../fixtures/categories'; -import { PUBLISHED_ITEM } from '../../../fixtures/items'; -import { MEMBERS, SIGNED_OUT_MEMBER } from '../../../fixtures/members'; - -const CATEGORIES_DATA_CY = buildDataCyWrapper( - buildPublishChipContainer(LIBRARY_SETTINGS_CATEGORIES_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 openCategoriesModal = () => { - cy.get(buildDataCyWrapper(CATEGORIES_ADD_BUTTON_HEADER)).click(); -}; - -describe('Categories', () => { - describe('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(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)); - openPublishItemTab(item.id); - }); - - it('Display item category', () => { - // check for displaying value - const { - categories: [{ category }], - } = item; - const { name } = SAMPLE_CATEGORIES.find(({ id }) => id === category.id); - const categoryContent = cy.get(CATEGORIES_DATA_CY); - categoryContent.contains(name); - }); - - describe('Delete a category', () => { - 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', () => { - 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(); - }); - - 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 }, - } = data; - expect(url.split('/')).contains(item.id); - }); - }); - }); - - // users without permission will not see the sections - describe('Categories permissions', () => { - it('User signed out cannot edit category 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'); - cy.get(`#${buildCategorySelectionId(CategoryType.Level)}`).should( - 'not.exist', - ); - }); - - it('Read-only user cannot edit category 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'); - cy.get(`#${buildCategorySelectionId(CategoryType.Level)}`).should( - 'not.exist', - ); - }); - }); -}); diff --git a/cypress/e2e/item/publish/coEditorSettings.cy.ts b/cypress/e2e/item/publish/coEditorSettings.cy.ts index 23fd1587a..c0baed957 100644 --- a/cypress/e2e/item/publish/coEditorSettings.cy.ts +++ b/cypress/e2e/item/publish/coEditorSettings.cy.ts @@ -9,16 +9,16 @@ import { buildDataCyWrapper, buildPublishButtonId, } from '../../../../src/config/selectors'; -import { ITEM_WITH_CATEGORIES_CONTEXT } from '../../../fixtures/categories'; import { MEMBERS, SIGNED_OUT_MEMBER } from '../../../fixtures/members'; +import { ITEM_WITH_TAGS_CONTEXT } from '../../../fixtures/tags'; import { EDIT_TAG_REQUEST_TIMEOUT } from '../../../support/constants'; const openPublishItemTab = (id: string) => { cy.get(`#${buildPublishButtonId(id)}`).click(); }; const visitItemPage = () => { - cy.setUpApi(ITEM_WITH_CATEGORIES_CONTEXT); - const item = ITEM_WITH_CATEGORIES_CONTEXT.items[0]; + cy.setUpApi(ITEM_WITH_TAGS_CONTEXT); + const item = ITEM_WITH_TAGS_CONTEXT.items[0]; cy.visit(buildItemPath(item.id)); openPublishItemTab(item.id); }; @@ -35,7 +35,7 @@ describe('Co-editor Setting', () => { it('Change choice', () => { visitItemPage(); - const item = ITEM_WITH_CATEGORIES_CONTEXT.items[0]; + const item = ITEM_WITH_TAGS_CONTEXT.items[0]; const newOptionValue = DISPLAY_CO_EDITORS_OPTIONS.NO.value; cy.wait('@getPublicationStatus').then(() => { diff --git a/cypress/e2e/item/publish/tags.cy.ts b/cypress/e2e/item/publish/tags.cy.ts index b91554063..7c439381b 100644 --- a/cypress/e2e/item/publish/tags.cy.ts +++ b/cypress/e2e/item/publish/tags.cy.ts @@ -2,97 +2,134 @@ import { ItemVisibilityType, PackedFolderItemFactory, PermissionLevel, + TagCategory, } from '@graasp/sdk'; import { buildItemPath } from '../../../../src/config/paths'; import { ITEM_HEADER_ID, - ITEM_TAGS_OPEN_MODAL_BUTTON_ID, + ITEM_TAGS_OPEN_MODAL_BUTTON_CY, MUI_CHIP_REMOVE_BTN, - MULTI_SELECT_CHIP_ADD_BUTTON_ID, - MULTI_SELECT_CHIP_INPUT_ID, buildCustomizedTagsSelector, buildDataCyWrapper, buildDataTestIdWrapper, + buildMultiSelectChipInputId, buildPublishButtonId, } from '../../../../src/config/selectors'; -import { - ITEM_WITH_CATEGORIES, - ITEM_WITH_CATEGORIES_CONTEXT, -} from '../../../fixtures/categories'; import { PUBLISHED_ITEM_NO_TAGS } from '../../../fixtures/items'; import { MEMBERS, SIGNED_OUT_MEMBER } from '../../../fixtures/members'; +import { SAMPLE_TAGS } from '../../../fixtures/tags'; import { EDIT_TAG_REQUEST_TIMEOUT } from '../../../support/constants'; import { ItemForTest } from '../../../support/types'; +const ITEM_WITH_TAGS = { + ...PackedFolderItemFactory( + { + settings: { + displayCoEditors: true, + }, + }, + { permission: PermissionLevel.Admin }, + ), + tags: SAMPLE_TAGS.slice(0, 2), +}; + const openPublishItemTab = (id: string) => { cy.get(`#${buildPublishButtonId(id)}`).click(); }; const visitItemPage = (item: ItemForTest) => { - cy.setUpApi(ITEM_WITH_CATEGORIES_CONTEXT); + cy.setUpApi({ items: [item] }); cy.visit(buildItemPath(item.id)); openPublishItemTab(item.id); }; describe('Customized Tags', () => { it('Display item without tags', () => { - // check for not displaying if no tags const item = PUBLISHED_ITEM_NO_TAGS; cy.setUpApi({ items: [item] }); cy.visit(buildItemPath(item.id)); openPublishItemTab(item.id); - cy.get(buildDataCyWrapper(buildCustomizedTagsSelector(0))).should( - 'not.exist', - ); + // check display edit button + cy.get(buildDataCyWrapper(ITEM_TAGS_OPEN_MODAL_BUTTON_CY)) + // scroll because description can be long + .scrollIntoView() + .should('be.visible'); }); it('Display tags', () => { - const item = ITEM_WITH_CATEGORIES; + const item = ITEM_WITH_TAGS; visitItemPage(item); - expect(item.settings.tags).to.have.lengthOf.above(0); - item.settings.tags!.forEach((tag, index) => { + expect(item.tags).to.have.lengthOf.above(0); + item.tags.forEach((tag) => { const displayTags = cy.get( - buildDataCyWrapper(buildCustomizedTagsSelector(index)), + buildDataCyWrapper(buildCustomizedTagsSelector(tag.id)), ); - displayTags.contains(tag); + displayTags.contains(tag.name); }); }); it('Remove tag', () => { - const item = ITEM_WITH_CATEGORIES; - const removeIdx = 0; - const removedTag = item.settings.tags[removeIdx]; + const item = ITEM_WITH_TAGS; + const tagToRemove = ITEM_WITH_TAGS.tags[1]; visitItemPage(item); - cy.get(buildDataCyWrapper(buildCustomizedTagsSelector(removeIdx))) + cy.get(buildDataCyWrapper(buildCustomizedTagsSelector(tagToRemove.id))) .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); - }); + cy.wait('@removeTag', { timeout: EDIT_TAG_REQUEST_TIMEOUT }).then( + (data) => { + const { + request: { url }, + } = data; + expect(url.split('/')).contains(item.id).contains(tagToRemove.id); + }, + ); }); it('Add tag', () => { - const item = ITEM_WITH_CATEGORIES; - const newTag = 'My new tag'; + cy.intercept( + { + method: 'Get', + url: /\/tags\?search=/, + }, + ({ reply }) => + reply([ + { name: 'secondary school', category: TagCategory.Level }, + ...SAMPLE_TAGS, + ]), + ).as('getTags'); + + const item = ITEM_WITH_TAGS; + const newTag = { name: 'My new tag', category: TagCategory.Level }; 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.get(buildDataCyWrapper(ITEM_TAGS_OPEN_MODAL_BUTTON_CY)).click(); + cy.get(buildDataCyWrapper(buildMultiSelectChipInputId(TagCategory.Level))) + // debounce of 500 + .type(`${newTag.name}`); + + // should call get tags when typing + cy.wait('@getTags').then(({ request: { query } }) => { + expect(query.search).to.contain(newTag.name); + expect(query.category).to.contain(newTag.category); + }); + + // display options for opened category + cy.get(`li:contains("secondary school")`).should('be.visible'); + + cy.get( + buildDataCyWrapper(buildMultiSelectChipInputId(TagCategory.Level)), + ).type('{Enter}'); - cy.wait('@editItem', { timeout: EDIT_TAG_REQUEST_TIMEOUT }).then((data) => { + cy.wait('@addTag', { timeout: EDIT_TAG_REQUEST_TIMEOUT }).then((data) => { const { request: { url, body }, } = data; expect(url.split('/')).contains(item.id); - expect(body.settings.tags).contains(newTag); + expect(body.name).contains(newTag.name); + expect(body.category).contains(newTag.category); }); }); }); diff --git a/cypress/fixtures/categories.ts b/cypress/fixtures/tags.ts similarity index 54% rename from cypress/fixtures/categories.ts rename to cypress/fixtures/tags.ts index f641c710d..01481663b 100644 --- a/cypress/fixtures/categories.ts +++ b/cypress/fixtures/tags.ts @@ -1,82 +1,62 @@ import { - Category, - CategoryType, - ItemCategory, ItemValidation, ItemValidationProcess, ItemValidationStatus, + Tag, + TagCategory, } from '@graasp/sdk'; import { ItemForTest } from '../support/types'; import { PUBLISHED_ITEM } from './items'; -import { MEMBERS } from './members'; -export const SAMPLE_CATEGORIES: Category[] = [ +export const SAMPLE_TAGS: Tag[] = [ { id: 'e873d800-5647-442c-930d-2d677532846a', - name: 'test_category', - type: CategoryType.Discipline, + name: 'discipline 1', + category: TagCategory.Discipline, + }, + { + id: '152ef74e-8893-4736-926e-214c17396ed3', + name: 'level 1', + category: TagCategory.Level, }, { id: '352ef74e-8893-4736-926e-214c17396ed3', - name: 'test_category_2', - type: CategoryType.Level, + name: 'level 2', + category: TagCategory.Level, }, { id: 'ba7f7e3d-dc75-4070-b892-381fbf4759d9', - name: 'language-1', - type: CategoryType.Language, + name: 'resource-type-1', + category: TagCategory.ResourceType, }, { id: 'af7f7e3d-dc75-4070-b892-381fbf4759d5', - name: 'language-2', - type: CategoryType.Language, - }, -]; - -export const SAMPLE_ITEM_CATEGORIES: ItemCategory[] = [ - { - id: 'e75e1950-c5b4-4e21-95a2-c7c3bfa4072b', - item: PUBLISHED_ITEM, - category: SAMPLE_CATEGORIES[0], - createdAt: '2021-08-11T12:56:36.834Z', - creator: MEMBERS.ANNA, + name: 'resource-type-2', + category: TagCategory.ResourceType, }, ]; -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 CUSTOMIZED_TAGS = ['water', 'ice', 'temperature']; - -export const ITEM_WITH_CATEGORIES: ItemForTest = { +export const ITEM_WITH_TAGS: ItemForTest = { ...PUBLISHED_ITEM, settings: { - tags: CUSTOMIZED_TAGS, displayCoEditors: true, }, // for tests - categories: SAMPLE_ITEM_CATEGORIES, + tags: SAMPLE_TAGS, }; -export const ITEM_WITH_CATEGORIES_CONTEXT = { - items: [ITEM_WITH_CATEGORIES], +export const ITEM_WITH_TAGS_CONTEXT = { + items: [ITEM_WITH_TAGS], itemValidationGroups: [ { id: '65c57d69-0e59-4569-a422-f330c31c995c', - item: ITEM_WITH_CATEGORIES, + item: ITEM_WITH_TAGS, createdAt: '2021-08-11T12:56:36.834Z', itemValidations: [ { id: 'id1', - item: ITEM_WITH_CATEGORIES, + item: ITEM_WITH_TAGS, // itemValidationGroup: iVG, process: ItemValidationProcess.BadWordsDetection, status: ItemValidationStatus.Success, @@ -86,7 +66,7 @@ export const ITEM_WITH_CATEGORIES_CONTEXT = { }, { id: 'id2', - item: ITEM_WITH_CATEGORIES, + item: ITEM_WITH_TAGS, // itemValidationGroup: iVG, process: ItemValidationProcess.ImageChecking, status: ItemValidationStatus.Success, diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 25c8fe12c..1061ca443 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -6,13 +6,13 @@ import { ItemLayoutMode } from '@/enums'; import { LAYOUT_MODE_BUTTON_ID } from '../../src/config/selectors'; import { APPS_LIST } from '../fixtures/apps/apps'; -import { SAMPLE_CATEGORIES } from '../fixtures/categories'; import { SAMPLE_MENTIONS } from '../fixtures/chatbox'; import { CURRENT_USER, MEMBERS } from '../fixtures/members'; import './commands/item'; import './commands/navigation'; import { mockAddFavorite, + mockAddTag, mockAppApiAccessToken, mockCheckShortLink, mockClearItemChat, @@ -21,7 +21,6 @@ import { mockDeleteAppData, mockDeleteFavorite, mockDeleteInvitation, - mockDeleteItemCategory, mockDeleteItemLoginSchema, mockDeleteItemMembershipForItem, mockDeleteItemThumbnail, @@ -38,11 +37,9 @@ import { mockGetAppLink, mockGetAppListRoute, mockGetAvatarUrl, - mockGetCategories, mockGetChildren, mockGetCurrentMember, mockGetItem, - mockGetItemCategories, mockGetItemChat, mockGetItemFavorites, mockGetItemInvitations, @@ -64,6 +61,7 @@ import { mockGetPublishItemInformations, mockGetPublishItemsForMember, mockGetShortLinksItem, + mockGetTagsByItem, mockImportH5p, mockImportZip, mockMoveItems, @@ -74,7 +72,6 @@ import { mockPostAvatar, mockPostInvitations, mockPostItem, - mockPostItemCategory, mockPostItemChatMessage, mockPostItemFlag, mockPostItemLogin, @@ -88,6 +85,7 @@ import { mockPutItemLoginSchema, mockRecycleItems, mockRejectMembershipRequest, + mockRemoveTag, mockRequestMembership, mockRestoreItems, mockSignInRedirection, @@ -109,7 +107,6 @@ Cypress.Commands.add( members = Object.values(MEMBERS), currentMember = CURRENT_USER, mentions = SAMPLE_MENTIONS, - categories = SAMPLE_CATEGORIES, itemValidationGroups = [], itemPublicationStatus = PublicationStatus.Unpublished, membershipRequests = [], @@ -137,10 +134,6 @@ Cypress.Commands.add( postItemThumbnailError = false, postAvatarError = false, importZipError = false, - getCategoriesError = false, - getItemCategoriesError = false, - postItemCategoryError = false, - deleteItemCategoryError = false, postInvitationsError = false, getItemInvitationsError = false, patchInvitationError = false, @@ -217,8 +210,6 @@ Cypress.Commands.add( mockSignOut(); - // mockGetItemLogin(items); - mockGetItemLoginSchema(items); mockGetItemLoginSchemaType(items); @@ -273,7 +264,6 @@ Cypress.Commands.add( mockRestoreItems(items, restoreItemsError); - // mockGetItemThumbnail(items, getItemThumbnailError); mockGetItemThumbnailUrl(items, getItemThumbnailError); mockDeleteItemThumbnail(items, getItemThumbnailError); @@ -286,13 +276,10 @@ Cypress.Commands.add( mockImportZip(importZipError); - mockGetCategories(categories, getCategoriesError); - - mockGetItemCategories(items, getItemCategoriesError); - - mockPostItemCategory(postItemCategoryError); + mockGetTagsByItem(items); - mockDeleteItemCategory(deleteItemCategoryError); + mockRemoveTag(); + mockAddTag(); mockGetItemValidationGroups(itemValidationGroups); diff --git a/cypress/support/server.ts b/cypress/support/server.ts index 7aaf12f43..3b74b8035 100644 --- a/cypress/support/server.ts +++ b/cypress/support/server.ts @@ -2,7 +2,6 @@ import { API_ROUTES } from '@graasp/query-client'; import { AccountType, App, - Category, ChatMention, CompleteMembershipRequest, DiscriminatedItem, @@ -76,13 +75,9 @@ const { buildPostItemChatMessageRoute, buildClearItemChatRoute, buildDeleteItemVisibilityRoute, - buildDeleteItemsRoute, buildUploadItemThumbnailRoute, buildUploadAvatarRoute, buildImportZipRoute, - buildGetCategoriesRoute, - buildPostItemCategoryRoute, - buildDeleteItemCategoryRoute, buildPostInvitationsRoute, buildGetItemInvitationsForItemRoute, buildDeleteInvitationRoute, @@ -253,7 +248,7 @@ export const mockDeleteItems = ( cy.intercept( { method: HttpMethod.Delete, - url: new RegExp(`${API_HOST}/${buildDeleteItemsRoute([])}`), + url: new RegExp(`${API_HOST}/items\\?id\\=`), }, ({ reply }) => { if (shouldThrowError) { @@ -1475,82 +1470,38 @@ export const mockPostAvatar = (shouldThrowError: boolean): void => { ).as('uploadAvatar'); }; -export const mockGetCategories = ( - categories: Category[], - shouldThrowError: boolean, -): void => { - cy.intercept( - { - method: HttpMethod.Get, - url: new RegExp( - `${API_HOST}/${parseStringToRegExp(buildGetCategoriesRoute())}`, - ), - }, - ({ reply }) => { - if (shouldThrowError) { - reply({ statusCode: StatusCodes.BAD_REQUEST }); - return; - } - reply(categories); - }, - ).as('getCategories'); -}; - -export const mockGetItemCategories = ( - items: ItemForTest[], - shouldThrowError: boolean, -): void => { +export const mockGetTagsByItem = (items: ItemForTest[]): void => { cy.intercept( { method: HttpMethod.Get, - url: new RegExp(`${API_HOST}/items/${ID_FORMAT}/categories`), + url: new RegExp(`${API_HOST}/items/${ID_FORMAT}/tags`), }, ({ reply, url }) => { - if (shouldThrowError) { - return reply({ statusCode: StatusCodes.BAD_REQUEST }); - } const itemId = url.slice(API_HOST.length).split('/')[2]; - const result = items.find(({ id }) => id === itemId)?.categories || []; + const result = items.find(({ id }) => id === itemId)?.tags || []; return reply(result); }, - ).as('getItemCategories'); + ).as('getTagsByItem'); }; -export const mockPostItemCategory = (shouldThrowError: boolean): void => { +export const mockAddTag = (): void => { cy.intercept( { method: HttpMethod.Post, - url: new RegExp(`${API_HOST}/${buildPostItemCategoryRoute(ID_FORMAT)}$`), - }, - ({ reply, body }) => { - if (shouldThrowError) { - return reply({ statusCode: StatusCodes.BAD_REQUEST }); - } - - return reply(body); + url: new RegExp(`${API_HOST}/items/${ID_FORMAT}/tags`), }, - ).as('postItemCategory'); + ({ reply }) => reply({ status: StatusCodes.NO_CONTENT }), + ).as('addTag'); }; -export const mockDeleteItemCategory = (shouldThrowError: boolean): void => { +export const mockRemoveTag = (): void => { cy.intercept( { method: HttpMethod.Delete, - url: new RegExp( - `${API_HOST}/${buildDeleteItemCategoryRoute({ - itemId: ID_FORMAT, - itemCategoryId: ID_FORMAT, - })}$`, - ), - }, - ({ reply, body }) => { - if (shouldThrowError) { - return reply({ statusCode: StatusCodes.BAD_REQUEST }); - } - - return reply(body); + url: new RegExp(`${API_HOST}/items/${ID_FORMAT}/tags/${ID_FORMAT}`), }, - ).as('deleteItemCategory'); + ({ reply }) => reply({ status: StatusCodes.NO_CONTENT }), + ).as('removeTag'); }; export const mockGetItemValidationGroups = ( diff --git a/cypress/support/types.ts b/cypress/support/types.ts index 2eb1b2858..725cbeb4f 100644 --- a/cypress/support/types.ts +++ b/cypress/support/types.ts @@ -1,5 +1,4 @@ import { - Category, ChatMention, ChatMessage, CompleteMember, @@ -7,7 +6,6 @@ import { DiscriminatedItem, Invitation, ItemBookmark, - ItemCategory, ItemLoginSchema, ItemMembership, ItemPublished, @@ -19,11 +17,12 @@ import { RecycledItemData, S3FileItemType, ShortLink, + Tag, ThumbnailsBySize, } from '@graasp/sdk'; export type ItemForTest = DiscriminatedItem & { - categories?: ItemCategory[]; + tags?: Tag[]; thumbnails?: ThumbnailsBySize; visibilities?: ItemVisibility[]; itemLoginSchema?: ItemLoginSchema; @@ -54,7 +53,6 @@ export type ApiConfig = { members?: MemberForTest[]; currentMember?: MemberForTest; mentions?: ChatMention[]; - categories?: Category[]; shortLinks?: ShortLink[]; itemId?: DiscriminatedItem['id']; bookmarkedItems?: ItemBookmark[]; @@ -62,7 +60,6 @@ export type ApiConfig = { itemPublicationStatus?: PublicationStatus; publishedItemData?: ItemPublished[]; membershipRequests?: CompleteMembershipRequest[]; - // statuses = SAMPLE_STATUSES, itemValidationGroups?: ItemValidationGroup[]; deleteItemsError?: boolean; postItemError?: boolean; diff --git a/package.json b/package.json index 3cf3d622d..ff3ba7a76 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,10 @@ "@emotion/styled": "11.13.0", "@graasp/chatbox": "3.3.1", "@graasp/map": "1.19.0", - "@graasp/query-client": "5.6.0", - "@graasp/sdk": "5.3.1", + "@graasp/query-client": "5.7.0", + "@graasp/sdk": "5.4.0", "@graasp/stylis-plugin-rtl": "2.2.0", - "@graasp/translations": "1.42.0", + "@graasp/translations": "1.43.0", "@graasp/ui": "5.4.4", "@mui/icons-material": "6.1.7", "@mui/lab": "6.0.0-beta.15", diff --git a/src/components/hooks/useItemCategories.tsx b/src/components/hooks/useItemCategories.tsx deleted file mode 100644 index aa2e7f7f1..000000000 --- a/src/components/hooks/useItemCategories.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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, - isPending: isPostLoading, - isSuccess: isPostSuccess, - isError: isPostError, - } = usePostItemCategory(); - const { - mutate: deleteItemCategory, - isPending: 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/input/MultiSelectChipInput.hook.tsx b/src/components/input/MultiSelectChipInput.hook.tsx deleted file mode 100644 index 2965c2a0f..000000000 --- a/src/components/input/MultiSelectChipInput.hook.tsx +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index c30ce7d1a..000000000 --- a/src/components/input/MultiSelectChipInput.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import AddIcon from '@mui/icons-material/Add'; -import { - Autocomplete, - AutocompleteRenderGetTagProps, - AutocompleteRenderInputParams, - Box, - Chip, - Fab, - Stack, - TextField, - Typography, -} from '@mui/material'; - -import { useBuilderTranslation } from '@/config/i18n'; -import { - MULTI_SELECT_CHIP_ADD_BUTTON_ID, - MULTI_SELECT_CHIP_CONTAINER_ID, - MULTI_SELECT_CHIP_INPUT_ID, - buildMultiSelectChipsSelector, -} from '@/config/selectors'; -import { BUILDER } from '@/langs/constants'; - -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 { t } = useBuilderTranslation(); - 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/input/MultiSelectTagChipInput.tsx b/src/components/input/MultiSelectTagChipInput.tsx new file mode 100644 index 000000000..6a846fd34 --- /dev/null +++ b/src/components/input/MultiSelectTagChipInput.tsx @@ -0,0 +1,164 @@ +import { + Autocomplete, + AutocompleteRenderGetTagProps, + AutocompleteRenderInputParams, + Box, + Button, + Chip, + Skeleton, + Stack, + TextField, +} from '@mui/material'; + +import { DiscriminatedItem, TagCategory } from '@graasp/sdk'; + +import { useBuilderTranslation, useEnumsTranslation } from '@/config/i18n'; +import { hooks } from '@/config/queryClient'; +import { + MULTI_SELECT_CHIP_CONTAINER_ID, + buildMultiSelectChipInputId, + buildMultiSelectChipsSelector, +} from '@/config/selectors'; + +import useTagsManager from '../item/publish/customizedTags/useTagsManager'; + +type Props = { + itemId: DiscriminatedItem['id']; + tagCategory: TagCategory; +}; + +export const MultiSelectTagChipInput = ({ + itemId, + tagCategory, +}: Props): JSX.Element | null => { + const { t } = useBuilderTranslation(); + const { t: translateEnums } = useEnumsTranslation(); + const { + currentValue, + error, + handleCurrentValueChanged, + addValue, + deleteValue, + resetCurrentValue, + debouncedCurrentValue, + tagsPerCategory, + } = useTagsManager({ + itemId, + }); + const { + data: tags, + isFetching, + isLoading, + } = hooks.useTags({ search: debouncedCurrentValue, category: tagCategory }); + const renderTags = ( + value: readonly string[], + getTagProps: AutocompleteRenderGetTagProps, + ) => ( + + {value.map((option: string, index: number) => ( + { + const tagId = tagsPerCategory?.[tagCategory].find( + ({ name }) => name === option, + ); + if (tagId) { + deleteValue(tagId.id); + } + }} + key={option} + /> + ))} + + ); + + const renderInput = (params: AutocompleteRenderInputParams) => ( + handleCurrentValueChanged(e.target.value, tagCategory)} + onKeyDown={(e) => { + if (e.code === 'Enter' && 'value' in e.target) { + addValue({ name: e.target.value as string, category: tagCategory }); + } + }} + /> + ); + + const options = + tags + ?.filter(({ category }) => category === tagCategory) + ?.map(({ name }) => name) ?? []; + + return ( + + + + addValue({ + name: currentValue, + category: tagCategory, + }) + } + > + {t('ADD_TAG_OPTION_BUTTON_TEXT', { value: currentValue })} + + ) + } + options={options} + value={tagsPerCategory?.[tagCategory]?.map(({ name }) => name) ?? []} + onChange={(_e, v) => { + if (v.length) { + addValue({ + name: v[v.length - 1], + category: tagCategory, + }); + } + }} + renderTags={renderTags} + renderOption={(optionProps, name) => ( + + {name} + + )} + renderInput={renderInput} + disableClearable + loading={ + isFetching || isLoading || debouncedCurrentValue !== currentValue + } + loadingText={} + /> + + + ); +}; diff --git a/src/components/item/publish/CategoriesContainer.tsx b/src/components/item/publish/CategoriesContainer.tsx deleted file mode 100644 index 9a5185a78..000000000 --- a/src/components/item/publish/CategoriesContainer.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 7c7830cfa..000000000 --- a/src/components/item/publish/CategorySelection.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { SyntheticEvent } from 'react'; - -import { AutocompleteChangeReason, Stack } from '@mui/material'; - -import { Category, CategoryType } from '@graasp/sdk'; -import { Loader } from '@graasp/ui'; - -import groupBy from 'lodash.groupby'; - -import { useCategoriesTranslation } from '@/config/i18n'; -import { hooks } from '@/config/queryClient'; -import { Filter } from '@/types/array'; -import { sortByName } from '@/utils/item'; - -import DropdownMenu from './DropdownMenu'; - -const { useCategories, useItemCategories } = hooks; - -const SELECT_OPTION = 'selectOption'; -const REMOVE_OPTION = 'removeOption'; - -type Props = { - itemId: string; - titleContent?: JSX.Element; - filterCategories?: Filter; - onCreate: (categoryId: string) => void; - onDelete: (itemCategoryId: string) => void; -}; -const CategorySelection = ({ - itemId, - titleContent, - filterCategories = () => true, - onCreate, - onDelete, -}: Props): JSX.Element | null => { - const { t: translateCategories } = useCategoriesTranslation(); - const { data: itemCategories, isLoading: isItemCategoriesLoading } = - useItemCategories(itemId); - const { data: allCategories, isLoading: isCategoriesLoading } = - useCategories(); - const isLoading = isItemCategoriesLoading || isCategoriesLoading; - const filteredCategories = allCategories?.filter(filterCategories); - const categoriesByType = groupBy(filteredCategories, (entry) => entry.type); - - if (isLoading) { - return ( - - - - ); - } - - if (!Object.values(categoriesByType).length) { - return null; - } - - const handleChange = ( - _event: SyntheticEvent, - _values: Category[], - reason: AutocompleteChangeReason, - details?: { option: Category }, - ) => { - if (!itemId) { - console.error('No item id is defined'); - return; - } - - if (reason === SELECT_OPTION) { - // post new category - const newCategoryId = details?.option.id; - if (newCategoryId) { - onCreate(newCategoryId); - } else { - console.error('Unable to create the category!'); - } - } - if (reason === REMOVE_OPTION) { - const deletedCategoryId = details?.option.id; - const itemCategoryIdToRemove = itemCategories?.find( - ({ category }) => category.id === deletedCategoryId, - )?.id; - if (itemCategoryIdToRemove) { - onDelete(itemCategoryIdToRemove); - } else { - console.error('Unable to delete the category!'); - } - } - }; - - return ( - - {titleContent} - {Object.values(CategoryType)?.map((type) => { - const values = - categoriesByType[type] - ?.map((c: Category) => ({ - ...c, - name: translateCategories(c.name), - })) - ?.sort(sortByName) ?? []; - - return ( - - ); - })} - - ); -}; - -export default CategorySelection; diff --git a/src/components/item/publish/DropdownMenu.tsx b/src/components/item/publish/DropdownMenu.tsx deleted file mode 100644 index 977fea79b..000000000 --- a/src/components/item/publish/DropdownMenu.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { SyntheticEvent } from 'react'; - -import { - Autocomplete, - AutocompleteChangeReason, - Box, - TextField, - Typography, -} from '@mui/material'; - -import { Category, CategoryType, ItemCategory } from '@graasp/sdk'; - -import { useBuilderTranslation } from '../../../config/i18n'; -import { - buildCategoryDropdownParentSelector, - buildCategorySelectionId, - buildCategorySelectionOptionId, - buildCategorySelectionTitleId, -} from '../../../config/selectors'; -import { BUILDER } from '../../../langs/constants'; - -type Props = { - disabled?: boolean; - type: CategoryType; - title: string; - values: Category[]; - selectedValues?: ItemCategory[]; - handleChange: ( - _event: SyntheticEvent, - value: Category[], - reason: AutocompleteChangeReason, - details?: { option: Category }, - ) => void; -}; - -const DropdownMenu = ({ - disabled = false, - type, - title, - handleChange, - values, - selectedValues, -}: Props): JSX.Element | null => { - const { t: translateBuilder } = useBuilderTranslation(); - - if (!values.length) { - return null; - } - - const selected = values.filter(({ id }) => - selectedValues?.find(({ category }) => category.id === id), - ); - - return ( - - - {title} - - option.name} - onChange={handleChange} - renderInput={(params) => ( - - )} - renderOption={(props, option) => ( -
  • - {option.name} -
  • - )} - /> -
    - ); -}; - -export default DropdownMenu; diff --git a/src/components/item/publish/ItemCategoryContainer.tsx b/src/components/item/publish/ItemCategoryContainer.tsx deleted file mode 100644 index 41a7f4992..000000000 --- a/src/components/item/publish/ItemCategoryContainer.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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/ItemPublishTab.tsx b/src/components/item/publish/ItemPublishTab.tsx index fb2b4a089..c8ad4a8ed 100644 --- a/src/components/item/publish/ItemPublishTab.tsx +++ b/src/components/item/publish/ItemPublishTab.tsx @@ -11,7 +11,6 @@ import { 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 { LanguageContainer } from '@/components/item/publish/LanguageContainer'; @@ -25,7 +24,7 @@ import { BUILDER } from '@/langs/constants'; import { SomeBreakPoints } from '@/types/breakpoint'; import EditItemName from './EditItemName'; -import CustomizedTags from './customizedTags/CustomizedTags'; +import { PublishCustomizedTags } from './customizedTags/PublishCustomizedTags'; import PublicationButtonSelector from './publicationButtons/PublicationButtonSelector'; type StackOrder = { order?: number | SomeBreakPoints }; @@ -64,25 +63,21 @@ const ItemPublishTab = (): JSX.Element => { ); } - const customizedTags = ; - const buildPreviewHeader = (): JSX.Element => ( - {!isMobile && customizedTags} - {isMobile && customizedTags} ); const buildPreviewContent = (): JSX.Element => ( - + diff --git a/src/components/item/publish/PublicationModal.tsx b/src/components/item/publish/PublicationModal.tsx index a9de2e165..7526d8abc 100644 --- a/src/components/item/publish/PublicationModal.tsx +++ b/src/components/item/publish/PublicationModal.tsx @@ -28,7 +28,7 @@ export const PublicationModal = ({ const { t } = useBuilderTranslation(); return ( - + {title && {title}} {modalContent} diff --git a/src/components/item/publish/customizedTags/CustomizedTags.hook.tsx b/src/components/item/publish/customizedTags/CustomizedTags.hook.tsx deleted file mode 100644 index ecf41f598..000000000 --- a/src/components/item/publish/customizedTags/CustomizedTags.hook.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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, - isPending: 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 index 4af969eb7..77b43467e 100644 --- a/src/components/item/publish/customizedTags/CustomizedTags.tsx +++ b/src/components/item/publish/customizedTags/CustomizedTags.tsx @@ -1,49 +1,38 @@ import EditIcon from '@mui/icons-material/Edit'; -import WarningIcon from '@mui/icons-material/Warning'; -import { Chip, Stack, Tooltip } from '@mui/material'; +import { Chip, Stack } from '@mui/material'; -import { DiscriminatedItem } from '@graasp/sdk'; +import { DiscriminatedItem, TagCategory } from '@graasp/sdk'; -import MultiSelectChipInput from '@/components/input/MultiSelectChipInput'; -import { WARNING_COLOR } from '@/config/constants'; +import { MultiSelectTagChipInput } from '@/components/input/MultiSelectTagChipInput'; import { useBuilderTranslation } from '@/config/i18n'; import { - ITEM_TAGS_OPEN_MODAL_BUTTON_ID, + ITEM_TAGS_OPEN_MODAL_BUTTON_CY, buildCustomizedTagsSelector, } from '@/config/selectors'; import { BUILDER } from '@/langs/constants'; import { useModalStatus } from '../../../hooks/useModalStatus'; import PublicationModal from '../PublicationModal'; -import useCustomizedTags from './CustomizedTags.hook'; +import { useTagsManager } from './useTagsManager'; type Props = { item: DiscriminatedItem; - warningWhenNoTags?: boolean; }; -export const CustomizedTags = ({ - item, - warningWhenNoTags = false, -}: Props): JSX.Element => { +export const CustomizedTags = ({ item }: Props): JSX.Element => { const { t } = useBuilderTranslation(); + const { deleteValue } = useTagsManager({ itemId: item.id }); const { isOpen, openModal, closeModal } = useModalStatus(); - const { tags, hasTags, saveTags, deleteTag } = useCustomizedTags({ - item, - enableNotifications: false, + const { tags } = useTagsManager({ + itemId: item.id, }); - const showWarning = warningWhenNoTags && !hasTags; - const onSave = (newTags: string[]) => { - saveTags(newTags); - }; - - const chipTags = tags.map((tag, idx) => ( + const chipTags = tags?.map(({ name, id }) => ( deleteTag(tag)} - data-cy={buildCustomizedTagsSelector(idx)} + key={id} + label={name} + onDelete={() => deleteValue(id)} + data-cy={buildCustomizedTagsSelector(id)} /> )); @@ -54,11 +43,20 @@ export const CustomizedTags = ({ handleOnClose={closeModal} isOpen={isOpen} modalContent={ - + + + + + } /> @@ -67,14 +65,9 @@ export const CustomizedTags = ({ label={t(BUILDER.ITEM_TAGS_CHIP_BUTTON_EDIT)} variant="outlined" onClick={openModal} - data-cy={ITEM_TAGS_OPEN_MODAL_BUTTON_ID} + data-cy={ITEM_TAGS_OPEN_MODAL_BUTTON_CY} /> {chipTags} - {showWarning && ( - - - - )} ); diff --git a/src/components/item/publish/customizedTags/PublishCustomizedTags.tsx b/src/components/item/publish/customizedTags/PublishCustomizedTags.tsx new file mode 100644 index 000000000..b0d6b3a6c --- /dev/null +++ b/src/components/item/publish/customizedTags/PublishCustomizedTags.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; + +import WarningIcon from '@mui/icons-material/Warning'; +import { Tooltip } from '@mui/material'; + +import { DiscriminatedItem } from '@graasp/sdk'; + +import { useDataSyncContext } from '@/components/context/DataSyncContext'; +import { WARNING_COLOR } from '@/config/constants'; +import { useBuilderTranslation } from '@/config/i18n'; +import { BUILDER } from '@/langs/constants'; + +import CustomizedTags from './CustomizedTags'; +import { useTagsManager } from './useTagsManager'; + +type Props = { + item: DiscriminatedItem; + onChange?: (args: { + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + }) => void; +}; + +const SYNC_STATUS_KEY = 'CustomizedTags'; +export const PublishCustomizedTags = ({ + item, + onChange, +}: Props): JSX.Element => { + const { t } = useBuilderTranslation(); + const { tags, isLoading, isSuccess, isError } = useTagsManager({ + itemId: item.id, + }); + const { computeStatusFor } = useDataSyncContext(); + const showWarning = !tags?.length; + + useEffect( + () => computeStatusFor(SYNC_STATUS_KEY, { isLoading, isSuccess, isError }), + [isLoading, isSuccess, isError, onChange, computeStatusFor], + ); + + return ( + <> + + + {showWarning && ( + + + + )} + + ); +}; diff --git a/src/components/item/publish/customizedTags/useTagsManager.tsx b/src/components/item/publish/customizedTags/useTagsManager.tsx new file mode 100644 index 000000000..f9e465f00 --- /dev/null +++ b/src/components/item/publish/customizedTags/useTagsManager.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; + +import { DiscriminatedItem, Tag, TagCategory } from '@graasp/sdk'; + +import groupBy from 'lodash.groupby'; + +import { useBuilderTranslation } from '@/config/i18n'; +import { hooks, mutations } from '@/config/queryClient'; +import { BUILDER } from '@/langs/constants'; + +const EMPTY_STRING = ''; +type Props = { + itemId: DiscriminatedItem['id']; +}; + +type UseMultiSelectChipInput = { + tags?: Tag[]; + tagsPerCategory?: { [key: string]: Tag[] }; + currentValue: string; + error: string | undefined; + hasError: boolean; + debouncedCurrentValue: string; + + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + + handleCurrentValueChanged: (newValue: string, category: TagCategory) => void; + addValue: (tag: Pick) => void; + resetCurrentValue: () => void; + deleteValue: (tagId: Tag['id']) => void; +}; + +export const useTagsManager = ({ itemId }: Props): UseMultiSelectChipInput => { + const { t } = useBuilderTranslation(); + const [currentValue, setCurrentValue] = useState(EMPTY_STRING); + const [error, setError] = useState(); + const debouncedCurrentValue = hooks.useDebounce(currentValue, 500); + const { data: tags } = hooks.useTagsByItem({ itemId }); + const { + mutate: addTag, + isPending: addTagIsLoading, + isSuccess: addTagIsSuccess, + isError: addTagIsError, + } = mutations.useAddTag(); + const { + mutate: removeTag, + isPending: removeTagIsLoading, + isSuccess: removeTagIsSuccess, + isError: removeTagIsError, + } = mutations.useRemoveTag(); + + const hasError = Boolean(error); + + const tagsPerCategory = groupBy(tags, ({ category }) => category); + + const valueIsValid = ( + dataToValidate: string | undefined, + ): dataToValidate is string => Boolean(dataToValidate); + + const valueExist = (tag: Pick) => + tags?.find( + ({ name, category }) => name === tag.name && category === tag.category, + ); + + const validateData = (tag: Pick) => { + if (valueExist(tag)) { + setError(t(BUILDER.CHIPS_ALREADY_EXIST, { element: tag.name })); + return false; + } + setError(undefined); + return true; + }; + + const resetCurrentValue = () => { + setCurrentValue(EMPTY_STRING); + }; + + const addValue = (tag: Pick) => { + if (valueIsValid(currentValue) && !valueExist(tag)) { + addTag({ itemId, tag }); + + resetCurrentValue(); + } + }; + + const deleteValue = (tagId: Tag['id']) => { + removeTag({ tagId, itemId }); + }; + + const handleCurrentValueChanged = ( + newValue: string, + category: TagCategory, + ) => { + validateData({ name: newValue, category }); + setCurrentValue(newValue); + }; + + return { + currentValue, + error, + hasError, + handleCurrentValueChanged, + addValue, + deleteValue, + resetCurrentValue, + // return debounced current value, or empty when removing everything + debouncedCurrentValue: currentValue.length + ? debouncedCurrentValue + : currentValue, + tags, + tagsPerCategory, + isLoading: addTagIsLoading || removeTagIsLoading, + isSuccess: addTagIsSuccess || removeTagIsSuccess, + isError: addTagIsError || removeTagIsError, + }; +}; + +export default useTagsManager; diff --git a/src/config/i18n.ts b/src/config/i18n.ts index 45cd309c5..7da6c6471 100644 --- a/src/config/i18n.ts +++ b/src/config/i18n.ts @@ -22,8 +22,6 @@ export const useBuilderTranslation = () => useTranslation(BUILDER_NAMESPACE); export const useCommonTranslation = () => useTranslation(namespaces.common); export const useMessagesTranslation = () => useTranslation(namespaces.messages); export const useEnumsTranslation = () => useTranslation(namespaces.enums); -export const useCategoriesTranslation = () => - useTranslation(namespaces.categories); export const useChatboxTranslation = () => useTranslation(namespaces.chatbox); export default i18n; diff --git a/src/config/selectors.ts b/src/config/selectors.ts index a627a278c..33fae546c 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -193,14 +193,15 @@ export const CROP_MODAL_CONFIRM_BUTTON_ID = 'cropModalConfirmButton'; export const ZIP_DASHBOARD_UPLOADER_ID = 'zipDashboardUploader'; export const H5P_DASHBOARD_UPLOADER_ID = 'h5pDashboardUploader'; -export const ITEM_TAGS_OPEN_MODAL_BUTTON_ID = 'itemTagsOpenModalButton'; -export const MULTI_SELECT_CHIP_INPUT_ID = 'multiSelectChipInput'; +export const ITEM_TAGS_OPEN_MODAL_BUTTON_CY = 'itemTagsOpenModalButton'; +export const buildMultiSelectChipInputId = (id: string): string => + `multiSelectChipInput-${id}`; export const MULTI_SELECT_CHIP_ADD_BUTTON_ID = 'multiSelectChipAddButton'; export const MULTI_SELECT_CHIP_CONTAINER_ID = 'multiSelectChipContainer'; export const buildMultiSelectChipsSelector = (index: number): string => `multiSelectChips-${index}`; -export const buildCustomizedTagsSelector = (index: number): string => - `customizedTagsPreview-${index}`; +export const buildCustomizedTagsSelector = (id: string): string => + `customizedTagsPreview-${id}`; export const buildCategoriesSelectionValueSelector = (title: string): string => `#${buildCategorySelectionTitleId(title)}+div span`; diff --git a/src/langs/en.json b/src/langs/en.json index 070d73254..1da5f316a 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -509,5 +509,6 @@ "DELETE_GUESTS_MODAL_TITLE_one": "Confirm deletion of one disabled reader", "DELETE_GUESTS_MODAL_TITLE_other": "Confirm deletion of {{count}} disabled reader(s)", "DELETE_GUESTS_MODAL_CONTENT": "I confirm deleting <1>permanently the following readers and their data:", - "DELETE_GUESTS_MODAL_DELETE_BUTTON": "Delete permanently" + "DELETE_GUESTS_MODAL_DELETE_BUTTON": "Delete permanently", + "ADD_TAG_OPTION_BUTTON_TEXT": "Add {{value}}" } diff --git a/yarn.lock b/yarn.lock index fa11e8a60..abd00a61d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1625,25 +1625,25 @@ __metadata: languageName: node linkType: hard -"@graasp/query-client@npm:5.6.0": - version: 5.6.0 - resolution: "@graasp/query-client@npm:5.6.0" +"@graasp/query-client@npm:5.7.0": + version: 5.7.0 + resolution: "@graasp/query-client@npm:5.7.0" dependencies: - "@tanstack/react-query": "npm:5.61.0" - "@tanstack/react-query-devtools": "npm:5.61.0" - axios: "npm:1.7.7" + "@tanstack/react-query": "npm:5.61.5" + "@tanstack/react-query-devtools": "npm:5.61.5" + axios: "npm:1.7.8" http-status-codes: "npm:2.3.0" peerDependencies: "@graasp/sdk": ^4.0.0 || ^5.0.0 "@graasp/translations": "*" react: ^18.0.0 - checksum: 10/0948a04579d39ecb4e48f5d7eaeacf308c588c786e3165999fdee6bb0444ed51f56ef6dddb62e1bf2f5b250abeb529870d94e58de84e567b489f1115e5bb16a1 + checksum: 10/c31a6395da6023332e03b8ee6d950fe7e106ca4e0ca9ce60cd4ecfd737ad12bcebe7ada735ed449120ede430f666f20cc6a026f6174399b0ad736273c8ba0cd0 languageName: node linkType: hard -"@graasp/sdk@npm:5.3.1": - version: 5.3.1 - resolution: "@graasp/sdk@npm:5.3.1" +"@graasp/sdk@npm:5.4.0": + version: 5.4.0 + resolution: "@graasp/sdk@npm:5.4.0" dependencies: "@faker-js/faker": "npm:9.2.0" filesize: "npm:10.1.6" @@ -1651,7 +1651,7 @@ __metadata: peerDependencies: date-fns: ^3 || ^4.0.0 uuid: ^9 || ^10 || ^11.0.0 - checksum: 10/9c414a3763c049e0360d78ce2c9f584621fdd829826e08c1c6416cdc02e1bbeefc7e55fedba6a09a86f5515a13c726d7c1b8558842e137fe04abc4ca8f172564 + checksum: 10/787def776e690aa3a07438c907e6d5adb6c016db243e52fdc1511e35d057380d2d51d98d28bfef5f9f6101987f85c4cf43ba89a0d4d56f970b3801b4b8f2acd7 languageName: node linkType: hard @@ -1666,12 +1666,12 @@ __metadata: languageName: node linkType: hard -"@graasp/translations@npm:1.42.0": - version: 1.42.0 - resolution: "@graasp/translations@npm:1.42.0" +"@graasp/translations@npm:1.43.0": + version: 1.43.0 + resolution: "@graasp/translations@npm:1.43.0" peerDependencies: i18next: ^23.8.1 - checksum: 10/5f09f7facfdd6c6fa434285493a2a3736e305064223df45f9c3203533b429b1c65e167cf2fac054e4fb53ed3aabcd70e31309addd58952edaeae61cbc29e8020 + checksum: 10/5672a9a9aec4cc6fe794a93c22eaaac8af5a7ca96227afe32e8a4cdce7741e0f05c456c0b52f3288ef71040285fa39f938905e12e69fed800e62e98353743854 languageName: node linkType: hard @@ -2525,40 +2525,40 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.60.6": - version: 5.60.6 - resolution: "@tanstack/query-core@npm:5.60.6" - checksum: 10/a87613d85b3a280f2fef69037dd0ad512052d49b8dc979ed289ee538452d331926a720678bbd3b4485a8b38d5baf65c42869179f2fdeefa3f7b6dda04e878e56 +"@tanstack/query-core@npm:5.61.5": + version: 5.61.5 + resolution: "@tanstack/query-core@npm:5.61.5" + checksum: 10/e84a9dbd93cf5fa7abe7d4ba607e054cf15621194e1ec0145e05b826c0a27123947f0cc937cafe6254fb2289c0013b2283796f8c3844c0a19ed6082fb70a6632 languageName: node linkType: hard -"@tanstack/query-devtools@npm:5.59.20": - version: 5.59.20 - resolution: "@tanstack/query-devtools@npm:5.59.20" - checksum: 10/0bb2995337d78910c7677f780af42cd4285b39d618cd7876e24ec16243783d4cfe9e4d067d210d5337aefaad0a21928c5e4cb30fb4c08a09521625fcfe9c14d4 +"@tanstack/query-devtools@npm:5.61.4": + version: 5.61.4 + resolution: "@tanstack/query-devtools@npm:5.61.4" + checksum: 10/5267732b37781865c24241b9ffdd4d84d04d4e193f29fa9f0220967dcc738a3be25edcd45784fbeed340012200742a18bae25ce34f13190cc8dcb65bbee076ea languageName: node linkType: hard -"@tanstack/react-query-devtools@npm:5.61.0": - version: 5.61.0 - resolution: "@tanstack/react-query-devtools@npm:5.61.0" +"@tanstack/react-query-devtools@npm:5.61.5": + version: 5.61.5 + resolution: "@tanstack/react-query-devtools@npm:5.61.5" dependencies: - "@tanstack/query-devtools": "npm:5.59.20" + "@tanstack/query-devtools": "npm:5.61.4" peerDependencies: - "@tanstack/react-query": ^5.61.0 + "@tanstack/react-query": ^5.61.5 react: ^18 || ^19 - checksum: 10/ac4019ad58a04e21ef8fd2f5ab1e33791e3961979c25538b51a8597e9b2e448326f2d15e407bb89cb53a7f566f9bd15036dcd4573777c4bec428b79e8acdeb96 + checksum: 10/1a6d34bd02a882aa98c6839e8d8b5859c498a82087f38b826956822c01054e76d690b89d5c36df102c1e62bc12f77c276856ed2dfc526f68f5684ad6d5d82a4e languageName: node linkType: hard -"@tanstack/react-query@npm:5.61.0": - version: 5.61.0 - resolution: "@tanstack/react-query@npm:5.61.0" +"@tanstack/react-query@npm:5.61.5": + version: 5.61.5 + resolution: "@tanstack/react-query@npm:5.61.5" dependencies: - "@tanstack/query-core": "npm:5.60.6" + "@tanstack/query-core": "npm:5.61.5" peerDependencies: react: ^18 || ^19 - checksum: 10/e98d1396cd157626f29fb0d0d10c75dbdde6e2efa55463540c9d8e33b58ec4b72eeb58b7d2eea6e625a8316235777f128222adeb10533ddfade77b23ec209872 + checksum: 10/ae2108b03cc4b02c0dd08ddab44a157cdfa9949ccf5d4f4f7aa2d11ff345b3bc7a405138322ad99a538f476a77b2a188eb199119806df507cc988f09e8b34535 languageName: node linkType: hard @@ -3679,6 +3679,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:1.7.8": + version: 1.7.8 + resolution: "axios@npm:1.7.8" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10/7ddcde188041ac55090186254b4025eb2af842be3cf615ce45393fd7f543c1eab0ad2fdd2017a5f6190695e3ecea73ee5e9c37f204854aec2698f9579046efdf + languageName: node + linkType: hard + "axobject-query@npm:^4.1.0": version: 4.1.0 resolution: "axobject-query@npm:4.1.0" @@ -6401,10 +6412,10 @@ __metadata: "@emotion/styled": "npm:11.13.0" "@graasp/chatbox": "npm:3.3.1" "@graasp/map": "npm:1.19.0" - "@graasp/query-client": "npm:5.6.0" - "@graasp/sdk": "npm:5.3.1" + "@graasp/query-client": "npm:5.7.0" + "@graasp/sdk": "npm:5.4.0" "@graasp/stylis-plugin-rtl": "npm:2.2.0" - "@graasp/translations": "npm:1.42.0" + "@graasp/translations": "npm:1.43.0" "@graasp/ui": "npm:5.4.4" "@mui/icons-material": "npm:6.1.7" "@mui/lab": "npm:6.0.0-beta.15"