Merge pull request #3647 from tchak/champs-editor-improuvements

Améliorations du Editeur de Types de Champ
This commit is contained in:
Paul Chavard 2019-04-03 14:54:52 +02:00 committed by GitHub
commit 28c4dc8f60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2493 additions and 1313 deletions

View file

@ -1,54 +0,0 @@
{
"presets": [
[
"@babel/env",
{
"modules": false,
"targets": {
"browsers": [
"> 1%",
"Chrome >= 50",
"IE >= 11",
"Edge >= 14",
"Firefox >= 50",
"Opera >= 40",
"Safari >= 8",
"iOS >= 8"
],
"uglify": true
},
"forceAllTransforms": true,
"useBuiltIns": "entry"
}
]
],
"plugins": [
"@babel/plugin-transform-destructuring",
"@babel/plugin-syntax-dynamic-import",
[
"@babel/plugin-proposal-object-rest-spread",
{
"useBuiltIns": true
}
],
[
"@babel/plugin-transform-runtime",
{
"helpers": false,
"regenerator": true
}
],
[
"@babel/plugin-transform-regenerator",
{
"async": false
}
],
[
"@babel/plugin-proposal-class-properties",
{
"loose": true
}
]
]
}

8
.browserslistrc Normal file
View file

@ -0,0 +1,8 @@
> 1%
Chrome >= 50
IE >= 11
Edge >= 14
Firefox >= 50
Opera >= 40
Safari >= 8
iOS >= 8

View file

@ -1,5 +1,6 @@
module.exports = {
root: true,
parser: 'babel-eslint',
parserOptions: {
ecmaVersion: 2017,
sourceType: 'module'
@ -8,18 +9,24 @@ module.exports = {
'process': true,
'gon': true
},
plugins: ['prettier'],
extends: ['eslint:recommended', 'prettier'],
plugins: ['prettier', 'react-hooks'],
extends: ['eslint:recommended', 'prettier', 'plugin:react/recommended'],
env: {
es6: true,
browser: true
},
rules: {
'prettier/prettier': 'error'
'prettier/prettier': 'error',
'react-hooks/rules-of-hooks': 'error'
},
settings: {
react: {
version: 'detect'
}
},
overrides: [
{
files: ['config/webpack/**/*.js'],
files: ['config/webpack/**/*.js', 'babel.config.js', 'postcss.config.js'],
env: {
node: true
}

6
.gitignore vendored
View file

@ -26,3 +26,9 @@ storage/
yarn-debug.log*
.yarn-integrity
/.vscode
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity

View file

@ -1,3 +0,0 @@
plugins:
postcss-import: {}
postcss-cssnext: {}

View file

@ -49,6 +49,7 @@ gem 'rack-mini-profiler'
gem 'rails'
gem 'rails-i18n' # Locales par défaut
gem 'rake-progressbar', require: false
gem 'react-rails'
gem 'rest-client'
gem 'rgeo-geojson'
gem 'sanitize-url'
@ -63,7 +64,7 @@ gem 'spreadsheet_architect'
gem 'turbolinks' # Turbolinks makes following links in your web application faster
gem 'typhoeus'
gem 'warden'
gem 'webpacker', '>= 4.0.x'
gem 'webpacker'
gem 'zxcvbn-ruby', require: 'zxcvbn'
group :test do

View file

@ -99,6 +99,10 @@ GEM
axlsx_styler (0.2.0)
activesupport (>= 3.1)
axlsx (>= 2.0, < 4)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.12)
bindata (2.4.4)
bindex (0.5.0)
@ -149,6 +153,7 @@ GEM
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.1.5)
connection_pool (2.2.2)
copy_carrierwave_file (1.3.0)
carrierwave (>= 0.9)
crack (0.4.3)
@ -278,7 +283,7 @@ GEM
domain_name (~> 0.5)
http_parser.rb (0.6.0)
httpclient (2.8.3)
i18n (1.5.3)
i18n (1.6.0)
concurrent-ruby (~> 1.0)
ipaddress (0.8.3)
jaro_winkler (1.5.2)
@ -465,6 +470,12 @@ GEM
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
react-rails (2.4.7)
babel-transpiler (>= 0.7.0)
connection_pool
execjs
railties (>= 3.2)
tilt
regexp_parser (1.3.0)
request_store (1.4.1)
rack (>= 1.4)
@ -637,7 +648,7 @@ GEM
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
webpacker (4.0.0.rc.2)
webpacker (4.0.2)
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
@ -720,6 +731,7 @@ DEPENDENCIES
rails-controller-testing
rails-i18n
rake-progressbar
react-rails
rest-client
rgeo-geojson
rspec-rails
@ -747,7 +759,7 @@ DEPENDENCIES
warden
web-console
webmock
webpacker (>= 4.0.x)
webpacker
xray-rails
zxcvbn-ruby

View file

@ -1,15 +1,8 @@
@import "colors";
@import "constants";
#champs-editor {
.spinner {
margin-right: auto;
margin-left: auto;
margin-top: 80px;
}
}
.draggable-item {
.type-de-champ {
background-color: $white;
border: 1px solid $border-grey;
border-radius: 5px;
margin-bottom: 10px;
@ -18,24 +11,11 @@
.handle {
cursor: ns-resize;
margin-right: 10px;
margin-top: 8px;
}
.error-message {
text-align: center;
flex-grow: 1;
font-size: 14px;
color: $light-grey;
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-around;
.content {
background-color: $medium-red;
border-radius: 8px;
padding: 4px 10px;
}
.move {
margin-right: 10px;
margin-bottom: 5px;
}
&.type-header-section {
@ -46,12 +26,6 @@
}
}
&:not(.type-header-section) {
input.error {
border: 1px solid $medium-red;
}
}
.flex {
&.section {
padding: 10px 10px 0 10px;
@ -67,7 +41,7 @@
}
&.shift-left {
margin-left: 35px;
margin-left: 55px;
}
&.head {
@ -112,11 +86,12 @@
}
}
.footer {
margin-bottom: 70px;
}
.champs-editor {
.footer {
margin-bottom: 40px;
}
.buttons {
.buttons {
display: flex;
justify-content: space-between;
margin: 0px;
@ -129,4 +104,5 @@
padding: 10px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
}

View file

@ -1,7 +1,7 @@
module NewAdministrateur
class TypesDeChampController < AdministrateurController
before_action :retrieve_procedure, only: [:create, :update, :destroy]
before_action :procedure_locked?, only: [:create, :update, :destroy]
before_action :retrieve_procedure, only: [:create, :update, :move, :destroy]
before_action :procedure_locked?, only: [:create, :update, :move, :destroy]
def create
type_de_champ = TypeDeChamp.new(type_de_champ_create_params)
@ -25,6 +25,15 @@ module NewAdministrateur
end
end
def move
type_de_champ = TypeDeChamp.where(procedure: @procedure).find(params[:id])
new_index = params[:order_place].to_i
@procedure.move_type_de_champ(type_de_champ, new_index)
head :no_content
end
def destroy
type_de_champ = TypeDeChamp.where(procedure: @procedure).find(params[:id])
@ -39,38 +48,54 @@ module NewAdministrateur
def serialize_type_de_champ(type_de_champ)
{
type_de_champ: type_de_champ.as_json(
except: [:created_at, :updated_at, :stable_id, :type, :parent_id, :procedure_id, :private],
methods: [:piece_justificative_template_filename, :piece_justificative_template_url, :drop_down_list_value]
except: [
:created_at,
:options,
:order_place,
:parent_id,
:private,
:procedure_id,
:stable_id,
:type,
:updated_at
],
methods: [
:cadastres,
:drop_down_list_value,
:parcelles_agricoles,
:piece_justificative_template_filename,
:piece_justificative_template_url,
:quartiers_prioritaires
]
)
}
end
def type_de_champ_create_params
params.required(:type_de_champ).permit(:libelle,
params.required(:type_de_champ).permit(:cadastres,
:description,
:order_place,
:type_champ,
:private,
:parent_id,
:drop_down_list_value,
:libelle,
:mandatory,
:piece_justificative_template,
:quartiers_prioritaires,
:cadastres,
:order_place,
:parcelles_agricoles,
:drop_down_list_value).merge(procedure: @procedure)
:parent_id,
:piece_justificative_template,
:private,
:quartiers_prioritaires,
:type_champ).merge(procedure: @procedure)
end
def type_de_champ_update_params
params.required(:type_de_champ).permit(:libelle,
params.required(:type_de_champ).permit(:cadastres,
:description,
:order_place,
:type_champ,
:drop_down_list_value,
:libelle,
:mandatory,
:parcelles_agricoles,
:piece_justificative_template,
:quartiers_prioritaires,
:cadastres,
:parcelles_agricoles,
:drop_down_list_value)
:type_champ)
end
end
end

View file

@ -36,23 +36,21 @@ module ProcedureHelper
def types_de_champ_data(procedure)
{
type: "champ",
types_de_champ_options: types_de_champ_options.to_json,
types_de_champ: types_de_champ_as_json(procedure.types_de_champ).to_json,
save_url: procedure_types_de_champ_path(procedure),
direct_upload_url: rails_direct_uploads_url,
drag_icon_url: image_url("icons/drag.svg")
isAnnotation: false,
typeDeChampsTypes: types_de_champ_types,
typeDeChamps: types_de_champ_as_json(procedure.types_de_champ),
baseUrl: procedure_types_de_champ_path(procedure),
directUploadUrl: rails_direct_uploads_url
}
end
def types_de_champ_private_data(procedure)
{
type: "annotation",
types_de_champ_options: types_de_champ_options.to_json,
types_de_champ: types_de_champ_as_json(procedure.types_de_champ_private).to_json,
save_url: procedure_types_de_champ_path(procedure),
direct_upload_url: rails_direct_uploads_url,
drag_icon_url: image_url("icons/drag.svg")
isAnnotation: true,
typeDeChampsTypes: types_de_champ_types,
typeDeChamps: types_de_champ_as_json(procedure.types_de_champ_private),
baseUrl: procedure_types_de_champ_path(procedure),
directUploadUrl: rails_direct_uploads_url
}
end
@ -63,20 +61,37 @@ module ProcedureHelper
TypeDeChamp.type_champs.fetch(:repetition) => :champ_repetition?
}
def types_de_champ_options
types_de_champ = TypeDeChamp.type_de_champs_list_fr
def types_de_champ_types
types_de_champ_types = TypeDeChamp.type_de_champs_list_fr
types_de_champ.select! do |tdc|
types_de_champ_types.select! do |tdc|
toggle = TOGGLES[tdc.last]
toggle.blank? || Flipflop.send(toggle)
end
types_de_champ
types_de_champ_types
end
TYPES_DE_CHAMP_BASE = {
except: [:created_at, :updated_at, :stable_id, :type, :parent_id, :procedure_id, :private],
methods: [:piece_justificative_template_filename, :piece_justificative_template_url, :drop_down_list_value]
except: [
:created_at,
:options,
:order_place,
:parent_id,
:private,
:procedure_id,
:stable_id,
:type,
:updated_at
],
methods: [
:cadastres,
:drop_down_list_value,
:parcelles_agricoles,
:piece_justificative_template_filename,
:piece_justificative_template_url,
:quartiers_prioritaires
]
}
TYPES_DE_CHAMP = TYPES_DE_CHAMP_BASE
.merge(include: { types_de_champ: TYPES_DE_CHAMP_BASE })

View file

@ -0,0 +1,35 @@
export default class Flash {
constructor(isAnnotation) {
this.element = document.querySelector('#flash_messages');
this.isAnnotation = isAnnotation;
}
success() {
if (this.isAnnotation) {
this.add('Annotations privées enregistrées.');
} else {
this.add('Formulaire enregistré.');
}
}
error(message) {
this.add(message, true);
}
clear() {
this.element.innerHTML = '';
}
add(message, isError) {
const html = `<div id="flash_message" class="center">
<div class="alert alert-fixed ${
isError ? 'alert-danger' : 'alert-success'
}">
${message}
</div>
</div>`;
this.element.innerHTML = html;
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.clear();
}, 4000);
}
}

View file

@ -0,0 +1,52 @@
import { to, getJSON } from '@utils';
export default class OperationsQueue {
constructor(baseUrl) {
this.queue = [];
this.isRunning = false;
this.baseUrl = baseUrl;
}
async run() {
if (this.queue.length > 0) {
this.isRunning = true;
const operation = this.queue.shift();
await this.exec(operation);
this.run();
} else {
this.isRunning = false;
}
}
enqueue(operation) {
return new Promise((resolve, reject) => {
this.queue.push({ ...operation, resolve, reject });
if (!this.isRunning) {
this.run();
}
});
}
async exec(operation) {
const { path, method, payload, resolve, reject } = operation;
const url = `${this.baseUrl}${path}`;
const [data, xhr] = await to(getJSON(url, payload, method));
if (xhr) {
handleError(xhr, reject);
} else {
resolve(data);
}
}
}
function handleError(xhr, reject) {
try {
const {
errors: [message]
} = JSON.parse(xhr.responseText);
reject(message);
} catch (e) {
reject(xhr.responseText);
}
}

View file

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
function DescriptionInput({ isVisible, handler }) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={handler.id}>Description</label>
<textarea
id={handler.id}
name={handler.name}
value={handler.value || ''}
onChange={handler.onChange}
rows={3}
cols={40}
className="small-margin small"
/>
</div>
);
}
return null;
}
DescriptionInput.propTypes = {
isVisible: PropTypes.bool,
handler: PropTypes.object
};
export default DescriptionInput;

