Merge pull request #9861 from demarches-simplifiees/US/move-type-de-champ-with-select

ETQ administrateur, je peux deplacer un champ via un select
This commit is contained in:
mfo 2024-01-15 13:09:56 +00:00 committed by GitHub
commit 0328446bee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 303 additions and 114 deletions

View file

@ -18,9 +18,9 @@ class TypesDeChampEditor::AddChampButtonComponent < ApplicationComponent
def button_title
if annotations?
"Ajouter une annotation"
"Ajouter une annotation"
else
"Ajouter un champ"
"Ajouter un champ"
end
end

View file

@ -7,14 +7,6 @@ class TypesDeChampEditor::BlockComponent < ApplicationComponent
private
def sortable_options
{
controller: 'sortable',
sortable_handle_value: '.handle',
sortable_group_value: block_id
}
end
def block_id
dom_id(@block, :types_de_champ_editor_block)
end

View file

@ -1,3 +1,6 @@
%ul.types-de-champ-block{ id: block_id, data: sortable_options }
- c = TypesDeChampEditor::SelectChampTemplatePositionComponent.new(block: @block, coordinates: @coordinates)
%ul.types-de-champ-block{ id: block_id, data: { controller: 'select-champ-position-template', 'select-champ-position-template-template-id-value': c.block_id } }
- @coordinates.each do |coordinate|
= render TypesDeChampEditor::ChampComponent.new(coordinate: coordinate, upper_coordinates: coordinate.upper_coordinates)
= render TypesDeChampEditor::ChampComponent.new(coordinate:, upper_coordinates: coordinate.upper_coordinates)
%li.hidden= render c

View file

@ -28,7 +28,6 @@ class TypesDeChampEditor::ChampComponent < ApplicationComponent
last: coordinate.last?),
data: {
controller: 'type-de-champ-editor',
type_de_champ_editor_move_url_value: move_admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id),
type_de_champ_editor_move_up_url_value: move_up_admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id),
type_de_champ_editor_move_down_url_value: move_down_admin_procedure_type_de_champ_path(procedure, type_de_champ.stable_id)
}

View file

@ -1,7 +1,7 @@
%li.type-de-champ.flex.column.justify-start{ html_options }
%li.type-de-champ.flex.column.justify-start.fr-mb-6w{ html_options }
.type-de-champ-container
.flex.justify-between.section.head
.fr-btn.fr-btn--tertiary-no-outline.handle.fr-icon-drag-move-2-line{ title: "Déplacer le champ vers le haut ou vers le bas" }
.position.flex.align-center= @coordinate.position.to_s
%button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-up-line.move-up{ move_button_options(:up) }
%button.fr-btn.fr-btn--tertiary-no-outline.fr-icon-arrow-down-line.move-down{ move_button_options(:down) }
@ -137,5 +137,6 @@
= render(Conditions::ChampsConditionsComponent.new(tdc: type_de_champ, upper_tdcs: @upper_coordinates.map(&:type_de_champ), procedure_id: procedure.id))
.type-de-champ-add-button{ class: class_names(root: !coordinate.child?) }
.type-de-champ-add-button{ class: class_names(root: !coordinate.child?, flex: true) }
= render TypesDeChampEditor::AddChampButtonComponent.new(revision: coordinate.revision, parent: coordinate&.parent, is_annotation: coordinate.private?, after_stable_id: type_de_champ.stable_id)
= render TypesDeChampEditor::SelectChampPositionComponent.new(revision:, coordinate:)

View file

@ -0,0 +1,14 @@
class TypesDeChampEditor::SelectChampPositionComponent < ApplicationComponent
def initialize(revision:, coordinate:)
@revision = revision
@coordinate = coordinate
end
def options
[["Selectionner une option", @coordinate.stable_id]]
end
def describedby_id
dom_id(@coordinate, :move_and_morph)
end
end

View file

@ -0,0 +1,3 @@
= form_with(url: move_and_morph_admin_procedure_type_de_champ_path(@coordinate.revision.procedure, @coordinate.type_de_champ.stable_id), class: 'fr-ml-3w flex', method: :patch, data: { turbo: true }) do |f|
= label_tag :target_stable_id, "Déplacer ce champ à la place de ", for: describedby_id, class: 'flex align-center flex-no-shrink fr-mr-3w'
= select_tag :target_stable_id, options_for_select(options), id: describedby_id, class: 'fr-select', aria: { discribedby: describedby_id }, data: { 'select-champ-position-template-target': 'select', selected: @coordinate.stable_id }

