Merge pull request #7136 from tchak/refactor-types-de-champ-editor-ts

refactor(type_de_champs): use typescript in type de champs editor
This commit is contained in:
Paul Chavard 2022-04-08 14:38:23 +02:00 committed by GitHub
commit 98b574b031
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 640 additions and 411 deletions

View file

@ -1,6 +1,14 @@
export default class Flash { import invariant from 'tiny-invariant';
constructor(isAnnotation) {
this.element = document.querySelector('#flash_messages'); export class Flash {
element: HTMLDivElement;
isAnnotation: boolean;
timeout?: number;
constructor(isAnnotation: boolean) {
const element = document.querySelector<HTMLDivElement>('#flash_messages');
invariant(element, 'Flash element is required');
this.element = element;
this.isAnnotation = isAnnotation; this.isAnnotation = isAnnotation;
} }
success() { success() {
@ -10,13 +18,13 @@ export default class Flash {
this.add('Formulaire enregistré.'); this.add('Formulaire enregistré.');
} }
} }
error(message) { error(message: string) {
this.add(message, true); this.add(message, true);
} }
clear() { clear() {
this.element.innerHTML = ''; this.element.innerHTML = '';
} }
add(message, isError) { add(message: string, isError = false) {
const html = `<div id="flash_message" class="center"> const html = `<div id="flash_message" class="center">
<div class="alert alert-fixed ${ <div class="alert alert-fixed ${
isError ? 'alert-danger' : 'alert-success' isError ? 'alert-danger' : 'alert-success'

View file

@ -1,9 +1,21 @@
import { getJSON } from '@utils'; import { getJSON } from '@utils';
import invariant from 'tiny-invariant';
export default class OperationsQueue { type Operation = {
constructor(baseUrl) { path: string;
method: string;
payload: unknown;
resolve: (value: unknown) => void;
reject: () => void;
};
export class OperationsQueue {
queue: Operation[];
isRunning = false;
baseUrl: string;
constructor(baseUrl: string) {
this.queue = []; this.queue = [];
this.isRunning = false;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
} }
@ -11,6 +23,7 @@ export default class OperationsQueue {
if (this.queue.length > 0) { if (this.queue.length > 0) {
this.isRunning = true; this.isRunning = true;
const operation = this.queue.shift(); const operation = this.queue.shift();
invariant(operation, 'Operation is required');
await this.exec(operation); await this.exec(operation);
this.run(); this.run();
} else { } else {
@ -18,7 +31,7 @@ export default class OperationsQueue {
} }
} }
enqueue(operation) { enqueue(operation: Omit<Operation, 'resolve' | 'reject'>) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.queue.push({ ...operation, resolve, reject }); this.queue.push({ ...operation, resolve, reject });
if (!this.isRunning) { if (!this.isRunning) {
@ -27,7 +40,7 @@ export default class OperationsQueue {
}); });
} }
async exec(operation) { async exec(operation: Operation) {
const { path, method, payload, resolve, reject } = operation; const { path, method, payload, resolve, reject } = operation;
const url = `${this.baseUrl}${path}`; const url = `${this.baseUrl}${path}`;
@ -35,12 +48,19 @@ export default class OperationsQueue {
const data = await getJSON(url, payload, method); const data = await getJSON(url, payload, method);
resolve(data); resolve(data);
} catch (e) { } catch (e) {
handleError(e, reject); handleError(e as OperationError, reject);
} }
} }
} }
async function handleError({ response, message }, reject) { class OperationError extends Error {
response?: Response;
}
async function handleError(
{ response, message }: OperationError,
reject: (error: string) => void
) {
if (response) { if (response) {
try { try {
const { const {

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function DescriptionInput({ isVisible, handler }) { import type { Handler } from '../types';
export function DescriptionInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLTextAreaElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -20,10 +27,3 @@ function DescriptionInput({ isVisible, handler }) {
} }
return null; return null;
} }
DescriptionInput.propTypes = {
isVisible: PropTypes.bool,
handler: PropTypes.object
};
export default DescriptionInput;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function LibelleInput({ isVisible, handler }) { import type { Handler } from '../types';
export function LibelleInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell libelle"> <div className="cell libelle">
@ -19,10 +26,3 @@ function LibelleInput({ isVisible, handler }) {
} }
return null; return null;
} }
LibelleInput.propTypes = {
handler: PropTypes.object,
isVisible: PropTypes.bool
};
export default LibelleInput;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function MandatoryInput({ isVisible, handler }) { import type { Handler } from '../types';
export function MandatoryInput({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -19,10 +26,3 @@ function MandatoryInput({ isVisible, handler }) {
} }
return null; return null;
} }
MandatoryInput.propTypes = {
handler: PropTypes.object,
isVisible: PropTypes.bool
};
export default MandatoryInput;

View file

@ -1,8 +1,17 @@
import React from 'react'; import React, { MouseEventHandler } from 'react';
import PropTypes from 'prop-types';
import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/solid'; import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/solid';
function MoveButton({ isEnabled, icon, title, onClick }) { export function MoveButton({
isEnabled,
icon,
title,
onClick
}: {
isEnabled: boolean;
icon: string;
title: string;
onClick: MouseEventHandler<HTMLButtonElement>;
}) {
return ( return (
<button <button
className="button small move" className="button small move"
@ -18,12 +27,3 @@ function MoveButton({ isEnabled, icon, title, onClick }) {
</button> </button>
); );
} }
MoveButton.propTypes = {
isEnabled: PropTypes.bool,
icon: PropTypes.string,
title: PropTypes.string,
onClick: PropTypes.func
};
export default MoveButton;

View file

@ -1,24 +1,40 @@
import React from 'react'; import React, { Dispatch } from 'react';
import PropTypes from 'prop-types'; import { SortableElement, SortableHandle } from 'react-sortable-hoc';
import { sortableElement, sortableHandle } from 'react-sortable-hoc';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { TrashIcon } from '@heroicons/react/outline'; import { TrashIcon } from '@heroicons/react/outline';
import DescriptionInput from './DescriptionInput'; import type { Action, TypeDeChamp, State, Handler } from '../types';
import LibelleInput from './LibelleInput'; import { DescriptionInput } from './DescriptionInput';
import MandatoryInput from './MandatoryInput'; import { LibelleInput } from './LibelleInput';
import MoveButton from './MoveButton'; import { MandatoryInput } from './MandatoryInput';
import TypeDeChampCarteOption from './TypeDeChampCarteOption'; import { MoveButton } from './MoveButton';
import TypeDeChampCarteOptions from './TypeDeChampCarteOptions'; import { TypeDeChampCarteOption } from './TypeDeChampCarteOption';
import TypeDeChampDropDownOptions from './TypeDeChampDropDownOptions'; import { TypeDeChampCarteOptions } from './TypeDeChampCarteOptions';
import TypeDeChampDropDownOther from './TypeDeChampDropDownOther'; import { TypeDeChampDropDownOptions } from './TypeDeChampDropDownOptions';
import TypeDeChampPieceJustificative from './TypeDeChampPieceJustificative'; import { TypeDeChampDropDownOther } from './TypeDeChampDropDownOther';
import TypeDeChampRepetitionOptions from './TypeDeChampRepetitionOptions'; import { TypeDeChampPieceJustificative } from './TypeDeChampPieceJustificative';
import TypeDeChampTypesSelect from './TypeDeChampTypesSelect'; import { TypeDeChampRepetitionOptions } from './TypeDeChampRepetitionOptions';
import TypeDeChampDropDownSecondary from './TypeDeChampDropDownSecondary'; import { TypeDeChampTypesSelect } from './TypeDeChampTypesSelect';
import { TypeDeChampDropDownSecondary } from './TypeDeChampDropDownSecondary';
const TypeDeChamp = sortableElement( type TypeDeChampProps = {
({ typeDeChamp, dispatch, idx: index, isFirstItem, isLastItem, state }) => { typeDeChamp: TypeDeChamp;
dispatch: Dispatch<Action>;
idx: number;
isFirstItem: boolean;
isLastItem: boolean;
state: State;
};
export const TypeDeChampComponent = SortableElement<TypeDeChampProps>(
({
typeDeChamp,
dispatch,
idx: index,
isFirstItem,
isLastItem,
state
}: TypeDeChampProps) => {
const isDropDown = [ const isDropDown = [
'drop_down_list', 'drop_down_list',
'multiple_drop_down_list', 'multiple_drop_down_list',
@ -175,23 +191,25 @@ const TypeDeChamp = sortableElement(
} }
); );
TypeDeChamp.propTypes = { const DragHandle = SortableHandle(() => (
dispatch: PropTypes.func,
idx: PropTypes.number,
isFirstItem: PropTypes.bool,
isLastItem: PropTypes.bool,
state: PropTypes.object,
typeDeChamp: PropTypes.object
};
const DragHandle = sortableHandle(() => (
<div <div
className="handle small icon-only icon move-handle" className="handle small icon-only icon move-handle"
title="Déplacer le champ vers le haut ou vers le bas" title="Déplacer le champ vers le haut ou vers le bas"
/> />
)); ));
function createUpdateHandler(dispatch, typeDeChamp, field, index, prefix) { type HandlerInputElement =
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;
function createUpdateHandler(
dispatch: Dispatch<Action>,
typeDeChamp: TypeDeChamp,
field: keyof TypeDeChamp,
index: number,
prefix?: string
): Handler<HandlerInputElement> {
return { return {
id: `${prefix ? `${prefix}-` : ''}champ-${index}-${field}`, id: `${prefix ? `${prefix}-` : ''}champ-${index}-${field}`,
name: field, name: field,
@ -209,25 +227,22 @@ function createUpdateHandler(dispatch, typeDeChamp, field, index, prefix) {
}; };
} }
function getValue(obj, path) { function createUpdateHandlers(
const [, optionsPath] = path.split('.'); dispatch: Dispatch<Action>,
if (optionsPath) { typeDeChamp: TypeDeChamp,
return (obj.editable_options || {})[optionsPath]; index: number,
} prefix?: string
return obj[path]; ) {
}
function createUpdateHandlers(dispatch, typeDeChamp, index, prefix) {
return FIELDS.reduce((handlers, field) => { return FIELDS.reduce((handlers, field) => {
handlers[field] = createUpdateHandler( handlers[field] = createUpdateHandler(
dispatch, dispatch,
typeDeChamp, typeDeChamp,
field, field as keyof TypeDeChamp,
index, index,
prefix prefix
); );
return handlers; return handlers;
}, {}); }, {} as Record<string, Handler<HandlerInputElement>>);
} }
const OPTIONS_FIELDS = { const OPTIONS_FIELDS = {
@ -242,7 +257,7 @@ const OPTIONS_FIELDS = {
'options.natura_2000': 'Natura 2000', 'options.natura_2000': 'Natura 2000',
'options.zones_humides': 'Zones humides dimportance internationale', 'options.zones_humides': 'Zones humides dimportance internationale',
'options.znieff': 'ZNIEFF' 'options.znieff': 'ZNIEFF'
}; } as const;
export const FIELDS = [ export const FIELDS = [
'description', 'description',
@ -257,10 +272,20 @@ export const FIELDS = [
'drop_down_secondary_libelle', 'drop_down_secondary_libelle',
'drop_down_secondary_description', 'drop_down_secondary_description',
...Object.keys(OPTIONS_FIELDS) ...Object.keys(OPTIONS_FIELDS)
]; ] as const;
function readValue(input) { function getValue(obj: TypeDeChamp, path: string) {
return input.type === 'checkbox' ? input.checked : input.value; const [, optionsPath] = path.split('.');
if (optionsPath) {
return (obj.editable_options || {})[optionsPath];
}
return obj[path as keyof TypeDeChamp] as string;
}
function readValue(input: HandlerInputElement) {
return input.type === 'checkbox' && 'checked' in input
? input.checked
: input.value;
} }
const EXCLUDE_FROM_REPETITION = [ const EXCLUDE_FROM_REPETITION = [
@ -269,5 +294,3 @@ const EXCLUDE_FROM_REPETITION = [
'repetition', 'repetition',
'siret' 'siret'
]; ];
export default TypeDeChamp;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampCarteOption({ label, handler }) { import type { Handler } from '../types';
export function TypeDeChampCarteOption({
label,
handler
}: {
label: string;
handler: Handler<HTMLInputElement>;
}) {
return ( return (
<label htmlFor={handler.id}> <label htmlFor={handler.id}>
<input <input
@ -16,10 +23,3 @@ function TypeDeChampCarteOption({ label, handler }) {
</label> </label>
); );
} }
TypeDeChampCarteOption.propTypes = {
label: PropTypes.string,
handler: PropTypes.object
};
export default TypeDeChampCarteOption;

View file

@ -1,21 +0,0 @@
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.node
};
export default TypeDeChampCarteOptions;

View file

@ -0,0 +1,19 @@
import React, { ReactNode } from 'react';
export function TypeDeChampCarteOptions({
isVisible,
children
}: {
isVisible: boolean;
children: ReactNode;
}) {
if (isVisible) {
return (
<div className="cell">
<label>Utilisation de la cartographie</label>
<div className="carte-options">{children}</div>
</div>
);
}
return null;
}

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampDropDownOptions({ isVisible, handler }) { import type { Handler } from '../types';
export function TypeDeChampDropDownOptions({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLTextAreaElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -21,11 +28,3 @@ function TypeDeChampDropDownOptions({ isVisible, handler }) {
} }
return null; return null;
} }
TypeDeChampDropDownOptions.propTypes = {
isVisible: PropTypes.bool,
value: PropTypes.string,
handler: PropTypes.object
};
export default TypeDeChampDropDownOptions;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampDropDownOther({ isVisible, handler }) { import type { Handler } from '../types';
export function TypeDeChampDropDownOther({
isVisible,
handler
}: {
isVisible: boolean;
handler: Handler<HTMLInputElement>;
}) {
if (isVisible) { if (isVisible) {
return ( return (
<div className="cell"> <div className="cell">
@ -21,10 +28,3 @@ function TypeDeChampDropDownOther({ isVisible, handler }) {
} }
return null; return null;
} }
TypeDeChampDropDownOther.propTypes = {
isVisible: PropTypes.bool,
handler: PropTypes.object
};
export default TypeDeChampDropDownOther;

View file

@ -1,10 +1,15 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
export default function TypeDeChampDropDownSecondary({ import type { Handler } from '../types';
export function TypeDeChampDropDownSecondary({
isVisible, isVisible,
libelleHandler, libelleHandler,
descriptionHandler descriptionHandler
}: {
isVisible: boolean;
libelleHandler: Handler<HTMLInputElement>;
descriptionHandler: Handler<HTMLTextAreaElement>;
}) { }) {
if (isVisible) { if (isVisible) {
return ( return (
@ -33,9 +38,3 @@ export default function TypeDeChampDropDownSecondary({
} }
return null; return null;
} }
TypeDeChampDropDownSecondary.propTypes = {
isVisible: PropTypes.bool,
libelleHandler: PropTypes.object,
descriptionHandler: PropTypes.object
};

View file

@ -1,13 +1,20 @@
import React from 'react'; import React, { ChangeEvent } from 'react';
import PropTypes from 'prop-types';
import Uploader from '../../../shared/activestorage/uploader';
function TypeDeChampPieceJustificative({ import Uploader from '../../../shared/activestorage/uploader';
import type { Handler } from '../types';
export function TypeDeChampPieceJustificative({
isVisible, isVisible,
url, url,
filename, filename,
handler, handler,
directUploadUrl directUploadUrl
}: {
isVisible: boolean;
url?: string;
filename?: string;
handler: Handler<HTMLInputElement>;
directUploadUrl: string;
}) { }) {
if (isVisible) { if (isVisible) {
const hasFile = !!filename; const hasFile = !!filename;
@ -28,15 +35,15 @@ function TypeDeChampPieceJustificative({
return null; return null;
} }
TypeDeChampPieceJustificative.propTypes = { function FileInformation({
isVisible: PropTypes.bool, isVisible,
url: PropTypes.string, url,
filename: PropTypes.string, filename
handler: PropTypes.object, }: {
directUploadUrl: PropTypes.string isVisible: boolean;
}; url?: string;
filename?: string;
function FileInformation({ isVisible, url, filename }) { }) {
if (isVisible) { if (isVisible) {
return ( return (
<> <>
@ -50,32 +57,29 @@ function FileInformation({ isVisible, url, filename }) {
return null; return null;
} }
FileInformation.propTypes = { function onFileChange(
isVisible: PropTypes.bool, handler: Handler<HTMLInputElement>,
url: PropTypes.string, directUploadUrl: string
filename: PropTypes.string ): (event: ChangeEvent<HTMLInputElement>) => void {
};
function onFileChange(handler, directUploadUrl) {
return async ({ target }) => { return async ({ target }) => {
const file = target.files[0]; const file = (target.files ?? [])[0];
if (file) { if (file) {
const signedId = await uploadFile(target, file, directUploadUrl); const signedId = await uploadFile(target, file, directUploadUrl);
handler.onChange({ handler.onChange({
target: { target: { value: signedId }
value: signedId } as ChangeEvent<HTMLInputElement>);
}
});
} }
}; };
} }
function uploadFile(input, file, directUploadUrl) { function uploadFile(
input: HTMLInputElement,
file: File,
directUploadUrl: string
) {
const controller = new Uploader(input, file, directUploadUrl); const controller = new Uploader(input, file, directUploadUrl);
return controller.start().then((signedId) => { return controller.start().then((signedId) => {
input.value = null; input.value = '';
return signedId; return signedId;
}); });
} }
export default TypeDeChampPieceJustificative;

View file

@ -1,15 +1,19 @@
import React, { useReducer } from 'react'; import React, { useReducer } from 'react';
import PropTypes from 'prop-types';
import { PlusIcon } from '@heroicons/react/outline'; import { PlusIcon } from '@heroicons/react/outline';
import { SortableContainer, addChampLabel } from '../utils'; import { SortableContainer, addChampLabel } from '../utils';
import TypeDeChamp from './TypeDeChamp'; import { TypeDeChampComponent } from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer'; import typeDeChampsReducer from '../typeDeChampsReducer';
import type { State, TypeDeChamp } from '../types';
function TypeDeChampRepetitionOptions({ export function TypeDeChampRepetitionOptions({
isVisible, isVisible,
state: parentState, state: parentState,
typeDeChamp typeDeChamp
}: {
isVisible: boolean;
state: State;
typeDeChamp: TypeDeChamp;
}) { }) {
const [state, dispatch] = useReducer(typeDeChampsReducer, parentState); const [state, dispatch] = useReducer(typeDeChampsReducer, parentState);
@ -23,7 +27,7 @@ function TypeDeChampRepetitionOptions({
useDragHandle useDragHandle
> >
{state.typeDeChamps.map((typeDeChamp, index) => ( {state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChamp <TypeDeChampComponent
dispatch={dispatch} dispatch={dispatch}
idx={index} idx={index}
index={index} index={index}
@ -54,11 +58,3 @@ function TypeDeChampRepetitionOptions({
} }
return null; return null;
} }
TypeDeChampRepetitionOptions.propTypes = {
isVisible: PropTypes.bool,
state: PropTypes.object,
typeDeChamp: PropTypes.object
};
export default TypeDeChampRepetitionOptions;

View file

@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
function TypeDeChampTypesSelect({ handler, options }) { import type { Handler } from '../types';
export function TypeDeChampTypesSelect({
handler,
options
}: {
handler: Handler<HTMLSelectElement>;
options: [label: string, type: string][];
}) {
return ( return (
<div className="cell"> <div className="cell">
<select <select
@ -20,10 +27,3 @@ function TypeDeChampTypesSelect({ handler, options }) {
</div> </div>
); );
} }
TypeDeChampTypesSelect.propTypes = {
handler: PropTypes.object,
options: PropTypes.array
};
export default TypeDeChampTypesSelect;

View file

@ -1,12 +1,20 @@
import React, { useReducer } from 'react'; import React, { useReducer } from 'react';
import PropTypes from 'prop-types';
import { PlusIcon, ArrowCircleDownIcon } from '@heroicons/react/outline'; import { PlusIcon, ArrowCircleDownIcon } from '@heroicons/react/outline';
import { SortableContainer, addChampLabel } from '../utils'; import { SortableContainer, addChampLabel } from '../utils';
import TypeDeChamp from './TypeDeChamp'; import { TypeDeChampComponent } from './TypeDeChamp';
import typeDeChampsReducer from '../typeDeChampsReducer'; import typeDeChampsReducer from '../typeDeChampsReducer';
import type { TypeDeChamp, State } from '../types';
function TypeDeChamps({ state: rootState, typeDeChamps }) { type TypeDeChampsProps = {
state: State;
typeDeChamps: TypeDeChamp[];
};
export function TypeDeChamps({
state: rootState,
typeDeChamps
}: TypeDeChampsProps) {
const [state, dispatch] = useReducer(typeDeChampsReducer, { const [state, dispatch] = useReducer(typeDeChampsReducer, {
...rootState, ...rootState,
typeDeChamps typeDeChamps
@ -24,7 +32,7 @@ function TypeDeChamps({ state: rootState, typeDeChamps }) {
useDragHandle useDragHandle
> >
{state.typeDeChamps.map((typeDeChamp, index) => ( {state.typeDeChamps.map((typeDeChamp, index) => (
<TypeDeChamp <TypeDeChampComponent
dispatch={dispatch} dispatch={dispatch}
idx={index} idx={index}
index={index} index={index}
@ -67,10 +75,3 @@ function TypeDeChamps({ state: rootState, typeDeChamps }) {
</div> </div>
); );
} }
TypeDeChamps.propTypes = {
state: PropTypes.object,
typeDeChamps: PropTypes.array
};
export default TypeDeChamps;

View file

@ -1,46 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Flash from './Flash';
import OperationsQueue from './OperationsQueue';
import TypeDeChamps from './components/TypeDeChamps';
class TypesDeChampEditor extends Component {
constructor(props) {
super(props);
const defaultTypeDeChampAttributes = {
type_champ: 'text',
types_de_champ: [],
private: props.isAnnotation,
libelle: `${
props.isAnnotation ? 'Nouvelle annotation' : 'Nouveau champ'
} ${props.typeDeChampsTypes[0][0]}`
};
this.state = {
flash: new Flash(props.isAnnotation),
queue: new OperationsQueue(props.baseUrl),
defaultTypeDeChampAttributes,
typeDeChampsTypes: props.typeDeChampsTypes,
directUploadUrl: props.directUploadUrl,
isAnnotation: props.isAnnotation,
continuerUrl: props.continuerUrl
};
}
render() {
return (
<TypeDeChamps state={this.state} typeDeChamps={this.props.typeDeChamps} />
);
}
}
TypesDeChampEditor.propTypes = {
baseUrl: PropTypes.string,
continuerUrl: PropTypes.string,
directUploadUrl: PropTypes.string,
isAnnotation: PropTypes.bool,
typeDeChamps: PropTypes.array,
typeDeChampsTypes: PropTypes.array
};
export default TypesDeChampEditor;

View file

@ -0,0 +1,54 @@
import React from 'react';
import { Flash } from './Flash';
import { OperationsQueue } from './OperationsQueue';
import { TypeDeChamps } from './components/TypeDeChamps';
import { TypeDeChamp } from './types';
type TypesDeChampEditorProps = {
baseUrl: string;
continuerUrl: string;
directUploadUrl: string;
isAnnotation: boolean;
typeDeChamps: TypeDeChamp[];
typeDeChampsTypes: [label: string, type: string][];
};
export type State = Omit<TypesDeChampEditorProps, 'baseUrl'> & {
flash: Flash;
queue: OperationsQueue;
defaultTypeDeChampAttributes: Pick<
TypeDeChamp,
| 'type_champ'
| 'types_de_champ'
| 'libelle'
| 'private'
| 'parent_id'
| 'mandatory'
>;
prefix?: string;
};
export default function TypesDeChampEditor(props: TypesDeChampEditorProps) {
const defaultTypeDeChampAttributes: Omit<TypeDeChamp, 'id'> = {
type_champ: 'text',
types_de_champ: [],
mandatory: false,
private: props.isAnnotation,
libelle: `${props.isAnnotation ? 'Nouvelle annotation' : 'Nouveau champ'} ${
props.typeDeChampsTypes[0][0]
}`
};
const state: State = {
flash: new Flash(props.isAnnotation),
queue: new OperationsQueue(props.baseUrl),
defaultTypeDeChampAttributes,
typeDeChamps: [],
typeDeChampsTypes: props.typeDeChampsTypes,
directUploadUrl: props.directUploadUrl,
isAnnotation: props.isAnnotation,
continuerUrl: props.continuerUrl
};
return <TypeDeChamps state={state} typeDeChamps={props.typeDeChamps} />;
}

View file

@ -1,51 +0,0 @@
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: { position: 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,71 @@
import type { TypeDeChamp, OperationsQueue } from './types';
export function createTypeDeChampOperation(
typeDeChamp: Omit<TypeDeChamp, 'id'>,
queue: OperationsQueue
) {
return queue
.enqueue({
path: '',
method: 'post',
payload: { type_de_champ: typeDeChamp }
})
.then((data) => {
handleResponseData(typeDeChamp, data as ResponseData);
});
}
export function destroyTypeDeChampOperation(
typeDeChamp: TypeDeChamp,
queue: OperationsQueue
) {
return queue.enqueue({
path: `/${typeDeChamp.id}`,
method: 'delete',
payload: {}
});
}
export function moveTypeDeChampOperation(
typeDeChamp: TypeDeChamp,
index: number,
queue: OperationsQueue
) {
return queue.enqueue({
path: `/${typeDeChamp.id}/move`,
method: 'patch',
payload: { position: index }
});
}
export function updateTypeDeChampOperation(
typeDeChamp: TypeDeChamp,
queue: OperationsQueue
) {
return queue
.enqueue({
path: `/${typeDeChamp.id}`,
method: 'patch',
payload: { type_de_champ: typeDeChamp }
})
.then((data) => {
handleResponseData(typeDeChamp, data as ResponseData);
});
}
type ResponseData = { type_de_champ: Record<string, string> };
function handleResponseData(
typeDeChamp: Partial<TypeDeChamp>,
{ type_de_champ }: ResponseData
) {
for (const field of RESPONSE_FIELDS) {
typeDeChamp[field] = type_de_champ[field];
}
}
const RESPONSE_FIELDS = [
'id',
'piece_justificative_template_filename',
'piece_justificative_template_url'
] as const;

View file

@ -5,36 +5,103 @@ import {
moveTypeDeChampOperation, moveTypeDeChampOperation,
updateTypeDeChampOperation updateTypeDeChampOperation
} from './operations'; } from './operations';
import type { TypeDeChamp, State, Flash, OperationsQueue } from './types';
export default function typeDeChampsReducer(state, { type, params, done }) { type AddNewTypeDeChampAction = {
switch (type) { type: 'addNewTypeDeChamp';
done: () => void;
};
type AddNewRepetitionTypeDeChampAction = {
type: 'addNewRepetitionTypeDeChamp';
params: { typeDeChamp: TypeDeChamp };
done: () => void;
};
type UpdateTypeDeChampAction = {
type: 'updateTypeDeChamp';
params: {
typeDeChamp: TypeDeChamp;
field: keyof TypeDeChamp;
value: string | boolean;
};
done: () => void;
};
type RemoveTypeDeChampAction = {
type: 'removeTypeDeChamp';
params: { typeDeChamp: TypeDeChamp };
};
type MoveTypeDeChampUpAction = {
type: 'moveTypeDeChampUp';
params: { typeDeChamp: TypeDeChamp };
};
type MoveTypeDeChampDownAction = {
type: 'moveTypeDeChampDown';
params: { typeDeChamp: TypeDeChamp };
};
type OnSortTypeDeChampsAction = {
type: 'onSortTypeDeChamps';
params: { oldIndex: number; newIndex: number };
};
type RefreshAction = {
type: 'refresh';
};
export type Action =
| AddNewTypeDeChampAction
| AddNewRepetitionTypeDeChampAction
| UpdateTypeDeChampAction
| RemoveTypeDeChampAction
| MoveTypeDeChampUpAction
| MoveTypeDeChampDownAction
| OnSortTypeDeChampsAction
| RefreshAction;
export default function typeDeChampsReducer(
state: State,
action: Action
): State {
switch (action.type) {
case 'addNewTypeDeChamp': case 'addNewTypeDeChamp':
return addNewTypeDeChamp(state, state.typeDeChamps, done); return addNewTypeDeChamp(state, state.typeDeChamps, action.done);
case 'addNewRepetitionTypeDeChamp': case 'addNewRepetitionTypeDeChamp':
return addNewRepetitionTypeDeChamp( return addNewRepetitionTypeDeChamp(
state, state,
state.typeDeChamps, state.typeDeChamps,
params.typeDeChamp, action.params,
done action.done
); );
case 'updateTypeDeChamp': case 'updateTypeDeChamp':
return updateTypeDeChamp(state, state.typeDeChamps, params, done); return updateTypeDeChamp(
state,
state.typeDeChamps,
action.params,
action.done
);
case 'removeTypeDeChamp': case 'removeTypeDeChamp':
return removeTypeDeChamp(state, state.typeDeChamps, params); return removeTypeDeChamp(state, state.typeDeChamps, action.params);
case 'moveTypeDeChampUp': case 'moveTypeDeChampUp':
return moveTypeDeChampUp(state, state.typeDeChamps, params); return moveTypeDeChampUp(state, state.typeDeChamps, action.params);
case 'moveTypeDeChampDown': case 'moveTypeDeChampDown':
return moveTypeDeChampDown(state, state.typeDeChamps, params); return moveTypeDeChampDown(state, state.typeDeChamps, action.params);
case 'onSortTypeDeChamps': case 'onSortTypeDeChamps':
return onSortTypeDeChamps(state, state.typeDeChamps, params); return onSortTypeDeChamps(state, state.typeDeChamps, action.params);
case 'refresh': case 'refresh':
return { ...state, typeDeChamps: [...state.typeDeChamps] }; return { ...state, typeDeChamps: [...state.typeDeChamps] };
default:
throw new Error(`Unknown action "${type}"`);
} }
} }
function addTypeDeChamp(state, typeDeChamps, insertAfter, done) { function addTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
insertAfter: { index: number; target: HTMLDivElement } | null,
done: () => void
) {
const typeDeChamp = { const typeDeChamp = {
...state.defaultTypeDeChampAttributes ...state.defaultTypeDeChampAttributes
}; };
@ -44,7 +111,7 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
if (insertAfter) { if (insertAfter) {
// Move the champ to the correct position server-side // Move the champ to the correct position server-side
await moveTypeDeChampOperation( await moveTypeDeChampOperation(
typeDeChamp, typeDeChamp as TypeDeChamp,
insertAfter.index, insertAfter.index,
state.queue state.queue
); );
@ -52,7 +119,7 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
state.flash.success(); state.flash.success();
done(); done();
if (insertAfter) { if (insertAfter) {
insertAfter.target.nextElementSibling.scrollIntoView({ insertAfter.target.nextElementSibling?.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'start', block: 'start',
inline: 'nearest' inline: 'nearest'
@ -61,7 +128,10 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
}) })
.catch((message) => state.flash.error(message)); .catch((message) => state.flash.error(message));
let newTypeDeChamps = [...typeDeChamps, typeDeChamp]; let newTypeDeChamps: TypeDeChamp[] = [
...typeDeChamps,
typeDeChamp as TypeDeChamp
];
if (insertAfter) { if (insertAfter) {
// Move the champ to the correct position client-side // Move the champ to the correct position client-side
newTypeDeChamps = arrayMove( newTypeDeChamps = arrayMove(
@ -77,11 +147,20 @@ function addTypeDeChamp(state, typeDeChamps, insertAfter, done) {
}; };
} }
function addNewTypeDeChamp(state, typeDeChamps, done) { function addNewTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
done: () => void
) {
return addTypeDeChamp(state, typeDeChamps, findItemToInsertAfter(), done); return addTypeDeChamp(state, typeDeChamps, findItemToInsertAfter(), done);
} }
function addNewRepetitionTypeDeChamp(state, typeDeChamps, typeDeChamp, done) { function addNewRepetitionTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: AddNewRepetitionTypeDeChampAction['params'],
done: () => void
) {
return addTypeDeChamp( return addTypeDeChamp(
{ {
...state, ...state,
@ -97,10 +176,10 @@ function addNewRepetitionTypeDeChamp(state, typeDeChamps, typeDeChamp, done) {
} }
function updateTypeDeChamp( function updateTypeDeChamp(
state, state: State,
typeDeChamps, typeDeChamps: TypeDeChamp[],
{ typeDeChamp, field, value }, { typeDeChamp, field, value }: UpdateTypeDeChampAction['params'],
done done: () => void
) { ) {
if (field == 'type_champ' && !typeDeChamp.drop_down_list_value) { if (field == 'type_champ' && !typeDeChamp.drop_down_list_value) {
switch (value) { switch (value) {
@ -117,9 +196,9 @@ function updateTypeDeChamp(
if (field.startsWith('options.')) { if (field.startsWith('options.')) {
const [, optionsField] = field.split('.'); const [, optionsField] = field.split('.');
typeDeChamp.editable_options = typeDeChamp.editable_options || {}; typeDeChamp.editable_options = typeDeChamp.editable_options || {};
typeDeChamp.editable_options[optionsField] = value; typeDeChamp.editable_options[optionsField] = value as string;
} else { } else {
typeDeChamp[field] = value; Object.assign(typeDeChamp, { [field]: value });
} }
getUpdateHandler(typeDeChamp, state)(done); getUpdateHandler(typeDeChamp, state)(done);
@ -130,7 +209,11 @@ function updateTypeDeChamp(
}; };
} }
function removeTypeDeChamp(state, typeDeChamps, { typeDeChamp }) { function removeTypeDeChamp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: RemoveTypeDeChampAction['params']
) {
destroyTypeDeChampOperation(typeDeChamp, state.queue) destroyTypeDeChampOperation(typeDeChamp, state.queue)
.then(() => state.flash.success()) .then(() => state.flash.success())
.catch((message) => state.flash.error(message)); .catch((message) => state.flash.error(message));
@ -141,7 +224,11 @@ function removeTypeDeChamp(state, typeDeChamps, { typeDeChamp }) {
}; };
} }
function moveTypeDeChampUp(state, typeDeChamps, { typeDeChamp }) { function moveTypeDeChampUp(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: MoveTypeDeChampUpAction['params']
) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp); const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex - 1; const newIndex = oldIndex - 1;
@ -155,7 +242,11 @@ function moveTypeDeChampUp(state, typeDeChamps, { typeDeChamp }) {
}; };
} }
function moveTypeDeChampDown(state, typeDeChamps, { typeDeChamp }) { function moveTypeDeChampDown(
state: State,
typeDeChamps: TypeDeChamp[],
{ typeDeChamp }: MoveTypeDeChampDownAction['params']
) {
const oldIndex = typeDeChamps.indexOf(typeDeChamp); const oldIndex = typeDeChamps.indexOf(typeDeChamp);
const newIndex = oldIndex + 1; const newIndex = oldIndex + 1;
@ -169,7 +260,11 @@ function moveTypeDeChampDown(state, typeDeChamps, { typeDeChamp }) {
}; };
} }
function onSortTypeDeChamps(state, typeDeChamps, { oldIndex, newIndex }) { function onSortTypeDeChamps(
state: State,
typeDeChamps: TypeDeChamp[],
{ oldIndex, newIndex }: OnSortTypeDeChampsAction['params']
) {
moveTypeDeChampOperation(typeDeChamps[oldIndex], newIndex, state.queue) moveTypeDeChampOperation(typeDeChamps[oldIndex], newIndex, state.queue)
.then(() => state.flash.success()) .then(() => state.flash.success())
.catch((message) => state.flash.error(message)); .catch((message) => state.flash.error(message));
@ -180,24 +275,27 @@ function onSortTypeDeChamps(state, typeDeChamps, { oldIndex, newIndex }) {
}; };
} }
function arrayRemove(array, item) { function arrayRemove<T>(array: T[], item: T) {
array = Array.from(array); array = Array.from(array);
array.splice(array.indexOf(item), 1); array.splice(array.indexOf(item), 1);
return array; return array;
} }
function arrayMove(array, from, to) { function arrayMove<T>(array: T[], from: number, to: number) {
array = Array.from(array); array = Array.from(array);
array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]); array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]);
return array; return array;
} }
const updateHandlers = new WeakMap(); const updateHandlers = new WeakMap();
function getUpdateHandler(typeDeChamp, { queue, flash }) { function getUpdateHandler(
typeDeChamp: TypeDeChamp,
{ queue, flash }: { queue: OperationsQueue; flash: Flash }
) {
let handler = updateHandlers.get(typeDeChamp); let handler = updateHandlers.get(typeDeChamp);
if (!handler) { if (!handler) {
handler = debounce( handler = debounce(
(done) => (done: () => void) =>
updateTypeDeChampOperation(typeDeChamp, queue) updateTypeDeChampOperation(typeDeChamp, queue)
.then(() => { .then(() => {
flash.success(); flash.success();
@ -217,7 +315,7 @@ function findItemToInsertAfter() {
if (target) { if (target) {
return { return {
target, target,
index: parseInt(target.dataset.index) + 1 index: parseInt(target.dataset.index ?? '0') + 1
}; };
} else { } else {
return null; return null;
@ -225,11 +323,12 @@ function findItemToInsertAfter() {
} }
function getLastVisibleTypeDeChamp() { function getLastVisibleTypeDeChamp() {
const typeDeChamps = document.querySelectorAll('[data-in-view]'); const typeDeChamps =
document.querySelectorAll<HTMLDivElement>('[data-in-view]');
const target = typeDeChamps[typeDeChamps.length - 1]; const target = typeDeChamps[typeDeChamps.length - 1];
if (target) { if (target) {
const parentTarget = target.closest('[data-repetition]'); const parentTarget = target.closest<HTMLDivElement>('[data-repetition]');
if (parentTarget) { if (parentTarget) {
return parentTarget; return parentTarget;
} }

View file

@ -0,0 +1,27 @@
import type { ChangeEventHandler } from 'react';
export type { Flash } from './Flash';
export type { OperationsQueue } from './OperationsQueue';
export type { State } from '.';
export { Action } from './typeDeChampsReducer';
export type TypeDeChamp = {
id: string;
libelle: string;
type_champ: string;
private: boolean;
mandatory: boolean;
types_de_champ: TypeDeChamp[];
parent_id?: string;
piece_justificative_template_filename?: string;
piece_justificative_template_url?: string;
drop_down_list_value?: string;
editable_options?: Record<string, string>;
};
export type Handler<Element extends HTMLElement> = {
id: string;
name: string;
value: string;
onChange: ChangeEventHandler<Element>;
};

View file

@ -1,14 +0,0 @@
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

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

View file

@ -18,7 +18,10 @@ export const FAILURE_CONNECTIVITY = 'file-upload-failure-connectivity';
Represent an error during a file upload. Represent an error during a file upload.
*/ */
export default class FileUploadError extends Error { export default class FileUploadError extends Error {
constructor(message, status, code) { status?: number;
code?: string;
constructor(message: string, status: number | undefined, code?: string) {
super(message); super(message);
this.name = 'FileUploadError'; this.name = 'FileUploadError';
@ -27,9 +30,9 @@ export default class FileUploadError extends Error {
// Prevent the constructor stacktrace from being included. // Prevent the constructor stacktrace from being included.
// (it messes up with Sentry issues grouping) // (it messes up with Sentry issues grouping)
if (Error.captureStackTrace) { if ('captureStackTrace' in Error) {
// V8-only // V8-only
Error.captureStackTrace(this, this.constructor); //Error.captureStackTrace(this, this.constructor);
} else { } else {
this.stack = new Error().stack; this.stack = new Error().stack;
} }
@ -40,7 +43,7 @@ export default class FileUploadError extends Error {
See FAILURE_* constants for values. See FAILURE_* constants for values.
*/ */
get failureReason() { get failureReason() {
let isNetworkError = this.code && this.code != ERROR_CODE_READ; const isNetworkError = this.code && this.code != ERROR_CODE_READ;
if (isNetworkError && this.status != 0) { if (isNetworkError && this.status != 0) {
return FAILURE_SERVER; return FAILURE_SERVER;
@ -60,9 +63,9 @@ export default class FileUploadError extends Error {
// 2. Create each kind of error on a different line // 2. Create each kind of error on a different line
// (so that Sentry knows they are different kind of errors, from // (so that Sentry knows they are different kind of errors, from
// the line they were created.) // the line they were created.)
export function errorFromDirectUploadMessage(message) { export function errorFromDirectUploadMessage(message: string) {
let matches = message.match(/ Status: ([0-9]{1,3})/); const matches = message.match(/ Status: ([0-9]{1,3})/);
let status = matches ? parseInt(matches[1], 10) : undefined; const status = matches ? parseInt(matches[1], 10) : undefined;
// prettier-ignore // prettier-ignore
if (message.includes('Error reading')) { if (message.includes('Error reading')) {

View file

@ -15,13 +15,13 @@ const COMPLETE_CLASS = 'direct-upload--complete';
be found. be found.
*/ */
export default class ProgressBar { export default class ProgressBar {
static init(input, id, file) { static init(input: HTMLInputElement, id: string, file: File) {
clearErrors(input); clearErrors(input);
const html = this.render(id, file.name); const html = this.render(id, file.name);
input.insertAdjacentHTML('beforebegin', html); input.insertAdjacentHTML('beforebegin', html);
} }
static start(id) { static start(id: string) {
const element = getDirectUploadElement(id); const element = getDirectUploadElement(id);
if (element) { if (element) {
element.classList.remove(PENDING_CLASS); element.classList.remove(PENDING_CLASS);
@ -29,15 +29,15 @@ export default class ProgressBar {
} }
} }
static progress(id, progress) { static progress(id: string, progress: number) {
const element = getDirectUploadProgressElement(id); const element = getDirectUploadProgressElement(id);
if (element) { if (element) {
element.style.width = `${progress}%`; element.style.width = `${progress}%`;
element.setAttribute('aria-valuenow', progress); element.setAttribute('aria-valuenow', `${progress}`);
} }
} }
static error(id, error) { static error(id: string, error: string) {
const element = getDirectUploadElement(id); const element = getDirectUploadElement(id);
if (element) { if (element) {
element.classList.add(ERROR_CLASS); element.classList.add(ERROR_CLASS);
@ -45,60 +45,63 @@ export default class ProgressBar {
} }
} }
static end(id) { static end(id: string) {
const element = getDirectUploadElement(id); const element = getDirectUploadElement(id);
if (element) { if (element) {
element.classList.add(COMPLETE_CLASS); element.classList.add(COMPLETE_CLASS);
} }
} }
static render(id, filename) { static render(id: string, filename: string) {
return `<div id="direct-upload-${id}" class="direct-upload ${PENDING_CLASS}" data-direct-upload-id="${id}"> return `<div id="direct-upload-${id}" class="direct-upload ${PENDING_CLASS}" data-direct-upload-id="${id}">
<div role="progressbar" aria-valuemin="0" aria-valuemax="100" class="direct-upload__progress" style="width: 0%"></div> <div role="progressbar" aria-valuemin="0" aria-valuemax="100" class="direct-upload__progress" style="width: 0%"></div>
<span class="direct-upload__filename">${filename}</span> <span class="direct-upload__filename">${filename}</span>
</div>`; </div>`;
} }
constructor(input, id, file) { id: string;
this.constructor.init(input, id, file);
constructor(input: HTMLInputElement, id: string, file: File) {
ProgressBar.init(input, id, file);
this.id = id; this.id = id;
} }
start() { start() {
this.constructor.start(this.id); ProgressBar.start(this.id);
} }
progress(progress) { progress(progress: number) {
this.constructor.progress(this.id, progress); ProgressBar.progress(this.id, progress);
} }
error(error) { error(error: string) {
this.constructor.error(this.id, error); ProgressBar.error(this.id, error);
} }
end() { end() {
this.constructor.end(this.id); ProgressBar.end(this.id);
} }
destroy() { destroy() {
const element = getDirectUploadElement(this.id); const element = getDirectUploadElement(this.id);
element?.remove();
}
}
function clearErrors(input: HTMLInputElement) {
const errorElements =
input.parentElement?.querySelectorAll(`.${ERROR_CLASS}`) ?? [];
for (const element of errorElements) {
element.remove(); element.remove();
} }
} }
function clearErrors(input) { function getDirectUploadElement(id: string) {
const errorElements = input.parentElement.querySelectorAll(`.${ERROR_CLASS}`); return document.querySelector<HTMLDivElement>(`#direct-upload-${id}`);
for (let element of errorElements) {
element.remove();
}
} }
function getDirectUploadElement(id) { function getDirectUploadProgressElement(id: string) {
return document.getElementById(`direct-upload-${id}`); return document.querySelector<HTMLDivElement>(
}
function getDirectUploadProgressElement(id) {
return document.querySelector(
`#direct-upload-${id} .direct-upload__progress` `#direct-upload-${id} .direct-upload__progress`
); );
} }

View file

@ -11,9 +11,18 @@ import FileUploadError, {
used to track lifecycle and progress of an upload. used to track lifecycle and progress of an upload.
*/ */
export default class Uploader { export default class Uploader {
constructor(input, file, directUploadUrl, autoAttachUrl) { directUpload: DirectUpload;
progressBar: ProgressBar;
autoAttachUrl?: string;
constructor(
input: HTMLInputElement,
file: File,
directUploadUrl: string,
autoAttachUrl?: string
) {
this.directUpload = new DirectUpload(file, directUploadUrl, this); this.directUpload = new DirectUpload(file, directUploadUrl, this);
this.progressBar = new ProgressBar(input, this.directUpload.id, file); this.progressBar = new ProgressBar(input, this.directUpload.id + '', file);
this.autoAttachUrl = autoAttachUrl; this.autoAttachUrl = autoAttachUrl;
} }
@ -26,10 +35,10 @@ export default class Uploader {
this.progressBar.start(); this.progressBar.start();
try { try {
let blobSignedId = await this._upload(); const blobSignedId = await this._upload();
if (this.autoAttachUrl) { if (this.autoAttachUrl) {
await this._attach(blobSignedId); await this._attach(blobSignedId, this.autoAttachUrl);
// On response, the attachment HTML fragment will replace the progress bar. // On response, the attachment HTML fragment will replace the progress bar.
} else { } else {
this.progressBar.end(); this.progressBar.end();
@ -38,7 +47,7 @@ export default class Uploader {
return blobSignedId; return blobSignedId;
} catch (error) { } catch (error) {
this.progressBar.error(error.message); this.progressBar.error((error as Error).message);
throw error; throw error;
} }
} }
@ -47,11 +56,11 @@ export default class Uploader {
Upload the file using the DirectUpload instance, and return the blob signed_id. Upload the file using the DirectUpload instance, and return the blob signed_id.
Throws a FileUploadError on failure. Throws a FileUploadError on failure.
*/ */
async _upload() { async _upload(): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.directUpload.create((errorMsg, attributes) => { this.directUpload.create((errorMsg, attributes) => {
if (errorMsg) { if (errorMsg) {
let error = errorFromDirectUploadMessage(errorMsg); const error = errorFromDirectUploadMessage(errorMsg.message);
reject(error); reject(error);
} else { } else {
resolve(attributes.signed_id); resolve(attributes.signed_id);
@ -65,9 +74,9 @@ export default class Uploader {
Throws a FileUploadError on failure (containing the first validation Throws a FileUploadError on failure (containing the first validation
error message, if any). error message, if any).
*/ */
async _attach(blobSignedId) { async _attach(blobSignedId: string, autoAttachUrl: string) {
const attachmentRequest = { const attachmentRequest = {
url: this.autoAttachUrl, url: autoAttachUrl,
type: 'PUT', type: 'PUT',
data: `blob_signed_id=${blobSignedId}` data: `blob_signed_id=${blobSignedId}`
}; };
@ -75,23 +84,27 @@ export default class Uploader {
try { try {
await ajax(attachmentRequest); await ajax(attachmentRequest);
} catch (e) { } catch (e) {
let message = e.response && e.response.errors && e.response.errors[0]; const error = e as {
response?: { errors: string[] };
xhr?: XMLHttpRequest;
};
const message = error.response?.errors && error.response.errors[0];
throw new FileUploadError( throw new FileUploadError(
message || 'Error attaching file.', message || 'Error attaching file.',
e.xhr.status, error.xhr?.status,
ERROR_CODE_ATTACH ERROR_CODE_ATTACH
); );
} }
} }
uploadRequestDidProgress(event) { uploadRequestDidProgress(event: ProgressEvent) {
const progress = (event.loaded / event.total) * 100; const progress = (event.loaded / event.total) * 100;
if (progress) { if (progress) {
this.progressBar.progress(progress); this.progressBar.progress(progress);
} }
} }
directUploadWillStoreFileWithXHR(xhr) { directUploadWillStoreFileWithXHR(xhr: XMLHttpRequest) {
xhr.upload.addEventListener('progress', (event) => xhr.upload.addEventListener('progress', (event) =>
this.uploadRequestDidProgress(event) this.uploadRequestDidProgress(event)
); );

View file

@ -35,7 +35,7 @@
"react-intersection-observer": "^8.31.0", "react-intersection-observer": "^8.31.0",
"react-popper": "^2.2.5", "react-popper": "^2.2.5",
"react-query": "^3.34.19", "react-query": "^3.34.19",
"react-sortable-hoc": "^1.11.0", "react-sortable-hoc": "^2.0.0",
"tiny-invariant": "^1.2.0", "tiny-invariant": "^1.2.0",
"trix": "^1.2.3", "trix": "^1.2.3",
"use-debounce": "^5.2.0", "use-debounce": "^5.2.0",
@ -49,6 +49,7 @@
"@types/geojson": "^7946.0.8", "@types/geojson": "^7946.0.8",
"@types/is-hotkey": "^0.1.7", "@types/is-hotkey": "^0.1.7",
"@types/mapbox__mapbox-gl-draw": "^1.2.3", "@types/mapbox__mapbox-gl-draw": "^1.2.3",
"@types/rails__activestorage": "^7.0.1",
"@types/rails__ujs": "^6.0.1", "@types/rails__ujs": "^6.0.1",
"@types/react": "^17.0.43", "@types/react": "^17.0.43",
"@types/react-dom": "^17.0.14", "@types/react-dom": "^17.0.14",

View file

@ -2459,6 +2459,11 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df"
integrity sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ== integrity sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==
"@types/rails__activestorage@^7.0.1":
version "7.0.1"
resolved "https://registry.yarnpkg.com/@types/rails__activestorage/-/rails__activestorage-7.0.1.tgz#7e60320fdb376d34051733e3f8f0df0a8ff24077"
integrity sha512-H8mjwqFNweMfvgg8U+rdsonqjouETdqZxEOfzARiA4ag8jtLHFhjfAgFF6gWMnzVpXgQPrJw1boiknx9rV23Eg==
"@types/rails__ujs@^6.0.1": "@types/rails__ujs@^6.0.1":
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/@types/rails__ujs/-/rails__ujs-6.0.1.tgz#83c5aa1dad88ca869de05a9523eff58041ab307a" resolved "https://registry.yarnpkg.com/@types/rails__ujs/-/rails__ujs-6.0.1.tgz#83c5aa1dad88ca869de05a9523eff58041ab307a"
@ -11294,10 +11299,10 @@ react-query@^3.34.19:
broadcast-channel "^3.4.1" broadcast-channel "^3.4.1"
match-sorter "^6.0.2" match-sorter "^6.0.2"
react-sortable-hoc@^1.11.0: react-sortable-hoc@^2.0.0:
version "1.11.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-1.11.0.tgz#fe4022362bbafc4b836f5104b9676608a40a278f" resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz#f6780d8aa4b922a21f3e754af542f032677078b7"
integrity sha512-v1CDCvdfoR3zLGNp6qsBa4J1BWMEVH25+UKxF/RvQRh+mrB+emqtVHMgZ+WreUiKJoEaiwYoScaueIKhMVBHUg== integrity sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==
dependencies: dependencies:
"@babel/runtime" "^7.2.0" "@babel/runtime" "^7.2.0"
invariant "^2.2.4" invariant "^2.2.4"