View file

@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
function LibelleInput({ isVisible, handler }) {
if (isVisible) {
return (
<div className="cell libelle">
<label htmlFor={handler.id}>Libellé</label>
<input
type="text"
id={handler.id}
name={handler.name}
value={handler.value}
onChange={handler.onChange}
className="small-margin small"
/>
</div>
);
}
return null;
}
LibelleInput.propTypes = {
handler: PropTypes.object,
isVisible: PropTypes.bool
};
export default LibelleInput;

View file

@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
function MandatoryInput({ isVisible, handler }) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={handler.id}>Obligatoire</label>
<input
type="checkbox"
id={handler.id}
name={handler.name}
checked={!!handler.value}
onChange={handler.onChange}
className="small-margin small"
/>
</div>
);
}
return null;
}
MandatoryInput.propTypes = {
handler: PropTypes.object,
isVisible: PropTypes.bool
};
export default MandatoryInput;

View file

@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
function MoveButton({ isVisible, icon, onClick }) {
if (isVisible) {
return (
<button className="button small icon-only move" onClick={onClick}>
<FontAwesomeIcon icon={icon} />
</button>
);
}
return null;
}
MoveButton.propTypes = {
isVisible: PropTypes.bool,
icon: PropTypes.string,
onClick: PropTypes.func
};
export default MoveButton;

View file