View file

@ -0,0 +1,10 @@
class TypesDeChampEditor::SelectChampTemplatePositionComponent < ApplicationComponent
def initialize(block:, coordinates:)
@block = block
@coordinates = coordinates
end
def block_id
dom_id(@block, :types_de_champ_editor_select_champ_template)
end
end

View file

@ -0,0 +1,4 @@
%div{ id: block_id, data: { 'select-champ-position-template-target': 'template', turbo_force: :server } }
%select
- @coordinates.each do |coordinate|
%option{ value: coordinate.stable_id }= "#{coordinate.position} #{coordinate.libelle}"

View file

@ -57,8 +57,22 @@ module Administrateurs
end
end
def move
draft.move_type_de_champ(params[:stable_id], params[:position].to_i)
def move_and_morph
source_type_de_champ = draft.find_and_ensure_exclusive_use(params[:stable_id])
target_type_de_champ = draft.find_and_ensure_exclusive_use(params[:target_stable_id])
@coordinate = draft.coordinate_for(source_type_de_champ)
from = @coordinate.position
to = draft.coordinate_for(target_type_de_champ).position
@coordinate = draft.move_type_de_champ(@coordinate.stable_id, to)
@destroyed = @coordinate
@created = champ_component_from(@coordinate)
@morphed = @coordinate.siblings
if from > to # case of moved up, update components from target (> plus one) to origin
@morphed = @morphed.where("position > ?", to).where("position <= ?", from)
else # case of moved down, update components from origin up to target (< minus one)
@morphed = @morphed.where("position >= ?", from).where("position < ?", to)
end
@morphed = @morphed.map { |c| champ_component_from(c) }
end
def move_up

View file

@ -0,0 +1,71 @@
import { ApplicationController } from './application_controller';
export class SelectChampPositionTemplateController extends ApplicationController {
static targets = ['select', 'template'];
static values = {
templateId: String
};
// this element is updated via turbostream as the source of truth for all select
declare readonly templateIdValue: string;
declare readonly selectTargets: HTMLSelectElement[];
selectTargetConnected(selectElement: HTMLSelectElement) {
selectElement.addEventListener('focus', this);
selectElement.addEventListener('change', this);
}
selectTargetDisconnected(selectElement: HTMLSelectElement) {
selectElement.removeEventListener('focus', this);
selectElement.removeEventListener('change', this);
}
handleEvent(event: Event) {
switch (event.type) {
case 'focus':
this.onFocus(event);
break;
case 'change':
this.onChange(event);
break;
}
}
private onFocus(event: Event): void {
const focusedSelect = event.target as HTMLSelectElement;
const focusedSelectStableId = this.getStableIdForSelect(focusedSelect);
const template = this.element.querySelector<HTMLElement>(
`#${this.templateIdValue}`
);
if (template) {
const fragment = template.cloneNode(true) as HTMLSelectElement;
const options = Array.from(fragment.querySelectorAll('option'))
.map((option) => {
if (option.value == focusedSelectStableId) {
option.setAttribute('selected', 'selected');
option.setAttribute('disabled', 'disabled');
}
return option.outerHTML;
})
.join('');
focusedSelect.innerHTML = options;
}
}
private onChange(event: Event): void {
const changedSelectTarget = event.target as HTMLSelectElement;
const stableIdDidChange =
changedSelectTarget.value !=
this.getStableIdForSelect(changedSelectTarget);
if (stableIdDidChange) {
changedSelectTarget.form?.requestSubmit();
}
event.stopImmediatePropagation();
}
private getStableIdForSelect(select: HTMLSelectElement): string | null {
return select.getAttribute('data-selected');
}
}

View file

@ -1,68 +0,0 @@
import Sortable from 'sortablejs';
import { ApplicationController } from './application_controller';
export class SortableController extends ApplicationController {
declare readonly animationValue: number;
declare readonly handleValue: string;
declare readonly groupValue: string;
#sortable?: Sortable;
static values = {
animation: Number,
handle: String,
group: String
};
connect() {
this.#sortable = new Sortable(this.element as HTMLElement, {
...this.defaultOptions,
...this.options
});
this.onGlobal('sortable:sort', () => this.setEdgeClassNames());
}
disconnect() {
this.#sortable?.destroy();
}
private onEnd({ item, newIndex }: { item: HTMLElement; newIndex?: number }) {
if (newIndex == null) return;
this.dispatch('end', {
target: item,
detail: { position: newIndex }
});
this.setEdgeClassNames();
}
setEdgeClassNames() {
const items = this.element.children;
for (const item of items) {
item.classList.remove('first', 'last');
}
if (items.length > 1) {
const first = items[0];
const last = items[items.length - 1];
first?.classList.add('first');
last?.classList.add('last');
}
}
get options(): Sortable.Options {
return {
animation: this.animationValue || this.defaultOptions.animation || 150,
handle: this.handleValue || this.defaultOptions.handle || undefined,
group: this.groupValue || this.defaultOptions.group || undefined,
onEnd: (event) => this.onEnd(event)
};
}
get defaultOptions(): Sortable.Options {
return {
fallbackOnBody: true,
swapThreshold: 0.65
};
}
}

