Merge pull request #8670 from mfo/US/markdown-like
amelioration(a11y): rend du texte saisi (par les admin/instructeur/expert) en html compatible avec les contraintes d'accessibilités
This commit is contained in:
commit
d0e05784e9
32 changed files with 206 additions and 133 deletions
1
Gemfile
1
Gemfile
|
@ -70,6 +70,7 @@ gem 'rack-attack'
|
|||
gem 'rails'
|
||||
gem 'rails-i18n' # Locales par défaut
|
||||
gem 'rake-progressbar', require: false
|
||||
gem 'redcarpet'
|
||||
gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/issues/325)
|
||||
gem 'rgeo-geojson'
|
||||
gem 'rqrcode'
|
||||
|
|
|
@ -573,6 +573,7 @@ GEM
|
|||
rb-fsevent (0.10.4)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.6.0)
|
||||
request_store (1.5.0)
|
||||
rack (>= 1.4)
|
||||
|
@ -909,6 +910,7 @@ DEPENDENCIES
|
|||
rails-erd
|
||||
rails-i18n
|
||||
rake-progressbar
|
||||
redcarpet
|
||||
rexml
|
||||
rgeo-geojson
|
||||
rqrcode
|
||||
|
|
|
@ -5,8 +5,6 @@ $procedure-context-breakpoint: $two-columns-breakpoint;
|
|||
$procedure-description-line-height: 22px;
|
||||
|
||||
.procedure-preview {
|
||||
font-size: 24px;
|
||||
|
||||
.paperless-logo {
|
||||
width: 100%;
|
||||
margin-bottom: 60px;
|
||||
|
@ -74,17 +72,6 @@ $procedure-description-line-height: 22px;
|
|||
border-bottom: 1px dotted $blue-france-500;
|
||||
}
|
||||
|
||||
.procedure-description {
|
||||
font-size: 16px;
|
||||
|
||||
p:not(:last-of-type) {
|
||||
// Space the paragraphs by exactly one line height,
|
||||
// so that the text always is truncated in the middle of a line,
|
||||
// regarless of the number of paragraphs.
|
||||
margin-bottom: 3 * $default-spacer;
|
||||
}
|
||||
}
|
||||
|
||||
.read-more-button {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -55,15 +55,6 @@ class Dossiers::MessageComponent < ApplicationComponent
|
|||
l(commentaire.created_at, format: is_current_year ? :message_date : :message_date_with_year)
|
||||
end
|
||||
|
||||
def commentaire_body
|
||||
if commentaire.discarded?
|
||||
t('.deleted_body')
|
||||
else
|
||||
body_formatted = commentaire.sent_by_system? ? commentaire.body : simple_format(commentaire.body)
|
||||
sanitize(body_formatted, commentaire.sent_by_system? ? { scrubber: Sanitizers::MailScrubber.new } : {})
|
||||
end
|
||||
end
|
||||
|
||||
def highlight?
|
||||
commentaire.created_at.present? && @messagerie_seen_at&.<(commentaire.created_at)
|
||||
end
|
||||
|
|
|
@ -8,7 +8,14 @@
|
|||
%span.fr-text--xs.fr-text-mention--grey.font-weight-normal= t('.guest')
|
||||
%span.date{ class: ["fr-text--xs", "fr-text-mention--grey", "font-weight-normal", highlight_if_unseen_class], data: scroll_to_target }
|
||||
= commentaire_date
|
||||
.rich-text= commentaire_body
|
||||
.rich-text
|
||||
- if commentaire.discarded?
|
||||
%p= t('.deleted_body')
|
||||
- elsif commentaire.sent_by_system?
|
||||
= sanitize(commentaire.body, scrubber: Sanitizers::MailScrubber.new)
|
||||
- else
|
||||
= render SimpleFormatComponent.new(commentaire.body, allow_a: false)
|
||||
|
||||
|
||||
.message-extras.flex.justify-start
|
||||
- if commentaire.soft_deletable?(connected_user)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# see: https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/mise-en-avant
|
||||
class Dsfr::CalloutComponent < ApplicationComponent
|
||||
renders_one :body
|
||||
renders_one :html_body
|
||||
renders_one :bottom
|
||||
|
||||
attr_reader :title, :theme, :icon, :extra_class_names
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
%div{ class: callout_class }
|
||||
- if title.present?
|
||||
%h3.fr-callout__title= title
|
||||
- if html_body?
|
||||
.fr-callout__text= html_body
|
||||
- if body?
|
||||
%p.fr-callout__text= body
|
||||
= bottom
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
class EditableChamp::ChampLabelComponent < ApplicationComponent
|
||||
include StringToHtmlHelper
|
||||
|
||||
def initialize(form:, champ:, seen_at: nil)
|
||||
@form, @champ, @seen_at = form, champ, seen_at
|
||||
end
|
||||
|
|
|
@ -7,4 +7,4 @@
|
|||
= render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at
|
||||
|
||||
- if @champ.description.present?
|
||||
.notice{ id: @champ.describedby_id }= string_to_html(@champ.description, allow_a: true)
|
||||
.notice{ id: @champ.describedby_id }= render SimpleFormatComponent.new(@champ.description, allow_a: true)
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
class EditableChamp::EditableChampComponent < ApplicationComponent
|
||||
include StringToHtmlHelper
|
||||
|
||||
def initialize(form:, champ:, seen_at: nil)
|
||||
@form, @champ, @seen_at = form, champ, seen_at
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
- if @champ.block?
|
||||
%h3.header-subsection= @champ.libelle
|
||||
- if @champ.description.present?
|
||||
%p.notice= string_to_html(@champ.description, false, allow_a: true)
|
||||
.notice= render SimpleFormatComponent.new(@champ.description, allow_a: true)
|
||||
|
||||
- elsif has_label?(@champ)
|
||||
= render EditableChamp::ChampLabelComponent.new form: @form, champ: @champ, seen_at: @seen_at
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::ExplicationComponent < EditableChamp::EditableChampBaseComponent
|
||||
include StringToHtmlHelper
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
= render Dsfr::CalloutComponent.new(title: @champ.libelle, extra_class_names: ['fr-mb-2w', 'fr-callout--blue-cumulus']) do |c|
|
||||
- c.with_body do
|
||||
- c.with_html_body do
|
||||
|
||||
= string_to_html(@champ.description, allow_a: true)
|
||||
= render SimpleFormatComponent.new(@champ.description, allow_a: true)
|
||||
|
||||
- if @champ.collapsible_explanation_enabled? && @champ.collapsible_explanation_text.present?
|
||||
%div
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
class EditableChamp::LinkedDropDownListComponent < EditableChamp::EditableChampBaseComponent
|
||||
include StringToHtmlHelper
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
= @form.label :secondary_value, for: "#{@champ.input_id}-secondary" do
|
||||
- sanitize((@champ.drop_down_secondary_libelle.presence || "Valeur secondaire dépendant de la première") + (@champ.type_de_champ.mandatory? ? tag.span(' *', class: 'mandatory') : ''))
|
||||
- if @champ.drop_down_secondary_description.present?
|
||||
.notice{ id: "#{@champ.describedby_id}-secondary" }= string_to_html(@champ.drop_down_secondary_description, allow_a: true)
|
||||
.notice{ id: "#{@champ.describedby_id}-secondary" }= render SimpleFormatComponent.new(@champ.drop_down_secondary_description, allow_a: true)
|
||||
= @form.select :secondary_value,
|
||||
@champ.secondary_options[@champ.primary_value],
|
||||
{},
|
||||
|
|
51
app/components/simple_format_component.rb
Normal file
51
app/components/simple_format_component.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
class SimpleFormatComponent < ApplicationComponent
|
||||
# see: https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use
|
||||
REDCARPET_EXTENSIONS = {
|
||||
no_intra_emphasis: false,
|
||||
tables: false,
|
||||
fenced_code_blocks: false,
|
||||
autolink: false,
|
||||
disable_indented_code_blocks: false,
|
||||
strikethrough: false,
|
||||
lax_spacing: false,
|
||||
space_after_headers: false,
|
||||
superscript: false,
|
||||
underline: false,
|
||||
highlight: false,
|
||||
quote: false,
|
||||
footnotes: false
|
||||
}
|
||||
|
||||
# see: https://github.com/vmg/redcarpet#darling-i-packed-you-a-couple-renderers-for-lunch
|
||||
REDCARPET_RENDERER_OPTS = {
|
||||
no_images: true
|
||||
}
|
||||
|
||||
def initialize(text, allow_a: true, class_names_map: {})
|
||||
@text = (text || "").gsub(/\R/, "\n\n") # force double \n otherwise a single one won't split paragraph
|
||||
.split("\n\n") #
|
||||
.map(&:lstrip) # this block prevent redcarpet to consider " text" as block code by lstriping
|
||||
.join("\n\n") #
|
||||
@allow_a = allow_a
|
||||
@renderer = Redcarpet::Markdown.new(
|
||||
Redcarpet::BareRenderer.new(link_attributes: external_link_attributes, class_names_map: class_names_map),
|
||||
REDCARPET_EXTENSIONS.merge(autolink: @allow_a)
|
||||
)
|
||||
end
|
||||
|
||||
def external_link_attributes
|
||||
{ target: '_blank', rel: 'noopener noreferrer' }
|
||||
end
|
||||
|
||||
def tags
|
||||
if @allow_a
|
||||
Rails.configuration.action_view.sanitized_allowed_tags + ['a']
|
||||
else
|
||||
Rails.configuration.action_view.sanitized_allowed_tags
|
||||
end
|
||||
end
|
||||
|
||||
def attributes
|
||||
['target', 'rel', 'href', 'class']
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
= sanitize(@renderer.render(@text), tags:, attributes:)
|
|
@ -1,15 +0,0 @@
|
|||
module StringToHtmlHelper
|
||||
def string_to_html(str, wrapper_tag = 'p', allow_a: false)
|
||||
return nil if str.blank?
|
||||
html_formatted = simple_format(str, {}, { wrapper_tag: wrapper_tag })
|
||||
with_links = Anchored::Linker.auto_link(html_formatted, target: '_blank', rel: 'noopener')
|
||||
|
||||
tags = if allow_a
|
||||
Rails.configuration.action_view.sanitized_allowed_tags + ['a']
|
||||
else
|
||||
Rails.configuration.action_view.sanitized_allowed_tags
|
||||
end
|
||||
|
||||
sanitize(with_links, tags:, attributes: ['target', 'rel', 'href'])
|
||||
end
|
||||
end
|
21
app/lib/redcarpet/bare_renderer.rb
Normal file
21
app/lib/redcarpet/bare_renderer.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
module Redcarpet
|
||||
class BareRenderer < Redcarpet::Render::HTML
|
||||
include ActionView::Helpers::TagHelper
|
||||
|
||||
# won't use rubocop tag method because it is missing output buffer
|
||||
# rubocop:disable Rails/ContentTag
|
||||
def list(content, list_type)
|
||||
tag = list_type == :ordered ? :ol : :ul
|
||||
content_tag(tag, content, { class: @options[:class_names_map].fetch(:list) {} }, false)
|
||||
end
|
||||
|
||||
def list_item(content, list_type)
|
||||
content_tag(:li, content.strip.gsub(/<\/?p>/, ''), {}, false)
|
||||
end
|
||||
|
||||
def paragraph(text)
|
||||
content_tag(:p, text, { class: @options[:class_names_map].fetch(:paragraph) {} }, false)
|
||||
end
|
||||
# rubocop:enable Rails/ContentTag
|
||||
end
|
||||
end
|
|
@ -35,4 +35,4 @@
|
|||
- if avis.piece_justificative_file.attached?
|
||||
= render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment)
|
||||
.answer-body
|
||||
= simple_format(avis.answer)
|
||||
= render SimpleFormatComponent.new(avis.answer, allow_a: false)
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
- if @dossier.etablissement&.as_degraded_mode?
|
||||
.container
|
||||
= render Dsfr::CalloutComponent.new(title: "Données de l’entreprise non vérifiées", theme: :warning, icon: "fr-icon-feedback-fill") do |c|
|
||||
- c.with_body do
|
||||
Les services de l’INSEE sont indisponibles, nous ne pouvons pas
|
||||
vérifier les informations liées à l’établissement de ce dossier.
|
||||
%strong Il n’est pas possible d’accepter ou de refuser un dossier sans cette étape.
|
||||
%br
|
||||
%br
|
||||
- c.with_html_body do
|
||||
%p
|
||||
Les services de l’INSEE sont indisponibles, nous ne pouvons pas vérifier les informations liées à l’établissement de ce dossier.
|
||||
%strong
|
||||
Il n’est pas possible d’accepter ou de refuser un dossier sans cette étape.
|
||||
%p
|
||||
Les informations sur l'entreprise arriveront d’ici quelques heures.
|
||||
|
||||
= render partial: "shared/dossiers/demande", locals: { dossier: @dossier, demande_seen_at: @demande_seen_at, profile: 'instructeur' }
|
||||
|
|
|
@ -51,4 +51,4 @@
|
|||
- if avis.piece_justificative_file.attached?
|
||||
= render Attachment::ShowComponent.new(attachment: avis.piece_justificative_file.attachment)
|
||||
.answer-body
|
||||
= simple_format(avis.answer)
|
||||
= render SimpleFormatComponent.new(avis.answer, allow_a: false)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
= render Dsfr::CalloutComponent.new(title: t("views.prefill_descriptions.edit.json_description_title"), theme: :success, icon: "fr-icon-layout-grid-fill") do |c|
|
||||
- c.with_body do
|
||||
= t("views.prefill_descriptions.edit.json_description_info")
|
||||
- c.with_html_body do
|
||||
%p= t("views.prefill_descriptions.edit.json_description_info")
|
||||
%pre
|
||||
%code.code-block
|
||||
= prefill_json_description_url(procedure.path)
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
|
||||
- if prefill_description.prefilled_champs.any?
|
||||
= render Dsfr::CalloutComponent.new(title: t("views.prefill_descriptions.edit.prefill_link_title"), theme: theme, icon: icon) do |c|
|
||||
- c.with_body do
|
||||
= body
|
||||
- c.with_html_body do
|
||||
%p= body
|
||||
%pre
|
||||
%code.code-block
|
||||
= prefill_description.prefill_link
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
- if prefill_description.prefilled_champs.any?
|
||||
|
||||
= render Dsfr::CalloutComponent.new(title: t("views.prefill_descriptions.edit.prefill_query_title"), theme: :success, icon: "fr-icon-code-box-fill") do |c|
|
||||
- c.with_body do
|
||||
= t("views.prefill_descriptions.edit.prefill_query_info")
|
||||
- c.with_html_body do
|
||||
%p= t("views.prefill_descriptions.edit.prefill_query_info")
|
||||
%pre
|
||||
%code.code-block
|
||||
= prefill_description.prefill_query
|
||||
|
|
|
@ -23,5 +23,5 @@
|
|||
|
||||
.procedure-description
|
||||
.procedure-description-body.read-more-enabled.read-more-collapsed{ tabindex: "0", role: "region", "aria-label": t('views.users.dossiers.identite.description') }
|
||||
= h string_to_html(procedure.description, allow_a: true)
|
||||
= h render SimpleFormatComponent.new(procedure.description, allow_a: true)
|
||||
= button_tag "Afficher la description complète", class: 'button read-more-button'
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
Pour poser une question sur ce dossier, contactez :
|
||||
%p
|
||||
= service.nom
|
||||
%br
|
||||
%p
|
||||
= service.organisme
|
||||
%br
|
||||
- horaires = "Horaires : #{formatted_horaires(service.horaires)}"
|
||||
= simple_format(horaires)
|
||||
|
||||
- if service.horaires.present?
|
||||
= render SimpleFormatComponent.new("Horaires : #{formatted_horaires(service.horaires)}")
|
||||
%p
|
||||
= mail_to service.email,
|
||||
service.email,
|
||||
|
|
|
@ -12,11 +12,14 @@
|
|||
- elsif service.present?
|
||||
%li
|
||||
= link_to I18n.t('users.procedure_footer.contact.email.link', service_email: service.email), "mailto:#{service.email}", class: 'fr-footer__top-link'
|
||||
- if service.telephone.present? || service.horaires.present?
|
||||
%li
|
||||
- horaires = "#{I18n.t('users.procedure_footer.contact.schedule.prefix')}#{formatted_horaires(service.horaires)}"
|
||||
- if service.telephone.present?
|
||||
= link_to service.telephone_url, class: 'fr-footer__top-link' do
|
||||
%p
|
||||
= I18n.t('users.procedure_footer.contact.phone.link', service_telephone: service.telephone)
|
||||
- if service.horaires.present?
|
||||
%p
|
||||
= horaires
|
||||
%li
|
||||
|
@ -65,12 +68,12 @@
|
|||
|
||||
- if service.present?
|
||||
.fr-footer__content
|
||||
|
||||
%p.fr-footer__content-desc
|
||||
= I18n.t('users.procedure_footer.managed_by.header')
|
||||
%span{ lang: :fr }
|
||||
= "#{service.nom},"
|
||||
= "#{service.organisme},"
|
||||
= string_to_html(service.adresse, 'span')
|
||||
%span{ lang: :fr }= "#{service.nom}, #{service.organisme},"
|
||||
%div{ lang: :fr }
|
||||
= render SimpleFormatComponent.new(service.adresse, class_names_map: {paragraph: 'fr-footer__content-desc'})
|
||||
= render partial: "shared/footer_content_list"
|
||||
|
||||
.fr-footer__bottom
|
||||
|
|
73
spec/components/simple_format_component_spec.rb
Normal file
73
spec/components/simple_format_component_spec.rb
Normal file
|
@ -0,0 +1,73 @@
|
|||
describe SimpleFormatComponent, type: :component do
|
||||
let(:allow_a) { false }
|
||||
before { render_inline(described_class.new(text, allow_a: allow_a)) }
|
||||
|
||||
context 'one line' do
|
||||
let(:text) do
|
||||
"1er paragraphe"
|
||||
end
|
||||
it { expect(page).to have_selector("p", count: 1, text: text) }
|
||||
end
|
||||
|
||||
context 'one with leading spaces' do
|
||||
let(:text) do
|
||||
<<-TEXT
|
||||
1er paragraphe
|
||||
TEXT
|
||||
end
|
||||
it { expect(page).to have_selector("p", count: 1, text: text.strip) }
|
||||
end
|
||||
|
||||
context 'two lines' do
|
||||
let(:text) do
|
||||
<<~TEXT
|
||||
1er paragraphe
|
||||
2eme paragraphe
|
||||
TEXT
|
||||
end
|
||||
|
||||
it { expect(page).to have_selector("p", count: 2) }
|
||||
it { text.split("\n").map(&:strip).map { expect(page).to have_text(_1) } }
|
||||
end
|
||||
|
||||
context 'unordered list items' do
|
||||
let(:text) do
|
||||
<<~TEXT
|
||||
- 1er paragraphe
|
||||
- paragraphe
|
||||
TEXT
|
||||
end
|
||||
|
||||
it { expect(page).to have_selector("ul", count: 1) }
|
||||
it { expect(page).to have_selector("li", count: 2) }
|
||||
end
|
||||
|
||||
context 'ordered list items' do
|
||||
let(:text) do
|
||||
<<~TEXT
|
||||
1. 1er paragraphe
|
||||
2. paragraphe
|
||||
TEXT
|
||||
end
|
||||
|
||||
it { expect(page).to have_selector("ol", count: 1) }
|
||||
it { expect(page).to have_selector("li", count: 2) }
|
||||
end
|
||||
|
||||
context 'auto-link' do
|
||||
let(:text) do
|
||||
<<~TEXT
|
||||
bonjour https://www.demarches-simplifiees.fr
|
||||
TEXT
|
||||
end
|
||||
|
||||
context 'enabled' do
|
||||
let(:allow_a) { true }
|
||||
it { expect(page).to have_selector("a") }
|
||||
end
|
||||
|
||||
context 'disabled' do
|
||||
it { expect(page).not_to have_selector("a") }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,49 +0,0 @@
|
|||
RSpec.describe StringToHtmlHelper, type: :helper do
|
||||
describe "#string_to_html" do
|
||||
let(:allow_a) { false }
|
||||
subject { string_to_html(description, allow_a:) }
|
||||
|
||||
context "with some simple texte" do
|
||||
let(:description) { "1er ligne \n 2ieme ligne" }
|
||||
|
||||
it { is_expected.to eq("<p>1er ligne \n<br> 2ieme ligne</p>") }
|
||||
end
|
||||
|
||||
context "with a link" do
|
||||
context "using an authorized scheme" do
|
||||
let(:description) { "Cliquez sur https://d-s.fr pour continuer." }
|
||||
|
||||
context 'with a tag authorized' do
|
||||
let(:allow_a) { true }
|
||||
it { is_expected.to eq("<p>Cliquez sur <a href=\"https://d-s.fr\" target=\"_blank\" rel=\"noopener\">https://d-s.fr</a> pour continuer.</p>") }
|
||||
end
|
||||
|
||||
context 'without a tag' do
|
||||
it { is_expected.to eq("<p>Cliquez sur https://d-s.fr pour continuer.</p>") }
|
||||
end
|
||||
end
|
||||
|
||||
context "using a non-authorized scheme" do
|
||||
let(:description) { "Cliquez sur file://etc/password pour continuer." }
|
||||
it { is_expected.to eq("<p>Cliquez sur file://etc/password pour continuer.</p>") }
|
||||
end
|
||||
|
||||
context "not actually an URL" do
|
||||
let(:description) { "Pour info: il ne devrait y avoir aucun lien." }
|
||||
it { is_expected.to eq("<p>Pour info: il ne devrait y avoir aucun lien.</p>") }
|
||||
end
|
||||
end
|
||||
|
||||
context "with empty decription" do
|
||||
let(:description) { nil }
|
||||
|
||||
it { is_expected.to eq nil }
|
||||
end
|
||||
|
||||
context "with a bad script" do
|
||||
let(:description) { '<script>bad</script>' }
|
||||
|
||||
it { is_expected.to eq('<p>bad</p>') }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -63,7 +63,7 @@ describe 'Inviting an expert:', js: true do
|
|||
click_on 'Avis externes'
|
||||
|
||||
expect(page).to have_content(answered_avis.expert.email)
|
||||
answered_avis.answer.split("\n").each do |answer_line|
|
||||
answered_avis.answer.split("\n").map { |line| line.gsub("- ", "") }.map do |answer_line|
|
||||
expect(page).to have_content(answer_line)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -26,7 +26,9 @@ describe 'instructeurs/shared/avis/_list.html.haml', type: :view do
|
|||
let(:avis) { [create(:avis, :with_answer, claimant: instructeur, experts_procedure: experts_procedure)] }
|
||||
|
||||
it 'renders the answer formatted with newlines' do
|
||||
expect(subject).to include(simple_format(avis.first.answer))
|
||||
expect(subject).to have_selector(".answer-body p", text: avis.first.answer.split("\n").first)
|
||||
expect(subject).to have_selector(".answer-body ul", count: 1) # avis.answer has two list item
|
||||
expect(subject).to have_selector(".answer-body ul li", count: 2)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in a new issue