@ -0,0 +1,227 @@
import React from 'react';
import PropTypes from 'prop-types';
import { sortableElement, sortableHandle } from 'react-sortable-hoc';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DescriptionInput from './DescriptionInput';
import LibelleInput from './LibelleInput';
import MandatoryInput from './MandatoryInput';
import MoveButton from './MoveButton';
import TypeDeChampCarteOption from './TypeDeChampCarteOption';
import TypeDeChampCarteOptions from './TypeDeChampCarteOptions';
import TypeDeChampDropDownOptions from './TypeDeChampDropDownOptions';
import TypeDeChampPieceJustificative from './TypeDeChampPieceJustificative';
import TypeDeChampRepetitionOptions from './TypeDeChampRepetitionOptions';
import TypeDeChampTypesSelect from './TypeDeChampTypesSelect';
const TypeDeChamp = sortableElement(
({ typeDeChamp, dispatch, idx: index, isFirstItem, isLastItem, state }) => {
const isDropDown = [
'drop_down_list',
'multiple_drop_down_list',
'linked_drop_down_list'
].includes(typeDeChamp.type_champ);
const isFile = typeDeChamp.type_champ === 'piece_justificative';
const isCarte = typeDeChamp.type_champ === 'carte';
const isExplication = typeDeChamp.type_champ === 'explication';
const isHeaderSection = typeDeChamp.type_champ === 'header_section';
const isRepetition = typeDeChamp.type_champ === 'repetition';
const canBeMandatory =
!isHeaderSection && !isExplication && !state.isAnnotation;
const updateHandlers = createUpdateHandlers(
dispatch,
typeDeChamp,
index,
state.prefix
);
const typeDeChampsTypesForRepetition = state.typeDeChampsTypes.filter(
([, type]) => !EXCLUDE_FROM_REPETITION.includes(type)
);
return (
<div
ref={isLastItem ? state.lastTypeDeChampRef : null}
data-index={index}
className={`type-de-champ form flex column justify-start ${
isHeaderSection ? 'type-header-section' : ''
}`}
>
<div
className={`flex justify-start section head ${
!isHeaderSection ? 'hr' : ''
}`}
>
<DragHandle />
<TypeDeChampTypesSelect
handler={updateHandlers.type_champ}
options={state.typeDeChampsTypes}
/>
<div className="flex justify-start delete">
<button
className="button small icon-only danger"
onClick={() =>
dispatch({ type: 'removeTypeDeChamp', params: { typeDeChamp } })
}
>
<FontAwesomeIcon icon="trash" title="Supprimer" />
</button>
</div>
</div>
<div
className={`flex justify-start section ${
isDropDown || isFile || isCarte ? 'hr' : ''
}`}
>
<div className="flex column justify-start">
<MoveButton
isVisible={!isFirstItem}
icon="arrow-up"
onClick={() =>
dispatch({
type: 'moveTypeDeChampUp',
params: { typeDeChamp }
})
}
/>
<MoveButton
isVisible={!isLastItem}
icon="arrow-down"
onClick={() =>
dispatch({
type: 'moveTypeDeChampDown',
params: { typeDeChamp }
})
}
/>
</div>
<div className="flex column justify-start">
<LibelleInput handler={updateHandlers.libelle} isVisible={true} />
<MandatoryInput
handler={updateHandlers.mandatory}
isVisible={canBeMandatory}
/>
</div>
<div className="flex justify-start">
<DescriptionInput
isVisible={!isHeaderSection}
handler={updateHandlers.description}
/>
</div>
</div>
<div className="flex justify-start section shift-left">
<TypeDeChampDropDownOptions
isVisible={isDropDown}
handler={updateHandlers.drop_down_list_value}
/>
<TypeDeChampPieceJustificative
isVisible={isFile}
directUploadUrl={state.directUploadUrl}
filename={typeDeChamp.piece_justificative_template_filename}
handler={updateHandlers.piece_justificative_template}
url={typeDeChamp.piece_justificative_template_url}
/>
<TypeDeChampCarteOptions isVisible={isCarte}>
<TypeDeChampCarteOption
label="Quartiers prioritaires"
handler={updateHandlers.quartiers_prioritaires}
/>
<TypeDeChampCarteOption
label="Cadastres"
handler={updateHandlers.cadastres}
/>
<TypeDeChampCarteOption
label="Parcelles Agricoles"
handler={updateHandlers.parcelles_agricoles}
/>
</TypeDeChampCarteOptions>
<TypeDeChampRepetitionOptions
isVisible={isRepetition}
state={{
...state,
typeDeChampsTypes: typeDeChampsTypesForRepetition,
prefix: `repetition-${index}`,
typeDeChamps: typeDeChamp.types_de_champ || []
}}
typeDeChamp={typeDeChamp}
/>
</div>
</div>
);
}
);
TypeDeChamp.propTypes = {
dispatch: PropTypes.func,
idx: PropTypes.number,
isFirstItem: PropTypes.bool,
isLastItem: PropTypes.bool,
state: PropTypes.object,
typeDeChamp: PropTypes.object
};
const DragHandle = sortableHandle(() => (
<div className="handle button small icon-only">
<FontAwesomeIcon icon="arrows-alt-v" size="lg" />
</div>
));
function createUpdateHandler(dispatch, typeDeChamp, field, index, prefix) {
return {
id: `${prefix ? `${prefix}-` : ''}champ-${index}-${field}`,
name: field,
value: typeDeChamp[field],
onChange: ({ target }) =>
dispatch({
type: 'updateTypeDeChamp',
params: {
typeDeChamp,
field,
value: readValue(target)
},
done: () => dispatch({ type: 'refresh' })
})
};
}
function createUpdateHandlers(dispatch, typeDeChamp, index, prefix) {
return FIELDS.reduce((handlers, field) => {
handlers[field] = createUpdateHandler(
dispatch,
typeDeChamp,
field,
index,
prefix
);
return handlers;
}, {});
}
export const FIELDS = [
'cadastres',
'description',
'drop_down_list_value',
'libelle',
'mandatory',
'order_place',
'parcelles_agricoles',
'parent_id',
'piece_justificative_template',
'private',
'quartiers_prioritaires',
'type_champ'
];
function readValue(input) {
return input.type === 'checkbox' ? input.checked : input.value;
}
const EXCLUDE_FROM_REPETITION = [
'carte',
'dossier_link',
'repetition',
'siret'
];
export default TypeDeChamp;

View file

@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampCarteOption({ label, handler }) {
return (
<label htmlFor={handler.id}>
<input
type="checkbox"
id={handler.id}
name={handler.name}
checked={!!handler.value}
onChange={handler.onChange}
className="small-margin small"
/>
{label}
</label>
);
}
TypeDeChampCarteOption.propTypes = {
label: PropTypes.string,
handler: PropTypes.object
};
export default TypeDeChampCarteOption;

View file

@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampCarteOptions({ isVisible, children }) {
if (isVisible) {
return (
<div className="cell">
<label>Utilisation de la cartographie</label>
<div className="carte-options">{children}</div>
</div>
);
}
return null;
}
TypeDeChampCarteOptions.propTypes = {
isVisible: PropTypes.bool,
children: PropTypes.array
};
export default TypeDeChampCarteOptions;

View file

@ -0,0 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampDropDownOptions({ isVisible, value, handler }) {
if (isVisible) {
return (
<div className="cell">
<label htmlFor={handler.id}>Liste déroulante</label>
<textarea
id={handler.id}
name={handler.name}
value={value}
onChange={handler.onChange}
rows={3}
cols={40}
placeholder="Ecrire une valeur par ligne et --valeur-- pour un séparateur."
className="small-margin small"
/>
</div>
);
}
return null;
}
TypeDeChampDropDownOptions.propTypes = {
isVisible: PropTypes.bool,
value: PropTypes.string,
handler: PropTypes.object
};
export default TypeDeChampDropDownOptions;

View file

@ -0,0 +1,81 @@
import React from 'react';
import PropTypes from 'prop-types';
import Uploader from '../../../shared/activestorage/uploader';
function TypeDeChampPieceJustificative({
isVisible,
url,
filename,
handler,
directUploadUrl
}) {
if (isVisible) {
const hasFile = !!filename;
return (
<div className="cell">
<label htmlFor={handler.id}>Modèle</label>
<FileInformation isVisible={hasFile} url={url} filename={filename} />
<input
type="file"
id={handler.id}
name={handler.name}
onChange={onFileChange(handler, directUploadUrl)}
className="small-margin small"
/>
</div>
);
}
return null;
}
TypeDeChampPieceJustificative.propTypes = {
isVisible: PropTypes.bool,
url: PropTypes.string,
filename: PropTypes.string,
handler: PropTypes.object,
directUploadUrl: PropTypes.string
};
function FileInformation({ isVisible, url, filename }) {
if (isVisible) {
return (
<>
<a href={url} rel="noopener noreferrer" target="_blank">
{filename}
</a>
<br /> Modifier :
</>
);
}
return null;
}
FileInformation.propTypes = {
isVisible: PropTypes.bool,
url: PropTypes.string,
filename: PropTypes.string
};
function onFileChange(handler, directUploadUrl) {
return async ({ target }) => {
const file = target.files[0];
if (file) {
const signedId = await uploadFile(target, file, directUploadUrl);
handler.onChange({
target: {
value: signedId
}
});
}
};
}
function uploadFile(input, file, directUploadUrl) {
const controller = new Uploader(input, file, directUploadUrl);
return controller.start().then(signedId => {
input.value = null;
return signedId;
});
}
export default TypeDeChampPieceJustificative;

View file

@ -0,0 +1,63 @@
import React, { useReducer, useRef } from 'react';
import PropTypes from 'prop-types';
import { SortableContainer, addChampLabel } from '../utils';
import TypeDeChamp from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer';
function TypeDeChampRepetitionOptions({
isVisible,
state: parentState,
typeDeChamp
}) {
const lastTypeDeChampRef = useRef(null);
const [state, dispatch] = useReducer(typeDeChampsReducer, {
...parentState,
lastTypeDeChampRef
});
if (isVisible) {
return (
<div className="repetition flex-grow cell">
<SortableContainer
onSortEnd={params => dispatch({ type: 'onSortTypeDeChamps', params })}
useDragHandle
>
{state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChamp
dispatch={dispatch}
idx={index}
index={index}
isFirstItem={index === 0}
isLastItem={index === state.typeDeChamps.length - 1}
key={`champ-${typeDeChamp.id}`}
state={state}
typeDeChamp={typeDeChamp}
/>
))}
</SortableContainer>
<button
className="button"
onClick={() =>
dispatch({
type: 'addNewRepetitionTypeDeChamp',
params: { typeDeChamp },
done: () => dispatch({ type: 'refresh' })
})
}
>
{addChampLabel(state.isAnnotation)}
</button>
</div>
);
}
return null;
}
TypeDeChampRepetitionOptions.propTypes = {
isVisible: PropTypes.bool,
state: PropTypes.object,
typeDeChamp: PropTypes.object
};
export default TypeDeChampRepetitionOptions;

