Feat: entry sorting (#3494)

* refactor: typescript search actions, add tests avoid duplicate search

* refactor: switch from promise chain to async/await in loadEntries

* feat: add sorting, initial commit

* fix: set isFetching to true on entries request

* fix: ui improvments and bug fixes

* test: fix tests

* feat(backend-gitlab): cache local tree)

* fix: fix prop type warning

* refactor: code cleanup

* feat(backend-bitbucket): add local tree caching support

* feat: swtich to orderBy and support multiple sort keys

* fix: backoff function

* fix: improve backoff

* feat: infer sortable fields

* feat: fetch file commit metadata - initial commit

* feat: extract file author and date, finalize GitLab & Bitbucket

* refactor: code cleanup

* feat: handle github rate limit errors

* refactor: code cleanup

* fix(github): add missing author and date when traversing cursor

* fix: add missing author and date when traversing cursor

* refactor: code cleanup

* refactor: code cleanup

* refactor: code cleanup

* test: fix tests

* fix: rebuild local tree when head doesn't exist in remote branch

* fix: allow sortable fields to be an empty array

* fix: allow translation of built in sort fields

* build: fix proxy server build

* fix: hide commit author and date fields by default on non git backends

* fix(algolia): add listAllEntries method for alogolia integration

* fix: handle sort fields overflow

* test(bitbucket): re-record some bitbucket e2e tests

* test(bitbucket): fix media library test

* refactor(gitgateway-gitlab): share request code and handle 404 errors

* fix: always show commit date by default

* docs: add sortableFields

* refactor: code cleanup

* improvement: drop multi-sort, rework sort UI

* chore: force main package bumps

Co-authored-by: Shawn Erquhart <shawn@erquh.art>
This commit is contained in:
Erez Rokah
2020-04-01 06:13:27 +03:00
committed by GitHub
parent cbb3927101
commit 174d86f0a0
82 changed files with 15128 additions and 12621 deletions

View File

