From 0aa4d03cbf9c7d451d5433444ce3a19bd68187d4 Mon Sep 17 00:00:00 2001 From: Vinicius Goulart Date: Thu, 12 Dec 2024 14:23:11 +0100 Subject: [PATCH 1/9] feat: implement document preview runtime --- .../EditorDocumentPreview.js | 25 ++ .../components/editor-form-fields/index.js | 10 +- packages/form-js-editor/test/spec/form.json | 2 +- .../test/spec/Playground.spec.js | 25 ++ .../form-js-playground/test/spec/form.json | 6 + .../form-js-viewer/assets/form-js-base.css | 80 ++++- .../src/render/components/Errors.js | 8 + .../components/form-fields/DocumentPreview.js | 300 +++++++++++++++++- .../components/form-fields/icons/Download.svg | 1 + .../form-fields/DocumentPreview.spec.js | 250 +++++++++++++++ 10 files changed, 697 insertions(+), 10 deletions(-) create mode 100644 packages/form-js-editor/src/render/components/editor-form-fields/EditorDocumentPreview.js create mode 100644 packages/form-js-viewer/src/render/components/form-fields/icons/Download.svg create mode 100644 packages/form-js-viewer/test/spec/render/components/form-fields/DocumentPreview.spec.js diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/EditorDocumentPreview.js b/packages/form-js-editor/src/render/components/editor-form-fields/EditorDocumentPreview.js new file mode 100644 index 000000000..52f617a61 --- /dev/null +++ b/packages/form-js-editor/src/render/components/editor-form-fields/EditorDocumentPreview.js @@ -0,0 +1,25 @@ +import { iconsByType, DocumentPreview, Label } from '@bpmn-io/form-js-viewer'; + +import { editorFormFieldClasses } from '../Util'; + +export function EditorDocumentPreview(props) { + const { field, domId } = props; + + const { label } = field; + + const Icon = iconsByType(field.type); + + return ( +
+
+ ); +} + +EditorDocumentPreview.config = DocumentPreview.config; diff --git a/packages/form-js-editor/src/render/components/editor-form-fields/index.js b/packages/form-js-editor/src/render/components/editor-form-fields/index.js index 0077dda85..2c317524f 100644 --- a/packages/form-js-editor/src/render/components/editor-form-fields/index.js +++ b/packages/form-js-editor/src/render/components/editor-form-fields/index.js @@ -3,5 +3,13 @@ import { EditorText } from './EditorText'; import { EditorHtml } from './EditorHtml'; import { EditorTable } from './EditorTable'; import { EditorExpressionField } from './EditorExpressionField'; +import { EditorDocumentPreview } from './EditorDocumentPreview'; -export const editorFormFields = [EditorIFrame, EditorText, EditorHtml, EditorTable, EditorExpressionField]; +export const editorFormFields = [ + EditorIFrame, + EditorText, + EditorHtml, + EditorTable, + EditorExpressionField, + EditorDocumentPreview, +]; diff --git a/packages/form-js-editor/test/spec/form.json b/packages/form-js-editor/test/spec/form.json index 8b70e0f2c..6e56df381 100644 --- a/packages/form-js-editor/test/spec/form.json +++ b/packages/form-js-editor/test/spec/form.json @@ -247,7 +247,7 @@ "accept": ".jpg,.png" }, { - "title": "My documents", + "label": "My documents", "type": "documentPreview", "id": "myDocuments", "dataSource": "=myDocuments", diff --git a/packages/form-js-playground/test/spec/Playground.spec.js b/packages/form-js-playground/test/spec/Playground.spec.js index 45a426811..8f83bd022 100644 --- a/packages/form-js-playground/test/spec/Playground.spec.js +++ b/packages/form-js-playground/test/spec/Playground.spec.js @@ -9,6 +9,7 @@ import { domify, query as domQuery, queryAll as domQueryAll } from 'min-dom'; import { Playground } from '../../src'; import schema from './form.json'; +// import schema from './temp.json'; import otherSchema from './other-form.json'; import rowsSchema from './rows-form.json'; import customSchema from './custom.json'; @@ -82,6 +83,30 @@ describe('playground', function () { tags: ['tag1', 'tag2', 'tag3'], conversation: '2010-06-06T12:00Z', language: 'english', + documents: [ + { + documentId: 'document0', + metadata: { + filename: 'My document.pdf', + mimeType: 'application/pdf', + }, + }, + { + documentId: 'document1', + metadata: { + filename: 'My document.png', + mimeType: 'image/png', + }, + }, + { + documentId: 'document2', + metadata: { + filename: 'My document.zip', + mimeType: 'application/zip', + }, + }, + ], + defaultDocumentsEndpointKey: 'https://pub-280be5f41fe1419e8d236b586696129e.r2.dev/{documentId}', }; // when diff --git a/packages/form-js-playground/test/spec/form.json b/packages/form-js-playground/test/spec/form.json index aaf4b2b61..dc42fe771 100644 --- a/packages/form-js-playground/test/spec/form.json +++ b/packages/form-js-playground/test/spec/form.json @@ -259,6 +259,12 @@ "alt": "The bpmn.io logo", "type": "image" }, + { + "label": "Document preview", + "type": "documentPreview", + "dataSource": "=documents", + "id": "Field_1w82te0" + }, { "label": "Submit", "type": "button" diff --git a/packages/form-js-viewer/assets/form-js-base.css b/packages/form-js-viewer/assets/form-js-base.css index 40791b4b8..b12f3babc 100644 --- a/packages/form-js-viewer/assets/form-js-base.css +++ b/packages/form-js-viewer/assets/form-js-base.css @@ -63,6 +63,7 @@ --color-borders: var(--cds-border-strong, var(--cds-border-strong-01, var(--color-grey-225-10-55))); --color-borders-group: var(--cds-border-subtle, var(--color-grey-225-10-85)); --color-borders-table: var(--color-borders-group); + --color-borders-documentPreview: var(--cds-border-subtle, var(--color-grey-225-10-85)); --color-borders-disabled: var(--cds-border-disabled, var(--color-grey-225-10-75)); --color-borders-adornment: var(--cds-border-subtle, var(--cds-border-subtle-01, var(--color-grey-225-10-85))); --color-borders-readonly: var(--cds-border-subtle, var(--color-grey-225-10-75)); @@ -1023,7 +1024,8 @@ } .fjs-container .fjs-image-placeholder, -.fjs-container .fjs-iframe-placeholder { +.fjs-container .fjs-iframe-placeholder, +.fjs-container .fjs-documentPreview-placeholder { margin: 4px 0; width: 100%; height: 90px; @@ -1033,12 +1035,14 @@ color: var(--color-text-light); } -.fjs-container .fjs-iframe-placeholder { +.fjs-container .fjs-iframe-placeholder, +.fjs-container .fjs-documentPreview-placeholder { border: 1px solid var(--color-borders-readonly); } .fjs-container .fjs-image-placeholder .fjs-image-placeholder-inner, -.fjs-container .fjs-iframe-placeholder .fjs-iframe-placeholder-text { +.fjs-container .fjs-iframe-placeholder .fjs-iframe-placeholder-text, +.fjs-container .fjs-documentPreview-placeholder .fjs-documentPreview-placeholder-text { display: flex; align-items: center; justify-content: center; @@ -1046,7 +1050,8 @@ overflow: hidden; } -.fjs-container .fjs-iframe-placeholder .fjs-iframe-placeholder-text { +.fjs-container .fjs-iframe-placeholder .fjs-iframe-placeholder-text, +.fjs-container .fjs-documentPreview-placeholder .fjs-documentPreview-placeholder-text { font-size: var(--font-size-label); } @@ -1153,6 +1158,73 @@ width: 16px; } +.fjs-container .fjs-documentPreview-document-container { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} + +.fjs-container .fjs-documentPreview-single-document-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid var(--color-borders-documentPreview); + border-radius: 3px; + + .fjs-form-field-error { + align-self: flex-start; + } +} + +.fjs-container .fjs-documentPreview-non-preview-item { + flex-direction: row; +} + +.fjs-container .fjs-documentPreview-single-document-container:not(.fjs-documentPreview-non-preview-item) { + position: relative; + overflow-y: auto; +} + +.fjs-container + .fjs-documentPreview-single-document-container:not(.fjs-documentPreview-non-preview-item) + .fjs-documentPreview-download-button { + position: absolute; + top: 6px; + right: 6px; + z-index: 1; +} + +.fjs-container .fjs-documentPreview-iframe { + all: unset; + width: 100%; + overflow: auto; + min-height: 400px; +} + +.fjs-container .fjs-documentPreview-download-button { + width: 24px; + height: 24px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + border: 1px solid var(--color-borders-documentPreview); + background: var(--color-layer); + padding: 0; +} + +.fjs-container .fjs-documentPreview-non-preview-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + color: var(--color-text-light); +} + .fjs-container .fjs-repeat-row-container { display: flex; flex-direction: row; diff --git a/packages/form-js-viewer/src/render/components/Errors.js b/packages/form-js-viewer/src/render/components/Errors.js index 92a973546..55b61bf06 100644 --- a/packages/form-js-viewer/src/render/components/Errors.js +++ b/packages/form-js-viewer/src/render/components/Errors.js @@ -1,3 +1,11 @@ +/** + * @typedef Props + * @property {string} id + * @property {string[]} errors + * + * @param {Props} props + * @returns {import("preact").JSX.Element} + */ export function Errors(props) { const { errors, id } = props; diff --git a/packages/form-js-viewer/src/render/components/form-fields/DocumentPreview.js b/packages/form-js-viewer/src/render/components/form-fields/DocumentPreview.js index 588bebac4..5301a43ca 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/DocumentPreview.js +++ b/packages/form-js-viewer/src/render/components/form-fields/DocumentPreview.js @@ -1,17 +1,309 @@ +import classNames from 'classnames'; +import { useExpressionEvaluation, useSingleLineTemplateEvaluation } from '../../hooks'; +import { Errors } from '../Errors'; +import { formFieldClasses } from '../Util'; +import { isString } from 'min-dash'; +import DownloadIcon from './icons/Download.svg'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { Label } from '../Label'; + +const type = 'documentPreview'; + /** + * @typedef DocumentMetadata + * @property {string} documentId + * @property {Object} metadata + * @property {string} metadata.mimeType + * @property {string} metadata.filename + * + * @typedef Field + * @property {string} id + * @property {string} [title] + * @property {string} [dataSource] + * @property {string} [endpointKey] + * @property {number} [maxHeight] + * @property {string} [label] + * + * @typedef Props + * @property {Field} field + * @property {string} domId + * + * @param {Props} props * @returns {import("preact").JSX.Element} */ -export function DocumentPreview() { - return null; +export function DocumentPreview(props) { + const { field, domId } = props; + const { dataSource, endpointKey, maxHeight, label } = field; + const errorMessageId = `${domId}-error-message`; + const endpoint = useExpressionEvaluation(endpointKey || ''); + const data = useValidDocumentData(dataSource || ''); + const evaluatedLabel = useSingleLineTemplateEvaluation(label, { debug: true }); + + return ( +
+
+ ); } DocumentPreview.config = { - type: 'documentPreview', + type, keyed: false, group: 'presentation', name: 'Document preview', create: (options = {}) => ({ - title: 'Document preview', + label: 'Document preview', + endpointKey: '=defaultDocumentsEndpointKey', ...options, }), }; + +// helpers ///////////////////////////// + +const DOCUMENT_ID_PLACEHOLDER = '{documentId}'; + +/** + * @typedef GetErrorOptions + * @property {string|undefined} dataSource + * @property {string|undefined} endpointKey + * @property {string|null} endpoint + * + * @param {GetErrorOptions} options + * @returns {string[]} + */ +function getErrors(options) { + const { dataSource, endpointKey, endpoint } = options; + let errors = []; + + if (!isString(dataSource) || dataSource.length < 1) { + errors.push('Data source is not defined.'); + } + + if (!isString(endpointKey) || endpointKey.length < 1) { + errors.push('Endpoint key is not defined.'); + } + + if (!URL.canParse(endpoint)) { + errors.push('Endpoint is not valid.'); + } else if (!isValidDocumentEndpoint(endpoint)) { + errors.push('Endpoint must contain "{documentId}".'); + } + + return errors; +} + +/** + * + * @param {unknown} endpoint + * @returns boolean + */ +function isValidDocumentEndpoint(endpoint) { + return typeof endpoint === 'string' && URL.canParse(endpoint) && endpoint.includes(DOCUMENT_ID_PLACEHOLDER); +} + +/** + * @param {unknown} document + * @returns {metadata is DocumentMetadata} + */ +function isValidDocument(document) { + return ( + typeof document === 'object' && + 'documentId' in document && + 'metadata' in document && + typeof document.metadata === 'object' && + 'mimeType' in document.metadata && + 'filename' in document.metadata + ); +} + +/** + * @param {string} dataSource + * @returns {DocumentMetadata[]} + */ +function useValidDocumentData(dataSource) { + const data = useExpressionEvaluation(dataSource); + + if (!Array.isArray(data)) { + return []; + } + + return data.filter(isValidDocument); +} + +/** + * + * @param {Object} props + * @param {DocumentMetadata} props.documentMetadata + * @param {string} props.endpoint + * @param {string} props.domId + * @param {number|undefined} props.maxHeight + * + * @returns {import("preact").JSX.Element} + */ +function DocumentRenderer(props) { + const { documentMetadata, endpoint, maxHeight, domId } = props; + const { metadata } = documentMetadata; + const [hasError, setHasError] = useState(false); + const ref = useRef(null); + const isInViewport = useInViewport(ref); + const fullUrl = endpoint.replace(DOCUMENT_ID_PLACEHOLDER, documentMetadata.documentId); + const singleDocumentContainerClassName = `fjs-${type}-single-document-container`; + const errorMessageId = `${domId}-error-message`; + const errorMessage = 'Unable to download document'; + + if (metadata.mimeType.toLowerCase().startsWith('image/') && isInViewport) { + return ( +
+ {metadata.filename} + { + setHasError(true); + }} + /> + {hasError ? : null} +
+ ); + } + + if (metadata.mimeType.toLowerCase() === 'application/pdf' && isInViewport) { + return ( +
+