View file

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampTypesSelect({ handler, options }) {
return (
<div className="cell">
<select
id={handler.id}
name={handler.name}
onChange={handler.onChange}
value={handler.value}
className="small-margin small inline"
>
{options.map(([label, key]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
);
}
TypeDeChampTypesSelect.propTypes = {
handler: PropTypes.object,
options: PropTypes.array
};
export default TypeDeChampTypesSelect;

View file

@ -0,0 +1,72 @@
import React, { useReducer, useRef } from 'react';
import PropTypes from 'prop-types';
import { SortableContainer, addChampLabel } from '../utils';
import TypeDeChamp from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer';
function TypeDeChamps({ state: rootState, typeDeChamps }) {
const lastTypeDeChampRef = useRef(null);
const [state, dispatch] = useReducer(typeDeChampsReducer, {
...rootState,
lastTypeDeChampRef,
typeDeChamps
});
if (state.typeDeChamps.length === 0) {
dispatch({
type: 'addFirstTypeDeChamp',
done: () => dispatch({ type: 'refresh' })
});
}
return (
<div className="champs-editor">
<SortableContainer
onSortEnd={params => dispatch({ type: 'onSortTypeDeChamps', params })}
lockAxis="y"
useDragHandle
>
{state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChamp
dispatch={dispatch}
idx={index}
index={index}
isFirstItem={index === 0}
isLastItem={index === state.typeDeChamps.length - 1}
key={`champ-${typeDeChamp.id}`}
state={state}
typeDeChamp={typeDeChamp}
/>
))}
</SortableContainer>
<div className="footer">&nbsp;</div>
<div className="buttons">
<button
className="button"
onClick={() =>
dispatch({
type: 'addNewTypeDeChamp',
done: () => dispatch({ type: 'refresh' })
})
}
>
{addChampLabel(state.isAnnotation)}
</button>
<button
className="button primary"
onClick={() => state.flash.success()}
>
Enregistrer
</button>
</div>
</div>
);
}
TypeDeChamps.propTypes = {
state: PropTypes.object,
typeDeChamps: PropTypes.array
};
export default TypeDeChamps;

View file

@ -0,0 +1,63 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { library } from '@fortawesome/fontawesome-svg-core';
import {
faArrowDown,
faArrowsAltV,
faArrowUp,
faTrash
} from '@fortawesome/free-solid-svg-icons';
import Flash from './Flash';
import OperationsQueue from './OperationsQueue';
import TypeDeChamps from './components/TypeDeChamps';
library.add(faArrowDown, faArrowsAltV, faArrowUp, faTrash);
class TypesDeChampEditor extends Component {
constructor({
baseUrl,
typeDeChampsTypes,
directUploadUrl,
isAnnotation,
typeDeChamps
}) {
super({ typeDeChamps });
const defaultTypeDeChampAttributes = {
type_champ: 'text',
types_de_champ: [],
private: isAnnotation,
libelle: `${isAnnotation ? 'Nouvelle annotation' : 'Nouveau champ'} ${
typeDeChampsTypes[0][0]
}`
};
this.state = {
flash: new Flash(isAnnotation),
queue: new OperationsQueue(baseUrl),
defaultTypeDeChampAttributes,
typeDeChampsTypes,
directUploadUrl,
isAnnotation
};
}
render() {
return (
<TypeDeChamps state={this.state} typeDeChamps={this.props.typeDeChamps} />
);
}
}
TypesDeChampEditor.propTypes = {
baseUrl: PropTypes.string,
directUploadUrl: PropTypes.string,
isAnnotation: PropTypes.bool,
typeDeChamps: PropTypes.array,
typeDeChampsTypes: PropTypes.array
};
export function createReactUJSElement(props) {
return React.createElement(TypesDeChampEditor, props);
}
export default TypesDeChampEditor;

View file

@ -0,0 +1,51 @@
export function createTypeDeChampOperation(typeDeChamp, queue) {
return queue
.enqueue({
path: '',
method: 'post',
payload: { type_de_champ: typeDeChamp }
})
.then(data => {
handleResponseData(typeDeChamp, data);
});
}
export function destroyTypeDeChampOperation(typeDeChamp, queue) {
return queue.enqueue({
path: `/${typeDeChamp.id}`,
method: 'delete',
payload: {}
});
}
export function moveTypeDeChampOperation(typeDeChamp, index, queue) {
return queue.enqueue({
path: `/${typeDeChamp.id}/move`,
method: 'patch',
payload: { order_place: index }
});
}
export function updateTypeDeChampOperation(typeDeChamp, queue) {
return queue
.enqueue({
path: `/${typeDeChamp.id}`,
method: 'patch',
payload: { type_de_champ: typeDeChamp }
})
.then(data => {
handleResponseData(typeDeChamp, data);
});
}
function handleResponseData(typeDeChamp, { type_de_champ }) {
for (let field of RESPONSE_FIELDS) {
typeDeChamp[field] = type_de_champ[field];
}
}
const RESPONSE_FIELDS = [
'id',
'piece_justificative_template_filename',
'piece_justificative_template_url'
];

View file

@ -0,0 +1,184 @@
import scrollToComponent from 'react-scroll-to-component';
import { debounce } from '@utils';
import {
createTypeDeChampOperation,
destroyTypeDeChampOperation,
moveTypeDeChampOperation,
updateTypeDeChampOperation
} from './operations';
export default function typeDeChampsReducer(state, { type, params, done }) {
switch (type) {
case 'addNewTypeDeChamp':
return addNewTypeDeChamp(state, state.typeDeChamps, done);
case 'addFirstTypeDeChamp':
return addFirstTypeDeChamp(state, state.typeDeChamps, done);
case 'addNewRepetitionTypeDeChamp':
return addNewRepetitionTypeDeChamp(
state,
state.typeDeChamps,
params.typeDeChamp,
done
);
case 'updateTypeDeChamp':
return updateTypeDeChamp(state, state.typeDeChamps, params, done);
case 'removeTypeDeChamp':
return removeTypeDeChamp(state, state.typeDeChamps, params);
case 'moveTypeDeChampUp':
return moveTypeDeChampUp(state, state.typeDeChamps, params);
case 'moveTypeDeChampDown':
return moveTypeDeChampDown(state, state.typeDeChamps, params);
case 'onSortTypeDeChamps':
return onSortTypeDeChamps(state, state.typeDeChamps, params);
case 'refresh':
return { ...state, typeDeChamps: [...state.typeDeChamps] };
default:
throw new Error(`Unknown action "${type}"`);
}
}
function addNewTypeDeChamp(state, typeDeChamps, done) {
const typeDeChamp = {
...state.defaultTypeDeChampAttributes,
order_place: typeDeChamps.length
};
createTypeDeChampOperation(typeDeChamp, state.queue)
.then(() => {
state.flash.success();
done();
if (state.lastTypeDeChampRef) {
scrollToComponent(state.lastTypeDeChampRef.current);
}
})
.catch(message => state.flash.error(message));
return {
...state,
typeDeChamps: [...typeDeChamps, typeDeChamp]
};
}
function addNewRepetitionTypeDeChamp(state, typeDeChamps, typeDeChamp, done) {
return addNewTypeDeChamp(
{
...state,
defaultTypeDeChampAttributes: {
...state.defaultTypeDeChampAttributes,
parent_id: typeDeChamp.id
}
},
typeDeChamps,
done
);
}
function addFirstTypeDeChamp(state, typeDeChamps, done) {
const typeDeChamp = { ...state.defaultTypeDeChampAttributes, order_place: 0 };
createTypeDeChampOperation(typeDeChamp, state.queue)
.then(() => done())
.catch(message => state.flash.error(message));
return {
...state,
typeDeChamps: [...typeDeChamps, typeDeChamp]
};
}
function updateTypeDeChamp(
state,
typeDeChamps,
{ typeDeChamp, field, value },
done
) {
typeDeChamp[field] = value;
getUpdateHandler(typeDeChamp, state)(done);
return {
...state,
typeDeChamps: [...typeDeChamps]
};
}
function removeTypeDeChamp(state, typeDeChamps, { typeDeChamp }) {
destroyTypeDeChampOperation(typeDeChamp, state.queue)
.then(() => state.flash.success())
.catch(message => state.flash.error(message));
return {
...state,
typeDeChamps: arrayRemove(typeDeChamps, typeDeChamp)
};
}
function moveTypeDeChampUp(state, typeDeChamps, { typeDeChamp }) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex - 1;
moveTypeDeChampOperation(typeDeChamp, newIndex, state.queue)
.then(() => state.flash.success())
.catch(message => state.flash.error(message));
return {
...state,
typeDeChamps: arrayMove(typeDeChamps, oldIndex, newIndex)
};
}
function moveTypeDeChampDown(state, typeDeChamps, { typeDeChamp }) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex + 1;
moveTypeDeChampOperation(typeDeChamp, newIndex, state.queue)
.then(() => state.flash.success())
.catch(message => state.flash.error(message));
return {
...state,
typeDeChamps: arrayMove(typeDeChamps, oldIndex, newIndex)
};
}
function onSortTypeDeChamps(state, typeDeChamps, { oldIndex, newIndex }) {
moveTypeDeChampOperation(typeDeChamps[oldIndex], newIndex, state.queue)
.then(() => state.flash.success())
.catch(message => state.flash.error(message));
return {
...state,
typeDeChamps: arrayMove(typeDeChamps, oldIndex, newIndex)
};
}
function arrayRemove(array, item) {
array = Array.from(array);
array.splice(array.indexOf(item), 1);
return array;
}
function arrayMove(array, from, to) {
array = Array.from(array);
array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]);
return array;
}
const updateHandlers = new WeakMap();
function getUpdateHandler(typeDeChamp, { queue, flash }) {
let handler = updateHandlers.get(typeDeChamp);
if (!handler) {
handler = debounce(
done =>
updateTypeDeChampOperation(typeDeChamp, queue)
.then(() => {
flash.success();
done();
})
.catch(message => flash.error(message)),
200
);
updateHandlers.set(typeDeChamp, handler);
}
return handler;
}

