Merge pull request #6273 from betagouv/main

This commit is contained in:
Pierre de La Morinerie 2021-06-15 14:29:26 +02:00 committed by GitHub
commit bcb787c5ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 196 additions and 132 deletions

View file

@ -9,6 +9,7 @@ inherit_gem:
- config/rails.yml
AllCops:
TargetRubyVersion: 2.7
Exclude:
- "db/schema.rb"
- "db/migrate/20190730153555_recreate_structure.rb"

View file

@ -2,6 +2,7 @@
@import "constants";
.card-admin {
color: $black;
padding-top: 10px;
padding-bottom: 10px;
width: 236px;
@ -35,6 +36,18 @@
.card-admin-action {
margin-top: auto;
}
.button {
margin: auto auto 0 auto;
}
&:hover {
color: $blue;
.button {
color: $blue;
}
}
}
@media only screen and (max-width: 600px) {

View file

@ -5,7 +5,7 @@
font-size: 14px;
th {
vertical-align: middle;
vertical-align: top;
padding: (2 * $default-spacer) $default-spacer;
}

View file

@ -289,7 +289,7 @@ class StatsController < ApplicationController
dossiers_grouped_by_groupe_instructeur = dossier_plucks.group_by { |(groupe_instructeur_id, *_)| groupe_instructeur_id }
# Compute the mean time for this procedure
procedure_processing_times = dossiers_grouped_by_groupe_instructeur.map do |groupe_instructeur_id, procedure_dossiers|
procedure_processing_times = dossiers_grouped_by_groupe_instructeur.filter_map do |groupe_instructeur_id, procedure_dossiers|
procedure_fields_count = groupe_instructeur_id_type_de_champs_count[groupe_instructeur_id]
if (procedure_fields_count == 0 || procedure_fields_count.nil?)
@ -302,7 +302,6 @@ class StatsController < ApplicationController
# We normalize the data for 24 fields
procedure_mean * (MEAN_NUMBER_OF_CHAMPS_IN_A_FORM / procedure_fields_count)
end
.compact
# Compute the average mean time for all the procedures of this month
month_average = mean(procedure_processing_times)

View file

@ -30,7 +30,8 @@ module ProcedureHelper
typeDeChampsTypes: TypeDeChamp.type_de_champ_types_for(procedure, current_user),
typeDeChamps: (procedure.draft_revision ? procedure.draft_revision : procedure).types_de_champ.as_json_for_editor,
baseUrl: admin_procedure_types_de_champ_path(procedure),
directUploadUrl: rails_direct_uploads_url
directUploadUrl: rails_direct_uploads_url,
continuerUrl: admin_procedure_path(procedure)
}
end
@ -40,7 +41,8 @@ module ProcedureHelper
typeDeChampsTypes: TypeDeChamp.type_de_champ_types_for(procedure, current_user),
typeDeChamps: (procedure.draft_revision ? procedure.draft_revision : procedure).types_de_champ_private.as_json_for_editor,
baseUrl: admin_procedure_types_de_champ_path(procedure),
directUploadUrl: rails_direct_uploads_url
directUploadUrl: rails_direct_uploads_url,
continuerUrl: admin_procedure_path(procedure)
}
end

View file