@ -186,6 +186,7 @@ describe('Backend', () => {
expect(result).toEqual({
entry: {
author: '',
mediaFiles: [],
collection: 'posts',
slug: 'slug',
@ -196,6 +197,7 @@ describe('Backend', () => {
label: null,
metaData: null,
isModification: null,
updatedOn: '',
},
});
expect(localForage.getItem).toHaveBeenCalledTimes(1);
@ -224,6 +226,7 @@ describe('Backend', () => {
expect(result).toEqual({
entry: {
author: '',
mediaFiles: [{ id: '1' }],
collection: 'posts',
slug: 'slug',
@ -234,6 +237,7 @@ describe('Backend', () => {
label: null,
metaData: null,
isModification: null,
updatedOn: '',
},
});
expect(localForage.getItem).toHaveBeenCalledTimes(1);
@ -367,6 +371,7 @@ describe('Backend', () => {
const result = await backend.unpublishedEntry(state, collection, slug);
expect(result).toEqual({
author: '',
collection: 'posts',
slug: '',
path: 'path',
@ -377,6 +382,7 @@ describe('Backend', () => {
metaData: {},
isModification: true,
mediaFiles: [{ id: '1', draft: true }],
updatedOn: '',
});
});
});

View File

@ -2,6 +2,11 @@ import { fromJS } from 'immutable';
import { applyDefaults, detectProxyServer, handleLocalBackend } from '../config';
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.mock('coreSrc/backend', () => {
return {
currentBackend: jest.fn(() => ({ isGitBackend: jest.fn(() => true) })),
};
});
describe('config', () => {
describe('applyDefaults', () => {

View File

@ -42,6 +42,7 @@ describe('entries', () => {
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: {},
isModification: null,
@ -52,6 +53,7 @@ describe('entries', () => {
path: '',
raw: '',
slug: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
@ -71,6 +73,7 @@ describe('entries', () => {
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: { title: 'title', boolean: true },
isModification: null,
@ -81,6 +84,7 @@ describe('entries', () => {
path: '',
raw: '',
slug: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
@ -102,6 +106,7 @@ describe('entries', () => {
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: { title: '&lt;script&gt;alert(&#039;hello&#039;)&lt;/script&gt;' },
isModification: null,
@ -112,6 +117,7 @@ describe('entries', () => {
path: '',
raw: '',
slug: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});

View File

@ -0,0 +1,108 @@
import { fromJS } from 'immutable';
import { searchEntries } from '../search';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
jest.mock('../../reducers');
jest.mock('../../backend');
jest.mock('../../integrations');
describe('search', () => {
describe('searchEntries', () => {
const { currentBackend } = require('../../backend');
const { selectIntegration } = require('../../reducers');
const { getIntegrationProvider } = require('../../integrations');
beforeEach(() => {
jest.resetAllMocks();
});
it('should search entries using integration', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
});
selectIntegration.mockReturnValue('search_integration');
currentBackend.mockReturnValue({});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const integration = { search: jest.fn().mockResolvedValue(response) };
getIntegrationProvider.mockReturnValue(integration);
await store.dispatch(searchEntries('find me'));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
entries: response.entries,
page: response.pagination,
},
});
expect(integration.search).toHaveBeenCalledTimes(1);
expect(integration.search).toHaveBeenCalledWith(['posts', 'pages'], 'find me', 0);
});
it('should search entries using backend', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const backend = { search: jest.fn().mockResolvedValue(response) };
currentBackend.mockReturnValue(backend);
await store.dispatch(searchEntries('find me'));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
entries: response.entries,
page: response.pagination,
},
});
expect(backend.search).toHaveBeenCalledTimes(1);
expect(backend.search).toHaveBeenCalledWith(
[fromJS({ name: 'posts' }), fromJS({ name: 'pages' })],
'find me',
);
});
it('should ignore identical search', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me' }),
});
await store.dispatch(searchEntries('find me'));
const actions = store.getActions();
expect(actions).toHaveLength(0);
});
});
});

View File

@ -4,6 +4,8 @@ import { trimStart, get, isPlainObject } from 'lodash';
import { authenticateUser } from 'Actions/auth';
import * as publishModes from 'Constants/publishModes';
import { validateConfig } from 'Constants/configSchema';
import { selectDefaultSortableFields } from '../reducers/collections';
import { currentBackend } from 'coreSrc/backend';
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
@ -71,18 +73,26 @@ export function applyDefaults(config) {
if (collection.has('media_folder') && !collection.has('public_folder')) {
collection = collection.set('public_folder', collection.get('media_folder'));
}
return collection.set('folder', trimStart(folder, '/'));
collection = collection.set('folder', trimStart(folder, '/'));
}
const files = collection.get('files');
if (files) {
return collection.set(
collection = collection.set(
'files',
files.map(file => {
return file.set('file', trimStart(file.get('file'), '/'));
}),
);
}
if (!collection.has('sortableFields')) {
const backend = currentBackend(config);
const defaultSortable = selectDefaultSortableFields(collection, backend);
collection = collection.set('sortableFields', fromJS(defaultSortable));
}
return collection;
}),
);
});

View File

@ -1,22 +1,31 @@
import { fromJS, List, Map, Set } from 'immutable';
import { isEqual } from 'lodash';
import { isEqual, orderBy } from 'lodash';
import { actions as notifActions } from 'redux-notifications';
import { serializeValues } from '../lib/serializeEntryValues';
import { currentBackend, Backend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration, selectPublishedSlugs } from '../reducers';
import { selectFields, updateFieldByKey } from '../reducers/collections';
import { selectFields, updateFieldByKey, selectSortDataPath } from '../reducers/collections';
import { selectCollectionEntriesCursor } from '../reducers/cursors';
import { Cursor, ImplementationMediaFile } from 'netlify-cms-lib-util';
import { createEntry, EntryValue } from '../valueObjects/Entry';
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import { addAssets, getAsset } from './media';
import { Collection, EntryMap, State, EntryFields, EntryField } from '../types/redux';
import {
Collection,
EntryMap,
State,
EntryFields,
EntryField,
SortDirection,
} from '../types/redux';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { waitForMediaLibraryToLoad, loadMedia } from './mediaLibrary';
import { waitUntil } from './waitUntil';
import { selectIsFetching, selectEntriesSortFields } from '../reducers/entries';
const { notifSend } = notifActions;
@ -31,6 +40,10 @@ export const ENTRIES_REQUEST = 'ENTRIES_REQUEST';
export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS';
export const ENTRIES_FAILURE = 'ENTRIES_FAILURE';
export const SORT_ENTRIES_REQUEST = 'SORT_ENTRIES_REQUEST';
export const SORT_ENTRIES_SUCCESS = 'SORT_ENTRIES_SUCCESS';
export const SORT_ENTRIES_FAILURE = 'SORT_ENTRIES_FAILURE';
export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY';
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
@ -124,6 +137,69 @@ export function entriesFailed(collection: Collection, error: Error) {
};
}
export function sortByField(
collection: Collection,
key: string,
direction: SortDirection = SortDirection.Ascending,
) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
// if we're already fetching we update the sort key, but skip loading entries
const isFetching = selectIsFetching(state.entries, collection.get('name'));
dispatch({
type: SORT_ENTRIES_REQUEST,
payload: {
collection: collection.get('name'),
key,
direction,
},
});
if (isFetching) {
return;
}
try {
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
const provider: Backend = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration)
: backend;
let entries = await provider.listAllEntries(collection);
const sortFields = selectEntriesSortFields(getState().entries, collection.get('name'));
if (sortFields && sortFields.length > 0) {
const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key')));
const orders = sortFields.map(v =>
v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc',
);
entries = orderBy(entries, keys, orders);
}
dispatch({
type: SORT_ENTRIES_SUCCESS,
payload: {
collection: collection.get('name'),
key,
direction,
entries,
},
});
} catch (error) {
dispatch({
type: SORT_ENTRIES_FAILURE,
payload: {
collection: collection.get('name'),
key,
direction,
error,
},
});
}
};
}
export function entryPersisting(collection: Collection, entry: EntryMap) {
return {
type: ENTRY_PERSIST_REQUEST,
@ -383,11 +459,17 @@ const addAppendActionsToCursor = (cursor: Cursor) => {
};
export function loadEntries(collection: Collection, page = 0) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
if (collection.get('isFetching')) {
return;
}
const state = getState();
const sortFields = selectEntriesSortFields(state.entries, collection.get('name'));
if (sortFields && sortFields.length > 0) {
const field = sortFields[0];
return dispatch(sortByField(collection, field.get('key'), field.get('direction')));
}
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
const provider = integration
@ -395,11 +477,15 @@ export function loadEntries(collection: Collection, page = 0) {
: backend;
const append = !!(page && !isNaN(page) && page > 0);
dispatch(entriesLoading(collection));
provider
.listEntries(collection, page)
.then((response: { cursor: typeof Cursor }) => ({
...response,
try {
let response: {
cursor: Cursor;
pagination: number;
entries: EntryValue[];
} = await provider.listEntries(collection, page);
response = {
...response,
// The only existing backend using the pagination system is the
// Algolia integration, which is also the only integration used
// to list entries. Thus, this checking for an integration can
@ -413,33 +499,32 @@ export function loadEntries(collection: Collection, page = 0) {
data: { nextPage: page + 1 },
})
: Cursor.create(response.cursor),
}))
.then((response: { cursor: Cursor; pagination: number; entries: EntryValue[] }) =>
dispatch(
entriesLoaded(
collection,
response.cursor.meta!.get('usingOldPaginationAPI')
? response.entries.reverse()
: response.entries,
response.pagination,
addAppendActionsToCursor(response.cursor),
append,
),
};
dispatch(
entriesLoaded(
collection,
response.cursor.meta!.get('usingOldPaginationAPI')
? response.entries.reverse()
: response.entries,
response.pagination,
addAppendActionsToCursor(response.cursor),
append,
),
)
.catch((err: Error) => {
dispatch(
notifSend({
message: {
details: err,
key: 'ui.toast.onFailToLoadEntries',
},
kind: 'danger',
dismissAfter: 8000,
}),
);
return Promise.reject(dispatch(entriesFailed(collection, err)));
});
);
} catch (err) {
dispatch(
notifSend({
message: {
details: err,
key: 'ui.toast.onFailToLoadEntries',
},
kind: 'danger',
dismissAfter: 8000,
}),
);
return Promise.reject(dispatch(entriesFailed(collection, err)));
}
};
}
@ -473,10 +558,10 @@ export function traverseCollectionCursor(collection: Collection, action: string)
try {
dispatch(entriesLoading(collection));
const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction);
// Pass null for the old pagination argument - this will
// eventually be removed.
const pagination = newCursor.meta?.get('page');
return dispatch(
entriesLoaded(collection, entries, null, addAppendActionsToCursor(newCursor), append),
entriesLoaded(collection, entries, pagination, addAppendActionsToCursor(newCursor), append),
);
} catch (err) {
console.error(err);
@ -484,7 +569,7 @@ export function traverseCollectionCursor(collection: Collection, action: string)
notifSend({
message: {
details: err,
key: 'ui.toast.onFailToPersist',
key: 'ui.toast.onFailToLoadEntries',
},
kind: 'danger',
dismissAfter: 8000,

View File

@ -1,6 +1,10 @@
import { currentBackend } from 'coreSrc/backend';
import { getIntegrationProvider } from 'Integrations';
import { selectIntegration } from 'Reducers';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { State } from '../types/redux';
import { currentBackend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
import { EntryValue } from '../valueObjects/Entry';
/*
* Constant Declarations
@ -19,14 +23,14 @@ export const SEARCH_CLEAR = 'SEARCH_CLEAR';
* Simple Action Creators (Internal)
* We still need to export them for tests
*/
export function searchingEntries(searchTerm) {
export function searchingEntries(searchTerm: string, page: number) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm },
payload: { searchTerm, page },
};
}
export function searchSuccess(searchTerm, entries, page) {
export function searchSuccess(searchTerm: string, entries: EntryValue[], page: number) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
@ -37,7 +41,7 @@ export function searchSuccess(searchTerm, entries, page) {
};
}
export function searchFailure(searchTerm, error) {
export function searchFailure(searchTerm: string, error: Error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: {
@ -47,7 +51,12 @@ export function searchFailure(searchTerm, error) {
};
}
export function querying(namespace, collection, searchFields, searchTerm) {
export function querying(
namespace: string,
collection: string,
searchFields: string[],
searchTerm: string,
) {
return {
type: QUERY_REQUEST,
payload: {
@ -59,7 +68,18 @@ export function querying(namespace, collection, searchFields, searchTerm) {
};
}
export function querySuccess(namespace, collection, searchFields, searchTerm, response) {
type Response = {
entries: EntryValue[];
pagination: number;
};
export function querySuccess(
namespace: string,
collection: string,
searchFields: string[],
searchTerm: string,
response: Response,
) {
return {
type: QUERY_SUCCESS,
payload: {
@ -72,7 +92,13 @@ export function querySuccess(namespace, collection, searchFields, searchTerm, re
};
}
export function queryFailure(namespace, collection, searchFields, searchTerm, error) {
export function queryFailure(
namespace: string,
collection: string,
searchFields: string[],
searchTerm: string,
error: Error,
) {
return {
type: QUERY_FAILURE,
payload: {
@ -98,17 +124,27 @@ export function clearSearch() {
*/
// SearchEntries will search for complete entries in all collections.
export function searchEntries(searchTerm, page = 0) {
return (dispatch, getState) => {
dispatch(searchingEntries(searchTerm));
export function searchEntries(searchTerm: string, page = 0) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const { search } = state;
const backend = currentBackend(state.config);
const allCollections = state.collections.keySeq().toArray();
const collections = allCollections.filter(collection =>
selectIntegration(state, collection, 'search'),
selectIntegration(state, collection as string, 'search'),
);
const integration = selectIntegration(state, collections[0], 'search');
const integration = selectIntegration(state, collections[0] as string, 'search');
// avoid duplicate searches
if (
search.get('isFetching') === true &&
search.get('term') === searchTerm &&
// if an integration doesn't exist, 'page' is not used
(search.get('page') === page || !integration)
) {
return;
}
dispatch(searchingEntries(searchTerm, page));
const searchPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
@ -119,16 +155,22 @@ export function searchEntries(searchTerm, page = 0) {
: backend.search(state.collections.valueSeq().toArray(), searchTerm);
return searchPromise.then(
response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
error => dispatch(searchFailure(searchTerm, error)),
(response: Response) =>
dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
(error: Error) => dispatch(searchFailure(searchTerm, error)),
);
};
}
// Instead of searching for complete entries, query will search for specific fields
// in specific collections and return raw data (no entries).
export function query(namespace, collectionName, searchFields, searchTerm) {
return (dispatch, getState) => {
export function query(
namespace: string,
collectionName: string,
searchFields: string[],
searchTerm: string,
) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
dispatch(querying(namespace, collectionName, searchFields, searchTerm));
const state = getState();
@ -147,9 +189,10 @@ export function query(namespace, collectionName, searchFields, searchTerm) {
: backend.query(collection, searchFields, searchTerm);
return queryPromise.then(
response =>
(response: Response) =>
dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response)),
error => dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error)),
(error: Error) =>
dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error)),
);
};
}

View File

@ -184,6 +184,10 @@ export class Backend {
return Promise.resolve(null);
}
isGitBackend() {
return this.implementation.isGitBackend?.() || false;
}
updateUserCredentials = (updatedCredentials: Credentials) => {
const storedUser = this.authStore!.retrieve();
if (storedUser && storedUser.backendName === this.backendName) {
@ -273,7 +277,12 @@ export class Backend {
collection.get('name'),
selectEntrySlug(collection, loadedEntry.file.path),
loadedEntry.file.path,
{ raw: loadedEntry.data || '', label: loadedEntry.file.label },
{
raw: loadedEntry.data || '',
label: loadedEntry.file.label,
author: loadedEntry.file.author,
updatedOn: loadedEntry.file.updatedOn,
},
),
);
const formattedEntries = entries.map(this.entryWithFormat(collection));
@ -284,7 +293,7 @@ export class Backend {
return filteredEntries;
}
listEntries(collection: Collection) {
async listEntries(collection: Collection) {
const extension = selectFolderEntryExtension(collection);
let listMethod: () => Promise<ImplementationEntry[]>;
const collectionType = collection.get('type');
@ -307,20 +316,23 @@ export class Backend {
} else {
throw new Error(`Unknown collection type: ${collectionType}`);
}
return listMethod().then((loadedEntries: ImplementationEntry[]) => ({
entries: this.processEntries(loadedEntries, collection),
/*
const loadedEntries = await listMethod();
/*
Wrap cursors so we can tell which collection the cursor is
from. This is done to prevent traverseCursor from requiring a
`collection` argument.
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: 'collectionEntries',
collection,
}),
}));
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
const cursor = Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
cursorType: 'collectionEntries',
collection,
});
return {
entries: this.processEntries(loadedEntries, collection),
pagination: cursor.meta?.get('page'),
cursor,
};
}
// The same as listEntries, except that if a cursor with the "next"

View File

@ -3,12 +3,17 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { translate } from 'react-polyglot';
import { lengths } from 'netlify-cms-ui-default';
import { getNewEntryUrl } from 'Lib/urlHelper';
import Sidebar from './Sidebar';
import CollectionTop from './CollectionTop';
import EntriesCollection from './Entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch';
import CollectionControls from './CollectionControls';
import { sortByField } from 'Actions/entries';
import { selectSortableFields } from 'Reducers/collections';
import { selectEntriesSort } from 'Reducers/entries';
import { VIEW_STYLE_LIST } from 'Constants/collectionViews';
const CollectionContainer = styled.div`
@ -26,6 +31,9 @@ class Collection extends React.Component {
isSearchResults: PropTypes.bool,
collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired,
sortableFields: PropTypes.array,
sort: ImmutablePropTypes.orderedMap,
onSortClick: PropTypes.func.isRequired,
};
state = {
@ -49,21 +57,33 @@ class Collection extends React.Component {
};
render() {
const { collection, collections, collectionName, isSearchResults, searchTerm } = this.props;
const {
collection,
collections,
collectionName,
isSearchResults,
searchTerm,
sortableFields,
onSortClick,
sort,
} = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
return (
<CollectionContainer>
<Sidebar collections={collections} searchTerm={searchTerm} />
<CollectionMain>
{isSearchResults ? null : (
<CollectionTop
collectionLabel={collection.get('label')}
collectionLabelSingular={collection.get('label_singular')}
collectionDescription={collection.get('description')}
newEntryUrl={newEntryUrl}
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
/>
<>
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
<CollectionControls
collection={collection}
viewStyle={this.state.viewStyle}
onChangeViewStyle={this.handleChangeViewStyle}
sortableFields={sortableFields}
onSortClick={onSortClick}
sort={sort}
/>
</>
)}
{isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection()}
</CollectionMain>
@ -74,10 +94,36 @@ class Collection extends React.Component {
function mapStateToProps(state, ownProps) {
const { collections } = state;
const { isSearchResults, match } = ownProps;
const { isSearchResults, match, t } = ownProps;
const { name, searchTerm } = match.params;
const collection = name ? collections.get(name) : collections.first();
return { collection, collections, collectionName: name, isSearchResults, searchTerm };
const sort = selectEntriesSort(state.entries, collection.get('name'));
const sortableFields = selectSortableFields(collection, t);
return {
collection,
collections,
collectionName: name,
isSearchResults,
searchTerm,
sort,
sortableFields,
};
}
export default connect(mapStateToProps)(Collection);
const mapDispatchToProps = {
sortByField,
};
const mergeProps = (stateProps, dispatchProps, ownProps) => {
return {
...stateProps,
...ownProps,
onSortClick: (key, direction) =>
dispatchProps.sortByField(stateProps.collection, key, direction),
};
};
const ConnectedCollection = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Collection);
export default translate()(ConnectedCollection);

View File

@ -0,0 +1,40 @@
import React from 'react';
import styled from '@emotion/styled';
import ViewStyleControl from './ViewStyleControl';
import SortControl from './SortControl';
import { lengths } from 'netlify-cms-ui-default';
const CollectionControlsContainer = styled.div`
display: flex;
align-items: center;
flex-direction: row-reverse;
margin-top: 22px;
width: ${lengths.topCardWidth};
& > div {
margin-left: 6px;
}
`;
const CollectionControls = ({
collection,
viewStyle,
onChangeViewStyle,
sortableFields,
onSortClick,
sort,
}) => (
<CollectionControlsContainer>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{sortableFields.length > 0 && (
<SortControl
fields={sortableFields}
collection={collection}
sort={sort}
onSortClick={onSortClick}
/>
)}
</CollectionControlsContainer>
);
export default CollectionControls;

View File

@ -1,20 +1,20 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { Link } from 'react-router-dom';
import { Icon, components, buttons, shadows, colors } from 'netlify-cms-ui-default';
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
import { components, buttons, shadows } from 'netlify-cms-ui-default';
const CollectionTopContainer = styled.div`
${components.cardTop};
margin-bottom: 22px;
`;
const CollectionTopRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
`;
const CollectionTopHeading = styled.h1`
@ -32,47 +32,27 @@ const CollectionTopNewButton = styled(Link)`
const CollectionTopDescription = styled.p`
${components.cardTopDescription};
margin-bottom: 0;
`;
const ViewControls = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 24px;
`;
const getCollectionProps = collection => {
const collectionLabel = collection.get('label');
const collectionLabelSingular = collection.get('label_singular');
const collectionDescription = collection.get('description');
const ViewControlsText = styled.span`
font-size: 14px;
color: ${colors.text};
margin-right: 12px;
`;
return {
collectionLabel,
collectionLabelSingular,
collectionDescription,
};
};
const ViewControlsButton = styled.button`
${buttons.button};
color: ${props => (props.isActive ? colors.active : '#b3b9c4')};
background-color: transparent;
display: block;
padding: 0;
margin: 0 4px;
const CollectionTop = ({ collection, newEntryUrl, t }) => {
const { collectionLabel, collectionLabelSingular, collectionDescription } = getCollectionProps(
collection,
t,
);
&:last-child {
margin-right: 0;
}
${Icon} {
display: block;
}
`;
const CollectionTop = ({
collectionLabel,
collectionLabelSingular,
collectionDescription,
viewStyle,
onChangeViewStyle,
newEntryUrl,
t,
}) => {
return (
<CollectionTopContainer>
<CollectionTopRow>
@ -88,31 +68,12 @@ const CollectionTop = ({
{collectionDescription ? (
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
) : null}
<ViewControls>
<ViewControlsText>{t('collection.collectionTop.viewAs')}:</ViewControlsText>
<ViewControlsButton
isActive={viewStyle === VIEW_STYLE_LIST}
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
>
<Icon type="list" />
</ViewControlsButton>
<ViewControlsButton
isActive={viewStyle === VIEW_STYLE_GRID}
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
>
<Icon type="grid" />
</ViewControlsButton>
</ViewControls>
</CollectionTopContainer>
);
};
CollectionTop.propTypes = {
collectionLabel: PropTypes.string.isRequired,
collectionLabelSingular: PropTypes.string,
collectionDescription: PropTypes.string,
viewStyle: PropTypes.oneOf([VIEW_STYLE_LIST, VIEW_STYLE_GRID]).isRequired,
onChangeViewStyle: PropTypes.func.isRequired,
collection: ImmutablePropTypes.map.isRequired,
newEntryUrl: PropTypes.string,
t: PropTypes.func.isRequired,
};

View File

@ -1,10 +1,21 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from '@emotion/styled';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { Loader } from 'netlify-cms-ui-default';
import { Loader, lengths } from 'netlify-cms-ui-default';
import EntryListing from './EntryListing';
const PaginationMessage = styled.div`
width: ${lengths.topCardWidth};
padding: 16px;
text-align: center;
`;
const NoEntriesMessage = styled(PaginationMessage)`
margin-top: 16px;
`;
const Entries = ({
collections,
entries,
@ -13,6 +24,7 @@ const Entries = ({
cursor,
handleCursorActions,
t,
page,
}) => {
const loadingMessages = [
t('collection.entries.loadingEntries'),
@ -20,27 +32,32 @@ const Entries = ({
t('collection.entries.longerLoading'),
];
if (entries) {
return (
<EntryListing
collections={collections}
entries={entries}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
/>
);
}
if (isFetching) {
if (isFetching && page === undefined) {
return <Loader active>{loadingMessages}</Loader>;
}
return <div className="nc-collectionPage-noEntries">No Entries</div>;
if (entries && entries.size > 0) {
return (
<>
<EntryListing
collections={collections}
entries={entries}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
/>
{isFetching && page !== undefined ? (
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
) : null}
</>
);
}
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
};
Entries.propTypes = {
collections: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.iterable.isRequired,
entries: ImmutablePropTypes.list,
page: PropTypes.number,
isFetching: PropTypes.bool,

View File

@ -8,13 +8,14 @@ import {
loadEntries as actionLoadEntries,
traverseCollectionCursor as actionTraverseCollectionCursor,
} from 'Actions/entries';
import { selectEntries } from 'Reducers';
import { selectEntries, selectEntriesLoaded, selectIsFetching } from '../../../reducers/entries';
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import Entries from './Entries';
class EntriesCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
page: PropTypes.number,
entries: ImmutablePropTypes.list,
isFetching: PropTypes.bool.isRequired,
viewStyle: PropTypes.string,
@ -44,7 +45,7 @@ class EntriesCollection extends React.Component {
};
render() {
const { collection, entries, isFetching, viewStyle, cursor } = this.props;
const { collection, entries, isFetching, viewStyle, cursor, page } = this.props;
return (
<Entries
@ -55,6 +56,7 @@ class EntriesCollection extends React.Component {
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={partial(this.handleCursorActions, cursor)}
page={page}
/>
);
}
@ -64,9 +66,9 @@ function mapStateToProps(state, ownProps) {
const { collection, viewStyle } = ownProps;
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
const entries = selectEntries(state, collection.get('name'));
const entriesLoaded = !!state.entries.getIn(['pages', collection.get('name')]);
const isFetching = state.entries.getIn(['pages', collection.get('name'), 'isFetching'], false);
const entries = selectEntries(state.entries, collection.get('name'));
const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name'));
const isFetching = selectIsFetching(state.entries, collection.get('name'));
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
const cursor = Cursor.create(rawCursor).clearData();

View File

@ -13,7 +13,7 @@ const ListCard = styled.li`
${components.card};
width: ${lengths.topCardWidth};
margin-left: 12px;
margin-bottom: 16px;
margin-bottom: 10px;
overflow: hidden;
`;

View File

@ -0,0 +1,69 @@
import React from 'react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { buttons, Dropdown, DropdownItem, StyledDropdownButton } from 'netlify-cms-ui-default';
import { SortDirection } from '../../types/redux';
const SortButton = styled(StyledDropdownButton)`
${buttons.button};
${buttons.medium};
${buttons.grayText};
font-size: 14px;
&:after {
top: 11px;
}
`;
function nextSortDirection(direction) {
switch (direction) {
case SortDirection.Ascending:
return SortDirection.Descending;
case SortDirection.Descending:
return SortDirection.None;
default:
return SortDirection.Ascending;
}
}
function sortIconProps(sortDir) {
return {
icon: 'chevron',
iconDirection: sortIconDirections[sortDir],
iconSmall: true,
};
}
const sortIconDirections = {
[SortDirection.Ascending]: 'up',
[SortDirection.Descending]: 'down',
};
const SortControl = ({ t, fields, onSortClick, sort }) => {
return (
<Dropdown
renderButton={() => <SortButton>{t('collection.collectionTop.sortBy')}</SortButton>}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{fields.map(field => {
const sortDir = sort?.getIn([field.key, 'direction']);
const isActive = sortDir && sortDir !== SortDirection.None;
const nextSortDir = nextSortDirection(sortDir);
return (
<DropdownItem
key={field.key}
label={field.label}
onClick={() => onSortClick(field.key, nextSortDir)}
isActive={isActive}
{...(isActive && sortIconProps(sortDir))}
/>
);
})}
</Dropdown>
);
};
export default translate()(SortControl);

View File

@ -0,0 +1,49 @@
import React from 'react';
import styled from '@emotion/styled';
import { Icon, buttons, colors } from 'netlify-cms-ui-default';
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
const ViewControlsSection = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
max-width: 500px;
`;
const ViewControlsButton = styled.button`
${buttons.button};
color: ${props => (props.isActive ? colors.active : '#b3b9c4')};
background-color: transparent;
display: block;
padding: 0;
margin: 0 4px;
&:last-child {
margin-right: 0;
}
${Icon} {
display: block;
}
`;
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }) => {
return (
<ViewControlsSection>
<ViewControlsButton
isActive={viewStyle === VIEW_STYLE_LIST}
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
>
<Icon type="list" />
</ViewControlsButton>
<ViewControlsButton
isActive={viewStyle === VIEW_STYLE_GRID}
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
>
<Icon type="grid" />
</ViewControlsButton>
</ViewControlsSection>
);
};
export default ViewStyleControl;

View File

@ -164,5 +164,23 @@ describe('config', () => {
validateConfig(merge(validConfig, { collections: [{ publish: false }] }));
}).not.toThrowError();
});
it('should throw if collections sortableFields is not a boolean or a string array', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ sortableFields: 'title' }] }));
}).toThrowError("'collections[0].sortableFields' should be array");
});
it('should allow sortableFields to be a string array', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ sortableFields: ['title'] }] }));
}).not.toThrow();
});
it('should allow sortableFields to be a an empty array', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ sortableFields: [] }] }));
}).not.toThrow();
});
});
});

View File

@ -135,6 +135,12 @@ const getConfigSchema = () => ({
},
},
fields: fieldsConfig,
sortableFields: {
type: 'array',
items: {
type: 'string',
},
},
},
required: ['name', 'label'],
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],

View File

@ -2,12 +2,14 @@ import React from 'react';
export const IDENTIFIER_FIELDS = ['title', 'path'];
export const SORTABLE_FIELDS = ['title', 'date', 'author', 'description'];
export const INFERABLE_FIELDS = {
title: {
type: 'string',
secondaryTypes: [],
synonyms: ['title', 'name', 'label', 'headline', 'header'],
defaultPreview: value => <h1>{value}</h1>, // eslint-disable-line react/display-name
defaultPreview: (value: React.ReactNode) => <h1>{value}</h1>, // eslint-disable-line react/display-name
fallbackToFirstField: true,
showError: true,
},
@ -15,7 +17,7 @@ export const INFERABLE_FIELDS = {
type: 'string',
secondaryTypes: [],
synonyms: ['short_title', 'shortTitle', 'short'],
defaultPreview: value => <h2>{value}</h2>, // eslint-disable-line react/display-name
defaultPreview: (value: React.ReactNode) => <h2>{value}</h2>, // eslint-disable-line react/display-name
fallbackToFirstField: false,
showError: false,
},
@ -23,7 +25,7 @@ export const INFERABLE_FIELDS = {
type: 'string',
secondaryTypes: [],
synonyms: ['author', 'name', 'by', 'byline', 'owner'],
defaultPreview: value => <strong>{value}</strong>, // eslint-disable-line react/display-name
defaultPreview: (value: React.ReactNode) => <strong>{value}</strong>, // eslint-disable-line react/display-name
fallbackToFirstField: false,
showError: false,
},
@ -31,7 +33,7 @@ export const INFERABLE_FIELDS = {
type: 'datetime',
secondaryTypes: ['date'],
synonyms: ['date', 'publishDate', 'publish_date'],
defaultPreview: value => value,
defaultPreview: (value: React.ReactNode) => value,
fallbackToFirstField: false,
showError: false,
},
@ -51,7 +53,7 @@ export const INFERABLE_FIELDS = {
'bio',
'summary',
],
defaultPreview: value => value,
defaultPreview: (value: React.ReactNode) => value,
fallbackToFirstField: false,
showError: false,
},
@ -69,7 +71,7 @@ export const INFERABLE_FIELDS = {
'hero',
'logo',
],
defaultPreview: value => value,
defaultPreview: (value: React.ReactNode) => value,
fallbackToFirstField: false,
showError: false,
},

View File

@ -129,6 +129,37 @@ export default class Algolia {
}
}
async listAllEntries(collection) {
const params = {
hitsPerPage: 1000,
};
let response = await this.request(
`${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`,
{ params },
);
let { nbPages = 0, hits, page } = response;
page = page + 1;
while (page < nbPages) {
response = await this.request(
`${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`,
{
params: { ...params, page },
},
);
hits = [...hits, ...response.hits];
page = page + 1;
}
const entries = hits.map(hit => {
const slug = selectEntrySlug(collection, hit.path);
return createEntry(collection.get('name'), slug, hit.path, {
data: hit.data,
partial: true,
});
});
return entries;
}
getEntry(collection, slug) {
return this.searchBy('slug', collection.get('name'), slug).then(response => {
const entry = response.hits.filter(hit => hit.slug === slug)[0];

View File

@ -7,7 +7,7 @@ import {
SLUG_MISSING_REQUIRED_DATE,
keyToPathArray,
} from './stringTemplate';
import { selectIdentifier } from '../reducers/collections';
import { selectIdentifier, selectField, COMMIT_AUTHOR, COMMIT_DATE } from '../reducers/collections';
import { Collection, SlugConfig, Config, EntryMap } from '../types/redux';
import { stripIndent } from 'common-tags';
import { basename, fileExtension } from 'netlify-cms-lib-util';
@ -205,6 +205,13 @@ export const summaryFormatter = (
const identifier = entryData.getIn(keyToPathArray(selectIdentifier(collection) as string));
entryData = addFileTemplateFields(entry.get('path'), entryData);
// allow commit information in summary template
if (entry.get('author') && !selectField(collection, COMMIT_AUTHOR)) {
entryData = entryData.set(COMMIT_AUTHOR, entry.get('author'));
}
if (entry.get('updatedOn') && !selectField(collection, COMMIT_DATE)) {
entryData = entryData.set(COMMIT_DATE, entry.get('updatedOn'));
}
const summary = compileStringTemplate(summaryTemplate, date, identifier, entryData);
return summary;
};

View File

@ -3,7 +3,7 @@ import { get, escapeRegExp } from 'lodash';
import consoleError from '../lib/consoleError';
import { CONFIG_SUCCESS } from '../actions/config';
import { FILES, FOLDER } from '../constants/collectionTypes';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS } from '../constants/fieldInference';
import { INFERABLE_FIELDS, IDENTIFIER_FIELDS, SORTABLE_FIELDS } from '../constants/fieldInference';
import { formatExtensions } from '../formats/formats';
import {
CollectionsAction,
@ -15,6 +15,7 @@ import {
} from '../types/redux';
import { selectMediaFolder } from './entries';
import { keyToPathArray } from '../lib/stringTemplate';
import { Backend } from '../backend';
const collections = (state = null, action: CollectionsAction) => {
switch (action.type) {
@ -288,6 +289,7 @@ export const selectIdentifier = (collection: Collection) => {
fieldNames.find(name => name?.toLowerCase().trim() === id.toLowerCase().trim()),
);
};
export const selectInferedField = (collection: Collection, fieldName: string) => {
if (fieldName === 'title' && collection.get('identifier_field')) {
return selectIdentifier(collection);
@ -337,4 +339,56 @@ export const selectInferedField = (collection: Collection, fieldName: string) =>
return null;
};
export const COMMIT_AUTHOR = 'commit_author';
export const COMMIT_DATE = 'commit_date';
export const selectDefaultSortableFields = (collection: Collection, backend: Backend) => {
let defaultSortable = SORTABLE_FIELDS.map((type: string) => {
const field = selectInferedField(collection, type);
if (backend.isGitBackend() && type === 'author' && !field) {
// default to commit author if not author field is found
return COMMIT_AUTHOR;
}
return field;
}).filter(Boolean);
if (backend.isGitBackend()) {
// always have commit date by default
defaultSortable = [COMMIT_DATE, ...defaultSortable];
}
return defaultSortable as string[];
};
export const selectSortableFields = (collection: Collection, t: (key: string) => string) => {
const fields = collection
.get('sortableFields')
.toArray()
.map(key => {
if (key === COMMIT_DATE) {
return { key, field: { name: key, label: t('collection.defaultFields.updatedOn.label') } };
}
const field = selectField(collection, key);
if (key === COMMIT_AUTHOR && !field) {
return { key, field: { name: key, label: t('collection.defaultFields.author.label') } };
}
return { key, field: field?.toJS() };
})
.filter(item => !!item.field)
.map(item => ({ ...item.field, key: item.key }));
return fields;
};
export const selectSortDataPath = (collection: Collection, key: string) => {
if (key === COMMIT_DATE) {
return 'updatedOn';
} else if (key === COMMIT_AUTHOR && !selectField(collection, key)) {
return 'author';
} else {
return `data.${key}`;
}
};
export default collections;

View File

@ -1,6 +1,6 @@
import { fromJS } from 'immutable';
import { Cursor } from 'netlify-cms-lib-util';
import { ENTRIES_SUCCESS } from 'Actions/entries';
import { ENTRIES_SUCCESS, SORT_ENTRIES_SUCCESS } from 'Actions/entries';
// Since pagination can be used for a variety of views (collections
// and searches are the most common examples), we namespace cursors by
@ -16,7 +16,9 @@ const cursors = (state = fromJS({ cursorsByType: { collectionEntries: {} } }), a
Cursor.create(action.payload.cursor).store,
);
}
case SORT_ENTRIES_SUCCESS: {
return state.deleteIn(['cursorsByType', 'collectionEntries', action.payload.collection]);
}
default:
return state;
}

View File

@ -1,4 +1,4 @@
import { Map, List, fromJS } from 'immutable';
import { Map, List, fromJS, OrderedMap } from 'immutable';
import { dirname, join } from 'path';
import {
ENTRY_REQUEST,
@ -8,6 +8,9 @@ import {
ENTRIES_SUCCESS,
ENTRIES_FAILURE,
ENTRY_DELETE_SUCCESS,
SORT_ENTRIES_REQUEST,
SORT_ENTRIES_SUCCESS,
SORT_ENTRIES_FAILURE,
} from '../actions/entries';
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
import {
@ -26,10 +29,17 @@ import {
EntryMap,
EntryField,
CollectionFiles,
EntriesSortRequestPayload,
EntriesSortSuccessPayload,
EntriesSortFailurePayload,
SortMap,
SortObject,
Sort,
SortDirection,
} from '../types/redux';
import { folderFormatter } from '../lib/formatters';
import { isAbsolutePath, basename } from 'netlify-cms-lib-util';
import { trim } from 'lodash';
import { trim, once, sortBy, set } from 'lodash';
let collection: string;
let loadedEntries: EntryObject[];
@ -37,7 +47,60 @@ let append: boolean;
let page: number;
let slug: string;
const entries = (state = Map({ entities: Map(), pages: Map() }), action: EntriesAction) => {
const storageSortKey = 'netlify-cms.entries.sort';
type StorageSortObject = SortObject & { index: number };
type StorageSort = { [collection: string]: { [key: string]: StorageSortObject } };
const loadSort = once(() => {
const sortString = localStorage.getItem(storageSortKey);
if (sortString) {
try {
const sort: StorageSort = JSON.parse(sortString);
let map = Map() as Sort;
Object.entries(sort).forEach(([collection, sort]) => {
let orderedMap = OrderedMap() as SortMap;
sortBy(Object.values(sort), ['index']).forEach(value => {
const { key, direction } = value;
orderedMap = orderedMap.set(key, fromJS({ key, direction }));
});
map = map.set(collection, orderedMap);
});
return map;
} catch (e) {
return Map() as Sort;
}
}
return Map() as Sort;
});
const clearSort = () => {
localStorage.removeItem(storageSortKey);
};
const persistSort = (sort: Sort | undefined) => {
if (sort) {
const storageSort: StorageSort = {};
sort.keySeq().forEach(key => {
const collection = key as string;
const sortObjects = (sort
.get(collection)
.valueSeq()
.toJS() as SortObject[]).map((value, index) => ({ ...value, index }));
sortObjects.forEach(value => {
set(storageSort, [collection, value.key], value);
});
});
localStorage.setItem(storageSortKey, JSON.stringify(storageSort));
} else {
clearSort();
}
};
const entries = (
state = Map({ entities: Map(), pages: Map(), sort: loadSort() }),
action: EntriesAction,
) => {
switch (action.type) {
case ENTRY_REQUEST: {
const payload = action.payload as EntryRequestPayload;
@ -59,7 +122,13 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action: Entries
case ENTRIES_REQUEST: {
const payload = action.payload as EntriesRequestPayload;
return state.setIn(['pages', payload.collection, 'isFetching'], true);
const newState = state.withMutations(map => {
map.deleteIn(['sort', payload.collection]);
map.setIn(['pages', payload.collection, 'isFetching'], true);
});
clearSort();
return newState;
}
case ENTRIES_SUCCESS: {
@ -123,11 +192,74 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action: Entries
});
}
case SORT_ENTRIES_REQUEST: {
const payload = action.payload as EntriesSortRequestPayload;
const { collection, key, direction } = payload;
const newState = state.withMutations(map => {
const sort = OrderedMap({ [key]: Map({ key, direction }) });
map.setIn(['sort', collection], sort);
map.setIn(['pages', collection, 'isFetching'], true);
map.deleteIn(['pages', collection, 'page']);
});
persistSort(newState.get('sort') as Sort);
return newState;
}
case SORT_ENTRIES_SUCCESS: {
const payload = action.payload as EntriesSortSuccessPayload;
const { collection, entries } = payload;
loadedEntries = entries;
const newState = state.withMutations(map => {
loadedEntries.forEach(entry =>
map.setIn(
['entities', `${entry.collection}.${entry.slug}`],
fromJS(entry).set('isFetching', false),
),
);
map.setIn(['pages', collection, 'isFetching'], false);
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(
['pages', collection],
Map({
page: 1,
ids,
}),
);
});
return newState;
}
case SORT_ENTRIES_FAILURE: {
const payload = action.payload as EntriesSortFailurePayload;
const { collection, key } = payload;
const newState = state.withMutations(map => {
map.deleteIn(['sort', collection, key]);
map.setIn(['pages', collection, 'isFetching'], false);
});
persistSort(newState.get('sort') as Sort);
return newState;
}
default:
return state;
}
};
export const selectEntriesSort = (entries: Entries, collection: string) => {
const sort = entries.get('sort') as Sort | undefined;
return sort?.get(collection);
};
export const selectEntriesSortFields = (entries: Entries, collection: string) => {
const sort = selectEntriesSort(entries, collection);
const values =
sort
?.valueSeq()
.filter(v => v?.get('direction') !== SortDirection.None)
.toArray() || [];
return values;
};
export const selectEntry = (state: Entries, collection: string, slug: string) =>
state.getIn(['entities', `${collection}.${slug}`]);
@ -136,7 +268,18 @@ export const selectPublishedSlugs = (state: Entries, collection: string) =>
export const selectEntries = (state: Entries, collection: string) => {
const slugs = selectPublishedSlugs(state, collection);
return slugs && slugs.map(slug => selectEntry(state, collection, slug as string));
const entries =
slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List<EntryMap>);
return entries;
};
export const selectEntriesLoaded = (state: Entries, collection: string) => {
return !!state.getIn(['pages', collection]);
};
export const selectIsFetching = (state: Entries, collection: string) => {
return state.getIn(['pages', collection, 'isFetching'], false);
};
const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';

View File

@ -31,6 +31,7 @@ const entries = (state = defaultState, action) => {
return state.withMutations(map => {
map.set('isFetching', true);
map.set('term', action.payload.searchTerm);
map.set('page', action.payload.page);
});
}
return state;

View File

@ -24,8 +24,9 @@ export interface StaticallyTypedRecord<T> {
filter<K extends keyof T>(
predicate: (value: T[K], key: K, iter: this) => boolean,
): StaticallyTypedRecord<T>;
valueSeq<K extends keyof T>(): T[K][];
valueSeq<K extends keyof T>(): T[K][] & { toArray: () => T[K][] };
map<K extends keyof T, V>(
mapFunc: (value: T[K]) => V,
): StaticallyTypedRecord<{ [key: string]: V }>;
keySeq<K extends keyof T>(): { toArray: () => K[] };
}

View File

@ -1,6 +1,6 @@
import { Action } from 'redux';
import { StaticallyTypedRecord } from './immutable';
import { Map, List } from 'immutable';
import { Map, List, OrderedMap } from 'immutable';
import AssetProxy from '../valueObjects/AssetProxy';
import { MediaFile as BackendMediaFile } from '../backend';
@ -52,11 +52,24 @@ type Pages = StaticallyTypedRecord<PagesObject>;
type EntitiesObject = { [key: string]: EntryMap };
export enum SortDirection {
Ascending = 'Ascending',
Descending = 'Descending',
None = 'None',
}
export type SortObject = { key: string; direction: SortDirection };
export type SortMap = OrderedMap<string, StaticallyTypedRecord<SortObject>>;
export type Sort = Map<string, SortMap>;
export type Entities = StaticallyTypedRecord<EntitiesObject>;
export type Entries = StaticallyTypedRecord<{
pages: Pages & PagesObject;
entities: Entities & EntitiesObject;
sort: Sort;
}>;
export type Deploys = StaticallyTypedRecord<{}>;
@ -76,6 +89,8 @@ export type EntryObject = {
mediaFiles: List<MediaFileMap>;
newRecord: boolean;
metaData: { status: string };
author?: string;
updatedOn?: string;
};
export type EntryMap = StaticallyTypedRecord<EntryObject>;
@ -140,6 +155,7 @@ type CollectionObject = {
slug?: string;
label_singular?: string;
label: string;
sortableFields: List<string>;
};
export type Collection = StaticallyTypedRecord<CollectionObject>;
@ -201,7 +217,12 @@ interface SearchItem {
slug: string;
}
export type Search = StaticallyTypedRecord<{ entryIds?: SearchItem[] }>;
export type Search = StaticallyTypedRecord<{
entryIds?: SearchItem[];
isFetching: boolean;
term: string | null;
page: number;
}>;
export type Cursors = StaticallyTypedRecord<{}>;
@ -269,6 +290,18 @@ export interface EntriesSuccessPayload extends EntryPayload {
append: boolean;
page: number;
}
export interface EntriesSortRequestPayload extends EntryPayload {
key: string;
direction: string;
}
export interface EntriesSortSuccessPayload extends EntriesSortRequestPayload {
entries: EntryObject[];
}
export interface EntriesSortFailurePayload extends EntriesSortRequestPayload {
error: Error;
}
export interface EntriesAction extends Action<string> {
payload:

View File

@ -10,6 +10,8 @@ interface Options {
metaData?: unknown | null;
isModification?: boolean | null;
mediaFiles?: MediaFile[] | null;
author?: string;
updatedOn?: string;
}
export interface EntryValue {
@ -24,6 +26,8 @@ export interface EntryValue {
metaData: unknown | null;
isModification: boolean | null;
mediaFiles: MediaFile[];
author: string;
updatedOn: string;
}
export function createEntry(collection: string, slug = '', path = '', options: Options = {}) {
@ -38,6 +42,8 @@ export function createEntry(collection: string, slug = '', path = '', options: O
metaData: options.metaData || null,
isModification: isBoolean(options.isModification) ? options.isModification : null,
mediaFiles: options.mediaFiles || [],
author: options.author || '',
updatedOn: options.updatedOn || '',
};
return returnObj;