Revrite types de champ editor using React
This commit is contained in:
parent
5a032d344d
commit
47694d286e
22 changed files with 1141 additions and 48 deletions
|
@ -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,21 +86,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-bottom: 70px;
|
||||
}
|
||||
.champs-editor {
|
||||
.footer {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0px;
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
background-color: $white;
|
||||
max-width: $page-width;
|
||||
width: 100%;
|
||||
border: 1px solid $border-grey;
|
||||
padding: 10px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0px;
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
background-color: $white;
|
||||
max-width: $page-width;
|
||||
width: 100%;
|
||||
border: 1px solid $border-grey;
|
||||
padding: 10px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
|
35
app/javascript/components/TypesDeChampEditor/Flash.js
Normal file
35
app/javascript/components/TypesDeChampEditor/Flash.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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"> </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;
|
63
app/javascript/components/TypesDeChampEditor/index.js
Normal file
63
app/javascript/components/TypesDeChampEditor/index.js
Normal 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;
|
51
app/javascript/components/TypesDeChampEditor/operations.js
Normal file
51
app/javascript/components/TypesDeChampEditor/operations.js
Normal 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'
|
||||
];
|
|
@ -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;
|
||||
}
|
14
app/javascript/components/TypesDeChampEditor/utils.js
Normal file
14
app/javascript/components/TypesDeChampEditor/utils.js
Normal 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';
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
"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",
|
||||
|
|
57
yarn.lock
57
yarn.lock
|
@ -2235,11 +2235,48 @@ commondir@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
|
||||
integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
|
||||
|
||||
component-clone@0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/component-clone/-/component-clone-0.2.2.tgz#c7f5979822880fad8cfb0962ba29186d061ee04f"
|
||||
integrity sha1-x/WXmCKID62M+wliuikYbQYe4E8=
|
||||
dependencies:
|
||||
component-type "*"
|
||||
|
||||
component-emitter@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.0.tgz#ccd113a86388d06482d03de3fc7df98526ba8efe"
|
||||
integrity sha1-zNETqGOI0GSC0D3j/H35hSa6jv4=
|
||||
|
||||
component-emitter@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
|
||||
integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
|
||||
|
||||
component-raf@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/component-raf/-/component-raf-1.2.0.tgz#b2bc72d43f1b014fde7a4b3c447c764bc73ccbaa"
|
||||
integrity sha1-srxy1D8bAU/eeks8RHx2S8c8y6o=
|
||||
|
||||
component-tween@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/component-tween/-/component-tween-1.2.0.tgz#cc39ce5dbab05b52825f41d1947638a0b01b2b8a"
|
||||
integrity sha1-zDnOXbqwW1KCX0HRlHY4oLAbK4o=
|
||||
dependencies:
|
||||
component-clone "0.2.2"
|
||||
component-emitter "1.2.0"
|
||||
component-type "1.1.0"
|
||||
ease-component "1.0.0"
|
||||
|
||||
component-type@*:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/component-type/-/component-type-1.2.1.tgz#8a47901700238e4fc32269771230226f24b415a9"
|
||||
integrity sha1-ikeQFwAjjk/DIml3EjAibyS0Fak=
|
||||
|
||||
component-type@1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/component-type/-/component-type-1.1.0.tgz#95b666aad53e5c8d1f2be135c45b5d499197c0c5"
|
||||
integrity sha1-lbZmqtU+XI0fK+E1xFtdSZGXwMU=
|
||||
|
||||
compressible@~2.0.13:
|
||||
version "2.0.14"
|
||||
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.14.tgz#326c5f507fbb055f54116782b969a81b67a29da7"
|
||||
|
@ -2971,6 +3008,11 @@ duplexify@^3.4.2, duplexify@^3.6.0:
|
|||
readable-stream "^2.0.0"
|
||||
stream-shift "^1.0.0"
|
||||
|
||||
ease-component@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ease-component/-/ease-component-1.0.0.tgz#b375726db0b5b04595b77440396fec7daa5d77c9"
|
||||
integrity sha1-s3VybbC1sEWVt3RAOW/sfapdd8k=
|
||||
|
||||
ecc-jsbn@~0.1.1:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505"
|
||||
|
@ -7236,6 +7278,13 @@ react-is@^16.8.1:
|
|||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.4.tgz#90f336a68c3a29a096a3d648ab80e87ec61482a2"
|
||||
integrity sha512-PVadd+WaUDOAciICm/J1waJaSvgq+4rHE/K70j0PFqKhkTBsPv/82UGQJNXAngz1fOQLLxI6z1sEDmJDQhCTAA==
|
||||
|
||||
react-scroll-to-component@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-scroll-to-component/-/react-scroll-to-component-1.0.2.tgz#f260dc936c62a53e772786d7832fe0884e195354"
|
||||
integrity sha1-8mDck2xipT53J4bXgy/giE4ZU1Q=
|
||||
dependencies:
|
||||
scroll-to "0.0.2"
|
||||
|
||||
react-sortable-hoc@^1.7.1:
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-1.7.1.tgz#2d61c6003f22523ab3adfe37e69774c5d73c1ac6"
|
||||
|
@ -7700,6 +7749,14 @@ schema-utils@^1.0.0:
|
|||
ajv-errors "^1.0.0"
|
||||
ajv-keywords "^3.1.0"
|
||||
|
||||
scroll-to@0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/scroll-to/-/scroll-to-0.0.2.tgz#936d398a9133660a2492145c2c0081dfcb0728f3"
|
||||
integrity sha1-k205ipEzZgokkhRcLACB38sHKPM=
|
||||
dependencies:
|
||||
component-raf "1.2.0"
|
||||
component-tween "1.2.0"
|
||||
|
||||
scss-tokenizer@^0.2.3:
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"
|
||||
|
|
Loading…
Add table
Reference in a new issue