feat: bundle assets with content (#2958)
* fix(media_folder_relative): use collection name in unpublished entry * refactor: pass arguments as object to AssetProxy ctor * feat: support media folders per collection * feat: resolve media files path based on entry path * fix: asset public path resolving * refactor: introduce typescript for AssetProxy * refactor: code cleanup * refactor(asset-proxy): add tests,switch to typescript,extract arguments * refactor: typescript for editorialWorkflow * refactor: add typescript for media library actions * refactor: fix type error on map set * refactor: move locale selector into reducer * refactor: add typescript for entries actions * refactor: remove duplication between asset store and media lib * feat: load assets from backend using API * refactor(github): add typescript, cache media files * fix: don't load media URL if already loaded * feat: add media folder config to collection * fix: load assets from API when not in UI state * feat: load entry media files when opening media library * fix: editorial workflow draft media files bug fixes * test(unit): fix unit tests * fix: editor control losing focus * style: add eslint object-shorthand rule * test(cypress): re-record mock data * fix: fix non github backends, large media * test: uncomment only in tests * fix(backend-test): add missing displayURL property * test(e2e): add media library tests * test(e2e): enable visual testing * test(e2e): add github backend media library tests * test(e2e): add git-gateway large media tests * chore: post rebase fixes * test: fix tests * test: fix tests * test(cypress): fix tests * docs: add media_folder docs * test(e2e): add media library delete test * test(e2e): try and fix image comparison on CI * ci: reduce test machines from 9 to 8 * test: add reducers and selectors unit tests * test(e2e): disable visual regression testing for now * test: add getAsset unit tests * refactor: use Asset class component instead of hooks * build: don't inline source maps * test: add more media path tests
This commit is contained in:
committed by
Shawn Erquhart
parent
7e4d4c1cc4
commit
2b41d8a838
43
packages/netlify-cms-core/index.d.ts
vendored
43
packages/netlify-cms-core/index.d.ts
vendored
@ -1,21 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module 'netlify-cms-core' {
|
||||
import React, { ComponentType } from 'react';
|
||||
import { Map } from 'immutable';
|
||||
|
||||
export type CmsBackendType
|
||||
= 'git-gateway'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'bitbucket'
|
||||
| 'test-repo';
|
||||
export type CmsBackendType = 'git-gateway' | 'github' | 'gitlab' | 'bitbucket' | 'test-repo';
|
||||
|
||||
export type CmsMapWidgetType
|
||||
= 'Point'
|
||||
| 'LineString'
|
||||
| 'Polygon';
|
||||
export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon';
|
||||
|
||||
export type CmsMarkdownWidgetButton
|
||||
= 'bold'
|
||||
export type CmsMarkdownWidgetButton =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'code'
|
||||
| 'link'
|
||||
@ -30,17 +23,10 @@ declare module 'netlify-cms-core' {
|
||||
| 'bulleted-list'
|
||||
| 'numbered-list';
|
||||
|
||||
export type CmsFilesExtension
|
||||
= 'yml'
|
||||
| 'yaml'
|
||||
| 'toml'
|
||||
| 'json'
|
||||
| 'md'
|
||||
| 'markdown'
|
||||
| 'html';
|
||||
export type CmsFilesExtension = 'yml' | 'yaml' | 'toml' | 'json' | 'md' | 'markdown' | 'html';
|
||||
|
||||
export type CmsCollectionFormatType
|
||||
= 'yml'
|
||||
export type CmsCollectionFormatType =
|
||||
| 'yml'
|
||||
| 'yaml'
|
||||
| 'toml'
|
||||
| 'json'
|
||||
@ -219,12 +205,19 @@ declare module 'netlify-cms-core' {
|
||||
registerMediaLibrary: (mediaLibrary: CmsMediaLibrary, options?: CmsMediaLibraryOptions) => void;
|
||||
registerPreviewStyle: (filePath: string, options?: PreviewStyleOptions) => void;
|
||||
registerPreviewTemplate: (name: string, component: ComponentType) => void;
|
||||
registerWidget: (widget: string | CmsWidgetParam, control: ComponentType, preview?: ComponentType) => void;
|
||||
registerWidgetValueSerializer: (widgetName: string, serializer: CmsWidgetValueSerializer) => void;
|
||||
registerWidget: (
|
||||
widget: string | CmsWidgetParam,
|
||||
control: ComponentType,
|
||||
preview?: ComponentType,
|
||||
) => void;
|
||||
registerWidgetValueSerializer: (
|
||||
widgetName: string,
|
||||
serializer: CmsWidgetValueSerializer,
|
||||
) => void;
|
||||
resolveWidget: (name: string) => CmsWidget | undefined;
|
||||
}
|
||||
|
||||
export const NetlifyCmsCore: CMS;
|
||||
|
||||
export default NetlifyCmsCore;
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
"scripts": {
|
||||
"develop": "yarn build:esm --watch",
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
|
||||
},
|
||||
"keywords": [
|
||||
"netlify",
|
||||
|
@ -6,10 +6,7 @@ import { Map, List, fromJS } from 'immutable';
|
||||
jest.mock('Lib/registry');
|
||||
jest.mock('netlify-cms-lib-util');
|
||||
jest.mock('Formats/formats');
|
||||
|
||||
const configWrapper = inputObject => ({
|
||||
get: prop => inputObject[prop],
|
||||
});
|
||||
jest.mock('../lib/urlHelper');
|
||||
|
||||
describe('Backend', () => {
|
||||
describe('filterEntries', () => {
|
||||
@ -19,9 +16,13 @@ describe('Backend', () => {
|
||||
registry.getBackend.mockReturnValue({
|
||||
init: jest.fn(),
|
||||
});
|
||||
backend = resolveBackend({
|
||||
getIn: jest.fn().mockReturnValue('git-gateway'),
|
||||
});
|
||||
backend = resolveBackend(
|
||||
Map({
|
||||
backend: Map({
|
||||
name: 'git-gateway',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters string values', () => {
|
||||
@ -40,7 +41,7 @@ describe('Backend', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
configWrapper({ field: 'testField', value: 'testValue' }),
|
||||
Map({ field: 'testField', value: 'testValue' }),
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
@ -62,7 +63,7 @@ describe('Backend', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
configWrapper({ field: 'testField', value: 42 }),
|
||||
Map({ field: 'testField', value: 42 }),
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
@ -84,7 +85,7 @@ describe('Backend', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
configWrapper({ field: 'testField', value: false }),
|
||||
Map({ field: 'testField', value: false }),
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
@ -106,7 +107,7 @@ describe('Backend', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
configWrapper({ field: 'testField', value: 'testValue' }),
|
||||
Map({ field: 'testField', value: 'testValue' }),
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
@ -184,9 +185,8 @@ describe('Backend', () => {
|
||||
const result = await backend.getLocalDraftBackup(collection, slug);
|
||||
|
||||
expect(result).toEqual({
|
||||
assets: [],
|
||||
mediaFiles: [],
|
||||
entry: {
|
||||
mediaFiles: [],
|
||||
collection: 'posts',
|
||||
slug: 'slug',
|
||||
path: '',
|
||||
@ -218,15 +218,13 @@ describe('Backend', () => {
|
||||
localForage.getItem.mockReturnValue({
|
||||
raw: 'content',
|
||||
mediaFiles: [{ id: '1' }],
|
||||
assets: [{ public_path: 'public_path' }],
|
||||
});
|
||||
|
||||
const result = await backend.getLocalDraftBackup(collection, slug);
|
||||
|
||||
expect(result).toEqual({
|
||||
assets: [{ public_path: 'public_path' }],
|
||||
mediaFiles: [{ id: '1' }],
|
||||
entry: {
|
||||
mediaFiles: [{ id: '1' }],
|
||||
collection: 'posts',
|
||||
slug: 'slug',
|
||||
path: '',
|
||||
@ -270,7 +268,7 @@ describe('Backend', () => {
|
||||
slug,
|
||||
});
|
||||
|
||||
await backend.persistLocalDraftBackup(entry, collection, List(), List());
|
||||
await backend.persistLocalDraftBackup(entry, collection);
|
||||
|
||||
expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
|
||||
expect(backend.entryToRaw).toHaveBeenCalledWith(collection, entry);
|
||||
@ -296,18 +294,15 @@ describe('Backend', () => {
|
||||
const entry = Map({
|
||||
slug,
|
||||
path: 'content/posts/entry.md',
|
||||
mediaFiles: List([{ id: '1' }]),
|
||||
});
|
||||
|
||||
const mediaFiles = List([{ id: '1' }]);
|
||||
const assets = List([{ public_path: 'public_path' }]);
|
||||
|
||||
await backend.persistLocalDraftBackup(entry, collection, mediaFiles, assets);
|
||||
await backend.persistLocalDraftBackup(entry, collection);
|
||||
|
||||
expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
|
||||
expect(backend.entryToRaw).toHaveBeenCalledWith(collection, entry);
|
||||
expect(localForage.setItem).toHaveBeenCalledTimes(2);
|
||||
expect(localForage.setItem).toHaveBeenCalledWith('backup.posts.slug', {
|
||||
assets: [{ public_path: 'public_path' }],
|
||||
mediaFiles: [{ id: '1' }],
|
||||
path: 'content/posts/entry.md',
|
||||
raw: 'content',
|
||||
@ -331,12 +326,12 @@ describe('Backend', () => {
|
||||
|
||||
const file = { path: 'static/media/image.png' };
|
||||
|
||||
const result = await backend.persistMedia(config, file, true);
|
||||
const result = await backend.persistMedia(config, file);
|
||||
expect(result).toBe(persistMediaResult);
|
||||
expect(implementation.persistMedia).toHaveBeenCalledTimes(1);
|
||||
expect(implementation.persistMedia).toHaveBeenCalledWith(
|
||||
{ path: 'static/media/image.png' },
|
||||
{ commitMessage: 'Upload “static/media/image.png”', draft: true },
|
||||
{ commitMessage: 'Upload “static/media/image.png”' },
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -366,7 +361,7 @@ describe('Backend', () => {
|
||||
|
||||
const result = await backend.unpublishedEntry(collection, slug);
|
||||
expect(result).toEqual({
|
||||
collection: 'draft',
|
||||
collection: 'posts',
|
||||
slug: '',
|
||||
path: 'path',
|
||||
partial: false,
|
||||
@ -386,6 +381,9 @@ describe('Backend', () => {
|
||||
});
|
||||
|
||||
it("should return unique slug when entry doesn't exist", async () => {
|
||||
const { sanitizeSlug } = require('../lib/urlHelper');
|
||||
sanitizeSlug.mockReturnValue('some-post-title');
|
||||
|
||||
const config = Map({});
|
||||
|
||||
const implementation = {
|
||||
@ -418,6 +416,10 @@ describe('Backend', () => {
|
||||
});
|
||||
|
||||
it('should return unique slug when entry exists', async () => {
|
||||
const { sanitizeSlug, sanitizeChar } = require('../lib/urlHelper');
|
||||
sanitizeSlug.mockReturnValue('some-post-title');
|
||||
sanitizeChar.mockReturnValue('-');
|
||||
|
||||
const config = Map({});
|
||||
|
||||
const implementation = {
|
||||
|
@ -10,7 +10,7 @@ describe('config', () => {
|
||||
public_folder: '/path/to/media',
|
||||
collections: [],
|
||||
});
|
||||
expect(applyDefaults(config)).toEqual(config.set('publish_mode', 'simple'));
|
||||
expect(applyDefaults(config).get('publish_mode')).toEqual('simple');
|
||||
});
|
||||
|
||||
it('should set publish_mode from config', () => {
|
||||
@ -21,7 +21,7 @@ describe('config', () => {
|
||||
public_folder: '/path/to/media',
|
||||
collections: [],
|
||||
});
|
||||
expect(applyDefaults(config)).toEqual(config);
|
||||
expect(applyDefaults(config).get('publish_mode')).toEqual('complex');
|
||||
});
|
||||
|
||||
it('should set public_folder based on media_folder if not set', () => {
|
||||
@ -32,16 +32,8 @@ describe('config', () => {
|
||||
media_folder: 'path/to/media',
|
||||
collections: [],
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
fromJS({
|
||||
foo: 'bar',
|
||||
publish_mode: 'simple',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/path/to/media',
|
||||
collections: [],
|
||||
}),
|
||||
);
|
||||
).get('public_folder'),
|
||||
).toEqual('/path/to/media');
|
||||
});
|
||||
|
||||
it('should not overwrite public_folder if set', () => {
|
||||
@ -53,16 +45,8 @@ describe('config', () => {
|
||||
public_folder: '/publib/path',
|
||||
collections: [],
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
fromJS({
|
||||
foo: 'bar',
|
||||
publish_mode: 'simple',
|
||||
media_folder: 'path/to/media',
|
||||
public_folder: '/publib/path',
|
||||
collections: [],
|
||||
}),
|
||||
);
|
||||
).get('public_folder'),
|
||||
).toEqual('/publib/path');
|
||||
});
|
||||
|
||||
it('should strip leading slashes from collection folder', () => {
|
||||
@ -71,14 +55,8 @@ describe('config', () => {
|
||||
fromJS({
|
||||
collections: [{ folder: '/foo' }],
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
fromJS({
|
||||
publish_mode: 'simple',
|
||||
public_folder: '/',
|
||||
collections: [{ folder: 'foo' }],
|
||||
}),
|
||||
);
|
||||
).get('collections'),
|
||||
).toEqual(fromJS([{ folder: 'foo' }]));
|
||||
});
|
||||
|
||||
it('should strip leading slashes from collection files', () => {
|
||||
@ -87,14 +65,41 @@ describe('config', () => {
|
||||
fromJS({
|
||||
collections: [{ files: [{ file: '/foo' }] }],
|
||||
}),
|
||||
),
|
||||
).toEqual(
|
||||
fromJS({
|
||||
publish_mode: 'simple',
|
||||
public_folder: '/',
|
||||
collections: [{ files: [{ file: 'foo' }] }],
|
||||
}),
|
||||
).get('collections'),
|
||||
).toEqual(fromJS([{ files: [{ file: 'foo' }] }]));
|
||||
});
|
||||
|
||||
it('should set default slug config', () => {
|
||||
expect(applyDefaults(fromJS({ collections: [] })).get('slug')).toEqual(
|
||||
fromJS({ encoding: 'unicode', clean_accents: false, sanitize_replacement: '-' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not override slug encoding', () => {
|
||||
expect(
|
||||
applyDefaults(fromJS({ collections: [], slug: { encoding: 'ascii' } })).getIn([
|
||||
'slug',
|
||||
'encoding',
|
||||
]),
|
||||
).toEqual('ascii');
|
||||
});
|
||||
|
||||
it('should not override slug clean_accents', () => {
|
||||
expect(
|
||||
applyDefaults(fromJS({ collections: [], slug: { clean_accents: true } })).getIn([
|
||||
'slug',
|
||||
'clean_accents',
|
||||
]),
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it('should not override slug sanitize_replacement', () => {
|
||||
expect(
|
||||
applyDefaults(fromJS({ collections: [], slug: { sanitize_replacement: '_' } })).getIn([
|
||||
'slug',
|
||||
'sanitize_replacement',
|
||||
]),
|
||||
).toEqual('_');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,18 +1,12 @@
|
||||
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
|
||||
import * as actions from '../editorialWorkflow';
|
||||
import { setDraftEntryMediaFiles } from '../entries';
|
||||
import { addAssets } from '../media';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
jest.mock('coreSrc/backend');
|
||||
jest.mock('Reducers', () => {
|
||||
return {
|
||||
getAsset: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
});
|
||||
jest.mock('ValueObjects/AssetProxy');
|
||||
jest.mock('../../valueObjects/AssetProxy');
|
||||
jest.mock('netlify-cms-lib-util');
|
||||
jest.mock('uuid/v4', () => {
|
||||
return jest.fn().mockReturnValue('000000000000000000000');
|
||||
@ -43,7 +37,7 @@ describe('editorialWorkflow actions', () => {
|
||||
const { currentBackend } = require('coreSrc/backend');
|
||||
const { createAssetProxy } = require('ValueObjects/AssetProxy');
|
||||
|
||||
const assetProxy = { name: 'name', public_path: 'public_path' };
|
||||
const assetProxy = { name: 'name', path: 'path' };
|
||||
const entry = { mediaFiles: [{ file: { name: 'name' }, id: '1' }] };
|
||||
const backend = {
|
||||
unpublishedEntry: jest.fn().mockResolvedValue(entry),
|
||||
@ -70,7 +64,7 @@ describe('editorialWorkflow actions', () => {
|
||||
|
||||
return store.dispatch(actions.loadUnpublishedEntry(collection, slug)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(5);
|
||||
expect(actions).toHaveLength(3);
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'UNPUBLISHED_ENTRY_REQUEST',
|
||||
payload: {
|
||||
@ -79,28 +73,11 @@ describe('editorialWorkflow actions', () => {
|
||||
},
|
||||
});
|
||||
expect(actions[1]).toEqual(addAssets([assetProxy]));
|
||||
expect(actions[2]).toEqual(
|
||||
setDraftEntryMediaFiles([
|
||||
{
|
||||
file: { name: 'name' },
|
||||
name: 'name',
|
||||
id: '1',
|
||||
draft: true,
|
||||
public_path: 'public_path',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(actions[3]).toEqual({
|
||||
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
|
||||
payload: {
|
||||
mediaFiles: [{ file: { name: 'name' }, id: '1', draft: true }],
|
||||
},
|
||||
});
|
||||
expect(actions[4]).toEqual({
|
||||
expect(actions[2]).toEqual({
|
||||
type: 'UNPUBLISHED_ENTRY_SUCCESS',
|
||||
payload: {
|
||||
collection: 'posts',
|
||||
entry,
|
||||
entry: { ...entry, mediaFiles: [{ file: { name: 'name' }, id: '1', draft: true }] },
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -111,15 +88,15 @@ describe('editorialWorkflow actions', () => {
|
||||
it('should publish unpublished entry and report success', () => {
|
||||
const { currentBackend } = require('coreSrc/backend');
|
||||
|
||||
const mediaFiles = [{ file: { name: 'name' }, id: '1' }];
|
||||
const entry = { mediaFiles };
|
||||
const entry = {};
|
||||
const backend = {
|
||||
publishUnpublishedEntry: jest.fn().mockResolvedValue({ mediaFiles }),
|
||||
publishUnpublishedEntry: jest.fn().mockResolvedValue(),
|
||||
getEntry: jest.fn().mockResolvedValue(entry),
|
||||
};
|
||||
|
||||
const store = mockStore({
|
||||
config: fromJS({}),
|
||||
integrations: fromJS([]),
|
||||
mediaLibrary: fromJS({
|
||||
isLoading: false,
|
||||
}),
|
||||
@ -134,7 +111,8 @@ describe('editorialWorkflow actions', () => {
|
||||
|
||||
return store.dispatch(actions.publishUnpublishedEntry('posts', slug)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(7);
|
||||
expect(actions).toHaveLength(6);
|
||||
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST',
|
||||
payload: {
|
||||
@ -144,12 +122,18 @@ describe('editorialWorkflow actions', () => {
|
||||
optimist: { type: BEGIN, id: '000000000000000000000' },
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'MEDIA_LOAD_REQUEST',
|
||||
payload: {
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
expect(actions[2]).toEqual({
|
||||
type: 'NOTIF_SEND',
|
||||
message: { key: 'ui.toast.entryPublished' },
|
||||
kind: 'success',
|
||||
dismissAfter: 4000,
|
||||
});
|
||||
expect(actions[2]).toEqual({
|
||||
expect(actions[3]).toEqual({
|
||||
type: 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS',
|
||||
payload: {
|
||||
collection: 'posts',
|
||||
@ -157,23 +141,14 @@ describe('editorialWorkflow actions', () => {
|
||||
},
|
||||
optimist: { type: COMMIT, id: '000000000000000000000' },
|
||||
});
|
||||
expect(actions[3]).toEqual({
|
||||
expect(actions[4]).toEqual({
|
||||
type: 'ENTRY_REQUEST',
|
||||
payload: {
|
||||
slug,
|
||||
collection: 'posts',
|
||||
},
|
||||
});
|
||||
expect(actions[4]).toEqual({
|
||||
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
|
||||
payload: {
|
||||
mediaFiles: [{ file: { name: 'name' }, id: '1', draft: false }],
|
||||
},
|
||||
});
|
||||
expect(actions[5]).toEqual({
|
||||
type: 'CLEAR_DRAFT_ENTRY_MEDIA_FILES',
|
||||
});
|
||||
expect(actions[6]).toEqual({
|
||||
type: 'ENTRY_SUCCESS',
|
||||
payload: {
|
||||
entry,
|
||||
|
@ -1,24 +1,23 @@
|
||||
import { fromJS, List, Map } from 'immutable';
|
||||
import { fromJS, Map } from 'immutable';
|
||||
import {
|
||||
createEmptyDraftData,
|
||||
retrieveLocalBackup,
|
||||
persistLocalBackup,
|
||||
getMediaAssets,
|
||||
discardDraft,
|
||||
loadLocalBackup,
|
||||
} from '../entries';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
jest.mock('coreSrc/backend');
|
||||
jest.mock('Reducers', () => {
|
||||
jest.mock('../media', () => {
|
||||
const media = jest.requireActual('../media');
|
||||
return {
|
||||
getAsset: jest.fn().mockReturnValue({}),
|
||||
...media,
|
||||
getAsset: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('ValueObjects/AssetProxy');
|
||||
jest.mock('netlify-cms-lib-util');
|
||||
jest.mock('../mediaLibrary.js');
|
||||
jest.mock('../mediaLibrary');
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureMockStore(middlewares);
|
||||
@ -108,61 +107,35 @@ describe('entries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('discardDraft', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should delete media files on discard draft', () => {
|
||||
const { deleteMedia } = require('../mediaLibrary');
|
||||
const mediaFiles = [{ draft: false }, { draft: true }];
|
||||
|
||||
deleteMedia.mockImplementation(file => ({ type: 'DELETE_MEDIA', payload: file }));
|
||||
|
||||
const store = mockStore({
|
||||
config: Map(),
|
||||
entryDraft: Map({
|
||||
mediaFiles: List(mediaFiles),
|
||||
}),
|
||||
});
|
||||
|
||||
store.dispatch(discardDraft());
|
||||
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({ type: 'DELETE_MEDIA', payload: { draft: true } });
|
||||
expect(actions[1]).toEqual({ type: 'DRAFT_DISCARD' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistLocalBackup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should persist local backup with media files', () => {
|
||||
const getState = jest.fn();
|
||||
const { currentBackend } = require('coreSrc/backend');
|
||||
const { getAsset } = require('Reducers');
|
||||
|
||||
const backend = {
|
||||
persistLocalDraftBackup: jest.fn((...args) => args),
|
||||
persistLocalDraftBackup: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
const state = { config: {} };
|
||||
const store = mockStore({
|
||||
config: Map(),
|
||||
});
|
||||
|
||||
currentBackend.mockReturnValue(backend);
|
||||
getAsset.mockImplementation((state, path) => path);
|
||||
getState.mockReturnValue(state);
|
||||
|
||||
const entry = Map();
|
||||
const collection = Map();
|
||||
const mediaFiles = [{ public_path: '/static/media/image.png' }];
|
||||
const mediaFiles = [{ path: 'static/media/image.png' }];
|
||||
const entry = fromJS({ mediaFiles });
|
||||
|
||||
const result = persistLocalBackup(entry, collection, mediaFiles)(null, getState);
|
||||
return store.dispatch(persistLocalBackup(entry, collection)).then(() => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(0);
|
||||
|
||||
expect(result).toEqual([entry, collection, mediaFiles, ['/static/media/image.png']]);
|
||||
expect(backend.persistLocalDraftBackup).toHaveBeenCalledTimes(1);
|
||||
expect(backend.persistLocalDraftBackup).toHaveBeenCalledWith(entry, collection);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -173,13 +146,7 @@ describe('entries', () => {
|
||||
|
||||
it('should retrieve media files with local backup', () => {
|
||||
const { currentBackend } = require('coreSrc/backend');
|
||||
const { createAssetProxy } = require('ValueObjects/AssetProxy');
|
||||
const { addMediaFilesToLibrary } = require('../mediaLibrary');
|
||||
|
||||
addMediaFilesToLibrary.mockImplementation(mediaFiles => ({
|
||||
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
|
||||
payload: { mediaFiles },
|
||||
}));
|
||||
const { createAssetProxy } = require('../../valueObjects/AssetProxy');
|
||||
|
||||
const backend = {
|
||||
getLocalDraftBackup: jest.fn((...args) => args),
|
||||
@ -190,83 +157,54 @@ describe('entries', () => {
|
||||
});
|
||||
|
||||
currentBackend.mockReturnValue(backend);
|
||||
createAssetProxy.mockImplementation((value, fileObj) => ({ value, fileObj }));
|
||||
|
||||
const collection = Map({
|
||||
name: 'collection',
|
||||
});
|
||||
const slug = 'slug';
|
||||
|
||||
const entry = {};
|
||||
const mediaFiles = [{ public_path: '/static/media/image.png' }];
|
||||
const assets = [{ value: 'image.png', fileObj: {} }];
|
||||
const file = new File([], 'image.png');
|
||||
const mediaFiles = [{ path: 'static/media/image.png', url: 'url', file }];
|
||||
const asset = createAssetProxy(mediaFiles[0]);
|
||||
const entry = { mediaFiles };
|
||||
|
||||
backend.getLocalDraftBackup.mockReturnValue({ entry, mediaFiles, assets });
|
||||
backend.getLocalDraftBackup.mockReturnValue({ entry });
|
||||
|
||||
return store.dispatch(retrieveLocalBackup(collection, slug)).then(() => {
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(createAssetProxy).toHaveBeenCalledTimes(1);
|
||||
expect(createAssetProxy).toHaveBeenCalledWith(assets[0].value, assets[0].fileObj);
|
||||
expect(actions).toHaveLength(2);
|
||||
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'ADD_ASSETS',
|
||||
payload: [{ value: 'image.png', fileObj: {} }],
|
||||
payload: [asset],
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'DRAFT_LOCAL_BACKUP_RETRIEVED',
|
||||
payload: { entry, mediaFiles },
|
||||
payload: { entry },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLocalBackup', () => {
|
||||
it('should add backup media files to media library', () => {
|
||||
const store = mockStore({
|
||||
config: Map(),
|
||||
entryDraft: Map({
|
||||
mediaFiles: List([{ path: 'static/media.image.png' }]),
|
||||
}),
|
||||
mediaLibrary: Map({
|
||||
isLoading: false,
|
||||
}),
|
||||
});
|
||||
|
||||
store.dispatch(loadLocalBackup());
|
||||
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'DRAFT_CREATE_FROM_LOCAL_BACKUP',
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
|
||||
payload: { mediaFiles: [{ path: 'static/media.image.png', draft: true }] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaAssets', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should map mediaFiles to assets', () => {
|
||||
const { getAsset } = require('Reducers');
|
||||
const state = {};
|
||||
const mediaFiles = [{ public_path: 'public_path' }];
|
||||
it('should map mediaFiles to assets', async () => {
|
||||
const { getAsset } = require('../media');
|
||||
const mediaFiles = fromJS([{ path: 'path1' }, { path: 'path2', draft: true }]);
|
||||
|
||||
const asset = { name: 'asset1' };
|
||||
const asset = { path: 'path1' };
|
||||
|
||||
getAsset.mockReturnValue(asset);
|
||||
getAsset.mockReturnValue(() => asset);
|
||||
|
||||
expect(getMediaAssets(state, mediaFiles)).toEqual([asset]);
|
||||
const collection = Map();
|
||||
await expect(getMediaAssets({ mediaFiles, collection })).resolves.toEqual([asset]);
|
||||
|
||||
expect(getAsset).toHaveBeenCalledTimes(1);
|
||||
expect(getAsset).toHaveBeenCalledWith(state, 'public_path');
|
||||
expect(getAsset).toHaveBeenCalledWith({ collection, path: 'path2' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
138
packages/netlify-cms-core/src/actions/__tests__/media.js
Normal file
138
packages/netlify-cms-core/src/actions/__tests__/media.js
Normal file
@ -0,0 +1,138 @@
|
||||
import { Map } from 'immutable';
|
||||
import { getAsset, ADD_ASSET } from '../media';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureMockStore(middlewares);
|
||||
|
||||
jest.mock('../../reducers/entries');
|
||||
jest.mock('../mediaLibrary');
|
||||
jest.mock('../../reducers/mediaLibrary');
|
||||
|
||||
describe('media', () => {
|
||||
describe('getAsset', () => {
|
||||
global.URL = { createObjectURL: jest.fn() };
|
||||
|
||||
const { selectMediaFilePath } = require('../../reducers/entries');
|
||||
const { selectMediaFileByPath } = require('../../reducers/mediaLibrary');
|
||||
const { getMediaDisplayURL, getMediaFile } = require('../mediaLibrary');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty asset for null path', () => {
|
||||
const store = mockStore({});
|
||||
|
||||
const payload = { collection: null, entryPath: null, path: null };
|
||||
|
||||
return store.dispatch(getAsset(payload)).then(result => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(0);
|
||||
|
||||
expect(result).toEqual(new AssetProxy({ file: new File([], 'empty'), path: '' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('should return asset from medias state', () => {
|
||||
const path = 'static/media/image.png';
|
||||
const asset = new AssetProxy({ file: new File([], 'empty'), path });
|
||||
const store = mockStore({
|
||||
config: Map(),
|
||||
medias: Map({
|
||||
[path]: asset,
|
||||
}),
|
||||
});
|
||||
|
||||
selectMediaFilePath.mockReturnValue(path);
|
||||
const payload = { collection: Map(), entryPath: 'entryPath', path };
|
||||
|
||||
return store.dispatch(getAsset(payload)).then(result => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(0);
|
||||
|
||||
expect(result).toBe(asset);
|
||||
expect(selectMediaFilePath).toHaveBeenCalledTimes(1);
|
||||
expect(selectMediaFilePath).toHaveBeenCalledWith(
|
||||
store.getState().config,
|
||||
payload.collection,
|
||||
payload.entryPath,
|
||||
path,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create asset for absolute path when not in medias state', () => {
|
||||
const path = 'https://asset.netlify.com/image.png';
|
||||
|
||||
const asset = new AssetProxy({ url: path, path });
|
||||
const store = mockStore({
|
||||
medias: Map({}),
|
||||
});
|
||||
|
||||
selectMediaFilePath.mockReturnValue(path);
|
||||
const payload = { collection: null, entryPath: null, path };
|
||||
|
||||
return store.dispatch(getAsset(payload)).then(result => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toEqual({
|
||||
type: ADD_ASSET,
|
||||
payload: asset,
|
||||
});
|
||||
expect(result).toEqual(asset);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create asset from media file when not in medias state', () => {
|
||||
const path = 'static/media/image.png';
|
||||
const mediaFile = { file: new File([], '') };
|
||||
const url = 'blob://displayURL';
|
||||
const asset = new AssetProxy({ url, path });
|
||||
const store = mockStore({
|
||||
medias: Map({}),
|
||||
});
|
||||
|
||||
selectMediaFilePath.mockReturnValue(path);
|
||||
selectMediaFileByPath.mockReturnValue(mediaFile);
|
||||
getMediaDisplayURL.mockResolvedValue(url);
|
||||
const payload = { path };
|
||||
|
||||
return store.dispatch(getAsset(payload)).then(result => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toEqual({
|
||||
type: ADD_ASSET,
|
||||
payload: asset,
|
||||
});
|
||||
expect(result).toEqual(asset);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch asset media file when not in redux store', () => {
|
||||
const path = 'static/media/image.png';
|
||||
const url = 'blob://displayURL';
|
||||
const asset = new AssetProxy({ url, path });
|
||||
const store = mockStore({
|
||||
medias: Map({}),
|
||||
});
|
||||
|
||||
selectMediaFilePath.mockReturnValue(path);
|
||||
selectMediaFileByPath.mockReturnValue(undefined);
|
||||
getMediaFile.mockResolvedValue({ url });
|
||||
const payload = { path };
|
||||
|
||||
return store.dispatch(getAsset(payload)).then(result => {
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toEqual({
|
||||
type: ADD_ASSET,
|
||||
payload: asset,
|
||||
});
|
||||
expect(result).toEqual(asset);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,118 +1,64 @@
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { fromJS, List, Map } from 'immutable';
|
||||
import { insertMedia, persistMedia, deleteMedia, addMediaFilesToLibrary } from '../mediaLibrary';
|
||||
import { List, Map } from 'immutable';
|
||||
import { insertMedia, persistMedia, deleteMedia } from '../mediaLibrary';
|
||||
|
||||
jest.mock('coreSrc/backend');
|
||||
jest.mock('ValueObjects/AssetProxy');
|
||||
jest.mock('../waitUntil');
|
||||
jest.mock('../../lib/urlHelper');
|
||||
jest.mock('netlify-cms-lib-util', () => {
|
||||
const lib = jest.requireActual('netlify-cms-lib-util');
|
||||
return {
|
||||
...lib,
|
||||
getBlobSHA: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureMockStore(middlewares);
|
||||
|
||||
describe('mediaLibrary', () => {
|
||||
describe('insertMedia', () => {
|
||||
it('should return url when input is an object with url property', () => {
|
||||
const store = mockStore({});
|
||||
store.dispatch(insertMedia({ url: '//localhost/foo.png' }));
|
||||
expect(store.getActions()[0]).toEqual({
|
||||
type: 'MEDIA_INSERT',
|
||||
payload: { mediaPath: '//localhost/foo.png' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve to relative path when media_folder_relative is true and object with name property is given', () => {
|
||||
const store = mockStore({
|
||||
config: fromJS({
|
||||
media_folder_relative: true,
|
||||
media_folder: 'content/media',
|
||||
}),
|
||||
entryDraft: fromJS({
|
||||
entry: {
|
||||
collection: 'blog-posts',
|
||||
},
|
||||
}),
|
||||
collections: fromJS({
|
||||
'blog-posts': {
|
||||
folder: 'content/blog/posts',
|
||||
},
|
||||
}),
|
||||
});
|
||||
store.dispatch(insertMedia({ name: 'foo.png' }));
|
||||
expect(store.getActions()[0]).toEqual({
|
||||
type: 'MEDIA_INSERT',
|
||||
payload: { mediaPath: '../../media/foo.png' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve to relative path and ignore public_folder when media_folder_relative is true', () => {
|
||||
const store = mockStore({
|
||||
config: fromJS({
|
||||
media_folder_relative: true,
|
||||
media_folder: 'content/media',
|
||||
public_folder: '/static/assets/media',
|
||||
}),
|
||||
entryDraft: fromJS({
|
||||
entry: {
|
||||
collection: 'blog-posts',
|
||||
},
|
||||
}),
|
||||
collections: fromJS({
|
||||
'blog-posts': {
|
||||
folder: 'content/blog/posts',
|
||||
},
|
||||
}),
|
||||
});
|
||||
store.dispatch(insertMedia({ name: 'foo.png' }));
|
||||
expect(store.getActions()[0]).toEqual({
|
||||
type: 'MEDIA_INSERT',
|
||||
payload: { mediaPath: '../../media/foo.png' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not resolve to relative path when media_folder_relative is not true', () => {
|
||||
const store = mockStore({
|
||||
config: fromJS({
|
||||
public_folder: '/static/assets/media',
|
||||
}),
|
||||
});
|
||||
store.dispatch(insertMedia({ name: 'foo.png' }));
|
||||
expect(store.getActions()[0]).toEqual({
|
||||
type: 'MEDIA_INSERT',
|
||||
payload: { mediaPath: '/static/assets/media/foo.png' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return mediaPath as string when string is given', () => {
|
||||
const store = mockStore({});
|
||||
const store = mockStore({
|
||||
config: Map({
|
||||
public_folder: '/media',
|
||||
}),
|
||||
collections: Map({
|
||||
posts: Map({ name: 'posts' }),
|
||||
}),
|
||||
entryDraft: Map({
|
||||
entry: Map({ isPersisting: false, collection: 'posts' }),
|
||||
}),
|
||||
});
|
||||
|
||||
store.dispatch(insertMedia('foo.png'));
|
||||
expect(store.getActions()[0]).toEqual({
|
||||
type: 'MEDIA_INSERT',
|
||||
payload: { mediaPath: 'foo.png' },
|
||||
payload: { mediaPath: '/media/foo.png' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return mediaPath as array of strings when array of strings is given', () => {
|
||||
const store = mockStore({});
|
||||
const store = mockStore({
|
||||
config: Map({
|
||||
public_folder: '/media',
|
||||
}),
|
||||
collections: Map({
|
||||
posts: Map({ name: 'posts' }),
|
||||
}),
|
||||
entryDraft: Map({
|
||||
entry: Map({ isPersisting: false, collection: 'posts' }),
|
||||
}),
|
||||
});
|
||||
|
||||
store.dispatch(insertMedia(['foo.png']));
|
||||
expect(store.getActions()[0]).toEqual({
|
||||
type: 'MEDIA_INSERT',
|
||||
payload: { mediaPath: ['foo.png'] },
|
||||
payload: { mediaPath: ['/media/foo.png'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when not a object with url or name property, a string or a string array', () => {
|
||||
const store = mockStore();
|
||||
|
||||
expect.assertions(1);
|
||||
try {
|
||||
store.dispatch(insertMedia({ foo: 'foo.png' }));
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual(
|
||||
'Incorrect usage, expected {url}, {file}, string or string array',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { currentBackend } = require('coreSrc/backend');
|
||||
@ -132,67 +78,82 @@ describe('mediaLibrary', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should persist media as draft in editorial workflow', () => {
|
||||
it('should not persist media in editorial workflow', () => {
|
||||
const { getBlobSHA } = require('netlify-cms-lib-util');
|
||||
|
||||
getBlobSHA.mockReturnValue('000000000000000');
|
||||
|
||||
const { sanitizeSlug } = require('../../lib/urlHelper');
|
||||
sanitizeSlug.mockReturnValue('name.png');
|
||||
|
||||
const store = mockStore({
|
||||
config: Map({
|
||||
publish_mode: 'editorial_workflow',
|
||||
media_folder: 'static/media',
|
||||
}),
|
||||
collections: Map({
|
||||
posts: Map({ name: 'posts' }),
|
||||
}),
|
||||
integrations: Map(),
|
||||
mediaLibrary: Map({
|
||||
files: List(),
|
||||
}),
|
||||
entryDraft: Map({
|
||||
entry: Map({ isPersisting: false }),
|
||||
entry: Map({ isPersisting: false, collection: 'posts' }),
|
||||
}),
|
||||
});
|
||||
|
||||
const file = new File([''], 'name.png');
|
||||
const assetProxy = { public_path: '/media/name.png' };
|
||||
const assetProxy = { path: 'static/media/name.png' };
|
||||
createAssetProxy.mockReturnValue(assetProxy);
|
||||
|
||||
return store.dispatch(persistMedia(file)).then(() => {
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toHaveLength(4);
|
||||
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
|
||||
expect(actions[1]).toEqual({
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'ADD_ASSET',
|
||||
payload: { public_path: '/media/name.png' },
|
||||
payload: { path: 'static/media/name.png' },
|
||||
});
|
||||
expect(actions[2]).toEqual({
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'ADD_DRAFT_ENTRY_MEDIA_FILE',
|
||||
payload: { draft: true, id: 'id', public_path: '/media/name.png' },
|
||||
});
|
||||
expect(actions[3]).toEqual({
|
||||
type: 'MEDIA_PERSIST_SUCCESS',
|
||||
payload: {
|
||||
file: { draft: true, id: 'id', displayURL: 'displayURL' },
|
||||
draft: true,
|
||||
id: '000000000000000',
|
||||
path: 'static/media/name.png',
|
||||
size: file.size,
|
||||
name: file.name,
|
||||
},
|
||||
});
|
||||
|
||||
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
|
||||
expect(backend.persistMedia).toHaveBeenCalledWith(
|
||||
store.getState().config,
|
||||
assetProxy,
|
||||
true,
|
||||
);
|
||||
expect(getBlobSHA).toHaveBeenCalledTimes(1);
|
||||
expect(getBlobSHA).toHaveBeenCalledWith(file);
|
||||
expect(backend.persistMedia).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not persist media as draft when not in editorial workflow', () => {
|
||||
it('should persist media when not in editorial workflow', () => {
|
||||
const { sanitizeSlug } = require('../../lib/urlHelper');
|
||||
sanitizeSlug.mockReturnValue('name.png');
|
||||
|
||||
const store = mockStore({
|
||||
config: Map({}),
|
||||
config: Map({
|
||||
media_folder: 'static/media',
|
||||
}),
|
||||
collections: Map({
|
||||
posts: Map({ name: 'posts' }),
|
||||
}),
|
||||
integrations: Map(),
|
||||
mediaLibrary: Map({
|
||||
files: List(),
|
||||
}),
|
||||
entryDraft: Map({
|
||||
entry: Map({ isPersisting: false }),
|
||||
entry: Map({ isPersisting: false, collection: 'posts' }),
|
||||
}),
|
||||
});
|
||||
|
||||
const file = new File([''], 'name.png');
|
||||
const assetProxy = { public_path: '/media/name.png' };
|
||||
const assetProxy = { path: 'static/media/name.png' };
|
||||
createAssetProxy.mockReturnValue(assetProxy);
|
||||
|
||||
return store.dispatch(persistMedia(file)).then(() => {
|
||||
@ -202,28 +163,27 @@ describe('mediaLibrary', () => {
|
||||
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'ADD_ASSET',
|
||||
payload: { public_path: '/media/name.png' },
|
||||
payload: { path: 'static/media/name.png' },
|
||||
});
|
||||
expect(actions[2]).toEqual({
|
||||
type: 'MEDIA_PERSIST_SUCCESS',
|
||||
payload: {
|
||||
file: { draft: false, id: 'id', displayURL: 'displayURL' },
|
||||
file: { id: 'id' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
|
||||
expect(backend.persistMedia).toHaveBeenCalledWith(
|
||||
store.getState().config,
|
||||
assetProxy,
|
||||
false,
|
||||
);
|
||||
expect(backend.persistMedia).toHaveBeenCalledWith(store.getState().config, assetProxy);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not persist media as draft when draft is empty', () => {
|
||||
it('should persist media when draft is empty', () => {
|
||||
const store = mockStore({
|
||||
config: Map({
|
||||
publish_mode: 'editorial_workflow',
|
||||
media_folder: 'static/media',
|
||||
}),
|
||||
collections: Map({
|
||||
posts: Map({ name: 'posts' }),
|
||||
}),
|
||||
integrations: Map(),
|
||||
mediaLibrary: Map({
|
||||
@ -235,16 +195,29 @@ describe('mediaLibrary', () => {
|
||||
});
|
||||
|
||||
const file = new File([''], 'name.png');
|
||||
const assetProxy = { public_path: '/media/name.png' };
|
||||
const assetProxy = { path: 'static/media/name.png' };
|
||||
createAssetProxy.mockReturnValue(assetProxy);
|
||||
|
||||
return store.dispatch(persistMedia(file)).then(() => {
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toHaveLength(3);
|
||||
|
||||
expect(actions).toHaveLength(3);
|
||||
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'ADD_ASSET',
|
||||
payload: { path: 'static/media/name.png' },
|
||||
});
|
||||
expect(actions[2]).toEqual({
|
||||
type: 'MEDIA_PERSIST_SUCCESS',
|
||||
payload: {
|
||||
file: { id: 'id' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(backend.persistMedia).toHaveBeenCalledTimes(1);
|
||||
expect(backend.persistMedia).toHaveBeenCalledWith(
|
||||
store.getState().config,
|
||||
assetProxy,
|
||||
false,
|
||||
);
|
||||
expect(backend.persistMedia).toHaveBeenCalledWith(store.getState().config, assetProxy);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -259,6 +232,7 @@ describe('mediaLibrary', () => {
|
||||
config: Map({
|
||||
publish_mode: 'editorial_workflow',
|
||||
}),
|
||||
collections: Map(),
|
||||
integrations: Map(),
|
||||
mediaLibrary: Map({
|
||||
files: List(),
|
||||
@ -269,7 +243,7 @@ describe('mediaLibrary', () => {
|
||||
});
|
||||
|
||||
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: false };
|
||||
const assetProxy = { public_path: '/media/name.png' };
|
||||
const assetProxy = { path: 'static/media/name.png' };
|
||||
createAssetProxy.mockReturnValue(assetProxy);
|
||||
|
||||
return store.dispatch(deleteMedia(file)).then(() => {
|
||||
@ -279,16 +253,16 @@ describe('mediaLibrary', () => {
|
||||
expect(actions[0]).toEqual({ type: 'MEDIA_DELETE_REQUEST' });
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'REMOVE_ASSET',
|
||||
payload: '/media/name.png',
|
||||
payload: 'static/media/name.png',
|
||||
});
|
||||
expect(actions[2]).toEqual({
|
||||
type: 'REMOVE_DRAFT_ENTRY_MEDIA_FILE',
|
||||
payload: { id: 'id' },
|
||||
});
|
||||
expect(actions[3]).toEqual({
|
||||
type: 'MEDIA_DELETE_SUCCESS',
|
||||
payload: { file },
|
||||
});
|
||||
expect(actions[3]).toEqual({
|
||||
type: 'REMOVE_DRAFT_ENTRY_MEDIA_FILE',
|
||||
payload: { id: 'id' },
|
||||
});
|
||||
|
||||
expect(backend.deleteMedia).toHaveBeenCalledTimes(1);
|
||||
expect(backend.deleteMedia).toHaveBeenCalledWith(
|
||||
@ -303,6 +277,7 @@ describe('mediaLibrary', () => {
|
||||
config: Map({
|
||||
publish_mode: 'editorial_workflow',
|
||||
}),
|
||||
collections: Map(),
|
||||
integrations: Map(),
|
||||
mediaLibrary: Map({
|
||||
files: List(),
|
||||
@ -313,61 +288,25 @@ describe('mediaLibrary', () => {
|
||||
});
|
||||
|
||||
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: true };
|
||||
const assetProxy = { public_path: '/media/name.png' };
|
||||
const assetProxy = { path: 'static/media/name.png' };
|
||||
createAssetProxy.mockReturnValue(assetProxy);
|
||||
|
||||
return store.dispatch(deleteMedia(file)).then(() => {
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'REMOVE_ASSET',
|
||||
payload: 'static/media/name.png',
|
||||
});
|
||||
|
||||
expect(actions[1]).toEqual({
|
||||
type: 'REMOVE_DRAFT_ENTRY_MEDIA_FILE',
|
||||
payload: { id: 'id' },
|
||||
});
|
||||
|
||||
expect(backend.deleteMedia).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMediaFilesToLibrary', () => {
|
||||
it('should not wait if media library is loaded', () => {
|
||||
const store = mockStore({
|
||||
mediaLibrary: Map({
|
||||
isLoading: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const mediaFiles = [{ id: '1' }];
|
||||
store.dispatch(addMediaFilesToLibrary(mediaFiles));
|
||||
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toEqual({
|
||||
payload: { mediaFiles: [{ id: '1' }] },
|
||||
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
|
||||
});
|
||||
});
|
||||
|
||||
it('should wait if media library is not loaded', () => {
|
||||
const { waitUntil } = require('../waitUntil');
|
||||
|
||||
waitUntil.mockImplementation(payload => ({ type: 'WAIT_UNTIL', ...payload }));
|
||||
|
||||
const store = mockStore({
|
||||
mediaLibrary: Map({}),
|
||||
});
|
||||
|
||||
const mediaFiles = [{ id: '1' }];
|
||||
store.dispatch(addMediaFilesToLibrary(mediaFiles));
|
||||
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toEqual({
|
||||
type: 'WAIT_UNTIL',
|
||||
predicate: expect.any(Function),
|
||||
run: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(actions[0].predicate({ type: 'MEDIA_LOAD_SUCCESS' })).toBe(true);
|
||||
expect(actions[0].run(store.dispatch)).toEqual({
|
||||
payload: { mediaFiles: [{ id: '1' }] },
|
||||
type: 'ADD_MEDIA_FILES_TO_LIBRARY',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -41,12 +41,29 @@ export function applyDefaults(config) {
|
||||
map.set('public_folder', defaultPublicFolder);
|
||||
}
|
||||
|
||||
// default values for the slug config
|
||||
if (!map.getIn(['slug', 'encoding'])) {
|
||||
map.setIn(['slug', 'encoding'], 'unicode');
|
||||
}
|
||||
|
||||
if (!map.getIn(['slug', 'clean_accents'])) {
|
||||
map.setIn(['slug', 'clean_accents'], false);
|
||||
}
|
||||
|
||||
if (!map.getIn(['slug', 'sanitize_replacement'])) {
|
||||
map.setIn(['slug', 'sanitize_replacement'], '-');
|
||||
}
|
||||
|
||||
// Strip leading slash from collection folders and files
|
||||
map.set(
|
||||
'collections',
|
||||
map.get('collections').map(collection => {
|
||||
const folder = collection.get('folder');
|
||||
if (folder) {
|
||||
if (collection.has('path') && !collection.has('media_folder')) {
|
||||
// default value for media folder when using the path config
|
||||
collection = collection.set('media_folder', '');
|
||||
}
|
||||
return collection.set('folder', trimStart(folder, '/'));
|
||||
}
|
||||
|
||||
|
@ -2,25 +2,23 @@ import uuid from 'uuid/v4';
|
||||
import { get } from 'lodash';
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
|
||||
import { Map } from 'immutable';
|
||||
import { serializeValues } from 'Lib/serializeEntryValues';
|
||||
import { currentBackend } from 'coreSrc/backend';
|
||||
import { selectPublishedSlugs, selectUnpublishedSlugs, selectEntry } from 'Reducers';
|
||||
import { selectFields } from 'Reducers/collections';
|
||||
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { Map, List } from 'immutable';
|
||||
import { serializeValues } from '../lib/serializeEntryValues';
|
||||
import { currentBackend } from '../backend';
|
||||
import { selectPublishedSlugs, selectUnpublishedSlugs, selectEntry } from '../reducers';
|
||||
import { selectFields } from '../reducers/collections';
|
||||
import { EDITORIAL_WORKFLOW, status, Status } from '../constants/publishModes';
|
||||
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
|
||||
import {
|
||||
loadEntry,
|
||||
entryDeleted,
|
||||
getMediaAssets,
|
||||
setDraftEntryMediaFiles,
|
||||
clearDraftEntryMediaFiles,
|
||||
} from './entries';
|
||||
import { createAssetProxy } from 'ValueObjects/AssetProxy';
|
||||
import { loadEntry, entryDeleted, getMediaAssets } from './entries';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { addAssets } from './media';
|
||||
import { addMediaFilesToLibrary } from './mediaLibrary';
|
||||
import { loadMedia } from './mediaLibrary';
|
||||
|
||||
import ValidationErrorTypes from 'Constants/validationErrorTypes';
|
||||
import ValidationErrorTypes from '../constants/validationErrorTypes';
|
||||
import { Collection, EntryMap, State, Collections, EntryDraft, MediaFile } from '../types/redux';
|
||||
import { AnyAction } from 'redux';
|
||||
import { EntryValue } from '../valueObjects/Entry';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
|
||||
@ -55,7 +53,7 @@ export const UNPUBLISHED_ENTRY_DELETE_FAILURE = 'UNPUBLISHED_ENTRY_DELETE_FAILUR
|
||||
* Simple Action Creators (Internal)
|
||||
*/
|
||||
|
||||
function unpublishedEntryLoading(collection, slug) {
|
||||
function unpublishedEntryLoading(collection: Collection, slug: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_REQUEST,
|
||||
payload: {
|
||||
@ -65,7 +63,10 @@ function unpublishedEntryLoading(collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryLoaded(collection, entry) {
|
||||
function unpublishedEntryLoaded(
|
||||
collection: Collection,
|
||||
entry: EntryValue & { mediaFiles: MediaFile[] },
|
||||
) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_SUCCESS,
|
||||
payload: {
|
||||
@ -75,7 +76,7 @@ function unpublishedEntryLoaded(collection, entry) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryRedirected(collection, slug) {
|
||||
function unpublishedEntryRedirected(collection: Collection, slug: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_REDIRECT,
|
||||
payload: {
|
||||
@ -91,7 +92,7 @@ function unpublishedEntriesLoading() {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntriesLoaded(entries, pagination) {
|
||||
function unpublishedEntriesLoaded(entries: EntryValue[], pagination: number) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRIES_SUCCESS,
|
||||
payload: {
|
||||
@ -101,7 +102,7 @@ function unpublishedEntriesLoaded(entries, pagination) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntriesFailed(error) {
|
||||
function unpublishedEntriesFailed(error: Error) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRIES_FAILURE,
|
||||
error: 'Failed to load entries',
|
||||
@ -109,7 +110,11 @@ function unpublishedEntriesFailed(error) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPersisting(collection, entry, transactionID) {
|
||||
function unpublishedEntryPersisting(
|
||||
collection: Collection,
|
||||
entry: EntryMap,
|
||||
transactionID: string,
|
||||
) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
|
||||
payload: {
|
||||
@ -120,19 +125,18 @@ function unpublishedEntryPersisting(collection, entry, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPersisted(collection, entry, transactionID, slug) {
|
||||
function unpublishedEntryPersisted(collection: Collection, transactionID: string, slug: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
|
||||
payload: {
|
||||
collection: collection.get('name'),
|
||||
entry,
|
||||
slug,
|
||||
},
|
||||
optimist: { type: COMMIT, id: transactionID },
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPersistedFail(error, transactionID) {
|
||||
function unpublishedEntryPersistedFail(error: Error, transactionID: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
|
||||
payload: { error },
|
||||
@ -142,11 +146,11 @@ function unpublishedEntryPersistedFail(error, transactionID) {
|
||||
}
|
||||
|
||||
function unpublishedEntryStatusChangeRequest(
|
||||
collection,
|
||||
slug,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
transactionID,
|
||||
collection: string,
|
||||
slug: string,
|
||||
oldStatus: Status,
|
||||
newStatus: Status,
|
||||
transactionID: string,
|
||||
) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
|
||||
@ -161,11 +165,11 @@ function unpublishedEntryStatusChangeRequest(
|
||||
}
|
||||
|
||||
function unpublishedEntryStatusChangePersisted(
|
||||
collection,
|
||||
slug,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
transactionID,
|
||||
collection: string,
|
||||
slug: string,
|
||||
oldStatus: Status,
|
||||
newStatus: Status,
|
||||
transactionID: string,
|
||||
) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
|
||||
@ -179,7 +183,11 @@ function unpublishedEntryStatusChangePersisted(
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryStatusChangeError(collection, slug, transactionID) {
|
||||
function unpublishedEntryStatusChangeError(
|
||||
collection: string,
|
||||
slug: string,
|
||||
transactionID: string,
|
||||
) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
|
||||
payload: { collection, slug },
|
||||
@ -187,7 +195,7 @@ function unpublishedEntryStatusChangeError(collection, slug, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPublishRequest(collection, slug, transactionID) {
|
||||
function unpublishedEntryPublishRequest(collection: string, slug: string, transactionID: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
|
||||
payload: { collection, slug },
|
||||
@ -195,7 +203,7 @@ function unpublishedEntryPublishRequest(collection, slug, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPublished(collection, slug, transactionID) {
|
||||
function unpublishedEntryPublished(collection: string, slug: string, transactionID: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
|
||||
payload: { collection, slug },
|
||||
@ -203,7 +211,7 @@ function unpublishedEntryPublished(collection, slug, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryPublishError(collection, slug, transactionID) {
|
||||
function unpublishedEntryPublishError(collection: string, slug: string, transactionID: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
|
||||
payload: { collection, slug },
|
||||
@ -211,7 +219,7 @@ function unpublishedEntryPublishError(collection, slug, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryDeleteRequest(collection, slug, transactionID) {
|
||||
function unpublishedEntryDeleteRequest(collection: string, slug: string, transactionID: string) {
|
||||
// The reducer doesn't handle this action -- it is for `optimist`.
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_DELETE_REQUEST,
|
||||
@ -220,7 +228,7 @@ function unpublishedEntryDeleteRequest(collection, slug, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryDeleted(collection, slug, transactionID) {
|
||||
function unpublishedEntryDeleted(collection: string, slug: string, transactionID: string) {
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_DELETE_SUCCESS,
|
||||
payload: { collection, slug },
|
||||
@ -228,7 +236,7 @@ function unpublishedEntryDeleted(collection, slug, transactionID) {
|
||||
};
|
||||
}
|
||||
|
||||
function unpublishedEntryDeleteError(collection, slug, transactionID) {
|
||||
function unpublishedEntryDeleteError(collection: string, slug: string, transactionID: string) {
|
||||
// The reducer doesn't handle this action -- it is for `optimist`.
|
||||
return {
|
||||
type: UNPUBLISHED_ENTRY_DELETE_FAILURE,
|
||||
@ -241,45 +249,42 @@ function unpublishedEntryDeleteError(collection, slug, transactionID) {
|
||||
* Exported Thunk Action Creators
|
||||
*/
|
||||
|
||||
export function loadUnpublishedEntry(collection, slug) {
|
||||
return async (dispatch, getState) => {
|
||||
export function loadUnpublishedEntry(collection: Collection, slug: string) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false);
|
||||
//run possible unpublishedEntries migration
|
||||
if (!entriesLoaded) {
|
||||
const response = await backend.unpublishedEntries(state.collections).catch(() => false);
|
||||
response && dispatch(unpublishedEntriesLoaded(response.entries, response.pagination));
|
||||
try {
|
||||
const { entries, pagination } = await backend.unpublishedEntries(state.collections);
|
||||
dispatch(unpublishedEntriesLoaded(entries, pagination));
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
dispatch(unpublishedEntryLoading(collection, slug));
|
||||
|
||||
try {
|
||||
const entry = await backend.unpublishedEntry(collection, slug);
|
||||
const mediaFiles = entry.mediaFiles;
|
||||
const entry = (await backend.unpublishedEntry(collection, slug)) as EntryValue;
|
||||
const assetProxies = await Promise.all(
|
||||
mediaFiles.map(({ file }) => createAssetProxy(file.name, file)),
|
||||
entry.mediaFiles.map(({ url, file, path }) =>
|
||||
createAssetProxy({
|
||||
path,
|
||||
url,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
dispatch(addAssets(assetProxies));
|
||||
dispatch(
|
||||
setDraftEntryMediaFiles(
|
||||
assetProxies.map((asset, index) => ({
|
||||
...asset,
|
||||
...mediaFiles[index],
|
||||
draft: true,
|
||||
})),
|
||||
),
|
||||
);
|
||||
dispatch(
|
||||
addMediaFilesToLibrary(
|
||||
mediaFiles.map(file => ({
|
||||
...file,
|
||||
draft: true,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
dispatch(unpublishedEntryLoaded(collection, entry));
|
||||
let mediaFiles: MediaFile[] = entry.mediaFiles.map(file => ({ ...file, draft: true }));
|
||||
if (!collection.has('media_folder')) {
|
||||
const libraryFiles = getState().mediaLibrary.get('files') || [];
|
||||
mediaFiles = mediaFiles.concat(libraryFiles);
|
||||
}
|
||||
|
||||
dispatch(unpublishedEntryLoaded(collection, { ...entry, mediaFiles }));
|
||||
} catch (error) {
|
||||
if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) {
|
||||
dispatch(unpublishedEntryRedirected(collection, slug));
|
||||
@ -300,8 +305,8 @@ export function loadUnpublishedEntry(collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUnpublishedEntries(collections) {
|
||||
return (dispatch, getState) => {
|
||||
export function loadUnpublishedEntries(collections: Collections) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false);
|
||||
@ -311,7 +316,7 @@ export function loadUnpublishedEntries(collections) {
|
||||
backend
|
||||
.unpublishedEntries(collections)
|
||||
.then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)))
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
||||
@ -328,14 +333,14 @@ export function loadUnpublishedEntries(collections) {
|
||||
};
|
||||
}
|
||||
|
||||
export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
return async (dispatch, getState) => {
|
||||
export function persistUnpublishedEntry(collection: Collection, existingUnpublishedEntry: boolean) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const entryDraft = state.entryDraft;
|
||||
const fieldsErrors = entryDraft.get('fieldsErrors');
|
||||
const unpublishedSlugs = selectUnpublishedSlugs(state, collection.get('name'));
|
||||
const publishedSlugs = selectPublishedSlugs(state, collection.get('name'));
|
||||
const usedSlugs = publishedSlugs.concat(unpublishedSlugs);
|
||||
const usedSlugs = publishedSlugs.concat(unpublishedSlugs) as List<string>;
|
||||
const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false);
|
||||
|
||||
//load unpublishedEntries
|
||||
@ -363,15 +368,21 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
|
||||
const backend = currentBackend(state.config);
|
||||
const transactionID = uuid();
|
||||
const assetProxies = getMediaAssets(state, entryDraft.get('mediaFiles'));
|
||||
const entry = entryDraft.get('entry');
|
||||
const assetProxies = await getMediaAssets({
|
||||
getState,
|
||||
mediaFiles: entry.get('mediaFiles'),
|
||||
dispatch,
|
||||
collection,
|
||||
entryPath: entry.get('path'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize the values of any fields with registered serializers, and
|
||||
* update the entry and entryDraft with the serialized values.
|
||||
*/
|
||||
const fields = selectFields(collection, entry.get('slug'));
|
||||
const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), fields);
|
||||
const serializedData = serializeValues(entry.get('data'), fields);
|
||||
const serializedEntry = entry.set('data', serializedData);
|
||||
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
|
||||
|
||||
@ -379,18 +390,16 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
const persistAction = existingUnpublishedEntry
|
||||
? backend.persistUnpublishedEntry
|
||||
: backend.persistEntry;
|
||||
const persistCallArgs = [
|
||||
backend,
|
||||
state.config,
|
||||
collection,
|
||||
serializedEntryDraft,
|
||||
assetProxies.toJS(),
|
||||
state.integrations,
|
||||
usedSlugs,
|
||||
];
|
||||
|
||||
try {
|
||||
const newSlug = await persistAction.call(...persistCallArgs);
|
||||
const newSlug = await persistAction.call(backend, {
|
||||
config: state.config,
|
||||
collection,
|
||||
entryDraft: serializedEntryDraft,
|
||||
assetProxies,
|
||||
integrations: state.integrations,
|
||||
usedSlugs,
|
||||
});
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
||||
@ -400,7 +409,7 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
dismissAfter: 4000,
|
||||
}),
|
||||
);
|
||||
dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID, newSlug));
|
||||
dispatch(unpublishedEntryPersisted(collection, transactionID, newSlug));
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
notifSend({
|
||||
@ -417,8 +426,13 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus) {
|
||||
return (dispatch, getState) => {
|
||||
export function updateUnpublishedEntryStatus(
|
||||
collection: string,
|
||||
slug: string,
|
||||
oldStatus: Status,
|
||||
newStatus: Status,
|
||||
) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
if (oldStatus === newStatus) return;
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
@ -448,7 +462,7 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
||||
@ -464,8 +478,8 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteUnpublishedEntry(collection, slug) {
|
||||
return (dispatch, getState) => {
|
||||
export function deleteUnpublishedEntry(collection: string, slug: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const transactionID = uuid();
|
||||
@ -482,7 +496,7 @@ export function deleteUnpublishedEntry(collection, slug) {
|
||||
);
|
||||
dispatch(unpublishedEntryDeleted(collection, slug, transactionID));
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: { key: 'ui.toast.onDeleteUnpublishedChanges', details: error },
|
||||
@ -495,8 +509,8 @@ export function deleteUnpublishedEntry(collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function publishUnpublishedEntry(collection, slug) {
|
||||
return (dispatch, getState) => {
|
||||
export function publishUnpublishedEntry(collection: string, slug: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const collections = state.collections;
|
||||
const backend = currentBackend(state.config);
|
||||
@ -504,7 +518,9 @@ export function publishUnpublishedEntry(collection, slug) {
|
||||
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
|
||||
return backend
|
||||
.publishUnpublishedEntry(collection, slug)
|
||||
.then(({ mediaFiles }) => {
|
||||
.then(() => {
|
||||
// re-load media after entry was published
|
||||
dispatch(loadMedia());
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: { key: 'ui.toast.entryPublished' },
|
||||
@ -515,11 +531,8 @@ export function publishUnpublishedEntry(collection, slug) {
|
||||
|
||||
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
|
||||
dispatch(loadEntry(collections.get(collection), slug));
|
||||
|
||||
dispatch(addMediaFilesToLibrary(mediaFiles.map(file => ({ ...file, draft: false }))));
|
||||
dispatch(clearDraftEntryMediaFiles());
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
|
||||
@ -532,23 +545,29 @@ export function publishUnpublishedEntry(collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function unpublishPublishedEntry(collection, slug) {
|
||||
return (dispatch, getState) => {
|
||||
export function unpublishPublishedEntry(collection: Collection, slug: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const transactionID = uuid();
|
||||
const entry = selectEntry(state, collection.get('name'), slug);
|
||||
const entryDraft = Map().set('entry', entry);
|
||||
const entryDraft = (Map().set('entry', entry) as unknown) as EntryDraft;
|
||||
dispatch(unpublishedEntryPersisting(collection, entry, transactionID));
|
||||
return backend
|
||||
.deleteEntry(state.config, collection, slug)
|
||||
.then(() =>
|
||||
backend.persistEntry(state.config, collection, entryDraft, [], state.integrations, [], {
|
||||
backend.persistEntry({
|
||||
config: state.config,
|
||||
collection,
|
||||
entryDraft,
|
||||
assetProxies: [],
|
||||
integrations: state.integrations,
|
||||
usedSlugs: List(),
|
||||
status: status.get('PENDING_PUBLISH'),
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
dispatch(unpublishedEntryPersisted(collection, entryDraft, transactionID, slug));
|
||||
dispatch(unpublishedEntryPersisted(collection, transactionID, slug));
|
||||
dispatch(entryDeleted(collection, slug));
|
||||
dispatch(loadUnpublishedEntry(collection, slug));
|
||||
dispatch(
|
||||
@ -559,7 +578,7 @@ export function unpublishPublishedEntry(collection, slug) {
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: { key: 'ui.toast.onFailToUnpublishEntry', details: error },
|
@ -1,18 +1,29 @@
|
||||
import { fromJS, List, Map } from 'immutable';
|
||||
import { fromJS, List, Map, Set } from 'immutable';
|
||||
import { isEqual } from 'lodash';
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { serializeValues } from 'Lib/serializeEntryValues';
|
||||
import { currentBackend } from 'coreSrc/backend';
|
||||
import { getIntegrationProvider } from 'Integrations';
|
||||
import { getAsset, selectIntegration, selectPublishedSlugs } from 'Reducers';
|
||||
import { selectFields } from 'Reducers/collections';
|
||||
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
|
||||
import { serializeValues } from '../lib/serializeEntryValues';
|
||||
import { currentBackend, Backend } from '../backend';
|
||||
import { getIntegrationProvider } from '../integrations';
|
||||
import { selectIntegration, selectPublishedSlugs } from '../reducers';
|
||||
import { selectFields } from '../reducers/collections';
|
||||
import { selectCollectionEntriesCursor } from '../reducers/cursors';
|
||||
import { Cursor } from 'netlify-cms-lib-util';
|
||||
import { createEntry } from 'ValueObjects/Entry';
|
||||
import { createAssetProxy } from 'ValueObjects/AssetProxy';
|
||||
import ValidationErrorTypes from 'Constants/validationErrorTypes';
|
||||
import { deleteMedia, addMediaFilesToLibrary } from './mediaLibrary';
|
||||
import { addAssets } from './media';
|
||||
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,
|
||||
MediaFile,
|
||||
State,
|
||||
EntryFields,
|
||||
EntryField,
|
||||
MediaFileMap,
|
||||
} from '../types/redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { AnyAction, Dispatch } from 'redux';
|
||||
import { waitForMediaLibraryToLoad } from './mediaLibrary';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
|
||||
@ -30,7 +41,6 @@ export const ENTRIES_FAILURE = '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';
|
||||
export const DRAFT_CHANGE = 'DRAFT_CHANGE';
|
||||
export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
|
||||
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
|
||||
export const DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_ERRORS';
|
||||
@ -47,15 +57,13 @@ export const ENTRY_DELETE_SUCCESS = 'ENTRY_DELETE_SUCCESS';
|
||||
export const ENTRY_DELETE_FAILURE = 'ENTRY_DELETE_FAILURE';
|
||||
|
||||
export const ADD_DRAFT_ENTRY_MEDIA_FILE = 'ADD_DRAFT_ENTRY_MEDIA_FILE';
|
||||
export const SET_DRAFT_ENTRY_MEDIA_FILES = 'SET_DRAFT_ENTRY_MEDIA_FILES';
|
||||
export const REMOVE_DRAFT_ENTRY_MEDIA_FILE = 'REMOVE_DRAFT_ENTRY_MEDIA_FILE';
|
||||
export const CLEAR_DRAFT_ENTRY_MEDIA_FILES = 'CLEAR_DRAFT_ENTRY_MEDIA_FILES';
|
||||
|
||||
/*
|
||||
* Simple Action Creators (Internal)
|
||||
* We still need to export them for tests
|
||||
*/
|
||||
export function entryLoading(collection, slug) {
|
||||
export function entryLoading(collection: Collection, slug: string) {
|
||||
return {
|
||||
type: ENTRY_REQUEST,
|
||||
payload: {
|
||||
@ -65,7 +73,7 @@ export function entryLoading(collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryLoaded(collection, entry) {
|
||||
export function entryLoaded(collection: Collection, entry: EntryValue) {
|
||||
return {
|
||||
type: ENTRY_SUCCESS,
|
||||
payload: {
|
||||
@ -75,7 +83,7 @@ export function entryLoaded(collection, entry) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryLoadError(error, collection, slug) {
|
||||
export function entryLoadError(error: Error, collection: Collection, slug: string) {
|
||||
return {
|
||||
type: ENTRY_FAILURE,
|
||||
payload: {
|
||||
@ -86,7 +94,7 @@ export function entryLoadError(error, collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entriesLoading(collection) {
|
||||
export function entriesLoading(collection: Collection) {
|
||||
return {
|
||||
type: ENTRIES_REQUEST,
|
||||
payload: {
|
||||
@ -95,7 +103,13 @@ export function entriesLoading(collection) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entriesLoaded(collection, entries, pagination, cursor, append = true) {
|
||||
export function entriesLoaded(
|
||||
collection: Collection,
|
||||
entries: EntryValue[],
|
||||
pagination: number | null,
|
||||
cursor: typeof Cursor,
|
||||
append = true,
|
||||
) {
|
||||
return {
|
||||
type: ENTRIES_SUCCESS,
|
||||
payload: {
|
||||
@ -108,7 +122,7 @@ export function entriesLoaded(collection, entries, pagination, cursor, append =
|
||||
};
|
||||
}
|
||||
|
||||
export function entriesFailed(collection, error) {
|
||||
export function entriesFailed(collection: Collection, error: Error) {
|
||||
return {
|
||||
type: ENTRIES_FAILURE,
|
||||
error: 'Failed to load entries',
|
||||
@ -117,7 +131,7 @@ export function entriesFailed(collection, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryPersisting(collection, entry) {
|
||||
export function entryPersisting(collection: Collection, entry: EntryMap) {
|
||||
return {
|
||||
type: ENTRY_PERSIST_REQUEST,
|
||||
payload: {
|
||||
@ -127,7 +141,7 @@ export function entryPersisting(collection, entry) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryPersisted(collection, entry, slug) {
|
||||
export function entryPersisted(collection: Collection, entry: EntryMap, slug: string) {
|
||||
return {
|
||||
type: ENTRY_PERSIST_SUCCESS,
|
||||
payload: {
|
||||
@ -142,7 +156,7 @@ export function entryPersisted(collection, entry, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryPersistFail(collection, entry, error) {
|
||||
export function entryPersistFail(collection: Collection, entry: EntryMap, error: Error) {
|
||||
return {
|
||||
type: ENTRY_PERSIST_FAILURE,
|
||||
error: 'Failed to persist entry',
|
||||
@ -154,7 +168,7 @@ export function entryPersistFail(collection, entry, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryDeleting(collection, slug) {
|
||||
export function entryDeleting(collection: Collection, slug: string) {
|
||||
return {
|
||||
type: ENTRY_DELETE_REQUEST,
|
||||
payload: {
|
||||
@ -164,7 +178,7 @@ export function entryDeleting(collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryDeleted(collection, slug) {
|
||||
export function entryDeleted(collection: Collection, slug: string) {
|
||||
return {
|
||||
type: ENTRY_DELETE_SUCCESS,
|
||||
payload: {
|
||||
@ -174,7 +188,7 @@ export function entryDeleted(collection, slug) {
|
||||
};
|
||||
}
|
||||
|
||||
export function entryDeleteFail(collection, slug, error) {
|
||||
export function entryDeleteFail(collection: Collection, slug: string, error: Error) {
|
||||
return {
|
||||
type: ENTRY_DELETE_FAILURE,
|
||||
payload: {
|
||||
@ -185,7 +199,7 @@ export function entryDeleteFail(collection, slug, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyDraftCreated(entry) {
|
||||
export function emptyDraftCreated(entry: EntryValue) {
|
||||
return {
|
||||
type: DRAFT_CREATE_EMPTY,
|
||||
payload: entry,
|
||||
@ -194,14 +208,14 @@ export function emptyDraftCreated(entry) {
|
||||
/*
|
||||
* Exported simple Action Creators
|
||||
*/
|
||||
export function createDraftFromEntry(entry, metadata, mediaFiles) {
|
||||
export function createDraftFromEntry(entry: EntryMap, metadata?: Map<string, unknown>) {
|
||||
return {
|
||||
type: DRAFT_CREATE_FROM_ENTRY,
|
||||
payload: { entry, metadata, mediaFiles },
|
||||
payload: { entry, metadata },
|
||||
};
|
||||
}
|
||||
|
||||
export function createDraftDuplicateFromEntry(entry) {
|
||||
export function createDraftDuplicateFromEntry(entry: EntryMap) {
|
||||
return {
|
||||
type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
|
||||
payload: createEntry(entry.get('collection'), '', '', { data: entry.get('data') }),
|
||||
@ -209,34 +223,20 @@ export function createDraftDuplicateFromEntry(entry) {
|
||||
}
|
||||
|
||||
export function discardDraft() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const mediaDrafts = state.entryDraft.get('mediaFiles').filter(file => file.draft);
|
||||
|
||||
mediaDrafts.forEach(file => {
|
||||
dispatch(deleteMedia(file));
|
||||
});
|
||||
|
||||
dispatch({ type: DRAFT_DISCARD });
|
||||
};
|
||||
return { type: DRAFT_DISCARD };
|
||||
}
|
||||
|
||||
export function changeDraft(entry) {
|
||||
return {
|
||||
type: DRAFT_CHANGE,
|
||||
payload: entry,
|
||||
};
|
||||
}
|
||||
|
||||
export function changeDraftField(field, value, metadata) {
|
||||
export function changeDraftField(field: string, value: string, metadata: Record<string, unknown>) {
|
||||
return {
|
||||
type: DRAFT_CHANGE_FIELD,
|
||||
payload: { field, value, metadata },
|
||||
};
|
||||
}
|
||||
|
||||
export function changeDraftFieldValidation(uniquefieldId, errors) {
|
||||
export function changeDraftFieldValidation(
|
||||
uniquefieldId: string,
|
||||
errors: { type: string; message: string }[],
|
||||
) {
|
||||
return {
|
||||
type: DRAFT_VALIDATION_ERRORS,
|
||||
payload: { uniquefieldId, errors },
|
||||
@ -247,78 +247,57 @@ export function clearFieldErrors() {
|
||||
return { type: DRAFT_CLEAR_ERRORS };
|
||||
}
|
||||
|
||||
export function localBackupRetrieved(entry, mediaFiles) {
|
||||
export function localBackupRetrieved(entry: EntryValue) {
|
||||
return {
|
||||
type: DRAFT_LOCAL_BACKUP_RETRIEVED,
|
||||
payload: { entry, mediaFiles },
|
||||
payload: { entry },
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLocalBackup() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: DRAFT_CREATE_FROM_LOCAL_BACKUP,
|
||||
});
|
||||
|
||||
// only add media files to the library after loading from backup was approved
|
||||
const state = getState();
|
||||
const mediaFiles = state.entryDraft.get('mediaFiles').toJS();
|
||||
const filesToAdd = mediaFiles.map(file => ({
|
||||
...file,
|
||||
draft: true,
|
||||
}));
|
||||
dispatch(addMediaFilesToLibrary(filesToAdd));
|
||||
return {
|
||||
type: DRAFT_CREATE_FROM_LOCAL_BACKUP,
|
||||
};
|
||||
}
|
||||
|
||||
export function addDraftEntryMediaFile(file) {
|
||||
export function addDraftEntryMediaFile(file: MediaFile) {
|
||||
return { type: ADD_DRAFT_ENTRY_MEDIA_FILE, payload: file };
|
||||
}
|
||||
|
||||
export function setDraftEntryMediaFiles(files) {
|
||||
return { type: SET_DRAFT_ENTRY_MEDIA_FILES, payload: files };
|
||||
export function removeDraftEntryMediaFile({ id }: { id: string }) {
|
||||
return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: { id } };
|
||||
}
|
||||
|
||||
export function removeDraftEntryMediaFile(file) {
|
||||
return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: file };
|
||||
}
|
||||
|
||||
export function clearDraftEntryMediaFiles() {
|
||||
return { type: CLEAR_DRAFT_ENTRY_MEDIA_FILES };
|
||||
}
|
||||
|
||||
export function persistLocalBackup(entry, collection, mediaFiles) {
|
||||
return (dispatch, getState) => {
|
||||
export function persistLocalBackup(entry: EntryMap, collection: Collection) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
|
||||
// persist any pending related media files and assets
|
||||
const assets = getMediaAssets(state, mediaFiles);
|
||||
|
||||
return backend.persistLocalDraftBackup(entry, collection, mediaFiles, assets);
|
||||
return backend.persistLocalDraftBackup(entry, collection);
|
||||
};
|
||||
}
|
||||
|
||||
export function retrieveLocalBackup(collection, slug) {
|
||||
return async (dispatch, getState) => {
|
||||
export function retrieveLocalBackup(collection: Collection, slug: string) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const { entry, mediaFiles, assets } = await backend.getLocalDraftBackup(collection, slug);
|
||||
const { entry } = await backend.getLocalDraftBackup(collection, slug);
|
||||
|
||||
if (entry) {
|
||||
// load assets from backup
|
||||
const assetProxies = await Promise.all(
|
||||
assets.map(asset => createAssetProxy(asset.value, asset.fileObj)),
|
||||
const mediaFiles = entry.mediaFiles || [];
|
||||
const assetProxies: AssetProxy[] = mediaFiles.map(file =>
|
||||
createAssetProxy({ path: file.path, file: file.file, url: file.url }),
|
||||
);
|
||||
dispatch(addAssets(assetProxies));
|
||||
|
||||
return dispatch(localBackupRetrieved(entry, mediaFiles));
|
||||
return dispatch(localBackupRetrieved(entry));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteLocalBackup(collection, slug) {
|
||||
return (dispatch, getState) => {
|
||||
export function deleteLocalBackup(collection: Collection, slug: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
return backend.deleteLocalDraftBackup(collection, slug);
|
||||
@ -329,17 +308,17 @@ export function deleteLocalBackup(collection, slug) {
|
||||
* Exported Thunk Action Creators
|
||||
*/
|
||||
|
||||
export function loadEntry(collection, slug) {
|
||||
return (dispatch, getState) => {
|
||||
export function loadEntry(collection: Collection, slug: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(entryLoading(collection, slug));
|
||||
return backend
|
||||
.getEntry(collection, slug)
|
||||
.then(loadedEntry => {
|
||||
.getEntry(state, collection, slug)
|
||||
.then((loadedEntry: EntryValue) => {
|
||||
return dispatch(entryLoaded(collection, loadedEntry));
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
@ -360,13 +339,18 @@ const appendActions = fromJS({
|
||||
['append_next']: { action: 'next', append: true },
|
||||
});
|
||||
|
||||
const addAppendActionsToCursor = cursor =>
|
||||
Cursor.create(cursor).updateStore('actions', actions =>
|
||||
actions.union(appendActions.filter(v => actions.has(v.get('action'))).keySeq()),
|
||||
);
|
||||
const addAppendActionsToCursor = (cursor: typeof Cursor) => {
|
||||
return Cursor.create(cursor).updateStore('actions', (actions: Set<string>) => {
|
||||
return actions.union(
|
||||
appendActions
|
||||
.filter((v: Map<string, string | boolean>) => actions.has(v.get('action') as string))
|
||||
.keySeq(),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export function loadEntries(collection, page = 0) {
|
||||
return (dispatch, getState) => {
|
||||
export function loadEntries(collection: Collection, page = 0) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
if (collection.get('isFetching')) {
|
||||
return;
|
||||
}
|
||||
@ -380,7 +364,7 @@ export function loadEntries(collection, page = 0) {
|
||||
dispatch(entriesLoading(collection));
|
||||
provider
|
||||
.listEntries(collection, page)
|
||||
.then(response => ({
|
||||
.then((response: { cursor: typeof Cursor }) => ({
|
||||
...response,
|
||||
|
||||
// The only existing backend using the pagination system is the
|
||||
@ -397,7 +381,7 @@ export function loadEntries(collection, page = 0) {
|
||||
})
|
||||
: Cursor.create(response.cursor),
|
||||
}))
|
||||
.then(response =>
|
||||
.then((response: { cursor: typeof Cursor; pagination: number; entries: EntryValue[] }) =>
|
||||
dispatch(
|
||||
entriesLoaded(
|
||||
collection,
|
||||
@ -410,7 +394,7 @@ export function loadEntries(collection, page = 0) {
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(err => {
|
||||
.catch((err: Error) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
||||
@ -426,17 +410,18 @@ export function loadEntries(collection, page = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
function traverseCursor(backend, cursor, action) {
|
||||
function traverseCursor(backend: Backend, cursor: typeof Cursor, action: string) {
|
||||
if (!cursor.actions.has(action)) {
|
||||
throw new Error(`The current cursor does not support the pagination action "${action}".`);
|
||||
}
|
||||
return backend.traverseCursor(cursor, action);
|
||||
}
|
||||
|
||||
export function traverseCollectionCursor(collection, action) {
|
||||
return async (dispatch, getState) => {
|
||||
export function traverseCollectionCursor(collection: Collection, action: string) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
if (state.entries.getIn(['pages', `${collection.get('name')}`, 'isFetching'])) {
|
||||
const collectionName = collection.get('name');
|
||||
if (state.entries.getIn(['pages', `${collectionName}`, 'isFetching'])) {
|
||||
return;
|
||||
}
|
||||
const backend = currentBackend(state.config);
|
||||
@ -477,59 +462,94 @@ export function traverseCollectionCursor(collection, action) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyDraft(collection) {
|
||||
return dispatch => {
|
||||
export function createEmptyDraft(collection: Collection) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const dataFields = createEmptyDraftData(collection.get('fields', List()));
|
||||
const newEntry = createEntry(collection.get('name'), '', '', { data: dataFields });
|
||||
|
||||
let mediaFiles = [] as MediaFile[];
|
||||
if (!collection.has('media_folder')) {
|
||||
await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
mediaFiles = getState().mediaLibrary.get('files');
|
||||
}
|
||||
|
||||
const newEntry = createEntry(collection.get('name'), '', '', { data: dataFields, mediaFiles });
|
||||
dispatch(emptyDraftCreated(newEntry));
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyDraftData(fields, withNameKey = true) {
|
||||
return fields.reduce((acc, item) => {
|
||||
const subfields = item.get('field') || item.get('fields');
|
||||
const list = item.get('widget') == 'list';
|
||||
const name = item.get('name');
|
||||
const defaultValue = item.get('default', null);
|
||||
const isEmptyDefaultValue = val => [[{}], {}].some(e => isEqual(val, e));
|
||||
|
||||
if (List.isList(subfields)) {
|
||||
const subDefaultValue = list
|
||||
? [createEmptyDraftData(subfields)]
|
||||
: createEmptyDraftData(subfields);
|
||||
if (!isEmptyDefaultValue(subDefaultValue)) {
|
||||
acc[name] = subDefaultValue;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (Map.isMap(subfields)) {
|
||||
const subDefaultValue = list
|
||||
? [createEmptyDraftData([subfields], false)]
|
||||
: createEmptyDraftData([subfields]);
|
||||
if (!isEmptyDefaultValue(subDefaultValue)) {
|
||||
acc[name] = subDefaultValue;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (defaultValue !== null) {
|
||||
if (!withNameKey) {
|
||||
return defaultValue;
|
||||
}
|
||||
acc[name] = defaultValue;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
interface DraftEntryData {
|
||||
[name: string]: string | null | DraftEntryData | DraftEntryData[] | (string | DraftEntryData)[];
|
||||
}
|
||||
|
||||
export function getMediaAssets(state, mediaFiles) {
|
||||
return mediaFiles.map(file => getAsset(state, file.public_path));
|
||||
export function createEmptyDraftData(fields: EntryFields, withNameKey = true) {
|
||||
return fields.reduce(
|
||||
(reduction: DraftEntryData | string | undefined, value: EntryField | undefined) => {
|
||||
const acc = reduction as DraftEntryData;
|
||||
const item = value as EntryField;
|
||||
const subfields = item.get('field') || item.get('fields');
|
||||
const list = item.get('widget') == 'list';
|
||||
const name = item.get('name');
|
||||
const defaultValue = item.get('default', null);
|
||||
const isEmptyDefaultValue = (val: unknown) => [[{}], {}].some(e => isEqual(val, e));
|
||||
|
||||
if (List.isList(subfields)) {
|
||||
const subDefaultValue = list
|
||||
? [createEmptyDraftData(subfields as EntryFields)]
|
||||
: createEmptyDraftData(subfields as EntryFields);
|
||||
if (!isEmptyDefaultValue(subDefaultValue)) {
|
||||
acc[name] = subDefaultValue;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (Map.isMap(subfields)) {
|
||||
const subDefaultValue = list
|
||||
? [createEmptyDraftData(List([subfields as EntryField]), false)]
|
||||
: createEmptyDraftData(List([subfields as EntryField]));
|
||||
if (!isEmptyDefaultValue(subDefaultValue)) {
|
||||
acc[name] = subDefaultValue;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (defaultValue !== null) {
|
||||
if (!withNameKey) {
|
||||
return defaultValue;
|
||||
}
|
||||
acc[name] = defaultValue;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as DraftEntryData,
|
||||
);
|
||||
}
|
||||
|
||||
export function persistEntry(collection) {
|
||||
return (dispatch, getState) => {
|
||||
export async function getMediaAssets({
|
||||
getState,
|
||||
mediaFiles,
|
||||
dispatch,
|
||||
collection,
|
||||
entryPath,
|
||||
}: {
|
||||
getState: () => State;
|
||||
mediaFiles: List<MediaFileMap>;
|
||||
collection: Collection;
|
||||
entryPath: string;
|
||||
dispatch: Dispatch;
|
||||
}) {
|
||||
const filesArray = mediaFiles.toJS() as MediaFile[];
|
||||
const assets = await Promise.all(
|
||||
filesArray
|
||||
.filter(file => file.draft)
|
||||
.map(file => getAsset({ collection, entryPath, path: file.path })(dispatch, getState)),
|
||||
);
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
export function persistEntry(collection: Collection) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const entryDraft = state.entryDraft;
|
||||
const fieldsErrors = entryDraft.get('fieldsErrors');
|
||||
@ -557,8 +577,14 @@ export function persistEntry(collection) {
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config);
|
||||
const assetProxies = getMediaAssets(state, entryDraft.get('mediaFiles'));
|
||||
const entry = entryDraft.get('entry');
|
||||
const assetProxies = await getMediaAssets({
|
||||
getState,
|
||||
mediaFiles: entry.get('mediaFiles'),
|
||||
dispatch,
|
||||
collection,
|
||||
entryPath: entry.get('path'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize the values of any fields with registered serializers, and
|
||||
@ -570,15 +596,15 @@ export function persistEntry(collection) {
|
||||
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
|
||||
dispatch(entryPersisting(collection, serializedEntry));
|
||||
return backend
|
||||
.persistEntry(
|
||||
state.config,
|
||||
.persistEntry({
|
||||
config: state.config,
|
||||
collection,
|
||||
serializedEntryDraft,
|
||||
assetProxies.toJS(),
|
||||
state.integrations,
|
||||
entryDraft: serializedEntryDraft,
|
||||
assetProxies,
|
||||
integrations: state.integrations,
|
||||
usedSlugs,
|
||||
)
|
||||
.then(slug => {
|
||||
})
|
||||
.then((slug: string) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
||||
@ -590,7 +616,7 @@ export function persistEntry(collection) {
|
||||
);
|
||||
dispatch(entryPersisted(collection, serializedEntry, slug));
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
@ -607,8 +633,8 @@ export function persistEntry(collection) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteEntry(collection, slug) {
|
||||
return (dispatch, getState) => {
|
||||
export function deleteEntry(collection: Collection, slug: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
|
||||
@ -618,7 +644,7 @@ export function deleteEntry(collection, slug) {
|
||||
.then(() => {
|
||||
return dispatch(entryDeleted(collection, slug));
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: {
|
@ -1,15 +0,0 @@
|
||||
export const ADD_ASSETS = 'ADD_ASSETS';
|
||||
export const ADD_ASSET = 'ADD_ASSET';
|
||||
export const REMOVE_ASSET = 'REMOVE_ASSET';
|
||||
|
||||
export function addAssets(assets) {
|
||||
return { type: ADD_ASSETS, payload: assets };
|
||||
}
|
||||
|
||||
export function addAsset(assetProxy) {
|
||||
return { type: ADD_ASSET, payload: assetProxy };
|
||||
}
|
||||
|
||||
export function removeAsset(path) {
|
||||
return { type: REMOVE_ASSET, payload: path };
|
||||
}
|
67
packages/netlify-cms-core/src/actions/media.ts
Normal file
67
packages/netlify-cms-core/src/actions/media.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { Collection, State, MediaFile } from '../types/redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { AnyAction } from 'redux';
|
||||
import { isAbsolutePath } from 'netlify-cms-lib-util';
|
||||
import { selectMediaFilePath } from '../reducers/entries';
|
||||
import { selectMediaFileByPath } from '../reducers/mediaLibrary';
|
||||
import { getMediaFile, waitForMediaLibraryToLoad, getMediaDisplayURL } from './mediaLibrary';
|
||||
|
||||
export const ADD_ASSETS = 'ADD_ASSETS';
|
||||
export const ADD_ASSET = 'ADD_ASSET';
|
||||
export const REMOVE_ASSET = 'REMOVE_ASSET';
|
||||
|
||||
export function addAssets(assets: AssetProxy[]) {
|
||||
return { type: ADD_ASSETS, payload: assets };
|
||||
}
|
||||
|
||||
export function addAsset(assetProxy: AssetProxy) {
|
||||
return { type: ADD_ASSET, payload: assetProxy };
|
||||
}
|
||||
|
||||
export function removeAsset(path: string) {
|
||||
return { type: REMOVE_ASSET, payload: path };
|
||||
}
|
||||
|
||||
interface GetAssetArgs {
|
||||
collection: Collection;
|
||||
entryPath: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function getAsset({ collection, entryPath, path }: GetAssetArgs) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
if (!path) return createAssetProxy({ path: '', file: new File([], 'empty') });
|
||||
|
||||
const state = getState();
|
||||
const resolvedPath = selectMediaFilePath(state.config, collection, entryPath, path);
|
||||
|
||||
let asset = state.medias.get(resolvedPath);
|
||||
if (asset) {
|
||||
// There is already an AssetProxy in memory for this path. Use it.
|
||||
return asset;
|
||||
}
|
||||
|
||||
// Create a new AssetProxy (for consistency) and return it.
|
||||
if (isAbsolutePath(resolvedPath)) {
|
||||
// asset path is a public url so we can just use it as is
|
||||
asset = createAssetProxy({ path: resolvedPath, url: path });
|
||||
} else {
|
||||
// load asset url from backend
|
||||
await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
const file: MediaFile | null = selectMediaFileByPath(state, resolvedPath);
|
||||
|
||||
if (file) {
|
||||
const url = await getMediaDisplayURL(dispatch, getState(), file);
|
||||
asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
|
||||
} else {
|
||||
const { url } = await getMediaFile(state, resolvedPath);
|
||||
asset = createAssetProxy({ path: resolvedPath, url });
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(addAsset(asset));
|
||||
|
||||
return asset;
|
||||
};
|
||||
}
|
@ -1,434 +0,0 @@
|
||||
import { Map } from 'immutable';
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { resolveMediaFilename, getBlobSHA } from 'netlify-cms-lib-util';
|
||||
import { currentBackend } from 'coreSrc/backend';
|
||||
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
|
||||
import { createAssetProxy } from 'ValueObjects/AssetProxy';
|
||||
import { selectIntegration } from 'Reducers';
|
||||
import { getIntegrationProvider } from 'Integrations';
|
||||
import { addAsset, removeAsset } from './media';
|
||||
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
|
||||
import { sanitizeSlug } from 'Lib/urlHelper';
|
||||
import { waitUntil } from './waitUntil';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
|
||||
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
|
||||
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
|
||||
export const MEDIA_LIBRARY_CREATE = 'MEDIA_LIBRARY_CREATE';
|
||||
export const MEDIA_INSERT = 'MEDIA_INSERT';
|
||||
export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED';
|
||||
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
|
||||
export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS';
|
||||
export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE';
|
||||
export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST';
|
||||
export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS';
|
||||
export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE';
|
||||
export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST';
|
||||
export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS';
|
||||
export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE';
|
||||
export const MEDIA_DISPLAY_URL_REQUEST = 'MEDIA_DISPLAY_URL_REQUEST';
|
||||
export const MEDIA_DISPLAY_URL_SUCCESS = 'MEDIA_DISPLAY_URL_SUCCESS';
|
||||
export const MEDIA_DISPLAY_URL_FAILURE = 'MEDIA_DISPLAY_URL_FAILURE';
|
||||
export const ADD_MEDIA_FILES_TO_LIBRARY = 'ADD_MEDIA_FILES_TO_LIBRARY';
|
||||
|
||||
export function createMediaLibrary(instance) {
|
||||
const api = {
|
||||
show: instance.show || (() => {}),
|
||||
hide: instance.hide || (() => {}),
|
||||
onClearControl: instance.onClearControl || (() => {}),
|
||||
onRemoveControl: instance.onRemoveControl || (() => {}),
|
||||
enableStandalone: instance.enableStandalone || (() => {}),
|
||||
};
|
||||
return { type: MEDIA_LIBRARY_CREATE, payload: api };
|
||||
}
|
||||
|
||||
export function clearMediaControl(id) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onClearControl({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function removeMediaControl(id) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onRemoveControl({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function openMediaLibrary(payload = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
const { controlID: id, value, config = Map(), allowMultiple, forImage } = payload;
|
||||
mediaLibrary.show({ id, value, config: config.toJS(), allowMultiple, imagesOnly: forImage });
|
||||
}
|
||||
dispatch({ type: MEDIA_LIBRARY_OPEN, payload });
|
||||
};
|
||||
}
|
||||
|
||||
export function closeMediaLibrary() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.hide();
|
||||
}
|
||||
dispatch({ type: MEDIA_LIBRARY_CLOSE });
|
||||
};
|
||||
}
|
||||
|
||||
export function insertMedia(media) {
|
||||
return (dispatch, getState) => {
|
||||
let mediaPath;
|
||||
if (media.url) {
|
||||
// media.url is public, and already resolved
|
||||
mediaPath = media.url;
|
||||
} else if (media.name) {
|
||||
// media.name still needs to be resolved to the appropriate URL
|
||||
const state = getState();
|
||||
const config = state.config;
|
||||
if (config.get('media_folder_relative')) {
|
||||
// the path is being resolved relatively
|
||||
// and we need to know the path of the entry to resolve it
|
||||
const mediaFolder = config.get('media_folder');
|
||||
const collection = state.entryDraft.getIn(['entry', 'collection']);
|
||||
const collectionFolder = state.collections.getIn([collection, 'folder']);
|
||||
mediaPath = resolveMediaFilename(media.name, { mediaFolder, collectionFolder });
|
||||
} else {
|
||||
// the path is being resolved to a public URL
|
||||
const publicFolder = config.get('public_folder');
|
||||
mediaPath = resolveMediaFilename(media.name, { publicFolder });
|
||||
}
|
||||
} else if (Array.isArray(media) || typeof media === 'string') {
|
||||
mediaPath = media;
|
||||
} else {
|
||||
throw new Error('Incorrect usage, expected {url}, {file}, string or string array');
|
||||
}
|
||||
dispatch({ type: MEDIA_INSERT, payload: { mediaPath } });
|
||||
};
|
||||
}
|
||||
|
||||
export function removeInsertedMedia(controlID) {
|
||||
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } };
|
||||
}
|
||||
|
||||
export function loadMedia(opts = {}) {
|
||||
const { delay = 0, query = '', page = 1, privateUpload } = opts;
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||
dispatch(mediaLoading(page));
|
||||
try {
|
||||
const files = await provider.retrieve(query, page, privateUpload);
|
||||
const mediaLoadedOpts = {
|
||||
page,
|
||||
canPaginate: true,
|
||||
dynamicSearch: true,
|
||||
dynamicSearchQuery: query,
|
||||
privateUpload,
|
||||
};
|
||||
return dispatch(mediaLoaded(files, mediaLoadedOpts));
|
||||
} catch (error) {
|
||||
return dispatch(mediaLoadFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
dispatch(mediaLoading(page));
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() =>
|
||||
resolve(
|
||||
backend
|
||||
.getMedia()
|
||||
.then(files => dispatch(mediaLoaded(files)))
|
||||
.catch(
|
||||
error =>
|
||||
console.error(error) ||
|
||||
dispatch(() => {
|
||||
if (error.status === 404) {
|
||||
console.log('This 404 was expected and handled appropriately.');
|
||||
return mediaLoaded();
|
||||
} else {
|
||||
return mediaLoadFailed();
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function persistMedia(file, opts = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
const files = state.mediaLibrary.get('files');
|
||||
const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.get('slug'));
|
||||
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
|
||||
|
||||
/**
|
||||
* Check for existing files of the same name before persisting. If no asset
|
||||
* store integration is used, files are being stored in Git, so we can
|
||||
* expect file names to be unique. If an asset store is in use, file names
|
||||
* may not be unique, so we forego this check.
|
||||
*/
|
||||
if (!integration && existingFile) {
|
||||
if (!window.confirm(`${existingFile.name} already exists. Do you want to replace it?`)) {
|
||||
return;
|
||||
} else {
|
||||
await dispatch(deleteMedia(existingFile, { privateUpload }));
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(mediaPersisting());
|
||||
|
||||
try {
|
||||
const id = await getBlobSHA(file);
|
||||
const assetProxy = await createAssetProxy(fileName, file, false, privateUpload);
|
||||
dispatch(addAsset(assetProxy));
|
||||
|
||||
const entry = state.entryDraft.get('entry');
|
||||
const useWorkflow = state.config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW;
|
||||
const draft = entry && !entry.isEmpty() && useWorkflow;
|
||||
|
||||
if (!integration) {
|
||||
const asset = await backend.persistMedia(state.config, assetProxy, draft);
|
||||
|
||||
const assetId = asset.id || id;
|
||||
const displayURL = asset.displayURL || URL.createObjectURL(file);
|
||||
|
||||
if (draft) {
|
||||
dispatch(
|
||||
addDraftEntryMediaFile({
|
||||
...asset,
|
||||
id: assetId,
|
||||
draft,
|
||||
public_path: assetProxy.public_path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch(
|
||||
mediaPersisted({
|
||||
...asset,
|
||||
id: assetId,
|
||||
displayURL,
|
||||
draft,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch(
|
||||
mediaPersisted(
|
||||
{ id, displayURL: URL.createObjectURL(file), ...assetProxy.asset, draft },
|
||||
{ privateUpload },
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to persist media: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaPersistFailed({ privateUpload }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteMedia(file, opts = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||
dispatch(mediaDeleting());
|
||||
|
||||
try {
|
||||
await provider.delete(file.id);
|
||||
return dispatch(mediaDeleted(file, { privateUpload }));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete media: ${error.message}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaDeleteFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
dispatch(mediaDeleting());
|
||||
|
||||
try {
|
||||
const assetProxy = await createAssetProxy(file.name, file);
|
||||
dispatch(removeAsset(assetProxy.public_path));
|
||||
dispatch(removeDraftEntryMediaFile({ id: file.id }));
|
||||
|
||||
if (!file.draft) {
|
||||
await backend.deleteMedia(state.config, file.path);
|
||||
}
|
||||
|
||||
return dispatch(mediaDeleted(file));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete media: ${error.message}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaDeleteFailed());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadMediaDisplayURL(file) {
|
||||
return async (dispatch, getState) => {
|
||||
const { displayURL, id, url } = file;
|
||||
const state = getState();
|
||||
const displayURLState = state.mediaLibrary.getIn(['displayURLs', id], Map());
|
||||
if (
|
||||
!id ||
|
||||
// displayURL is used by most backends; url (like urlIsPublicPath) is used exclusively by the
|
||||
// assetStore integration. Only the assetStore uses URLs which can actually be inserted into
|
||||
// an entry - other backends create a domain-relative URL using the public_folder from the
|
||||
// config and the file's name.
|
||||
(!displayURL && !url) ||
|
||||
displayURLState.get('url') ||
|
||||
displayURLState.get('isFetching') ||
|
||||
displayURLState.get('err')
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (typeof displayURL === 'string') {
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
dispatch(mediaDisplayURLSuccess(id, displayURL));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
const newURL = await backend.getMediaDisplayURL(displayURL);
|
||||
if (newURL) {
|
||||
dispatch(mediaDisplayURLSuccess(id, newURL));
|
||||
} else {
|
||||
throw new Error('No display URL was returned!');
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch(mediaDisplayURLFailure(id, err));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaLoading(page) {
|
||||
return {
|
||||
type: MEDIA_LOAD_REQUEST,
|
||||
payload: { page },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaLoaded(files, opts = {}) {
|
||||
return {
|
||||
type: MEDIA_LOAD_SUCCESS,
|
||||
payload: { files, ...opts },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaLoadFailed(error, opts = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } };
|
||||
}
|
||||
|
||||
export function mediaPersisting() {
|
||||
return { type: MEDIA_PERSIST_REQUEST };
|
||||
}
|
||||
|
||||
export function mediaPersisted(asset, opts = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return {
|
||||
type: MEDIA_PERSIST_SUCCESS,
|
||||
payload: { file: asset, privateUpload },
|
||||
};
|
||||
}
|
||||
|
||||
export function addMediaFilesToLibrary(mediaFiles) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const action = {
|
||||
type: ADD_MEDIA_FILES_TO_LIBRARY,
|
||||
payload: { mediaFiles },
|
||||
};
|
||||
// add media files to library only after the library finished loading
|
||||
if (state.mediaLibrary.get('isLoading') === false) {
|
||||
dispatch(action);
|
||||
} else {
|
||||
dispatch(
|
||||
waitUntil({
|
||||
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS,
|
||||
run: dispatch => dispatch(action),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaPersistFailed(error, opts = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } };
|
||||
}
|
||||
|
||||
export function mediaDeleting() {
|
||||
return { type: MEDIA_DELETE_REQUEST };
|
||||
}
|
||||
|
||||
export function mediaDeleted(file, opts = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return {
|
||||
type: MEDIA_DELETE_SUCCESS,
|
||||
payload: { file, privateUpload },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaDeleteFailed(error, opts = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } };
|
||||
}
|
||||
|
||||
export function mediaDisplayURLRequest(key) {
|
||||
return { type: MEDIA_DISPLAY_URL_REQUEST, payload: { key } };
|
||||
}
|
||||
|
||||
export function mediaDisplayURLSuccess(key, url) {
|
||||
return {
|
||||
type: MEDIA_DISPLAY_URL_SUCCESS,
|
||||
payload: { key, url },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaDisplayURLFailure(key, err) {
|
||||
console.error(err);
|
||||
return {
|
||||
type: MEDIA_DISPLAY_URL_FAILURE,
|
||||
payload: { key, err },
|
||||
};
|
||||
}
|
505
packages/netlify-cms-core/src/actions/mediaLibrary.ts
Normal file
505
packages/netlify-cms-core/src/actions/mediaLibrary.ts
Normal file
@ -0,0 +1,505 @@
|
||||
import { Map } from 'immutable';
|
||||
import { actions as notifActions } from 'redux-notifications';
|
||||
import { getBlobSHA } from 'netlify-cms-lib-util';
|
||||
import { currentBackend } from '../backend';
|
||||
import AssetProxy, { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { selectIntegration } from '../reducers';
|
||||
import { selectMediaFilePath, selectMediaFilePublicPath } from '../reducers/entries';
|
||||
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
|
||||
import { getIntegrationProvider } from '../integrations';
|
||||
import { addAsset, removeAsset } from './media';
|
||||
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
|
||||
import { sanitizeSlug } from '../lib/urlHelper';
|
||||
import { State, MediaFile, DisplayURLState } from '../types/redux';
|
||||
import { AnyAction } from 'redux';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { MediaLibraryInstance } from '../mediaLibrary';
|
||||
import { selectEditingWorkflowDraft } from '../reducers/editorialWorkflow';
|
||||
import { waitUntilWithTimeout } from './waitUntil';
|
||||
|
||||
const { notifSend } = notifActions;
|
||||
|
||||
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
|
||||
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
|
||||
export const MEDIA_LIBRARY_CREATE = 'MEDIA_LIBRARY_CREATE';
|
||||
export const MEDIA_INSERT = 'MEDIA_INSERT';
|
||||
export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED';
|
||||
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
|
||||
export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS';
|
||||
export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE';
|
||||
export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST';
|
||||
export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS';
|
||||
export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE';
|
||||
export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST';
|
||||
export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS';
|
||||
export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE';
|
||||
export const MEDIA_DISPLAY_URL_REQUEST = 'MEDIA_DISPLAY_URL_REQUEST';
|
||||
export const MEDIA_DISPLAY_URL_SUCCESS = 'MEDIA_DISPLAY_URL_SUCCESS';
|
||||
export const MEDIA_DISPLAY_URL_FAILURE = 'MEDIA_DISPLAY_URL_FAILURE';
|
||||
|
||||
export function createMediaLibrary(instance: MediaLibraryInstance) {
|
||||
const api = {
|
||||
show: instance.show || (() => undefined),
|
||||
hide: instance.hide || (() => undefined),
|
||||
onClearControl: instance.onClearControl || (() => undefined),
|
||||
onRemoveControl: instance.onRemoveControl || (() => undefined),
|
||||
enableStandalone: instance.enableStandalone || (() => undefined),
|
||||
};
|
||||
return { type: MEDIA_LIBRARY_CREATE, payload: api };
|
||||
}
|
||||
|
||||
export function clearMediaControl(id: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onClearControl({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function removeMediaControl(id: string) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onRemoveControl({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function openMediaLibrary(
|
||||
payload: {
|
||||
controlID?: string;
|
||||
value?: string;
|
||||
config?: Map<string, unknown>;
|
||||
allowMultiple?: boolean;
|
||||
forImage?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
const { controlID: id, value, config = Map(), allowMultiple, forImage } = payload;
|
||||
mediaLibrary.show({ id, value, config: config.toJS(), allowMultiple, imagesOnly: forImage });
|
||||
}
|
||||
dispatch({ type: MEDIA_LIBRARY_OPEN, payload });
|
||||
};
|
||||
}
|
||||
|
||||
export function closeMediaLibrary() {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.get('externalLibrary');
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.hide();
|
||||
}
|
||||
dispatch({ type: MEDIA_LIBRARY_CLOSE });
|
||||
};
|
||||
}
|
||||
|
||||
export function insertMedia(mediaPath: string | string[]) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const config = state.config;
|
||||
const collectionName = state.entryDraft.getIn(['entry', 'collection']);
|
||||
const collection = state.collections.get(collectionName);
|
||||
if (Array.isArray(mediaPath)) {
|
||||
mediaPath = mediaPath.map(path => selectMediaFilePublicPath(config, collection, path));
|
||||
} else {
|
||||
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string);
|
||||
}
|
||||
dispatch({ type: MEDIA_INSERT, payload: { mediaPath } });
|
||||
};
|
||||
}
|
||||
|
||||
export function removeInsertedMedia(controlID: string) {
|
||||
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } };
|
||||
}
|
||||
|
||||
export function loadMedia(
|
||||
opts: { delay?: number; query?: string; page?: number; privateUpload?: boolean } = {},
|
||||
) {
|
||||
const { delay = 0, query = '', page = 1, privateUpload } = opts;
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||
dispatch(mediaLoading(page));
|
||||
try {
|
||||
const files = await provider.retrieve(query, page, privateUpload);
|
||||
const mediaLoadedOpts = {
|
||||
page,
|
||||
canPaginate: true,
|
||||
dynamicSearch: true,
|
||||
dynamicSearchQuery: query,
|
||||
privateUpload,
|
||||
};
|
||||
return dispatch(mediaLoaded(files, mediaLoadedOpts));
|
||||
} catch (error) {
|
||||
return dispatch(mediaLoadFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
dispatch(mediaLoading(page));
|
||||
return new Promise(resolve => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
backend
|
||||
.getMedia()
|
||||
.then((files: MediaFile[]) => dispatch(mediaLoaded(files)))
|
||||
.catch((error: { status?: number }) => {
|
||||
console.error(error);
|
||||
if (error.status === 404) {
|
||||
console.log('This 404 was expected and handled appropriately.');
|
||||
dispatch(mediaLoaded([]));
|
||||
} else {
|
||||
dispatch(mediaLoadFailed());
|
||||
}
|
||||
}),
|
||||
),
|
||||
delay,
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaFileFromAsset({
|
||||
id,
|
||||
file,
|
||||
assetProxy,
|
||||
draft,
|
||||
}: {
|
||||
id: string;
|
||||
file: File;
|
||||
assetProxy: AssetProxy;
|
||||
draft: boolean;
|
||||
}): MediaFile {
|
||||
const mediaFile = {
|
||||
id,
|
||||
name: file.name,
|
||||
displayURL: assetProxy.url,
|
||||
draft,
|
||||
size: file.size,
|
||||
url: assetProxy.url,
|
||||
path: assetProxy.path,
|
||||
};
|
||||
return mediaFile;
|
||||
}
|
||||
|
||||
export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
const files: MediaFile[] = selectMediaFiles(state);
|
||||
const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.get('slug'));
|
||||
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
|
||||
|
||||
const editingDraft = selectEditingWorkflowDraft(state);
|
||||
|
||||
/**
|
||||
* Check for existing files of the same name before persisting. If no asset
|
||||
* store integration is used, files are being stored in Git, so we can
|
||||
* expect file names to be unique. If an asset store is in use, file names
|
||||
* may not be unique, so we forego this check.
|
||||
*/
|
||||
if (!integration && existingFile) {
|
||||
if (!window.confirm(`${existingFile.name} already exists. Do you want to replace it?`)) {
|
||||
return;
|
||||
} else {
|
||||
await dispatch(deleteMedia(existingFile, { privateUpload }));
|
||||
}
|
||||
}
|
||||
|
||||
if (integration || !editingDraft) {
|
||||
dispatch(mediaPersisting());
|
||||
}
|
||||
|
||||
try {
|
||||
let assetProxy: AssetProxy;
|
||||
if (integration) {
|
||||
try {
|
||||
const provider = getIntegrationProvider(
|
||||
state.integrations,
|
||||
backend.getToken,
|
||||
integration,
|
||||
);
|
||||
const response = await provider.upload(file, privateUpload);
|
||||
assetProxy = createAssetProxy({
|
||||
url: response.asset.url,
|
||||
path: response.asset.url,
|
||||
});
|
||||
} catch (error) {
|
||||
assetProxy = createAssetProxy({
|
||||
file,
|
||||
path: file.name,
|
||||
});
|
||||
}
|
||||
} else if (privateUpload) {
|
||||
throw new Error('The Private Upload option is only available for Asset Store Integration');
|
||||
} else {
|
||||
const entry = state.entryDraft.get('entry');
|
||||
const entryPath = entry?.get('path');
|
||||
const collection = state.collections.get(entry?.get('collection'));
|
||||
const path = selectMediaFilePath(state.config, collection, entryPath, file.name);
|
||||
assetProxy = createAssetProxy({
|
||||
file,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(addAsset(assetProxy));
|
||||
|
||||
let mediaFile: MediaFile;
|
||||
if (integration) {
|
||||
const id = await getBlobSHA(file);
|
||||
// integration assets are persisted immediately, thus draft is false
|
||||
mediaFile = createMediaFileFromAsset({ id, file, assetProxy, draft: false });
|
||||
} else if (editingDraft) {
|
||||
const id = await getBlobSHA(file);
|
||||
mediaFile = createMediaFileFromAsset({ id, file, assetProxy, draft: editingDraft });
|
||||
return dispatch(addDraftEntryMediaFile(mediaFile));
|
||||
} else {
|
||||
mediaFile = await backend.persistMedia(state.config, assetProxy);
|
||||
}
|
||||
|
||||
return dispatch(mediaPersisted(mediaFile, { privateUpload }));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to persist media: ${error}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaPersistFailed({ privateUpload }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const state = getState();
|
||||
const backend = currentBackend(state.config);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getIntegrationProvider(state.integrations, backend.getToken, integration);
|
||||
dispatch(mediaDeleting());
|
||||
|
||||
try {
|
||||
await provider.delete(file.id);
|
||||
return dispatch(mediaDeleted(file, { privateUpload }));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete media: ${error.message}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaDeleteFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (file.draft) {
|
||||
dispatch(removeAsset(file.path));
|
||||
dispatch(removeDraftEntryMediaFile({ id: file.id }));
|
||||
} else {
|
||||
const editingDraft = selectEditingWorkflowDraft(state);
|
||||
|
||||
dispatch(mediaDeleting());
|
||||
dispatch(removeAsset(file.path));
|
||||
|
||||
await backend.deleteMedia(state.config, file.path);
|
||||
|
||||
dispatch(mediaDeleted(file));
|
||||
if (editingDraft) {
|
||||
dispatch(removeDraftEntryMediaFile({ id: file.id }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
notifSend({
|
||||
message: `Failed to delete media: ${error.message}`,
|
||||
kind: 'danger',
|
||||
dismissAfter: 8000,
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaDeleteFailed());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMediaFile(state: State, path: string) {
|
||||
const backend = currentBackend(state.config);
|
||||
try {
|
||||
const { url } = await backend.getMediaFile(path);
|
||||
return { url };
|
||||
} catch (e) {
|
||||
return { url: path };
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMediaDisplayURL(file: MediaFile) {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
const { displayURL, id } = file;
|
||||
const state = getState();
|
||||
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, id);
|
||||
if (
|
||||
!id ||
|
||||
!displayURL ||
|
||||
displayURLState.get('url') ||
|
||||
displayURLState.get('isFetching') ||
|
||||
displayURLState.get('err')
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (typeof displayURL === 'string') {
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
dispatch(mediaDisplayURLSuccess(id, displayURL));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const backend = currentBackend(state.config);
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
const newURL = await backend.getMediaDisplayURL(displayURL);
|
||||
if (newURL) {
|
||||
dispatch(mediaDisplayURLSuccess(id, newURL));
|
||||
} else {
|
||||
throw new Error('No display URL was returned!');
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch(mediaDisplayURLFailure(id, err));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaLoading(page: number) {
|
||||
return {
|
||||
type: MEDIA_LOAD_REQUEST,
|
||||
payload: { page },
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaOptions {
|
||||
privateUpload?: boolean;
|
||||
}
|
||||
|
||||
export function mediaLoaded(files: MediaFile[], opts: MediaOptions = {}) {
|
||||
return {
|
||||
type: MEDIA_LOAD_SUCCESS,
|
||||
payload: { files, ...opts },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaLoadFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } };
|
||||
}
|
||||
|
||||
export function mediaPersisting() {
|
||||
return { type: MEDIA_PERSIST_REQUEST };
|
||||
}
|
||||
|
||||
export function mediaPersisted(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return {
|
||||
type: MEDIA_PERSIST_SUCCESS,
|
||||
payload: { file, privateUpload },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaPersistFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } };
|
||||
}
|
||||
|
||||
export function mediaDeleting() {
|
||||
return { type: MEDIA_DELETE_REQUEST };
|
||||
}
|
||||
|
||||
export function mediaDeleted(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return {
|
||||
type: MEDIA_DELETE_SUCCESS,
|
||||
payload: { file, privateUpload },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaDeleteFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } };
|
||||
}
|
||||
|
||||
export function mediaDisplayURLRequest(key: string) {
|
||||
return { type: MEDIA_DISPLAY_URL_REQUEST, payload: { key } };
|
||||
}
|
||||
|
||||
export function mediaDisplayURLSuccess(key: string, url: string) {
|
||||
return {
|
||||
type: MEDIA_DISPLAY_URL_SUCCESS,
|
||||
payload: { key, url },
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaDisplayURLFailure(key: string, err: Error) {
|
||||
console.error(err);
|
||||
return {
|
||||
type: MEDIA_DISPLAY_URL_FAILURE,
|
||||
payload: { key, err },
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForMediaLibraryToLoad(
|
||||
dispatch: ThunkDispatch<State, {}, AnyAction>,
|
||||
state: State,
|
||||
) {
|
||||
if (state.mediaLibrary.get('isLoading') !== false) {
|
||||
await waitUntilWithTimeout(dispatch, resolve => ({
|
||||
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE,
|
||||
run: () => resolve(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaDisplayURL(
|
||||
dispatch: ThunkDispatch<State, {}, AnyAction>,
|
||||
state: State,
|
||||
file: MediaFile,
|
||||
) {
|
||||
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, file.id);
|
||||
|
||||
let url: string | null | undefined;
|
||||
if (displayURLState.get('url')) {
|
||||
// url was already loaded
|
||||
url = displayURLState.get('url');
|
||||
} else if (displayURLState.get('err')) {
|
||||
// url loading had an error
|
||||
url = null;
|
||||
} else {
|
||||
if (!displayURLState.get('isFetching')) {
|
||||
// load display url
|
||||
dispatch(loadMediaDisplayURL(file));
|
||||
}
|
||||
|
||||
const key = file.id;
|
||||
url = await waitUntilWithTimeout<string>(dispatch, resolve => ({
|
||||
predicate: ({ type, payload }) =>
|
||||
(type === MEDIA_DISPLAY_URL_SUCCESS || type === MEDIA_DISPLAY_URL_FAILURE) &&
|
||||
payload.key === key,
|
||||
run: (_dispatch, _getState, action) => resolve(action.payload.url),
|
||||
}));
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { WAIT_UNTIL_ACTION } from '../redux/middleware/waitUntilAction';
|
||||
|
||||
export function waitUntil({ predicate, run }) {
|
||||
return {
|
||||
type: WAIT_UNTIL_ACTION,
|
||||
predicate,
|
||||
run,
|
||||
};
|
||||
}
|
38
packages/netlify-cms-core/src/actions/waitUntil.ts
Normal file
38
packages/netlify-cms-core/src/actions/waitUntil.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { WAIT_UNTIL_ACTION, WaitActionArgs } from '../redux/middleware/waitUntilAction';
|
||||
import { ThunkDispatch } from 'redux-thunk';
|
||||
import { AnyAction } from 'redux';
|
||||
import { State } from '../types/redux';
|
||||
|
||||
export const waitUntil = ({ predicate, run }: WaitActionArgs) => {
|
||||
return {
|
||||
type: WAIT_UNTIL_ACTION,
|
||||
predicate,
|
||||
run,
|
||||
};
|
||||
};
|
||||
|
||||
export const waitUntilWithTimeout = async <T>(
|
||||
dispatch: ThunkDispatch<State, {}, AnyAction>,
|
||||
waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs,
|
||||
timeout = 30000,
|
||||
): Promise<T | null> => {
|
||||
let waitDone = false;
|
||||
|
||||
const waitPromise = new Promise<T>(resolve => {
|
||||
dispatch(waitUntil(waitActionArgs(resolve)));
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<T>((resolve, reject) => {
|
||||
setTimeout(() => (waitDone ? resolve() : reject(new Error('Wait Action timed out'))), timeout);
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
waitPromise.then(result => {
|
||||
waitDone = true;
|
||||
return result;
|
||||
}),
|
||||
timeoutPromise,
|
||||
]).catch(null);
|
||||
|
||||
return result;
|
||||
};
|
@ -1,8 +1,10 @@
|
||||
import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight, uniq } from 'lodash';
|
||||
import { List } from 'immutable';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import fuzzy from 'fuzzy';
|
||||
import { resolveFormat } from 'Formats/formats';
|
||||
import { selectIntegration } from 'Reducers/integrations';
|
||||
import * as fuzzy from 'fuzzy';
|
||||
import { resolveFormat } from './formats/formats';
|
||||
import { selectMediaFilePath, selectMediaFolder } from './reducers/entries';
|
||||
import { selectIntegration } from './reducers/integrations';
|
||||
import {
|
||||
selectListMethod,
|
||||
selectEntrySlug,
|
||||
@ -12,25 +14,41 @@ import {
|
||||
selectAllowDeletion,
|
||||
selectFolderEntryExtension,
|
||||
selectInferedField,
|
||||
} from 'Reducers/collections';
|
||||
import { createEntry } from 'ValueObjects/Entry';
|
||||
import { sanitizeSlug, sanitizeChar } from 'Lib/urlHelper';
|
||||
import { getBackend } from 'Lib/registry';
|
||||
import { commitMessageFormatter, slugFormatter, prepareSlug } from 'Lib/backendHelper';
|
||||
} from './reducers/collections';
|
||||
import { createEntry, EntryValue } from './valueObjects/Entry';
|
||||
import { sanitizeSlug, sanitizeChar } from './lib/urlHelper';
|
||||
import { getBackend } from './lib/registry';
|
||||
import { commitMessageFormatter, slugFormatter, prepareSlug } from './lib/backendHelper';
|
||||
import {
|
||||
localForage,
|
||||
Cursor,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
EditorialWorkflowError,
|
||||
} from 'netlify-cms-lib-util';
|
||||
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
|
||||
import { EDITORIAL_WORKFLOW, status } from './constants/publishModes';
|
||||
import {
|
||||
SLUG_MISSING_REQUIRED_DATE,
|
||||
compileStringTemplate,
|
||||
extractTemplateVars,
|
||||
parseDateFromEntry,
|
||||
dateParsers,
|
||||
} from 'Lib/stringTemplate';
|
||||
} from './lib/stringTemplate';
|
||||
import {
|
||||
Collection,
|
||||
EntryMap,
|
||||
Config,
|
||||
SlugConfig,
|
||||
DisplayURL,
|
||||
FilterRule,
|
||||
Collections,
|
||||
MediaFile,
|
||||
Integrations,
|
||||
EntryDraft,
|
||||
CollectionFile,
|
||||
State,
|
||||
} from './types/redux';
|
||||
import AssetProxy from './valueObjects/AssetProxy';
|
||||
import { selectEditingWorkflowDraft } from './reducers/editorialWorkflow';
|
||||
|
||||
export class LocalStorageAuthStore {
|
||||
storageKey = 'netlify-cms-user';
|
||||
@ -40,7 +58,7 @@ export class LocalStorageAuthStore {
|
||||
return data && JSON.parse(data);
|
||||
}
|
||||
|
||||
store(userData) {
|
||||
store(userData: unknown) {
|
||||
window.localStorage.setItem(this.storageKey, JSON.stringify(userData));
|
||||
}
|
||||
|
||||
@ -49,7 +67,7 @@ export class LocalStorageAuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
function getEntryBackupKey(collectionName, slug) {
|
||||
function getEntryBackupKey(collectionName?: string, slug?: string) {
|
||||
const baseKey = 'backup';
|
||||
if (!collectionName) {
|
||||
return baseKey;
|
||||
@ -58,9 +76,9 @@ function getEntryBackupKey(collectionName, slug) {
|
||||
return `${baseKey}.${collectionName}${suffix}`;
|
||||
}
|
||||
|
||||
const extractSearchFields = searchFields => entry =>
|
||||
const extractSearchFields = (searchFields: string[]) => (entry: EntryValue) =>
|
||||
searchFields.reduce((acc, field) => {
|
||||
let nestedFields = field.split('.');
|
||||
const nestedFields = field.split('.');
|
||||
let f = entry.data;
|
||||
for (let i = 0; i < nestedFields.length; i++) {
|
||||
f = f[nestedFields[i]];
|
||||
@ -69,13 +87,19 @@ const extractSearchFields = searchFields => entry =>
|
||||
return f ? `${acc} ${f}` : acc;
|
||||
}, '');
|
||||
|
||||
const sortByScore = (a, b) => {
|
||||
const sortByScore = (a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<EntryValue>) => {
|
||||
if (a.score > b.score) return -1;
|
||||
if (a.score < b.score) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
||||
function createPreviewUrl(
|
||||
baseUrl: string,
|
||||
collection: Collection,
|
||||
slug: string,
|
||||
slugConfig: SlugConfig,
|
||||
entry: EntryMap,
|
||||
) {
|
||||
/**
|
||||
* Preview URL can't be created without `baseUrl`. This makes preview URLs
|
||||
* optional for backends that don't support them.
|
||||
@ -97,7 +121,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
||||
* URL path.
|
||||
*/
|
||||
const basePath = trimEnd(baseUrl, '/');
|
||||
const pathTemplate = collection.get('preview_path');
|
||||
const pathTemplate = collection.get('preview_path') as string;
|
||||
const fields = entry.get('data');
|
||||
const date = parseDateFromEntry(entry, collection, collection.get('preview_path_date_field'));
|
||||
|
||||
@ -130,17 +154,145 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
||||
return `${basePath}/${previewPath}`;
|
||||
}
|
||||
|
||||
interface ImplementationInitOptions {
|
||||
useWorkflow: boolean;
|
||||
updateUserCredentials: (credentials: Credentials) => void;
|
||||
initialWorkflowStatus: string;
|
||||
}
|
||||
|
||||
interface ImplementationEntry {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
file: { path: string; label: string };
|
||||
metaData: { collection: string };
|
||||
isModification?: boolean;
|
||||
slug: string;
|
||||
mediaFiles: MediaFile[];
|
||||
}
|
||||
|
||||
interface Implementation {
|
||||
authComponent: () => void;
|
||||
restoreUser: (user: User) => Promise<User>;
|
||||
init: (config: Config, options: ImplementationInitOptions) => Implementation;
|
||||
authenticate: (credentials: Credentials) => Promise<User>;
|
||||
logout: () => Promise<void>;
|
||||
getToken: () => Promise<string>;
|
||||
unpublishedEntry?: (collection: Collection, slug: string) => Promise<ImplementationEntry>;
|
||||
getEntry: (collection: Collection, slug: string, path: string) => Promise<ImplementationEntry>;
|
||||
allEntriesByFolder?: (
|
||||
collection: Collection,
|
||||
extension: string,
|
||||
) => Promise<ImplementationEntry[]>;
|
||||
traverseCursor: (
|
||||
cursor: typeof Cursor,
|
||||
action: unknown,
|
||||
) => Promise<{ entries: ImplementationEntry[]; cursor: typeof Cursor }>;
|
||||
entriesByFolder: (collection: Collection, extension: string) => Promise<ImplementationEntry[]>;
|
||||
entriesByFiles: (collection: Collection, extension: string) => Promise<ImplementationEntry[]>;
|
||||
unpublishedEntries: () => Promise<ImplementationEntry[]>;
|
||||
getMediaDisplayURL?: (displayURL: DisplayURL) => Promise<string>;
|
||||
getMedia: (folder?: string) => Promise<MediaFile[]>;
|
||||
getMediaFile: (path: string) => Promise<MediaFile>;
|
||||
getDeployPreview: (
|
||||
collection: Collection,
|
||||
slug: string,
|
||||
) => Promise<{ url: string; status: string }>;
|
||||
|
||||
persistEntry: (
|
||||
obj: { path: string; slug: string; raw: string },
|
||||
assetProxies: AssetProxy[],
|
||||
opts: {
|
||||
newEntry: boolean;
|
||||
parsedData: { title: string; description: string };
|
||||
commitMessage: string;
|
||||
collectionName: string;
|
||||
useWorkflow: boolean;
|
||||
unpublished: boolean;
|
||||
hasAssetStore: boolean;
|
||||
status?: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
persistMedia: (file: AssetProxy, opts: { commitMessage: string }) => Promise<MediaFile>;
|
||||
deleteFile: (
|
||||
path: string,
|
||||
commitMessage: string,
|
||||
opts?: { collection: Collection; slug: string },
|
||||
) => Promise<void>;
|
||||
updateUnpublishedEntryStatus: (
|
||||
collection: string,
|
||||
slug: string,
|
||||
newStatus: string,
|
||||
) => Promise<void>;
|
||||
publishUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
|
||||
deleteUnpublishedEntry: (collection: string, slug: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type Credentials = {};
|
||||
|
||||
interface User {
|
||||
backendName: string;
|
||||
login: string;
|
||||
name: string;
|
||||
useOpenAuthoring: boolean;
|
||||
}
|
||||
|
||||
interface AuthStore {
|
||||
retrieve: () => User;
|
||||
store: (user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
interface BackendOptions {
|
||||
backendName?: string;
|
||||
authStore?: AuthStore | null;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
interface BackupMediaFile extends MediaFile {
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export interface ImplementationMediaFile extends MediaFile {
|
||||
file?: File;
|
||||
}
|
||||
|
||||
interface BackupEntry {
|
||||
raw: string;
|
||||
path: string;
|
||||
mediaFiles: BackupMediaFile[];
|
||||
}
|
||||
|
||||
interface PersistArgs {
|
||||
config: Config;
|
||||
collection: Collection;
|
||||
entryDraft: EntryDraft;
|
||||
assetProxies: AssetProxy[];
|
||||
integrations: Integrations;
|
||||
usedSlugs: List<string>;
|
||||
unpublished?: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class Backend {
|
||||
constructor(implementation, { backendName, authStore = null, config } = {}) {
|
||||
implementation: Implementation;
|
||||
backendName: string;
|
||||
authStore: AuthStore | null;
|
||||
config: Config;
|
||||
user?: User | null;
|
||||
|
||||
constructor(
|
||||
implementation: Implementation,
|
||||
{ backendName, authStore = null, config }: BackendOptions = {},
|
||||
) {
|
||||
// We can't reliably run this on exit, so we do cleanup on load.
|
||||
this.deleteAnonymousBackup();
|
||||
this.config = config;
|
||||
this.implementation = implementation.init(config, {
|
||||
useWorkflow: config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW,
|
||||
this.config = config as Config;
|
||||
this.implementation = implementation.init(this.config, {
|
||||
useWorkflow: this.config.get('publish_mode') === EDITORIAL_WORKFLOW,
|
||||
updateUserCredentials: this.updateUserCredentials,
|
||||
initialWorkflowStatus: status.first(),
|
||||
});
|
||||
this.backendName = backendName;
|
||||
this.backendName = backendName as string;
|
||||
this.authStore = authStore;
|
||||
if (this.implementation === null) {
|
||||
throw new Error('Cannot instantiate a Backend with no implementation');
|
||||
@ -151,23 +303,23 @@ export class Backend {
|
||||
if (this.user) {
|
||||
return this.user;
|
||||
}
|
||||
const stored = this.authStore && this.authStore.retrieve();
|
||||
const stored = this.authStore?.retrieve();
|
||||
if (stored && stored.backendName === this.backendName) {
|
||||
return Promise.resolve(this.implementation.restoreUser(stored)).then(user => {
|
||||
this.user = { ...user, backendName: this.backendName };
|
||||
// return confirmed/rehydrated user object instead of stored
|
||||
this.authStore.store(this.user);
|
||||
this.authStore?.store(this.user);
|
||||
return this.user;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
updateUserCredentials = updatedCredentials => {
|
||||
const storedUser = this.authStore && this.authStore.retrieve();
|
||||
updateUserCredentials = (updatedCredentials: Credentials) => {
|
||||
const storedUser = this.authStore?.retrieve();
|
||||
if (storedUser && storedUser.backendName === this.backendName) {
|
||||
this.user = { ...storedUser, ...updatedCredentials };
|
||||
this.authStore.store(this.user);
|
||||
this.authStore?.store(this.user as User);
|
||||
return this.user;
|
||||
}
|
||||
};
|
||||
@ -176,11 +328,11 @@ export class Backend {
|
||||
return this.implementation.authComponent();
|
||||
}
|
||||
|
||||
authenticate(credentials) {
|
||||
authenticate(credentials: Credentials) {
|
||||
return this.implementation.authenticate(credentials).then(user => {
|
||||
this.user = { ...user, backendName: this.backendName };
|
||||
if (this.authStore) {
|
||||
this.authStore.store(this.user);
|
||||
this.authStore.store(this.user as User);
|
||||
}
|
||||
return this.user;
|
||||
});
|
||||
@ -197,7 +349,7 @@ export class Backend {
|
||||
|
||||
getToken = () => this.implementation.getToken();
|
||||
|
||||
async entryExist(collection, path, slug) {
|
||||
async entryExist(collection: Collection, path: string, slug: string) {
|
||||
const unpublishedEntry =
|
||||
this.implementation.unpublishedEntry &&
|
||||
(await this.implementation.unpublishedEntry(collection, slug).catch(error => {
|
||||
@ -219,22 +371,31 @@ export class Backend {
|
||||
return publishedEntry;
|
||||
}
|
||||
|
||||
async generateUniqueSlug(collection, entryData, slugConfig, usedSlugs) {
|
||||
const slug = slugFormatter(collection, entryData, slugConfig);
|
||||
async generateUniqueSlug(
|
||||
collection: Collection,
|
||||
entryData: EntryMap,
|
||||
slugConfig: SlugConfig,
|
||||
usedSlugs: List<string>,
|
||||
) {
|
||||
const slug: string = slugFormatter(collection, entryData, slugConfig);
|
||||
let i = 1;
|
||||
let uniqueSlug = slug;
|
||||
|
||||
// Check for duplicate slug in loaded entities store first before repo
|
||||
while (
|
||||
usedSlugs.includes(uniqueSlug) ||
|
||||
(await this.entryExist(collection, selectEntryPath(collection, uniqueSlug), uniqueSlug))
|
||||
(await this.entryExist(
|
||||
collection,
|
||||
selectEntryPath(collection, uniqueSlug) as string,
|
||||
uniqueSlug,
|
||||
))
|
||||
) {
|
||||
uniqueSlug = `${slug}${sanitizeChar(' ', slugConfig)}${i++}`;
|
||||
}
|
||||
return uniqueSlug;
|
||||
}
|
||||
|
||||
processEntries(loadedEntries, collection) {
|
||||
processEntries(loadedEntries: ImplementationEntry[], collection: Collection) {
|
||||
const collectionFilter = collection.get('filter');
|
||||
const entries = loadedEntries.map(loadedEntry =>
|
||||
createEntry(
|
||||
@ -252,21 +413,25 @@ export class Backend {
|
||||
return filteredEntries;
|
||||
}
|
||||
|
||||
listEntries(collection) {
|
||||
listEntries(collection: Collection) {
|
||||
const listMethod = this.implementation[selectListMethod(collection)];
|
||||
const extension = selectFolderEntryExtension(collection);
|
||||
return listMethod.call(this.implementation, collection, extension).then(loadedEntries => ({
|
||||
entries: this.processEntries(loadedEntries, collection),
|
||||
/*
|
||||
return listMethod
|
||||
.call(this.implementation, collection, extension)
|
||||
.then((loadedEntries: ImplementationEntry[]) => ({
|
||||
entries: this.processEntries(loadedEntries, collection),
|
||||
/*
|
||||
Wrap cursors so we can tell which collection the cursor is
|
||||
from. This is done to prevent traverseCursor from requiring a
|
||||
`collection` argument.
|
||||
*/
|
||||
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
|
||||
cursorType: 'collectionEntries',
|
||||
collection,
|
||||
}),
|
||||
}));
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore
|
||||
cursor: Cursor.create(loadedEntries[CURSOR_COMPATIBILITY_SYMBOL]).wrapData({
|
||||
cursorType: 'collectionEntries',
|
||||
collection,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// The same as listEntries, except that if a cursor with the "next"
|
||||
@ -274,7 +439,7 @@ export class Backend {
|
||||
// repeats the process. Once there is no available "next" action, it
|
||||
// returns all the collected entries. Used to retrieve all entries
|
||||
// for local searches and queries.
|
||||
async listAllEntries(collection) {
|
||||
async listAllEntries(collection: Collection) {
|
||||
if (collection.get('folder') && this.implementation.allEntriesByFolder) {
|
||||
const extension = selectFolderEntryExtension(collection);
|
||||
return this.implementation
|
||||
@ -293,14 +458,14 @@ export class Backend {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async search(collections, searchTerm) {
|
||||
async search(collections: Collection[], searchTerm: string) {
|
||||
// Perform a local search by requesting all entries. For each
|
||||
// collection, load it, search, and call onCollectionResults with
|
||||
// its results.
|
||||
const errors = [];
|
||||
const errors: Error[] = [];
|
||||
const collectionEntriesRequests = collections
|
||||
.map(async collection => {
|
||||
const summary = collection.get('summary', '');
|
||||
const summary = collection.get('summary', '') as string;
|
||||
const summaryFields = extractTemplateVars(summary);
|
||||
|
||||
// TODO: pass search fields in as an argument
|
||||
@ -314,27 +479,35 @@ export class Backend {
|
||||
}
|
||||
return elem;
|
||||
}),
|
||||
].filter(Boolean);
|
||||
].filter(Boolean) as string[];
|
||||
const collectionEntries = await this.listAllEntries(collection);
|
||||
return fuzzy.filter(searchTerm, collectionEntries, {
|
||||
extract: extractSearchFields(uniq(searchFields)),
|
||||
});
|
||||
})
|
||||
.map(p => p.catch(err => errors.push(err) && []));
|
||||
.map(p =>
|
||||
p.catch(err => {
|
||||
errors.push(err);
|
||||
return [] as fuzzy.FilterResult<EntryValue>[];
|
||||
}),
|
||||
);
|
||||
|
||||
const entries = await Promise.all(collectionEntriesRequests).then(arrs => flatten(arrs));
|
||||
const entries = await Promise.all(collectionEntriesRequests).then(arrays => flatten(arrays));
|
||||
|
||||
if (errors.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore
|
||||
throw new Error({ message: 'Errors ocurred while searching entries locally!', errors });
|
||||
}
|
||||
|
||||
const hits = entries
|
||||
.filter(({ score }) => score > 5)
|
||||
.filter(({ score }: fuzzy.FilterResult<EntryValue>) => score > 5)
|
||||
.sort(sortByScore)
|
||||
.map(f => f.original);
|
||||
.map((f: fuzzy.FilterResult<EntryValue>) => f.original);
|
||||
return { entries: hits };
|
||||
}
|
||||
|
||||
async query(collection, searchFields, searchTerm) {
|
||||
async query(collection: Collection, searchFields: string[], searchTerm: string) {
|
||||
const entries = await this.listAllEntries(collection);
|
||||
const hits = fuzzy
|
||||
.filter(searchTerm, entries, { extract: extractSearchFields(searchFields) })
|
||||
@ -343,10 +516,10 @@ export class Backend {
|
||||
return { query: searchTerm, hits };
|
||||
}
|
||||
|
||||
traverseCursor(cursor, action) {
|
||||
traverseCursor(cursor: typeof Cursor, action: string) {
|
||||
const [data, unwrappedCursor] = cursor.unwrapData();
|
||||
// TODO: stop assuming all cursors are for collections
|
||||
const collection = data.get('collection');
|
||||
const collection: Collection = data.get('collection');
|
||||
return this.implementation
|
||||
.traverseCursor(unwrappedCursor, action)
|
||||
.then(async ({ entries, cursor: newCursor }) => ({
|
||||
@ -358,40 +531,61 @@ export class Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
async getLocalDraftBackup(collection, slug) {
|
||||
async getLocalDraftBackup(collection: Collection, slug: string) {
|
||||
const key = getEntryBackupKey(collection.get('name'), slug);
|
||||
const backup = await localForage.getItem(key);
|
||||
const backup = await localForage.getItem<BackupEntry>(key);
|
||||
if (!backup || !backup.raw.trim()) {
|
||||
return {};
|
||||
}
|
||||
const { raw, path, mediaFiles = [], assets = [] } = backup;
|
||||
const { raw, path } = backup;
|
||||
let { mediaFiles = [] } = backup;
|
||||
|
||||
mediaFiles = mediaFiles.map(file => {
|
||||
// de-serialize the file object
|
||||
if (file.file) {
|
||||
return { ...file, url: URL.createObjectURL(file.file) };
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
const label = selectFileEntryLabel(collection, slug);
|
||||
const entry = this.entryWithFormat(
|
||||
collection,
|
||||
slug,
|
||||
)(createEntry(collection.get('name'), slug, path, { raw, label }));
|
||||
const entry: EntryValue = this.entryWithFormat(collection)(
|
||||
createEntry(collection.get('name'), slug, path, { raw, label, mediaFiles }),
|
||||
);
|
||||
|
||||
return { entry, mediaFiles, assets };
|
||||
return { entry };
|
||||
}
|
||||
|
||||
async persistLocalDraftBackup(entry, collection, mediaFiles, assets) {
|
||||
async persistLocalDraftBackup(entry: EntryMap, collection: Collection) {
|
||||
const key = getEntryBackupKey(collection.get('name'), entry.get('slug'));
|
||||
const raw = this.entryToRaw(collection, entry);
|
||||
if (!raw.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await localForage.setItem(key, {
|
||||
const mediaFiles = await Promise.all<BackupMediaFile>(
|
||||
entry
|
||||
.get('mediaFiles')
|
||||
.toJS()
|
||||
.map(async (file: MediaFile) => {
|
||||
// make sure to serialize the file
|
||||
if (file.url?.startsWith('blob:')) {
|
||||
const blob = await fetch(file.url).then(res => res.blob());
|
||||
return { ...file, file: new File([blob], file.name) };
|
||||
}
|
||||
return file;
|
||||
}),
|
||||
);
|
||||
|
||||
await localForage.setItem<BackupEntry>(key, {
|
||||
raw,
|
||||
path: entry.get('path'),
|
||||
mediaFiles: mediaFiles.toJS(),
|
||||
assets: assets.toJS(),
|
||||
mediaFiles,
|
||||
});
|
||||
return localForage.setItem(getEntryBackupKey(), raw);
|
||||
}
|
||||
|
||||
async deleteLocalDraftBackup(collection, slug) {
|
||||
async deleteLocalDraftBackup(collection: Collection, slug: string) {
|
||||
const key = getEntryBackupKey(collection.get('name'), slug);
|
||||
await localForage.removeItem(key);
|
||||
return this.deleteAnonymousBackup();
|
||||
@ -403,39 +597,50 @@ export class Backend {
|
||||
return localForage.removeItem(getEntryBackupKey());
|
||||
}
|
||||
|
||||
getEntry(collection, slug) {
|
||||
const path = selectEntryPath(collection, slug);
|
||||
async getEntry(state: State, collection: Collection, slug: string) {
|
||||
const path = selectEntryPath(collection, slug) as string;
|
||||
const label = selectFileEntryLabel(collection, slug);
|
||||
return this.implementation.getEntry(collection, slug, path).then(loadedEntry =>
|
||||
this.entryWithFormat(
|
||||
collection,
|
||||
slug,
|
||||
)(
|
||||
createEntry(collection.get('name'), slug, loadedEntry.file.path, {
|
||||
raw: loadedEntry.data,
|
||||
label,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const workflowDraft = selectEditingWorkflowDraft(state);
|
||||
const integration = selectIntegration(state.integrations, null, 'assetStore');
|
||||
|
||||
const [loadedEntry, mediaFiles] = await Promise.all([
|
||||
this.implementation.getEntry(collection, slug, path),
|
||||
workflowDraft && !integration
|
||||
? this.implementation.getMedia(selectMediaFolder(state.config, collection, path))
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, {
|
||||
raw: loadedEntry.data,
|
||||
label,
|
||||
mediaFiles,
|
||||
});
|
||||
|
||||
return this.entryWithFormat(collection)(entry);
|
||||
}
|
||||
|
||||
getMedia() {
|
||||
return this.implementation.getMedia();
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL) {
|
||||
getMediaFile(path: string) {
|
||||
return this.implementation.getMediaFile(path);
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
if (this.implementation.getMediaDisplayURL) {
|
||||
return this.implementation.getMediaDisplayURL(displayURL);
|
||||
}
|
||||
const err = new Error(
|
||||
'getMediaDisplayURL is not implemented by the current backend, but the backend returned a displayURL which was not a string!',
|
||||
);
|
||||
) as Error & { displayURL: DisplayURL };
|
||||
err.displayURL = displayURL;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
entryWithFormat(collectionOrEntity) {
|
||||
return entry => {
|
||||
entryWithFormat(collectionOrEntity: unknown) {
|
||||
return (entry: EntryValue): EntryValue => {
|
||||
const format = resolveFormat(collectionOrEntity, entry);
|
||||
if (entry && entry.raw !== undefined) {
|
||||
const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {};
|
||||
@ -446,7 +651,7 @@ export class Backend {
|
||||
};
|
||||
}
|
||||
|
||||
unpublishedEntries(collections) {
|
||||
unpublishedEntries(collections: Collections) {
|
||||
return this.implementation
|
||||
.unpublishedEntries()
|
||||
.then(loadedEntries => loadedEntries.filter(entry => entry !== null))
|
||||
@ -468,26 +673,26 @@ export class Backend {
|
||||
entries: entries.reduce((acc, entry) => {
|
||||
const collection = collections.get(entry.collection);
|
||||
if (collection) {
|
||||
acc.push(this.entryWithFormat(collection)(entry));
|
||||
acc.push(this.entryWithFormat(collection)(entry) as EntryValue);
|
||||
}
|
||||
return acc;
|
||||
}, []),
|
||||
}, [] as EntryValue[]),
|
||||
}));
|
||||
}
|
||||
|
||||
unpublishedEntry(collection, slug) {
|
||||
unpublishedEntry(collection: Collection, slug: string) {
|
||||
return this.implementation
|
||||
.unpublishedEntry(collection, slug)
|
||||
.unpublishedEntry?.(collection, slug)
|
||||
.then(loadedEntry => {
|
||||
const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, {
|
||||
const entry = createEntry(collection.get('name'), loadedEntry.slug, loadedEntry.file.path, {
|
||||
raw: loadedEntry.data,
|
||||
isModification: loadedEntry.isModification,
|
||||
metaData: loadedEntry.metaData,
|
||||
mediaFiles: loadedEntry.mediaFiles,
|
||||
});
|
||||
entry.metaData = loadedEntry.metaData;
|
||||
entry.mediaFiles = loadedEntry.mediaFiles;
|
||||
return entry;
|
||||
})
|
||||
.then(this.entryWithFormat(collection, slug));
|
||||
.then(this.entryWithFormat(collection));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -495,7 +700,7 @@ export class Backend {
|
||||
* entry's collection. Does not currently make a request through the backend,
|
||||
* but likely will in the future.
|
||||
*/
|
||||
getDeploy(collection, slug, entry) {
|
||||
getDeploy(collection: Collection, slug: string, entry: EntryMap) {
|
||||
/**
|
||||
* If `site_url` is undefined or `show_preview_links` in the config is set to false, do nothing.
|
||||
*/
|
||||
@ -517,7 +722,12 @@ export class Backend {
|
||||
* Supports polling via `maxAttempts` and `interval` options, as there is
|
||||
* often a delay before a preview URL is available.
|
||||
*/
|
||||
async getDeployPreview(collection, slug, entry, { maxAttempts = 1, interval = 5000 } = {}) {
|
||||
async getDeployPreview(
|
||||
collection: Collection,
|
||||
slug: string,
|
||||
entry: EntryMap,
|
||||
{ maxAttempts = 1, interval = 5000 } = {},
|
||||
) {
|
||||
/**
|
||||
* If the registered backend does not provide a `getDeployPreview` method, or
|
||||
* `show_preview_links` in the config is set to false, do nothing.
|
||||
@ -559,15 +769,16 @@ export class Backend {
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(
|
||||
async persistEntry({
|
||||
config,
|
||||
collection,
|
||||
entryDraft,
|
||||
MediaFiles,
|
||||
assetProxies,
|
||||
integrations,
|
||||
usedSlugs,
|
||||
options = {},
|
||||
) {
|
||||
unpublished = false,
|
||||
status,
|
||||
}: PersistArgs) {
|
||||
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
|
||||
|
||||
const parsedData = {
|
||||
@ -575,7 +786,12 @@ export class Backend {
|
||||
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!'),
|
||||
};
|
||||
|
||||
let entryObj;
|
||||
let entryObj: {
|
||||
path: string;
|
||||
slug: string;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
if (newEntry) {
|
||||
if (!selectAllowNewEntries(collection)) {
|
||||
throw new Error('Not allowed to create new entries in this collection');
|
||||
@ -586,12 +802,20 @@ export class Backend {
|
||||
config.get('slug'),
|
||||
usedSlugs,
|
||||
);
|
||||
const path = selectEntryPath(collection, slug);
|
||||
const path = selectEntryPath(collection, slug) as string;
|
||||
|
||||
entryObj = {
|
||||
path,
|
||||
slug,
|
||||
raw: this.entryToRaw(collection, entryDraft.get('entry')),
|
||||
};
|
||||
|
||||
assetProxies.map(asset => {
|
||||
// update media files path based on entry path
|
||||
const oldPath = asset.path;
|
||||
const newPath = selectMediaFilePath(config, collection, path, oldPath);
|
||||
asset.path = newPath;
|
||||
});
|
||||
} else {
|
||||
const path = entryDraft.getIn(['entry', 'path']);
|
||||
const slug = entryDraft.getIn(['entry', 'slug']);
|
||||
@ -602,7 +826,7 @@ export class Backend {
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.currentUser();
|
||||
const user = (await this.currentUser()) as User;
|
||||
const commitMessage = commitMessageFormatter(
|
||||
newEntry ? 'create' : 'update',
|
||||
config,
|
||||
@ -616,7 +840,7 @@ export class Backend {
|
||||
user.useOpenAuthoring,
|
||||
);
|
||||
|
||||
const useWorkflow = config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW;
|
||||
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
|
||||
const collectionName = collection.get('name');
|
||||
|
||||
@ -624,7 +848,7 @@ export class Backend {
|
||||
* Determine whether an asset store integration is in use.
|
||||
*/
|
||||
const hasAssetStore = integrations && !!selectIntegration(integrations, null, 'assetStore');
|
||||
const updatedOptions = { ...options, hasAssetStore };
|
||||
const updatedOptions = { unpublished, hasAssetStore, status };
|
||||
const opts = {
|
||||
newEntry,
|
||||
parsedData,
|
||||
@ -634,35 +858,36 @@ export class Backend {
|
||||
...updatedOptions,
|
||||
};
|
||||
|
||||
return this.implementation.persistEntry(entryObj, MediaFiles, opts).then(() => entryObj.slug);
|
||||
return this.implementation.persistEntry(entryObj, assetProxies, opts).then(() => entryObj.slug);
|
||||
}
|
||||
|
||||
async persistMedia(config, file, draft) {
|
||||
const user = await this.currentUser();
|
||||
async persistMedia(config: Config, file: AssetProxy) {
|
||||
const user = (await this.currentUser()) as User;
|
||||
const options = {
|
||||
commitMessage: commitMessageFormatter(
|
||||
'uploadMedia',
|
||||
config,
|
||||
{
|
||||
slug: '',
|
||||
collection: '',
|
||||
path: file.path,
|
||||
authorLogin: user.login,
|
||||
authorName: user.name,
|
||||
},
|
||||
user.useOpenAuthoring,
|
||||
),
|
||||
draft,
|
||||
};
|
||||
return this.implementation.persistMedia(file, options);
|
||||
}
|
||||
|
||||
async deleteEntry(config, collection, slug) {
|
||||
const path = selectEntryPath(collection, slug);
|
||||
async deleteEntry(config: Config, collection: Collection, slug: string) {
|
||||
const path = selectEntryPath(collection, slug) as string;
|
||||
|
||||
if (!selectAllowDeletion(collection)) {
|
||||
throw new Error('Not allowed to delete entries in this collection');
|
||||
}
|
||||
|
||||
const user = await this.currentUser();
|
||||
const user = (await this.currentUser()) as User;
|
||||
const commitMessage = commitMessageFormatter(
|
||||
'delete',
|
||||
config,
|
||||
@ -678,12 +903,14 @@ export class Backend {
|
||||
return this.implementation.deleteFile(path, commitMessage, { collection, slug });
|
||||
}
|
||||
|
||||
async deleteMedia(config, path) {
|
||||
const user = await this.currentUser();
|
||||
async deleteMedia(config: Config, path: string) {
|
||||
const user = (await this.currentUser()) as User;
|
||||
const commitMessage = commitMessageFormatter(
|
||||
'deleteMedia',
|
||||
config,
|
||||
{
|
||||
slug: '',
|
||||
collection: '',
|
||||
path,
|
||||
authorLogin: user.login,
|
||||
authorName: user.name,
|
||||
@ -693,49 +920,52 @@ export class Backend {
|
||||
return this.implementation.deleteFile(path, commitMessage);
|
||||
}
|
||||
|
||||
persistUnpublishedEntry(...args) {
|
||||
return this.persistEntry(...args, { unpublished: true });
|
||||
persistUnpublishedEntry(args: PersistArgs) {
|
||||
return this.persistEntry({ ...args, unpublished: true });
|
||||
}
|
||||
|
||||
updateUnpublishedEntryStatus(collection, slug, newStatus) {
|
||||
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
|
||||
return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus);
|
||||
}
|
||||
|
||||
publishUnpublishedEntry(collection, slug) {
|
||||
publishUnpublishedEntry(collection: string, slug: string) {
|
||||
return this.implementation.publishUnpublishedEntry(collection, slug);
|
||||
}
|
||||
|
||||
deleteUnpublishedEntry(collection, slug) {
|
||||
deleteUnpublishedEntry(collection: string, slug: string) {
|
||||
return this.implementation.deleteUnpublishedEntry(collection, slug);
|
||||
}
|
||||
|
||||
entryToRaw(collection, entry) {
|
||||
entryToRaw(collection: Collection, entry: EntryMap): string {
|
||||
const format = resolveFormat(collection, entry.toJS());
|
||||
const fieldsOrder = this.fieldsOrder(collection, entry);
|
||||
return format && format.toFile(entry.get('data').toJS(), fieldsOrder);
|
||||
}
|
||||
|
||||
fieldsOrder(collection, entry) {
|
||||
fieldsOrder(collection: Collection, entry: EntryMap) {
|
||||
const fields = collection.get('fields');
|
||||
if (fields) {
|
||||
return collection
|
||||
.get('fields')
|
||||
.map(f => f.get('name'))
|
||||
.map(f => f?.get('name'))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
const files = collection.get('files');
|
||||
const file = (files || []).filter(f => f.get('name') === entry.get('slug')).get(0);
|
||||
const file = (files || List<CollectionFile>())
|
||||
.filter(f => f?.get('name') === entry.get('slug'))
|
||||
.get(0);
|
||||
|
||||
if (file == null) {
|
||||
throw new Error(`No file found for ${entry.get('slug')} in ${collection.get('name')}`);
|
||||
}
|
||||
return file
|
||||
.get('fields')
|
||||
.map(f => f.get('name'))
|
||||
.map(f => f?.get('name'))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
filterEntries(collection, filterRule) {
|
||||
filterEntries(collection: { entries: EntryValue[] }, filterRule: FilterRule) {
|
||||
return collection.entries.filter(entry => {
|
||||
const fieldValue = entry.data[filterRule.get('field')];
|
||||
if (Array.isArray(fieldValue)) {
|
||||
@ -746,7 +976,7 @@ export class Backend {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBackend(config) {
|
||||
export function resolveBackend(config: Config) {
|
||||
const name = config.getIn(['backend', 'name']);
|
||||
if (name == null) {
|
||||
throw new Error('No backend defined in configuration');
|
||||
@ -762,14 +992,13 @@ export function resolveBackend(config) {
|
||||
}
|
||||
|
||||
export const currentBackend = (function() {
|
||||
let backend = null;
|
||||
let backend: Backend;
|
||||
|
||||
return config => {
|
||||
return (config: Config) => {
|
||||
if (backend) {
|
||||
return backend;
|
||||
}
|
||||
if (config.get('backend')) {
|
||||
return (backend = resolveBackend(config));
|
||||
}
|
||||
|
||||
return (backend = resolveBackend(config));
|
||||
};
|
||||
})();
|
2
packages/netlify-cms-core/src/bootstrap.js
vendored
2
packages/netlify-cms-core/src/bootstrap.js
vendored
@ -7,7 +7,7 @@ import history from 'Routing/history';
|
||||
import store from 'ReduxStore';
|
||||
import { mergeConfig } from 'Actions/config';
|
||||
import { getPhrases } from 'Lib/phrases';
|
||||
import { selectLocale } from 'Selectors/config';
|
||||
import { selectLocale } from 'Reducers/config';
|
||||
import { I18n } from 'react-polyglot';
|
||||
import { GlobalStyles } from 'netlify-cms-ui-default';
|
||||
import { ErrorBoundary } from 'UI';
|
||||
|
@ -8,7 +8,6 @@ import EntryListing from './EntryListing';
|
||||
const Entries = ({
|
||||
collections,
|
||||
entries,
|
||||
publicFolder,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
@ -26,7 +25,6 @@ const Entries = ({
|
||||
<EntryListing
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
publicFolder={publicFolder}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
@ -44,7 +42,6 @@ const Entries = ({
|
||||
Entries.propTypes = {
|
||||
collections: ImmutablePropTypes.map.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
publicFolder: PropTypes.string.isRequired,
|
||||
page: PropTypes.number,
|
||||
isFetching: PropTypes.bool,
|
||||
viewStyle: PropTypes.string,
|
||||
|
@ -15,7 +15,6 @@ import Entries from './Entries';
|
||||
class EntriesCollection extends React.Component {
|
||||
static propTypes = {
|
||||
collection: ImmutablePropTypes.map.isRequired,
|
||||
publicFolder: PropTypes.string.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
viewStyle: PropTypes.string,
|
||||
@ -45,13 +44,12 @@ class EntriesCollection extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collection, entries, publicFolder, isFetching, viewStyle, cursor } = this.props;
|
||||
const { collection, entries, isFetching, viewStyle, cursor } = this.props;
|
||||
|
||||
return (
|
||||
<Entries
|
||||
collections={collection}
|
||||
entries={entries}
|
||||
publicFolder={publicFolder}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.get('label')}
|
||||
viewStyle={viewStyle}
|
||||
@ -64,8 +62,6 @@ class EntriesCollection extends React.Component {
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const { collection, viewStyle } = ownProps;
|
||||
const { config } = state;
|
||||
const publicFolder = config.get('public_folder');
|
||||
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
|
||||
|
||||
const entries = selectEntries(state, collection.get('name'));
|
||||
@ -75,7 +71,7 @@ function mapStateToProps(state, ownProps) {
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { publicFolder, collection, page, entries, entriesLoaded, isFetching, viewStyle, cursor };
|
||||
return { collection, page, entries, entriesLoaded, isFetching, viewStyle, cursor };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
|
@ -19,7 +19,6 @@ class EntriesSearch extends React.Component {
|
||||
collections: ImmutablePropTypes.seq,
|
||||
entries: ImmutablePropTypes.list,
|
||||
page: PropTypes.number,
|
||||
publicFolder: PropTypes.string,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
@ -53,14 +52,13 @@ class EntriesSearch extends React.Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { collections, entries, publicFolder, isFetching } = this.props;
|
||||
const { collections, entries, isFetching } = this.props;
|
||||
return (
|
||||
<Entries
|
||||
cursor={this.getCursor()}
|
||||
handleCursorActions={this.handleCursorActions}
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
publicFolder={publicFolder}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
@ -73,9 +71,8 @@ function mapStateToProps(state, ownProps) {
|
||||
const isFetching = state.search.get('isFetching');
|
||||
const page = state.search.get('page');
|
||||
const entries = selectSearchedEntries(state);
|
||||
const publicFolder = state.config.get('public_folder');
|
||||
|
||||
return { isFetching, page, collections, entries, publicFolder, searchTerm };
|
||||
return { isFetching, page, collections, entries, searchTerm };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { connect } from 'react-redux';
|
||||
import { getAsset } from 'Actions/media';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { resolvePath } from 'netlify-cms-lib-util';
|
||||
import { colors, colorsRaw, components, lengths } from 'netlify-cms-ui-default';
|
||||
import { colors, colorsRaw, components, lengths, Asset } from 'netlify-cms-ui-default';
|
||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
|
||||
import { compileStringTemplate, parseDateFromEntry } from 'Lib/stringTemplate';
|
||||
import { selectIdentifier } from 'Reducers/collections';
|
||||
@ -76,20 +77,24 @@ const CardBody = styled.div`
|
||||
`;
|
||||
|
||||
const CardImage = styled.div`
|
||||
background-image: url(${props => props.url});
|
||||
background-image: url(${props => props.value?.toString()});
|
||||
background-position: center center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 150px;
|
||||
`;
|
||||
|
||||
const CardImageAsset = ({ getAsset, image }) => {
|
||||
return <Asset path={image} getAsset={getAsset} component={CardImage} />;
|
||||
};
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
entry,
|
||||
inferedFields,
|
||||
publicFolder,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
boundGetAsset,
|
||||
}) => {
|
||||
const label = entry.get('label');
|
||||
const entryData = entry.get('data');
|
||||
@ -103,7 +108,6 @@ const EntryCard = ({
|
||||
: defaultTitle;
|
||||
|
||||
let image = entryData.get(inferedFields.imageField);
|
||||
image = resolvePath(image, publicFolder);
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
@ -127,11 +131,28 @@ const EntryCard = ({
|
||||
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
|
||||
<CardHeading>{title}</CardHeading>
|
||||
</CardBody>
|
||||
{image ? <CardImage url={image} /> : null}
|
||||
{image ? <CardImageAsset getAsset={boundGetAsset} image={image} /> : null}
|
||||
</GridCardLink>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default EntryCard;
|
||||
const mapDispatchToProps = {
|
||||
boundGetAsset: (collection, entryPath) => (dispatch, getState) => path => {
|
||||
return getAsset({ collection, entryPath, path })(dispatch, getState);
|
||||
},
|
||||
};
|
||||
|
||||
const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
boundGetAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry.get('path')),
|
||||
};
|
||||
};
|
||||
|
||||
const ConnectedEntryCard = connect(null, mapDispatchToProps, mergeProps)(EntryCard);
|
||||
|
||||
export default ConnectedEntryCard;
|
||||
|
@ -17,7 +17,6 @@ const CardsGrid = styled.ul`
|
||||
|
||||
export default class EntryListing extends React.Component {
|
||||
static propTypes = {
|
||||
publicFolder: PropTypes.string.isRequired,
|
||||
collections: ImmutablePropTypes.iterable.isRequired,
|
||||
entries: ImmutablePropTypes.list,
|
||||
viewStyle: PropTypes.string,
|
||||
@ -47,20 +46,20 @@ export default class EntryListing extends React.Component {
|
||||
};
|
||||
|
||||
renderCardsForSingleCollection = () => {
|
||||
const { collections, entries, publicFolder, viewStyle } = this.props;
|
||||
const { collections, entries, viewStyle } = this.props;
|
||||
const inferedFields = this.inferFields(collections);
|
||||
const entryCardProps = { collection: collections, inferedFields, publicFolder, viewStyle };
|
||||
const entryCardProps = { collection: collections, inferedFields, viewStyle };
|
||||
return entries.map((entry, idx) => <EntryCard {...entryCardProps} entry={entry} key={idx} />);
|
||||
};
|
||||
|
||||
renderCardsForMultipleCollections = () => {
|
||||
const { collections, entries, publicFolder } = this.props;
|
||||
const { collections, entries } = this.props;
|
||||
return entries.map((entry, idx) => {
|
||||
const collectionName = entry.get('collection');
|
||||
const collection = collections.find(coll => coll.get('name') === collectionName);
|
||||
const collectionLabel = collection.get('label');
|
||||
const inferedFields = this.inferFields(collection);
|
||||
const entryCardProps = { collection, entry, inferedFields, publicFolder, collectionLabel };
|
||||
const entryCardProps = { collection, entry, inferedFields, collectionLabel };
|
||||
return <EntryCard {...entryCardProps} key={idx} />;
|
||||
});
|
||||
};
|
||||
|
@ -31,7 +31,8 @@ import {
|
||||
} from 'Actions/editorialWorkflow';
|
||||
import { loadDeployPreview } from 'Actions/deploys';
|
||||
import { deserializeValues } from 'Lib/serializeEntryValues';
|
||||
import { selectEntry, selectUnpublishedEntry, selectDeployPreview, getAsset } from 'Reducers';
|
||||
import { selectEntry, selectUnpublishedEntry, selectDeployPreview } from 'Reducers';
|
||||
import { getAsset } from 'Actions/media';
|
||||
import { selectFields } from 'Reducers/collections';
|
||||
import { status, EDITORIAL_WORKFLOW } from 'Constants/publishModes';
|
||||
import EditorInterface from './EditorInterface';
|
||||
@ -193,11 +194,7 @@ export class Editor extends React.Component {
|
||||
}
|
||||
|
||||
if (this.props.hasChanged) {
|
||||
this.createBackup(
|
||||
this.props.entryDraft.get('entry'),
|
||||
this.props.collection,
|
||||
this.props.entryDraft.get('mediaFiles'),
|
||||
);
|
||||
this.createBackup(this.props.entryDraft.get('entry'), this.props.collection);
|
||||
}
|
||||
|
||||
if (prevProps.entry === this.props.entry) return;
|
||||
@ -212,8 +209,7 @@ export class Editor extends React.Component {
|
||||
const values = deserializeValues(entry.get('data'), fields);
|
||||
const deserializedEntry = entry.set('data', values);
|
||||
const fieldsMetaData = this.props.entryDraft && this.props.entryDraft.get('fieldsMetaData');
|
||||
const mediaFiles = this.props.entryDraft && this.props.entryDraft.get('mediaFiles');
|
||||
this.createDraft(deserializedEntry, fieldsMetaData, mediaFiles);
|
||||
this.createDraft(deserializedEntry, fieldsMetaData);
|
||||
} else if (newEntry) {
|
||||
prevProps.createEmptyDraft(collection);
|
||||
}
|
||||
@ -225,12 +221,12 @@ export class Editor extends React.Component {
|
||||
window.removeEventListener('beforeunload', this.exitBlocker);
|
||||
}
|
||||
|
||||
createBackup = debounce(function(entry, collection, mediaFiles) {
|
||||
this.props.persistLocalBackup(entry, collection, mediaFiles);
|
||||
createBackup = debounce(function(entry, collection) {
|
||||
this.props.persistLocalBackup(entry, collection);
|
||||
}, 2000);
|
||||
|
||||
createDraft = (entry, metadata, mediaFiles) => {
|
||||
if (entry) this.props.createDraftFromEntry(entry, metadata, mediaFiles);
|
||||
createDraft = (entry, metadata) => {
|
||||
if (entry) this.props.createDraftFromEntry(entry, metadata);
|
||||
};
|
||||
|
||||
handleChangeStatus = newStatusName => {
|
||||
@ -464,7 +460,6 @@ function mapStateToProps(state, ownProps) {
|
||||
const newEntry = ownProps.newRecord === true;
|
||||
const fields = selectFields(collection, slug);
|
||||
const entry = newEntry ? null : selectEntry(state, collectionName, slug);
|
||||
const boundGetAsset = getAsset.bind(null, state);
|
||||
const user = auth && auth.get('user');
|
||||
const hasChanged = entryDraft.get('hasChanged');
|
||||
const displayUrl = config.get('display_url');
|
||||
@ -482,7 +477,6 @@ function mapStateToProps(state, ownProps) {
|
||||
collections,
|
||||
newEntry,
|
||||
entryDraft,
|
||||
boundGetAsset,
|
||||
fields,
|
||||
slug,
|
||||
entry,
|
||||
@ -500,7 +494,7 @@ function mapStateToProps(state, ownProps) {
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField,
|
||||
changeDraftFieldValidation,
|
||||
loadEntry,
|
||||
@ -521,4 +515,25 @@ export default connect(mapStateToProps, {
|
||||
unpublishPublishedEntry,
|
||||
deleteUnpublishedEntry,
|
||||
logoutUser,
|
||||
})(withWorkflow(translate()(Editor)));
|
||||
boundGetAsset: (collection, entryPath) => (dispatch, getState) => path => {
|
||||
return getAsset({ collection, entryPath, path })(dispatch, getState);
|
||||
},
|
||||
};
|
||||
|
||||
const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
boundGetAsset: dispatchProps.boundGetAsset(
|
||||
stateProps.collection,
|
||||
stateProps.entryDraft.getIn(['entry', 'path']),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps,
|
||||
)(withWorkflow(translate()(Editor)));
|
||||
|
@ -9,7 +9,7 @@ import { connect } from 'react-redux';
|
||||
import { FieldLabel, colors, transitions, lengths, borders } from 'netlify-cms-ui-default';
|
||||
import { resolveWidget, getEditorComponents } from 'Lib/registry';
|
||||
import { clearFieldErrors, loadEntry } from 'Actions/entries';
|
||||
import { addAsset } from 'Actions/media';
|
||||
import { addAsset, getAsset } from 'Actions/media';
|
||||
import { query, clearSearch } from 'Actions/search';
|
||||
import {
|
||||
openMediaLibrary,
|
||||
@ -17,7 +17,6 @@ import {
|
||||
clearMediaControl,
|
||||
removeMediaControl,
|
||||
} from 'Actions/mediaLibrary';
|
||||
import { getAsset } from 'Reducers';
|
||||
import Widget from './Widget';
|
||||
|
||||
/**
|
||||
@ -152,6 +151,7 @@ class EditorControl extends React.Component {
|
||||
isNewEditorComponent,
|
||||
t,
|
||||
} = this.props;
|
||||
|
||||
const widgetName = field.get('widget');
|
||||
const widget = resolveWidget(widgetName);
|
||||
const fieldName = field.get('name');
|
||||
@ -260,12 +260,19 @@ class EditorControl extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
mediaPaths: state.mediaLibrary.get('controlMedia'),
|
||||
boundGetAsset: getAsset.bind(null, state),
|
||||
isFetching: state.search.get('isFetching'),
|
||||
queryHits: state.search.get('queryHits'),
|
||||
});
|
||||
const mapStateToProps = state => {
|
||||
const { collections, entryDraft } = state;
|
||||
const entryPath = entryDraft.getIn(['entry', 'path']);
|
||||
const collection = collections.get(entryDraft.getIn(['entry', 'collection']));
|
||||
|
||||
return {
|
||||
mediaPaths: state.mediaLibrary.get('controlMedia'),
|
||||
isFetching: state.search.get('isFetching'),
|
||||
queryHits: state.search.get('queryHits'),
|
||||
collection,
|
||||
entryPath,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
openMediaLibrary,
|
||||
@ -280,10 +287,24 @@ const mapDispatchToProps = {
|
||||
},
|
||||
clearSearch,
|
||||
clearFieldErrors,
|
||||
boundGetAsset: (collection, entryPath) => (dispatch, getState) => path => {
|
||||
return getAsset({ collection, entryPath, path })(dispatch, getState);
|
||||
},
|
||||
};
|
||||
|
||||
const ConnectedEditorControl = connect(mapStateToProps, mapDispatchToProps, null, {
|
||||
withRef: false,
|
||||
})(translate()(EditorControl));
|
||||
const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
||||
return {
|
||||
...stateProps,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entryPath),
|
||||
};
|
||||
};
|
||||
|
||||
const ConnectedEditorControl = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps,
|
||||
)(translate()(EditorControl));
|
||||
|
||||
export default ConnectedEditorControl;
|
||||
|
@ -10,7 +10,7 @@ const truthy = () => ({ error: false });
|
||||
const isEmpty = value =>
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(value.hasOwnProperty('length') && value.length === 0) ||
|
||||
(Object.prototype.hasOwnProperty.call(value, 'length') && value.length === 0) ||
|
||||
(value.constructor === Object && Object.keys(value).length === 0) ||
|
||||
(List.isList(value) && value.size === 0);
|
||||
|
||||
@ -168,7 +168,7 @@ export default class Widget extends Component {
|
||||
if (typeof response === 'boolean') {
|
||||
const isValid = response;
|
||||
return { error: !isValid };
|
||||
} else if (response.hasOwnProperty('error')) {
|
||||
} else if (Object.prototype.hasOwnProperty.call(response, 'error')) {
|
||||
return response;
|
||||
} else if (response instanceof Promise) {
|
||||
response.then(
|
||||
|
@ -40,6 +40,7 @@ export default class PreviewPane extends React.Component {
|
||||
value={valueIsInMap ? value.get(field.get('name')) : value}
|
||||
entry={entry}
|
||||
fieldsMetaData={metadata}
|
||||
resolveWidget={resolveWidget}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -193,7 +193,7 @@ describe('Editor', () => {
|
||||
const { rerender } = render(
|
||||
<Editor
|
||||
{...props}
|
||||
entryDraft={fromJS({ entry: { slug: 'slug' } })}
|
||||
entryDraft={fromJS({ entry: {} })}
|
||||
entry={fromJS({ isFetching: false })}
|
||||
/>,
|
||||
);
|
||||
@ -202,7 +202,7 @@ describe('Editor', () => {
|
||||
rerender(
|
||||
<Editor
|
||||
{...props}
|
||||
entryDraft={fromJS({ entry: { slug: 'slug' }, mediaFiles: [{ id: '1' }] })}
|
||||
entryDraft={fromJS({ entry: { mediaFiles: [{ id: '1' }] } })}
|
||||
entry={fromJS({ isFetching: false })}
|
||||
hasChanged={true}
|
||||
/>,
|
||||
@ -210,9 +210,8 @@ describe('Editor', () => {
|
||||
|
||||
expect(props.persistLocalBackup).toHaveBeenCalledTimes(1);
|
||||
expect(props.persistLocalBackup).toHaveBeenCalledWith(
|
||||
fromJS({ slug: 'slug' }),
|
||||
fromJS({ mediaFiles: [{ id: '1' }] }),
|
||||
props.collection,
|
||||
fromJS([{ id: '1' }]),
|
||||
);
|
||||
});
|
||||
|
||||
@ -220,8 +219,8 @@ describe('Editor', () => {
|
||||
const { rerender } = render(
|
||||
<Editor
|
||||
{...props}
|
||||
entryDraft={fromJS({ entry: { slug: 'slug' } })}
|
||||
entry={fromJS({ isFetching: false })}
|
||||
entryDraft={fromJS({ entry: {} })}
|
||||
entry={fromJS({ isFetching: false, mediaFiles: [] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
@ -230,19 +229,17 @@ describe('Editor', () => {
|
||||
<Editor
|
||||
{...props}
|
||||
entryDraft={fromJS({
|
||||
entry: { slug: 'slug' },
|
||||
mediaFiles: [{ id: '1' }],
|
||||
entry: {},
|
||||
fieldsMetaData: {},
|
||||
})}
|
||||
entry={fromJS({ isFetching: false })}
|
||||
entry={fromJS({ isFetching: false, mediaFiles: [{ id: '1' }] })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(props.createDraftFromEntry).toHaveBeenCalledTimes(1);
|
||||
expect(props.createDraftFromEntry).toHaveBeenCalledWith(
|
||||
fromJS({ isFetching: false, data: {} }),
|
||||
fromJS({ isFetching: false, data: {}, mediaFiles: [{ id: '1' }] }),
|
||||
fromJS({}),
|
||||
fromJS([{ id: '1' }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -14,7 +14,8 @@ import {
|
||||
loadMediaDisplayURL as loadMediaDisplayURLAction,
|
||||
closeMediaLibrary as closeMediaLibraryAction,
|
||||
} from 'Actions/mediaLibrary';
|
||||
import MediaLibraryModal from './MediaLibraryModal';
|
||||
import { selectMediaFiles } from 'Reducers/mediaLibrary';
|
||||
import MediaLibraryModal, { fileShape } from './MediaLibraryModal';
|
||||
|
||||
/**
|
||||
* Extensions used to determine which files to show when the media library is
|
||||
@ -23,17 +24,6 @@ import MediaLibraryModal from './MediaLibraryModal';
|
||||
const IMAGE_EXTENSIONS_VIEWABLE = ['jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', 'tiff', 'svg'];
|
||||
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
|
||||
|
||||
const fileShape = {
|
||||
displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
queryOrder: PropTypes.number,
|
||||
size: PropTypes.number,
|
||||
url: PropTypes.string,
|
||||
urlIsPublicPath: PropTypes.bool,
|
||||
};
|
||||
|
||||
class MediaLibrary extends React.Component {
|
||||
static propTypes = {
|
||||
isVisible: PropTypes.bool,
|
||||
@ -118,17 +108,16 @@ class MediaLibrary extends React.Component {
|
||||
toTableData = files => {
|
||||
const tableData =
|
||||
files &&
|
||||
files.map(({ key, name, id, size, queryOrder, url, urlIsPublicPath, displayURL, draft }) => {
|
||||
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
type: ext.toUpperCase(),
|
||||
size,
|
||||
queryOrder,
|
||||
url,
|
||||
urlIsPublicPath,
|
||||
displayURL,
|
||||
draft,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
@ -167,9 +156,9 @@ class MediaLibrary extends React.Component {
|
||||
* get the file for upload, and retain the synthetic event for access after
|
||||
* the asynchronous persist operation.
|
||||
*/
|
||||
event.persist();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.persist();
|
||||
const { persistMedia, privateUpload } = this.props;
|
||||
const { files: fileList } = event.dataTransfer || event.target;
|
||||
const files = [...fileList];
|
||||
@ -190,9 +179,9 @@ class MediaLibrary extends React.Component {
|
||||
*/
|
||||
handleInsert = () => {
|
||||
const { selectedFile } = this.state;
|
||||
const { name, url, urlIsPublicPath } = selectedFile;
|
||||
const { path } = selectedFile;
|
||||
const { insertMedia } = this.props;
|
||||
insertMedia(urlIsPublicPath ? { url } : { name });
|
||||
insertMedia(path);
|
||||
this.handleClose();
|
||||
};
|
||||
|
||||
@ -315,14 +304,11 @@ class MediaLibrary extends React.Component {
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { config, mediaLibrary } = state;
|
||||
const configProps = {
|
||||
publicFolder: config.get('public_folder'),
|
||||
};
|
||||
const { mediaLibrary } = state;
|
||||
const mediaLibraryProps = {
|
||||
isVisible: mediaLibrary.get('isVisible'),
|
||||
canInsert: mediaLibrary.get('canInsert'),
|
||||
files: mediaLibrary.get('files'),
|
||||
files: selectMediaFiles(state),
|
||||
displayURLs: mediaLibrary.get('displayURLs'),
|
||||
dynamicSearch: mediaLibrary.get('dynamicSearch'),
|
||||
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
|
||||
@ -336,7 +322,7 @@ const mapStateToProps = state => {
|
||||
hasNextPage: mediaLibrary.get('hasNextPage'),
|
||||
isPaginating: mediaLibrary.get('isPaginating'),
|
||||
};
|
||||
return { ...configProps, ...mediaLibraryProps };
|
||||
return { ...mediaLibraryProps };
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
|
@ -75,8 +75,6 @@ MediaLibraryCardGrid.propTypes = {
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
url: PropTypes.string,
|
||||
urlIsPublicPath: PropTypes.bool,
|
||||
draft: PropTypes.bool,
|
||||
}),
|
||||
).isRequired,
|
||||
|
@ -181,15 +181,14 @@ const MediaLibraryModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const fileShape = {
|
||||
export const fileShape = {
|
||||
displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
key: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
queryOrder: PropTypes.number,
|
||||
size: PropTypes.number,
|
||||
url: PropTypes.string,
|
||||
urlIsPublicPath: PropTypes.bool,
|
||||
path: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
MediaLibraryModal.propTypes = {
|
||||
|
@ -1,5 +1,5 @@
|
||||
export { DragSource, DropTarget, HTML5DragDrop } from './DragDrop';
|
||||
export ErrorBoundary from './ErrorBoundary';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { FileUploadButton } from './FileUploadButton';
|
||||
export { Modal } from './Modal';
|
||||
export Toast from './Toast';
|
||||
export { default as Toast } from './Toast';
|
||||
|
@ -118,7 +118,7 @@ class Workflow extends Component {
|
||||
<WorkflowTopDescription>
|
||||
{t('workflow.workflow.description', {
|
||||
smart_count: reviewCount,
|
||||
readyCount: readyCount,
|
||||
readyCount,
|
||||
})}
|
||||
</WorkflowTopDescription>
|
||||
</WorkflowTop>
|
||||
|
@ -4,15 +4,19 @@ import { Map, OrderedMap } from 'immutable';
|
||||
export const SIMPLE = 'simple';
|
||||
export const EDITORIAL_WORKFLOW = 'editorial_workflow';
|
||||
|
||||
// Available status
|
||||
export const status = OrderedMap({
|
||||
export const Statues = {
|
||||
DRAFT: 'draft',
|
||||
PENDING_REVIEW: 'pending_review',
|
||||
PENDING_PUBLISH: 'pending_publish',
|
||||
});
|
||||
};
|
||||
|
||||
// Available status
|
||||
export const status = OrderedMap(Statues);
|
||||
|
||||
export const statusDescriptions = Map({
|
||||
[status.get('DRAFT')]: 'Draft',
|
||||
[status.get('PENDING_REVIEW')]: 'Waiting for Review',
|
||||
[status.get('PENDING_PUBLISH')]: 'Waiting to go live',
|
||||
});
|
||||
|
||||
export type Status = keyof typeof Statues;
|
@ -5,8 +5,6 @@ import {
|
||||
frontmatterYAML,
|
||||
} from '../frontmatter';
|
||||
|
||||
jest.mock('../../valueObjects/AssetProxy.js');
|
||||
|
||||
describe('Frontmatter', () => {
|
||||
it('should parse YAML with --- delimiters', () => {
|
||||
expect(
|
||||
|
@ -78,7 +78,7 @@ export default class AssetStore {
|
||||
};
|
||||
const response = await this.request(url, { headers });
|
||||
const files = response.map(({ id, name, size, url }) => {
|
||||
return { id, name, size, url, urlIsPublicPath: true };
|
||||
return { id, name, size, displayURL: url, url };
|
||||
});
|
||||
return files;
|
||||
}
|
||||
@ -133,10 +133,11 @@ export default class AssetStore {
|
||||
await this.confirmRequest(id);
|
||||
}
|
||||
|
||||
const asset = { id, name, size, url, urlIsPublicPath: true };
|
||||
return { success: true, url, asset };
|
||||
const asset = { id, name, size, displayURL: url, url };
|
||||
return { success: true, asset };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -195,6 +195,12 @@ describe('backendHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const slugConfig = Map({
|
||||
encoding: 'unicode',
|
||||
clean_accents: false,
|
||||
sanitize_replacement: '-',
|
||||
});
|
||||
|
||||
describe('slugFormatter', () => {
|
||||
const date = new Date('2020-01-01');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => date);
|
||||
@ -207,7 +213,7 @@ describe('backendHelper', () => {
|
||||
|
||||
it('should format with default pattern', () => {
|
||||
selectIdentifier.mockReturnValueOnce('title');
|
||||
expect(slugFormatter(Map(), Map({ title: 'Post Title' }))).toBe('post-title');
|
||||
expect(slugFormatter(Map(), Map({ title: 'Post Title' }), slugConfig)).toBe('post-title');
|
||||
});
|
||||
|
||||
it('should format with date', () => {
|
||||
@ -217,6 +223,7 @@ describe('backendHelper', () => {
|
||||
slugFormatter(
|
||||
Map({ slug: '{{year}}-{{month}}-{{day}}_{{slug}}' }),
|
||||
Map({ title: 'Post Title' }),
|
||||
slugConfig,
|
||||
),
|
||||
).toBe('2020-01-01_post-title');
|
||||
});
|
||||
@ -228,6 +235,7 @@ describe('backendHelper', () => {
|
||||
slugFormatter(
|
||||
Map({ slug: '{{fields.slug}}' }),
|
||||
Map({ title: 'Post Title', slug: 'entry-slug' }),
|
||||
slugConfig,
|
||||
),
|
||||
).toBe('entry-slug');
|
||||
});
|
||||
@ -235,9 +243,9 @@ describe('backendHelper', () => {
|
||||
it('should return slug', () => {
|
||||
selectIdentifier.mockReturnValueOnce('title');
|
||||
|
||||
expect(slugFormatter(Map({ slug: '{{slug}}' }), Map({ title: 'Post Title' }))).toBe(
|
||||
'post-title',
|
||||
);
|
||||
expect(
|
||||
slugFormatter(Map({ slug: '{{slug}}' }), Map({ title: 'Post Title' }), slugConfig),
|
||||
).toBe('post-title');
|
||||
});
|
||||
|
||||
it('should return slug with path', () => {
|
||||
@ -247,6 +255,7 @@ describe('backendHelper', () => {
|
||||
slugFormatter(
|
||||
Map({ slug: '{{year}}-{{month}}-{{day}}-{{slug}}', path: 'sub_dir/{{year}}/{{slug}}' }),
|
||||
Map({ title: 'Post Title' }),
|
||||
slugConfig,
|
||||
),
|
||||
).toBe('sub_dir/2020/2020-01-01-post-title');
|
||||
});
|
||||
@ -261,6 +270,7 @@ describe('backendHelper', () => {
|
||||
path: 'sub_dir/{{year}}/{{slug}}',
|
||||
}),
|
||||
Map({ title: 'Post Title' }),
|
||||
slugConfig,
|
||||
),
|
||||
).toBe('sub_dir/2020/2020-01-01-post-title.en');
|
||||
});
|
||||
|
@ -42,6 +42,12 @@ describe('sanitizeURI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const slugConfig = {
|
||||
encoding: 'unicode',
|
||||
clean_accents: false,
|
||||
sanitize_replacement: '-',
|
||||
};
|
||||
|
||||
describe('sanitizeSlug', () => {
|
||||
it('throws an error for non-strings', () => {
|
||||
expect(() => sanitizeSlug({})).toThrowError('The input slug must be a string.');
|
||||
@ -77,49 +83,56 @@ describe('sanitizeSlug', () => {
|
||||
});
|
||||
|
||||
it('should keep valid URI chars (letters digits _ - . ~)', () => {
|
||||
expect(sanitizeSlug('This, that-one_or.the~other 123!')).toEqual(
|
||||
expect(sanitizeSlug('This, that-one_or.the~other 123!', Map(slugConfig))).toEqual(
|
||||
'This-that-one_or.the~other-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove accents with `clean_accents` set', () => {
|
||||
expect(sanitizeSlug('ěščřžý', Map({ clean_accents: true }))).toEqual('escrzy');
|
||||
expect(sanitizeSlug('ěščřžý', Map({ ...slugConfig, clean_accents: true }))).toEqual('escrzy');
|
||||
});
|
||||
|
||||
it('should remove non-latin chars in "ascii" mode', () => {
|
||||
expect(sanitizeSlug('ěščřžý日本語のタイトル', Map({ encoding: 'ascii' }))).toEqual('');
|
||||
expect(
|
||||
sanitizeSlug('ěščřžý日本語のタイトル', Map({ ...slugConfig, encoding: 'ascii' })),
|
||||
).toEqual('');
|
||||
});
|
||||
|
||||
it('should clean accents and strip non-latin chars in "ascii" mode with `clean_accents` set', () => {
|
||||
expect(
|
||||
sanitizeSlug('ěščřžý日本語のタイトル', Map({ encoding: 'ascii', clean_accents: true })),
|
||||
sanitizeSlug(
|
||||
'ěščřžý日本語のタイトル',
|
||||
Map({ ...slugConfig, encoding: 'ascii', clean_accents: true }),
|
||||
),
|
||||
).toEqual('escrzy');
|
||||
});
|
||||
|
||||
it('removes double replacements', () => {
|
||||
expect(sanitizeSlug('test--test')).toEqual('test-test');
|
||||
expect(sanitizeSlug('test test')).toEqual('test-test');
|
||||
expect(sanitizeSlug('test--test', Map(slugConfig))).toEqual('test-test');
|
||||
expect(sanitizeSlug('test test', Map(slugConfig))).toEqual('test-test');
|
||||
});
|
||||
|
||||
it('removes trailing replacements', () => {
|
||||
expect(sanitizeSlug('test test ')).toEqual('test-test');
|
||||
expect(sanitizeSlug('test test ', Map(slugConfig))).toEqual('test-test');
|
||||
});
|
||||
|
||||
it('removes leading replacements', () => {
|
||||
expect(sanitizeSlug('"test" test')).toEqual('test-test');
|
||||
expect(sanitizeSlug('"test" test', Map(slugConfig))).toEqual('test-test');
|
||||
});
|
||||
|
||||
it('uses alternate replacements', () => {
|
||||
expect(sanitizeSlug('test test ', Map({ sanitize_replacement: '_' }))).toEqual('test_test');
|
||||
expect(
|
||||
sanitizeSlug('test test ', Map({ ...slugConfig, sanitize_replacement: '_' })),
|
||||
).toEqual('test_test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeChar', () => {
|
||||
it('should sanitize whitespace with default replacement', () => {
|
||||
expect(sanitizeChar(' ', Map())).toBe('-');
|
||||
expect(sanitizeChar(' ', Map(slugConfig))).toBe('-');
|
||||
});
|
||||
|
||||
it('should sanitize whitespace with custom replacement', () => {
|
||||
expect(sanitizeChar(' ', Map({ sanitize_replacement: '_' }))).toBe('_');
|
||||
expect(sanitizeChar(' ', Map({ ...slugConfig, sanitize_replacement: '_' }))).toBe('_');
|
||||
});
|
||||
});
|
||||
|
@ -1,18 +1,20 @@
|
||||
import moment from 'moment';
|
||||
import { selectInferedField } from 'Reducers/collections';
|
||||
import { Map } from 'immutable';
|
||||
import { selectInferedField } from '../reducers/collections';
|
||||
import { EntryMap, Collection } from '../types/redux';
|
||||
|
||||
// prepends a Zero if the date has only 1 digit
|
||||
function formatDate(date) {
|
||||
function formatDate(date: number) {
|
||||
return `0${date}`.slice(-2);
|
||||
}
|
||||
|
||||
export const dateParsers = {
|
||||
year: date => date.getUTCFullYear(),
|
||||
month: date => formatDate(date.getUTCMonth() + 1),
|
||||
day: date => formatDate(date.getUTCDate()),
|
||||
hour: date => formatDate(date.getUTCHours()),
|
||||
minute: date => formatDate(date.getUTCMinutes()),
|
||||
second: date => formatDate(date.getUTCSeconds()),
|
||||
export const dateParsers: Record<string, (date: Date) => string> = {
|
||||
year: (date: Date) => `${date.getUTCFullYear()}`,
|
||||
month: (date: Date) => formatDate(date.getUTCMonth() + 1),
|
||||
day: (date: Date) => formatDate(date.getUTCDate()),
|
||||
hour: (date: Date) => formatDate(date.getUTCHours()),
|
||||
minute: (date: Date) => formatDate(date.getUTCMinutes()),
|
||||
second: (date: Date) => formatDate(date.getUTCSeconds()),
|
||||
};
|
||||
|
||||
export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE';
|
||||
@ -23,7 +25,7 @@ const templateVariablePattern = `{{(${templateContentPattern})}}`;
|
||||
|
||||
// Allow `fields.` prefix in placeholder to override built in replacements
|
||||
// like "slug" and "year" with values from fields of the same name.
|
||||
function getExplicitFieldReplacement(key, data) {
|
||||
function getExplicitFieldReplacement(key: string, data: Map<string, string>) {
|
||||
if (!key.startsWith(FIELD_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
@ -31,7 +33,11 @@ function getExplicitFieldReplacement(key, data) {
|
||||
return data.get(fieldName, '');
|
||||
}
|
||||
|
||||
export function parseDateFromEntry(entry, collection, fieldName) {
|
||||
export function parseDateFromEntry(
|
||||
entry: EntryMap,
|
||||
collection: Collection,
|
||||
fieldName: string | undefined,
|
||||
) {
|
||||
const dateFieldName = fieldName || selectInferedField(collection, 'date');
|
||||
if (!dateFieldName) {
|
||||
return;
|
||||
@ -44,14 +50,20 @@ export function parseDateFromEntry(entry, collection, fieldName) {
|
||||
}
|
||||
}
|
||||
|
||||
export function compileStringTemplate(template, date, identifier = '', data = Map(), processor) {
|
||||
export function compileStringTemplate(
|
||||
template: string,
|
||||
date: Date | undefined,
|
||||
identifier = '',
|
||||
data = Map<string, string>(),
|
||||
processor: (value: string) => string,
|
||||
) {
|
||||
let missingRequiredDate;
|
||||
|
||||
// Turn off date processing (support for replacements like `{{year}}`), by passing in
|
||||
// `null` as the date arg.
|
||||
const useDate = date !== null;
|
||||
|
||||
const slug = template.replace(RegExp(templateVariablePattern, 'g'), (_, key) => {
|
||||
const slug = template.replace(RegExp(templateVariablePattern, 'g'), (_, key: string) => {
|
||||
let replacement;
|
||||
const explicitFieldReplacement = getExplicitFieldReplacement(key, data);
|
||||
|
||||
@ -61,7 +73,7 @@ export function compileStringTemplate(template, date, identifier = '', data = Ma
|
||||
missingRequiredDate = true;
|
||||
return '';
|
||||
} else if (dateParsers[key]) {
|
||||
replacement = dateParsers[key](date);
|
||||
replacement = dateParsers[key](date as Date);
|
||||
} else if (key === 'slug') {
|
||||
replacement = identifier;
|
||||
} else {
|
||||
@ -84,9 +96,12 @@ export function compileStringTemplate(template, date, identifier = '', data = Ma
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTemplateVars(template) {
|
||||
export function extractTemplateVars(template: string) {
|
||||
const regexp = RegExp(templateVariablePattern, 'g');
|
||||
const contentRegexp = RegExp(templateContentPattern, 'g');
|
||||
const matches = template.match(regexp) || [];
|
||||
return matches.map(elem => elem.match(contentRegexp)[0]);
|
||||
return matches.map(elem => {
|
||||
const match = elem.match(contentRegexp);
|
||||
return match ? match[0] : '';
|
||||
});
|
||||
}
|
@ -2,27 +2,27 @@ import url from 'url';
|
||||
import diacritics from 'diacritics';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
import { isString, escapeRegExp, flow, partialRight } from 'lodash';
|
||||
import { Map } from 'immutable';
|
||||
import { SlugConfig } from '../types/redux';
|
||||
|
||||
function getUrl(urlString, direct) {
|
||||
function getUrl(urlString: string, direct: boolean) {
|
||||
return `${direct ? '/#' : ''}${urlString}`;
|
||||
}
|
||||
|
||||
export function getCollectionUrl(collectionName, direct) {
|
||||
export function getCollectionUrl(collectionName: string, direct: boolean) {
|
||||
return getUrl(`/collections/${collectionName}`, direct);
|
||||
}
|
||||
|
||||
export function getNewEntryUrl(collectionName, direct) {
|
||||
export function getNewEntryUrl(collectionName: string, direct: boolean) {
|
||||
return getUrl(`/collections/${collectionName}/new`, direct);
|
||||
}
|
||||
|
||||
export function addParams(urlString, params) {
|
||||
export function addParams(urlString: string, params: {}) {
|
||||
const parsedUrl = url.parse(urlString, true);
|
||||
parsedUrl.query = { ...parsedUrl.query, ...params };
|
||||
return url.format(parsedUrl);
|
||||
}
|
||||
|
||||
export function stripProtocol(urlString) {
|
||||
export function stripProtocol(urlString: string) {
|
||||
const protocolEndIndex = urlString.indexOf('//');
|
||||
return protocolEndIndex > -1 ? urlString.slice(protocolEndIndex + 2) : urlString;
|
||||
}
|
||||
@ -36,11 +36,11 @@ export function stripProtocol(urlString) {
|
||||
*/
|
||||
const uriChars = /[\w\-.~]/i;
|
||||
const ucsChars = /[\xA0-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFEF}\u{10000}-\u{1FFFD}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}\u{40000}-\u{4FFFD}\u{50000}-\u{5FFFD}\u{60000}-\u{6FFFD}\u{70000}-\u{7FFFD}\u{80000}-\u{8FFFD}\u{90000}-\u{9FFFD}\u{A0000}-\u{AFFFD}\u{B0000}-\u{BFFFD}\u{C0000}-\u{CFFFD}\u{D0000}-\u{DFFFD}\u{E1000}-\u{EFFFD}]/u;
|
||||
const validURIChar = char => uriChars.test(char);
|
||||
const validIRIChar = char => uriChars.test(char) || ucsChars.test(char);
|
||||
const validURIChar = (char: string) => uriChars.test(char);
|
||||
const validIRIChar = (char: string) => uriChars.test(char) || ucsChars.test(char);
|
||||
|
||||
export function getCharReplacer(encoding, replacement) {
|
||||
let validChar;
|
||||
export function getCharReplacer(encoding: string, replacement: string) {
|
||||
let validChar: (char: string) => boolean;
|
||||
|
||||
if (encoding === 'unicode') {
|
||||
validChar = validIRIChar;
|
||||
@ -55,10 +55,10 @@ export function getCharReplacer(encoding, replacement) {
|
||||
throw new Error('The replacement character(s) (options.replacement) is itself unsafe.');
|
||||
}
|
||||
|
||||
return char => (validChar(char) ? char : replacement);
|
||||
return (char: string) => (validChar(char) ? char : replacement);
|
||||
}
|
||||
// `sanitizeURI` does not actually URI-encode the chars (that is the browser's and server's job), just removes the ones that are not allowed.
|
||||
export function sanitizeURI(str, { replacement = '', encoding = 'unicode' } = {}) {
|
||||
export function sanitizeURI(str: string, { replacement = '', encoding = 'unicode' } = {}) {
|
||||
if (!isString(str)) {
|
||||
throw new Error('The input slug must be a string.');
|
||||
}
|
||||
@ -73,22 +73,22 @@ export function sanitizeURI(str, { replacement = '', encoding = 'unicode' } = {}
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function sanitizeChar(char, options = Map()) {
|
||||
const encoding = options.get('encoding', 'unicode');
|
||||
const replacement = options.get('sanitize_replacement', '-');
|
||||
export function sanitizeChar(char: string, options: SlugConfig) {
|
||||
const encoding = options.get('encoding');
|
||||
const replacement = options.get('sanitize_replacement');
|
||||
|
||||
return getCharReplacer(encoding, replacement)(char);
|
||||
}
|
||||
|
||||
export function sanitizeSlug(str, options = Map()) {
|
||||
const encoding = options.get('encoding', 'unicode');
|
||||
const stripDiacritics = options.get('clean_accents', false);
|
||||
const replacement = options.get('sanitize_replacement', '-');
|
||||
|
||||
export function sanitizeSlug(str: string, options: SlugConfig) {
|
||||
if (!isString(str)) {
|
||||
throw new Error('The input slug must be a string.');
|
||||
}
|
||||
|
||||
const encoding = options.get('encoding');
|
||||
const stripDiacritics = options.get('clean_accents');
|
||||
const replacement = options.get('sanitize_replacement');
|
||||
|
||||
const sanitizedSlug = flow([
|
||||
...(stripDiacritics ? [diacritics.remove] : []),
|
||||
partialRight(sanitizeURI, { replacement, encoding }),
|
||||
@ -97,13 +97,13 @@ export function sanitizeSlug(str, options = Map()) {
|
||||
|
||||
// Remove any doubled or leading/trailing replacement characters (that were added in the sanitizers).
|
||||
const doubleReplacement = new RegExp(`(?:${escapeRegExp(replacement)})+`, 'g');
|
||||
const trailingReplacment = new RegExp(`${escapeRegExp(replacement)}$`);
|
||||
const leadingReplacment = new RegExp(`^${escapeRegExp(replacement)}`);
|
||||
const trailingReplacement = new RegExp(`${escapeRegExp(replacement)}$`);
|
||||
const leadingReplacement = new RegExp(`^${escapeRegExp(replacement)}`);
|
||||
|
||||
const normalizedSlug = sanitizedSlug
|
||||
const normalizedSlug: string = sanitizedSlug
|
||||
.replace(doubleReplacement, replacement)
|
||||
.replace(leadingReplacment, '')
|
||||
.replace(trailingReplacment, '');
|
||||
.replace(leadingReplacement, '')
|
||||
.replace(trailingReplacement, '');
|
||||
|
||||
return normalizedSlug;
|
||||
}
|
@ -3,13 +3,30 @@
|
||||
* registered via `registerMediaLibrary`.
|
||||
*/
|
||||
import { once } from 'lodash';
|
||||
import { getMediaLibrary } from 'Lib/registry';
|
||||
import store from 'ReduxStore';
|
||||
import { createMediaLibrary, insertMedia } from 'Actions/mediaLibrary';
|
||||
import { getMediaLibrary } from './lib/registry';
|
||||
import store from './redux';
|
||||
import { createMediaLibrary, insertMedia } from './actions/mediaLibrary';
|
||||
|
||||
type MediaLibraryOptions = {};
|
||||
|
||||
interface MediaLibrary {
|
||||
init: (args: {
|
||||
options: MediaLibraryOptions;
|
||||
handleInsert: (url: string) => void;
|
||||
}) => MediaLibraryInstance;
|
||||
}
|
||||
|
||||
export interface MediaLibraryInstance {
|
||||
show?: () => void;
|
||||
hide?: () => void;
|
||||
onClearControl?: (args: { id: string }) => void;
|
||||
onRemoveControl?: (args: { id: string }) => void;
|
||||
enableStandalone?: () => boolean;
|
||||
}
|
||||
|
||||
const initializeMediaLibrary = once(async function initializeMediaLibrary(name, options) {
|
||||
const lib = getMediaLibrary(name);
|
||||
const handleInsert = url => store.dispatch(insertMedia(url));
|
||||
const lib = (getMediaLibrary(name) as unknown) as MediaLibrary;
|
||||
const handleInsert = (url: string) => store.dispatch(insertMedia(url));
|
||||
const instance = await lib.init({ options, handleInsert });
|
||||
store.dispatch(createMediaLibrary(instance));
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import { Map } from 'immutable';
|
||||
import { configLoaded, configLoading, configFailed } from 'Actions/config';
|
||||
import config from 'Reducers/config';
|
||||
import config, { selectLocale } from 'Reducers/config';
|
||||
|
||||
describe('config', () => {
|
||||
it('should handle an empty state', () => {
|
||||
@ -22,4 +22,8 @@ describe('config', () => {
|
||||
Map({ error: 'Error: Config could not be loaded' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to "en" locale', () => {
|
||||
expect(selectLocale(Map())).toEqual('en');
|
||||
});
|
||||
});
|
||||
|
@ -1,67 +1,215 @@
|
||||
import { Map, OrderedMap, fromJS } from 'immutable';
|
||||
import * as actions from 'Actions/entries';
|
||||
import reducer from '../entries';
|
||||
import reducer, {
|
||||
selectMediaFolder,
|
||||
selectMediaFilePath,
|
||||
selectMediaFilePublicPath,
|
||||
} from '../entries';
|
||||
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
|
||||
|
||||
const initialState = OrderedMap({
|
||||
posts: Map({ name: 'posts' }),
|
||||
});
|
||||
|
||||
describe('entries', () => {
|
||||
it('should mark entries as fetching', () => {
|
||||
expect(reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))).toEqual(
|
||||
OrderedMap(
|
||||
fromJS({
|
||||
posts: { name: 'posts' },
|
||||
pages: {
|
||||
posts: { isFetching: true },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
describe('reducer', () => {
|
||||
it('should mark entries as fetching', () => {
|
||||
expect(reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))).toEqual(
|
||||
OrderedMap(
|
||||
fromJS({
|
||||
posts: { name: 'posts' },
|
||||
pages: {
|
||||
posts: { isFetching: true },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle loaded entries', () => {
|
||||
const entries = [
|
||||
{ slug: 'a', path: '' },
|
||||
{ slug: 'b', title: 'B' },
|
||||
];
|
||||
expect(
|
||||
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0)),
|
||||
).toEqual(
|
||||
OrderedMap(
|
||||
fromJS({
|
||||
posts: { name: 'posts' },
|
||||
entities: {
|
||||
'posts.a': { slug: 'a', path: '', isFetching: false },
|
||||
'posts.b': { slug: 'b', title: 'B', isFetching: false },
|
||||
},
|
||||
pages: {
|
||||
posts: {
|
||||
page: 0,
|
||||
ids: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle loaded entry', () => {
|
||||
const entry = { slug: 'a', path: '' };
|
||||
expect(reducer(initialState, actions.entryLoaded(Map({ name: 'posts' }), entry))).toEqual(
|
||||
OrderedMap(
|
||||
fromJS({
|
||||
posts: { name: 'posts' },
|
||||
entities: {
|
||||
'posts.a': { slug: 'a', path: '' },
|
||||
},
|
||||
pages: {
|
||||
posts: {
|
||||
ids: ['a'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle loaded entries', () => {
|
||||
const entries = [
|
||||
{ slug: 'a', path: '' },
|
||||
{ slug: 'b', title: 'B' },
|
||||
];
|
||||
expect(
|
||||
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0)),
|
||||
).toEqual(
|
||||
OrderedMap(
|
||||
fromJS({
|
||||
posts: { name: 'posts' },
|
||||
entities: {
|
||||
'posts.a': { slug: 'a', path: '', isFetching: false },
|
||||
'posts.b': { slug: 'b', title: 'B', isFetching: false },
|
||||
},
|
||||
pages: {
|
||||
posts: {
|
||||
page: 0,
|
||||
ids: ['a', 'b'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
describe('selectMediaFolder', () => {
|
||||
it('should return global media folder when not in editorial workflow', () => {
|
||||
expect(selectMediaFolder(Map({ media_folder: 'static/media' }))).toEqual('static/media');
|
||||
});
|
||||
|
||||
it("should return global media folder when in editorial workflow and collection doesn't specify media_folder", () => {
|
||||
expect(
|
||||
selectMediaFolder(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts' }),
|
||||
),
|
||||
).toEqual('static/media');
|
||||
});
|
||||
|
||||
it('should return draft media folder when in editorial workflow, collection specifies media_folder and entry path is null', () => {
|
||||
expect(
|
||||
selectMediaFolder(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
|
||||
null,
|
||||
),
|
||||
).toEqual('posts/DRAFT_MEDIA_FILES');
|
||||
});
|
||||
|
||||
it('should return relative media folder when in editorial workflow, collection specifies media_folder and entry path is not null', () => {
|
||||
expect(
|
||||
selectMediaFolder(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
|
||||
'posts/title/index.md',
|
||||
),
|
||||
).toEqual('posts/title');
|
||||
});
|
||||
|
||||
it('should resolve relative media folder', () => {
|
||||
expect(
|
||||
selectMediaFolder(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts', media_folder: '../' }),
|
||||
'posts/title/index.md',
|
||||
),
|
||||
).toEqual('posts/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle loaded entry', () => {
|
||||
const entry = { slug: 'a', path: '' };
|
||||
expect(reducer(initialState, actions.entryLoaded(Map({ name: 'posts' }), entry))).toEqual(
|
||||
OrderedMap(
|
||||
fromJS({
|
||||
posts: { name: 'posts' },
|
||||
entities: {
|
||||
'posts.a': { slug: 'a', path: '' },
|
||||
},
|
||||
pages: {
|
||||
posts: {
|
||||
ids: ['a'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
describe('selectMediaFilePath', () => {
|
||||
it('should return absolute URL as is', () => {
|
||||
expect(selectMediaFilePath(null, null, null, 'https://www.netlify.com/image.png')).toBe(
|
||||
'https://www.netlify.com/image.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve path from global media folder when absolute path', () => {
|
||||
expect(
|
||||
selectMediaFilePath(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
null,
|
||||
null,
|
||||
'/media/image.png',
|
||||
),
|
||||
).toBe('static/media/image.png');
|
||||
});
|
||||
|
||||
it('should resolve path from global media folder when relative path for collection with no media folder', () => {
|
||||
expect(
|
||||
selectMediaFilePath(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts' }),
|
||||
null,
|
||||
'image.png',
|
||||
),
|
||||
).toBe('static/media/image.png');
|
||||
});
|
||||
|
||||
it('should resolve path from collection media folder when relative path for collection with media folder', () => {
|
||||
expect(
|
||||
selectMediaFilePath(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
|
||||
null,
|
||||
'image.png',
|
||||
),
|
||||
).toBe('posts/DRAFT_MEDIA_FILES/image.png');
|
||||
});
|
||||
|
||||
it('should handle relative media_folder', () => {
|
||||
expect(
|
||||
selectMediaFilePath(
|
||||
Map({ media_folder: 'static/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
|
||||
'posts/title/index.md',
|
||||
'image.png',
|
||||
),
|
||||
).toBe('static/media/image.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectMediaFilePublicPath', () => {
|
||||
it('should return absolute URL as is', () => {
|
||||
expect(selectMediaFilePublicPath(null, null, 'https://www.netlify.com/image.png')).toBe(
|
||||
'https://www.netlify.com/image.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve path from public folder when not in editorial workflow', () => {
|
||||
expect(
|
||||
selectMediaFilePublicPath(Map({ public_folder: '/media' }), null, '/media/image.png'),
|
||||
).toBe('/media/image.png');
|
||||
});
|
||||
|
||||
it('should resolve path from public folder when in editorial workflow for collection with no media folder', () => {
|
||||
expect(
|
||||
selectMediaFilePublicPath(
|
||||
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts' }),
|
||||
'image.png',
|
||||
),
|
||||
).toBe('/media/image.png');
|
||||
});
|
||||
|
||||
it('should resolve path from collection media folder when in editorial workflow for collection with media folder', () => {
|
||||
expect(
|
||||
selectMediaFilePublicPath(
|
||||
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts', media_folder: '' }),
|
||||
'image.png',
|
||||
),
|
||||
).toBe('image.png');
|
||||
});
|
||||
|
||||
it('should handle relative media_folder', () => {
|
||||
expect(
|
||||
selectMediaFilePublicPath(
|
||||
Map({ public_folder: '/media', publish_mode: EDITORIAL_WORKFLOW }),
|
||||
Map({ name: 'posts', folder: 'posts', media_folder: '../../static/media/' }),
|
||||
'image.png',
|
||||
),
|
||||
).toBe('../../static/media/image.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
import { Map, fromJS } from 'immutable';
|
||||
import * as actions from 'Actions/entries';
|
||||
import reducer from '../entryDraft';
|
||||
|
||||
@ -6,7 +6,6 @@ jest.mock('uuid/v4', () => jest.fn(() => '1'));
|
||||
|
||||
const initialState = Map({
|
||||
entry: Map(),
|
||||
mediaFiles: List(),
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
@ -33,7 +32,6 @@ describe('entryDraft reducer', () => {
|
||||
...entry,
|
||||
newRecord: false,
|
||||
},
|
||||
mediaFiles: [],
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
@ -52,7 +50,6 @@ describe('entryDraft reducer', () => {
|
||||
...entry,
|
||||
newRecord: true,
|
||||
},
|
||||
mediaFiles: [],
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
@ -124,16 +121,15 @@ describe('entryDraft reducer', () => {
|
||||
describe('REMOVE_DRAFT_ENTRY_MEDIA_FILE', () => {
|
||||
it('should remove a media file', () => {
|
||||
const actualState = reducer(
|
||||
initialState.set('mediaFiles', List([{ id: '1' }, { id: '2' }])),
|
||||
initialState.setIn(['entry', 'mediaFiles'], fromJS([{ id: '1' }, { id: '2' }])),
|
||||
actions.removeDraftEntryMediaFile({ id: '1' }),
|
||||
);
|
||||
|
||||
expect(actualState.toJS()).toEqual({
|
||||
entry: {},
|
||||
mediaFiles: [{ id: '2' }],
|
||||
entry: { mediaFiles: [{ id: '2' }] },
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: false,
|
||||
hasChanged: true,
|
||||
key: '',
|
||||
});
|
||||
});
|
||||
@ -142,34 +138,15 @@ describe('entryDraft reducer', () => {
|
||||
describe('ADD_DRAFT_ENTRY_MEDIA_FILE', () => {
|
||||
it('should overwrite an existing media file', () => {
|
||||
const actualState = reducer(
|
||||
initialState.set('mediaFiles', List([{ id: '1', name: 'old' }])),
|
||||
initialState.setIn(['entry', 'mediaFiles'], fromJS([{ id: '1', name: 'old' }])),
|
||||
actions.addDraftEntryMediaFile({ id: '1', name: 'new' }),
|
||||
);
|
||||
|
||||
expect(actualState.toJS()).toEqual({
|
||||
entry: {},
|
||||
mediaFiles: [{ id: '1', name: 'new' }],
|
||||
entry: { mediaFiles: [{ id: '1', name: 'new' }] },
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: false,
|
||||
key: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SET_DRAFT_ENTRY_MEDIA_FILES', () => {
|
||||
it('should overwrite an existing media file', () => {
|
||||
const actualState = reducer(
|
||||
initialState,
|
||||
actions.setDraftEntryMediaFiles([{ id: '1' }, { id: '2' }]),
|
||||
);
|
||||
|
||||
expect(actualState.toJS()).toEqual({
|
||||
entry: {},
|
||||
mediaFiles: [{ id: '1' }, { id: '2' }],
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: false,
|
||||
hasChanged: true,
|
||||
key: '',
|
||||
});
|
||||
});
|
||||
@ -177,7 +154,7 @@ describe('entryDraft reducer', () => {
|
||||
|
||||
describe('DRAFT_CREATE_FROM_LOCAL_BACKUP', () => {
|
||||
it('should create draft from local backup', () => {
|
||||
const localBackup = Map({ entry: fromJS(entry), mediaFiles: List([{ id: '1' }]) });
|
||||
const localBackup = Map({ entry: fromJS({ ...entry, mediaFiles: [{ id: '1' }] }) });
|
||||
|
||||
const actualState = reducer(initialState.set('localBackup', localBackup), {
|
||||
type: actions.DRAFT_CREATE_FROM_LOCAL_BACKUP,
|
||||
@ -185,9 +162,9 @@ describe('entryDraft reducer', () => {
|
||||
expect(actualState.toJS()).toEqual({
|
||||
entry: {
|
||||
...entry,
|
||||
mediaFiles: [{ id: '1' }],
|
||||
newRecord: false,
|
||||
},
|
||||
mediaFiles: [{ id: '1' }],
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: true,
|
||||
@ -200,17 +177,18 @@ describe('entryDraft reducer', () => {
|
||||
it('should set local backup', () => {
|
||||
const mediaFiles = [{ id: '1' }];
|
||||
|
||||
const actualState = reducer(initialState, actions.localBackupRetrieved(entry, mediaFiles));
|
||||
const actualState = reducer(
|
||||
initialState,
|
||||
actions.localBackupRetrieved({ ...entry, mediaFiles }),
|
||||
);
|
||||
|
||||
expect(actualState.toJS()).toEqual({
|
||||
entry: {},
|
||||
mediaFiles: [],
|
||||
fieldsMetaData: {},
|
||||
fieldsErrors: {},
|
||||
hasChanged: false,
|
||||
localBackup: {
|
||||
entry,
|
||||
mediaFiles: [{ id: '1' }],
|
||||
entry: { ...entry, mediaFiles: [{ id: '1' }] },
|
||||
},
|
||||
key: '',
|
||||
});
|
||||
|
@ -0,0 +1,70 @@
|
||||
import { fromJS } from 'immutable';
|
||||
import integrations from '../integrations';
|
||||
import { CONFIG_SUCCESS } from '../../actions/config';
|
||||
|
||||
describe('integrations', () => {
|
||||
it('should return default state when no integrations', () => {
|
||||
const result = integrations(null, {
|
||||
type: CONFIG_SUCCESS,
|
||||
payload: fromJS({ integrations: [] }),
|
||||
});
|
||||
expect(result && result.toJS()).toEqual({
|
||||
providers: {},
|
||||
hooks: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return hooks and providers map when has integrations', () => {
|
||||
const result = integrations(null, {
|
||||
type: CONFIG_SUCCESS,
|
||||
payload: fromJS({
|
||||
integrations: [
|
||||
{
|
||||
hooks: ['listEntries'],
|
||||
collections: '*',
|
||||
provider: 'algolia',
|
||||
applicationID: 'applicationID',
|
||||
apiKey: 'apiKey',
|
||||
},
|
||||
{
|
||||
hooks: ['listEntries'],
|
||||
collections: ['posts'],
|
||||
provider: 'algolia',
|
||||
applicationID: 'applicationID',
|
||||
apiKey: 'apiKey',
|
||||
},
|
||||
{
|
||||
hooks: ['assetStore'],
|
||||
provider: 'assetStore',
|
||||
getSignedFormURL: 'https://asset.store.com/signedUrl',
|
||||
},
|
||||
],
|
||||
collections: [{ name: 'posts' }, { name: 'pages' }, { name: 'faq' }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result && result.toJS()).toEqual({
|
||||
providers: {
|
||||
algolia: {
|
||||
applicationID: 'applicationID',
|
||||
apiKey: 'apiKey',
|
||||
},
|
||||
assetStore: {
|
||||
getSignedFormURL: 'https://asset.store.com/signedUrl',
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
posts: {
|
||||
listEntries: 'algolia',
|
||||
},
|
||||
pages: {
|
||||
listEntries: 'algolia',
|
||||
},
|
||||
faq: {
|
||||
listEntries: 'algolia',
|
||||
},
|
||||
assetStore: 'assetStore',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
@ -1,38 +1,16 @@
|
||||
import { Map } from 'immutable';
|
||||
import { ADD_MEDIA_FILES_TO_LIBRARY, mediaDeleted } from 'Actions/mediaLibrary';
|
||||
import mediaLibrary from '../mediaLibrary';
|
||||
import { Map, fromJS } from 'immutable';
|
||||
import { mediaDeleted } from 'Actions/mediaLibrary';
|
||||
import mediaLibrary, {
|
||||
selectMediaFiles,
|
||||
selectMediaFileByPath,
|
||||
selectMediaDisplayURL,
|
||||
} from '../mediaLibrary';
|
||||
|
||||
jest.mock('uuid/v4');
|
||||
jest.mock('Reducers/editorialWorkflow');
|
||||
jest.mock('Reducers');
|
||||
|
||||
describe('mediaLibrary', () => {
|
||||
const uuid = require('uuid/v4');
|
||||
|
||||
it('should add media files to library', () => {
|
||||
uuid.mockReturnValue('newKey');
|
||||
|
||||
expect(
|
||||
mediaLibrary(
|
||||
Map({
|
||||
files: [
|
||||
{ sha: 'old', path: 'path', key: 'key1' },
|
||||
{ sha: 'sha', path: 'some-other-pas', key: 'key2' },
|
||||
],
|
||||
}),
|
||||
{
|
||||
type: ADD_MEDIA_FILES_TO_LIBRARY,
|
||||
payload: { mediaFiles: [{ sha: 'new', path: 'path' }] },
|
||||
},
|
||||
),
|
||||
).toEqual(
|
||||
Map({
|
||||
files: [
|
||||
{ sha: 'new', path: 'path', key: 'newKey' },
|
||||
{ sha: 'sha', path: 'some-other-pas', key: 'key2' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove media file by key', () => {
|
||||
expect(
|
||||
mediaLibrary(
|
||||
@ -64,4 +42,60 @@ describe('mediaLibrary', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should select draft media files when editing a workflow draft', () => {
|
||||
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
|
||||
|
||||
selectEditingWorkflowDraft.mockReturnValue(true);
|
||||
|
||||
const state = {
|
||||
entryDraft: fromJS({ entry: { mediaFiles: [{ id: 1 }] } }),
|
||||
};
|
||||
|
||||
expect(selectMediaFiles(state)).toEqual([{ key: 1, id: 1 }]);
|
||||
});
|
||||
|
||||
it('should select global media files when not editing a workflow draft', () => {
|
||||
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
|
||||
|
||||
selectEditingWorkflowDraft.mockReturnValue(false);
|
||||
|
||||
const state = {
|
||||
mediaLibrary: Map({ files: [{ id: 1 }] }),
|
||||
};
|
||||
|
||||
expect(selectMediaFiles(state)).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it('should select global media files when not using asset store integration', () => {
|
||||
const { selectIntegration } = require('Reducers');
|
||||
|
||||
selectIntegration.mockReturnValue({});
|
||||
|
||||
const state = {
|
||||
mediaLibrary: Map({ files: [{ id: 1 }] }),
|
||||
};
|
||||
|
||||
expect(selectMediaFiles(state)).toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it('should return media file by path', () => {
|
||||
const { selectEditingWorkflowDraft } = require('Reducers/editorialWorkflow');
|
||||
|
||||
selectEditingWorkflowDraft.mockReturnValue(false);
|
||||
|
||||
const state = {
|
||||
mediaLibrary: Map({ files: [{ id: 1, path: 'path' }] }),
|
||||
};
|
||||
|
||||
expect(selectMediaFileByPath(state, 'path')).toEqual({ id: 1, path: 'path' });
|
||||
});
|
||||
|
||||
it('should return media display URL state', () => {
|
||||
const state = {
|
||||
mediaLibrary: fromJS({ displayURLs: { id: { url: 'url' } } }),
|
||||
};
|
||||
|
||||
expect(selectMediaDisplayURL(state, 'id')).toEqual(Map({ url: 'url' }));
|
||||
});
|
||||
});
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { Map } from 'immutable';
|
||||
import { addAssets, addAsset, removeAsset } from 'Actions/media';
|
||||
import reducer from '../medias';
|
||||
|
||||
jest.mock('ValueObjects/AssetProxy');
|
||||
|
||||
describe('medias', () => {
|
||||
it('should add assets', () => {
|
||||
expect(reducer(Map(), addAssets([{ public_path: 'public_path' }]))).toEqual(
|
||||
Map({ public_path: { public_path: 'public_path' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add asset', () => {
|
||||
expect(reducer(Map(), addAsset({ public_path: 'public_path' }))).toEqual(
|
||||
Map({ public_path: { public_path: 'public_path' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove asset', () => {
|
||||
expect(
|
||||
reducer(Map({ public_path: { public_path: 'public_path' } }), removeAsset('public_path')),
|
||||
).toEqual(Map());
|
||||
});
|
||||
});
|
@ -0,0 +1,20 @@
|
||||
import { Map, fromJS } from 'immutable';
|
||||
import { addAssets, addAsset, removeAsset } from '../../actions/media';
|
||||
import reducer from '../medias';
|
||||
import { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||
|
||||
describe('medias', () => {
|
||||
const asset = createAssetProxy({ url: 'url', path: 'path' });
|
||||
|
||||
it('should add assets', () => {
|
||||
expect(reducer(fromJS({}), addAssets([asset]))).toEqual(Map({ path: asset }));
|
||||
});
|
||||
|
||||
it('should add asset', () => {
|
||||
expect(reducer(fromJS({}), addAsset(asset))).toEqual(Map({ path: asset }));
|
||||
});
|
||||
|
||||
it('should remove asset', () => {
|
||||
expect(reducer(fromJS({ path: asset }), removeAsset(asset.path))).toEqual(Map());
|
||||
});
|
||||
});
|
@ -1,172 +0,0 @@
|
||||
import { List } from 'immutable';
|
||||
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 { formatExtensions } from 'Formats/formats';
|
||||
|
||||
const collections = (state = null, action) => {
|
||||
switch (action.type) {
|
||||
case CONFIG_SUCCESS: {
|
||||
const configCollections = action.payload ? action.payload.get('collections') : List();
|
||||
return configCollections
|
||||
.toOrderedMap()
|
||||
.map(collection => {
|
||||
if (collection.has('folder')) {
|
||||
return collection.set('type', FOLDER);
|
||||
}
|
||||
if (collection.has('files')) {
|
||||
return collection.set('type', FILES);
|
||||
}
|
||||
})
|
||||
.mapKeys((key, collection) => collection.get('name'));
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const selectors = {
|
||||
[FOLDER]: {
|
||||
entryExtension(collection) {
|
||||
return (
|
||||
collection.get('extension') ||
|
||||
get(formatExtensions, collection.get('format') || 'frontmatter')
|
||||
).replace(/^\./, '');
|
||||
},
|
||||
fields(collection) {
|
||||
return collection.get('fields');
|
||||
},
|
||||
entryPath(collection, slug) {
|
||||
const folder = collection.get('folder').replace(/\/$/, '');
|
||||
return `${folder}/${slug}.${this.entryExtension(collection)}`;
|
||||
},
|
||||
entrySlug(collection, path) {
|
||||
const folder = collection.get('folder').replace(/\/$/, '');
|
||||
const slug = path
|
||||
.split(folder + '/')
|
||||
.pop()
|
||||
.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
|
||||
|
||||
return slug;
|
||||
},
|
||||
listMethod() {
|
||||
return 'entriesByFolder';
|
||||
},
|
||||
allowNewEntries(collection) {
|
||||
return collection.get('create');
|
||||
},
|
||||
allowDeletion(collection) {
|
||||
return collection.get('delete', true);
|
||||
},
|
||||
templateName(collection) {
|
||||
return collection.get('name');
|
||||
},
|
||||
},
|
||||
[FILES]: {
|
||||
fileForEntry(collection, slug) {
|
||||
const files = collection.get('files');
|
||||
return files && files.filter(f => f.get('name') === slug).get(0);
|
||||
},
|
||||
fields(collection, slug) {
|
||||
const file = this.fileForEntry(collection, slug);
|
||||
return file && file.get('fields');
|
||||
},
|
||||
entryPath(collection, slug) {
|
||||
const file = this.fileForEntry(collection, slug);
|
||||
return file && file.get('file');
|
||||
},
|
||||
entrySlug(collection, path) {
|
||||
const file = collection
|
||||
.get('files')
|
||||
.filter(f => f.get('file') === path)
|
||||
.get(0);
|
||||
return file && file.get('name');
|
||||
},
|
||||
entryLabel(collection, slug) {
|
||||
const path = this.entryPath(collection, slug);
|
||||
const files = collection.get('files');
|
||||
return files && files.find(f => f.get('file') === path).get('label');
|
||||
},
|
||||
listMethod() {
|
||||
return 'entriesByFiles';
|
||||
},
|
||||
allowNewEntries() {
|
||||
return false;
|
||||
},
|
||||
allowDeletion(collection) {
|
||||
return collection.get('delete', false);
|
||||
},
|
||||
templateName(collection, slug) {
|
||||
return slug;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const selectFields = (collection, slug) =>
|
||||
selectors[collection.get('type')].fields(collection, slug);
|
||||
export const selectFolderEntryExtension = collection =>
|
||||
selectors[FOLDER].entryExtension(collection);
|
||||
export const selectFileEntryLabel = (collection, slug) =>
|
||||
selectors[FILES].entryLabel(collection, slug);
|
||||
export const selectEntryPath = (collection, slug) =>
|
||||
selectors[collection.get('type')].entryPath(collection, slug);
|
||||
export const selectEntrySlug = (collection, path) =>
|
||||
selectors[collection.get('type')].entrySlug(collection, path);
|
||||
export const selectListMethod = collection => selectors[collection.get('type')].listMethod();
|
||||
export const selectAllowNewEntries = collection =>
|
||||
selectors[collection.get('type')].allowNewEntries(collection);
|
||||
export const selectAllowDeletion = collection =>
|
||||
selectors[collection.get('type')].allowDeletion(collection);
|
||||
export const selectTemplateName = (collection, slug) =>
|
||||
selectors[collection.get('type')].templateName(collection, slug);
|
||||
export const selectIdentifier = collection => {
|
||||
const identifier = collection.get('identifier_field');
|
||||
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS;
|
||||
const fieldNames = collection.get('fields', []).map(field => field.get('name'));
|
||||
return identifierFields.find(id =>
|
||||
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
|
||||
);
|
||||
};
|
||||
export const selectInferedField = (collection, fieldName) => {
|
||||
if (fieldName === 'title' && collection.get('identifier_field')) {
|
||||
return selectIdentifier(collection);
|
||||
}
|
||||
const inferableField = INFERABLE_FIELDS[fieldName];
|
||||
const fields = collection.get('fields');
|
||||
let field;
|
||||
|
||||
// If collection has no fields or fieldName is not defined within inferables list, return null
|
||||
if (!fields || !inferableField) return null;
|
||||
// Try to return a field of the specified type with one of the synonyms
|
||||
const mainTypeFields = fields
|
||||
.filter(f => f.get('widget', 'string') === inferableField.type)
|
||||
.map(f => f.get('name'));
|
||||
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
|
||||
if (field && field.size > 0) return field.first();
|
||||
|
||||
// Try to return a field for each of the specified secondary types
|
||||
const secondaryTypeFields = fields
|
||||
.filter(f => inferableField.secondaryTypes.indexOf(f.get('widget', 'string')) !== -1)
|
||||
.map(f => f.get('name'));
|
||||
field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f) !== -1);
|
||||
if (field && field.size > 0) return field.first();
|
||||
|
||||
// Try to return the first field of the specified type
|
||||
if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first();
|
||||
|
||||
// Coundn't infer the field. Show error and return null.
|
||||
if (inferableField.showError) {
|
||||
consoleError(
|
||||
`The Field ${fieldName} is missing for the collection “${collection.get('name')}”`,
|
||||
`Netlify CMS tries to infer the entry ${fieldName} automatically, but one couldn't be found for entries of the collection “${collection.get(
|
||||
'name',
|
||||
)}”. Please check your site configuration.`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default collections;
|
195
packages/netlify-cms-core/src/reducers/collections.ts
Normal file
195
packages/netlify-cms-core/src/reducers/collections.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { List } from 'immutable';
|
||||
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 { formatExtensions } from '../formats/formats';
|
||||
import { CollectionsAction, Collection, CollectionFiles, EntryField } from '../types/redux';
|
||||
|
||||
const collections = (state = null, action: CollectionsAction) => {
|
||||
switch (action.type) {
|
||||
case CONFIG_SUCCESS: {
|
||||
const configCollections = action.payload
|
||||
? action.payload.get('collections')
|
||||
: List<Collection>();
|
||||
|
||||
return (
|
||||
configCollections
|
||||
.toOrderedMap()
|
||||
.map(item => {
|
||||
const collection = item as Collection;
|
||||
if (collection.has('folder')) {
|
||||
return collection.set('type', FOLDER);
|
||||
}
|
||||
if (collection.has('files')) {
|
||||
return collection.set('type', FILES);
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// @ts-ignore
|
||||
.mapKeys((key: string, collection: Collection) => collection.get('name'))
|
||||
);
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
enum ListMethod {
|
||||
ENTRIES_BY_FOLDER = 'entriesByFolder',
|
||||
ENTRIES_BY_FILES = 'entriesByFiles',
|
||||
}
|
||||
|
||||
const selectors = {
|
||||
[FOLDER]: {
|
||||
entryExtension(collection: Collection) {
|
||||
return (
|
||||
collection.get('extension') ||
|
||||
get(formatExtensions, collection.get('format') || 'frontmatter')
|
||||
).replace(/^\./, '');
|
||||
},
|
||||
fields(collection: Collection) {
|
||||
return collection.get('fields');
|
||||
},
|
||||
entryPath(collection: Collection, slug: string) {
|
||||
const folder = (collection.get('folder') as string).replace(/\/$/, '');
|
||||
return `${folder}/${slug}.${this.entryExtension(collection)}`;
|
||||
},
|
||||
entrySlug(collection: Collection, path: string) {
|
||||
const folder = (collection.get('folder') as string).replace(/\/$/, '');
|
||||
const slug = path
|
||||
.split(folder + '/')
|
||||
.pop()
|
||||
?.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), '');
|
||||
|
||||
return slug;
|
||||
},
|
||||
listMethod() {
|
||||
return ListMethod.ENTRIES_BY_FOLDER;
|
||||
},
|
||||
allowNewEntries(collection: Collection) {
|
||||
return collection.get('create');
|
||||
},
|
||||
allowDeletion(collection: Collection) {
|
||||
return collection.get('delete', true);
|
||||
},
|
||||
templateName(collection: Collection) {
|
||||
return collection.get('name');
|
||||
},
|
||||
},
|
||||
[FILES]: {
|
||||
fileForEntry(collection: Collection, slug: string) {
|
||||
const files = collection.get('files');
|
||||
return files && files.filter(f => f?.get('name') === slug).get(0);
|
||||
},
|
||||
fields(collection: Collection, slug: string) {
|
||||
const file = this.fileForEntry(collection, slug);
|
||||
return file && file.get('fields');
|
||||
},
|
||||
entryPath(collection: Collection, slug: string) {
|
||||
const file = this.fileForEntry(collection, slug);
|
||||
return file && file.get('file');
|
||||
},
|
||||
entrySlug(collection: Collection, path: string) {
|
||||
const file = (collection.get('files') as CollectionFiles)
|
||||
.filter(f => f?.get('file') === path)
|
||||
.get(0);
|
||||
return file && file.get('name');
|
||||
},
|
||||
entryLabel(collection: Collection, slug: string) {
|
||||
const path = this.entryPath(collection, slug);
|
||||
const files = collection.get('files');
|
||||
return files && files.find(f => f?.get('file') === path).get('label');
|
||||
},
|
||||
listMethod() {
|
||||
return ListMethod.ENTRIES_BY_FILES;
|
||||
},
|
||||
allowNewEntries() {
|
||||
return false;
|
||||
},
|
||||
allowDeletion(collection: Collection) {
|
||||
return collection.get('delete', false);
|
||||
},
|
||||
templateName(collection: Collection, slug: string) {
|
||||
return slug;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const selectFields = (collection: Collection, slug: string) =>
|
||||
selectors[collection.get('type')].fields(collection, slug);
|
||||
export const selectFolderEntryExtension = (collection: Collection) =>
|
||||
selectors[FOLDER].entryExtension(collection);
|
||||
export const selectFileEntryLabel = (collection: Collection, slug: string) =>
|
||||
selectors[FILES].entryLabel(collection, slug);
|
||||
export const selectEntryPath = (collection: Collection, slug: string) =>
|
||||
selectors[collection.get('type')].entryPath(collection, slug);
|
||||
export const selectEntrySlug = (collection: Collection, path: string) =>
|
||||
selectors[collection.get('type')].entrySlug(collection, path);
|
||||
export const selectListMethod = (collection: Collection) =>
|
||||
selectors[collection.get('type')].listMethod();
|
||||
export const selectAllowNewEntries = (collection: Collection) =>
|
||||
selectors[collection.get('type')].allowNewEntries(collection);
|
||||
export const selectAllowDeletion = (collection: Collection) =>
|
||||
selectors[collection.get('type')].allowDeletion(collection);
|
||||
export const selectTemplateName = (collection: Collection, slug: string) =>
|
||||
selectors[collection.get('type')].templateName(collection, slug);
|
||||
export const selectIdentifier = (collection: Collection) => {
|
||||
const identifier = collection.get('identifier_field');
|
||||
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS;
|
||||
const fieldNames = collection.get('fields', List<EntryField>()).map(field => field?.get('name'));
|
||||
return identifierFields.find(id =>
|
||||
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);
|
||||
}
|
||||
const inferableField = (INFERABLE_FIELDS as Record<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
synonyms: string[];
|
||||
secondaryTypes: string[];
|
||||
fallbackToFirstField: boolean;
|
||||
showError: boolean;
|
||||
}
|
||||
>)[fieldName];
|
||||
const fields = collection.get('fields');
|
||||
let field;
|
||||
|
||||
// If collection has no fields or fieldName is not defined within inferables list, return null
|
||||
if (!fields || !inferableField) return null;
|
||||
// Try to return a field of the specified type with one of the synonyms
|
||||
const mainTypeFields = fields
|
||||
.filter(f => f?.get('widget', 'string') === inferableField.type)
|
||||
.map(f => f?.get('name'));
|
||||
field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1);
|
||||
if (field && field.size > 0) return field.first();
|
||||
|
||||
// Try to return a field for each of the specified secondary types
|
||||
const secondaryTypeFields = fields
|
||||
.filter(f => inferableField.secondaryTypes.indexOf(f?.get('widget', 'string') as string) !== -1)
|
||||
.map(f => f?.get('name'));
|
||||
field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1);
|
||||
if (field && field.size > 0) return field.first();
|
||||
|
||||
// Try to return the first field of the specified type
|
||||
if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first();
|
||||
|
||||
// Coundn't infer the field. Show error and return null.
|
||||
if (inferableField.showError) {
|
||||
consoleError(
|
||||
`The Field ${fieldName} is missing for the collection “${collection.get('name')}”`,
|
||||
`Netlify CMS tries to infer the entry ${fieldName} automatically, but one couldn't be found for entries of the collection “${collection.get(
|
||||
'name',
|
||||
)}”. Please check your site configuration.`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default collections;
|
@ -1,7 +1,8 @@
|
||||
import { Map } from 'immutable';
|
||||
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE, CONFIG_MERGE } from 'Actions/config';
|
||||
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE, CONFIG_MERGE } from '../actions/config';
|
||||
import { Config, ConfigAction } from '../types/redux';
|
||||
|
||||
const config = (state = Map({ isFetching: true }), action) => {
|
||||
const config = (state = Map({ isFetching: true }), action: ConfigAction) => {
|
||||
switch (action.type) {
|
||||
case CONFIG_MERGE:
|
||||
return state.mergeDeep(action.payload);
|
||||
@ -21,4 +22,6 @@ const config = (state = Map({ isFetching: true }), action) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const selectLocale = (state: Config) => state.get('locale', 'en') as string;
|
||||
|
||||
export default config;
|
@ -86,7 +86,7 @@ const unpublishedEntries = (state = Map(), action) => {
|
||||
// Update Optimistically
|
||||
return state.deleteIn([
|
||||
'entities',
|
||||
`${action.payload.collection}.${action.payload.entry.get('slug')}`,
|
||||
`${action.payload.collection}.${action.payload.slug}`,
|
||||
'isPersisting',
|
||||
]);
|
||||
|
||||
@ -150,4 +150,11 @@ export const selectUnpublishedSlugs = (state, collection) => {
|
||||
.valueSeq();
|
||||
};
|
||||
|
||||
export const selectEditingWorkflowDraft = state => {
|
||||
const entry = state.entryDraft.get('entry');
|
||||
const useWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
const workflowDraft = entry && !entry.isEmpty() && useWorkflow;
|
||||
return workflowDraft;
|
||||
};
|
||||
|
||||
export default unpublishedEntries;
|
||||
|
@ -1,115 +0,0 @@
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
import {
|
||||
ENTRY_REQUEST,
|
||||
ENTRY_SUCCESS,
|
||||
ENTRY_FAILURE,
|
||||
ENTRIES_REQUEST,
|
||||
ENTRIES_SUCCESS,
|
||||
ENTRIES_FAILURE,
|
||||
ENTRY_DELETE_SUCCESS,
|
||||
} from 'Actions/entries';
|
||||
|
||||
import { SEARCH_ENTRIES_SUCCESS } from 'Actions/search';
|
||||
|
||||
let collection;
|
||||
let loadedEntries;
|
||||
let append;
|
||||
let page;
|
||||
let slug;
|
||||
|
||||
const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
|
||||
switch (action.type) {
|
||||
case ENTRY_REQUEST:
|
||||
return state.setIn(
|
||||
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'],
|
||||
true,
|
||||
);
|
||||
|
||||
case ENTRY_SUCCESS:
|
||||
collection = action.payload.collection;
|
||||
slug = action.payload.entry.slug;
|
||||
return state.withMutations(map => {
|
||||
map.setIn(['entities', `${collection}.${slug}`], fromJS(action.payload.entry));
|
||||
const ids = map.getIn(['pages', collection, 'ids'], List());
|
||||
if (!ids.includes(slug)) {
|
||||
map.setIn(['pages', collection, 'ids'], ids.unshift(slug));
|
||||
}
|
||||
});
|
||||
|
||||
case ENTRIES_REQUEST:
|
||||
return state.setIn(['pages', action.payload.collection, 'isFetching'], true);
|
||||
|
||||
case ENTRIES_SUCCESS:
|
||||
collection = action.payload.collection;
|
||||
loadedEntries = action.payload.entries;
|
||||
append = action.payload.append;
|
||||
page = action.payload.page;
|
||||
return state.withMutations(map => {
|
||||
loadedEntries.forEach(entry =>
|
||||
map.setIn(
|
||||
['entities', `${collection}.${entry.slug}`],
|
||||
fromJS(entry).set('isFetching', false),
|
||||
),
|
||||
);
|
||||
|
||||
const ids = List(loadedEntries.map(entry => entry.slug));
|
||||
map.setIn(
|
||||
['pages', collection],
|
||||
Map({
|
||||
page,
|
||||
ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
case ENTRIES_FAILURE:
|
||||
return state.setIn(['pages', action.meta.collection, 'isFetching'], false);
|
||||
|
||||
case ENTRY_FAILURE:
|
||||
return state.withMutations(map => {
|
||||
map.setIn(
|
||||
['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'],
|
||||
false,
|
||||
);
|
||||
map.setIn(
|
||||
['entities', `${action.payload.collection}.${action.payload.slug}`, 'error'],
|
||||
action.payload.error.message,
|
||||
);
|
||||
});
|
||||
|
||||
case SEARCH_ENTRIES_SUCCESS:
|
||||
loadedEntries = action.payload.entries;
|
||||
return state.withMutations(map => {
|
||||
loadedEntries.forEach(entry =>
|
||||
map.setIn(
|
||||
['entities', `${entry.collection}.${entry.slug}`],
|
||||
fromJS(entry).set('isFetching', false),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
case ENTRY_DELETE_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.deleteIn(['entities', `${action.payload.collectionName}.${action.payload.entrySlug}`]);
|
||||
map.updateIn(['pages', action.payload.collectionName, 'ids'], ids =>
|
||||
ids.filter(id => id !== action.payload.entrySlug),
|
||||
);
|
||||
});
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const selectEntry = (state, collection, slug) =>
|
||||
state.getIn(['entities', `${collection}.${slug}`]);
|
||||
|
||||
export const selectPublishedSlugs = (state, collection) =>
|
||||
state.getIn(['pages', collection, 'ids'], List());
|
||||
|
||||
export const selectEntries = (state, collection) => {
|
||||
const slugs = selectPublishedSlugs(state, collection);
|
||||
return slugs && slugs.map(slug => selectEntry(state, collection, slug));
|
||||
};
|
||||
|
||||
export default entries;
|
200
packages/netlify-cms-core/src/reducers/entries.ts
Normal file
200
packages/netlify-cms-core/src/reducers/entries.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
import { dirname, join } from 'path';
|
||||
import {
|
||||
ENTRY_REQUEST,
|
||||
ENTRY_SUCCESS,
|
||||
ENTRY_FAILURE,
|
||||
ENTRIES_REQUEST,
|
||||
ENTRIES_SUCCESS,
|
||||
ENTRIES_FAILURE,
|
||||
ENTRY_DELETE_SUCCESS,
|
||||
} from '../actions/entries';
|
||||
|
||||
import { SEARCH_ENTRIES_SUCCESS } from '../actions/search';
|
||||
import {
|
||||
EntriesAction,
|
||||
EntryRequestPayload,
|
||||
EntrySuccessPayload,
|
||||
EntriesSuccessPayload,
|
||||
EntryObject,
|
||||
Entries,
|
||||
Config,
|
||||
Collection,
|
||||
EntryFailurePayload,
|
||||
EntryDeletePayload,
|
||||
EntriesRequestPayload,
|
||||
} from '../types/redux';
|
||||
import { isAbsolutePath, basename } from 'netlify-cms-lib-util/src';
|
||||
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
|
||||
|
||||
let collection: string;
|
||||
let loadedEntries: EntryObject[];
|
||||
let append: boolean;
|
||||
let page: number;
|
||||
let slug: string;
|
||||
|
||||
const entries = (state = Map({ entities: Map(), pages: Map() }), action: EntriesAction) => {
|
||||
switch (action.type) {
|
||||
case ENTRY_REQUEST: {
|
||||
const payload = action.payload as EntryRequestPayload;
|
||||
return state.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], true);
|
||||
}
|
||||
|
||||
case ENTRY_SUCCESS: {
|
||||
const payload = action.payload as EntrySuccessPayload;
|
||||
collection = payload.collection;
|
||||
slug = payload.entry.slug;
|
||||
return state.withMutations(map => {
|
||||
map.setIn(['entities', `${collection}.${slug}`], fromJS(payload.entry));
|
||||
const ids = map.getIn(['pages', collection, 'ids'], List());
|
||||
if (!ids.includes(slug)) {
|
||||
map.setIn(['pages', collection, 'ids'], ids.unshift(slug));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
case ENTRIES_REQUEST: {
|
||||
const payload = action.payload as EntriesRequestPayload;
|
||||
return state.setIn(['pages', payload.collection, 'isFetching'], true);
|
||||
}
|
||||
|
||||
case ENTRIES_SUCCESS: {
|
||||
const payload = action.payload as EntriesSuccessPayload;
|
||||
collection = payload.collection;
|
||||
loadedEntries = payload.entries;
|
||||
append = payload.append;
|
||||
page = payload.page;
|
||||
return state.withMutations(map => {
|
||||
loadedEntries.forEach(entry =>
|
||||
map.setIn(
|
||||
['entities', `${collection}.${entry.slug}`],
|
||||
fromJS(entry).set('isFetching', false),
|
||||
),
|
||||
);
|
||||
|
||||
const ids = List(loadedEntries.map(entry => entry.slug));
|
||||
map.setIn(
|
||||
['pages', collection],
|
||||
Map({
|
||||
page,
|
||||
ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
case ENTRIES_FAILURE:
|
||||
return state.setIn(['pages', action.meta.collection, 'isFetching'], false);
|
||||
|
||||
case ENTRY_FAILURE: {
|
||||
const payload = action.payload as EntryFailurePayload;
|
||||
return state.withMutations(map => {
|
||||
map.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], false);
|
||||
map.setIn(
|
||||
['entities', `${payload.collection}.${payload.slug}`, 'error'],
|
||||
payload.error.message,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
case SEARCH_ENTRIES_SUCCESS: {
|
||||
const payload = action.payload as EntriesSuccessPayload;
|
||||
loadedEntries = payload.entries;
|
||||
return state.withMutations(map => {
|
||||
loadedEntries.forEach(entry =>
|
||||
map.setIn(
|
||||
['entities', `${entry.collection}.${entry.slug}`],
|
||||
fromJS(entry).set('isFetching', false),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
case ENTRY_DELETE_SUCCESS: {
|
||||
const payload = action.payload as EntryDeletePayload;
|
||||
return state.withMutations(map => {
|
||||
map.deleteIn(['entities', `${payload.collectionName}.${payload.entrySlug}`]);
|
||||
map.updateIn(['pages', payload.collectionName, 'ids'], (ids: string[]) =>
|
||||
ids.filter(id => id !== payload.entrySlug),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const selectEntry = (state: Entries, collection: string, slug: string) =>
|
||||
state.getIn(['entities', `${collection}.${slug}`]);
|
||||
|
||||
export const selectPublishedSlugs = (state: Entries, collection: string) =>
|
||||
state.getIn(['pages', collection, 'ids'], List<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 DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
|
||||
|
||||
export const selectMediaFolder = (
|
||||
config: Config,
|
||||
collection: Collection | null,
|
||||
entryPath: string | null,
|
||||
) => {
|
||||
let mediaFolder = config.get('media_folder');
|
||||
|
||||
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
if (useWorkflow && collection && collection.has('media_folder')) {
|
||||
if (entryPath) {
|
||||
const entryDir = dirname(entryPath);
|
||||
mediaFolder = join(entryDir, collection.get('media_folder') as string);
|
||||
} else {
|
||||
mediaFolder = join(collection.get('folder') as string, DRAFT_MEDIA_FILES);
|
||||
}
|
||||
}
|
||||
|
||||
return mediaFolder;
|
||||
};
|
||||
|
||||
export const selectMediaFilePath = (
|
||||
config: Config,
|
||||
collection: Collection | null,
|
||||
entryPath: string | null,
|
||||
mediaPath: string,
|
||||
) => {
|
||||
if (isAbsolutePath(mediaPath)) {
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
let mediaFolder;
|
||||
if (mediaPath.startsWith('/')) {
|
||||
// absolute media paths are not bound to a collection
|
||||
mediaFolder = selectMediaFolder(config, null, null);
|
||||
} else {
|
||||
mediaFolder = selectMediaFolder(config, collection, entryPath);
|
||||
}
|
||||
|
||||
return join(mediaFolder, basename(mediaPath));
|
||||
};
|
||||
|
||||
export const selectMediaFilePublicPath = (
|
||||
config: Config,
|
||||
collection: Collection | null,
|
||||
mediaPath: string,
|
||||
) => {
|
||||
if (isAbsolutePath(mediaPath)) {
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
let publicFolder = config.get('public_folder');
|
||||
|
||||
const useWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
|
||||
if (useWorkflow && collection && collection.has('media_folder')) {
|
||||
publicFolder = collection.get('media_folder') as string;
|
||||
}
|
||||
|
||||
return join(publicFolder, basename(mediaPath));
|
||||
};
|
||||
|
||||
export default entries;
|
@ -15,9 +15,7 @@ import {
|
||||
ENTRY_PERSIST_FAILURE,
|
||||
ENTRY_DELETE_SUCCESS,
|
||||
ADD_DRAFT_ENTRY_MEDIA_FILE,
|
||||
SET_DRAFT_ENTRY_MEDIA_FILES,
|
||||
REMOVE_DRAFT_ENTRY_MEDIA_FILE,
|
||||
CLEAR_DRAFT_ENTRY_MEDIA_FILES,
|
||||
} from 'Actions/entries';
|
||||
import {
|
||||
UNPUBLISHED_ENTRY_PERSIST_REQUEST,
|
||||
@ -27,7 +25,6 @@ import {
|
||||
|
||||
const initialState = Map({
|
||||
entry: Map(),
|
||||
mediaFiles: List(),
|
||||
fieldsMetaData: Map(),
|
||||
fieldsErrors: Map(),
|
||||
hasChanged: false,
|
||||
@ -41,7 +38,6 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
return state.withMutations(state => {
|
||||
state.set('entry', action.payload.entry);
|
||||
state.setIn(['entry', 'newRecord'], false);
|
||||
state.set('mediaFiles', action.payload.mediaFiles || List());
|
||||
// An existing entry may already have metadata. If we surfed away and back to its
|
||||
// editor page, the metadata will have been fetched already, so we shouldn't
|
||||
// clear it as to not break relation lists.
|
||||
@ -55,7 +51,6 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
return state.withMutations(state => {
|
||||
state.set('entry', fromJS(action.payload));
|
||||
state.setIn(['entry', 'newRecord'], true);
|
||||
state.set('mediaFiles', List());
|
||||
state.set('fieldsMetaData', Map());
|
||||
state.set('fieldsErrors', Map());
|
||||
state.set('hasChanged', false);
|
||||
@ -69,7 +64,6 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
state.delete('localBackup');
|
||||
state.set('entry', backupEntry);
|
||||
state.setIn(['entry', 'newRecord'], !backupEntry.get('path'));
|
||||
state.set('mediaFiles', backupDraftEntry.get('mediaFiles'));
|
||||
state.set('fieldsMetaData', Map());
|
||||
state.set('fieldsErrors', Map());
|
||||
state.set('hasChanged', true);
|
||||
@ -88,10 +82,9 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
case DRAFT_DISCARD:
|
||||
return initialState;
|
||||
case DRAFT_LOCAL_BACKUP_RETRIEVED: {
|
||||
const { entry, mediaFiles } = action.payload;
|
||||
const { entry } = action.payload;
|
||||
const newState = new Map({
|
||||
entry: fromJS(entry),
|
||||
mediaFiles: List(mediaFiles),
|
||||
});
|
||||
return state.set('localBackup', newState);
|
||||
}
|
||||
@ -139,28 +132,31 @@ const entryDraftReducer = (state = Map(), action) => {
|
||||
state.set('hasChanged', false);
|
||||
});
|
||||
|
||||
case ADD_DRAFT_ENTRY_MEDIA_FILE:
|
||||
if (state.has('mediaFiles')) {
|
||||
return state.update('mediaFiles', list =>
|
||||
list.filterNot(file => file.id === action.payload.id).push({ ...action.payload }),
|
||||
);
|
||||
}
|
||||
return state;
|
||||
case ADD_DRAFT_ENTRY_MEDIA_FILE: {
|
||||
return state.withMutations(state => {
|
||||
const mediaFiles = state.getIn(['entry', 'mediaFiles']);
|
||||
|
||||
case SET_DRAFT_ENTRY_MEDIA_FILES: {
|
||||
return state.set('mediaFiles', List(action.payload));
|
||||
state.setIn(
|
||||
['entry', 'mediaFiles'],
|
||||
mediaFiles
|
||||
.filterNot(file => file.get('id') === action.payload.id)
|
||||
.insert(0, fromJS(action.payload)),
|
||||
);
|
||||
state.set('hasChanged', true);
|
||||
});
|
||||
}
|
||||
|
||||
case REMOVE_DRAFT_ENTRY_MEDIA_FILE:
|
||||
if (state.has('mediaFiles')) {
|
||||
return state.update('mediaFiles', list =>
|
||||
list.filterNot(file => file.id === action.payload.id),
|
||||
);
|
||||
}
|
||||
return state;
|
||||
case REMOVE_DRAFT_ENTRY_MEDIA_FILE: {
|
||||
return state.withMutations(state => {
|
||||
const mediaFiles = state.getIn(['entry', 'mediaFiles']);
|
||||
|
||||
case CLEAR_DRAFT_ENTRY_MEDIA_FILES:
|
||||
return state.set('mediaFiles', List());
|
||||
state.setIn(
|
||||
['entry', 'mediaFiles'],
|
||||
mediaFiles.filterNot(file => file.get('id') === action.payload.id),
|
||||
);
|
||||
state.set('hasChanged', true);
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
|
@ -7,10 +7,12 @@ import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
|
||||
import entryDraft from './entryDraft';
|
||||
import collections from './collections';
|
||||
import search from './search';
|
||||
import medias from './medias';
|
||||
import mediaLibrary from './mediaLibrary';
|
||||
import medias, * as fromMedias from './medias';
|
||||
import deploys, * as fromDeploys from './deploys';
|
||||
import globalUI from './globalUI';
|
||||
import { Status } from '../constants/publishModes';
|
||||
import { State } from '../types/redux';
|
||||
|
||||
const reducers = {
|
||||
auth,
|
||||
@ -22,8 +24,8 @@ const reducers = {
|
||||
cursors,
|
||||
editorialWorkflow,
|
||||
entryDraft,
|
||||
mediaLibrary,
|
||||
medias,
|
||||
mediaLibrary,
|
||||
deploys,
|
||||
globalUI,
|
||||
};
|
||||
@ -33,16 +35,16 @@ export default reducers;
|
||||
/*
|
||||
* Selectors
|
||||
*/
|
||||
export const selectEntry = (state, collection, slug) =>
|
||||
export const selectEntry = (state: State, collection: string, slug: string) =>
|
||||
fromEntries.selectEntry(state.entries, collection, slug);
|
||||
|
||||
export const selectEntries = (state, collection) =>
|
||||
export const selectEntries = (state: State, collection: string) =>
|
||||
fromEntries.selectEntries(state.entries, collection);
|
||||
|
||||
export const selectPublishedSlugs = (state, collection) =>
|
||||
export const selectPublishedSlugs = (state: State, collection: string) =>
|
||||
fromEntries.selectPublishedSlugs(state.entries, collection);
|
||||
|
||||
export const selectSearchedEntries = state => {
|
||||
export const selectSearchedEntries = (state: State) => {
|
||||
const searchItems = state.search.get('entryIds');
|
||||
return (
|
||||
searchItems &&
|
||||
@ -52,27 +54,17 @@ export const selectSearchedEntries = state => {
|
||||
);
|
||||
};
|
||||
|
||||
export const selectDeployPreview = (state, collection, slug) =>
|
||||
export const selectDeployPreview = (state: State, collection: string, slug: string) =>
|
||||
fromDeploys.selectDeployPreview(state.deploys, collection, slug);
|
||||
|
||||
export const selectUnpublishedEntry = (state, collection, slug) =>
|
||||
export const selectUnpublishedEntry = (state: State, collection: string, slug: string) =>
|
||||
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, collection, slug);
|
||||
|
||||
export const selectUnpublishedEntriesByStatus = (state, status) =>
|
||||
export const selectUnpublishedEntriesByStatus = (state: State, status: Status) =>
|
||||
fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status);
|
||||
|
||||
export const selectUnpublishedSlugs = (state, collection) =>
|
||||
export const selectUnpublishedSlugs = (state: State, collection: string) =>
|
||||
fromEditorialWorkflow.selectUnpublishedSlugs(state.editorialWorkflow, collection);
|
||||
|
||||
export const selectIntegration = (state, collection, hook) =>
|
||||
export const selectIntegration = (state: State, collection: string | null, hook: string) =>
|
||||
fromIntegrations.selectIntegration(state.integrations, collection, hook);
|
||||
|
||||
export const getAsset = (state, path) => {
|
||||
/**
|
||||
* If an external media library is in use, just return the path.
|
||||
*/
|
||||
if (state.mediaLibrary.get('externalLibrary')) {
|
||||
return path;
|
||||
}
|
||||
return fromMedias.getAsset(state.config.get('public_folder'), state.medias, path);
|
||||
};
|
@ -1,10 +1,16 @@
|
||||
import { fromJS, List } from 'immutable';
|
||||
import { CONFIG_SUCCESS } from 'Actions/config';
|
||||
import { CONFIG_SUCCESS } from '../actions/config';
|
||||
import { Integrations, IntegrationsAction, Integration } from '../types/redux';
|
||||
|
||||
const integrations = (state = null, action) => {
|
||||
interface Acc {
|
||||
providers: Record<string, {}>;
|
||||
hooks: Record<string, string | Record<string, string>>;
|
||||
}
|
||||
|
||||
const integrations = (state = null, action: IntegrationsAction): Integrations | null => {
|
||||
switch (action.type) {
|
||||
case CONFIG_SUCCESS: {
|
||||
const integrations = action.payload.get('integrations', List()).toJS() || [];
|
||||
const integrations: Integration[] = action.payload.get('integrations', List()).toJS() || [];
|
||||
const newState = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
const { hooks, collections, provider, ...providerData } = integration;
|
||||
@ -17,18 +23,18 @@ const integrations = (state = null, action) => {
|
||||
}
|
||||
const integrationCollections =
|
||||
collections === '*'
|
||||
? action.payload.collections.map(collection => collection.name)
|
||||
: collections;
|
||||
? action.payload.get('collections').map(collection => collection.get('name'))
|
||||
: (collections as string[]);
|
||||
integrationCollections.forEach(collection => {
|
||||
hooks.forEach(hook => {
|
||||
acc.hooks[collection]
|
||||
? (acc.hooks[collection][hook] = provider)
|
||||
? ((acc.hooks[collection] as Record<string, string>)[hook] = provider)
|
||||
: (acc.hooks[collection] = { [hook]: provider });
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{ providers: {}, hooks: {} },
|
||||
{ providers: {}, hooks: {} } as Acc,
|
||||
);
|
||||
return fromJS(newState);
|
||||
}
|
||||
@ -37,7 +43,7 @@ const integrations = (state = null, action) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const selectIntegration = (state, collection, hook) =>
|
||||
export const selectIntegration = (state: Integrations, collection: string | null, hook: string) =>
|
||||
collection
|
||||
? state.getIn(['hooks', collection, hook], false)
|
||||
: state.getIn(['hooks', hook], false);
|
@ -1,6 +1,5 @@
|
||||
import { Map } from 'immutable';
|
||||
import { Map, List } from 'immutable';
|
||||
import uuid from 'uuid/v4';
|
||||
import { differenceBy } from 'lodash';
|
||||
import {
|
||||
MEDIA_LIBRARY_OPEN,
|
||||
MEDIA_LIBRARY_CLOSE,
|
||||
@ -19,8 +18,9 @@ import {
|
||||
MEDIA_DISPLAY_URL_REQUEST,
|
||||
MEDIA_DISPLAY_URL_SUCCESS,
|
||||
MEDIA_DISPLAY_URL_FAILURE,
|
||||
ADD_MEDIA_FILES_TO_LIBRARY,
|
||||
} from 'Actions/mediaLibrary';
|
||||
import { selectEditingWorkflowDraft } from 'Reducers/editorialWorkflow';
|
||||
import { selectIntegration } from 'Reducers';
|
||||
|
||||
const defaultState = {
|
||||
isVisible: false,
|
||||
@ -129,12 +129,6 @@ const mediaLibrary = (state = Map(defaultState), action) => {
|
||||
map.set('isPersisting', false);
|
||||
});
|
||||
}
|
||||
case ADD_MEDIA_FILES_TO_LIBRARY: {
|
||||
const { mediaFiles } = action.payload;
|
||||
let updatedFiles = differenceBy(state.get('files'), mediaFiles, 'path');
|
||||
updatedFiles = [...mediaFiles.map(file => ({ ...file, key: uuid() })), ...updatedFiles];
|
||||
return state.set('files', updatedFiles);
|
||||
}
|
||||
case MEDIA_PERSIST_FAILURE: {
|
||||
const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload;
|
||||
if (privateUploadChanged) {
|
||||
@ -193,4 +187,33 @@ const mediaLibrary = (state = Map(defaultState), action) => {
|
||||
}
|
||||
};
|
||||
|
||||
export function selectMediaFiles(state) {
|
||||
const { mediaLibrary, entryDraft } = state;
|
||||
const workflowDraft = selectEditingWorkflowDraft(state);
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
|
||||
let files;
|
||||
if (workflowDraft && !integration) {
|
||||
files = entryDraft
|
||||
.getIn(['entry', 'mediaFiles'], List())
|
||||
.toJS()
|
||||
.map(file => ({ key: file.id, ...file }));
|
||||
} else {
|
||||
files = mediaLibrary.get('files') || [];
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function selectMediaFileByPath(state, path) {
|
||||
const files = selectMediaFiles(state);
|
||||
const file = files.find(file => file.path === path);
|
||||
return file;
|
||||
}
|
||||
|
||||
export function selectMediaDisplayURL(state, id) {
|
||||
const displayUrlState = state.mediaLibrary.getIn(['displayURLs', id], Map());
|
||||
return displayUrlState;
|
||||
}
|
||||
|
||||
export default mediaLibrary;
|
||||
|
@ -1,41 +0,0 @@
|
||||
import { Map } from 'immutable';
|
||||
import { resolvePath } from 'netlify-cms-lib-util';
|
||||
import { ADD_ASSETS, ADD_ASSET, REMOVE_ASSET } from 'Actions/media';
|
||||
import AssetProxy from 'ValueObjects/AssetProxy';
|
||||
|
||||
const medias = (state = Map(), action) => {
|
||||
switch (action.type) {
|
||||
case ADD_ASSETS: {
|
||||
let newState = state;
|
||||
action.payload.forEach(asset => {
|
||||
newState = newState.set(asset.public_path, asset);
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
case ADD_ASSET:
|
||||
return state.set(action.payload.public_path, action.payload);
|
||||
case REMOVE_ASSET:
|
||||
return state.delete(action.payload);
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default medias;
|
||||
|
||||
const memoizedProxies = {};
|
||||
export const getAsset = (publicFolder, state, path) => {
|
||||
// No path provided, skip
|
||||
if (!path) return null;
|
||||
|
||||
let proxy = state.get(path) || memoizedProxies[path];
|
||||
if (proxy) {
|
||||
// There is already an AssetProxy in memory for this path. Use it.
|
||||
return proxy;
|
||||
}
|
||||
|
||||
// Create a new AssetProxy (for consistency) and return it.
|
||||
proxy = memoizedProxies[path] = new AssetProxy(resolvePath(path, publicFolder), null, true);
|
||||
return proxy;
|
||||
};
|
29
packages/netlify-cms-core/src/reducers/medias.ts
Normal file
29
packages/netlify-cms-core/src/reducers/medias.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { fromJS } from 'immutable';
|
||||
import { ADD_ASSETS, ADD_ASSET, REMOVE_ASSET } from '../actions/media';
|
||||
import AssetProxy from '../valueObjects/AssetProxy';
|
||||
import { Medias, MediasAction } from '../types/redux';
|
||||
|
||||
const medias = (state: Medias = fromJS({}), action: MediasAction) => {
|
||||
switch (action.type) {
|
||||
case ADD_ASSETS: {
|
||||
const payload = action.payload as AssetProxy[];
|
||||
let newState = state;
|
||||
payload.forEach(asset => {
|
||||
newState = newState.set(asset.path, asset);
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
case ADD_ASSET: {
|
||||
const payload = action.payload as AssetProxy;
|
||||
return state.set(payload.path, payload);
|
||||
}
|
||||
case REMOVE_ASSET: {
|
||||
const payload = action.payload as string;
|
||||
return state.delete(payload);
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default medias;
|
@ -1,14 +0,0 @@
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import waitUntilAction from './middleware/waitUntilAction';
|
||||
import reducer from 'Reducers/combinedReducer';
|
||||
|
||||
const store = createStore(
|
||||
reducer,
|
||||
compose(
|
||||
applyMiddleware(thunkMiddleware, waitUntilAction),
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f,
|
||||
),
|
||||
);
|
||||
|
||||
export default store;
|
24
packages/netlify-cms-core/src/redux/index.ts
Normal file
24
packages/netlify-cms-core/src/redux/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { createStore, applyMiddleware, compose, AnyAction } from 'redux';
|
||||
import thunkMiddleware, { ThunkMiddleware } from 'redux-thunk';
|
||||
import { waitUntilAction } from './middleware/waitUntilAction';
|
||||
import reducer from '../reducers/combinedReducer';
|
||||
import { State } from '../types/redux';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__REDUX_DEVTOOLS_EXTENSION__?: Function;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const store = createStore<State, any, {}, {}>(
|
||||
reducer,
|
||||
compose(
|
||||
applyMiddleware(thunkMiddleware as ThunkMiddleware<State, AnyAction>, waitUntilAction),
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__
|
||||
? window.__REDUX_DEVTOOLS_EXTENSION__()
|
||||
: (f: Function): Function => f,
|
||||
),
|
||||
);
|
||||
|
||||
export default store;
|
@ -7,12 +7,27 @@
|
||||
* action coming through the system. Think of it as a thunk that
|
||||
* blocks until the condition is met.
|
||||
*/
|
||||
import { Middleware, MiddlewareAPI, Dispatch, AnyAction } from 'redux';
|
||||
import { State } from '../../types/redux';
|
||||
|
||||
export const WAIT_UNTIL_ACTION = 'WAIT_UNTIL_ACTION';
|
||||
|
||||
export default function waitUntilAction({ dispatch, getState }) {
|
||||
let pending = [];
|
||||
export interface WaitActionArgs {
|
||||
predicate: (action: AnyAction) => boolean;
|
||||
run: (dispatch: Dispatch, getState: () => State, action: AnyAction) => void;
|
||||
}
|
||||
|
||||
function checkPending(action) {
|
||||
interface WaitAction extends WaitActionArgs {
|
||||
type: typeof WAIT_UNTIL_ACTION;
|
||||
}
|
||||
|
||||
export const waitUntilAction: Middleware<{}, State, Dispatch> = ({
|
||||
dispatch,
|
||||
getState,
|
||||
}: MiddlewareAPI<Dispatch, State>) => {
|
||||
let pending: WaitAction[] = [];
|
||||
|
||||
function checkPending(action: AnyAction): void {
|
||||
const readyRequests = [];
|
||||
const stillPending = [];
|
||||
|
||||
@ -35,13 +50,13 @@ export default function waitUntilAction({ dispatch, getState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return next => action => {
|
||||
return (next: Dispatch) => (action: AnyAction): null | AnyAction => {
|
||||
if (action.type === WAIT_UNTIL_ACTION) {
|
||||
pending.push(action);
|
||||
pending.push(action as WaitAction);
|
||||
return null;
|
||||
}
|
||||
const result = next(action);
|
||||
checkPending(action);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
};
|
@ -1 +0,0 @@
|
||||
export const selectLocale = state => state.get('locale', 'en');
|
@ -1,15 +0,0 @@
|
||||
import { slugFormatter } from 'Lib/backendHelper';
|
||||
import { selectEntryPath } from 'Reducers/collections';
|
||||
|
||||
export const selectDraftPath = (state, collection, entry) => {
|
||||
const config = state.config;
|
||||
|
||||
try {
|
||||
// slugFormatter throws in case an identifier is missing from the entry
|
||||
// we can safely ignore this error as this is just a preview path value
|
||||
const slug = slugFormatter(collection, entry.get('data'), config.get('slug'));
|
||||
return selectEntryPath(collection, slug);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
1
packages/netlify-cms-core/src/types/diacritics.d.ts
vendored
Normal file
1
packages/netlify-cms-core/src/types/diacritics.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module 'diacritics';
|
24
packages/netlify-cms-core/src/types/immutable.ts
Normal file
24
packages/netlify-cms-core/src/types/immutable.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export interface StaticallyTypedRecord<T> {
|
||||
get<K extends keyof T>(key: K, defaultValue?: T[K]): T[K];
|
||||
set<K extends keyof T, V extends T[K]>(key: K, value: V): StaticallyTypedRecord<T> & T;
|
||||
has<K extends keyof T>(key: K): boolean;
|
||||
delete<K extends keyof T>(key: K): StaticallyTypedRecord<T>;
|
||||
getIn<K1 extends keyof T, K2 extends keyof T[K1], V extends T[K1][K2]>(
|
||||
keys: [K1, K2],
|
||||
defaultValue?: V,
|
||||
): T[K1][K2];
|
||||
getIn<
|
||||
K1 extends keyof T,
|
||||
K2 extends keyof T[K1],
|
||||
K3 extends keyof T[K1][K2],
|
||||
V extends T[K1][K2][K3]
|
||||
>(
|
||||
keys: [K1, K2, K3],
|
||||
defaultValue?: V,
|
||||
): T[K1][K2][K3];
|
||||
toJS(): T;
|
||||
isEmpty(): boolean;
|
||||
some<K extends keyof T>(predicate: (value: T[K], key: K, iter: this) => boolean): boolean;
|
||||
mapKeys<K extends keyof T, V>(mapFunc: (key: K, value: StaticallyTypedRecord<T>) => V): V[];
|
||||
find<K extends keyof T>(findFunc: (value: T[K]) => boolean): T[K];
|
||||
}
|
1
packages/netlify-cms-core/src/types/redux-notifications.d.ts
vendored
Normal file
1
packages/netlify-cms-core/src/types/redux-notifications.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module 'redux-notifications';
|
1
packages/netlify-cms-core/src/types/redux-optimist.d.ts
vendored
Normal file
1
packages/netlify-cms-core/src/types/redux-optimist.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module 'redux-optimist';
|
263
packages/netlify-cms-core/src/types/redux.ts
Normal file
263
packages/netlify-cms-core/src/types/redux.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { Action } from 'redux';
|
||||
import { StaticallyTypedRecord } from './immutable';
|
||||
import { Map, List } from 'immutable';
|
||||
import AssetProxy from '../valueObjects/AssetProxy';
|
||||
|
||||
export type SlugConfig = StaticallyTypedRecord<{
|
||||
encoding: string;
|
||||
clean_accents: boolean;
|
||||
sanitize_replacement: string;
|
||||
}>;
|
||||
|
||||
type BackendObject = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Backend = StaticallyTypedRecord<Backend> & BackendObject;
|
||||
|
||||
export type Config = StaticallyTypedRecord<{
|
||||
backend: Backend;
|
||||
media_folder: string;
|
||||
public_folder: string;
|
||||
publish_mode?: string;
|
||||
media_library: StaticallyTypedRecord<{ name: string }> & { name: string };
|
||||
locale?: string;
|
||||
slug: SlugConfig;
|
||||
media_folder_relative?: boolean;
|
||||
site_url?: string;
|
||||
show_preview_links?: boolean;
|
||||
}>;
|
||||
|
||||
type PagesObject = {
|
||||
[collection: string]: { isFetching: boolean; page: number; ids: List<string> };
|
||||
};
|
||||
|
||||
type Pages = StaticallyTypedRecord<PagesObject>;
|
||||
|
||||
type EntitiesObject = { [key: string]: EntryMap };
|
||||
|
||||
type Entities = StaticallyTypedRecord<EntitiesObject>;
|
||||
|
||||
export type Entries = StaticallyTypedRecord<{
|
||||
pages: Pages & PagesObject;
|
||||
entities: Entities & EntitiesObject;
|
||||
}>;
|
||||
|
||||
export type Deploys = StaticallyTypedRecord<{}>;
|
||||
|
||||
export type EditorialWorkflow = StaticallyTypedRecord<{}>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type EntryObject = {
|
||||
path: string;
|
||||
slug: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
collection: string;
|
||||
mediaFiles: List<MediaFileMap>;
|
||||
newRecord: boolean;
|
||||
};
|
||||
|
||||
export type EntryMap = StaticallyTypedRecord<EntryObject>;
|
||||
|
||||
export type Entry = EntryMap & EntryObject;
|
||||
|
||||
export type FieldsErrors = StaticallyTypedRecord<{ [field: string]: { type: string }[] }>;
|
||||
|
||||
export type EntryDraft = StaticallyTypedRecord<{
|
||||
entry: Entry;
|
||||
fieldsErrors: FieldsErrors;
|
||||
}>;
|
||||
|
||||
export type EntryField = StaticallyTypedRecord<{
|
||||
field?: EntryField;
|
||||
fields?: List<EntryField>;
|
||||
widget: string;
|
||||
name: string;
|
||||
default: string | null;
|
||||
}>;
|
||||
|
||||
export type EntryFields = List<EntryField>;
|
||||
|
||||
export type FilterRule = StaticallyTypedRecord<{
|
||||
value: string;
|
||||
field: string;
|
||||
}>;
|
||||
|
||||
export type CollectionFile = StaticallyTypedRecord<{
|
||||
file: string;
|
||||
name: string;
|
||||
fields: EntryFields;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
export type CollectionFiles = List<CollectionFile>;
|
||||
|
||||
type CollectionObject = {
|
||||
name: string;
|
||||
folder?: string;
|
||||
files?: CollectionFiles;
|
||||
fields: EntryFields;
|
||||
isFetching: boolean;
|
||||
media_folder?: string;
|
||||
preview_path?: string;
|
||||
preview_path_date_field?: string;
|
||||
summary?: string;
|
||||
filter?: FilterRule;
|
||||
type: 'file_based_collection' | 'folder_based_collection';
|
||||
extension?: string;
|
||||
format?: string;
|
||||
create?: boolean;
|
||||
delete?: boolean;
|
||||
identifier_field?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type Collection = StaticallyTypedRecord<CollectionObject>;
|
||||
|
||||
export type Collections = StaticallyTypedRecord<{ [path: string]: Collection & CollectionObject }>;
|
||||
|
||||
export type Medias = StaticallyTypedRecord<{ [path: string]: AssetProxy | undefined }>;
|
||||
|
||||
interface MediaLibraryInstance {
|
||||
show: (args: {
|
||||
id?: string;
|
||||
value?: string;
|
||||
config: StaticallyTypedRecord<{}>;
|
||||
allowMultiple?: boolean;
|
||||
imagesOnly?: boolean;
|
||||
}) => void;
|
||||
hide: () => void;
|
||||
onClearControl: (args: { id: string }) => void;
|
||||
onRemoveControl: (args: { id: string }) => void;
|
||||
enableStandalone: () => boolean;
|
||||
}
|
||||
|
||||
export type DisplayURL = { id: string; path: string } | string;
|
||||
|
||||
export interface MediaFile {
|
||||
name: string;
|
||||
id: string;
|
||||
size?: number;
|
||||
displayURL?: DisplayURL;
|
||||
path: string;
|
||||
draft?: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type MediaFileMap = StaticallyTypedRecord<MediaFile>;
|
||||
|
||||
export type DisplayURLState = StaticallyTypedRecord<{
|
||||
isFetching: boolean;
|
||||
url?: string;
|
||||
err?: Error;
|
||||
}>;
|
||||
|
||||
interface DisplayURLsObject {
|
||||
[id: string]: DisplayURLState;
|
||||
}
|
||||
|
||||
export type MediaLibrary = StaticallyTypedRecord<{
|
||||
externalLibrary?: MediaLibraryInstance;
|
||||
files: MediaFile[];
|
||||
displayURLs: StaticallyTypedRecord<DisplayURLsObject> & DisplayURLsObject;
|
||||
isLoading: boolean;
|
||||
}>;
|
||||
|
||||
export type Hook = string | boolean;
|
||||
|
||||
export type Integrations = StaticallyTypedRecord<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hooks: { [collectionOrHook: string]: any };
|
||||
}>;
|
||||
|
||||
interface SearchItem {
|
||||
collection: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export type Search = StaticallyTypedRecord<{ entryIds?: SearchItem[] }>;
|
||||
|
||||
export type Cursors = StaticallyTypedRecord<{}>;
|
||||
|
||||
export interface State {
|
||||
config: Config;
|
||||
cursors: Cursors;
|
||||
collections: Collections;
|
||||
deploys: Deploys;
|
||||
editorialWorkflow: EditorialWorkflow;
|
||||
entries: Entries;
|
||||
entryDraft: EntryDraft;
|
||||
integrations: Integrations;
|
||||
medias: Medias;
|
||||
mediaLibrary: MediaLibrary;
|
||||
search: Search;
|
||||
}
|
||||
|
||||
export interface MediasAction extends Action<string> {
|
||||
payload: string | AssetProxy | AssetProxy[];
|
||||
}
|
||||
|
||||
export interface ConfigAction extends Action<string> {
|
||||
payload: Map<string, boolean>;
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
hooks: string[];
|
||||
collections?: string | string[];
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface IntegrationsAction extends Action<string> {
|
||||
payload: StaticallyTypedRecord<{
|
||||
integrations: List<Integration>;
|
||||
collections: StaticallyTypedRecord<{ name: string }>[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface EntryPayload {
|
||||
collection: string;
|
||||
}
|
||||
|
||||
export interface EntryRequestPayload extends EntryPayload {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface EntrySuccessPayload extends EntryPayload {
|
||||
entry: EntryObject;
|
||||
}
|
||||
|
||||
export interface EntryFailurePayload extends EntryPayload {
|
||||
slug: string;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export interface EntryDeletePayload {
|
||||
entrySlug: string;
|
||||
collectionName: string;
|
||||
}
|
||||
|
||||
export type EntriesRequestPayload = EntryPayload;
|
||||
|
||||
export interface EntriesSuccessPayload extends EntryPayload {
|
||||
entries: EntryObject[];
|
||||
append: boolean;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface EntriesAction extends Action<string> {
|
||||
payload:
|
||||
| EntryRequestPayload
|
||||
| EntrySuccessPayload
|
||||
| EntryFailurePayload
|
||||
| EntriesSuccessPayload
|
||||
| EntriesRequestPayload
|
||||
| EntryDeletePayload;
|
||||
meta: {
|
||||
collection: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CollectionsAction extends Action<string> {
|
||||
payload?: StaticallyTypedRecord<{ collections: List<Collection> }>;
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import { resolvePath } from 'netlify-cms-lib-util';
|
||||
import { currentBackend } from 'coreSrc/backend';
|
||||
import store from 'ReduxStore';
|
||||
import { getIntegrationProvider } from 'Integrations';
|
||||
import { selectIntegration } from 'Reducers';
|
||||
|
||||
export default function AssetProxy(value, fileObj, uploaded = false, asset) {
|
||||
const config = store.getState().config;
|
||||
this.value = value;
|
||||
this.fileObj = fileObj;
|
||||
this.uploaded = uploaded;
|
||||
this.sha = null;
|
||||
this.path =
|
||||
config.get('media_folder') && !uploaded
|
||||
? resolvePath(value, config.get('media_folder'))
|
||||
: value;
|
||||
this.public_path = !uploaded ? resolvePath(value, config.get('public_folder')) : value;
|
||||
this.asset = asset;
|
||||
}
|
||||
|
||||
AssetProxy.prototype.toString = function() {
|
||||
// Use the deployed image path if we do not have a locally cached copy.
|
||||
if (this.uploaded && !this.fileObj) return this.public_path;
|
||||
try {
|
||||
return window.URL.createObjectURL(this.fileObj);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
AssetProxy.prototype.toBase64 = function() {
|
||||
return new Promise(resolve => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = readerEvt => {
|
||||
const binaryString = readerEvt.target.result;
|
||||
|
||||
resolve(binaryString.split('base64,')[1]);
|
||||
};
|
||||
fr.readAsDataURL(this.fileObj);
|
||||
});
|
||||
};
|
||||
|
||||
export function createAssetProxy(value, fileObj, uploaded = false, privateUpload = false) {
|
||||
const state = store.getState();
|
||||
const integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration && !uploaded) {
|
||||
const provider =
|
||||
integration &&
|
||||
getIntegrationProvider(
|
||||
state.integrations,
|
||||
currentBackend(state.config).getToken,
|
||||
integration,
|
||||
);
|
||||
return provider.upload(fileObj, privateUpload).then(
|
||||
response =>
|
||||
new AssetProxy(response.asset.url.replace(/^(https?):/, ''), null, true, response.asset),
|
||||
() => new AssetProxy(value, fileObj, false),
|
||||
);
|
||||
} else if (privateUpload) {
|
||||
throw new Error('The Private Upload option is only available for Asset Store Integration');
|
||||
}
|
||||
|
||||
return Promise.resolve(new AssetProxy(value, fileObj, uploaded));
|
||||
}
|
40
packages/netlify-cms-core/src/valueObjects/AssetProxy.ts
Normal file
40
packages/netlify-cms-core/src/valueObjects/AssetProxy.ts
Normal file
@ -0,0 +1,40 @@
|
||||
interface AssetProxyArgs {
|
||||
path: string;
|
||||
url?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export default class AssetProxy {
|
||||
url: string;
|
||||
fileObj?: File;
|
||||
path: string;
|
||||
|
||||
constructor({ url, file, path }: AssetProxyArgs) {
|
||||
this.url = url ? url : window.URL.createObjectURL(file);
|
||||
this.fileObj = file;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
async toBase64(): Promise<string> {
|
||||
const blob = await fetch(this.url).then(response => response.blob());
|
||||
const result = await new Promise<string>(resolve => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = (readerEvt): void => {
|
||||
const binaryString = readerEvt.target?.result || '';
|
||||
|
||||
resolve(binaryString.toString().split('base64,')[1]);
|
||||
};
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAssetProxy({ url, file, path }: AssetProxyArgs): AssetProxy {
|
||||
return new AssetProxy({ url, file, path });
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import { isBoolean } from 'lodash';
|
||||
|
||||
export function createEntry(collection, slug = '', path = '', options = {}) {
|
||||
const returnObj = {};
|
||||
returnObj.collection = collection;
|
||||
returnObj.slug = slug;
|
||||
returnObj.path = path;
|
||||
returnObj.partial = options.partial || false;
|
||||
returnObj.raw = options.raw || '';
|
||||
returnObj.data = options.data || {};
|
||||
returnObj.label = options.label || null;
|
||||
returnObj.metaData = options.metaData || null;
|
||||
returnObj.isModification = isBoolean(options.isModification) ? options.isModification : null;
|
||||
return returnObj;
|
||||
}
|
44
packages/netlify-cms-core/src/valueObjects/Entry.ts
Normal file
44
packages/netlify-cms-core/src/valueObjects/Entry.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { isBoolean } from 'lodash';
|
||||
import { ImplementationMediaFile } from '../backend';
|
||||
|
||||
interface Options {
|
||||
partial?: boolean;
|
||||
raw?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data?: any;
|
||||
label?: string | null;
|
||||
metaData?: unknown | null;
|
||||
isModification?: boolean | null;
|
||||
mediaFiles?: ImplementationMediaFile[] | null;
|
||||
}
|
||||
|
||||
export interface EntryValue {
|
||||
collection: string;
|
||||
slug: string;
|
||||
path: string;
|
||||
partial: boolean;
|
||||
raw: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
label: string | null;
|
||||
metaData: unknown | null;
|
||||
isModification: boolean | null;
|
||||
mediaFiles: ImplementationMediaFile[];
|
||||
}
|
||||
|
||||
export function createEntry(collection: string, slug = '', path = '', options: Options = {}) {
|
||||
const returnObj: EntryValue = {
|
||||
collection,
|
||||
slug,
|
||||
path,
|
||||
partial: options.partial || false,
|
||||
raw: options.raw || '',
|
||||
data: options.data || {},
|
||||
label: options.label || null,
|
||||
metaData: options.metaData || null,
|
||||
isModification: isBoolean(options.isModification) ? options.isModification : null,
|
||||
mediaFiles: options.mediaFiles || [],
|
||||
};
|
||||
|
||||
return returnObj;
|
||||
}
|
Reference in New Issue
Block a user