diff --git a/packages/@sanity/base/sanity.json b/packages/@sanity/base/sanity.json index c10aecc5ef9..c62c29a5afa 100644 --- a/packages/@sanity/base/sanity.json +++ b/packages/@sanity/base/sanity.json @@ -583,6 +583,10 @@ "implements": "part:@sanity/base/sort-alpha-desc-icon", "path": "components/icons/SortAlphaDesc.js" }, + { + "implements": "part:@sanity/base/sort-icon", + "path": "components/icons/Sort.js" + }, { "implements": "part:@sanity/base/bars-icon", "path": "components/icons/Bars.js" diff --git a/packages/@sanity/base/src/components/icons/Sort.js b/packages/@sanity/base/src/components/icons/Sort.js new file mode 100644 index 00000000000..aabd3eba397 --- /dev/null +++ b/packages/@sanity/base/src/components/icons/Sort.js @@ -0,0 +1 @@ +export {default} from 'react-icons/lib/fa/sort' diff --git a/packages/@sanity/base/src/preview/PreviewSubscriber.js b/packages/@sanity/base/src/preview/PreviewSubscriber.js index 59094d9b695..9889caf6eb1 100644 --- a/packages/@sanity/base/src/preview/PreviewSubscriber.js +++ b/packages/@sanity/base/src/preview/PreviewSubscriber.js @@ -13,6 +13,7 @@ export default class PreviewSubscriber extends React.PureComponent { type: PropTypes.object.isRequired, fields: PropTypes.arrayOf(PropTypes.oneOf(['title', 'description', 'imageUrl'])), value: PropTypes.any.isRequired, + ordering: PropTypes.object, children: PropTypes.func } @@ -46,6 +47,10 @@ export default class PreviewSubscriber extends React.PureComponent { subscribe(value, type, fields) { this.unsubscribe() + const viewOptions = this.props.ordering + ? {ordering: this.props.ordering} + : {} + const visibilityOn$ = Observable.of(!document.hidden) .merge(visibilityChange$.map(event => !event.target.hidden)) @@ -58,7 +63,7 @@ export default class PreviewSubscriber extends React.PureComponent { .distinctUntilChanged() .switchMap(isInViewport => { return isInViewport - ? observeForPreview(value, type, fields) + ? observeForPreview(value, type, fields, viewOptions) : Observable.of(null) }) .subscribe(result => { diff --git a/packages/@sanity/base/src/preview/SanityPreview.js b/packages/@sanity/base/src/preview/SanityPreview.js index 758688f3383..e7e1fe8bd8e 100644 --- a/packages/@sanity/base/src/preview/SanityPreview.js +++ b/packages/@sanity/base/src/preview/SanityPreview.js @@ -8,14 +8,15 @@ export default class SanityPreview extends React.PureComponent { static propTypes = { layout: PropTypes.string, value: PropTypes.any, + ordering: PropTypes.object, type: PropTypes.object.isRequired } render() { - const {type, value, layout} = this.props + const {type, value, layout, ordering} = this.props return ( - + {RenderPreviewSnapshot} ) diff --git a/packages/@sanity/base/src/preview/observeForPreview.js b/packages/@sanity/base/src/preview/observeForPreview.js index a5f76a7bb1c..7a210fb14bc 100644 --- a/packages/@sanity/base/src/preview/observeForPreview.js +++ b/packages/@sanity/base/src/preview/observeForPreview.js @@ -11,7 +11,7 @@ function is(typeName, type) { } // Takes a value and its type and prepares a snapshot for it that can be passed to a preview component -export default function observeForPreview(value, type, fields) { +export default function observeForPreview(value, type, fields, viewOptions) { if (is('reference', type)) { // if the value is of type reference, but has no _ref property, we cannot prepare any value for the preview // and the most sane thing to do is to return `null` for snapshot @@ -31,7 +31,13 @@ export default function observeForPreview(value, type, fields) { const targetFields = fields ? configFields.filter(fieldName => fields.includes(fieldName)) : configFields const paths = targetFields.map(key => selection[key].split('.')) return observe(value, paths) - .map(snapshot => ({type: type, snapshot: prepareForPreview(snapshot, type)})) + .map(snapshot => ({ + type: type, + snapshot: prepareForPreview(snapshot, type, viewOptions) + })) } - return Observable.of({type: type, snapshot: invokePrepare(type, value)}) + return Observable.of({ + type: type, + snapshot: invokePrepare(type, value, viewOptions) + }) } diff --git a/packages/@sanity/base/src/preview/prepareForPreview.js b/packages/@sanity/base/src/preview/prepareForPreview.js index ef9caa9c057..3f5e8c078cf 100644 --- a/packages/@sanity/base/src/preview/prepareForPreview.js +++ b/packages/@sanity/base/src/preview/prepareForPreview.js @@ -34,13 +34,13 @@ const reportErrors = debounce(() => { /* eslint-enable no-console */ }, 1000) -function invokePrepareChecked(type, value) { +function invokePrepareChecked(type, value, viewOptions) { const prepare = type.preview.prepare if (!prepare) { return value } try { - return prepare(value) + return prepare(value, viewOptions) } catch (error) { if (!COLLECTED_ERRORS[type.name]) { COLLECTED_ERRORS[type.name] = [] @@ -57,7 +57,7 @@ function invokePrepareUnchecked(type, value) { export const invokePrepare = __DEV__ ? invokePrepareChecked : invokePrepareUnchecked -export default function prepareForPreview(rawValue, type) { +export default function prepareForPreview(rawValue, type, viewOptions) { const selection = type.preview.select const targetKeys = Object.keys(selection) @@ -66,5 +66,5 @@ export default function prepareForPreview(rawValue, type) { return acc }, pick(rawValue, PRESERVE_KEYS)) - return invokePrepare(type, remapped) + return invokePrepare(type, remapped, viewOptions) } diff --git a/packages/@sanity/desk-tool/src/pane/DocumentsPane.js b/packages/@sanity/desk-tool/src/pane/DocumentsPane.js index 0abc9f87757..0499adfb4fa 100644 --- a/packages/@sanity/desk-tool/src/pane/DocumentsPane.js +++ b/packages/@sanity/desk-tool/src/pane/DocumentsPane.js @@ -3,9 +3,10 @@ import React from 'react' import Spinner from 'part:@sanity/components/loading/spinner' import styles from './styles/DocumentsPane.css' import {StateLink, IntentLink, withRouterHOC} from 'part:@sanity/base/router' -import {Item} from 'part:@sanity/components/lists/default' +import SortIcon from 'part:@sanity/base/sort-icon' + import ListView from './ListView' -import {partition} from 'lodash' +import {partition, uniqBy} from 'lodash' import VisibilityOffIcon from 'part:@sanity/base/visibility-off-icon' import EditIcon from 'part:@sanity/base/edit-icon' import QueryContainer from 'part:@sanity/base/query-container' @@ -21,18 +22,43 @@ import Snackbar from 'part:@sanity/components/snackbar/default' const NOOP = () => {} // eslint-disable-line -function readListLayoutSettings() { - return JSON.parse(window.localStorage.getItem('desk-tool.listlayout-settings') || '{}') +const LOCALSTORAGE_KEY = 'desk-tool.documents-pane-settings' + +function readSettings() { + return JSON.parse(window.localStorage.getItem(LOCALSTORAGE_KEY) || '{}') } -function writeListLayoutSettings(settings) { - window.localStorage.setItem('desk-tool.listlayout-settings', JSON.stringify(settings)) +function writeSettings(settings) { + window.localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)) } function getDocumentKey(document) { return getPublishedId(document._id) } +function toGradientOrderClause(orderBy) { + return orderBy.map( + ordering => [ordering.field, ordering.direction] + .filter(Boolean) + .join(' ') + ).join(', ') +} + +const ORDER_BY_UPDATED_AT = { + title: 'Last edited', + name: 'updatedAt', + by: [{field: '_updatedAt', direction: 'desc'}] +} + +const ORDER_BY_CREATED_AT = { + title: 'Created', + name: 'createdAt', + by: [{field: '_createdAt', direction: 'desc'}] +} + +const DEFAULT_SELECTED_ORDERING_OPTION = ORDER_BY_UPDATED_AT +const DEFAULT_ORDERING_OPTIONS = [ORDER_BY_UPDATED_AT, ORDER_BY_CREATED_AT] + function removePublishedWithDrafts(documents) { const [draftIds, publishedIds] = partition(documents.map(doc => doc._id), isDraftId) @@ -50,18 +76,19 @@ function removePublishedWithDrafts(documents) { .filter(doc => !(isPublishedId(doc._id) && doc.hasDraft)) } +function writeSettingsForType(type, settings) { + writeSettings(Object.assign(readSettings(), { + [type]: settings + })) +} + export default withRouterHOC(class DocumentsPane extends React.PureComponent { static propTypes = { selectedType: PropTypes.string, selectedDocumentId: PropTypes.string, schemaType: PropTypes.object, isCollapsed: PropTypes.bool, - router: PropTypes.shape({ - state: PropTypes.shape({ - selectType: PropTypes.string, - selectedType: PropTypes.string - }) - }) + router: PropTypes.object } static defaultProps = { @@ -69,38 +96,41 @@ export default withRouterHOC(class DocumentsPane extends React.PureComponent { isCollapsed: false, published: [], drafts: [], - onSetSorting: NOOP, onSetListLayout: NOOP } - state = { - listLayoutSettings: readListLayoutSettings(), - sorting: '_updatedAt desc', - menuIsOpen: false - } - - static contextTypes = { - __internalRouter: PropTypes.object + handleSetListLayout = listLayout => { + this.setState(prevState => ({ + settings: { + ...prevState.settings, + listLayout: listLayout.key + } + }), this.writeSettings) } - - handleSetListLayout = listLayout => { - const {selectedType} = this.props.router.state - const nextSettings = Object.assign(readListLayoutSettings(), { - [selectedType]: listLayout - }) - writeListLayoutSettings(nextSettings) - this.setState({listLayoutSettings: nextSettings}) + constructor(props) { + super() + const settings = readSettings() + this.state = { + settings: (settings && settings[props.selectedType]) || { + listLayout: 'default', + ordering: DEFAULT_SELECTED_ORDERING_OPTION + }, + menuIsOpen: false + } } - getListLayoutForType(typeName) { - return this.state.listLayoutSettings[typeName] || 'default' + handleSetOrdering = ordering => { + this.setState(prevState => ({ + settings: { + ...prevState.settings, + ordering: ordering.name + } + }), this.writeSettings) } - handleSetSorting = sorting => { - this.setState({ - sorting: sorting - }) + writeSettings() { + writeSettingsForType(this.props.selectedType, this.state.settings) } handleToggleMenu = () => { @@ -115,31 +145,52 @@ export default withRouterHOC(class DocumentsPane extends React.PureComponent { }) } - handleGoToCreateNew = () => { - const {selectedType} = this.props + getOrderingOptions(selectedType) { const type = schema.get(selectedType) - const url = this.context.__internalRouter.resolveIntentLink('create', { - type: type.name - }) - this.context.__internalRouter.navigateUrl(url) + + const optionsWithDefaults = type.orderings + ? type.orderings.concat(DEFAULT_ORDERING_OPTIONS) + : DEFAULT_ORDERING_OPTIONS + + return uniqBy(optionsWithDefaults, 'name') + .map(option => { + return { + ...option, + icon: option.icon || SortIcon, + title: Sort by {option.title} + } + }) + } + + handleGoToCreateNew = () => { + const {selectedType, router} = this.props + router.navigateIntent('create', {type: selectedType}) } renderDocumentsPaneMenu = () => { + const {selectedType} = this.props + const type = schema.get(selectedType) return ( ) } renderDocumentPaneItem = (item, index, options = {}) => { const {selectedType, selectedDocumentId} = this.props - const listLayout = this.getListLayoutForType(selectedType) + const {settings} = this.state + + const ordering = this.getOrderingOptions(selectedType) + .find(option => option.name === settings.ordering) + const type = schema.get(selectedType) const linkState = { selectedDocumentId: getPublishedId(item._id), @@ -158,7 +209,8 @@ export default withRouterHOC(class DocumentsPane extends React.PureComponent {
@@ -194,16 +246,17 @@ export default withRouterHOC(class DocumentsPane extends React.PureComponent { render() { const { - router, selectedDocumentId, schemaType, isCollapsed } = this.props + const {settings} = this.state + const currentOrderingOption = this.getOrderingOptions(schemaType.name) + .find(option => option.name === settings.ordering) || DEFAULT_SELECTED_ORDERING_OPTION const params = {type: schemaType.name, draftsPath: `${DRAFTS_FOLDER}.**`} - const query = `*[_type == $type] | order(${this.state.sorting}) [0...10000] {_id, _type}` - + const query = `*[_type == $type] | order(${toGradientOrderClause(currentOrderingOption.by)}) [0...10000] {_id, _type}` return ( - {({result, loading, error, onRetry, type, listLayout}) => { + {({result, loading, error, onRetry, type}) => { if (error) { return ( )} diff --git a/packages/@sanity/desk-tool/src/pane/DocumentsPaneMenu.js b/packages/@sanity/desk-tool/src/pane/DocumentsPaneMenu.js index fdea9c2b70d..67d02d4a158 100644 --- a/packages/@sanity/desk-tool/src/pane/DocumentsPaneMenu.js +++ b/packages/@sanity/desk-tool/src/pane/DocumentsPaneMenu.js @@ -10,31 +10,7 @@ import IconNew from 'part:@sanity/base/plus-circle-icon' const TEST_CARDS_AND_THUMBNAILS = false -const menuItems = [ - // Todo: Disabled for now as we need to rethink how sorting should - // work wrt. previews and what is actually displayed on screen - - // { - // title: 'Alphabetical', - // icon: IconSortAlphaDesc, - // action: 'setSorting', - // key: 'byAlphabetical', - // sorting: 'name' - // }, - { - title: 'Last edited', - icon: undefined, - action: 'setSorting', - key: 'byLastEdited', - sorting: '_updatedAt desc' - }, - { - title: 'Created', - icon: undefined, - action: 'setSorting', - key: 'byCreated', - sort: '_createdAt desc' - }, +const LIST_VIEW_ITEMS = [ { title: 'List', icon: IconList, @@ -71,36 +47,48 @@ const menuItems = [ } ].filter(Boolean) +const NULL_COMPONENT = () => null + export default class DocumentsPaneMenu extends React.PureComponent { static propTypes = { onSetListLayout: PropTypes.func, - onSetSorting: PropTypes.func, + onSetOrdering: PropTypes.func, onGoToCreateNew: PropTypes.func, - onMenuClose: PropTypes.func + onMenuClose: PropTypes.func, + orderingOptions: PropTypes.array } handleMenuAction = item => { if (item.action === 'setListLayout') { - this.props.onSetListLayout(item.key) + this.props.onSetListLayout(item) } - if (item.action === 'setSorting') { - this.props.onSetSorting(item.sorting) + if (item.action === 'setOrdering') { + this.props.onSetOrdering(item.ordering) } if (item.action === 'createNew') { this.props.onGoToCreateNew() } - this.props.onMenuClose() } render() { + const {orderingOptions} = this.props + const orderingItems = orderingOptions.map(orderingOption => ({ + title: orderingOption.title, + icon: orderingOption.icon || NULL_COMPONENT, + ordering: orderingOption, + action: 'setOrdering', + key: orderingOption.name + })) + .concat(LIST_VIEW_ITEMS) + return ( diff --git a/packages/@sanity/schema/src/ordering/guessOrderingConfig.js b/packages/@sanity/schema/src/ordering/guessOrderingConfig.js new file mode 100644 index 00000000000..3b8f8678235 --- /dev/null +++ b/packages/@sanity/schema/src/ordering/guessOrderingConfig.js @@ -0,0 +1,24 @@ +import {capitalize, startCase} from 'lodash' + +const CANDIDATES = ['title', 'name', 'label', 'heading', 'header', 'caption', 'description'] + +const PRIMITIVES = ['string', 'boolean', 'number'] + +const isPrimitive = field => PRIMITIVES.includes(field.type) + +export default function guessOrderingConfig(objectTypeDef) { + + let candidates = CANDIDATES.filter(candidate => objectTypeDef.fields.some(field => field.name === candidate)) + + // None of the candidates were found, fallback to all fields + if (candidates.length === 0) { + candidates = objectTypeDef.fields.filter(isPrimitive).map(field => field.name) + } + + return candidates + .map(name => ({ + name: name, + title: capitalize(startCase(name)), + by: [{field: name, direction: 'asc'}] + })) +} diff --git a/packages/@sanity/schema/src/types/object.js b/packages/@sanity/schema/src/types/object.js index 0b017494acb..6d761a0301b 100644 --- a/packages/@sanity/schema/src/types/object.js +++ b/packages/@sanity/schema/src/types/object.js @@ -1,8 +1,20 @@ import {pick, keyBy, startCase} from 'lodash' import {lazyGetter} from './utils' import createPreviewGetter from '../preview/createPreviewGetter' +import guessOrderingConfig from '../ordering/guessOrderingConfig' -const OVERRIDABLE_FIELDS = ['jsonType', 'type', 'name', 'title', 'description', 'options', 'inputComponent'] +const OVERRIDABLE_FIELDS = [ + 'jsonType', + 'orderings', + 'type', + 'name', + 'title', + 'readOnly', + 'hidden', + 'description', + 'options', + 'inputComponent' +] const OBJECT_CORE = { name: 'object', @@ -20,6 +32,7 @@ export const ObjectType = { type: OBJECT_CORE, title: subTypeDef.title || (subTypeDef.name ? startCase(subTypeDef.name) : ''), options: options, + orderings: subTypeDef.orderings || guessOrderingConfig(subTypeDef), fields: subTypeDef.fields.map(fieldDef => { const {name, fieldset, ...rest} = fieldDef diff --git a/packages/test-studio/schemas/book.js b/packages/test-studio/schemas/book.js index 398090b538b..9a0cfb40efe 100644 --- a/packages/test-studio/schemas/book.js +++ b/packages/test-studio/schemas/book.js @@ -1,29 +1,47 @@ +function formatSubtitle(book) { + if (book.authorName && book.publicationYear) { + return `By ${book.authorName} (${book.publicationYear})` + } + return book.authorName ? `By ${book.authorName}` : book.publicationYear +} + export default { name: 'book', type: 'object', title: 'Book', description: 'This is just a simple type for generating some test data', - preview: { - select: { - title: 'title', - createdAt: '_createdAt', - lead: 'lead', - imageUrl: 'mainImage.asset.url', - author: 'authorRef.name' - } - }, fields: [ { name: 'title', title: 'Title', type: 'string' }, + { + name: 'translations', + title: 'Translations', + type: 'object', + fields: [ + {name: 'no', type: 'string', title: 'Norwegian (Bokmål)'}, + {name: 'nn', type: 'string', title: 'Norwegian (Nynorsk)'}, + {name: 'se', type: 'string', title: 'Swedish'} + ] + }, { name: 'author', title: 'Author', type: 'reference', to: {type: 'author', title: 'Author'} }, + { + name: 'coverImage', + title: 'Cover Image', + type: 'image' + }, + { + name: 'publicationYear', + title: 'Year of publication', + type: 'number' + }, { name: 'isbn', title: 'ISBN number', @@ -31,5 +49,39 @@ export default { type: 'number', hidden: true } - ] + ], + orderings: [ + { + title: 'Title', + name: 'title', + by: [ + {field: 'title', direction: 'asc'}, + {field: 'publicationYear', direction: 'asc'} + ] + }, + { + title: 'Swedish title', + name: 'swedishTitle', + by: [ + {field: 'translations.se', direction: 'asc'}, + {field: 'title', direction: 'asc'} + ] + } + ], + preview: { + select: { + title: 'title', + translations: 'translations', + createdAt: '_createdAt', + authorName: 'author.name', + publicationYear: 'publicationYear', + imageUrl: 'coverImage.asset.url' + }, + prepare(book, options = {}) { + return Object.assign({}, book, { + title: ((options.sorting || {}).name === 'swedishTitle' && (book.translations || {}).se) || book.title, + subtitle: formatSubtitle(book) + }) + } + } } diff --git a/packages/test-studio/schemas/objects.js b/packages/test-studio/schemas/objects.js index 357623e7902..3182d29be7d 100644 --- a/packages/test-studio/schemas/objects.js +++ b/packages/test-studio/schemas/objects.js @@ -41,7 +41,12 @@ export default { description: 'This is a field of (anonymous, inline) object type. Values here should never get a `_type` property', fields: [ {name: 'field1', type: 'string', description: 'This is a string field'}, - {name: 'field2', type: 'myObject', title: 'A field of myObject', description: 'This is another field of "myObject"'}, + { + name: 'field2', + type: 'myObject', + title: 'A field of myObject', + description: 'This is another field of "myObject"' + }, ] }, {