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:
Erez Rokah
2019-12-18 18:16:02 +02:00
committed by Shawn Erquhart
parent 7e4d4c1cc4
commit 2b41d8a838
231 changed files with 37961 additions and 18373 deletions

View File

@ -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;
}
}

View File

@ -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",

View File

@ -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 = {

View File

@ -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('_');
});
});
});

View File

@ -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,

View File

@ -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' });
});
});
});

View 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);
});
});
});
});

View File

@ -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',
});
});
});
});

View File

@ -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, '/'));
}

View File

@ -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 },

View File

@ -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: {

View File

@ -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 };
}

View 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;
};
}

View File

@ -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 },
};
}

View 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;
}

View File

@ -1,9 +0,0 @@
import { WAIT_UNTIL_ACTION } from '../redux/middleware/waitUntilAction';
export function waitUntil({ predicate, run }) {
return {
type: WAIT_UNTIL_ACTION,
predicate,
run,
};
}

View 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;
};

View File

@ -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));
};
})();

View File

@ -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';

View File

@ -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,

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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;

View File

@ -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} />;
});
};

View File

@ -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)));

View File

@ -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;

View File

@ -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(

View File

@ -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}
/>
);
};

View File

@ -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' }]),
);
});
});

View File

@ -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 = {

View File

@ -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,

View File

@ -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 = {

View File

@ -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';

View File

@ -118,7 +118,7 @@ class Workflow extends Component {
<WorkflowTopDescription>
{t('workflow.workflow.description', {
smart_count: reviewCount,
readyCount: readyCount,
readyCount,
})}
</WorkflowTopDescription>
</WorkflowTop>

View File

@ -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;

View File

@ -5,8 +5,6 @@ import {
frontmatterYAML,
} from '../frontmatter';
jest.mock('../../valueObjects/AssetProxy.js');
describe('Frontmatter', () => {
it('should parse YAML with --- delimiters', () => {
expect(

View File

@ -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;
}
}
}

View File

@ -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');
});

View File

@ -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('_');
});
});

View File

@ -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] : '';
});
}

View File

@ -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;
}

View File

@ -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));
});

View File

@ -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');
});
});

View File

@ -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');
});
});
});

View File

@ -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: '',
});

View File

@ -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',
},
});
});
});

View File

@ -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' }));
});
});

View File

@ -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());
});
});

View File

@ -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());
});
});

View File

@ -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;

View 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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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;

View File

@ -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;

View File

@ -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);
};

View File

@ -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);

View File

@ -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;

View File

@ -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;
};

View 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;

View File

@ -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;

View 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;

View File

@ -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;
};
}
};

View File

@ -1 +0,0 @@
export const selectLocale = state => state.get('locale', 'en');

View File

@ -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 '';
}
};

View File

@ -0,0 +1 @@
declare module 'diacritics';

View 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];
}

View File

@ -0,0 +1 @@
declare module 'redux-notifications';

View File

@ -0,0 +1 @@
declare module 'redux-optimist';

View 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> }>;
}

View File

@ -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));
}

View 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 });
}

View File

@ -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;
}

View 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;
}