From 50f61ee37b2601abc6aaf04f8dc0077c09b4ee8c Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Wed, 19 Aug 2020 12:32:26 +0200 Subject: [PATCH 1/8] views: remove "Select a value" text on radio lists This text: - Isn't present on other form controls - Should only be displayed if the field is mandatory anyway --- .../shared/dossiers/editable_champs/_drop_down_list.html.haml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml b/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml index a2c38f0fc..52c08912d 100644 --- a/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml +++ b/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml @@ -1,8 +1,6 @@ - if champ.options? - if champ.render_as_radios? %fieldset.radios - %legend.mandatory-explanation - Sélectionnez une des valeurs - champ.enabled_non_empty_options.each do |option| %label = form.radio_button :value, option From 4bba1f0660d658feee8155a98bd82724df827cc9 Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Wed, 19 Aug 2020 15:27:57 +0000 Subject: [PATCH 2/8] views: add a "None" option to optional radio lists After clicking on a radio button option, it is impossible to revert to the "None of the values selected" state. However on non-mandatory fields, reverting to the no-selection value should be possible. To fix this, add an explicit "N/A" option. --- app/assets/stylesheets/new_design/forms.scss | 5 + .../editable_champs/_drop_down_list.html.haml | 5 + .../shared/dossiers/_edit.html.haml_spec.rb | 100 ++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 spec/views/shared/dossiers/_edit.html.haml_spec.rb diff --git a/app/assets/stylesheets/new_design/forms.scss b/app/assets/stylesheets/new_design/forms.scss index 13f9ef273..2caef5dcd 100644 --- a/app/assets/stylesheets/new_design/forms.scss +++ b/app/assets/stylesheets/new_design/forms.scss @@ -147,6 +147,11 @@ margin-left: 0; } + &.blank-radio { + color: $dark-grey; + font-style: italic; + } + input[type=radio] { margin-bottom: 0; } diff --git a/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml b/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml index 52c08912d..153217cff 100644 --- a/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml +++ b/app/views/shared/dossiers/editable_champs/_drop_down_list.html.haml @@ -5,6 +5,11 @@ %label = form.radio_button :value, option = option + - if !champ.mandatory? + %label.blank-radio + = form.radio_button :value, '' + Non renseigné + - else = form.select :value, champ.options, diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb new file mode 100644 index 000000000..de765d3bb --- /dev/null +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -0,0 +1,100 @@ +describe 'shared/dossiers/edit.html.haml', type: :view do + before do + allow(controller).to receive(:current_user).and_return(dossier.user) + end + + subject { render 'shared/dossiers/edit.html.haml', dossier: dossier, apercu: false } + + context 'when there are some champs' do + let(:dossier) { create(:dossier) } + let(:champ_checkbox) { create(:champ_checkbox, dossier: dossier, value: 'on') } + let(:champ_header_section) { create(:champ_header_section, dossier: dossier, value: 'Section') } + let(:champ_explication) { create(:champ_explication, dossier: dossier, value: 'mazette') } + let(:champ_dossier_link) { create(:champ_dossier_link, dossier: dossier, value: dossier.id) } + let(:champ_textarea) { create(:champ_textarea, dossier: dossier, value: 'Some long text in a textarea.') } + let(:champs) { [champ_checkbox, champ_header_section, champ_explication, champ_dossier_link, champ_textarea] } + + before { dossier.champs << champs } + + it 'renders labels and editable values of champs' do + expect(subject).to have_field(champ_checkbox.libelle, checked: true) + expect(subject).to have_css(".header-section", text: champ_header_section.libelle) + expect(subject).to have_text(champ_explication.libelle) + expect(subject).to have_field(champ_dossier_link.libelle, with: champ_dossier_link.value) + expect(subject).to have_field(champ_textarea.libelle, with: champ_textarea.value) + end + end + + context 'with a single-value list' do + let(:dossier) { create(:dossier) } + let(:type_de_champ) { create(:type_de_champ_drop_down_list, mandatory: mandatory, procedure: dossier.procedure) } + let(:champ) { create(:champ_drop_down_list, dossier: dossier, type_de_champ: type_de_champ) } + let(:options) { type_de_champ.drop_down_list_options } + let(:enabled_options) { type_de_champ.drop_down_list_enabled_non_empty_options } + let(:mandatory) { true } + + before { dossier.champs << champ } + + context 'when the list is short' do + it 'renders the list as radio buttons' do + expect(subject).to have_selector('input[type=radio]', count: enabled_options.count) + end + + context 'when the champ is optional' do + let(:mandatory) { false } + + it 'allows unselecting a previously selected value' do + expect(subject).to have_selector('input[type=radio]', count: enabled_options.count + 1) + expect(subject).to have_unchecked_field('Non renseigné', count: 1) + end + end + end + + context 'when the list is long' do + let(:type_de_champ) { create(:type_de_champ_drop_down_list, :long, procedure: dossier.procedure) } + + it 'renders the list as a dropdown' do + expect(subject).to have_select(type_de_champ.libelle, options: options) + end + end + end + + context 'with a multiple-values list' do + let(:dossier) { create(:dossier) } + let(:type_de_champ) { create(:type_de_champ_multiple_drop_down_list, procedure: dossier.procedure) } + let(:champ) { create(:champ_multiple_drop_down_list, dossier: dossier, type_de_champ: type_de_champ) } + let(:options) { type_de_champ.drop_down_list_options } + let(:enabled_options) { type_de_champ.drop_down_list_enabled_non_empty_options } + + before { dossier.champs << champ } + + context 'when the list is short' do + it 'renders the list as checkboxes' do + expect(subject).to have_selector('input[type=checkbox]', count: enabled_options.count) + end + end + + context 'when the list is long' do + let(:type_de_champ) { create(:type_de_champ_multiple_drop_down_list, :long, procedure: dossier.procedure) } + + it 'renders the list as a multiple-selection dropdown' do + expect(subject).to have_selector('select.select2') + end + end + end + + context 'with a routed procedure' do + let(:procedure) do + create(:procedure, + :routee, + routing_criteria_name: 'departement') + end + let(:dossier) { create(:dossier, procedure: procedure) } + let(:champs) { [] } + + it 'renders the routing criteria name and its value' do + expect(subject).to have_field(procedure.routing_criteria_name) + expect(subject).to include(dossier.groupe_instructeur.label) + end + end +end From 3c91cfc83c361f2ebb3af26d0ca6bfbee259fe1a Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Tue, 25 Aug 2020 09:28:43 +0000 Subject: [PATCH 3/8] jobs: add health-related keywords to dubious procedures scanner We are not certified for hosting health-related data yet. --- app/jobs/find_dubious_procedures_job.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/jobs/find_dubious_procedures_job.rb b/app/jobs/find_dubious_procedures_job.rb index b3a06a63e..8f399f672 100644 --- a/app/jobs/find_dubious_procedures_job.rb +++ b/app/jobs/find_dubious_procedures_job.rb @@ -4,8 +4,9 @@ class FindDubiousProceduresJob < CronJob FORBIDDEN_KEYWORDS = [ 'NIR', 'NIRPP', 'race', 'religion', 'carte bancaire', 'carte bleue', 'sécurité sociale', - 'agdref', 'handicap', 'syndicat', 'syndical', - 'parti politique', 'opinion politique', 'bord politique', 'courant politique' + 'agdref', 'syndicat', 'syndical', + 'parti politique', 'opinion politique', 'bord politique', 'courant politique', + 'médical', 'handicap', 'maladie', 'allergie', 'hospitalisé', 'RQTH', 'vaccin' ] def perform(*args) From e157a289e10df89e3c71559c494cf3bbc8840d2d Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Thu, 20 Aug 2020 11:05:14 +0000 Subject: [PATCH 4/8] autosave: factorize some javascript code --- .../new_design/dossiers/auto-save.js | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/app/javascript/new_design/dossiers/auto-save.js b/app/javascript/new_design/dossiers/auto-save.js index c96a97e6c..1d33b323f 100644 --- a/app/javascript/new_design/dossiers/auto-save.js +++ b/app/javascript/new_design/dossiers/auto-save.js @@ -16,26 +16,33 @@ const AUTOSAVE_STATUS_VISIBLE_DURATION = gon.autosave.status_visible_duration; // Create a controller responsible for queuing autosave operations. const autoSaveController = new AutoSaveController(); +function enqueueAutosaveRequest() { + const form = document.querySelector(FORM_SELECTOR); + autoSaveController.enqueueAutosaveRequest(form); +} + +// // Whenever a 'change' event is triggered on one of the form inputs, try to autosave. +// -const formSelector = 'form#dossier-edit-form.autosave-enabled'; -const formInputsSelector = `${formSelector} input:not([type=file]), ${formSelector} select, ${formSelector} textarea`; +const FORM_SELECTOR = 'form#dossier-edit-form.autosave-enabled'; +const INPUTS_SELECTOR = `${FORM_SELECTOR} input:not([type=file]), ${FORM_SELECTOR} select, ${FORM_SELECTOR} textarea`; +const RETRY_BUTTON_SELECTOR = '.autosave-retry'; +// When an input changes, batches changes for N seconds, then auto-save the form delegate( 'change', - formInputsSelector, - debounce(() => { - const form = document.querySelector(formSelector); - autoSaveController.enqueueAutosaveRequest(form); - }, AUTOSAVE_DEBOUNCE_DELAY) + INPUTS_SELECTOR, + debounce(enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY) ); -delegate('click', '.autosave-retry', () => { - const form = document.querySelector(formSelector); - autoSaveController.enqueueAutosaveRequest(form); -}); +// When the "Retry" button is clicked, auto-save the form immediately +delegate('click', RETRY_BUTTON_SELECTOR, enqueueAutosaveRequest); + +// // Display some UI during the autosave +// addEventListener('autosave:enqueue', () => { disable(document.querySelector('button.autosave-retry')); From 96037069ff434f6edcc855db9ebf796bdb28b410 Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Thu, 20 Aug 2020 15:56:41 +0200 Subject: [PATCH 5/8] autosave: remove the repetition row after deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, when autosaving a draft, removing a repetition row would send `_destroy` inputs to the controller – but not remove the row from the DOM. This led to the `_destroy` inputs being sent again on the next autosave request, which made the controller raise (because the row fields were already deleted before). To fix this, we let the controller response remove the deleted row(s) from the DOM. Doing it using a controller response avoids the need to keep track of operations on the Javascript side: the controller can easily know which row was just deleted, and emit the relevant changes for the DOM. This keeps the autosave requests robust: even if a request is skipped (e.g. because of a network interruption), the next request will still contain the relevant informations to succeed, and not let the form in an unstable state. Fix #5470 --- app/controllers/users/dossiers_controller.rb | 2 +- .../new_design/champs/repetition.js | 5 +++- .../dossiers/auto-save-controller.js | 25 +++++++++---------- app/views/champs/repetition/_show.html.haml | 6 ++++- .../editable_champs/_repetition.html.haml | 6 ++++- app/views/users/dossiers/brouillon.js.erb | 3 +++ 6 files changed, 30 insertions(+), 17 deletions(-) create mode 100644 app/views/users/dossiers/brouillon.js.erb diff --git a/app/controllers/users/dossiers_controller.rb b/app/controllers/users/dossiers_controller.rb index 4d65e3e05..47536f28c 100644 --- a/app/controllers/users/dossiers_controller.rb +++ b/app/controllers/users/dossiers_controller.rb @@ -163,7 +163,7 @@ module Users respond_to do |format| format.html { render :brouillon } - format.json { render json: {}, status: :ok } + format.js { render :brouillon } end end diff --git a/app/javascript/new_design/champs/repetition.js b/app/javascript/new_design/champs/repetition.js index 061cd519e..1fd0a1840 100644 --- a/app/javascript/new_design/champs/repetition.js +++ b/app/javascript/new_design/champs/repetition.js @@ -1,8 +1,9 @@ import { delegate } from '@utils'; +const CHAMP_SELECTOR = '.editable-champ'; const BUTTON_SELECTOR = '.button.remove-row'; const DESTROY_INPUT_SELECTOR = 'input[type=hidden][name*=_destroy]'; -const CHAMP_SELECTOR = '.editable-champ'; +const DOM_ID_INPUT_SELECTOR = 'input[type=hidden][name*=deleted_row_dom_ids]'; delegate('click', BUTTON_SELECTOR, (evt) => { evt.preventDefault(); @@ -13,6 +14,8 @@ delegate('click', BUTTON_SELECTOR, (evt) => { input.disabled = false; input.value = true; } + row.querySelector(DOM_ID_INPUT_SELECTOR).disabled = false; + for (let champ of row.querySelectorAll(CHAMP_SELECTOR)) { champ.remove(); } diff --git a/app/javascript/new_design/dossiers/auto-save-controller.js b/app/javascript/new_design/dossiers/auto-save-controller.js index e828332bd..7bf61c461 100644 --- a/app/javascript/new_design/dossiers/auto-save-controller.js +++ b/app/javascript/new_design/dossiers/auto-save-controller.js @@ -1,4 +1,4 @@ -import { fire, timeoutable } from '@utils'; +import { ajax, fire, timeoutable } from '@utils'; // Manages a queue of Autosave operations, // and sends `autosave:*` events to indicate the state of the requests. @@ -34,21 +34,20 @@ export default class AutoSaveController { return reject(formDataError); } - const fetchOptions = { - method: form.method, - body: formData, - credentials: 'same-origin', - headers: { Accept: 'application/json' } + const params = { + url: form.action, + type: form.method, + data: formData, + dataType: 'script' }; - return window.fetch(form.action, fetchOptions).then((response) => { - if (response.ok) { + return ajax(params) + .then(({ response }) => { resolve(response); - } else { - const message = `Network request failed (${response.status}, "${response.statusText}")`; - reject(new Error(message)); - } - }); + }) + .catch((error) => { + reject(error); + }); }); // Time out the request after a while, to avoid recent requests not starting diff --git a/app/views/champs/repetition/_show.html.haml b/app/views/champs/repetition/_show.html.haml index 045f82cbd..98ba3f239 100644 --- a/app/views/champs/repetition/_show.html.haml +++ b/app/views/champs/repetition/_show.html.haml @@ -1,7 +1,11 @@ - champs = champ.rows.last - if champs.present? - index = (champ.rows.size - 1) * champs.size - %div{ class: "row row-#{champs.first.row}" } + - row_dom_id = "row-#{SecureRandom.hex(4)}" + %div{ class: "row row-#{champs.first.row}", id: row_dom_id } + -# Tell the controller which DOM element should be removed once the row deletion is successful + = hidden_field_tag 'deleted_row_dom_ids[]', row_dom_id, disabled: true + - champs.each.with_index(index) do |champ, index| = fields_for "#{attribute}[#{index}]", champ do |form| = render partial: "shared/dossiers/editable_champs/editable_champ", locals: { champ: champ, form: form } diff --git a/app/views/shared/dossiers/editable_champs/_repetition.html.haml b/app/views/shared/dossiers/editable_champs/_repetition.html.haml index 8fd77e177..64ff2c084 100644 --- a/app/views/shared/dossiers/editable_champs/_repetition.html.haml +++ b/app/views/shared/dossiers/editable_champs/_repetition.html.haml @@ -1,6 +1,10 @@ %div{ class: "repetition-#{form.index}" } - champ.rows.each do |champs| - %div{ class: "row row-#{champs.first.row}" } + - row_dom_id = "row-#{SecureRandom.hex(4)}" + %div{ class: "row row-#{champs.first.row}", id: row_dom_id } + -# Tell the controller which DOM element should be removed once the row deletion is successful + = hidden_field_tag 'deleted_row_dom_ids[]', row_dom_id, disabled: true + - champs.each do |champ| = form.fields_for :champs, champ do |form| = render partial: 'shared/dossiers/editable_champs/editable_champ', locals: { champ: form.object, form: form } diff --git a/app/views/users/dossiers/brouillon.js.erb b/app/views/users/dossiers/brouillon.js.erb new file mode 100644 index 000000000..bb354a490 --- /dev/null +++ b/app/views/users/dossiers/brouillon.js.erb @@ -0,0 +1,3 @@ +<% (params['deleted_row_dom_ids'] || []).each do |deleted_row_dom_id| %> + <%= remove_element('#' + deleted_row_dom_id) %> +<% end %> From ecc4f01c20068dd0df83f7884963624517544b52 Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Tue, 25 Aug 2020 10:10:42 +0000 Subject: [PATCH 6/8] autosave: trigger an autosave after removing a row --- app/javascript/new_design/champs/repetition.js | 10 +++++++++- app/javascript/new_design/dossiers/auto-save.js | 15 +++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/javascript/new_design/champs/repetition.js b/app/javascript/new_design/champs/repetition.js index 1fd0a1840..3fb740666 100644 --- a/app/javascript/new_design/champs/repetition.js +++ b/app/javascript/new_design/champs/repetition.js @@ -1,4 +1,4 @@ -import { delegate } from '@utils'; +import { delegate, fire } from '@utils'; const CHAMP_SELECTOR = '.editable-champ'; const BUTTON_SELECTOR = '.button.remove-row'; @@ -22,4 +22,12 @@ delegate('click', BUTTON_SELECTOR, (evt) => { evt.target.remove(); row.classList.remove('row'); + + // We could debounce the autosave request, so that row removal would be batched + // with the next changes. + // However *adding* a new repetition row isn't debounced (changes are immediately + // effective server-side). + // So, to avoid ordering issues, enqueue an autosave request as soon as the row + // is removed. + fire(row, 'autosave:trigger'); }); diff --git a/app/javascript/new_design/dossiers/auto-save.js b/app/javascript/new_design/dossiers/auto-save.js index 1d33b323f..1b4b9185c 100644 --- a/app/javascript/new_design/dossiers/auto-save.js +++ b/app/javascript/new_design/dossiers/auto-save.js @@ -29,6 +29,17 @@ const FORM_SELECTOR = 'form#dossier-edit-form.autosave-enabled'; const INPUTS_SELECTOR = `${FORM_SELECTOR} input:not([type=file]), ${FORM_SELECTOR} select, ${FORM_SELECTOR} textarea`; const RETRY_BUTTON_SELECTOR = '.autosave-retry'; +// When an autosave is requested programatically, auto-save the form immediately +addEventListener('autosave:trigger', (event) => { + const form = event.target.closest('form'); + if (form && form.classList.contains('autosave-enabled')) { + enqueueAutosaveRequest(); + } +}); + +// When the "Retry" button is clicked, auto-save the form immediately +delegate('click', RETRY_BUTTON_SELECTOR, enqueueAutosaveRequest); + // When an input changes, batches changes for N seconds, then auto-save the form delegate( 'change', @@ -36,10 +47,6 @@ delegate( debounce(enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY) ); - -// When the "Retry" button is clicked, auto-save the form immediately -delegate('click', RETRY_BUTTON_SELECTOR, enqueueAutosaveRequest); - // // Display some UI during the autosave // From 3fdecf0924fa3b0e2b146862b43a153c663dfc5f Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Tue, 25 Aug 2020 12:47:13 +0200 Subject: [PATCH 7/8] helpers: remove element immediately when no timeout is specified This fixes an issue where clicking quickly on several "Remove row" buttons on a repetition field results in autosave errors. This is because the N+1 autosave request is correctly sent after the Nth response from the server, but _before_ the row element is actually removed from the DOM. So the N+1 request actually sends the fields for the deleted row, which makes the server raise an error. With this fix, the row gets properly removed when the server responds, and before the next request is started. --- app/helpers/application_helper.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 68000c339..cc6adef9e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -62,7 +62,11 @@ module ApplicationHelper script = "(function() {"; script << "var el = document.querySelector('#{selector}');" method = (inner ? "el.innerHTML = ''" : "el.parentNode.removeChild(el)") - script << "if (el) { setTimeout(function() { #{method}; }, #{timeout}); }"; + if timeout.present? && timeout > 0 + script << "if (el) { setTimeout(function() { #{method}; }, #{timeout}); }" + else + script << "if (el) { #{method} };" + end script << "})();" # rubocop:disable Rails/OutputSafety raw(script); From cb2be72f4599f14fa5c4de832ae5fc839fef2523 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2020 14:05:52 +0000 Subject: [PATCH 8/8] build(deps): bump highcharts from 8.1.0 to 8.1.1 Bumps [highcharts](https://github.com/highcharts/highcharts-dist) from 8.1.0 to 8.1.1. - [Release notes](https://github.com/highcharts/highcharts-dist/releases) - [Commits](https://github.com/highcharts/highcharts-dist/compare/v8.1.0...v8.1.1) Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e6ea7104c..892752834 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "debounce": "^1.2.0", "dom4": "^2.1.5", "email-butler": "^1.0.13", - "highcharts": "^8.1.0", + "highcharts": "^8.1.1", "intersection-observer": "^0.10.0", "jquery": "^3.5.1", "mapbox-gl": "^1.11.1", diff --git a/yarn.lock b/yarn.lock index 827891110..4c736e2f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4674,10 +4674,10 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== -highcharts@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-8.1.0.tgz#f93adaf8d53b0f83c74c9854f0ad10baec010d97" - integrity sha512-4KXq9t2/PU0cqKUtET7om9Kh5AyOinIn4vYi62oYVsb4ql5wyUYW06f9Si/ERG2Thoy/rcwNmR77upKjg8xhqQ== +highcharts@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-8.1.1.tgz#7dc011e260289ab64d775807df0d13b85ed88338" + integrity sha512-DSkI+fAqkqYDslOVLcEk8DX7W9itRIwzsdS0uVEOnVf0LF1hSKZtDINHP7ze/uBN9NdWQV9HydtiPTrkLx0lXg== highlight-words-core@1.2.2: version "1.2.2"