View file

@ -0,0 +1,14 @@
import React from 'react';
import { sortableContainer } from 'react-sortable-hoc';
export const SortableContainer = sortableContainer(({ children }) => {
return <ul>{children}</ul>;
});
export function addChampLabel(isAnnotation) {
if (isAnnotation) {
return 'Ajouter une annotation';
} else {
return 'Ajouter un champ';
}
}

View file

@ -1,254 +0,0 @@
import { getJSON, debounce } from '@utils';
import Uploader from '../../shared/activestorage/uploader';
export default {
props: ['state', 'index', 'item'],
computed: {
isValid() {
if (this.deleted) {
return true;
}
if (this.libelle) {
return !!this.libelle.trim();
}
return false;
},
itemClassName() {
const classNames = [`draggable-item-${this.index}`];
if (this.isHeaderSection) {
classNames.push('type-header-section');
}
return classNames.join(' ');
},
isDropDown() {
return [
'drop_down_list',
'multiple_drop_down_list',
'linked_drop_down_list'
].includes(this.typeChamp);
},
isFile() {
return this.typeChamp === 'piece_justificative';
},
isCarte() {
return this.typeChamp === 'carte';
},
isExplication() {
return this.typeChamp === 'explication';
},
isHeaderSection() {
return this.typeChamp === 'header_section';
},
isRepetition() {
return this.typeChamp === 'repetition';
},
options() {
const options = this.item.options || {};
for (let key of Object.keys(options)) {
options[key] = castBoolean(options[key]);
}
return options;
},
attribute() {
if (this.state.isAnnotation) {
return 'types_de_champ_private_attributes';
} else {
return 'types_de_champ_attributes';
}
},
payload() {
const payload = {
libelle: this.libelle,
type_champ: this.typeChamp,
mandatory: this.mandatory,
description: this.description,
drop_down_list_value: this.dropDownListValue,
order_place: this.index
};
if (this.pieceJustificativeTemplate) {
payload.piece_justificative_template = this.pieceJustificativeTemplate;
}
if (this.state.parentId) {
payload.parent_id = this.state.parentId;
}
if (!this.id && this.state.isAnnotation) {
payload.private = true;
}
Object.assign(payload, this.options);
return payload;
},
saveUrl() {
if (this.id) {
return `${this.state.saveUrl}/${this.id}`;
}
return this.state.saveUrl;
},
savePayload() {
if (this.deleted) {
return {};
}
return { type_de_champ: this.payload };
},
saveMethod() {
if (this.deleted) {
return 'delete';
} else if (this.id) {
return 'patch';
}
return 'post';
},
typesDeChamp() {
return this.item.types_de_champ;
},
typesDeChampOptions() {
return this.state.typesDeChampOptions.filter(
([, typeChamp]) => !EXCLUDE_FROM_REPETITION.includes(typeChamp)
);
},
stateForRepetition() {
return Object.assign({}, this.state, {
typesDeChamp: this.typesDeChamp,
typesDeChampOptions: this.typesDeChampOptions,
prefix: `${this.state.prefix}[${this.attribute}][${this.index}]`,
parentId: this.id
});
}
},
data() {
return {
id: this.item.id,
typeChamp: this.item.type_champ,
libelle: this.item.libelle,
mandatory: this.item.mandatory,
description: this.item.description,
pieceJustificativeTemplate: null,
pieceJustificativeTemplateUrl: this.item.piece_justificative_template_url,
pieceJustificativeTemplateFilename: this.item
.piece_justificative_template_filename,
dropDownListValue: this.item.drop_down_list_value,
deleted: false,
isSaving: false,
isUploading: false,
hasChanges: false
};
},
watch: {
index() {
this.update();
}
},
created() {
this.debouncedSave = debounce(() => this.save(), 500);
this.debouncedUpload = debounce(evt => this.upload(evt), 500);
},
methods: {
removeChamp() {
if (this.id) {
this.deleted = true;
this.debouncedSave();
} else {
const index = this.state.typesDeChamp.indexOf(this.item);
this.state.typesDeChamp.splice(index, 1);
}
},
nameFor(name) {
return `${this.state.prefix}[${this.attribute}][${this.index}][${name}]`;
},
elementIdFor(name) {
const prefix = this.state.prefix.replace(/\[/g, '_').replace(/\]/g, '');
return `${prefix}_${this.attribute}_${this.index}_${name}`;
},
addChamp() {
this.typesDeChamp.push({
type_champ: 'text',
types_de_champ: []
});
},
update() {
this.hasChanges = true;
if (this.isValid) {
if (this.state.inFlight === 0) {
this.state.flash.clear();
}
this.debouncedSave();
}
},
upload(evt) {
if (this.isUploading) {
this.debouncedUpload();
} else {
const input = evt.target;
const file = input.files[0];
if (file) {
this.isUploading = true;
const controller = new Uploader(
input,
file,
this.state.directUploadUrl
);
controller.start().then(signed_id => {
this.pieceJustificativeTemplate = signed_id;
this.isUploading = false;
this.debouncedSave();
});
}
input.value = null;
}
},
save() {
if (this.isSaving) {
this.debouncedSave();
} else {
this.isSaving = true;
this.state.inFlight++;
getJSON(this.saveUrl, this.savePayload, this.saveMethod)
.then(data => {
this.onSuccess(data);
})
.catch(xhr => {
this.onError(xhr);
});
}
},
onSuccess(data) {
if (data && data.type_de_champ) {
this.id = data.type_de_champ.id;
this.pieceJustificativeTemplateUrl =
data.type_de_champ.piece_justificative_template_url;
this.pieceJustificativeTemplateFilename =
data.type_de_champ.piece_justificative_template_filename;
this.pieceJustificativeTemplate = null;
}
this.state.inFlight--;
this.isSaving = false;
this.hasChanges = false;
if (this.state.inFlight === 0) {
this.state.flash.success();
}
},
onError(xhr) {
this.isSaving = false;
this.state.inFlight--;
try {
const {
errors: [message]
} = JSON.parse(xhr.responseText);
this.state.flash.error(message);
} catch (e) {
this.state.flash.error(xhr.responseText);
}
}
}
};
const EXCLUDE_FROM_REPETITION = [
'carte',
'dossier_link',
'repetition',
'siret'
];
function castBoolean(value) {
return value && value != 0;
}

View file