@ -21,6 +21,8 @@ import { fire } from '@utils';
import { XIcon } from '@heroicons/react/outline';
import isHotkey from 'is-hotkey';
import { useDeferredSubmit } from './shared/hooks';
const Context = createContext();
function ComboMultipleDropdownList({
@ -78,39 +80,12 @@ function ComboMultipleDropdownList({
() => document.querySelector(`input[data-uuid="${hiddenFieldId}"]`),
[hiddenFieldId]
);
const awaitFormSubmit = useDeferredSubmit(hiddenField);
const handleChange = (event) => {
setTerm(event.target.value);
};
const onKeyDown = (event) => {
if (
isHotkey('enter', event) ||
isHotkey(' ', event) ||
isHotkey(',', event) ||
isHotkey(';', event)
) {
if (
term &&
[...extraOptions, ...options].map(([label]) => label).includes(term)
) {
event.preventDefault();
return onSelect(term);
}
}
};
const onBlur = (event) => {
if (
acceptNewValues &&
term &&
[...extraOptions, ...options].map(([label]) => label).includes(term)
) {
event.preventDefault();
return onSelect(term);
}
};
const saveSelection = (fn) => {
setSelections((selections) => {
selections = fn(selections);
@ -138,6 +113,7 @@ function ComboMultipleDropdownList({
saveSelection((selections) => [...selections, selectedValue]);
}
setTerm('');
awaitFormSubmit.done();
};
const onRemove = (label) => {
@ -153,6 +129,34 @@ function ComboMultipleDropdownList({
inputRef.current.focus();
};
const onKeyDown = (event) => {
if (
isHotkey('enter', event) ||
isHotkey(' ', event) ||
isHotkey(',', event) ||
isHotkey(';', event)
) {
if (
term &&
[...extraOptions, ...options].map(([label]) => label).includes(term)
) {
event.preventDefault();
onSelect(term);
}
}
};
const onBlur = () => {
if (
term &&
[...extraOptions, ...options].map(([label]) => label).includes(term)
) {
awaitFormSubmit(() => {
onSelect(term);
});
}
};
return (
<Combobox openOnFocus={true} onSelect={onSelect} aria-label={label}>
<ComboboxTokenLabel onRemove={onRemove}>

View file

@ -12,6 +12,8 @@ import {
import '@reach/combobox/styles.css';
import { fire } from '@utils';
import { useDeferredSubmit } from './shared/hooks';
function defaultTransformResults(_, results) {
return results;
}
@ -45,17 +47,23 @@ function ComboSearch({
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const [value, setValue] = useState(initialValue);
const resultsMap = useRef({});
const setExternalValue = useCallback((value) => {
if (hiddenValueField) {
hiddenValueField.setAttribute('value', value);
fire(hiddenValueField, 'autosave:trigger');
}
});
const setExternalId = useCallback((key) => {
if (hiddenIdField) {
hiddenIdField.setAttribute('value', key);
}
});
const setExternalValue = useCallback(
(value) => {
if (hiddenValueField) {
hiddenValueField.setAttribute('value', value);
fire(hiddenValueField, 'autosave:trigger');
}
},
[hiddenValueField]
);
const setExternalId = useCallback(
(key) => {
if (hiddenIdField) {
hiddenIdField.setAttribute('value', key);
}
},
[hiddenIdField]
);
const setExternalValueAndId = useCallback((value) => {
const [key, result] = resultsMap.current[value];
setExternalId(key);
@ -63,7 +71,8 @@ function ComboSearch({
if (onChange) {
onChange(value, result);
}
});
}, []);
const awaitFormSubmit = useDeferredSubmit(hiddenValueField);
const handleOnChange = useCallback(
({ target: { value } }) => {
@ -82,7 +91,9 @@ function ComboSearch({
const handleOnSelect = useCallback((value) => {
setExternalValueAndId(value);
setValue(value);
});
setSearchTerm('');
awaitFormSubmit.done();
}, []);
const { isSuccess, data } = useQuery([scope, debouncedSearchTerm], {
enabled: !!debouncedSearchTerm,
@ -91,12 +102,22 @@ function ComboSearch({
});
const results = isSuccess ? transformResults(debouncedSearchTerm, data) : [];
const onBlur = useCallback(() => {
if (!allowInputValues && isSuccess && results[0]) {
const [, value] = transformResult(results[0]);
awaitFormSubmit(() => {
handleOnSelect(value);
});
}
}, [data]);
return (
<Combobox aria-label={label} onSelect={handleOnSelect}>
<ComboboxInput
className={className}
placeholder={placeholder}
onChange={handleOnChange}
onBlur={onBlur}
value={value}
required={required}
/>

View file

@ -60,12 +60,9 @@ function TypeDeChamps({ state: rootState, typeDeChamps }) {
&nbsp;&nbsp;
{addChampLabel(state.isAnnotation)}
</button>
<button
className="button primary"
onClick={() => state.flash.success()}
>
Enregistrer
</button>
<a className="button accepted" href={state.continuerUrl}>
Continuer &gt;
</a>
</div>
</div>
);

View file

@ -22,7 +22,8 @@ class TypesDeChampEditor extends Component {
defaultTypeDeChampAttributes,
typeDeChampsTypes: props.typeDeChampsTypes,
directUploadUrl: props.directUploadUrl,
isAnnotation: props.isAnnotation
isAnnotation: props.isAnnotation,
continuerUrl: props.continuerUrl
};
}
@ -35,6 +36,7 @@ class TypesDeChampEditor extends Component {
TypesDeChampEditor.propTypes = {
baseUrl: PropTypes.string,
continuerUrl: PropTypes.string,
directUploadUrl: PropTypes.string,
isAnnotation: PropTypes.bool,
typeDeChamps: PropTypes.array,

View file

@ -0,0 +1,33 @@
import { useRef, useCallback } from 'react';
export function useDeferredSubmit(input) {
const calledRef = useRef(false);
const awaitFormSubmit = useCallback(
(callback) => {
const form = input.form;
if (!form) {
return;
}
const interceptFormSubmit = (event) => {
event.preventDefault();
runCallback();
form.submit();
};
calledRef.current = false;
form.addEventListener('submit', interceptFormSubmit);
const runCallback = () => {
form.removeEventListener('submit', interceptFormSubmit);
clearTimeout(timer);
if (!calledRef.current) {
callback();
}
};
const timer = setTimeout(runCallback, 400);
},
[input]
);
awaitFormSubmit.done = () => {
calledRef.current = true;
};
return awaitFormSubmit;
}

View file

@ -45,13 +45,13 @@ class AttestationTemplate < ApplicationRecord
acc
end
used_tags.map do |used_tag|
used_tags.filter_map do |used_tag|
corresponding_champ = all_champs_with_libelle_index[used_tag]
if corresponding_champ && corresponding_champ.value.blank?
corresponding_champ
end
end.compact
end
end
def dup

View file

@ -56,9 +56,9 @@ class Champs::CarteChamp < Champ
:zones_humides,
:znieff,
:cadastres
].map do |layer|
].filter_map do |layer|
layer_enabled?(layer) ? layer : nil
end.compact
end
end
def render_options
@ -82,7 +82,7 @@ class Champs::CarteChamp < Champ
bounding_box = RGeo::Cartesian::BoundingBox.new(factory)
if geo_areas.present?
geo_areas.map(&:rgeo_geometry).compact.each do |geometry|
geo_areas.filter_map(&:rgeo_geometry).each do |geometry|
bounding_box.add(geometry)
end
elsif dossier.present?

View file

@ -24,4 +24,8 @@ class Champs::PhoneChamp < Champs::TextChamp
allow_blank: true,
message: I18n.t(:not_a_phone, scope: 'activerecord.errors.messages')
}, unless: -> { Phonelib.valid_for_country?(value, :pf) }
def to_s
value.present? ? Phonelib.parse(value).full_national : ''
end
end

View file

@ -879,7 +879,7 @@ class Dossier < ApplicationRecord
end
def linked_dossiers_for(instructeur_or_expert)
dossier_ids = champs.filter(&:dossier_link?).map(&:value).compact
dossier_ids = champs.filter(&:dossier_link?).filter_map(&:value)
instructeur_or_expert.dossiers.where(id: dossier_ids)
end
@ -920,7 +920,7 @@ class Dossier < ApplicationRecord
factory = RGeo::Geographic.simple_mercator_factory
bounding_box = RGeo::Cartesian::BoundingBox.new(factory)
geo_areas.map(&:rgeo_geometry).compact.each do |geometry|
geo_areas.filter_map(&:rgeo_geometry).each do |geometry|
bounding_box.add(geometry)
end

View file

@ -135,8 +135,8 @@ class ProcedurePresentation < ApplicationRecord
case table
when 'self'
dates = values
.map { |v| Time.zone.parse(v).beginning_of_day rescue nil }
.compact
.filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil }
dossiers.filter_by_datetimes(column, dates)
when TYPE_DE_CHAMP
dossiers.with_type_de_champ(column)
@ -147,8 +147,8 @@ class ProcedurePresentation < ApplicationRecord
when 'etablissement'
if column == 'entreprise_date_creation'
dates = values
.map { |v| v.to_date rescue nil }
.compact
.filter_map { |v| v.to_date rescue nil }
dossiers
.includes(table)
.where(table.pluralize => { column => dates })

View file

@ -81,6 +81,7 @@ class DossierProjectionService
.pluck(:id, *fields.map { |f| f[COLUMN].to_sym })
.each { |id, *columns| fields.zip(columns).each { |field, value| field[:id_value_h][id] = value } }
when 'followers_instructeurs'
# rubocop:disable Style/HashTransformValues
fields[0][:id_value_h] = Follow
.active
.joins(instructeur: :user)
@ -88,6 +89,7 @@ class DossierProjectionService
.pluck('dossier_id, users.email')
.group_by { |dossier_id, _| dossier_id }
.to_h { |dossier_id, dossier_id_emails| [dossier_id, dossier_id_emails.sort.map { |_, email| email }&.join(', ')] }
# rubocop:enable Style/HashTransformValues
end
end

View file

@ -12,8 +12,8 @@ class IPService
if ENV['TRUSTED_NETWORKS'].present?
ENV['TRUSTED_NETWORKS']
.split
.map { |string| parse_address(string) }
.compact
.filter_map { |string| parse_address(string) }
else
[]
end

View file

@ -81,7 +81,7 @@ class PiecesJustificativesService
end
def self.pjs_for_dossier(dossier)
bill_signatures = dossier.dossier_operation_logs.map(&:bill_signature).compact.uniq
bill_signatures = dossier.dossier_operation_logs.filter_map(&:bill_signature).uniq
[
dossier.justificatif_motivation,

View file

@ -32,7 +32,7 @@ class ProcedureExportService
[dossier.champs, dossier.champs_private]
.flatten
.filter { |champ| champ.is_a?(Champs::SiretChamp) }
end.map(&:etablissement).compact + dossiers.map(&:etablissement).compact
end.filter_map(&:etablissement) + dossiers.filter_map(&:etablissement)
end
def avis

View file

@ -4,8 +4,9 @@
- steps.each do |step|
%li= step
- if defined?(preview) && preview
= link_to "Prévisualiser le formulaire", apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'button'
= link_to "Continuer >", admin_procedure_path(@procedure), title: 'Vous pourrez revenir ici par la suite', class: 'button accepted'
.mb-2
= link_to "Prévisualiser le formulaire", apercu_admin_procedure_path(@procedure), target: "_blank", rel: "noopener", class: 'button'
= link_to "Continuer >", admin_procedure_path(@procedure), title: 'Vous pourrez revenir ici par la suite', class: 'button accepted'
- if defined?(metadatas)
%ul.admin-metadata
- metadatas.each do |metadata|

View file

@ -32,18 +32,17 @@
.container
%h2.procedure-admin-explanation Indispensable avant publication
.procedure-grid
.card-admin
= link_to edit_admin_procedure_path(@procedure), id: 'presentation', class: 'card-admin' do
%div
%span.icon.accept
%p.card-admin-status-accept Validé
%div
%p.card-admin-title Présentation
%p.card-admin-subtitle Logo, nom, description
.card-admin-action
= link_to 'Modifier', edit_admin_procedure_path(@procedure), class: 'button', id: "presentation"
%p.button Modifier
- if !@procedure.locked?
.card-admin
= link_to champs_admin_procedure_path(@procedure), class: 'card-admin' do
- if @procedure.draft_types_de_champ.count > 0
%div
%span.icon.accept
@ -57,10 +56,19 @@
%span.badge.baseline= @procedure.draft_types_de_champ.count
Champs du formulaire
%p.card-admin-subtitle À remplir par les usagers
.card-admin-action
= link_to 'Modifier', champs_admin_procedure_path(@procedure), class: 'button'
%p.button Modifier
.card-admin
- if @procedure.service.present?
- service_link = edit_admin_service_path(@procedure.service, procedure_id: @procedure.id)
- service_button_text = 'Modifier'
- elsif current_administrateur.services.present?
- service_link = admin_services_path(procedure_id: @procedure.id)
- service_button_text = 'Choisir'
- else
- service_link = new_admin_service_path(procedure_id: @procedure.id)
- service_button_text = 'Remplir'
= link_to service_link, class: 'card-admin' do
- if @procedure.service_id.present?
%div
%span.icon.accept
@ -76,15 +84,9 @@
= @procedure.service.nom
- else
Choix du service administratif
.card-admin-action
- if @procedure.service.present?
= link_to 'Modifier', edit_admin_service_path(@procedure.service, procedure_id: @procedure.id), class: 'button'
- elsif current_administrateur.services.present?
= link_to 'Choisir', admin_services_path(procedure_id: @procedure.id), class: 'button'
- else
= link_to 'Remplir', new_admin_service_path(procedure_id: @procedure.id), class: 'button'
%p.button= service_button_text
.card-admin
= link_to admin_procedure_administrateurs_path(@procedure), id: 'administrateurs', class: 'card-admin' do
%div
%span.icon.accept
%p.card-admin-status-accept Validé
@ -92,17 +94,17 @@
%p.card-admin-title
%span.badge.baseline= @procedure.administrateurs.count
#{"Administrateur".pluralize(@procedure.administrateurs.count)}
%p.card-admin-subtitle Gestion de la démarche
.card-admin-action
= link_to 'Modifier', admin_procedure_administrateurs_path(@procedure), class: 'button', id: "administrateurs"
%p.button Modifier
.card-admin
- if feature_enabled?(:administrateur_routage)
%div
%span.icon.accept
%p.card-admin-status-accept Validé
- elsif @procedure.instructeurs.count > 1
- if feature_enabled?(:administrateur_routage)
- instructeur_link = admin_procedure_groupe_instructeurs_path(@procedure)
- else
- instructeur_link = admin_procedure_groupe_instructeur_path(@procedure, @procedure.defaut_groupe_instructeur)
= link_to instructeur_link, id: 'groupe-instructeurs', class: 'card-admin' do
- if feature_enabled?(:administrateur_routage) || @procedure.instructeurs.count > 1
%div
%span.icon.accept
%p.card-admin-status-accept Validé
@ -119,15 +121,12 @@
= feature_enabled?(:administrateur_routage) ? "Groupe Instructeurs" : "#{"Instructeur".pluralize(@procedure.instructeurs.count)}"
%p.card-admin-subtitle Suivi des dossiers
.card-admin-action
- if feature_enabled?(:administrateur_routage)
= link_to 'Modifier', admin_procedure_groupe_instructeurs_path(@procedure), class: 'button', id: "groupe-instructeurs"
- else
= link_to 'Modifier', admin_procedure_groupe_instructeur_path(@procedure, @procedure.defaut_groupe_instructeur), class: 'button', id: "instructeurs"
%p.button Modifier
%h2.procedure-admin-explanation Pour aller plus loin
.procedure-grid
.card-admin
= link_to edit_admin_procedure_attestation_template_path(@procedure), class: 'card-admin' do
- if @procedure.attestation_template.present? && @procedure.attestation_template.activated
%div
%span.icon.accept
@ -139,34 +138,29 @@
%div
%p.card-admin-title Attestation
%p.card-admin-subtitle Délivrance automatique pour les dossiers acceptés
.card-admin-action
= link_to 'Modifier', edit_admin_procedure_attestation_template_path(@procedure), class: 'button'
%p.button Modifier
.card-admin
= link_to admin_procedure_experts_path(@procedure), class: 'card-admin' do
%div
%span.icon.preview
%p.card-admin-status-todo À configurer
%div
%p.card-admin-title Avis externes
%p.card-admin-subtitle Gérer les avis des experts invités
.card-admin-action
= link_to "Modifier", admin_procedure_experts_path(@procedure), class: 'button'
%p.button Modifier
.card-admin
= link_to admin_procedure_mail_templates_path(@procedure), class: 'card-admin' do
%div
%span.icon.clock
%p.card-admin-status-todo À configurer
%div
%p.card-admin-title Configuration des emails
%p.card-admin-subtitle Notifications automatiques
.card-admin-action
= link_to 'Modifier', admin_procedure_mail_templates_path(@procedure), class: 'button'
%p.button Modifier
- if !@procedure.locked?
.card-admin
= link_to annotations_admin_procedure_path(@procedure), class: 'card-admin' do
- if @procedure.draft_types_de_champ_private.present?
%div
%span.icon.accept
@ -178,10 +172,9 @@
%div
%p.card-admin-title Annotations privées
%p.card-admin-subtitle Champs à remplir par ladministration
.card-admin-action
= link_to 'Modifier', annotations_admin_procedure_path(@procedure), class: 'button'
%p.button Modifier
.card-admin
= link_to jeton_admin_procedure_path(@procedure), class: 'card-admin' do
- if @procedure.api_entreprise_token.present?
%div
%span.icon.accept
@ -193,10 +186,9 @@
%div
%p.card-admin-title Jeton
%p.card-admin-subtitle Configurer le jeton API entreprise
.card-admin-action
= link_to 'Modifier', jeton_admin_procedure_path(@procedure), class: 'button'
%p.button Modifier
.card-admin
= link_to monavis_admin_procedure_path(@procedure), class: 'card-admin' do
- if @procedure.monavis_embed.present?
%div
%span.icon.accept
@ -208,5 +200,4 @@
%div
%p.card-admin-title MonAvis
%p.card-admin-subtitle Avis des usagers sur votre démarche
.card-admin-action
= link_to 'Modifier', monavis_admin_procedure_path(@procedure), class: 'button'
%p.button Modifier

View file

@ -272,7 +272,7 @@ describe Experts::AvisController, type: :controller do
context 'when the expert also shares the linked dossiers' do
context 'and the expert can access the linked dossiers' do
let(:created_avis) { create(:avis, dossier: dossier, claimant: claimant, email: "toto3@gmail.com") }
let(:linked_dossier) { Dossier.find_by(id: dossier.reload.champs.filter(&:dossier_link?).map(&:value).compact) }
let(:linked_dossier) { Dossier.find_by(id: dossier.reload.champs.filter(&:dossier_link?).filter_map(&:value)) }
let(:linked_avis) { create(:avis, dossier: linked_dossier, claimant: claimant) }
let(:invite_linked_dossiers) { true }

View file

@ -520,7 +520,7 @@ describe Instructeurs::DossiersController, type: :controller do
context 'and the expert can access the linked dossiers' do
let(:saved_avis) { Avis.last(2).first }
let(:linked_avis) { Avis.last }
let(:linked_dossier) { Dossier.find_by(id: dossier.reload.champs.filter(&:dossier_link?).map(&:value).compact) }
let(:linked_dossier) { Dossier.find_by(id: dossier.reload.champs.filter(&:dossier_link?).filter_map(&:value)) }
let(:invite_linked_dossiers) do
instructeur.assign_to_procedure(linked_dossier.procedure)
true

View file

@ -8,7 +8,7 @@ feature 'Inviting an expert:', js: true do
let(:expert_password) { 'mot de passe dexpert' }
let(:procedure) { create(:procedure, :published, instructeurs: [instructeur]) }
let(:dossier) { create(:dossier, :en_construction, :with_dossier_link, procedure: procedure) }
let(:linked_dossier) { Dossier.find_by(id: dossier.reload.champs.filter(&:dossier_link?).map(&:value).compact) }
let(:linked_dossier) { Dossier.find_by(id: dossier.reload.champs.filter(&:dossier_link?).filter_map(&:value)) }
context 'as an Instructeur' do
scenario 'I can invite an expert' do

View file

@ -13,12 +13,6 @@ feature 'As an administrateur I can edit types de champ', js: true do
fill_in 'champ-0-libelle', with: 'libellé de champ'
blur
expect(page).to have_content('Formulaire enregistré')
page.refresh
within '.buttons' do
click_on 'Enregistrer'
end
expect(page).to have_content('Formulaire enregistré')
end
it "Add multiple champs" do

View file

@ -328,7 +328,7 @@ describe Champ do
context 'for phone champ' do
let(:type_de_champ) { build(:type_de_champ_phone) }
let(:value) { "0606060606" }
let(:value) { "06 06 06 06 06" }
it { is_expected.to eq([value]) }
end

View file

@ -13039,9 +13039,9 @@ write@1.0.3:
mkdirp "^0.5.1"
ws@^6.0.0, ws@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb"
integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==
version "6.2.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e"
integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==
dependencies:
async-limiter "~1.0.0"