Repository: oppia/oppia

Issue:
[Feature Request]: Improve the experience of adding concept cards in the exploration editor.
### Is your feature request related to a problem? Please describe.

The experience of adding skill concept cards in the exploration editor has a number of issues:

- The highlighted text in the RTE does not get transferred to the concept card skill link modal when it is opened.
- Cancelling the operation results in the disappearance of the highlighted text.
- The list of skills takes a long time to load, even if it has already been fetched previously.
- Terms entered in the skill filter input box do not get applied when the skill list finally loads.
- The detailed skill explanations show up in red, which is hard to read (and suggests some kind of error).

See the video below for the current experience:

https://github.com/oppia/oppia/assets/10575562/60c94af1-8550-4be8-b277-ad4084b8daa9

### Describe the solution you'd like

- [x] When the skill link modal is opened, the text input field should be populated with the highlighted text in the RTE, or "concept card" if there is no highlighted text.
- [x] When the modal is cancelled, the RTE contents should stay as they were before the modal was opened. In particular, any highlighted text should not be deleted.
- [x] The skill filter input box should be disabled until the list of skills has loaded.
- [x] Skill explanations should show up in black text.

### Describe alternatives you've considered

- I considered also writing "the list of skills should load faster", but in reality there's always going to be some lag time. It might be possible to cache this list so that it loads fast for the second and subsequent queries, but that could lead to problems with stale data if a skill is created/deleted while an exploration is being edited. So I suggest that we don't solve that problem in this issue, and fix the other points first -- then, we can see if the loading time is still an issue.

### Additional context

_No response_


Discussion:
@seanlip Just to confirm a few things
1.  When canceling the highlighted portion should not be a RTE component anymore and also it shouldn't be deleted?
2. When highlighting and creating a concept card, the delete button shouldn't be there and only the cancel button?
1. Yes, correct.
2. Yes, I think that would be fine, since one can already just delete directly from the RTE itself. But I have less strong feelings about this as long as it's clear to the user what is going on and the behaviour for all flows is conceptually correct. (I'm a bit unsure if I've interpreted your question correctly, though, since I don't see a delete button in the modal currently?)

Thanks for checking!

Resolving patch:
diff --git a/core/domain/rte_component_registry.py b/core/domain/rte_component_registry.py
--- a/core/domain/rte_component_registry.py
+++ b/core/domain/rte_component_registry.py
@@ -43,6 +43,7 @@ class CustomizationArgSpecDict(TypedDict):
     # Here we use type Any because values in schema dictionary can be of
     # type str, List, Dict and other types too.
     schema: Dict[str, Any]
+    default_value_obtainable_from_highlight: bool
     default_value: Union[str, int, List[str], Dict[str, str]]
 
 


Test patch:
diff --git a/core/domain/rte_component_registry_test.py b/core/domain/rte_component_registry_test.py
--- a/core/domain/rte_component_registry_test.py
+++ b/core/domain/rte_component_registry_test.py
@@ -76,7 +76,8 @@ def _validate_customization_arg_specs(
         """Validates the given customization arg specs."""
         for ca_spec in customization_arg_specs:
             self.assertEqual(set(ca_spec.keys()), set([
-                'name', 'description', 'schema', 'default_value']))
+                'name', 'description', 'schema', 'default_value',
+                'default_value_obtainable_from_highlight']))
 
             self.assertTrue(
                 isinstance(ca_spec['name'], str))
diff --git a/core/tests/webdriverio_desktop/extensions.js b/core/tests/webdriverio_desktop/extensions.js
--- a/core/tests/webdriverio_desktop/extensions.js
+++ b/core/tests/webdriverio_desktop/extensions.js
@@ -43,9 +43,13 @@ describe('rich-text components', function() {
   });
 
   it('should display correctly', async function() {
-    await users.createAndLoginUser(
+    await users.createAndLoginCurriculumAdminUser(
       'user@richTextComponents.com', 'userRichTextComponent');
 
+    await workflow.createTopic(
+      'topic 1', 'topic-one', 'topic 1 description', false);
+    await workflow.createSkillAndAssignTopic(
+      'skill 1', 'description 1', 'topic 1');
     await workflow.createExploration(true);
 
     await explorationEditorMainTab.setContent(async function(richTextEditor) {
@@ -66,6 +70,11 @@ describe('rich-text components', function() {
         title: 'title 1',
         content: await forms.toRichText('contents 2')
       }]);
+      // Test highlighting on Skillreview component.
+      await richTextEditor.appendPlainText('highlight');
+      await general.expectNoTextToBeSelected();
+      await richTextEditor.highlightText('highlight');
+      await richTextEditor.addRteComponent('Skillreview', null, 'skill 1');
     });
 
     await explorationEditorPage.navigateToPreviewTab();
@@ -86,6 +95,8 @@ describe('rich-text components', function() {
           title: 'title 1',
           content: await forms.toRichText('contents 2')
         }]);
+        await richTextChecker.readRteComponent(
+          'Skillreview', 'highlight', await forms.toRichText('description 1'));
       });
 
     await explorationEditorPage.discardChanges();
