diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7eeb6f5..5ab671064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [3.0.1](2019-11-18) + +### Fixed + +- [#820](https://github.com/dadi/publish/pull/820): fix routing issue in single-document collections + ## [3.0.0](2019-10-29) ### Added diff --git a/app/components/MediaList/MediaList.jsx b/app/components/MediaList/MediaList.jsx new file mode 100644 index 000000000..79a31dd5b --- /dev/null +++ b/app/components/MediaList/MediaList.jsx @@ -0,0 +1,170 @@ +import * as Constants from 'lib/constants' +import {connectRedux} from 'lib/redux' +import DocumentGridList from 'components/DocumentGridList/DocumentGridList' +import DocumentTableList from 'containers/DocumentTableList/DocumentTableList' +import DropArea from 'components/DropArea/DropArea' +import FieldMediaItem from 'components/FieldMedia/FieldMediaItem' +import MediaGridCard from 'containers/MediaGridCard/MediaGridCard' +import MediaListController from 'components/MediaListController/MediaListController' +import proptypes from 'prop-types' +import React from 'react' + +const MEDIA_TABLE_FIELDS = ['url', 'fileName', 'mimeType', 'width', 'height'] + +class MediaList extends React.Component { + static propTypes = { + /** + * The list of documents shown in the list. + */ + documents: proptypes.array, + + /** + * Whether any documents are selected. + */ + hasSelection: proptypes.bool, + + /** + * Whether the view is filtered to only show selected documents. + */ + isFilteringSelection: proptypes.bool, + + /** + * Whether to display the items in a table or grid form. + */ + mode: proptypes.oneOf(['table', 'grid']), + + /** + * Callback to be called to obtain the base URL for the given page, as + * determined by the view. + */ + onBuildBaseUrl: proptypes.func, + + /** + * Callback to be called when the user changes the list display mode. + */ + onListModeUpdate: proptypes.func, + + /** + * Callback to be called when files are uploaded. + */ + onMediaUpload: proptypes.func, + + /** + * Callback to be called when the selection changes. + */ + onSelect: proptypes.func, + + /** + * Callback to be called when the user sorts the list. + */ + onSort: proptypes.func, + + /** + * The order used to sort the documents by the `sort` field. + */ + order: proptypes.oneOf(['asc', 'desc']), + + /** + * The list of currently selected documents. + */ + selectedDocuments: proptypes.object, + + /** + * The name of the field currently being used to sort the documents. + */ + sort: proptypes.string + } + + constructor(props) { + super(props) + + this.renderFieldMediaItem = this.renderFieldMediaItem.bind(this) + } + + render() { + const { + documents, + hasSelection, + isFilteringSelection, + mode, + onBuildBaseUrl, + onListModeUpdate, + onMediaUpload, + onSelect, + onSort, + order, + selectedDocuments, + sort + } = this.props + const schema = { + ...Constants.MEDIA_COLLECTION_SCHEMA, + fields: { + ...Constants.MEDIA_COLLECTION_SCHEMA.fields, + url: {label: 'Thumbnail', FieldComponentList: this.renderFieldMediaItem} + } + } + + const contents = ( + <> + {!isFilteringSelection && ( + + )} + + {mode === 'grid' && ( + ( + + )} + onSelect={onSelect} + selectedDocuments={selectedDocuments} + /> + )} + + {mode === 'table' && ( + + )} + + ) + + return onMediaUpload ? ( + {contents} + ) : ( + contents + ) + } + + renderFieldMediaItem({document}) { + return + } +} + +const mapState = state => ({ + config: state.app.config +}) + +export default connectRedux(mapState)(MediaList) diff --git a/app/components/Notification/Notification.css b/app/components/Notification/Notification.css index a6eb69dbc..3f8706f68 100644 --- a/app/components/Notification/Notification.css +++ b/app/components/Notification/Notification.css @@ -1,4 +1,7 @@ .container { + position: absolute; + bottom: 100%; + width: 100%; opacity: 0; transform: translateY(100%); transition: opacity 0.3s ease-in-out 0.05s, transform 0.3s ease-in-out; diff --git a/app/components/RichEditor/RichEditor.jsx b/app/components/RichEditor/RichEditor.jsx index 19bbbab66..902d6c55e 100644 --- a/app/components/RichEditor/RichEditor.jsx +++ b/app/components/RichEditor/RichEditor.jsx @@ -11,9 +11,12 @@ import { Fullscreen, FullscreenExit, ImageSearch, - InsertLink + InsertLink, + Redo, + StrikethroughS, + Undo } from '@material-ui/icons' -import {isTouchDevice, openLinkPrompt} from './utils' +import {isMac, isTouchDevice, openLinkPrompt} from './utils' import {renderBlock, renderMark} from './slateRender' import {RichEditorToolbar, RichEditorToolbarButton} from './RichEditorToolbar' import {Editor} from 'slate-react' @@ -32,6 +35,25 @@ import Style from 'lib/Style' import styles from './RichEditor.css' import {Value} from 'slate' +const MOD = isMac ? '⌘' : 'Ctrl' +const OPT = isMac ? '⌥' : 'Alt' +const undoTooltip = `Undo (${MOD}+Z)` +const redoTooltip = `Redo (${MOD}+Y)` +const boldTooltip = `Bold (${MOD}+B)` +const italicTooltip = `Italic (${MOD}+I)` +const strikeTooltip = `Strikethrough (${MOD}+${OPT}+S)` +const h1Tooltip = `Heading 1 (${MOD}+${OPT}+1)` +const h2Tooltip = `Heading 2 (${MOD}+${OPT}+2)` +const h3Tooltip = `Heading 3 (${MOD}+${OPT}+3)` +const numListTooltip = `Numbered list (${MOD}+${OPT}+N)` +const bulListTooltip = `Bulleted list (${MOD}+${OPT}+B)` +const deindentTooltip = `Decrease indent (${MOD}+[)` +const indentTooltip = `Increase indent (${MOD}+])` +const quoteTooltip = `Blockquote (${MOD}+Q)` +const codeTooltip = `Code (${MOD}+\`)` +const linkTooltip = `Insert link (${MOD}+K)` +const imageTooltip = `Insert image from library` + const EMPTY_VALUE = { document: { nodes: [ @@ -160,8 +182,8 @@ export default class RichEditor extends React.Component { } } - componentDidUpdate(_, prevState) { - if (this.state.isFullscreen !== prevState.isFullscreen) { + componentDidUpdate() { + if (this.container) { this.containerBounds = this.container.getBoundingClientRect() } } @@ -205,43 +227,33 @@ export default class RichEditor extends React.Component { const newHref = openLinkPrompt(currentHref) - return this.handleLinkUpdate(node, newHref) + this.handleLinkUpdate(node, newHref) } handleLinkUpdate(node, href) { - if (href === '') { - return this.editor.unwrapInlineByKey(node.key, Nodes.INLINE_LINK) - } + href === '' + ? this.editor.unwrapInlineByKey(node.key, Nodes.INLINE_LINK) + : this.editor.setNodeByKey(node.key, {data: {href}}) - this.editor.setNodeByKey(node.key, {data: {href}}) + this.editor.focus() } handleMediaInsert(mediaSelection) { - const {isRawMode} = this.state - this.setState({...this.initialMediaState}, () => { - mediaSelection.forEach(mediaObject => { - if (!mediaObject.url) return - - if (isRawMode) { - return this.editor.insertBlock({ - type: 'line', - nodes: [ - { - object: 'text', - text: `![${mediaObject.altText || ''}](${mediaObject.url})` - } - ] - }) - } - - this.editor.insertBlock({ - type: 'image', - data: { - alt: mediaObject.altText, - src: mediaObject.url - } - }) + mediaSelection.forEach(({altText, url}) => { + if (!url) return + + const block = this.state.isRawMode + ? { + type: 'line', + nodes: [{object: 'text', text: `![${altText || ''}](${url})`}] + } + : { + type: 'image', + data: {alt: altText, src: url} + } + + this.editor.insertBlock(block) }) }) } @@ -317,33 +329,64 @@ export default class RichEditor extends React.Component { .addIf('fullscreen', isFullscreen) .addIf('raw-mode', isRawMode) .addIf('focused', isFocused) + const {data} = editor.value + const undos = data.get('undos') + const redos = data.get('redos') return (
+ + + + + + + + + H1 @@ -351,7 +394,8 @@ export default class RichEditor extends React.Component { action={editor.toggleHeading2} active={!isRawMode && editor.isInBlocks(Nodes.BLOCK_HEADING2)} disabled={isRawMode} - title="Heading 2" // (Ctrl+Alt+2)" + name="editor-h2-button" + title={h2Tooltip} > H2 @@ -359,7 +403,8 @@ export default class RichEditor extends React.Component { action={editor.toggleHeading3} active={!isRawMode && editor.isInBlocks(Nodes.BLOCK_HEADING3)} disabled={isRawMode} - title="Heading 3" // (Ctrl+Alt+3)" + name="editor-h3-button" + title={h3Tooltip} > H3 @@ -367,7 +412,8 @@ export default class RichEditor extends React.Component { action={editor.toggleNumberedList} active={!isRawMode && editor.isInList(Nodes.BLOCK_NUMBERED_LIST)} disabled={isRawMode} - title="Numbered list" // (Ctrl+Shift+7)" + name="editor-ol-button" + title={numListTooltip} > @@ -375,21 +421,24 @@ export default class RichEditor extends React.Component { action={editor.toggleBulletedList} active={!isRawMode && editor.isInList(Nodes.BLOCK_BULLETED_LIST)} disabled={isRawMode} - title="Bulleted list" // (Ctrl+Shift+8)" + name="editor-ul-button" + title={bulListTooltip} > @@ -397,7 +446,8 @@ export default class RichEditor extends React.Component { action={editor.toggleBlockquote} active={!isRawMode && editor.isInBlockQuote()} disabled={isRawMode} - title="Blockquote" // (Ctrl+Q)" + name="editor-blockquote-button" + title={quoteTooltip} > @@ -409,21 +459,25 @@ export default class RichEditor extends React.Component { editor.hasMark(Nodes.MARK_CODE)) } disabled={isRawMode} - title="Code" // (Ctrl+`)" + name="editor-code-button" + title={codeTooltip} > @@ -433,12 +487,14 @@ export default class RichEditor extends React.Component { {isFullscreen ? : } diff --git a/app/components/RichEditor/RichEditorLink.css b/app/components/RichEditor/RichEditorLink.css index 0479a1a49..6fac00f04 100644 --- a/app/components/RichEditor/RichEditorLink.css +++ b/app/components/RichEditor/RichEditorLink.css @@ -1,3 +1,7 @@ +.container { + position: relative; +} + .popup { background-color: var(--edit-color-background-1); border-radius: var(--edit-radius-elem); diff --git a/app/components/RichEditor/RichEditorLink.jsx b/app/components/RichEditor/RichEditorLink.jsx index 4fbcb1392..59d7ac861 100644 --- a/app/components/RichEditor/RichEditorLink.jsx +++ b/app/components/RichEditor/RichEditorLink.jsx @@ -1,8 +1,11 @@ import {Button, TextInput} from '@dadi/edit-ui' +import isHotkey from 'is-hotkey' import proptypes from 'prop-types' import React from 'react' import styles from './RichEditorLink.css' +const isEscape = isHotkey('escape') + export default class RichEditorLink extends React.Component { static propTypes = { /** @@ -29,13 +32,23 @@ export default class RichEditorLink extends React.Component { /** * A callback to be fired when the value of the link is updated. */ - onChange: proptypes.func + onChange: proptypes.func.isRequired } constructor(props) { super(props) - this.clickHandler = this.handleClick.bind(this) + this.detectClickOut = this.detectClickOut.bind(this) + this.detectEscape = this.detectEscape.bind(this) + this.focusInput = () => this.inputRef.current.focus() + this.openPrompt = () => this.setState({editing: true}) + this.saveValue = this.saveValue.bind(this) + this.updateValue = e => this.setState({href: e.target.value}) + + this.containerRef = React.createRef() + this.inputRef = React.createRef() + this.linkRef = React.createRef() + this.popupRef = React.createRef() this.state = { editing: props.href === '', href: props.href, @@ -44,106 +57,66 @@ export default class RichEditorLink extends React.Component { } componentDidMount() { - document.body.addEventListener('mousedown', this.clickHandler) - } - - componentWillUnmount() { - document.body.removeEventListener('mousedown', this.clickHandler) - } - - handleClick(event) { - const {href, onChange} = this.props - const {editing} = this.state + document.body.addEventListener('mousedown', this.detectClickOut) - if (editing && this.container && !this.container.contains(event.target)) { - this.setState({ - editing: false, - href - }) - - if (href === '' && typeof onChange === 'function') { - onChange(href) - } + if (this.inputRef.current) { + setTimeout(this.focusInput, 0) } - } - - handleLinkClick(event) { - event.preventDefault() - this.setState({ - editing: true - }) + this.setPopupPosition() } - handleLinkUpdate(event) { - this.setState({ - href: event.target.value - }) + componentDidUpdate() { + this.setPopupPosition() } - // getPopupOffset() { - // const {bounds} = this.props - // const {popupElement, container} = this - - // if (!bounds || !popupElement || !container) { - // return {} - // } - - // const {right} = popupElement.getBoundingClientRect() - // const {top: linkTop} = this.container.getBoundingClientRect() + componentWillUnmount() { + document.body.removeEventListener('mousedown', this.detectClickOut) + } - // const leftOffset = Math.min(bounds.right - right, 0) - // const verticalOffset = -popupElement.clientHeight * 1.5 - // const topOrBottom = bounds.top > linkTop + verticalOffset ? 'bottom' : 'top' + detectClickOut(event) { + const {current} = this.containerRef - // return {left: leftOffset, [topOrBottom]: verticalOffset} - // } + if (this.state.editing && current && !current.contains(event.target)) { + const {href, onChange} = this.props - handleSave(event) { - event.preventDefault() + this.setState({editing: false, href}) + if (href === '') onChange('') + } + } - const {onChange} = this.props - const {href} = this.state + detectEscape(event) { + if (isEscape(event)) { + const {href, onChange} = this.props - if (typeof onChange === 'function') { - onChange(href) + this.setState({editing: false, href}) + if (href === '') onChange('') } - - this.setState({ - editing: false - }) } render() { - const {children} = this.props - const {editing, href} = this.state - // const popupStyle = this.getPopupOffset() + const {editing, href, popupStyle} = this.state return ( - { - this.container = el - }} - > - - {children} + + + {this.props.children} {editing && (
{ - this.popupElement = el - }} - // style={popupStyle} + ref={this.popupRef} + style={popupStyle} > -
+ @@ -161,4 +134,39 @@ export default class RichEditorLink extends React.Component { ) } + + saveValue(event) { + event.preventDefault() + this.setState({editing: false}) + this.props.onChange(this.state.href) + } + + setPopupPosition() { + const {bounds} = this.props + const {popupStyle} = this.state + + if (!bounds || !this.popupRef.current || !this.linkRef.current) return + + const {height, width} = this.popupRef.current.getBoundingClientRect() + const { + bottom: linkBottom, + left: linkLeft, + top: linkTop + } = this.linkRef.current.getBoundingClientRect() + + const POPUP_MARGIN = 10 + const left = + Math.min(bounds.right - linkLeft - width - POPUP_MARGIN, 0) + 'px' + const isEnoughSpaceBottom = + linkBottom + POPUP_MARGIN + height < bounds.bottom + const isEnoughSpaceTop = linkTop - POPUP_MARGIN - height > bounds.top + const top = + !isEnoughSpaceBottom && isEnoughSpaceTop + ? -(height + 2 * POPUP_MARGIN) + 'px' + : undefined + + if (popupStyle.left !== left || popupStyle.top !== top) { + this.setState({popupStyle: {left, top}}) + } + } } diff --git a/app/components/RichEditor/RichEditorToolbar.jsx b/app/components/RichEditor/RichEditorToolbar.jsx index 14b5e2b1c..f6fd2a224 100644 --- a/app/components/RichEditor/RichEditorToolbar.jsx +++ b/app/components/RichEditor/RichEditorToolbar.jsx @@ -50,23 +50,26 @@ export class RichEditorToolbarButton extends React.Component { text: proptypes.string } - handleClick(event) { - const {action} = this.props + constructor(props) { + super(props) + this.handleClick = this.handleClick.bind(this) + } + handleClick(event) { event.preventDefault() - - action.call(this, event) + this.props.action(event) } render() { - const {active, children, disabled, title} = this.props + const {active, children, disabled, name, title} = this.props const buttonStyle = new Style(styles, 'button').addIf('active', active) return (