diff --git a/cypress/e2e/builder/enterSettings.cy.ts b/cypress/e2e/builder/enterSettings.cy.ts index da8a3d68..16c008ee 100644 --- a/cypress/e2e/builder/enterSettings.cy.ts +++ b/cypress/e2e/builder/enterSettings.cy.ts @@ -2,21 +2,17 @@ import { Context, PermissionLevel } from '@graasp/sdk'; import { KeywordsData } from '../../../src/config/appSettingTypes'; import { - ADD_KEYWORD_BUTTON_CY, BUILDER_VIEW_CY, CHATBOT_CONTAINER_CY, - DELETE_KEYWORD_BUTTON_CY, - ENTER_DEFINITION_FIELD_CY, - ENTER_KEYWORD_FIELD_CY, INITIAL_CHATBOT_PROMPT_INPUT_FIELD_CY, INITIAL_PROMPT_INPUT_FIELD_CY, - KEYWORD_LIST_ITEM_CY, SETTINGS_SAVE_BUTTON_CY, TEXT_INPUT_FIELD_CY, TITLE_INPUT_FIELD_CY, USE_CHATBOT_DATA_CY, buildDataCy, - buildKeywordNotExistWarningCy, + buildKeywordDefinitionTextInputCy, + buildKeywordTextInputCy, } from '../../../src/config/selectors'; import { MOCK_APP_SETTINGS, @@ -81,77 +77,6 @@ describe('Enter Settings', () => { cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('not.be.disabled'); }); - it('set keywords', () => { - cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)) - .should('be.visible') - .type('Lorem'); - - cy.get(buildDataCy(ENTER_DEFINITION_FIELD_CY)) - .should('be.visible') - .type('Latin'); - - cy.get(buildDataCy(KEYWORD_LIST_ITEM_CY)).should('not.exist'); - - cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY)) - .should('be.visible') - .should('not.be.disabled') - .click() - .should('be.disabled'); - cy.get(buildDataCy(KEYWORD_LIST_ITEM_CY)).should('exist'); - - cy.get(buildDataCy(DELETE_KEYWORD_BUTTON_CY)).should('be.visible').click(); - cy.get(buildDataCy(KEYWORD_LIST_ITEM_CY)).should('not.exist'); - }); - - // Detected incomplete keywords in the text. - // 'wef' was found incomplete in 'wefwef hello'. - // Check that only complete words are detected in text. - it('only detect complete keywords', () => { - const PRBLEMATIC_TEXT = 'wefwef hello'; - const PROBLEMATIC_KEYWORDS = ['wef', 'he']; - - cy.get(buildDataCy(TEXT_INPUT_FIELD_CY)) - .should('be.visible') - .type(PRBLEMATIC_TEXT); - - PROBLEMATIC_KEYWORDS.forEach((k) => { - cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)).should('be.visible').type(k); - - cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY)) - .should('be.visible') - .should('not.be.disabled') - .click() - .should('be.disabled'); - - cy.get(buildDataCy(buildKeywordNotExistWarningCy(k))).should( - 'be.visible', - ); - }); - - cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('be.disabled'); - }); - - it('detect keywords case insensitive', () => { - const TEXT = 'hello this is a Test'; - const KEYWORDS = ['Hello', 'test']; - - cy.get(buildDataCy(TEXT_INPUT_FIELD_CY)).should('be.visible').type(TEXT); - - KEYWORDS.forEach((k) => { - cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)).should('be.visible').type(k); - - cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY)) - .should('be.visible') - .should('not.be.disabled') - .click() - .should('be.disabled'); - - cy.get(buildDataCy(buildKeywordNotExistWarningCy(k))).should('not.exist'); - }); - - cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('be.disabled'); - }); - it('does not use chatbot (by default)', () => { cy.get(buildDataCy(USE_CHATBOT_DATA_CY)).should('not.be.checked'); cy.get(buildDataCy(CHATBOT_CONTAINER_CY)).should('not.exist'); @@ -198,12 +123,14 @@ describe('Load Settings', () => { const list = MOCK_APP_SETTINGS.find( (appSetting) => appSetting === MOCK_KEYWORDS_SETTING, ).data as KeywordsData; - list.keywords.forEach((elem) => { - cy.get(buildDataCy(KEYWORD_LIST_ITEM_CY)).should( + cy.get(buildDataCy(buildKeywordTextInputCy(elem.word, true))).should( 'contain', - `${elem.word} : ${elem.def}`, + elem.word, ); + cy.get( + buildDataCy(buildKeywordDefinitionTextInputCy(elem.word, true)), + ).should('contain', elem.def); }); }); }); diff --git a/cypress/e2e/builder/keywords.cy.ts b/cypress/e2e/builder/keywords.cy.ts new file mode 100644 index 00000000..b8c68937 --- /dev/null +++ b/cypress/e2e/builder/keywords.cy.ts @@ -0,0 +1,685 @@ +import { AppSetting, Context, PermissionLevel } from '@graasp/sdk'; + +import { CheckBoxState } from '@/components/common/table/types'; + +import { Keyword, KeywordsData } from '../../../src/config/appSettingTypes'; +import { + ADD_KEYWORD_BUTTON_CY, + EDITABLE_TABLE_DELETE_SELECTION_BUTTON_CY, + EDITABLE_TABLE_DISCARD_ALL_BUTTON_CY, + EDITABLE_TABLE_FILTER_INPUT_CY, + EDITABLE_TABLE_FILTER_NO_RESULT_CY, + EDITABLE_TABLE_NO_DATA_CY, + EDITABLE_TABLE_ROW_CY, + EDITABLE_TABLE_SAVE_ALL_BUTTON_CY, + ENTER_DEFINITION_FIELD_CY, + ENTER_KEYWORD_FIELD_CY, + SETTINGS_SAVE_BUTTON_CY, + TEXT_INPUT_FIELD_CY, + buildDataCy, + buildEditableSelectAllButtonCy, + buildEditableTableDeleteButtonCy, + buildEditableTableDiscardButtonCy, + buildEditableTableEditButtonCy, + buildEditableTableSaveButtonCy, + buildEditableTableSelectButtonCy, + buildKeywordDefinitionTextInputCy, + buildKeywordNotExistWarningCy, + buildKeywordTextInputCy, + buildTextFieldSelectorCy, +} from '../../../src/config/selectors'; +import { + MOCK_APP_SETTINGS_USING_CHATBOT, + MOCK_KEYWORDS_SETTING, + MOCK_KEYWORD_NOT_IN_TEXT, +} from '../../fixtures/appSettings'; + +const getKeywords = (appSettings: AppSetting[]): KeywordsData => + appSettings.find((appSetting) => appSetting === MOCK_KEYWORDS_SETTING) + .data as KeywordsData; + +/** + * Checks that the given keywords are displayed correctly in the table. + * @param keywords The keywords that should be displayed in the table. + */ +const checkAllKeywords = (keywords: Keyword[]): void => + keywords.forEach((elem) => { + cy.get(buildDataCy(buildKeywordTextInputCy(elem.word, true))).should( + 'contain', + elem.word, + ); + cy.get( + buildDataCy(buildKeywordDefinitionTextInputCy(elem.word, true)), + ).should('contain', elem.def); + }); + +describe('Empty Keywords', () => { + const NEW_KEYWORD: Keyword = { + word: 'lorem', + def: 'Latin', + }; + + beforeEach(() => { + cy.setUpApi({ + database: { + appData: [], + appSettings: [], + }, + appContext: { + context: Context.Builder, + permission: PermissionLevel.Admin, + }, + }); + cy.visit('/'); + }); + + it('add keyword, then remove it', () => { + cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)) + .should('be.visible') + .type(NEW_KEYWORD.word); + + cy.get(buildDataCy(ENTER_DEFINITION_FIELD_CY)) + .should('be.visible') + .type(NEW_KEYWORD.def); + + cy.get(buildDataCy(EDITABLE_TABLE_NO_DATA_CY)).should('exist'); + + cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY)) + .should('be.visible') + .should('not.be.disabled') + .click() + .should('be.disabled'); + cy.get(buildDataCy(EDITABLE_TABLE_NO_DATA_CY)).should('not.exist'); + + cy.get(buildDataCy(buildEditableTableDeleteButtonCy(NEW_KEYWORD.word))) + .should('be.visible') + .click(); + cy.get(buildDataCy(EDITABLE_TABLE_NO_DATA_CY)).should('exist'); + }); + + // Detected incomplete keywords in the text. + // 'wef' was found incomplete in 'wefwef hello'. + // Check that only complete words are detected in text. + it('only detect complete keywords', () => { + const PRBLEMATIC_TEXT = 'wefwef hello'; + const PROBLEMATIC_KEYWORDS = ['wef', 'he']; + + cy.get(buildDataCy(TEXT_INPUT_FIELD_CY)) + .should('be.visible') + .type(PRBLEMATIC_TEXT); + + PROBLEMATIC_KEYWORDS.forEach((k) => { + cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)).should('be.visible').type(k); + + cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY)) + .should('be.visible') + .should('not.be.disabled') + .click() + .should('be.disabled'); + + cy.get(buildDataCy(buildKeywordNotExistWarningCy(k))).should( + 'be.visible', + ); + }); + + cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('be.disabled'); + }); + + it('detect keywords case insensitive', () => { + const TEXT = 'hello this is a Test'; + const KEYWORDS = ['Hello', 'test']; + + cy.get(buildDataCy(TEXT_INPUT_FIELD_CY)).should('be.visible').type(TEXT); + + KEYWORDS.forEach((k) => { + cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)).should('be.visible').type(k); + + cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY)) + .should('be.visible') + .should('not.be.disabled') + .click() + .should('be.disabled'); + + cy.get(buildDataCy(buildKeywordNotExistWarningCy(k))).should('not.exist'); + }); + + cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('be.disabled'); + }); +}); + +describe('Existing Keywords', () => { + beforeEach(() => { + cy.setUpApi({ + database: { + appData: [], + appSettings: MOCK_APP_SETTINGS_USING_CHATBOT, + }, + appContext: { + context: Context.Builder, + permission: PermissionLevel.Admin, + }, + }); + cy.visit('/'); + }); + + describe('keywords are unique', () => { + it('cannot add existing keyword', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const EXISTING_KEYWORD = KEYWORDS.at(0); + + const NEW_KEYWORD = { + ...EXISTING_KEYWORD, + def: `A totally new definition`, + }; + + cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)) + .should('be.visible') + .type(NEW_KEYWORD.word); + + cy.get(buildDataCy(ENTER_DEFINITION_FIELD_CY)) + .should('be.visible') + .type(NEW_KEYWORD.def); + + cy.get(buildDataCy(EDITABLE_TABLE_NO_DATA_CY)).should('not.exist'); + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + + // try to add the existing keyword + cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY)) + .should('be.visible') + .should('not.be.disabled') + .click() + // the button should not be disabled because it fails + .should('not.be.disabled'); + + // check that the keyword is not added in the table + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + // the add inputs should still contains the values + cy.get(buildTextFieldSelectorCy(ENTER_KEYWORD_FIELD_CY)).should( + 'have.value', + NEW_KEYWORD.word, + ); + cy.get(buildTextFieldSelectorCy(ENTER_DEFINITION_FIELD_CY)).should( + 'have.value', + NEW_KEYWORD.def, + ); + + // check that the current keyword was not updated + cy.get( + buildDataCy( + buildKeywordDefinitionTextInputCy(EXISTING_KEYWORD.word, true), + ), + ).should('contain', EXISTING_KEYWORD.def); + }); + + it('cannot rename the keyword to an existing keyword', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const UPDATING_KEYWORD = KEYWORDS.at(0); + const EXISTING_KEYWORD = KEYWORDS.at(1); + + checkAllKeywords(KEYWORDS); + + // edit the existing keyword to a keyword that are already in the table + cy.get( + buildDataCy(buildEditableTableEditButtonCy(UPDATING_KEYWORD.word)), + ).click(); + cy.get(buildDataCy(buildKeywordTextInputCy(UPDATING_KEYWORD.word, false))) + .clear() + .type(EXISTING_KEYWORD.word); + + // try to save the modifications, it should fail + cy.get( + buildDataCy(buildEditableTableSaveButtonCy(UPDATING_KEYWORD.word)), + ).click(); + + // the save button should still be visible + cy.get( + buildDataCy(buildEditableTableSaveButtonCy(UPDATING_KEYWORD.word)), + ).should('be.visible'); + + // discard the unsaved changes + cy.get( + buildDataCy(buildEditableTableDiscardButtonCy(UPDATING_KEYWORD.word)), + ).click(); + + // it should not have any modifications in the table + checkAllKeywords(KEYWORDS); + }); + }); + + describe('edit and delete single keyword', () => { + it('cannot save empty keyword', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const UPDATING_KEYWORD = KEYWORDS.at(0); + + checkAllKeywords(KEYWORDS); + + // edit the existing keyword to a keyword that are already in the table + cy.get( + buildDataCy(buildEditableTableEditButtonCy(UPDATING_KEYWORD.word)), + ).click(); + cy.get( + buildDataCy(buildKeywordTextInputCy(UPDATING_KEYWORD.word, false)), + ).clear(); + + // should not be able to save the modifications + cy.get( + buildDataCy(buildEditableTableSaveButtonCy(UPDATING_KEYWORD.word)), + ).should('be.disabled'); + }); + + it('edit a keyword', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const UPDATING_KEYWORD = KEYWORDS.at(0); + const NEW_KEYWORD: Keyword = { + word: 'new keyword', + def: 'a new definition', + }; + const NEW_KEYWORDS = [ + NEW_KEYWORD, + ...KEYWORDS.filter( + (k) => k.word.toLowerCase() !== UPDATING_KEYWORD.word.toLowerCase(), + ), + ]; + + checkAllKeywords(KEYWORDS); + + // edit the existing keyword to a keyword that are already in the table + cy.get( + buildDataCy(buildEditableTableEditButtonCy(UPDATING_KEYWORD.word)), + ).click(); + cy.get(buildDataCy(buildKeywordTextInputCy(UPDATING_KEYWORD.word, false))) + .clear() + .type(NEW_KEYWORD.word); + cy.get( + buildDataCy( + buildKeywordDefinitionTextInputCy(UPDATING_KEYWORD.word, false), + ), + ) + .clear() + .type(NEW_KEYWORD.def); + + // save the modifications + cy.get( + buildDataCy(buildEditableTableSaveButtonCy(UPDATING_KEYWORD.word)), + ).click(); + + // the save button should not exist + cy.get( + buildDataCy(buildEditableTableSaveButtonCy(UPDATING_KEYWORD.word)), + ).should('not.exist'); + + // the modifications should have been apply and other keywords should still have same values + checkAllKeywords(NEW_KEYWORDS); + }); + + it('delete a keyword', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const REMOVING_KEYWORD = KEYWORDS.at(0); + const NEW_KEYWORDS = KEYWORDS.filter( + (k) => k.word.toLowerCase() !== REMOVING_KEYWORD.word.toLowerCase(), + ); + + checkAllKeywords(KEYWORDS); + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + + cy.get( + buildDataCy(buildEditableTableDeleteButtonCy(REMOVING_KEYWORD.word)), + ).click(); + + // check that the keyword is not in the table anymore + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + NEW_KEYWORDS.length, + ); + // check that the other keywords are still in the table + checkAllKeywords(NEW_KEYWORDS); + }); + }); + + describe('filter keywords', () => { + it('filter by keywords', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const FILTER_WORD = KEYWORDS.at(0).word; + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + + cy.get(buildTextFieldSelectorCy(EDITABLE_TABLE_FILTER_INPUT_CY)) + .clear() + .type(FILTER_WORD); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should('have.length', 1); + cy.get(buildDataCy(buildKeywordTextInputCy(FILTER_WORD, true))); + + cy.get(buildTextFieldSelectorCy(EDITABLE_TABLE_FILTER_INPUT_CY)).clear(); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + }); + + it('invalid filter display no result', () => { + const FILTER_WORD = 'kd kjfd fkjbd'; + + cy.get(buildTextFieldSelectorCy(EDITABLE_TABLE_FILTER_INPUT_CY)) + .clear() + .type(FILTER_WORD); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should('have.length', 0); + cy.get(buildDataCy(EDITABLE_TABLE_FILTER_NO_RESULT_CY)).should('exist'); + }); + + it('delete apply only on filtered selection', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const FILTER_WORD = KEYWORDS.at(0).word; + const KEYWORDS_WITHOUT_FILTERED = KEYWORDS.filter( + (k) => k.word.toLowerCase() !== FILTER_WORD.toLowerCase(), + ); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + + // select all the keywords + cy.get( + buildDataCy(buildEditableSelectAllButtonCy(CheckBoxState.UNCHECKED)), + ).click(); + + // filter by keyword + cy.get(buildTextFieldSelectorCy(EDITABLE_TABLE_FILTER_INPUT_CY)) + .clear() + .type(FILTER_WORD); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should('have.length', 1); + cy.get(buildDataCy(buildKeywordTextInputCy(FILTER_WORD, true))); + + // the global checkbox should be checked + cy.get( + buildDataCy(buildEditableSelectAllButtonCy(CheckBoxState.CHECKED)), + ).should('exist'); + + // delete the filtered selection + cy.get(buildDataCy(EDITABLE_TABLE_DELETE_SELECTION_BUTTON_CY)).click(); + + // reset the filter + cy.get(buildTextFieldSelectorCy(EDITABLE_TABLE_FILTER_INPUT_CY)).clear(); + + // the other keywords should still be in the table and selected + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS_WITHOUT_FILTERED.length, + ); + checkAllKeywords(KEYWORDS_WITHOUT_FILTERED); + + // the filtered keyword should not exist + cy.get(buildDataCy(buildKeywordTextInputCy(FILTER_WORD, true))).should( + 'not.exist', + ); + + // the global checkbox should be selected + cy.get( + buildDataCy(buildEditableSelectAllButtonCy(CheckBoxState.CHECKED)), + ).should('exist'); + }); + + it('editing keywords are conserved during filtering', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const FILTER_WORD = KEYWORDS.at(0).word; + const UPDATING_KEYWORDS = [ + FILTER_WORD, + KEYWORDS.at(KEYWORDS.length - 1).word, + ]; + const NEW_KEYWORDS: Keyword[] = [ + { + word: 'new keyword', + def: 'a new definition', + }, + { + word: 'another keyword', + def: 'another definition', + }, + ]; + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + + // updates the keywords without saving for now + UPDATING_KEYWORDS.forEach((k, idx) => { + cy.get(buildDataCy(buildEditableTableEditButtonCy(k))).click(); + cy.get(buildDataCy(buildKeywordTextInputCy(k, false))) + .clear() + .type(NEW_KEYWORDS[idx].word); + cy.get(buildDataCy(buildKeywordDefinitionTextInputCy(k, false))) + .clear() + .type(NEW_KEYWORDS[idx].def); + }); + + // filter by keyword + cy.get(buildTextFieldSelectorCy(EDITABLE_TABLE_FILTER_INPUT_CY)) + .clear() + .type(FILTER_WORD); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should('have.length', 1); + + // check that the keyword is still in edition + cy.get( + buildTextFieldSelectorCy(buildKeywordTextInputCy(FILTER_WORD, false)), + ); + + // reset the filter + cy.get(buildTextFieldSelectorCy(EDITABLE_TABLE_FILTER_INPUT_CY)).clear(); + + // the keywords in edition must still be in this mode + UPDATING_KEYWORDS.forEach((k, idx) => { + cy.get( + buildTextFieldSelectorCy(buildKeywordTextInputCy(k, false)), + ).should('have.value', NEW_KEYWORDS[idx].word); + cy.get(buildDataCy(buildKeywordDefinitionTextInputCy(k, false))).should( + 'contain', + NEW_KEYWORDS[idx].def, + ); + }); + }); + }); + + describe('multiple keywords', () => { + it('delete selection', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const SELECTED_KEYWORDS = [ + KEYWORDS.at(0).word, + KEYWORDS.at(KEYWORDS.length - 1).word, + ]; + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + + SELECTED_KEYWORDS.forEach((k) => { + // Select the given keywords and check that it is possible to unselect it again + cy.get(buildDataCy(buildEditableTableSelectButtonCy(k, false))).click(); + cy.get(buildDataCy(buildEditableTableSelectButtonCy(k, true))).click(); + cy.get(buildDataCy(buildEditableTableSelectButtonCy(k, false))).click(); + }); + + // check the state of the global checkbox + if (KEYWORDS.length > SELECTED_KEYWORDS.length) { + cy.get( + buildDataCy( + buildEditableSelectAllButtonCy(CheckBoxState.INDETERMINATE), + ), + ); + } else { + cy.get( + buildDataCy(buildEditableSelectAllButtonCy(CheckBoxState.CHECKED)), + ); + } + + // delete the selection + cy.get(buildDataCy(EDITABLE_TABLE_DELETE_SELECTION_BUTTON_CY)).click(); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length - SELECTED_KEYWORDS.length, + ); + if (KEYWORDS.length - SELECTED_KEYWORDS.length === 0) { + cy.get(buildDataCy(EDITABLE_TABLE_NO_DATA_CY)).should('be.visible'); + } + }); + + it('delete all', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should( + 'have.length', + KEYWORDS.length, + ); + + KEYWORDS.forEach((k) => + cy + .get(buildDataCy(buildEditableTableSelectButtonCy(k.word, false))) + .click(), + ); + + // check the state of the global checkbox + cy.get( + buildDataCy(buildEditableSelectAllButtonCy(CheckBoxState.CHECKED)), + ); + + // delete the selection + cy.get(buildDataCy(EDITABLE_TABLE_DELETE_SELECTION_BUTTON_CY)).click(); + + cy.get(buildDataCy(EDITABLE_TABLE_ROW_CY)).should('have.length', 0); + cy.get(buildDataCy(EDITABLE_TABLE_NO_DATA_CY)).should('be.visible'); + }); + + it('edit multiple keywords', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const UPDATING_KEYWORDS = [ + KEYWORDS.at(0).word, + KEYWORDS.at(KEYWORDS.length - 1).word, + ]; + const NEW_KEYWORDS: Keyword[] = [ + { + word: 'new keyword', + def: 'a new definition', + }, + { + word: 'another keyword', + def: 'another definition', + }, + ]; + const RESULTS_KEYWORDS = [ + ...NEW_KEYWORDS, + ...KEYWORDS.filter( + (k) => + !UPDATING_KEYWORDS.find( + (k1) => k.word.toLowerCase() === k1.toLowerCase(), + ), + ), + ]; + + checkAllKeywords(KEYWORDS); + + // updates the keywords without saving for now + UPDATING_KEYWORDS.forEach((k, idx) => { + cy.get(buildDataCy(buildEditableTableEditButtonCy(k))).click(); + cy.get(buildDataCy(buildKeywordTextInputCy(k, false))) + .clear() + .type(NEW_KEYWORDS[idx].word); + cy.get(buildDataCy(buildKeywordDefinitionTextInputCy(k, false))) + .clear() + .type(NEW_KEYWORDS[idx].def); + }); + + // save all the modifications + cy.get(buildDataCy(EDITABLE_TABLE_SAVE_ALL_BUTTON_CY)).click(); + + // the save button should not exist + cy.get(buildDataCy(EDITABLE_TABLE_SAVE_ALL_BUTTON_CY)).should( + 'not.exist', + ); + + // the modifications should have been apply and other keywords should still have same values + checkAllKeywords(RESULTS_KEYWORDS); + }); + + it('discard multiple keywords', () => { + const KEYWORDS = getKeywords(MOCK_APP_SETTINGS_USING_CHATBOT).keywords; + const UPDATING_KEYWORDS = [ + KEYWORDS.at(0).word, + KEYWORDS.at(KEYWORDS.length - 1).word, + ]; + const NEW_KEYWORDS: Keyword[] = [ + { + word: 'new keyword', + def: 'a new definition', + }, + { + word: 'another keyword', + def: 'another definition', + }, + ]; + + checkAllKeywords(KEYWORDS); + + UPDATING_KEYWORDS.forEach((k, idx) => { + // edit the existing keyword to a keyword that are already in the table + cy.get(buildDataCy(buildEditableTableEditButtonCy(k))).click(); + cy.get(buildDataCy(buildKeywordTextInputCy(k, false))) + .clear() + .type(NEW_KEYWORDS[idx].word); + cy.get(buildDataCy(buildKeywordDefinitionTextInputCy(k, false))) + .clear() + .type(NEW_KEYWORDS[idx].def); + }); + + // discard all the modifications + cy.get(buildDataCy(EDITABLE_TABLE_DISCARD_ALL_BUTTON_CY)).click(); + + // the discard button should not exist + cy.get(buildDataCy(EDITABLE_TABLE_DISCARD_ALL_BUTTON_CY)).should( + 'not.exist', + ); + + // all keywords should still have same values + checkAllKeywords(KEYWORDS); + }); + }); + + it('keyword not in text display warning', () => { + const KEYWORD_IN_TEXT = getKeywords( + MOCK_APP_SETTINGS_USING_CHATBOT, + ).keywords.at(0).word; + + // add keyword to text to be sure that it appear in it + cy.get(buildDataCy(TEXT_INPUT_FIELD_CY)).type(` ${KEYWORD_IN_TEXT}`); + // should be disabled automatically by auto save + cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('be.disabled'); + + // the existing keyword should not have warning + cy.get(buildDataCy(buildKeywordNotExistWarningCy(KEYWORD_IN_TEXT))).should( + 'not.exist', + ); + + // the missing keyword should have a warning + cy.get( + buildDataCy(buildKeywordNotExistWarningCy(MOCK_KEYWORD_NOT_IN_TEXT.word)), + ).should('be.visible'); + }); +}); diff --git a/cypress/fixtures/appSettings.ts b/cypress/fixtures/appSettings.ts index f38f87ae..a935eeae 100644 --- a/cypress/fixtures/appSettings.ts +++ b/cypress/fixtures/appSettings.ts @@ -15,11 +15,17 @@ import { CURRENT_MEMBER, MOCK_SERVER_ITEM } from '../../src/data/db'; export const MOCK_TEXT_RESOURCE = 'Lorem ipsum dolor sit amet. Ut optio laborum qui ducimus rerum eum illum possimus non quidem facere. A neque quia et placeat exercitationem vel necessitatibus Quis ea quod necessitatibus sit voluptas culpa ut laborum quia ad nobis numquam. Quo quibusdam maiores et numquam molestiae ut mollitia quaerat et voluptates autem qui expedita delectus aut aliquam expedita et odit incidunt. Id quia nulla est voluptate repellat non internos voluptatem sed cumque omnis et consequatur placeat qui illum aperiam eos consequatur suscipit.'; +export const MOCK_KEYWORD_NOT_IN_TEXT: Keyword = { + word: 'kjbjkhfbjhdbfjhd', + def: 'i am not in the text', +}; + export const MOCK_KEYWORDS: Keyword[] = [ { word: 'lorem', def: 'definition of lorem is blablabla' }, { word: 'ispum', def: 'ipsum is blablabla' }, { word: 'et', def: 'et means blablabla' }, { word: 'expedita', def: 'expedita means blablabla' }, + MOCK_KEYWORD_NOT_IN_TEXT, ]; export const MOCK_TEXT_RESOURCE_SETTING: AppSetting = { diff --git a/src/components/common/settings/KeyWords.tsx b/src/components/common/settings/KeyWords.tsx index aba53739..7d5c3ee9 100644 --- a/src/components/common/settings/KeyWords.tsx +++ b/src/components/common/settings/KeyWords.tsx @@ -1,40 +1,20 @@ -import { FC, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import DeleteIcon from '@mui/icons-material/Delete'; -import WarningIcon from '@mui/icons-material/Warning'; -import { - Alert, - Box, - IconButton, - List, - ListItem, - Stack, - TextField, - Tooltip, - TooltipProps, - Typography, - styled, - tooltipClasses, -} from '@mui/material'; +import { Alert, Box, Stack, TextField } from '@mui/material'; import { TEXT_ANALYSIS } from '@/langs/constants'; -import { isKeywordPresent } from '@/utils/keywords'; +import { includes } from '@/utils/keywords'; import { Keyword } from '../../../config/appSettingTypes'; import { ADD_KEYWORD_BUTTON_CY, - DELETE_KEYWORD_BUTTON_CY, ENTER_DEFINITION_FIELD_CY, ENTER_KEYWORD_FIELD_CY, - KEYWORD_LIST_ITEM_CY, - buildKeywordNotExistWarningCy, } from '../../../config/selectors'; -import { - DEFAULT_IN_SECTION_SPACING, - ICON_MARGIN, -} from '../../../config/stylingConstants'; +import { DEFAULT_IN_SECTION_SPACING } from '../../../config/stylingConstants'; +import KeywordsTable from '../../views/admin/KeywordsTable'; import GraaspButton from './GraaspButton'; type Prop = { @@ -44,18 +24,6 @@ type Prop = { onChange: (keywords: Keyword[]) => void; }; -const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( - -))(({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: '#f5f5f9', - color: 'rgba(0, 0, 0, 0.87)', - maxWidth: 220, - fontSize: theme.typography.pxToRem(12), - border: '1px solid #dadde9', - }, -})); - const KeyWords: FC = ({ keywords, textStudents, @@ -66,6 +34,17 @@ const KeyWords: FC = ({ const defaultKeywordDef = { word: '', def: '' }; const [keywordDef, setKeywordDef] = useState(defaultKeywordDef); + // This temporary storage for keywords is necessary to handle multiple saves. + // Without it, subsequent saves could overwrite previous changes + // as the parent component's keywords state might not update in time. + // Using keywords.map directly would not account for unsaved changes. + const pendingKeywordsRef = useRef(keywords); + + // Effect to synchronize temporary storage with the latest keywords state. + useEffect(() => { + pendingKeywordsRef.current = keywords; + }, [keywords]); + const updateKeywordDefinition = ( key: keyof Keyword, target: { value: string }, @@ -76,15 +55,12 @@ const KeyWords: FC = ({ }); }; - const isDefinitionSet = keywordDef.def && keywordDef.def !== ''; - const handleOnChanges = (newDictionary: Keyword[]): void => onChange(newDictionary); const handleClickAdd = (): void => { const wordToLowerCase = keywordDef.word.toLocaleLowerCase(); - const definition = isDefinitionSet ? keywordDef.def : 'no definition'; - const newKeyword = { word: wordToLowerCase, def: definition }; + const newKeyword = { word: wordToLowerCase, def: keywordDef.def ?? '' }; if (keywords.some((k) => k.word === wordToLowerCase)) { toast.warning( @@ -101,46 +77,8 @@ const KeyWords: FC = ({ setKeywordDef(defaultKeywordDef); }; - const handleDelete = (id: string): void => { - handleOnChanges(keywords.filter((k) => k.word !== id)); - }; - - const keyWordsItems = keywords.map((k) => ( - - handleDelete(k.word)} - > - - - {`${k.word} : ${k.def}`} - {!isKeywordPresent(textStudents, k.word) && ( - - - {t(TEXT_ANALYSIS.KEYWORDS_NOT_IN_TEXT_TOOLTIP_TITLE)} - - {t(TEXT_ANALYSIS.KEYWORDS_NOT_IN_TEXT_TOOLTIP_MSG, { - keyword: k.word, - })} - - } - > - - - )} - - )); + const handleDelete = (deletedKeywords: Keyword[]): void => + handleOnChanges(keywords.filter((k) => !includes(deletedKeywords, k))); return ( @@ -182,7 +120,19 @@ const KeyWords: FC = ({ /> - {keyWordsItems} + { + // Update the temporary storage with the new keyword. + pendingKeywordsRef.current = pendingKeywordsRef.current.map( + (keyword) => (keyword.word === oldKey ? newKeyword : keyword), + ); + // Propagate changes to the parent component. + handleOnChanges(pendingKeywordsRef.current); + }} + onDeleteSelection={handleDelete} + /> ); }; diff --git a/src/components/common/settings/SwitchModes.tsx b/src/components/common/settings/SwitchModes.tsx index 7b776608..b669bd83 100644 --- a/src/components/common/settings/SwitchModes.tsx +++ b/src/components/common/settings/SwitchModes.tsx @@ -19,6 +19,7 @@ const SwitchModes: FC = ({ value, onChange }) => { return ( = { + columns: Column[]; + rows: Row[]; + isSelectable?: boolean; + isEditable?: boolean; + + onUpdate: (rowId: string, newRow: Row) => Promise; + onDeleteSelection: (selection: Row[]) => void; + rowIsInFilter: (row: Row, filter: string) => boolean; +}; + +const EditableTable = ({ + columns, + rows, + isSelectable = true, + isEditable = true, + + onUpdate, + onDeleteSelection, + rowIsInFilter, +}: Props): JSX.Element => { + const viewModel = useEditableTable({ + columns, + rows, + isSelectable, + isEditable, + + onUpdate, + onDeleteSelection, + rowIsInFilter, + }); + + return ( + + + + + {viewModel.filteredRows.length ? ( + viewModel.filteredRows.map((r) => ( + + {isSelectable && ( + + + viewModel.handleCheckBoxChanged(isChecked, r.rowId) + } + checked={viewModel.isRowChecked(r.rowId)} + /> + + )} + + {columns.map((c) => ( + + + + viewModel.handleRowChanged(r, c.key, value) + } + multiline={c.multiline} + readonly={!viewModel.isEditingRow(r.rowId)} + /> + {c.renderAfter?.(r)} + + + ))} + + {isEditable && ( + + + + )} + + )) + ) : ( + + + + {viewModel.tableNoResultMessage} + + + + )} + + + + + ); +}; + +export default EditableTable; diff --git a/src/components/common/table/MissingKeywordWarning.tsx b/src/components/common/table/MissingKeywordWarning.tsx new file mode 100644 index 00000000..e3d78534 --- /dev/null +++ b/src/components/common/table/MissingKeywordWarning.tsx @@ -0,0 +1,59 @@ +import { t } from 'i18next'; + +import WarningIcon from '@mui/icons-material/Warning'; +import { + Tooltip, + TooltipProps, + Typography, + styled, + tooltipClasses, +} from '@mui/material'; + +import { Keyword } from '@/config/appSettingTypes'; +import { buildKeywordNotExistWarningCy } from '@/config/selectors'; +import { TEXT_ANALYSIS } from '@/langs/constants'; +import { isKeywordPresent } from '@/utils/keywords'; + +import { Row } from './types'; + +const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: '#f5f5f9', + color: 'rgba(0, 0, 0, 0.87)', + maxWidth: 220, + fontSize: theme.typography.pxToRem(12), + border: '1px solid #dadde9', + }, +})); + +const MissingKeywordWarning = ( + row: Row, + text: string, +): JSX.Element | null => { + if (!isKeywordPresent(text, row.word)) { + return ( + + + {t(TEXT_ANALYSIS.KEYWORDS_NOT_IN_TEXT_TOOLTIP_TITLE)} + + {t(TEXT_ANALYSIS.KEYWORDS_NOT_IN_TEXT_TOOLTIP_MSG, { + keyword: row.word, + })} + + } + > + + + ); + } + return null; +}; + +export default MissingKeywordWarning; diff --git a/src/components/common/table/ReadableTextField.tsx b/src/components/common/table/ReadableTextField.tsx new file mode 100644 index 00000000..5eefac8e --- /dev/null +++ b/src/components/common/table/ReadableTextField.tsx @@ -0,0 +1,49 @@ +import { TextField, TextFieldProps, Typography, styled } from '@mui/material'; + +import { buildEditableTableTextInputCy } from '@/config/selectors'; + +type Props = { + readonly: boolean; + size?: TextFieldProps['size']; + multiline?: TextFieldProps['multiline']; + value: string | unknown; + rowId: string; + fieldName: string; + onChange: (value: string) => void; +}; + +// Set the same padding as for the text input to avoid +// movements between Typography and Textfield texts. +const ReadyonlyTextField = styled(Typography)(({ theme }) => ({ + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, +})); + +const ReadableTextField = ({ + rowId, + value, + fieldName, + size, + multiline, + readonly, + onChange, +}: Props): JSX.Element => { + const dataCy = buildEditableTableTextInputCy(rowId, fieldName, readonly); + + return readonly ? ( + {`${value}`} + ) : ( + onChange(e.target.value)} + fullWidth + sx={{ + minWidth: '25ch', + }} + /> + ); +}; + +export default ReadableTextField; diff --git a/src/components/common/table/TableActions.tsx b/src/components/common/table/TableActions.tsx new file mode 100644 index 00000000..7473ed55 --- /dev/null +++ b/src/components/common/table/TableActions.tsx @@ -0,0 +1,103 @@ +import { t } from 'i18next'; + +import CancelIcon from '@mui/icons-material/Cancel'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import SaveIcon from '@mui/icons-material/Save'; +import { IconButton, Stack, Tooltip } from '@mui/material'; + +import { + buildEditableTableDeleteButtonCy, + buildEditableTableDiscardButtonCy, + buildEditableTableEditButtonCy, + buildEditableTableSaveButtonCy, +} from '@/config/selectors'; +import { TEXT_ANALYSIS } from '@/langs/constants'; + +export enum TableActionEvent { + EDIT = 'edit', + DISCARD = 'discard', + SAVE = 'save', + DELETE = 'delete', +} + +type Props = { + isEditing: boolean; + isValid: boolean; + rowId: string; + onEvent: (rowId: string, event: TableActionEvent) => void; +}; + +const TableActions = ({ + isEditing, + isValid, + rowId, + onEvent, +}: Props): JSX.Element => { + const handleEvent = (event: TableActionEvent): void => onEvent(rowId, event); + + return ( + + {isEditing ? ( + <> + + { + if (isValid) { + handleEvent(TableActionEvent.SAVE); + } + }} + > + + + + + handleEvent(TableActionEvent.DISCARD)} + > + + + + + ) : ( + <> + + handleEvent(TableActionEvent.EDIT)} + > + + + + + handleEvent(TableActionEvent.DELETE)} + > + + + + + )} + + ); +}; + +export default TableActions; diff --git a/src/components/common/table/TableFooter.tsx b/src/components/common/table/TableFooter.tsx new file mode 100644 index 00000000..d2aa529a --- /dev/null +++ b/src/components/common/table/TableFooter.tsx @@ -0,0 +1,51 @@ +import { t } from 'i18next'; + +import DeleteIcon from '@mui/icons-material/Delete'; +import { Button } from '@mui/material'; + +import { EDITABLE_TABLE_DELETE_SELECTION_BUTTON_CY } from '@/config/selectors'; +import { TEXT_ANALYSIS } from '@/langs/constants'; + +import { StyledTd } from './styles'; + +type Props = { + isSelectable: boolean; + isEditable: boolean; + totalColumns: number; + numberFilteredSelection: number; + + handleDeleteSelection: () => void; +}; + +const TableFooter = ({ + isSelectable, + isEditable, + totalColumns, + numberFilteredSelection, + handleDeleteSelection, +}: Props): JSX.Element | null => { + if (isSelectable && isEditable) { + return ( + + + + + + + + ); + } + return null; +}; + +export default TableFooter; diff --git a/src/components/common/table/TableHeader.tsx b/src/components/common/table/TableHeader.tsx new file mode 100644 index 00000000..12c9700b --- /dev/null +++ b/src/components/common/table/TableHeader.tsx @@ -0,0 +1,157 @@ +import { t } from 'i18next'; + +import CancelIcon from '@mui/icons-material/Cancel'; +import SaveIcon from '@mui/icons-material/Save'; +import SearchIcon from '@mui/icons-material/Search'; +import { + Checkbox, + IconButton, + InputAdornment, + Stack, + TextField, + Tooltip, +} from '@mui/material'; + +import { UseEditableTableType } from '@/components/hooks/useEditableTable'; +import { + EDITABLE_TABLE_DISCARD_ALL_BUTTON_CY, + EDITABLE_TABLE_FILTER_INPUT_CY, + EDITABLE_TABLE_SAVE_ALL_BUTTON_CY, + buildEditableSelectAllButtonCy, +} from '@/config/selectors'; +import { TEXT_ANALYSIS } from '@/langs/constants'; + +import { StyledTd, StyledTh } from './styles'; +import { CheckBoxState, Column, RowType } from './types'; + +const computeCheckBoxState = ( + isChecked: boolean, + isIndeterminate: boolean, +): CheckBoxState => { + if (isChecked) { + return CheckBoxState.CHECKED; + } + + if (isIndeterminate) { + return CheckBoxState.INDETERMINATE; + } + + return CheckBoxState.UNCHECKED; +}; + +type Props = { + columns: Column[]; + isSelectable: boolean; + isEditable: boolean; + + viewModel: UseEditableTableType; +}; + +const TableHeader = ({ + columns, + isSelectable, + isEditable, + + viewModel, +}: Props): JSX.Element => ( + <> + + + + + + + ), + }} + variant="outlined" + size="small" + onChange={(event) => + viewModel.updateTableFilter(event.target.value) + } + /> + + {viewModel.isEditing && ( + + + + { + if (viewModel.areRowsValid) { + viewModel.handleSaveAll(); + } + }} + > + + + + + + + + + + + )} + + + + + {isSelectable && ( + + + viewModel.handleGlobalOnChange(isChecked) + } + /> + + )} + {columns.map((c) => ( + {c.displayColumn} + ))} + {isEditable && ( + + {t(TEXT_ANALYSIS.BUILDER_KEYWORDS_TABLE_ACTIONS_COLUMN)} + + )} + + + +); + +export default TableHeader; diff --git a/src/components/common/table/styles.tsx b/src/components/common/table/styles.tsx new file mode 100644 index 00000000..6e7b721f --- /dev/null +++ b/src/components/common/table/styles.tsx @@ -0,0 +1,35 @@ +import { Box, SxProps, styled } from '@mui/material'; + +type StyledThProps = SxProps & { + padding?: number; +}; +const BORDER_COLOR = '#dddddd'; +const BORDER_STYLE = `1px solid ${BORDER_COLOR}`; +const HEADER_BG_COLOR = '#f2f2f2'; + +export const StyledBox = styled(Box)({ + width: '100%', + border: BORDER_STYLE, + overflowY: 'auto', +}); + +export const StyledTable = styled('table')({ + borderCollapse: 'collapse', + width: '100%', + border: BORDER_STYLE, +}); + +export const StyledTh = styled('th')(({ theme }) => ({ + border: BORDER_STYLE, + padding: theme.spacing(1), + textAlign: 'left', + backgroundColor: HEADER_BG_COLOR, +})); + +export const StyledTd = styled('td')( + ({ width, padding, theme }) => ({ + border: BORDER_STYLE, + padding: theme.spacing(padding ?? 1), + width: width ?? 'auto', + }), +); diff --git a/src/components/common/table/types.ts b/src/components/common/table/types.ts new file mode 100644 index 00000000..60bb06f5 --- /dev/null +++ b/src/components/common/table/types.ts @@ -0,0 +1,22 @@ +// An alias to better represent the value of the string. +export type RowId = string; + +export type RowType = { [key: string]: unknown }; + +export type Row = { rowId: RowId } & T; + +export type RowKey = Extract; + +export type Column = { + key: RowKey; + displayColumn: string; + multiline?: boolean; + optional?: boolean; + renderAfter?: (row: Row) => JSX.Element | null; +}; + +export enum CheckBoxState { + UNCHECKED = 'unchecked', + CHECKED = 'checked', + INDETERMINATE = 'indeterminate', +} diff --git a/src/components/hooks/useEditableTable.tsx b/src/components/hooks/useEditableTable.tsx new file mode 100644 index 00000000..b31ff13c --- /dev/null +++ b/src/components/hooks/useEditableTable.tsx @@ -0,0 +1,283 @@ +import { t } from 'i18next'; + +import { useState } from 'react'; + +import { + EDITABLE_TABLE_FILTER_NO_RESULT_CY, + EDITABLE_TABLE_NO_DATA_CY, + buildEditableTableSelectButtonCy, +} from '@/config/selectors'; +import { TEXT_ANALYSIS } from '@/langs/constants'; + +import { TableActionEvent } from '../common/table/TableActions'; +import { Column, Row, RowId, RowType } from '../common/table/types'; + +export type UseEditableTableType = { + totalColumns: number; + numberFilteredSelection: number; + filteredRows: Row[]; + tableNoResultDataCy: string; + tableNoResultMessage: string; + isEditing: boolean; + isGlobalChecked: boolean; + isGlobalIndeterminate: boolean; + areRowsValid: boolean; + isEditingRow: (rowId: RowId) => boolean; + isRowChecked: (rowId: RowId) => boolean; + isValidRow: (row: Row) => boolean; + getColValue: (row: Row, col: string) => unknown; + buildTableSelectButtonCy: (rowId: RowId) => string; + updateTableFilter: (newFilter: string) => void; + handleCheckBoxChanged: (isChecked: boolean, rowId: RowId) => void; + handleDeleteSelection: () => void; + handleRowChanged: (row: Row, columnKey: string, value: string) => void; + handleSaveAll: () => void; + handleDiscardAll: () => void; + handleActionEvents: (rowId: RowId, event: TableActionEvent) => void; + handleGlobalOnChange: (isChecked: boolean) => void; +}; + +type UseEditableTableProps = { + columns: Column[]; + rows: Row[]; + isSelectable?: boolean; + isEditable?: boolean; + + onUpdate: (rowId: string, newRow: Row) => Promise; + onDeleteSelection: (selection: Row[]) => void; + rowIsInFilter: (row: Row, filter: string) => boolean; +}; + +export const useEditableTable = ({ + rows, + columns, + isSelectable, + isEditable, + + onUpdate, + onDeleteSelection, + rowIsInFilter, +}: UseEditableTableProps): UseEditableTableType => { + const [tableFilter, setTableFilter] = useState(''); + const [selectedRows, setSelectedRows] = useState[]>([]); + const [editingRows, setEditingRows] = useState>>(new Map()); + + const filteredRows = rows.filter((r) => rowIsInFilter(r, tableFilter)); + const filteredSelection = selectedRows.filter((r) => + rowIsInFilter(r, tableFilter), + ); + const numberFilteredSelection = filteredSelection.length; + const totalColumns = + columns.length + + // add checkbox and actions columns + (isSelectable ? 1 : 0) + + (isEditable ? 1 : 0); + + const isEditingRow = (rowId: RowId): boolean => editingRows.has(rowId); + + const isEditing = Boolean(editingRows.size); + + const isGlobalChecked = Boolean( + numberFilteredSelection && numberFilteredSelection === filteredRows.length, + ); + + const isGlobalIndeterminate = Boolean( + numberFilteredSelection && numberFilteredSelection !== filteredRows.length, + ); + + const isKeysEquals = (r1: RowId, r2: RowId): boolean => + r1.toLowerCase() === r2.toLowerCase(); + + const rowsInclude = (searchInRows: Row[], rowId: RowId): boolean => + searchInRows.find((r) => isKeysEquals(r.rowId, rowId)) !== undefined; + + const getColValue = (row: Row, col: string): unknown => + editingRows.get(row.rowId)?.[col] ?? row[col]; + + const getRow = (rowId: RowId): Row | undefined => + rows.find((r) => isKeysEquals(r.rowId, rowId)); + + const isValidRow = (row: Row): boolean => + columns.some((c) => c.optional === false && !getColValue(row, c.key)); + + const areRowsValid = !rows.some(isValidRow); + + const addInEditing = (row: Row): void => + setEditingRows((currState) => new Map([...currState, [row.rowId, row]])); + + const handleCheckBoxChanged = (isChecked: boolean, rowId: RowId): void => { + const rowInSelection = rowsInclude(selectedRows, rowId); + if (isChecked && !rowInSelection) { + const row = getRow(rowId); + if (row) { + setSelectedRows((currState) => [...currState, row]); + } + } else if (!isChecked && rowInSelection) { + setSelectedRows((currState) => + currState.filter((s) => !isKeysEquals(s.rowId, rowId)), + ); + } + }; + + const handleDeleteSelection = (): void => { + if (selectedRows.length) { + setEditingRows((currState) => { + const newMap = new Map(currState); + filteredSelection.forEach((k) => newMap.delete(k.rowId)); + return newMap; + }); + onDeleteSelection(filteredSelection); + setSelectedRows((currSelection) => + currSelection.filter( + ({ rowId }) => !rowsInclude(filteredSelection, rowId), + ), + ); + } + }; + + const updateEditingRow = (rowId: RowId, newRow: Row): void => + setEditingRows((currState) => { + const newMap = new Map(currState); + newMap.set(rowId, newRow); + return newMap; + }); + + const handleRowChanged = ( + row: Row, + columnKey: string, + value: string, + ): void => { + updateEditingRow(row.rowId, { + ...(editingRows.get(row.rowId) ?? row), + [columnKey]: value, + }); + }; + + const removeRowFromEditing = (rowId: RowId): void => + setEditingRows((currState) => { + const newMap = new Map(currState); + newMap.delete(rowId); + return newMap; + }); + + const saveRow = (rowId: RowId): void => { + const newRow = editingRows.get(rowId); + + if (newRow) { + onUpdate(rowId, newRow) + .then(() => { + removeRowFromEditing(rowId); + // remove the update item from the selection because we don't know its new id from this component. + // TODO: remove this code if the keyword had a unique ID that didn't change after an update. + setSelectedRows((currSelection) => + currSelection.filter(({ rowId: rId }) => !isKeysEquals(rId, rowId)), + ); + }) + // catch the exception, but there is nothing to do here. + .catch((e) => e); + } + }; + + const handleSaveAll = (): void => { + editingRows.forEach((_, rowId) => { + if (rowsInclude(filteredRows, rowId)) { + saveRow(rowId); + } + }); + }; + + const handleDiscardAll = (): void => { + editingRows.forEach((_, rowId) => { + if (rowsInclude(filteredRows, rowId)) { + removeRowFromEditing(rowId); + } + }); + }; + + const handleOnEdit = (rowId: RowId): void => { + if (isEditingRow(rowId)) { + // Row is already been editingKeywords. + return; + } + + const row = getRow(rowId); + if (row) { + addInEditing(row); + } + }; + + const handleActionEvents = (rowId: RowId, event: TableActionEvent): void => { + switch (event) { + case TableActionEvent.EDIT: + handleOnEdit(rowId); + break; + case TableActionEvent.DISCARD: + removeRowFromEditing(rowId); + break; + case TableActionEvent.SAVE: + saveRow(rowId); + break; + case TableActionEvent.DELETE: + removeRowFromEditing(rowId); + onDeleteSelection(rows.filter((r) => isKeysEquals(r.rowId, rowId))); + setSelectedRows((currSelection) => + currSelection.filter((s) => !isKeysEquals(s.rowId, rowId)), + ); + break; + default: + throw new Error(`TableActionEvent "${event}" unknown.`); + } + }; + + const handleGlobalOnChange = (isChecked: boolean): void => { + const newSelection = selectedRows.filter( + ({ rowId }) => !rowsInclude(filteredSelection, rowId), + ); + + if (isChecked) { + filteredRows.forEach((r) => newSelection.push(r)); + } + setSelectedRows(newSelection); + }; + + const tableNoResultDataCy = rows.length + ? EDITABLE_TABLE_FILTER_NO_RESULT_CY + : EDITABLE_TABLE_NO_DATA_CY; + + const tableNoResultMessage = rows.length + ? t(TEXT_ANALYSIS.BUILDER_KEYWORDS_TABLE_FILTER_NO_DATA, { + filter: tableFilter, + }) + : t(TEXT_ANALYSIS.BUILDER_KEYWORDS_TABLE_NO_DATA); + + const isRowChecked = (rowId: RowId): boolean => + rowsInclude(selectedRows, rowId); + + const buildTableSelectButtonCy = (rowId: RowId): string => + buildEditableTableSelectButtonCy(rowId, isRowChecked(rowId)); + + return { + totalColumns, + numberFilteredSelection, + filteredRows, + tableNoResultDataCy, + tableNoResultMessage, + isEditing, + isGlobalChecked, + isGlobalIndeterminate, + areRowsValid, + isEditingRow, + isRowChecked, + isValidRow, + getColValue, + buildTableSelectButtonCy, + updateTableFilter: setTableFilter, + handleCheckBoxChanged, + handleDeleteSelection, + handleRowChanged, + handleSaveAll, + handleDiscardAll, + handleActionEvents, + handleGlobalOnChange, + }; +}; diff --git a/src/components/views/admin/KeywordsTable.tsx b/src/components/views/admin/KeywordsTable.tsx new file mode 100644 index 00000000..13899b4f --- /dev/null +++ b/src/components/views/admin/KeywordsTable.tsx @@ -0,0 +1,82 @@ +import { t } from 'i18next'; + +import { toast } from 'react-toastify'; + +import MissingKeywordWarning from '@/components/common/table/MissingKeywordWarning'; +import { Keyword } from '@/config/appSettingTypes'; +import { TEXT_ANALYSIS } from '@/langs/constants'; +import { areKeywordsEquals } from '@/utils/keywords'; + +import EditableTable from '../../common/table/EditableTable'; +import { Column, Row } from '../../common/table/types'; + +const includes = (text: string, search: string): boolean => + text.toLowerCase().includes(search.toLowerCase()); + +const keywordIsInFilter = (k: Row, filter: string): boolean => + includes(k.word, filter) || includes(k.def, filter); + +type Props = { + keywords: Keyword[]; + text: string; + onUpdate: (oldKey: string, newKeyword: Keyword) => void; + onDeleteSelection: (selectedKeywords: Keyword[]) => void; +}; + +const KeywordsTable = ({ + keywords, + text, + onUpdate, + onDeleteSelection, +}: Props): JSX.Element => { + const columns: Column[] = [ + { + key: 'word', + displayColumn: t(TEXT_ANALYSIS.BUILDER_KEYWORDS_TABLE_KEYWORD_COLUMN), + renderAfter: (content) => MissingKeywordWarning(content, text), + optional: false, + }, + { + key: 'def', + displayColumn: t(TEXT_ANALYSIS.BUILDER_KEYWORDS_TABLE_DEFINITION_COLUMN), + multiline: true, + }, + ]; + + const rows = keywords.map((k) => ({ rowId: k.word, ...k })); + + const handleSave = (rowId: string, newRow: Row): Promise => { + if ( + !areKeywordsEquals(rowId, newRow) && + keywords.find((k) => areKeywordsEquals(k, newRow)) + ) { + const alreadyExistsMsg = t( + TEXT_ANALYSIS.KEYWORD_ALREADY_EXIST_WARNING_MESSAGE, + { + keyword: newRow.word.toLowerCase(), + }, + ); + + toast.warning(alreadyExistsMsg); + return Promise.reject(alreadyExistsMsg); + } + + onUpdate(rowId, { word: newRow.word, def: newRow.def }); + return Promise.resolve(); + }; + + const handleOnDeleteSelection = (selection: Row[]): void => + onDeleteSelection(selection.map((r) => ({ word: r.rowId, def: r.def }))); + + return ( + + ); +}; + +export default KeywordsTable; diff --git a/src/config/selectors.ts b/src/config/selectors.ts index 7f2e5f9f..33e4fcd1 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -1,3 +1,5 @@ +import { CheckBoxState } from '@/components/common/table/types'; + export const PLAYER_VIEW_CY = 'player_view'; export const BUILDER_VIEW_CY = 'builder_view'; export const APP_DATA_CONTAINER_CY = 'app_data_container'; @@ -8,11 +10,9 @@ export const SETTING_NAME_FIELD_CY = 'setting_name_field'; export const SETTING_VALUE_FIELD_CY = 'setting_value_field'; export const TEXT_DISPLAY_FIELD_CY = 'text_display_field'; -export const KEYWORD_LIST_ITEM_CY = 'keyword_list_item'; export const ENTER_KEYWORD_FIELD_CY = 'enter_keyword_field'; export const ENTER_DEFINITION_FIELD_CY = 'enter_definition_field'; export const ADD_KEYWORD_BUTTON_CY = 'add_keyword_button'; -export const DELETE_KEYWORD_BUTTON_CY = 'delete_keyword_button'; export const CHATBOT_CONTAINER_CY = 'chatbot_container'; export const INITIAL_PROMPT_INPUT_FIELD_CY = 'initial_prompt_input_field'; @@ -35,8 +35,54 @@ export const CHATBOT_MODE_CY = 'chatbot_mode_cy'; export const SETTINGS_SAVE_BUTTON_CY = 'settings_save_button'; +// editable table +const removeRowIdSpaces = (rowId: string): string => rowId.replaceAll(' ', '-'); +export const EDITABLE_TABLE_CY = 'editable_table'; +export const EDITABLE_TABLE_ROW_CY = 'editable_table_row'; +export const EDITABLE_TABLE_FILTER_INPUT_CY = 'editable_table_filter_input'; +export const EDITABLE_TABLE_DELETE_SELECTION_BUTTON_CY = + 'editable_table_delete_selection_button'; +export const EDITABLE_TABLE_SAVE_ALL_BUTTON_CY = + 'editable_table_save_all_button'; +export const EDITABLE_TABLE_DISCARD_ALL_BUTTON_CY = + 'editable_table_discard_all_button'; +export const EDITABLE_TABLE_NO_DATA_CY = 'edit_table_no_data'; +export const EDITABLE_TABLE_FILTER_NO_RESULT_CY = 'edit_table_filter_no_result'; +export const buildEditableSelectAllButtonCy = (state: CheckBoxState): string => + `editable_table_select_all_button_${state}`; +export const buildEditableTableSelectButtonCy = ( + rowId: string, + isChecked: boolean, +): string => + `editable_table_${removeRowIdSpaces(rowId)}_select_button_${isChecked}`; +export const buildEditableTableEditButtonCy = (rowId: string): string => + `editable_table_${removeRowIdSpaces(rowId)}_edit_button`; +export const buildEditableTableDeleteButtonCy = (rowId: string): string => + `editable_table_${removeRowIdSpaces(rowId)}_delete_button`; +export const buildEditableTableSaveButtonCy = (rowId: string): string => + `editable_table_${removeRowIdSpaces(rowId)}_save_button`; +export const buildEditableTableDiscardButtonCy = (rowId: string): string => + `editable_table_${removeRowIdSpaces(rowId)}_discard_button`; +export const buildEditableTableTextInputCy = ( + rowId: string, + columnName: string, + readonly: boolean, +): string => + `editable_table_${removeRowIdSpaces( + rowId, + )}_${columnName}_text_input_${readonly}`; + +export const buildKeywordTextInputCy = ( + keyword: string, + readonly: boolean, +): string => buildEditableTableTextInputCy(keyword, 'word', readonly); +export const buildKeywordDefinitionTextInputCy = ( + keyword: string, + readonly: boolean, +): string => buildEditableTableTextInputCy(keyword, 'def', readonly); + export const buildKeywordNotExistWarningCy = (keyword: string): string => - `keyword_${keyword.replaceAll(' ', '-')}_not_in_text_warning`; + `keyword_${removeRowIdSpaces(keyword)}_not_in_text_warning`; export const buildDataCy = (selector: string): string => `[data-cy=${selector}]`; diff --git a/src/langs/constants.ts b/src/langs/constants.ts index e13da916..12916581 100644 --- a/src/langs/constants.ts +++ b/src/langs/constants.ts @@ -33,4 +33,29 @@ export const TEXT_ANALYSIS = { PLAYER_SYNC_ICON_SAVED_LABEL: 'PLAYER_SYNC_ICON_SAVED_LABEL', PLAYER_SYNC_ICON_NO_CHANGES_LABEL: 'PLAYER_SYNC_ICON_NO_CHANGES_LABEL', PLAYER_SYNC_ICON_ERROR_LABEL: 'PLAYER_SYNC_ICON_ERROR_LABEL', + BUILDER_KEYWORDS_TABLE_NO_DATA: 'BUILDER_KEYWORDS_TABLE_NO_DATA', + BUILDER_KEYWORDS_TABLE_FILTER_NO_DATA: + 'BUILDER_KEYWORDS_TABLE_FILTER_NO_DATA', + BUILDER_KEYWORDS_TABLE_SAVE_ROW_TOOLTIP: + 'BUILDER_KEYWORDS_TABLE_SAVE_ROW_TOOLTIP', + BUILDER_KEYWORDS_TABLE_DISCARD_ROW_TOOLTIP: + 'BUILDER_KEYWORDS_TABLE_DISCARD_ROW_TOOLTIP', + BUILDER_KEYWORDS_TABLE_EDIT_ROW_TOOLTIP: + 'BUILDER_KEYWORDS_TABLE_EDIT_ROW_TOOLTIP', + BUILDER_KEYWORDS_TABLE_DELETE_ROW_TOOLTIP: + 'BUILDER_KEYWORDS_TABLE_DELETE_ROW_TOOLTIP', + BUILDER_KEYWORDS_TABLE_DELETE_SELECTION_BTN: + 'BUILDER_KEYWORDS_TABLE_DELETE_SELECTION_BTN', + BUILDER_KEYWORDS_TABLE_SEARCH_PLACEHOLDER: + 'BUILDER_KEYWORDS_TABLE_SEARCH_PLACEHOLDER', + BUILDER_KEYWORDS_TABLE_SAVE_ALL_ROWS_TOOLTIP: + 'BUILDER_KEYWORDS_TABLE_SAVE_ALL_ROWS_TOOLTIP', + BUILDER_KEYWORDS_TABLE_DISCARD_ALL_ROWS_TOOLTIP: + 'BUILDER_KEYWORDS_TABLE_DISCARD_ALL_ROWS_TOOLTIP', + BUILDER_KEYWORDS_TABLE_ACTIONS_COLUMN: + 'BUILDER_KEYWORDS_TABLE_ACTIONS_COLUMN', + BUILDER_KEYWORDS_TABLE_KEYWORD_COLUMN: + 'BUILDER_KEYWORDS_TABLE_KEYWORD_COLUMN', + BUILDER_KEYWORDS_TABLE_DEFINITION_COLUMN: + 'BUILDER_KEYWORDS_TABLE_DEFINITION_COLUMN', } as const; diff --git a/src/langs/en.json b/src/langs/en.json index 909fabdf..e0f83fc7 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -3,7 +3,7 @@ "CONTEXT_FETCHING_ERROR_MESSAGE": "An error occurred while fetching the context.", "TOKEN_REQUEST_ERROR_MESSAGE": "An error occurred while requesting the token.", "CHAT_BOT_ERROR_MESSAGE": "Sorry, an error occurred with the chatbot.", - "KEYWORD_ALREADY_EXIST_WARNING_MESSAGE": "The keyword \"{{keyword}}\" already exists. To update its definition, delete it and enter it again with the new definition.", + "KEYWORD_ALREADY_EXIST_WARNING_MESSAGE": "The keyword \"{{keyword}}\" already exists.", "FIRST_CHATBOT_MESSAGE": "You clicked on {{keyword}}, what do you want to know about it ?", "MAX_CONVERSATION_LENGTH_ALERT": "You have reached the maximum number of messages allowed in the conversation", "INPUT_BAR_REPLY_HERE": "reply here ...", @@ -30,6 +30,19 @@ "PLAYER_SYNC_ICON_LOADING_LABEL": "synchronizing", "PLAYER_SYNC_ICON_SAVED_LABEL": "saved {{timeAgo}}", "PLAYER_SYNC_ICON_NO_CHANGES_LABEL": "no changes", - "PLAYER_SYNC_ICON_ERROR_LABEL": "an error occurred during the synchronization" + "PLAYER_SYNC_ICON_ERROR_LABEL": "an error occurred during the synchronization", + "BUILDER_KEYWORDS_TABLE_NO_DATA": "There is no data for now.", + "BUILDER_KEYWORDS_TABLE_FILTER_NO_DATA": "No data found for \"{{filter}}\".", + "BUILDER_KEYWORDS_TABLE_SAVE_ROW_TOOLTIP": "Save the modifications", + "BUILDER_KEYWORDS_TABLE_DISCARD_ROW_TOOLTIP": "Discard the modifications", + "BUILDER_KEYWORDS_TABLE_EDIT_ROW_TOOLTIP": "Edit the row", + "BUILDER_KEYWORDS_TABLE_DELETE_ROW_TOOLTIP": "Delete the row", + "BUILDER_KEYWORDS_TABLE_DELETE_SELECTION_BTN": "Delete selection ({{numberFilteredSelection}})", + "BUILDER_KEYWORDS_TABLE_SEARCH_PLACEHOLDER": "Search in the table", + "BUILDER_KEYWORDS_TABLE_SAVE_ALL_ROWS_TOOLTIP": "Save all the modifications", + "BUILDER_KEYWORDS_TABLE_DISCARD_ALL_ROWS_TOOLTIP": "Discard all the modifications", + "BUILDER_KEYWORDS_TABLE_ACTIONS_COLUMN": "Actions", + "BUILDER_KEYWORDS_TABLE_KEYWORD_COLUMN": "Keyword", + "BUILDER_KEYWORDS_TABLE_DEFINITION_COLUMN": "Definition" } } diff --git a/src/utils/keywords.ts b/src/utils/keywords.ts index a3308d8a..5592fc03 100644 --- a/src/utils/keywords.ts +++ b/src/utils/keywords.ts @@ -1,3 +1,5 @@ +import { Keyword } from '@/config/appSettingTypes'; + export const isKeywordPresent = (text: string, keyword: string): boolean => { const regex = new RegExp(`\\b${keyword}\\b`, 'i'); return regex.test(text); @@ -11,3 +13,40 @@ export const replaceWordCaseInsensitive = ( const regex = new RegExp(wordToReplace, 'gi'); return text.replace(regex, replacement); }; + +export const areKeywordsEquals = ( + k1: Keyword | string, + k2: Keyword | string, +): boolean => { + const compareStrings = (s1: string, s2: string): boolean => + s1.toLowerCase() === s2.toLowerCase(); + + const isString = (k: Keyword | string): k is string => typeof k === 'string'; + const k1IsString = isString(k1); + const k2IsString = isString(k2); + + // Don't use switch case here because it seems that + // Typescript can't infer the types using case guards. + if (k1IsString && k2IsString) { + return compareStrings(k1, k2); + } + if (k1IsString && !k2IsString) { + return compareStrings(k1, k2.word); + } + if (!k1IsString && k2IsString) { + return compareStrings(k1.word, k2); + } + + if (!k1IsString && !k2IsString) { + return compareStrings(k1.word, k2.word); + } + + throw new Error( + `${typeof k1} and ${typeof k1} must be valid keywords or strings.`, + ); +}; + +export const includes = ( + keywords: Keyword[], + keyword: Keyword | string, +): boolean => keywords.find((k) => areKeywordsEquals(k, keyword)) !== undefined; diff --git a/vite.config.ts b/vite.config.ts index 450ffcad..56b797d0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,7 @@ const config = ({ mode }: { mode: string }): UserConfigExport => { : checker({ typescript: true, eslint: { lintCommand: 'eslint "./**/*.{ts,tsx}"' }, + overlay: { initialIsOpen: false }, }), react(), istanbul({