Skip to content

Commit

Permalink
feat: implement file registry on filepicker (#1246)
Browse files Browse the repository at this point in the history
* feat: implement file registry on filepicker

* fix: fix file registry key

* fix: delete file when dynamic list item is deleted

* test: fix filepicker tests

* chore: revert unnecessary change

* fix: properly handle dynamic lists deletion

* fix: set input file value after expanding dynamic list items

* test: fix tests

* fix: trim all files from a dynamic list subtree

* fix: refactor form field instance registry

* chore: added `useBooleanExpressionEvaluation` hook

* fix: improve file picker reliability

Related to #1239

* chore: changed filepicker prefix to `files::`

Related to #1239

* fix: ensure the hidden filepicker gets disabled as well

Related to #1239

* chore: adjust filepicker tests

* chore: cleanup formFieldInstanceRegistry tests

Related to #1239

* refactor: Improve typing

* refactor: Improve variable naming

* fix: fix dynamic list expand button label

* fix: prevent collapsed dynamic list items from unmounting

* refactor: Remove unnecessary method, improve types and delete files on hide if events

* test: remove duplicated test

* fix: clear file references to deleted files

Related to #1239

---------

Co-authored-by: Skaiir <serval.core@gmail.com>
  • Loading branch information
vsgoulart and Skaiir authored Sep 9, 2024
1 parent 4397dd6 commit e4ff280
Show file tree
Hide file tree
Showing 21 changed files with 505 additions and 304 deletions.
3 changes: 2 additions & 1 deletion packages/form-js-playground/src/components/PlaygroundRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ export function PlaygroundRoot(config) {

// pipe viewer changes to output data editor
formViewer.on('changed', updateOutputData);
formViewer.on('formFieldInstanceRegistry.changed', updateOutputData);
formViewer.on('formFieldInstance.added', updateOutputData);
formViewer.on('formFieldInstance.removed', updateOutputData);

inputDataEditor.on('changed', (event) => {
try {
Expand Down
5 changes: 4 additions & 1 deletion packages/form-js-viewer/src/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export class Form {
/**
* Submit the form, triggering all field validations.
*
* @returns { { data: Data, errors: Errors } }
* @returns { { data: Data, errors: Errors, files: Map<string, File[]> } }
*/
submit() {
const { properties } = this._getState();
Expand All @@ -168,9 +168,12 @@ export class Form {

const errors = this.validate();

const files = this.get('fileRegistry').getAllFiles();

const result = {
data,
errors,
files,
};

this._emit('submit', result);
Expand Down
60 changes: 37 additions & 23 deletions packages/form-js-viewer/src/core/FormFieldInstanceRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,50 @@ export class FormFieldInstanceRegistry {
eventBus.on('form.clear', () => this.clear());
}

add(instance) {
const { id, expressionContextInfo, valuePath, indexes } = instance;

const instanceId = [id, ...Object.values(indexes || {})].join('_');

if (this._formFieldInstances[instanceId]) {
throw new Error('this form field instance is already registered');
syncInstance(instanceId, formFieldInfo) {
const { hidden, ...restInfo } = formFieldInfo;

const isInstanceExpected = !hidden;
const doesInstanceExist = this._formFieldInstances[instanceId];

if (isInstanceExpected && !doesInstanceExist) {
this._formFieldInstances[instanceId] = {
instanceId,
...restInfo,
};

this._eventBus.fire('formFieldInstance.added', { instanceId });
} else if (!isInstanceExpected && doesInstanceExist) {
delete this._formFieldInstances[instanceId];

this._eventBus.fire('formFieldInstance.removed', { instanceId });
} else if (isInstanceExpected && doesInstanceExist) {
const wasInstanceChaged = Object.keys(restInfo).some((key) => {
return this._formFieldInstances[instanceId][key] !== restInfo[key];
});

if (wasInstanceChaged) {
this._formFieldInstances[instanceId] = {
instanceId,
...restInfo,
};

this._eventBus.fire('formFieldInstance.changed', { instanceId });
}
}

this._formFieldInstances[instanceId] = {
id,
instanceId,
expressionContextInfo,
valuePath,
indexes,
};

this._eventBus.fire('formFieldInstanceRegistry.changed', { instanceId, action: 'added' });

return instanceId;
}

remove(instanceId) {
if (!this._formFieldInstances[instanceId]) {
return;
cleanupInstance(instanceId) {
if (this._formFieldInstances[instanceId]) {
delete this._formFieldInstances[instanceId];
this._eventBus.fire('formFieldInstance.removed', { instanceId });
}
}

delete this._formFieldInstances[instanceId];

this._eventBus.fire('formFieldInstanceRegistry.changed', { instanceId, action: 'removed' });
get(instanceId) {
return this._formFieldInstances[instanceId];
}

getAll() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,17 @@ export class ConditionChecker {
// if we have a hidden repeatable field, and the data structure allows, we clear it directly at the root and stop recursion
if (context.isHidden && isRepeatable) {
context.preventRecursion = true;
this._eventBus.fire('conditionChecker.remove', {
item: { [field.key]: get(workingData, getFilterPath(field, indexes)) },
});
this._cleanlyClearDataAtPath(getFilterPath(field, indexes), workingData);
}

// for simple leaf fields, we always clear
if (context.isHidden && isClosed) {
this._eventBus.fire('conditionChecker.remove', {
item: { [field.key]: get(workingData, getFilterPath(field, indexes)) },
});
this._cleanlyClearDataAtPath(getFilterPath(field, indexes), workingData);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ import { useScrollIntoView } from '../../render/hooks';
import classNames from 'classnames';

export class RepeatRenderManager {
constructor(form, formFields, formFieldRegistry, pathRegistry) {
constructor(form, formFields, formFieldRegistry, pathRegistry, eventBus) {
this._form = form;
/** @type {import('../../render/FormFields').FormFields} */
this._formFields = formFields;
/** @type {import('../../core/FormFieldRegistry').FormFieldRegistry} */
this._formFieldRegistry = formFieldRegistry;
/** @type {import('../../core/PathRegistry').PathRegistry} */
this._pathRegistry = pathRegistry;
/** @type {import('../../core/EventBus').EventBus} */
this._eventBus = eventBus;
this.Repeater = this.Repeater.bind(this);
this.RepeatFooter = this.RepeatFooter.bind(this);
}
Expand Down Expand Up @@ -58,12 +63,14 @@ export class RepeatRenderManager {
const hasChildren = repeaterField.components && repeaterField.components.length > 0;
const showRemove = repeaterField.allowAddRemove && hasChildren;

const displayValues = isCollapsed ? values.slice(0, nonCollapsedItems) : values;
const hiddenValues = isCollapsed ? values.slice(nonCollapsedItems) : [];

/**
* @param {number} index
*/
const onDeleteItem = (index) => {
const updatedValues = values.slice();
updatedValues.splice(index, 1);
const removedItem = updatedValues.splice(index, 1)[0];

this._eventBus.fire('repeatRenderManager.remove', { dataPath, index, item: removedItem });

props.onChange({
field: repeaterField,
Expand All @@ -76,38 +83,24 @@ export class RepeatRenderManager {

return (
<>
{displayValues.map((itemValue, itemIndex) => (
<RepetitionScaffold
key={itemIndex}
itemIndex={itemIndex}
itemValue={itemValue}
parentExpressionContextInfo={parentExpressionContextInfo}
repeaterField={repeaterField}
RowsRenderer={RowsRenderer}
indexes={indexes}
onDeleteItem={onDeleteItem}
showRemove={showRemove}
{...restProps}
/>
))}
{hiddenValues.length > 0 ? (
<div className="fjs-repeat-row-collapsed">
{hiddenValues.map((itemValue, itemIndex) => (
<RepetitionScaffold
key={itemIndex}
itemIndex={itemIndex + nonCollapsedItems}
itemValue={itemValue}
parentExpressionContextInfo={parentExpressionContextInfo}
repeaterField={repeaterField}
RowsRenderer={RowsRenderer}
indexes={indexes}
onDeleteItem={onDeleteItem}
showRemove={showRemove}
{...restProps}
/>
))}
{values.map((itemValue, itemIndex) => (
<div
class={classNames({
'fjs-repeat-row-collapsed': isCollapsed ? itemIndex >= nonCollapsedItems : false,
})}>
<RepetitionScaffold
itemIndex={itemIndex}
itemValue={itemValue}
parentExpressionContextInfo={parentExpressionContextInfo}
repeaterField={repeaterField}
RowsRenderer={RowsRenderer}
indexes={indexes}
onDeleteItem={onDeleteItem}
showRemove={showRemove}
{...restProps}
/>
</div>
) : null}
))}
</>
);
}
Expand Down Expand Up @@ -146,6 +139,8 @@ export class RepeatRenderManager {

shouldScroll.current = true;

this._eventBus.fire('repeatRenderManager.add', { dataPath, index: updatedValues.length - 1, item: newItem });

props.onChange({
value: updatedValues,
});
Expand Down Expand Up @@ -186,7 +181,7 @@ export class RepeatRenderManager {
<button type="button" class="fjs-repeat-render-collapse" onClick={toggle}>
{isCollapsed ? (
<>
<ExpandSvg /> {`Expand all (${values.length})`}
<ExpandSvg /> {`Expand all (${values.length - 1})`}
</>
) : (
<>
Expand Down Expand Up @@ -277,4 +272,4 @@ const RepetitionScaffold = (props) => {
);
};

RepeatRenderManager.$inject = ['form', 'formFields', 'formFieldRegistry', 'pathRegistry'];
RepeatRenderManager.$inject = ['form', 'formFields', 'formFieldRegistry', 'pathRegistry', 'eventBus'];
91 changes: 91 additions & 0 deletions packages/form-js-viewer/src/render/FileRegistry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { extractFileReferencesFromRemovedData } from '../util/extractFileReferencesFromRemovedData';

const fileRegistry = Symbol('fileRegistry');
const eventBusSymbol = Symbol('eventBus');
const formFieldRegistrySymbol = Symbol('formFieldRegistry');
const formFieldInstanceRegistrySymbol = Symbol('formFieldInstanceRegistry');
const EMPTY_ARRAY = [];

class FileRegistry {
/**
* @param {import('../core/EventBus').EventBus} eventBus
* @param {import('../core/FormFieldRegistry').FormFieldRegistry} formFieldRegistry
* @param {import('../core/FormFieldInstanceRegistry').FormFieldInstanceRegistry} formFieldInstanceRegistry
*/
constructor(eventBus, formFieldRegistry, formFieldInstanceRegistry) {
/** @type {Map<string, File[]>} */
this[fileRegistry] = new Map();
/** @type {import('../core/EventBus').EventBus} */
this[eventBusSymbol] = eventBus;
/** @type {import('../core/FormFieldRegistry').FormFieldRegistry} */
this[formFieldRegistrySymbol] = formFieldRegistry;
/** @type {import('../core/FormFieldInstanceRegistry').FormFieldInstanceRegistry} */
this[formFieldInstanceRegistrySymbol] = formFieldInstanceRegistry;

const removeFileHandler = ({ item }) => {
const fileReferences = extractFileReferencesFromRemovedData(item);

// Remove all file references from the registry
fileReferences.forEach((fileReference) => {
this.deleteFiles(fileReference);
});
};

eventBus.on('form.clear', () => this.clear());
eventBus.on('conditionChecker.remove', removeFileHandler);
eventBus.on('repeatRenderManager.remove', removeFileHandler);
}

/**
* @param {string} id
* @param {File[]} files
*/
setFiles(id, files) {
this[fileRegistry].set(id, files);
}

/**
* @param {string} id
* @returns {File[]}
*/
getFiles(id) {
return this[fileRegistry].get(id) || EMPTY_ARRAY;
}

/**
* @returns {string[]}
*/
getKeys() {
return Array.from(this[fileRegistry].keys());
}

/**
* @param {string} id
* @returns {boolean}
*/
hasKey(id) {
return this[fileRegistry].has(id);
}

/**
* @param {string} id
*/
deleteFiles(id) {
this[fileRegistry].delete(id);
}

/**
* @returns {Map<string, File[]>}
*/
getAllFiles() {
return new Map(this[fileRegistry]);
}

clear() {
this[fileRegistry].clear();
}
}

FileRegistry.$inject = ['eventBus', 'formFieldRegistry', 'formFieldInstanceRegistry'];

export { FileRegistry };
36 changes: 23 additions & 13 deletions packages/form-js-viewer/src/render/components/FormField.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks';
import Ids from 'ids';

import { useRef, useCallback, useContext, useEffect, useMemo, useState } from 'preact/hooks';
import isEqual from 'lodash/isEqual';

import { get } from 'min-dash';
Expand All @@ -10,8 +12,11 @@ import { useCondition, useReadonly, useService } from '../hooks';
import { gridColumnClasses, prefixId } from './Util';

const noop = () => false;
const ids = new Ids([32, 36, 1]);

export function FormField(props) {
const instanceIdRef = useRef(ids.next());

const { field, indexes, onChange: _onChange } = props;

const formFields = useService('formFields'),
Expand Down Expand Up @@ -53,26 +58,31 @@ export function FormField(props) {

const hidden = useCondition((field.conditional && field.conditional.hide) || null);

const fieldInstance = useMemo(
() => ({
const instanceId = useMemo(() => {
if (!formFieldInstanceRegistry) {
return null;
}

return formFieldInstanceRegistry.syncInstance(instanceIdRef.current, {
id: field.id,
expressionContextInfo: localExpressionContext,
valuePath,
value,
indexes,
}),
[field.id, valuePath, localExpressionContext, indexes],
);
hidden,
});
}, [formFieldInstanceRegistry, field.id, localExpressionContext, valuePath, value, indexes, hidden]);

const fieldInstance = instanceId ? formFieldInstanceRegistry.get(instanceId) : null;

// register form field instance
// cleanup the instance on unmount
useEffect(() => {
if (formFieldInstanceRegistry && !hidden) {
const instanceId = formFieldInstanceRegistry.add(fieldInstance);
const instanceId = instanceIdRef.current;

return () => {
formFieldInstanceRegistry.remove(instanceId);
};
if (formFieldInstanceRegistry) {
return () => formFieldInstanceRegistry.cleanupInstance(instanceId);
}
}, [fieldInstance, formFieldInstanceRegistry, hidden]);
}, [formFieldInstanceRegistry]);

// ensures the initial validation behavior can be re-triggered upon form reset
useEffect(() => {
Expand Down
Loading

0 comments on commit e4ff280

Please sign in to comment.