View file

@ -15,12 +15,10 @@ const AUTOSAVE_DEBOUNCE_DELAY = debounce_delay;
export class TypeDeChampEditorController extends ApplicationController {
static values = {
typeDeChampStableId: String,
moveUrl: String,
moveUpUrl: String,
moveDownUrl: String
};
declare readonly moveUrlValue: string;
declare readonly moveUpUrlValue: string;
declare readonly moveDownUrlValue: string;
declare readonly isVisible: boolean;
@ -33,9 +31,6 @@ export class TypeDeChampEditorController extends ApplicationController {
this.#latestPromise = Promise.resolve();
this.on('change', (event) => this.onChange(event));
this.on('input', (event) => this.onInput(event));
this.on('sortable:end', (event) =>
this.onSortableEnd(event as CustomEvent)
);
}
disconnect() {
@ -77,15 +72,6 @@ export class TypeDeChampEditorController extends ApplicationController {
});
}
private onSortableEnd(event: CustomEvent<{ position: number }>) {
const position = event.detail.position;
if (event.target == this.element) {
const form = createForm(this.moveUrlValue, 'patch');
createHiddenInput(form, 'position', position);
this.requestSubmitForm(form);
}
}
private save(form?: HTMLFormElement | null): void {
if (form) {
createHiddenInput(form, 'should_render', true);

View file

@ -1,3 +1,11 @@
- if @coordinate.present?
- if @coordinate.parent.present?
- c = TypesDeChampEditor::SelectChampTemplatePositionComponent.new(block: @coordinate.parent, coordinates: @coordinate.parent.revision_types_de_champ)
- else
- c = TypesDeChampEditor::SelectChampTemplatePositionComponent.new(block: @coordinate.revision, coordinates: @coordinate.private? ? @coordinate.revision.revision_types_de_champ_private : @coordinate.revision.revision_types_de_champ_public)
= turbo_stream.replace(c.block_id) do
= render c
= turbo_stream.replace 'breadcrumbs' , render(partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Démarches', admin_procedures_path],
[@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)],
@ -35,8 +43,6 @@
= turbo_stream.replace dom_id(@coordinate.revision, :estimated_fill_duration) do
- render TypesDeChampEditor::EstimatedFillDurationComponent.new(revision: @coordinate.revision, is_annotation: @coordinate.private?)
= turbo_stream.dispatch 'sortable:sort'
- if @created&.coordinate&.child?
= turbo_stream.hide dom_id(@created.coordinate.parent, :type_de_champ_add_button)
- elsif @destroyed&.child? && @destroyed.parent.empty?

View file

@ -0,0 +1,2 @@
= render partial: 'insert'

View file

@ -622,7 +622,7 @@ Rails.application.routes.draw do
resources :types_de_champ, only: [:create, :update, :destroy], param: :stable_id do
member do
patch :move
patch :move_and_morph
patch :move_up
patch :move_down
put :piece_justificative_template

View file

@ -61,7 +61,6 @@
"react-dom": "^18.2.0",
"react-popper": "^2.3.0",
"react-query": "^3.39.3",
"sortablejs": "^1.15.0",
"spectaql": "^2.3.0",
"stimulus-use": "^0.52.0",
"terser": "^5.18.2",

View file

@ -59,5 +59,14 @@ describe TypesDeChampEditor::ChampComponent, type: :component do
expect(page).to have_css('input[type=file]')
end
end
describe 'select champ position' do
let(:tdc) { procedure.draft_revision.types_de_champ.first }
let(:coordinate) { procedure.draft_revision.revision_types_de_champ_public.first }
let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text, libelle: 'a' }]) }
it 'does not have select to move champs' do
expect(page).to have_css("select##{ActionView::RecordIdentifier.dom_id(coordinate, :move_and_morph)}")
end
end
end
end

