From 15bd637b8a9d944ce7fff16585c177376b394067 Mon Sep 17 00:00:00 2001 From: Piotr Nalepa Date: Fri, 10 May 2019 08:20:07 +0200 Subject: [PATCH 1/2] EZP-30525: Convert Browse Tab as a standalone ReactJS module --- .eslintrc.json | 1 + Resources/encore/ez.config.js | 5 +- Resources/public/scss/modules/udw/_main.scss | 2 + .../public/scss/modules/udw/tabs/_main.scss | 1 + .../scss/modules/udw/tabs/browse/_main.scss | 31 +++ .../_selected.content.scss | 2 + .../bookmark.icon.component.js | 68 ++++++ .../content.image.preview.component.js | 46 ++++ .../content.meta.preview.component.js | 107 +++++++++ .../content.type.icon.component.js | 23 ++ .../select.content.button.component.js | 74 +++++++ .../selected.content.component.js | 93 ++++++++ .../selected.content.item.component.js | 42 ++++ .../tab.content.panel.component.js | 23 ++ src/modules/udw/services/bookmark.service.js | 111 ++++++++++ .../udw/tabs/browse/browse.tab.module.js | 100 +++++++++ .../components/finder/finder.component.js | 208 ++++++++++++++++++ .../finder/finder.load.more.component.js | 23 ++ .../finder/finder.tree.branch.component.js | 97 ++++++++ .../finder/finder.tree.leaf.component.js | 108 +++++++++ src/modules/udw/udw.module.js | 2 +- webpack.common.js | 1 + 22 files changed, 1166 insertions(+), 2 deletions(-) create mode 100644 Resources/public/scss/modules/udw/tabs/_main.scss create mode 100644 Resources/public/scss/modules/udw/tabs/browse/_main.scss create mode 100644 src/modules/udw/common/content-meta-preview/bookmark.icon.component.js create mode 100644 src/modules/udw/common/content-meta-preview/content.image.preview.component.js create mode 100644 src/modules/udw/common/content-meta-preview/content.meta.preview.component.js create mode 100644 src/modules/udw/common/content-type-icon/content.type.icon.component.js create mode 100644 src/modules/udw/common/select-content-button/select.content.button.component.js create mode 100644 src/modules/udw/common/selected-content/selected.content.component.js create mode 100644 src/modules/udw/common/selected-content/selected.content.item.component.js create mode 100644 src/modules/udw/common/tab-content-panel/tab.content.panel.component.js create mode 100644 src/modules/udw/services/bookmark.service.js create mode 100644 src/modules/udw/tabs/browse/browse.tab.module.js create mode 100644 src/modules/udw/tabs/browse/components/finder/finder.component.js create mode 100644 src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js create mode 100644 src/modules/udw/tabs/browse/components/finder/finder.tree.branch.component.js create mode 100644 src/modules/udw/tabs/browse/components/finder/finder.tree.leaf.component.js diff --git a/.eslintrc.json b/.eslintrc.json index c95ac39c..af9958b8 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -34,6 +34,7 @@ "no-extra-boolean-cast": "off", "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", + "react/require-default-props": "warn", "jsx-quotes": ["error", "prefer-double"], "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", diff --git a/Resources/encore/ez.config.js b/Resources/encore/ez.config.js index aaf36ce8..d481d7d2 100644 --- a/Resources/encore/ez.config.js +++ b/Resources/encore/ez.config.js @@ -11,5 +11,8 @@ module.exports = (Encore) => { .addEntry('ezplatform-admin-ui-modules-content-tree-js', [ path.resolve(__dirname, '../../src/modules/content-tree/content.tree.module.js'), ]) - .addEntry('ezplatform-admin-ui-modules-udw-v2-js', [path.resolve(__dirname, '../../src/modules/udw/udw.module.js')]); + .addEntry('ezplatform-admin-ui-modules-udw-v2-js', [path.resolve(__dirname, '../../src/modules/udw/udw.module.js')]) + .addEntry('ezplatform-admin-ui-modules-udw-v2-tab-browse-js', [ + path.resolve(__dirname, '../../src/modules/udw/tabs/browse/browse.tab.module.js'), + ]); }; diff --git a/Resources/public/scss/modules/udw/_main.scss b/Resources/public/scss/modules/udw/_main.scss index e5b3c217..d67a8f36 100644 --- a/Resources/public/scss/modules/udw/_main.scss +++ b/Resources/public/scss/modules/udw/_main.scss @@ -1,3 +1,5 @@ +@import './tabs/main'; + .ez-udw-module { .c-popup__body { padding: calculateRem(16px); diff --git a/Resources/public/scss/modules/udw/tabs/_main.scss b/Resources/public/scss/modules/udw/tabs/_main.scss new file mode 100644 index 00000000..1565706f --- /dev/null +++ b/Resources/public/scss/modules/udw/tabs/_main.scss @@ -0,0 +1 @@ +@import './browse/main'; diff --git a/Resources/public/scss/modules/udw/tabs/browse/_main.scss b/Resources/public/scss/modules/udw/tabs/browse/_main.scss new file mode 100644 index 00000000..12cdcef6 --- /dev/null +++ b/Resources/public/scss/modules/udw/tabs/browse/_main.scss @@ -0,0 +1,31 @@ +$browse-tab: '.ez-browse-tab'; +#{$browse-tab} { + display: grid; + grid-template-areas: + 'finder finder' + 'items actions'; + grid-template-columns: 1fr calculateRem(230px); + grid-gap: calculateRem(16px); + + &--with-preview { + grid-template-areas: + 'finder preview' + 'items actions'; + } + + &__finder { + grid-area: finder; + } + + &__preview { + grid-area: preview; + } + + &__selected-items { + grid-area: items; + } + + &__actions { + grid-area: actions; + } +} diff --git a/Resources/public/scss/modules/universal-discovery/_selected.content.scss b/Resources/public/scss/modules/universal-discovery/_selected.content.scss index e9d284b7..7bdd3540 100644 --- a/Resources/public/scss/modules/universal-discovery/_selected.content.scss +++ b/Resources/public/scss/modules/universal-discovery/_selected.content.scss @@ -7,6 +7,7 @@ display: inline-block; border-radius: calculateRem(4px); background: $ez-ground-base-dark; + border: none; &--any-item-selected { cursor: pointer; @@ -19,6 +20,7 @@ } &__content-names { + display: block; max-width: calculateRem(320px); white-space: nowrap; overflow: hidden; diff --git a/src/modules/udw/common/content-meta-preview/bookmark.icon.component.js b/src/modules/udw/common/content-meta-preview/bookmark.icon.component.js new file mode 100644 index 00000000..0c13a044 --- /dev/null +++ b/src/modules/udw/common/content-meta-preview/bookmark.icon.component.js @@ -0,0 +1,68 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { checkIsBookmarked, addBookmark, removeBookmark } from '../../services/bookmark.service'; +import Icon from '../../../common/icon/icon'; +import LoadingSpinnerComponent from '../../../common/loading-spinner/loading.spinner.component'; +import { showErrorNotification } from '../../../common/services/notification.service'; +import { restInfo } from '../../../common/rest-info/rest.info'; + +const BookmarkIconComponent = ({ locationId }) => { + const [isBookmarked, setIsBookmarked] = useState(false); + const [isCheckingBookmarkedStatus, setIsCheckingBookmarkedStatus] = useState(false); + /** + * Required to change a bookmark state while in a location view of a bookmarked item + * + * @function dispatchBookmarkChangeEvent + */ + const dispatchBookmarkChangeEvent = () => { + const event = new CustomEvent('ez-bookmark-change', { detail: { bookmarked: isBookmarked, locationId } }); + + document.body.dispatchEvent(event); + }; + const toggleBookmark = () => { + setIsCheckingBookmarkedStatus(true); + + const bookmarkAction = isBookmarked ? removeBookmark : addBookmark; + const bookmarkToggled = new Promise((resolve) => bookmarkAction(restInfo, locationId, resolve)); + + bookmarkToggled + .then(() => { + setIsBookmarked(!isBookmarked); + setIsCheckingBookmarkedStatus(false); + }) + .catch(showErrorNotification); + }; + const bookmarkIconId = isBookmarked ? 'bookmark-active' : 'bookmark'; + const action = isBookmarked ? 'remove' : 'add'; + const iconExtraClasses = 'ez-icon--medium ez-icon--secondary'; + const btnAttrs = { + type: 'button', + className: `c-bookmark-icon c-bookmark-icon--${action}`, + onClick: toggleBookmark, + disabled: isCheckingBookmarkedStatus, + }; + + useEffect(dispatchBookmarkChangeEvent, [isBookmarked]); + useEffect(() => { + setIsCheckingBookmarkedStatus(true); + checkIsBookmarked(restInfo, locationId, (isBookmarked) => { + setIsBookmarked(isBookmarked); + setIsCheckingBookmarkedStatus(false); + }); + }, [locationId]); + + const icon = isCheckingBookmarkedStatus ? ( + + ) : ( + + ); + + return ; +}; + +BookmarkIconComponent.propTypes = { + locationId: PropTypes.string.isRequired, + location: PropTypes.object.isRequired, +}; + +export default BookmarkIconComponent; diff --git a/src/modules/udw/common/content-meta-preview/content.image.preview.component.js b/src/modules/udw/common/content-meta-preview/content.image.preview.component.js new file mode 100644 index 00000000..4c460dca --- /dev/null +++ b/src/modules/udw/common/content-meta-preview/content.image.preview.component.js @@ -0,0 +1,46 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import LoadingSpinnerComponent from '../../../common/loading-spinner/loading.spinner.component'; + +const getImageUri = (version) => { + if (!version) { + return ''; + } + + const imageField = version.Fields.field.find((field) => field.fieldTypeIdentifier === 'ezimage'); + + return imageField && imageField.fieldValue ? imageField.fieldValue.uri : ''; +}; + +const ContentImagePreviewComponent = ({ version }) => { + if (!version) { + return ; + } + + const imageUri = getImageUri(version); + const imagePreviewNotAvailableLabel = Translator.trans( + /*@Desc("Content preview is not available")*/ 'content_meta_preview.image_preview_not_available.info', + {}, + 'universal_discovery_widget' + ); + + if (!imageUri.length) { + return {imagePreviewNotAvailableLabel}; + } + + return ; +}; + +ContentImagePreviewComponent.propTypes = { + version: PropTypes.shape({ + Fields: PropTypes.shape({ + field: PropTypes.array.isRequired, + }).isRequired, + }), +}; + +ContentImagePreviewComponent.defaultProps = { + version: null, +}; + +export default ContentImagePreviewComponent; diff --git a/src/modules/udw/common/content-meta-preview/content.meta.preview.component.js b/src/modules/udw/common/content-meta-preview/content.meta.preview.component.js new file mode 100644 index 00000000..0e0850c8 --- /dev/null +++ b/src/modules/udw/common/content-meta-preview/content.meta.preview.component.js @@ -0,0 +1,107 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ContentImagePreviewComponent from './content.image.preview.component'; +import BookmarkIconComponent from './bookmark.icon.component'; +import ContentTypeIconComponent from '../content-type-icon/content.type.icon.component'; + +const TEXT_LAST_MODIFIED = Translator.trans( + /*@Desc("Last modified")*/ 'content_meta_preview.last_modified.label', + {}, + 'universal_discovery_widget' +); +const TEXT_CREATION_DATE = Translator.trans( + /*@Desc("Creation date")*/ 'content_meta_preview.creation_date.label', + {}, + 'universal_discovery_widget' +); +const TEXT_TRANSLATIONS = Translator.trans( + /*@Desc("Translations")*/ 'content_meta_preview.translations.label', + {}, + 'universal_discovery_widget' +); +const TEXT_CONTAINER_TITLE = Translator.trans( + /*@Desc("Content Meta Preview")*/ 'content_meta_preview.title', + {}, + 'universal_discovery_widget' +); +const getTranslations = (location) => { + if (!location || !location.CurrentVersion) { + return []; + } + + const languages = window.eZ.adminUiConfig.languages; + const version = location.CurrentVersion.Version; + const versionLanguages = version.VersionInfo.VersionTranslationInfo.Language; + + return versionLanguages.map((language) => languages.mappings[language.languageCode].name); +}; + +const ContentMetaPreviewComponent = ({ location, maxHeight, isVisible }) => { + if (!isVisible) { + return null; + } + + const renderTranslation = (translation, index) => { + return ( + + {translation} + + ); + }; + const content = location.ContentInfo.Content; + let contentTypeIdentifier = null; + let contentTypeName = ''; + + if (content.ContentTypeInfo) { + contentTypeIdentifier = content.ContentTypeInfo.identifier; + contentTypeName = window.eZ.adminUiConfig.contentTypeNames[contentTypeIdentifier]; + } + + const { formatShortDateTime } = window.eZ.helpers.timezone; + const translations = getTranslations(location); + const version = location.CurrentVersion ? location.CurrentVersion.Version : null; + + return ( +
+

{TEXT_CONTAINER_TITLE}

+
+
+
+ {contentTypeName} +
+ +
+
+
+ +
+
{content.Name}
+
+

{TEXT_LAST_MODIFIED}:

+ {formatShortDateTime(new Date(content.lastModificationDate))} +
+
+

{TEXT_CREATION_DATE}:

+ {formatShortDateTime(new Date(content.publishedDate))} +
+
+

{TEXT_TRANSLATIONS}:

+ {translations.map(renderTranslation)} +
+
+
+
+ ); +}; + +ContentMetaPreviewComponent.propTypes = { + location: PropTypes.object.isRequired, + maxHeight: PropTypes.number.isRequired, + isVisible: PropTypes.bool, +}; + +ContentMetaPreviewComponent.defaultProps = { + isVisible: false, +}; + +export default ContentMetaPreviewComponent; diff --git a/src/modules/udw/common/content-type-icon/content.type.icon.component.js b/src/modules/udw/common/content-type-icon/content.type.icon.component.js new file mode 100644 index 00000000..90440d9c --- /dev/null +++ b/src/modules/udw/common/content-type-icon/content.type.icon.component.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from '../../../common/icon/icon'; + +const ContentTypeIconComponent = ({ identifier }) => { + if (!identifier) { + return null; + } + + const contentTypeIconUrl = eZ.helpers.contentType.getContentTypeIconUrl(identifier); + + return ; +}; + +ContentTypeIconComponent.propTypes = { + identifier: PropTypes.string, +}; + +ContentTypeIconComponent.defaultProps = { + identifier: null, +}; + +export default ContentTypeIconComponent; diff --git a/src/modules/udw/common/select-content-button/select.content.button.component.js b/src/modules/udw/common/select-content-button/select.content.button.component.js new file mode 100644 index 00000000..1bcfc6ac --- /dev/null +++ b/src/modules/udw/common/select-content-button/select.content.button.component.js @@ -0,0 +1,74 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import Icon from '../../../common/icon/icon'; +import { createCssClassNames } from '../../../common/css-class-names/css.class.names'; + +const SelectContentButtonComponent = ({ checkCanSelectContent, location, onSelect, onDeselect, isSelected }) => { + const [isSelectContentEnabled, setIsSelectContentEnabled] = useState(true); + const handleSelect = useCallback( + (event) => { + event.stopPropagation(); + + onSelect(location); + }, + [location, onSelect] + ); + const handleDeselect = useCallback( + (event) => { + event.stopPropagation(); + + onDeselect(location.id); + }, + [location, onDeselect] + ); + const toggleEnabledState = useCallback( + (selectContentEnabled) => { + if (isSelectContentEnabled === selectContentEnabled) { + return; + } + + setIsSelectContentEnabled(selectContentEnabled); + }, + [isSelectContentEnabled] + ); + + useEffect(() => { + checkCanSelectContent(location, toggleEnabledState); + }, [checkCanSelectContent, location, toggleEnabledState]); + + const iconId = isSelected ? 'checkmark' : 'create'; + const attrs = { + type: 'button', + className: createCssClassNames({ + 'c-select-content-button': true, + 'c-select-content-button--selected': isSelected, + }), + onClick: isSelected ? handleDeselect : handleSelect, + }; + + if (!isSelected && !isSelectContentEnabled) { + return null; + } + + return ( + + ); +}; + +SelectContentButtonComponent.propTypes = { + checkCanSelectContent: PropTypes.func.isRequired, + location: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + onSelect: PropTypes.func.isRequired, + onDeselect: PropTypes.func.isRequired, + isSelected: PropTypes.bool, +}; + +SelectContentButtonComponent.defaultProps = { + isSelected: false, +}; + +export default SelectContentButtonComponent; diff --git a/src/modules/udw/common/selected-content/selected.content.component.js b/src/modules/udw/common/selected-content/selected.content.component.js new file mode 100644 index 00000000..49c9cef8 --- /dev/null +++ b/src/modules/udw/common/selected-content/selected.content.component.js @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import SelectedContentItemComponent from './selected.content.item.component'; +import PopupComponent from '../../../common/tooltip-popup/tooltip.popup.component'; +import { createCssClassNames } from '../../../common/css-class-names/css.class.names'; + +const noConfirmedContentTitle = Translator.trans( + /*@Desc("No confirmed content yet")*/ 'select_content.no_confirmed_content.title', + {}, + 'universal_discovery_widget' +); +const getTitle = (total) => { + let title = Translator.trans(/*@Desc("Confirmed items")*/ 'select_content.confirmed_items.title', {}, 'universal_discovery_widget'); + + if (total) { + title = `${title} (${total})`; + } + + return title; +}; +const renderLimitLabel = (itemsLimit) => { + let limitLabel = ''; + + if (!!itemsLimit) { + const limitLabelText = Translator.trans( + /*@Desc("Limit %items% max")*/ 'select_content.limit.label', + { + items: itemsLimit, + }, + 'universal_discovery_widget' + ); + + limitLabel = {limitLabelText}; + } + + return limitLabel; +}; + +const SelectedContentComponent = ({ items, itemsLimit, onItemRemove }) => { + const [isPopupVisible, setIsPopupVisible] = useState(false); + const itemsCount = items.length; + const togglePopup = () => setIsPopupVisible(!isPopupVisible && !!itemsCount); + const hidePopup = () => setIsPopupVisible(false); + const renderSelectedItem = (item) => { + const contentTypeInfo = item.ContentInfo.Content.ContentTypeInfo; + const attrs = { + key: item.remoteId, + contentName: item.ContentInfo.Content.Name, + locationId: item.id, + contentTypeIdentifie: contentTypeInfo && contentTypeInfo.identifier, + contentTypeName: contentTypeInfo && contentTypeInfo.names.value[0]['#text'], + onRemove: onItemRemove, + }; + return ; + }; + const renderSelectedItems = () => { + if (!itemsCount) { + return null; + } + + return ( +
+ + {items.map(renderSelectedItem)} + +
+ ); + }; + const titles = items.map((item) => item.ContentInfo.Content.Name).join(', '); + const btnClassNames = createCssClassNames({ + 'c-selected-content__info': true, + 'c-selected-content__info--any-item-selected': !!itemsCount, + }); + + return ( +
+ {renderSelectedItems()} + +
+ ); +}; + +SelectedContentComponent.propTypes = { + items: PropTypes.array.isRequired, + itemsLimit: PropTypes.number.isRequired, + onItemRemove: PropTypes.func.isRequired, +}; + +export default SelectedContentComponent; diff --git a/src/modules/udw/common/selected-content/selected.content.item.component.js b/src/modules/udw/common/selected-content/selected.content.item.component.js new file mode 100644 index 00000000..14d98833 --- /dev/null +++ b/src/modules/udw/common/selected-content/selected.content.item.component.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from '../../../common/icon/icon'; +import ContentTypeIconComponent from '../content-type-icon/content.type.icon.component'; + +const notAvailableLabel = Translator.trans(/*@Desc("N/A")*/ 'select_content.not_available.label', {}, 'universal_discovery_widget'); +const SelectedContentItemComponent = ({ contentName, locationId, contentTypeIdentifier, contentTypeName, onRemove }) => { + let icon = null; + + if (contentTypeIdentifier) { + icon = ; + } + + return ( +
+
+
{contentName}
+
+ {icon} {contentTypeName} +
+
+ +
+ ); +}; + +SelectedContentItemComponent.propTypes = { + contentName: PropTypes.string.isRequired, + locationId: PropTypes.string.isRequired, + onRemove: PropTypes.func.isRequired, + contentTypeName: PropTypes.string, + contentTypeIdentifier: PropTypes.string, +}; + +SelectedContentItemComponent.defaultProps = { + contentTypeIdentifier: null, + contentTypeName: notAvailableLabel, +}; + +export default SelectedContentItemComponent; diff --git a/src/modules/udw/common/tab-content-panel/tab.content.panel.component.js b/src/modules/udw/common/tab-content-panel/tab.content.panel.component.js new file mode 100644 index 00000000..a8a93405 --- /dev/null +++ b/src/modules/udw/common/tab-content-panel/tab.content.panel.component.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const TabContentPanelComponent = ({ id, isVisible, children }) => { + const attrs = { + id, + className: 'c-tab-content-panel', + }; + + if (!isVisible) { + attrs.hidden = true; + } + + return
{children}
; +}; + +TabContentPanelComponent.propTypes = { + id: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, +}; + +export default TabContentPanelComponent; diff --git a/src/modules/udw/services/bookmark.service.js b/src/modules/udw/services/bookmark.service.js new file mode 100644 index 00000000..f0181b20 --- /dev/null +++ b/src/modules/udw/services/bookmark.service.js @@ -0,0 +1,111 @@ +import { showErrorNotification } from '../../common/services/notification.service'; +import { + getBasicRequestInit, + handleRequestResponse, + handleRequestError, + handleRequestResponseStatus, +} from '../../common/helpers/request.helper.js'; + +const ENDPOINT_BOOKMARK = '/api/ezp/v2/bookmark'; + +/** + * Loads bookmarks + * + * @function loadBookmarks + * @param {Object} restInfo REST config hash containing: token and siteaccess properties + * @param {Function} callback + */ +export const loadBookmarks = (restInfo, limit, offset, callback) => { + const basicRequestInit = getBasicRequestInit(restInfo); + const request = new Request(`${ENDPOINT_BOOKMARK}?limit=${limit}&offset=${offset}`, { + ...basicRequestInit, + method: 'GET', + headers: { + ...basicRequestInit.headers, + Accept: 'application/vnd.ez.api.ContentTypeInfoList+json', + }, + }); + + fetch(request) + .then(handleRequestResponse) + .then(callback) + .catch(showErrorNotification); +}; + +/** + * Adds bookmark + * + * @function addBookmark + * @param {Object} restInfo REST config hash containing: token and siteaccess properties + * @param {String} locationId location id + * @param {Function} callback + */ +export const addBookmark = (restInfo, locationId, callback) => { + const basicRequestInit = getBasicRequestInit(restInfo); + const request = new Request(`${ENDPOINT_BOOKMARK}/${locationId}`, { + ...basicRequestInit, + method: 'POST', + }); + + fetch(request) + .then(handleRequestResponseStatus) + .then(callback) + .catch(showErrorNotification); +}; + +/** + * Removes bookmark + * + * @function removeBookmark + * @param {Object} restInfo REST config hash containing: token and siteaccess properties + * @param {String} locationId location id + * @param {Function} callback + */ +export const removeBookmark = (restInfo, locationId, callback) => { + const basicRequestInit = getBasicRequestInit(restInfo); + const request = new Request(`${ENDPOINT_BOOKMARK}/${locationId}`, { + ...basicRequestInit, + method: 'DELETE', + }); + + fetch(request) + .then(handleRequestResponseStatus) + .then(callback) + .catch(showErrorNotification); +}; + +/** + * Checks if given location is bookmarked + * + * @function checkIsBookmarked + * @param {Object} restInfo REST config hash containing: token and siteaccess properties + * @param {String} locationId location id + * @param {Function} callback + */ +export const checkIsBookmarked = (restInfo, locationId, callback) => { + const basicRequestInit = getBasicRequestInit(restInfo); + const request = new Request(`${ENDPOINT_BOOKMARK}/${locationId}`, { + ...basicRequestInit, + method: 'HEAD', + }); + /** @TODO 10.05.2019 + * fix it to receive 200 from backend always + * the information about whether content item is bookmarked + * should come from { bookmarked: true|false } + */ + const bookmarkedStatusCode = 200; + const notBookmarkedStatusCode = 404; + + fetch(request) + .then((response) => { + const { status } = response; + + if (status === bookmarkedStatusCode || status === notBookmarkedStatusCode) { + return status === bookmarkedStatusCode; + } + + return handleRequestError(response); + }) + .then(callback) + .catch(showErrorNotification); +}; diff --git a/src/modules/udw/tabs/browse/browse.tab.module.js b/src/modules/udw/tabs/browse/browse.tab.module.js new file mode 100644 index 00000000..cfa45149 --- /dev/null +++ b/src/modules/udw/tabs/browse/browse.tab.module.js @@ -0,0 +1,100 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TabContentPanelComponent from '../../common/tab-content-panel/tab.content.panel.component'; +import FinderComponent from './components/finder/finder.component'; +import ContentMetaPreviewComponent from '../../common/content-meta-preview/content.meta.preview.component'; +import SelectedContentComponent from '../../common/selected-content/selected.content.component'; +import { createCssClassNames } from '../../../common/css-class-names/css.class.names'; +import BaseTabComponent from '../base.tab.component'; + +const UDWBrowseTab = ({ selectedItemsLimit, onCancel, onConfirm, ...props }) => { + const renderTab = (parentProps) => { + const { + showContentMetaPreview, + contentMeta, + selectedContent, + markContentAsSelected, + unmarkContentAsSelected, + onItemMarked, + } = parentProps; + const confirmSelection = () => onConfirm(selectedContent); + const previewAttrs = { + isVisible: showContentMetaPreview && !!contentMeta, + location: contentMeta, + }; + const finderAttrs = { + selectedContent, + onItemMarked, + onItemSelect: markContentAsSelected, + onItemDeselect: unmarkContentAsSelected, + ...props, + }; + const selectedContentAttrs = { + items: selectedContent, + selectedItemsLimit, + onItemRemove: unmarkContentAsSelected, + }; + const tabAttrs = { + className: createCssClassNames({ + 'ez-browse-tab': true, + 'ez-browse-tab--with-preview': !!contentMeta, + }), + }; + const confirmBtnAttrs = { + className: 'ez-browse-tab__action', + disabled: !selectedContent.length, + onClick: confirmSelection, + type: 'button', + }; + const cancelBtnAttrs = { + className: 'ez-browse-tab__action', + type: 'button', + onClick: onCancel, + }; + + return ( +
+
+ + + +
+
+ +
+
+ +
+
+ + +
+
+ ); + }; + + return {renderTab}; +}; + +UDWBrowseTab.propTypes = { + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + maxHeight: PropTypes.number.isRequired, + multiple: PropTypes.bool, + selectedItemsLimit: PropTypes.number, + startingLocationId: PropTypes.number, + allowContainersOnly: PropTypes.bool, + checkCanSelectContent: PropTypes.func, +}; + +UDWBrowseTab.defaultProps = { + multiple: false, + startingLocationId: 1, + selectedItemsLimit: 0, + allowContainersOnly: false, + checkCanSelectContent: (item, callback) => callback(true), +}; + +eZ.addConfig('udwTabs.Browse', UDWBrowseTab); + +export default UDWBrowseTab; diff --git a/src/modules/udw/tabs/browse/components/finder/finder.component.js b/src/modules/udw/tabs/browse/components/finder/finder.component.js new file mode 100644 index 00000000..fa9af9b4 --- /dev/null +++ b/src/modules/udw/tabs/browse/components/finder/finder.component.js @@ -0,0 +1,208 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import FinderTreeBranchComponent from './finder.tree.branch.component'; +import deepClone from '../../../../../common/helpers/deep.clone.helper'; +import { QUERY_LIMIT, findLocationsByParentLocationId } from '../../../../services/universal.discovery.service'; +import { restInfo } from '../../../../../common/rest-info/rest.info'; + +const ROOT_LOCATION_OBJECT = null; + +const FinderComponent = (props) => { + const { startingLocationId, allowedLocations, maxHeight, allowContainersOnly, multiple, selectedContent } = props; + const { onItemSelect, checkCanSelectContent, onItemDeselect, onItemMarked } = props; + const [locationsMap, setLocationsMap] = useState({}); + const [activeLocations, setActiveLocations] = useState([]); + const refBranchesContainer = useRef(); + const setDefaultActiveLocations = () => setActiveLocations([ROOT_LOCATION_OBJECT]); + const setLocationData = useCallback( + (locationData) => { + setLocationsMap((prevLocationsMap) => { + const { location } = locationData; + const locationId = location ? location.id : startingLocationId; + const locationsMap = { ...deepClone(prevLocationsMap), [locationId]: locationData }; + + return locationsMap; + }); + }, + [startingLocationId] + ); + const updateBranchesContainerScroll = () => { + const container = refBranchesContainer.current; + + if (container) { + container.scrollLeft = container.scrollWidth - container.clientWidth; + } + }; + const onLoadMore = (parentLocation) => { + const parentLocationId = parentLocation ? parentLocation.id : startingLocationId; + const offset = locationsMap[parentLocationId].offset + QUERY_LIMIT; + const sortClauses = parentLocation ? getLocationSortClauses(parentLocation) : {}; + + findLocationsByParentLocationId({ ...restInfo, parentLocationId, QUERY_LIMIT, offset, sortClauses }, appendMoreItems); + }; + const appendMoreItems = ({ parentLocationId, offset, data }) => { + setLocationsMap((prevLocationsMap) => { + const locationsMap = deepClone(prevLocationsMap); + const locationData = locationsMap[parentLocationId]; + + locationData.offset = offset; + locationData.data = [...locationData.data, ...data.View.Result.searchHits.searchHit]; + + return locationsMap; + }); + }; + const loadBranchLeaves = (parentLocation) => { + const sortClauses = getLocationSortClauses(parentLocation); + const promise = new Promise((resolve) => + findLocationsByParentLocationId( + { + ...restInfo, + parentLocationId: parentLocation ? parentLocation.id : startingLocationId, + sortClauses, + }, + resolve + ) + ); + + promise.then((response) => updateLocationsData(response, parentLocation)); + }; + const updateLocationsData = useCallback( + ({ data, offset }, location = null) => { + setLocationData({ + location, + data: data.View.Result.searchHits.searchHit, + count: data.View.Result.count, + offset, + }); + }, + [setLocationData] + ); + const getLocationSortClauses = (location) => { + const sortField = window.eZ.adminUiConfig.sortFieldMappings[location.sortField]; + const sortOrder = window.eZ.adminUiConfig.sortOrderMappings[location.sortOrder]; + + if (!sortField || !sortOrder) { + return {}; + } + + return { [sortField]: sortOrder }; + }; + const findLocationChildren = (location) => { + if (allowedLocations.length === 1) { + return; + } + + onItemMarked(location); + updateSelectedBranches(location); + + if (!location.childCount) { + setLocationData({ location, data: [], count: 0, offset: 0 }); + + return; + } + + const promise = new Promise((resolve) => + findLocationsByParentLocationId( + { + ...restInfo, + parentLocationId: location.id, + sortClauses: getLocationSortClauses(location), + }, + resolve + ) + ); + + promise.then((response) => updateLocationsData(response, location)); + }; + const updateSelectedBranches = (location) => { + setActiveLocations((prevActiveLocations) => { + const locationDepth = parseInt(location.depth, 10); + const activeLocations = prevActiveLocations.slice(0, locationDepth); + + activeLocations[locationDepth] = location; + + return activeLocations; + }); + }; + const renderBranch = (locationData, branchActiveLocationId, isBranchActiveLocationLoading) => { + if (!locationData || !locationData.count) { + return null; + } + + const { data: childrenData, count, location } = locationData; + const locationId = location ? location.id : startingLocationId; + + return ( + + ); + }; + + useEffect(() => { + setDefaultActiveLocations(); + findLocationsByParentLocationId({ ...restInfo, parentLocationId: startingLocationId }, updateLocationsData); + }, [startingLocationId, updateLocationsData]); + + useEffect(() => { + updateBranchesContainerScroll(); + }); + + if (!activeLocations.length) { + return null; + } + + return ( +
+
+ {activeLocations.map((location, index) => { + const locationId = location ? location.id : startingLocationId; + const branchActiveLocation = activeLocations[index + 1]; + const branchActiveLocationId = branchActiveLocation ? branchActiveLocation.id : null; + const isBranchActiveLocationLoading = branchActiveLocationId && !locationsMap[branchActiveLocationId]; + const locationData = locationsMap[locationId]; + + return renderBranch(locationData, branchActiveLocationId, isBranchActiveLocationLoading); + })} +
+
+ ); +}; + +FinderComponent.propTypes = { + multiple: PropTypes.bool.isRequired, + maxHeight: PropTypes.number.isRequired, + onItemSelect: PropTypes.func.isRequired, + startingLocationId: PropTypes.number.isRequired, + allowContainersOnly: PropTypes.bool, + allowedLocations: PropTypes.array, + selectedContent: PropTypes.array.isRequired, + onItemMarked: PropTypes.func.isRequired, + checkCanSelectContent: PropTypes.func.isRequired, + onItemDeselect: PropTypes.func.isRequired, +}; + +FinderComponent.defaultProps = { + allowedLocations: [], + allowContainersOnly: false, + isVisible: true, +}; + +export default FinderComponent; diff --git a/src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js b/src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js new file mode 100644 index 00000000..6cce937e --- /dev/null +++ b/src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const FinderLoadMoreComponent = ({ isVisible, onClick }) => { + if (!isVisible) { + return null; + } + + const loadMoreLabel = Translator.trans(/*@Desc("Load more")*/ 'finder.branch.load_more.label', {}, 'universal_discovery_widget'); + + return ( + + ); +}; + +FinderLoadMoreComponent.propTypes = { + isVisible: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default FinderLoadMoreComponent; diff --git a/src/modules/udw/tabs/browse/components/finder/finder.tree.branch.component.js b/src/modules/udw/tabs/browse/components/finder/finder.tree.branch.component.js new file mode 100644 index 00000000..d896ad01 --- /dev/null +++ b/src/modules/udw/tabs/browse/components/finder/finder.tree.branch.component.js @@ -0,0 +1,97 @@ +import React, { useContext, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { ContentTypesContext } from '../../../../udw.module'; +import FinderTreeLeafComponent from './finder.tree.leaf.component'; +import { createCssClassNames } from '../../../../../common/css-class-names/css.class.names'; + +const TEXT_LOAD_MORE = Translator.trans(/*@Desc("Load more")*/ 'finder.branch.load_more.label', {}, 'universal_discovery_widget'); + +const FinderTreeBranchComponent = (props) => { + const { activeLocationId, isActiveLocationLoading, allowContainersOnly, allowedLocations } = props; + const { multiple, selectedContent, checkCanSelectContent, parentLocation, items, total } = props; + const { onItemSelect, onLoadMore, onItemDeselect, onBranchClick, onItemClick } = props; + const contentTypesMap = useContext(ContentTypesContext); + const expandBranch = useCallback(() => onBranchClick(parentLocation), [onBranchClick, parentLocation]); + const handleLoadMore = useCallback(() => onLoadMore(parentLocation), [onLoadMore, parentLocation]); + const renderLeaf = (data) => { + const location = data.value.Location; + const contentTypeHref = location.ContentInfo.Content.ContentType._href; + const isContainer = contentTypesMap && contentTypesMap[contentTypeHref] && contentTypesMap[contentTypeHref].isContainer; + const isSelectable = !(allowContainersOnly && !isContainer); + const active = location.id === activeLocationId; + const isLoadingChildren = active && isActiveLocationLoading; + + return ( + content.id === location.id)} + onItemSelect={onItemSelect} + checkCanSelectContent={checkCanSelectContent} + onItemDeselect={onItemDeselect} + /> + ); + }; + const renderLoadMore = () => { + if (!items.length || items.length === total) { + return null; + } + + return ( + + ); + }; + + const wrapperAttrs = { + className: createCssClassNames({ + 'c-finder-tree-branch': true, + 'c-finder-tree-branch--collapsed': !items.length, + }), + onClick: !items.length ? expandBranch : undefined, + }; + + return ( +
+
+ {items.map(renderLeaf)} + {renderLoadMore()} +
+
+ ); +}; + +FinderTreeBranchComponent.propTypes = { + items: PropTypes.array.isRequired, + total: PropTypes.number.isRequired, + parentLocation: PropTypes.object.isRequired, + onItemClick: PropTypes.func.isRequired, + onBranchClick: PropTypes.func.isRequired, + onLoadMore: PropTypes.func.isRequired, + maxHeight: PropTypes.number.isRequired, + allowContainersOnly: PropTypes.bool, + allowedLocations: PropTypes.array.isRequired, + multiple: PropTypes.bool.isRequired, + selectedContent: PropTypes.array.isRequired, + onSelectContent: PropTypes.func.isRequired, + checkCanSelectContent: PropTypes.func.isRequired, + onItemRemove: PropTypes.func.isRequired, + activeLocationId: PropTypes.string.isRequired, + isActiveLocationLoading: PropTypes.bool, + onItemSelect: PropTypes.func.isRequired, + onItemDeselect: PropTypes.func.isRequired, +}; + +FinderTreeBranchComponent.defaultProps = { + allowContainersOnly: false, + isActiveLocationLoading: false, +}; + +export default FinderTreeBranchComponent; diff --git a/src/modules/udw/tabs/browse/components/finder/finder.tree.leaf.component.js b/src/modules/udw/tabs/browse/components/finder/finder.tree.leaf.component.js new file mode 100644 index 00000000..5b8393cf --- /dev/null +++ b/src/modules/udw/tabs/browse/components/finder/finder.tree.leaf.component.js @@ -0,0 +1,108 @@ +import React, { useContext, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import SelectContentButtonComponent from '../../../../common/select-content-button/select.content.button.component'; +import LoadingSpinnerComponent from '../../../../../common/loading-spinner/loading.spinner.component'; +import Icon from '../../../../../common/icon/icon'; +import { ContentTypesContext } from '../../../../udw.module'; +import { createCssClassNames } from '../../../../../common/css-class-names/css.class.names'; + +const FinderTreeLeafComponent = (props) => { + const { location, isSelectable, onClick, isMarked, isLoadingChildren } = props; + const { multiple, isSelected, onItemSelect, onItemDeselect, checkCanSelectContent, allowedLocations } = props; + const contentTypesMap = useContext(ContentTypesContext); + const handleClick = useCallback( + (event) => { + if (!isSelectable || event.target.closest('.c-finder-tree-leaf__btn--toggle-selection')) { + return; + } + + onClick(location); + }, + [isSelectable, location, onClick] + ); + const getContentTypeIdentifier = () => { + const contentTypeHref = location.ContentInfo.Content.ContentType._href; + const contentType = contentTypesMap ? contentTypesMap[contentTypeHref] : null; + const contentTypeIdentifier = contentType ? contentType.identifier : null; + + return contentTypeIdentifier; + }; + const renderIcon = () => { + const contentTypeIdentifier = getContentTypeIdentifier(); + + if (!contentTypeIdentifier) { + return null; + } + + const contentTypeIconUrl = window.eZ.helpers.contentType.getContentTypeIconUrl(contentTypeIdentifier); + const extraClasses = createCssClassNames({ + 'ez-icon--small': true, + 'ez-icon--light': isMarked, + }); + + return ( +
+ +
+ ); + }; + const renderLoadingIcon = () => { + if (!isMarked || !isLoadingChildren) { + return null; + } + + return ; + }; + const renderSelectContentBtn = () => { + if (!isSelectable || isLoadingChildren || !multiple) { + return null; + } + + return ( + + ); + }; + + const isForcedLocation = allowedLocations.length === 1; + const wrapperAttrs = { + className: createCssClassNames({ + 'c-finder-tree-leaf': true, + 'c-finder-tree-leaf--selected': isMarked, + 'c-finder-tree-leaf--not-selectable': !isSelectable || isForcedLocation, + 'c-finder-tree-leaf--has-children': location.childCount, + 'c-finder-tree-leaf--loading': isLoadingChildren, + }), + onClick: !isForcedLocation ? handleClick : undefined, + }; + + return ( +
+ {renderIcon()} + {location.ContentInfo.Content.Name} + {renderLoadingIcon()} + {renderSelectContentBtn()} +
+ ); +}; + +FinderTreeLeafComponent.propTypes = { + location: PropTypes.object.isRequired, + onClick: PropTypes.func.isRequired, + isMarked: PropTypes.bool.isRequired, + isLoadingChildren: PropTypes.bool.isRequired, + isSelectable: PropTypes.bool.isRequired, + allowedLocations: PropTypes.array.isRequired, + multiple: PropTypes.bool.isRequired, + isSelected: PropTypes.bool.isRequired, + onItemSelect: PropTypes.func.isRequired, + checkCanSelectContent: PropTypes.func.isRequired, + onItemDeselect: PropTypes.func.isRequired, +}; + +export default FinderTreeLeafComponent; diff --git a/src/modules/udw/udw.module.js b/src/modules/udw/udw.module.js index 9b2a00df..3c7e6d1f 100644 --- a/src/modules/udw/udw.module.js +++ b/src/modules/udw/udw.module.js @@ -106,7 +106,7 @@ UDWModule.propTypes = { panel: PropTypes.func.isRequired, attrs: PropTypes.object, active: PropTypes.bool, - }) + }).isRequired ), maxHeight: PropTypes.number, onClose: PropTypes.func, diff --git a/webpack.common.js b/webpack.common.js index ec15c15d..81082329 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -8,6 +8,7 @@ module.exports = { MultiFileUpload: './src/modules/multi-file-upload/multi.file.upload.module.js', ContentTree: './src/modules/content-tree/content.tree.module.js', UDW: './src/modules/udw/udw.module.js', + UDWBrowseTab: './src/modules/udw/tabs/browse/browse.tab.module.js', }, output: { filename: '[name].module.js', From e5d152c8aa3cd59ceb13515d0480975d59592fd9 Mon Sep 17 00:00:00 2001 From: Piotr Nalepa Date: Mon, 13 May 2019 15:10:46 +0200 Subject: [PATCH 2/2] fixup --- .../content.image.preview.component.js | 12 ++++++------ .../selected-content/selected.content.component.js | 11 ++++++++--- .../selected.content.item.component.js | 4 ++-- .../components/finder/finder.load.more.component.js | 6 +++--- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/modules/udw/common/content-meta-preview/content.image.preview.component.js b/src/modules/udw/common/content-meta-preview/content.image.preview.component.js index 4c460dca..5b5ba63b 100644 --- a/src/modules/udw/common/content-meta-preview/content.image.preview.component.js +++ b/src/modules/udw/common/content-meta-preview/content.image.preview.component.js @@ -2,6 +2,11 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import LoadingSpinnerComponent from '../../../common/loading-spinner/loading.spinner.component'; +const TEXT_NO_PREVIEW = Translator.trans( + /*@Desc("Content preview is not available")*/ 'content_meta_preview.image_preview_not_available.info', + {}, + 'universal_discovery_widget' +); const getImageUri = (version) => { if (!version) { return ''; @@ -18,14 +23,9 @@ const ContentImagePreviewComponent = ({ version }) => { } const imageUri = getImageUri(version); - const imagePreviewNotAvailableLabel = Translator.trans( - /*@Desc("Content preview is not available")*/ 'content_meta_preview.image_preview_not_available.info', - {}, - 'universal_discovery_widget' - ); if (!imageUri.length) { - return {imagePreviewNotAvailableLabel}; + return {TEXT_NO_PREVIEW}; } return ; diff --git a/src/modules/udw/common/selected-content/selected.content.component.js b/src/modules/udw/common/selected-content/selected.content.component.js index 49c9cef8..77d05696 100644 --- a/src/modules/udw/common/selected-content/selected.content.component.js +++ b/src/modules/udw/common/selected-content/selected.content.component.js @@ -4,13 +4,18 @@ import SelectedContentItemComponent from './selected.content.item.component'; import PopupComponent from '../../../common/tooltip-popup/tooltip.popup.component'; import { createCssClassNames } from '../../../common/css-class-names/css.class.names'; -const noConfirmedContentTitle = Translator.trans( +const TEXT_NO_CONFIRMED_CONTENT = Translator.trans( /*@Desc("No confirmed content yet")*/ 'select_content.no_confirmed_content.title', {}, 'universal_discovery_widget' ); +const TEXT_CONFIRMED_ITEMS = Translator.trans( + /*@Desc("Confirmed items")*/ 'select_content.confirmed_items.title', + {}, + 'universal_discovery_widget' +); const getTitle = (total) => { - let title = Translator.trans(/*@Desc("Confirmed items")*/ 'select_content.confirmed_items.title', {}, 'universal_discovery_widget'); + let title = `${TEXT_CONFIRMED_ITEMS}`; if (total) { title = `${title} (${total})`; @@ -78,7 +83,7 @@ const SelectedContentComponent = ({ items, itemsLimit, onItemRemove }) => { ); diff --git a/src/modules/udw/common/selected-content/selected.content.item.component.js b/src/modules/udw/common/selected-content/selected.content.item.component.js index 14d98833..fdd95d35 100644 --- a/src/modules/udw/common/selected-content/selected.content.item.component.js +++ b/src/modules/udw/common/selected-content/selected.content.item.component.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import Icon from '../../../common/icon/icon'; import ContentTypeIconComponent from '../content-type-icon/content.type.icon.component'; -const notAvailableLabel = Translator.trans(/*@Desc("N/A")*/ 'select_content.not_available.label', {}, 'universal_discovery_widget'); +const TEXT_NOT_AVAILABLE = Translator.trans(/*@Desc("N/A")*/ 'select_content.not_available.label', {}, 'universal_discovery_widget'); const SelectedContentItemComponent = ({ contentName, locationId, contentTypeIdentifier, contentTypeName, onRemove }) => { let icon = null; @@ -36,7 +36,7 @@ SelectedContentItemComponent.propTypes = { SelectedContentItemComponent.defaultProps = { contentTypeIdentifier: null, - contentTypeName: notAvailableLabel, + contentTypeName: TEXT_NOT_AVAILABLE, }; export default SelectedContentItemComponent; diff --git a/src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js b/src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js index 6cce937e..0ddbfeda 100644 --- a/src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js +++ b/src/modules/udw/tabs/browse/components/finder/finder.load.more.component.js @@ -1,16 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; +const TEXT_LOAD_MORE = Translator.trans(/*@Desc("Load more")*/ 'finder.branch.load_more.label', {}, 'universal_discovery_widget'); + const FinderLoadMoreComponent = ({ isVisible, onClick }) => { if (!isVisible) { return null; } - const loadMoreLabel = Translator.trans(/*@Desc("Load more")*/ 'finder.branch.load_more.label', {}, 'universal_discovery_widget'); - return ( ); };