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:
@ -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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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', () => {
|
||||
|
@ -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: '<script>alert('hello')</script>' },
|
||||
isModification: null,
|
||||
@ -112,6 +117,7 @@ describe('entries', () => {
|
||||
path: '',
|
||||
raw: '',
|
||||
slug: '',
|
||||
updatedOn: '',
|
||||
},
|
||||
type: 'DRAFT_CREATE_EMPTY',
|
||||
});
|
||||
|
108
packages/netlify-cms-core/src/actions/__tests__/search.spec.js
Normal file
108
packages/netlify-cms-core/src/actions/__tests__/search.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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)),
|
||||
);
|
||||
};
|
||||
}
|
@ -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"
|
||||
|
@ -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);
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -13,7 +13,7 @@ const ListCard = styled.li`
|
||||
${components.card};
|
||||
width: ${lengths.topCardWidth};
|
||||
margin-left: 12px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
|
@ -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);
|
@ -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;
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -135,6 +135,12 @@ const getConfigSchema = () => ({
|
||||
},
|
||||
},
|
||||
fields: fieldsConfig,
|
||||
sortableFields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['name', 'label'],
|
||||
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],
|
||||
|
@ -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,
|
||||
},
|
@ -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];
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
@ -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[] };
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user