View file

@ -154,6 +154,43 @@ describe Administrateurs::TypesDeChampController, type: :controller do
end
end
describe '#move_and_morph' do
# l1, l2, l3 => l2, l3, l1
context 'move and morph down' do
let(:params) do
{ procedure_id: procedure.id, stable_id: first_coordinate.stable_id, target_stable_id: third_coordinate.stable_id }
end
subject { patch :move_and_morph, params: params, format: :turbo_stream }
it do
is_expected.to have_http_status(:ok)
expect(assigns(:coordinate)).to eq(first_coordinate)
expect(assigns(:destroyed)).to eq(first_coordinate)
expect(extract_libelle(assigns(:created))).to eq(['l1', ['l2', 'l3']])
expect(morpheds).to eq([['l2', []], ['l3', ['l2']]])
end
end
# l1, l2, l3 => l3, l1, l2
context 'move and morph up' do
let(:params) do
{ procedure_id: procedure.id, stable_id: third_coordinate.stable_id, target_stable_id: first_coordinate.stable_id }
end
subject { patch :move_and_morph, params: params, format: :turbo_stream }
it do
is_expected.to have_http_status(:ok)
[first_coordinate, second_coordinate, third_coordinate].map(&:reload)
expect(assigns(:coordinate).stable_id).to eq(first_coordinate.stable_id)
expect(assigns(:destroyed).stable_id).to eq(first_coordinate.stable_id)
expect(extract_libelle(assigns(:created))).to eq(['l3', []])
expect(morpheds).to eq([['l1', ['l3']], ['l2', ['l3', 'l1']]])
end
end
end
# l1, l2, l3 => l1, l3
# destroyed: l2, morphed: (l3, [l1])
describe '#destroy' do

View file

@ -71,11 +71,11 @@ module SystemHelpers
# Add a new type de champ in the procedure editor
def add_champ
click_on 'Ajouter un champ'
click_on 'Ajouter un champ'
end
def remove_flash_message
expect(page).to have_button('Ajouter un champ', disabled: false)
expect(page).to have_button('Ajouter un champ', disabled: false)
expect(page).to have_content('Formulaire enregistré')
execute_script("document.querySelector('#flash_message').remove();")
execute_script("document.querySelector('#autosave-notice').remove();")

View file

@ -8,7 +8,7 @@ describe 'As an administrateur I can edit annotation', js: true do
end
scenario "adding a new champ" do
click_on 'Ajouter une annotation'
click_on 'Ajouter une annotation'
select('Carte', from: 'Type de champ')
# ensure UI update is ok

View file

