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 (
+
+
+
+ }
+ variant="contained"
+ onClick={handleDeleteSelection}
+ disabled={!numberFilteredSelection}
+ >
+ {t(TEXT_ANALYSIS.BUILDER_KEYWORDS_TABLE_DELETE_SELECTION_BTN, {
+ numberFilteredSelection,
+ })}
+
+
+
+
+ );
+ }
+ 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