diff --git a/core/tests/webdriverio_utils/forms.js b/core/tests/webdriverio_utils/forms.js
--- a/core/tests/webdriverio_utils/forms.js
+++ b/core/tests/webdriverio_utils/forms.js
@@ -251,6 +251,40 @@ var RichTextEditor = async function(elem) {
       }
       await _clickToolbarButton('.cke_button__bulletedlist');
     },
+    // This finds and selects a certain text in the RTE.
+    // NOTE: This only selects the first occurrence of the text.
+    highlightText: async function(text) {
+      var paragraphNodes = await rteElements[0].$$('p');
+      var textNode = null;
+      var startIndex = -1;
+      var endIndex = -1;
+
+      for (var node of paragraphNodes) {
+        var nodeText = await node.getText();
+        var index = nodeText.indexOf(text);
+
+        if (index !== -1) {
+          textNode = node;
+          startIndex = index;
+          endIndex = index + text.length;
+          break;
+        }
+      }
+
+      if (!textNode || startIndex === -1 || endIndex === -1) {
+        throw new Error(
+          `Specified text ${text} to highlight not found in RTE`);
+      }
+
+      await browser.execute((element, start, end) => {
+        var range = document.createRange();
+        range.setStart(element.firstChild, start);
+        range.setEnd(element.firstChild, end);
+        var selection = window.getSelection();
+        selection.removeAllRanges();
+        selection.addRange(range);
+      }, textNode, startIndex, endIndex);
+    },
     // This adds and customizes RTE components.
     // Additional arguments may be sent to this function, and they will be
     // passed on to the relevant RTE component editor.
diff --git a/core/tests/webdriverio_utils/general.js b/core/tests/webdriverio_utils/general.js
--- a/core/tests/webdriverio_utils/general.js
+++ b/core/tests/webdriverio_utils/general.js
@@ -242,6 +242,14 @@ var checkConsoleErrorsExist = async function(expectedErrors) {
   }
 };
 
+// This function checks if any text is selected on the page.
+var expectNoTextToBeSelected = async function() {
+  expect(await browser.execute(function() {
+    var selection = window.getSelection();
+    return selection.toString() === '';
+  })).toBe(true);
+};
+
 var goToHomePage = async function() {
   var oppiaMainLogo = $('.e2e-test-oppia-main-logo');
   await action.click('Oppia Main Logo', oppiaMainLogo);
@@ -355,6 +363,7 @@ exports.expectErrorPage = expectErrorPage;
 exports.closeCurrentTabAndSwitchTo = closeCurrentTabAndSwitchTo;
 exports.dragAndDrop = dragAndDrop;
 exports.openAboutDropdown = openAboutDropdown;
+exports.expectNoTextToBeSelected = expectNoTextToBeSelected;
 
 exports.ensurePageHasNoTranslationIds = ensurePageHasNoTranslationIds;
 
diff --git a/core/tests/webdriverio_utils/workflow.js b/core/tests/webdriverio_utils/workflow.js
--- a/core/tests/webdriverio_utils/workflow.js
+++ b/core/tests/webdriverio_utils/workflow.js
@@ -331,6 +331,15 @@ var getExplorationPlaytesters = async function(isEmpty = false) {
   return await _getExplorationRoles('viewer', isEmpty);
 };
 
+var createTopic = async function(
+    topicName, topicUrlFragment, description, shouldCloseTopicEditor) {
+  var topicsAndSkillsDashboardPage = (
+    new TopicsAndSkillsDashboardPage.TopicsAndSkillsDashboardPage());
+  await topicsAndSkillsDashboardPage.get();
+  await topicsAndSkillsDashboardPage.createTopic(
+    topicName, topicUrlFragment, description, shouldCloseTopicEditor);
+};
+
 var createSkillAndAssignTopic = async function(
     skillDescription, material, topicName) {
   var topicsAndSkillsDashboardPage = (
@@ -444,5 +453,6 @@ exports.getExplorationCollaborators = getExplorationCollaborators;
 exports.getExplorationVoiceArtists = getExplorationVoiceArtists;
 exports.getExplorationPlaytesters = getExplorationPlaytesters;
 exports.createAddExpDetailsAndPublishExp = createAddExpDetailsAndPublishExp;
+exports.createTopic = createTopic;
 exports.createSkillAndAssignTopic = createSkillAndAssignTopic;
 exports.createQuestion = createQuestion;