@ -38,7 +38,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
}
# Champs are automatically saved
expect(page).to have_button('Ajouter un champ', disabled: false)
expect(page).to have_button('Ajouter un champ', disabled: false)
page.refresh
expect(page).to have_selector('.type-de-champ', count: 3)
@ -111,7 +111,7 @@ describe 'As an administrateur I can edit types de champ', js: true do
page.refresh
within '.type-de-champ .editor-block' do
click_on 'Ajouter un champ'
click_on 'Ajouter un champ'
fill_in 'Libellé du champ', with: 'libellé de champ 1'
end
@ -233,4 +233,116 @@ describe 'As an administrateur I can edit types de champ', js: true do
expect(page).to have_content("Le titre de section suivant est invalide, veuillez le corriger :")
end
end
context 'move and morph' do
let(:procedure) { create(:procedure, types_de_champ_public: tdcs) }
let!(:initial_first_coordinate) { procedure.draft_revision.revision_types_de_champ_public[0] }
let!(:initial_second_coordinate) { procedure.draft_revision.revision_types_de_champ_public[1] }
let!(:initial_third_coordinate) { procedure.draft_revision.revision_types_de_champ_public[2] }
context 'with root champs' do
let(:tdcs) do
[
{ type: :text, libelle: 'first_tdc' },
{ type: :text, libelle: 'middle_tdc' },
{ type: :text, libelle: 'last_tdc' }
]
end
let(:initial_first_coordinate_selector) { "##{ActionView::RecordIdentifier.dom_id(initial_first_coordinate, :move_and_morph)}" }
scenario 'root select is empty by default' do
# at first, select only contains the current coordinate
expect(page).to have_selector("#{initial_first_coordinate_selector} option", count: 1)
expect(page.find(initial_first_coordinate_selector).all("option").first.value.to_s).to eq(initial_first_coordinate.stable_id.to_s)
end
scenario 'when select is focused, it seeds its options' do
# once clicked, the select is updated with root champs options only, preselected on coordinates and have nice libelles
page.find(initial_first_coordinate_selector).click
expect(page).to have_selector("#{initial_first_coordinate_selector} option", count: 3)
expect(page.find(initial_first_coordinate_selector).find("option[selected]").value.to_s).to eq(initial_first_coordinate.stable_id.to_s)
expect(page.find(initial_first_coordinate_selector).all("option").map(&:text)).to match_array(['0 first_tdc', '1 middle_tdc', '2 last_tdc'])
# renaming a tdc renames it's option
within "##{dom_id(initial_first_coordinate, :type_de_champ_editor)}" do
fill_in 'Libellé du champ', with: 'renamed'
end
wait_until { initial_first_coordinate.reload.libelle == 'renamed' }
page.find(initial_first_coordinate_selector).click
expect(page.find(initial_first_coordinate_selector).all("option").map(&:text)).to match_array(['0 renamed', '1 middle_tdc', '2 last_tdc'])
end
scenario 'when select is changed, it move the coordinates' do
page.find(initial_first_coordinate_selector).click # seeds
page.find(initial_first_coordinate_selector).select(initial_third_coordinate.libelle)
wait_until do
procedure.reload.draft_revision.revision_types_de_champ.last.type_de_champ.libelle == initial_first_coordinate.type_de_champ.libelle
end
# wait until turbo response
expect(page).to have_text('Formulaire enregistré')
# check reorder worked on backend
reordered_coordinates = [initial_second_coordinate, initial_third_coordinate, initial_first_coordinate]
expect(procedure.reload.draft_revision.revision_types_de_champ.map(&:stable_id)).to eq(reordered_coordinates.map(&:stable_id))
# check reorder rerendered champ component between target->destination
reordered_coordinates.map(&:reload).map do |coordinate|
expect(page).to have_selector("##{ActionView::RecordIdentifier.dom_id(coordinate, :type_de_champ_editor)} .position", text: coordinate.position)
end
end
end
context 'with repetition champs' do
let(:tdcs) do
[
{ type: :text, libelle: 'root_first_tdc' },
{
type: :repetition,
libelle: 'root_second_tdc',
children: [
{ type: :text, libelle: 'child_first_tdc' },
{ type: :text, libelle: 'child_second_tdc' }
]
},
{ type: :text, libelle: 'root_thrid_tdc' }
]
end
let(:children_coordinates) { procedure.draft_revision.revision_types_de_champ.filter { _1.parent.present? } }
let(:first_child_coordinate_selector) { "##{ActionView::RecordIdentifier.dom_id(children_coordinates.first, :move_and_morph)}" }
scenario 'first child of repetition select is empty by default' do
expect(page).to have_selector("#{first_child_coordinate_selector} option", count: 1)
expect(page.find(first_child_coordinate_selector).all("option").first.value.to_s).to eq(children_coordinates.first.stable_id.to_s)
end
scenario 'when first child select is focused, seed with repetition only tdcs' do
page.find(first_child_coordinate_selector).click
expect(page).to have_selector("#{first_child_coordinate_selector} option", count: 2)
opts = page.find(first_child_coordinate_selector).all("option").map(&:text)
expect(opts).to match_array(children_coordinates.map { "#{_1.position} #{_1.libelle}" })
end
scenario 'when first child select is changed, move champ in repetition' do
page.find(first_child_coordinate_selector).click
expect(children_coordinates.first.position).to eq(0)
page.find(first_child_coordinate_selector).select(children_coordinates.last.libelle)
# check reorder works on backend
wait_until do
children_coordinates.first.reload.position == 1
end
# wait until turbo response
expect(page).to have_text('Formulaire enregistré')
# check reorder worked on backend
reordered_coordinates = children_coordinates.reverse
expect(procedure.reload.draft_revision.revision_types_de_champ.filter { _1.parent.present? }.sort_by(&:position).map(&:stable_id)).to eq(reordered_coordinates.map(&:stable_id))
# check reorder rerendered champ component between target->destination
reordered_coordinates.map(&:reload).map do |coordinate|
expect(page).to have_selector("##{ActionView::RecordIdentifier.dom_id(coordinate, :type_de_champ_editor)} .position", text: coordinate.position)
end
end
end
end
end

View file

@ -7777,11 +7777,6 @@ slash@^4.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
sortablejs@^1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a"
integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"