@ -1,181 +0,0 @@
<template>
<div class="deleted" v-if="deleted">
<input type="hidden" :name="nameFor('id')" :value="id">
<input type="hidden" :name="nameFor('_destroy')" value="true">
</div>
<div class="draggable-item flex column justify-start" v-else :class="itemClassName">
<div class="flex justify-start section head" :class="{ hr: !isHeaderSection }">
<div class="handle">
<img :src="state.dragIconUrl" alt="">
</div>
<div class="cell">
<select
:id="elementIdFor('type_champ')"
:name="nameFor('type_champ')"
v-model="typeChamp"
@change="update"
class="small-margin small inline">
<option v-for="option in state.typesDeChampOptions" :key="option[1]" :value="option[1]">
{{ option[0] }}
</option>
</select>
</div>
<div class="flex justify-start delete">
<button class="button danger" @click.prevent="removeChamp">
Supprimer
</button>
</div>
</div>
<div class="flex justify-start section" :class="{ hr: isDropDown || isFile || isCarte }">
<div class="flex column justify-start shift-left">
<div class="cell libelle">
<label :for="elementIdFor('libelle')">
Libellé
</label>
<input
type="text"
:id="elementIdFor('libelle')"
:name="nameFor('libelle')"
v-model="libelle"
@change="update"
class="small-margin small"
:class="{ error: hasChanges && !isValid }">
</div>
<div class="cell" v-show="!isHeaderSection && !isExplication && !state.isAnnotation">
<label :for="elementIdFor('mandatory')">
Obligatoire
</label>
<input :name="nameFor('mandatory')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('mandatory')"
:name="nameFor('mandatory')"
v-model="mandatory"
@change="update"
class="small-margin small"
value="1">
</div>
</div>
<div class="flex justify-start">
<div class="cell" v-show="!isHeaderSection">
<label :for="elementIdFor('description')">
Description
</label>
<textarea
:id="elementIdFor('description')"
:name="nameFor('description')"
v-model="description"
@change="update"
rows=3
cols=40
class="small-margin small">
</textarea>
</div>
</div>
</div>
<div class="flex justify-start section shift-left" v-show="!isHeaderSection">
<div class="cell" v-show="isDropDown">
<label :for="elementIdFor('drop_down_list')">
Liste déroulante
</label>
<textarea
:id="elementIdFor('drop_down_list')"
:name="nameFor('drop_down_list_attributes[value]')"
v-model="dropDownListValue"
@change="update"
rows=3
cols=40
placeholder="Ecrire une valeur par ligne et --valeur-- pour un séparateur."
class="small-margin small">
</textarea>
</div>
<div class="cell" v-show="isFile">
<label :for="elementIdFor('piece_justificative_template')">
Modèle
</label>
<template v-if="pieceJustificativeTemplateUrl">
<a :href="pieceJustificativeTemplateUrl" rel="noopener" target="_blank">
{{pieceJustificativeTemplateFilename}}
</a>
<br> Modifier :
</template>
<input
type="file"
:id="elementIdFor('piece_justificative_template')"
:name="nameFor('piece_justificative_template')"
@change="upload"
class="small-margin small">
</div>
<div class="cell" v-show="isCarte">
<label>
Utilisation de la cartographie
</label>
<div class="carte-options">
<label :for="elementIdFor('quartiers_prioritaires')">
<input :name="nameFor('quartiers_prioritaires')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('quartiers_prioritaires')"
:name="nameFor('quartiers_prioritaires')"
v-model="options.quartiers_prioritaires"
@change="update"
class="small-margin small"
value="1">
Quartiers prioritaires
</label>
<label :for="elementIdFor('cadastres')">
<input :name="nameFor('cadastres')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('cadastres')"
:name="nameFor('cadastres')"
v-model="options.cadastres"
@change="update"
class="small-margin small"
value="1">
Cadastres
</label>
<label :for="elementIdFor('parcelles_agricoles')">
<input :name="nameFor('parcelles_agricoles')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('parcelles_agricoles')"
:name="nameFor('parcelles_agricoles')"
v-model="options.parcelles_agricoles"
@change="update"
class="small-margin small"
value="1">
Parcelles Agricoles
</label>
</div>
</div>
<div class="flex-grow cell" v-show="isRepetition">
<Draggable :list="typesDeChamp" :options="{handle:'.handle'}">
<DraggableItem
v-for="(item, index) in typesDeChamp"
:state="stateForRepetition"
:index="index"
:item="item"
:key="item.id" />
</Draggable>
<button class="button" @click.prevent="addChamp">
<template v-if="state.isAnnotation">
Ajouter une annotation
</template>
<template v-else>
Ajouter un champ
</template>
</button>
</div>
</div>
<div class="meta">
<input type="hidden" :name="nameFor('order_place')" :value="index">
<input type="hidden" :name="nameFor('id')" :value="id">
</div>
</div>
</template>
<script src="./DraggableItem.js"></script>

View file

@ -1,14 +0,0 @@
export default {
props: ['state', 'version'],
methods: {
addChamp() {
this.state.typesDeChamp.push({
type_champ: 'text',
types_de_champ: []
});
},
save() {
this.state.flash.success();
}
}
};

View file

@ -1,28 +0,0 @@
<template>
<div class="champs-editor">
<Draggable :list="state.typesDeChamp" :options="{handle:'.handle'}">
<DraggableItem
v-for="(item, index) in state.typesDeChamp"
:state="state"
:index="index"
:item="item"
:key="item.id" />
</Draggable>
<div class="footer"></div>
<div class="buttons">
<button class="button" v-scroll-to="'.footer'" @click.prevent="addChamp">
<template v-if="state.isAnnotation">
Ajouter une annotation
</template>
<template v-else>
Ajouter un champ
</template>
</button>
<button class="button primary" @click.prevent="save">Enregistrer</button>
</div>
</div>
</template>
<script src="./DraggableList.js"></script>

View file

@ -1,90 +0,0 @@
import Vue from 'vue';
import Draggable from 'vuedraggable';
import VueScrollTo from 'vue-scrollto';
import DraggableItem from './DraggableItem';
import DraggableList from './DraggableList';
Vue.component('Draggable', Draggable);
Vue.component('DraggableItem', DraggableItem);
Vue.use(VueScrollTo, { duration: 1500, easing: 'ease' });
addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#champs-editor');
if (el) {
initEditor(el);
}
});
function initEditor(el) {
const { directUploadUrl, dragIconUrl, saveUrl } = el.dataset;
const state = {
typesDeChamp: JSON.parse(el.dataset.typesDeChamp),
typesDeChampOptions: JSON.parse(el.dataset.typesDeChampOptions),
directUploadUrl,
dragIconUrl,
saveUrl,
isAnnotation: el.dataset.type === 'annotation',
prefix: 'procedure',
inFlight: 0,
flash: new Flash()
};
// We add an initial type de champ here if form is empty
if (state.typesDeChamp.length === 0) {
state.typesDeChamp.push({
type_champ: 'text',
types_de_champ: []
});
}
new Vue({
el,
data: {
state
},
render(h) {
return h(DraggableList, {
props: {
state: this.state
}
});
}
});
}
class Flash {
constructor(isAnnotation) {
this.element = document.querySelector('#flash_messages');
this.isAnnotation = isAnnotation;
}
success() {
if (this.isAnnotation) {
this.add('Annotations privées enregistrées.');
} else {
this.add('Formulaire enregistré.');
}
}
error(message) {
this.add(message, true);
}
clear() {
this.element.innerHTML = '';
}
add(message, isError) {
const html = `<div id="flash_message" class="center">
<div class="alert alert-fixed ${
isError ? 'alert-danger' : 'alert-success'
}">
${message}
</div>
</div>`;
this.element.innerHTML = html;
setTimeout(() => {
this.clear();
}, 6000);
}
}

View file

@ -5,6 +5,9 @@ import * as ActiveStorage from 'activestorage';
import Chartkick from 'chartkick';
import Highcharts from 'highcharts';
import ReactUJS from '../shared/react-ujs';
import reactComponents from '../shared/react-components';
import '../shared/activestorage/ujs';
import '../shared/rails-ujs-fix';
import '../shared/safari-11-file-xhr-workaround';
@ -23,8 +26,6 @@ import '../new_design/champs/carte';
import '../new_design/champs/linked-drop-down-list';
import '../new_design/champs/repetition';
import '../new_design/administrateur/champs-editor';
import { toggleCondidentielExplanation } from '../new_design/avis';
import { scrollMessagerie } from '../new_design/messagerie';
import { showMotivation, motivationCancel } from '../new_design/state-button';
@ -48,6 +49,9 @@ Rails.start();
Turbolinks.start();
ActiveStorage.start();
const loader = new ReactUJS(reactComponents);
loader.start();
// Expose globals
window.DS = window.DS || DS;
window.Chartkick = Chartkick;

View file

@ -1,5 +1,5 @@
// Include runtime-polyfills for older browsers.
// Due to .babelrc's 'useBuiltIns', only polyfills actually
// Due to babel.config.js's 'useBuiltIns', only polyfills actually
// required by the browsers we support will be included.
import '@babel/polyfill';
import 'dom4';

View file

@ -0,0 +1,8 @@
export default function reactComponents(className) {
switch (className) {
case 'TypesDeChampEditor':
return import('components/TypesDeChampEditor').then(
mod => mod.createReactUJSElement
);
}
}

61
app/javascript/shared/react-ujs.js vendored Normal file
View file

@ -0,0 +1,61 @@
import ReactDOM from 'react-dom';
// This attribute holds the name of component which should be mounted
// example: `data-react-class="MyApp.Items.EditForm"`
const CLASS_NAME_ATTR = 'data-react-class';
// This attribute holds JSON stringified props for initializing the component
// example: `data-react-props="{\"item\": { \"id\": 1, \"name\": \"My Item\"} }"`
const PROPS_ATTR = 'data-react-props';
// This attribute holds which method to use between: ReactDOM.hydrate, ReactDOM.render
const RENDER_ATTR = 'data-hydrate';
function findDOMNodes() {
return document.querySelectorAll(`[${CLASS_NAME_ATTR}]`);
}
export default class ReactUJS {
constructor(loadComponent) {
this.loadComponent = loadComponent;
}
async mountComponents() {
const nodes = findDOMNodes();
for (let node of nodes) {
const className = node.getAttribute(CLASS_NAME_ATTR);
const createReactUJSElement = await this.loadComponent(className).catch(
() => null
);
if (!createReactUJSElement) {
const message = "Cannot find component: '" + className + "'";
// eslint-disable-next-line no-console
console.error(
'%c[react-rails] %c' + message + ' for element',
'font-weight: bold',
'',
node
);
throw new Error(
message + '. Make sure your component is available to render.'
);
} else {
const propsJson = node.getAttribute(PROPS_ATTR);
const props = propsJson && JSON.parse(propsJson);
const hydrate = node.getAttribute(RENDER_ATTR);
if (hydrate && typeof ReactDOM.hydrate === 'function') {
ReactDOM.hydrate(createReactUJSElement(props), node);
} else {
ReactDOM.render(createReactUJSElement(props), node);
}
}
}
}
start() {
addEventListener('turbolinks:load', () => this.mountComponents());
}
}

View file

@ -51,6 +51,10 @@ export function on(selector, eventName, fn) {
);
}
export function to(promise) {
return promise.then(result => [result]).catch(error => [null, error]);
}
function offset(element) {
const rect = element.getBoundingClientRect();
return {

View file

@ -410,8 +410,48 @@ class Procedure < ApplicationRecord
result
end
def move_type_de_champ(type_de_champ, new_index)
types_de_champ, collection_attribute_name = if type_de_champ.parent&.repetition?
if type_de_champ.parent.private?
[type_de_champ.parent.types_de_champ, :types_de_champ_private_attributes]
else
[type_de_champ.parent.types_de_champ, :types_de_champ_attributes]
end
elsif type_de_champ.private?
[self.types_de_champ_private, :types_de_champ_private_attributes]
else
[self.types_de_champ, :types_de_champ_attributes]
end
attributes = move_type_de_champ_attributes(types_de_champ.to_a, type_de_champ, new_index)
if type_de_champ.parent&.repetition?
attributes = [
{
id: type_de_champ.parent.id,
libelle: type_de_champ.parent.libelle,
types_de_champ_attributes: attributes
}
]
end
update!(collection_attribute_name => attributes)
end
private
def move_type_de_champ_attributes(types_de_champ, type_de_champ, new_index)
old_index = types_de_champ.index(type_de_champ)
types_de_champ.insert(new_index, types_de_champ.delete_at(old_index))
.map.with_index do |type_de_champ, index|
{
id: type_de_champ.id,
libelle: type_de_champ.libelle,
order_place: index
}
end
end
def claim_path_ownership!(path)
procedure = Procedure.joins(:administrateurs)
.where(administrateurs: { id: administrateur_ids })

View file

@ -7,6 +7,4 @@
%h1 Configuration des annotations privées
%br
= form_for @procedure, remote: true, html: { class: 'form' } do |form|
#champs-editor{ data: types_de_champ_private_data(@procedure) }
.spinner
= react_component("TypesDeChampEditor", types_de_champ_private_data(@procedure))

View file

@ -7,6 +7,4 @@
%h1 Configuration des champs
%br
= form_for @procedure, remote: true, html: { class: 'form' } do |form|
#champs-editor{ data: types_de_champ_data(@procedure) }
.spinner
= react_component("TypesDeChampEditor", types_de_champ_data(@procedure))

83
babel.config.js Normal file
View file

@ -0,0 +1,83 @@
module.exports = function(api) {
var validEnv = ['development', 'test', 'production'];
var currentEnv = api.env();
var isDevelopmentEnv = api.env('development');
var isProductionEnv = api.env('production');
var isTestEnv = api.env('test');
if (!validEnv.includes(currentEnv)) {
throw new Error(
'Please specify a valid `NODE_ENV` or ' +
'`BABEL_ENV` environment variables. Valid values are "development", ' +
'"test", and "production". Instead, received: ' +
JSON.stringify(currentEnv) +
'.'
);
}
return {
presets: [
isTestEnv && [
require('@babel/preset-env').default,
{
targets: {
node: 'current'
}
}
],
(isProductionEnv || isDevelopmentEnv) && [
require('@babel/preset-env').default,
{
forceAllTransforms: true,
useBuiltIns: 'entry',
modules: false,
exclude: ['transform-typeof-symbol']
}
],
[
require('@babel/preset-react').default,
{
development: isDevelopmentEnv || isTestEnv,
useBuiltIns: true
}
]
].filter(Boolean),
plugins: [
require('babel-plugin-macros'),
require('@babel/plugin-syntax-dynamic-import').default,
isTestEnv && require('babel-plugin-dynamic-import-node'),
require('@babel/plugin-transform-destructuring').default,
[
require('@babel/plugin-proposal-class-properties').default,
{
loose: true
}
],
[
require('@babel/plugin-proposal-object-rest-spread').default,
{
useBuiltIns: true
}
],
[
require('@babel/plugin-transform-runtime').default,
{
helpers: false,
regenerator: true
}
],
[
require('@babel/plugin-transform-regenerator').default,
{
async: false
}
],
isProductionEnv && [
require('babel-plugin-transform-react-remove-prop-types').default,
{
removeImport: true
}
]
].filter(Boolean)
};
};

View file

@ -1,4 +1,4 @@
# See .babelrc
# See .browserslistrc
Browser.modern_rules.clear
Browser.modern_rules << -> b { b.chrome? && b.version.to_i >= 50 }
Browser.modern_rules << -> b { b.ie? && b.version.to_i >= 11 && !b.compatibility_view? }

View file

@ -372,7 +372,11 @@ Rails.application.routes.draw do
get 'annotations'
end
resources :types_de_champ, only: [:create, :update, :destroy]
resources :types_de_champ, only: [:create, :update, :destroy] do
member do
patch :move
end
end
end
resources :services, except: [:show] do

View file

@ -1,7 +1,5 @@
const path = require('path');
const { environment } = require('@rails/webpacker');
const { VueLoaderPlugin } = require('vue-loader');
const vue = require('./loaders/vue');
const resolve = {
alias: {
@ -10,7 +8,4 @@ const resolve = {
};
environment.config.merge({ resolve });
environment.plugins.append('VueLoaderPlugin', new VueLoaderPlugin());
environment.loaders.append('vue', vue);
module.exports = environment;

View file

@ -1,8 +0,0 @@
module.exports = {
test: /\.vue(\.erb)?$/,
use: [
{
loader: 'vue-loader'
}
]
};

View file

@ -3,8 +3,11 @@
default: &default
source_path: app/javascript
source_entry_path: packs
public_root_path: public
public_output_path: packs
cache_path: tmp/cache/webpacker
check_yarn_integrity: false
webpack_compile_output: false
# Additional paths webpack should lookup modules
# ['app/assets', 'engine/foo/app/assets']
@ -13,8 +16,25 @@ default: &default
# Reload manifest.json on all requests so we reload latest compiled packs
cache_manifest: false
# Extract and emit a css file
extract_css: false
static_assets_extensions:
- .jpg
- .jpeg
- .png
- .gif
- .tiff
- .ico
- .svg
- .eot
- .otf
- .ttf
- .woff
- .woff2
extensions:
- .vue
- .mjs
- .js
- .sass
- .scss
@ -32,6 +52,9 @@ development:
<<: *default
compile: true
# Verifies that correct packages and versions are installed by inspecting package.json, yarn.lock, and node_modules
check_yarn_integrity: true
# Reference: https://webpack.js.org/configuration/dev-server/
dev_server:
https: false
@ -49,7 +72,7 @@ development:
headers:
'Access-Control-Allow-Origin': '*'
watch_options:
ignored: /node_modules/
ignored: '**/node_modules/**'
test:
@ -65,5 +88,8 @@ production:
# Production depends on precompilation of packs prior to booting for performance.
compile: false
# Extract and emit a css file
extract_css: true
# Cache manifest.json for performance
cache_manifest: true

View file

@ -1,32 +1,42 @@
{
"dependencies": {
"@babel/preset-react": "^7.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.15",
"@fortawesome/free-solid-svg-icons": "^5.7.2",
"@fortawesome/react-fontawesome": "^0.1.4",
"@rails/webpacker": "4.0.0-pre.3",
"@sentry/browser": "^4.6.5",
"@turf/area": "^6.0.1",
"activestorage": "^5.2.2-rc1",
"autocomplete.js": "^0.31.0",
"activestorage": "^5.2.2",
"autocomplete.js": "^0.36.0",
"chartkick": "^3.0.1",
"debounce": "^1.2.0",
"dom4": "^2.1.3",
"highcharts": "^6.1.2",
"jquery": "^3.3.1",
"leaflet": "^1.3.4",
"leaflet-freedraw": "^2.9.0",
"rails-ujs": "^5.2.1",
"leaflet": "^1.3.4",
"prop-types": "^15.7.2",
"rails-ujs": "^5.2.2",
"ramda": "^0.25.0",
"react_ujs": "^2.4.4",
"react-dom": "^16.8.4",
"react-scroll-to-component": "^1.0.2",
"react-sortable-hoc": "^1.7.1",
"react": "^16.8.4",
"select2": "^4.0.6-rc.1",
"turbolinks": "^5.2.0",
"vue": "^2.5.21",
"vue-loader": "^15.5.1",
"vue-template-compiler": "^2.5.21",
"vuedraggable": "^2.16.0",
"vue-scrollto": "^2.13.0"
"turbolinks": "^5.2.0"
},
"devDependencies": {
"babel-eslint": "^10.0.1",
"babel-plugin-macros": "^2.5.0",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"eclint": "^2.8.0",
"eslint": "^5.9.0",
"eslint-config-prettier": "^3.3.0",
"eslint-plugin-prettier": "^3.0.0",
"eslint-plugin-react": "^7.12.4",
"eslint-plugin-react-hooks": "^1.5.1",
"prettier": "^1.15.3",
"webpack-dev-server": "^3.1.9"
},

12
postcss.config.js Normal file
View file

@ -0,0 +1,12 @@
module.exports = {
plugins: [
require('postcss-import'),
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009'
},
stage: 3
})
]
};

View file

@ -76,15 +76,15 @@ feature 'As an administrateur I wanna create a new procedure', js: true do
page.refresh
expect(page).to have_current_path(champs_procedure_path(Procedure.last))
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libelle de champ'
expect(page).to have_selector('#champ-0-libelle')
fill_in 'champ-0-libelle', with: 'libelle de champ'
blur
expect(page).to have_content('Formulaire enregistré')
within '.buttons' do
click_on 'Ajouter un champ'
end
expect(page).to have_selector('#procedure_types_de_champ_attributes_1_libelle')
expect(page).to have_selector('#champ-1-libelle')
click_on Procedure.last.libelle
click_on 'onglet-pieces'
@ -105,7 +105,7 @@ feature 'As an administrateur I wanna create a new procedure', js: true do
scenario 'After adding champ and file, make publication' do
page.refresh
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libelle de champ'
fill_in 'champ-0-libelle', with: 'libelle de champ'
blur
expect(page).to have_content('Formulaire enregistré')

View file

@ -16,8 +16,8 @@ feature 'As an administrateur I can edit types de champ', js: true do
within '.buttons' do
click_on 'Ajouter un champ'
end
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
expect(page).to have_selector('#champ-0-libelle')
fill_in 'champ-0-libelle', with: 'libellé de champ'
blur
expect(page).to have_content('Formulaire enregistré')
@ -35,24 +35,24 @@ feature 'As an administrateur I can edit types de champ', js: true do
click_on 'Ajouter un champ'
click_on 'Ajouter un champ'
end
expect(page).not_to have_content('Formulaire enregistré')
page.refresh
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ 0'
fill_in 'procedure_types_de_champ_attributes_1_libelle', with: 'libellé de champ 1'
fill_in 'champ-0-libelle', with: 'libellé de champ 0'
fill_in 'champ-1-libelle', with: 'libellé de champ 1'
blur
expect(page).to have_content('Formulaire enregistré')
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
expect(page).to have_selector('#procedure_types_de_champ_attributes_1_libelle')
expect(page).to have_selector('#procedure_types_de_champ_attributes_2_libelle')
expect(page).to have_selector('#procedure_types_de_champ_attributes_3_libelle')
expect(page).to have_selector('#champ-0-libelle')
expect(page).to have_selector('#champ-1-libelle')
expect(page).to have_selector('#champ-2-libelle')
expect(page).to have_selector('#champ-3-libelle')
within '.draggable-item-2' do
within '.type-de-champ[data-index="2"]' do
click_on 'Supprimer'
end
expect(page).not_to have_selector('#procedure_types_de_champ_attributes_3_libelle')
fill_in 'procedure_types_de_champ_attributes_2_libelle', with: 'libellé de champ 2'
expect(page).not_to have_selector('#champ-3-libelle')
fill_in 'champ-2-libelle', with: 'libellé de champ 2'
blur
expect(page).to have_content('Formulaire enregistré')
@ -64,44 +64,45 @@ feature 'As an administrateur I can edit types de champ', js: true do
end
it "Remove champs" do
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
fill_in 'champ-0-libelle', with: 'libellé de champ'
blur
expect(page).to have_content('Formulaire enregistré')
page.refresh
click_on 'Supprimer'
expect(page).to have_content('Formulaire enregistré')
expect(page).not_to have_content('Supprimer')
expect(page).to have_content('Supprimer', count: 1)
page.refresh
expect(page).to have_content('Supprimer', count: 1)
end
it "Only add valid champs" do
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_description')
fill_in 'procedure_types_de_champ_attributes_0_description', with: 'déscription du champ'
expect(page).to have_selector('#champ-0-description')
fill_in 'champ-0-libelle', with: ''
fill_in 'champ-0-description', with: 'déscription du champ'
blur
expect(page).not_to have_content('Formulaire enregistré')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
fill_in 'champ-0-libelle', with: 'libellé de champ'
blur
expect(page).to have_content('Formulaire enregistré')
end
it "Add repetition champ" do
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
select('Bloc répétable', from: 'procedure_types_de_champ_attributes_0_type_champ')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
expect(page).to have_selector('#champ-0-libelle')
select('Bloc répétable', from: 'champ-0-type_champ')
fill_in 'champ-0-libelle', with: 'libellé de champ'
blur
expect(page).to have_content('Formulaire enregistré')
page.refresh
within '.flex-grow' do
within '.type-de-champ .repetition' do
click_on 'Ajouter un champ'
end
fill_in 'procedure_types_de_champ_attributes_0_types_de_champ_attributes_0_libelle', with: 'libellé de champ 1'
fill_in 'repetition-0-champ-0-libelle', with: 'libellé de champ 1'
blur
expect(page).to have_content('Formulaire enregistré')
@ -111,16 +112,16 @@ feature 'As an administrateur I can edit types de champ', js: true do
click_on 'Ajouter un champ'
end
select('Bloc répétable', from: 'procedure_types_de_champ_attributes_1_type_champ')
fill_in 'procedure_types_de_champ_attributes_1_libelle', with: 'libellé de champ 2'
select('Bloc répétable', from: 'champ-0-type_champ')
fill_in 'champ-0-libelle', with: 'libellé de champ 2'
blur
expect(page).to have_content('Supprimer', count: 3)
end
it "Add carte champ" do
select('Carte', from: 'procedure_types_de_champ_attributes_0_type_champ')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ carte'
select('Carte', from: 'champ-0-type_champ')
fill_in 'champ-0-libelle', with: 'libellé de champ carte'
blur
check 'Quartiers prioritaires'
expect(page).to have_content('Formulaire enregistré')

View file

@ -819,4 +819,80 @@ describe Procedure do
it { expect(procedure.usual_instruction_time).to be_nil }
end
end
describe '#move_type_de_champ' do
let(:procedure) { create(:procedure) }
context 'type_de_champ' do
let(:type_de_champ) { create(:type_de_champ_text, order_place: 0, procedure: procedure) }
let!(:type_de_champ1) { create(:type_de_champ_text, order_place: 1, procedure: procedure) }
let!(:type_de_champ2) { create(:type_de_champ_text, order_place: 2, procedure: procedure) }
it 'move down' do
procedure.move_type_de_champ(type_de_champ, 2)
type_de_champ.reload
procedure.reload
expect(procedure.types_de_champ.index(type_de_champ)).to eq(2)
expect(type_de_champ.order_place).to eq(2)
end
context 'repetition' do
let!(:type_de_champ_repetition) do
create(:type_de_champ_repetition, types_de_champ: [
type_de_champ,
type_de_champ1,
type_de_champ2
], procedure: procedure)
end
it 'move down' do
procedure.move_type_de_champ(type_de_champ, 2)
type_de_champ.reload
procedure.reload
expect(type_de_champ.parent.types_de_champ.index(type_de_champ)).to eq(2)
expect(type_de_champ.order_place).to eq(2)
end
context 'private' do
let!(:type_de_champ_repetition) do
create(:type_de_champ_repetition, types_de_champ: [
type_de_champ,
type_de_champ1,
type_de_champ2
], private: true, procedure: procedure)
end
it 'move down' do
procedure.move_type_de_champ(type_de_champ, 2)
type_de_champ.reload
procedure.reload
expect(type_de_champ.parent.types_de_champ.index(type_de_champ)).to eq(2)
expect(type_de_champ.order_place).to eq(2)
end
end
end
end
context 'private' do
let(:type_de_champ) { create(:type_de_champ_text, order_place: 0, private: true, procedure: procedure) }
let!(:type_de_champ1) { create(:type_de_champ_text, order_place: 1, private: true, procedure: procedure) }
let!(:type_de_champ2) { create(:type_de_champ_text, order_place: 2, private: true, procedure: procedure) }
it 'move down' do
procedure.move_type_de_champ(type_de_champ, 2)
type_de_champ.reload
procedure.reload
expect(procedure.types_de_champ_private.index(type_de_champ)).to eq(2)
expect(type_de_champ.order_place).to eq(2)
end
end
end
end

1447
yarn.lock

File diff suppressed because it is too large Load diff