This commit is contained in:
2025-08-25 20:24:23 +08:00
parent 30106e0129
commit 0ae8d7a709
1044 changed files with 321581 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
# Docs coming soon!
Decap CMS was converted from a single npm package to a "monorepo" of over 20 packages.
We haven't created a README for this package yet, but you can:
1. Check out the [main readme](https://github.com/decaporg/decap-cms/#readme) or the [documentation
site](https://www.decapcms.org) for more info.
2. Reach out to the [community chat](https://decapcms.org/chat/) if you need help.
3. Help out and [write the readme yourself](https://github.com/decaporg/decap-cms/edit/main/packages/decap-cms-core/README.md)!

View File

@@ -0,0 +1,610 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module 'decap-cms-core' {
import type { ComponentType } from 'react';
import type { List, Map } from 'immutable';
import type { Pluggable } from 'unified';
export type CmsBackendType =
| 'azure'
| 'git-gateway'
| 'github'
| 'gitlab'
| 'gitea'
| 'bitbucket'
| 'test-repo'
| 'proxy';
export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon';
export type CmsMarkdownWidgetButton =
| 'bold'
| 'italic'
| 'code'
| 'link'
| 'heading-one'
| 'heading-two'
| 'heading-three'
| 'heading-four'
| 'heading-five'
| 'heading-six'
| 'quote'
| 'code-block'
| 'bulleted-list'
| 'numbered-list';
export interface CmsSelectWidgetOptionObject {
label: string;
value: any;
}
export type CmsCollectionFormatType = string;
export type CmsAuthScope = 'repo' | 'public_repo';
export type CmsPublishMode = 'simple' | 'editorial_workflow' | '';
export type CmsSlugEncoding = 'unicode' | 'ascii';
export interface CmsI18nConfig {
structure: 'multiple_folders' | 'multiple_files' | 'single_file';
locales: string[];
default_locale?: string;
}
export interface CmsFieldBase {
name: string;
label?: string;
required?: boolean;
hint?: string;
pattern?: [string, string];
i18n?: boolean | 'translate' | 'duplicate' | 'none';
media_folder?: string;
public_folder?: string;
comment?: string;
}
export interface CmsFieldBoolean {
widget: 'boolean';
default?: boolean;
}
export interface CmsFieldCode {
widget: 'code';
default?: any;
default_language?: string;
allow_language_selection?: boolean;
keys?: { code: string; lang: string };
output_code_only?: boolean;
}
export interface CmsFieldColor {
widget: 'color';
default?: string;
allowInput?: boolean;
enableAlpha?: boolean;
}
export interface CmsFieldDateTime {
widget: 'datetime';
default?: string;
format?: string;
date_format?: boolean | string;
time_format?: boolean | string;
picker_utc?: boolean;
/**
* @deprecated Use date_format instead
*/
dateFormat?: boolean | string;
/**
* @deprecated Use time_format instead
*/
timeFormat?: boolean | string;
/**
* @deprecated Use picker_utc instead
*/
pickerUtc?: boolean;
}
export interface CmsFieldFileOrImage {
widget: 'file' | 'image';
default?: string;
media_library?: CmsMediaLibrary;
allow_multiple?: boolean;
choose_url?: boolean;
config?: any;
}
export interface CmsFieldObject {
widget: 'object';
default?: any;
collapsed?: boolean;
summary?: string;
fields: CmsField[];
}
export interface CmsFieldList {
widget: 'list';
default?: any;
allow_add?: boolean;
collapsed?: boolean;
summary?: string;
minimize_collapsed?: boolean;
label_singular?: string;
field?: CmsField;
fields?: CmsField[];
max?: number;
min?: number;
add_to_top?: boolean;
types?: (CmsFieldBase & CmsFieldObject)[];
}
export interface CmsFieldMap {
widget: 'map';
default?: string;
decimals?: number;
type?: CmsMapWidgetType;
}
export interface CmsFieldMarkdown {
widget: 'markdown';
default?: string;
minimal?: boolean;
buttons?: CmsMarkdownWidgetButton[];
editor_components?: string[];
modes?: ('raw' | 'rich_text')[];
/**
* @deprecated Use editor_components instead
*/
editorComponents?: string[];
}
export interface CmsFieldNumber {
widget: 'number';
default?: string | number;
value_type?: 'int' | 'float' | string;
min?: number;
max?: number;
step?: number;
/**
* @deprecated Use valueType instead
*/
valueType?: 'int' | 'float' | string;
}
export interface CmsFieldSelect {
widget: 'select';
default?: string | string[];
options: string[] | CmsSelectWidgetOptionObject[];
multiple?: boolean;
min?: number;
max?: number;
}
export interface CmsFieldRelation {
widget: 'relation';
default?: string | string[];
collection: string;
value_field: string;
search_fields: string[];
file?: string;
display_fields?: string[];
multiple?: boolean;
options_length?: number;
/**
* @deprecated Use value_field instead
*/
valueField?: string;
/**
* @deprecated Use search_fields instead
*/
searchFields?: string[];
/**
* @deprecated Use display_fields instead
*/
displayFields?: string[];
/**
* @deprecated Use options_length instead
*/
optionsLength?: number;
}
export interface CmsFieldHidden {
widget: 'hidden';
default?: any;
}
export interface CmsFieldStringOrText {
// This is the default widget, so declaring its type is optional.
widget?: 'string' | 'text';
default?: string;
visualEditing?: boolean;
}
export interface CmsFieldMeta {
name: string;
label: string;
widget: string;
required: boolean;
index_file: string;
meta: boolean;
}
export type CmsField = CmsFieldBase &
(
| CmsFieldBoolean
| CmsFieldCode
| CmsFieldColor
| CmsFieldDateTime
| CmsFieldFileOrImage
| CmsFieldList
| CmsFieldMap
| CmsFieldMarkdown
| CmsFieldNumber
| CmsFieldObject
| CmsFieldRelation
| CmsFieldSelect
| CmsFieldHidden
| CmsFieldStringOrText
| CmsFieldMeta
);
export interface CmsCollectionFile {
name: string;
label: string;
file: string;
fields: CmsField[];
label_singular?: string;
description?: string;
preview_path?: string;
preview_path_date_field?: string;
i18n?: boolean | CmsI18nConfig;
media_folder?: string;
public_folder?: string;
}
export interface ViewFilter {
label: string;
field: string;
pattern: string;
}
export interface ViewGroup {
label: string;
field: string;
pattern?: string;
}
export interface CmsCollection {
name: string;
label: string;
label_singular?: string;
description?: string;
folder?: string;
files?: CmsCollectionFile[];
identifier_field?: string;
summary?: string;
slug?: string;
preview_path?: string;
preview_path_date_field?: string;
create?: boolean;
delete?: boolean;
hide?: boolean;
editor?: {
preview?: boolean;
visualEditing?: boolean;
};
publish?: boolean;
nested?: {
depth: number;
subfolders?: boolean;
};
meta?: { path?: { label: string; widget: string; index_file: string } };
/**
* It accepts the following values: yml, yaml, toml, json, md, markdown, html
*
* You may also specify a custom extension not included in the list above, by specifying the format value.
*/
extension?: string;
format?: CmsCollectionFormatType;
frontmatter_delimiter?: string[] | string;
fields?: CmsField[];
filter?: { field: string; value: any };
path?: string;
media_folder?: string;
public_folder?: string;
sortable_fields?: string[];
view_filters?: ViewFilter[];
view_groups?: ViewGroup[];
i18n?: boolean | CmsI18nConfig;
/**
* @deprecated Use sortable_fields instead
*/
sortableFields?: string[];
}
export interface CmsBackend {
name: CmsBackendType;
auth_scope?: CmsAuthScope;
open_authoring?: boolean;
always_fork?: boolean;
repo?: string;
branch?: string;
api_root?: string;
site_domain?: string;
base_url?: string;
auth_endpoint?: string;
app_id?: string;
auth_type?: 'implicit' | 'pkce';
cms_label_prefix?: string;
squash_merges?: boolean;
proxy_url?: string;
commit_messages?: {
create?: string;
update?: string;
delete?: string;
uploadMedia?: string;
deleteMedia?: string;
openAuthoring?: string;
};
}
export interface CmsSlug {
encoding?: CmsSlugEncoding;
clean_accents?: boolean;
sanitize_replacement?: string;
}
export interface CmsLocalBackend {
url?: string;
allowed_hosts?: string[];
}
export interface CmsConfig {
backend: CmsBackend;
collections: CmsCollection[];
locale?: string;
site_url?: string;
display_url?: string;
logo_url?: string; // Deprecated, replaced by `logo.src`
logo?: {
src: string;
show_in_header?: boolean;
};
show_preview_links?: boolean;
media_folder?: string;
public_folder?: string;
media_folder_relative?: boolean;
media_library?: CmsMediaLibrary;
publish_mode?: CmsPublishMode;
load_config_file?: boolean;
integrations?: {
hooks: string[];
provider: string;
collections?: '*' | string[];
applicationID?: string;
apiKey?: string;
getSignedFormURL?: string;
}[];
slug?: CmsSlug;
i18n?: CmsI18nConfig;
local_backend?: boolean | CmsLocalBackend;
editor?: {
preview?: boolean;
};
}
export interface InitOptions {
config: CmsConfig;
}
export type EditorComponentField =
| ({
name: string;
label: string;
} & {
widget: Omit<string, 'list'>;
})
| {
widget: 'list';
/**
* Used if widget === "list" to create a flat array
*/
field?: EditorComponentField;
/**
* Used if widget === "list" to create an array of objects
*/
fields?: EditorComponentField[];
};
export interface EditorComponentOptions {
id: string;
label: string;
fields?: EditorComponentField[];
pattern: RegExp;
allow_add?: boolean;
fromBlock: (match: RegExpMatchArray) => any;
toBlock: (data: any) => string;
toPreview: (data: any) => string | JSX.Element;
}
export interface PreviewStyleOptions {
raw: boolean;
}
export interface PreviewStyle extends PreviewStyleOptions {
value: string;
}
export type CmsBackendClass = any; // TODO: type properly
export interface CmsRegistryBackend {
init: (args: any) => CmsBackendClass;
}
export interface CmsWidgetControlProps<T = any> {
value: T;
field: Map<string, any>;
onChange: (value: T) => void;
forID: string;
classNameWrapper: string;
}
export interface CmsWidgetPreviewProps<T = any> {
value: T;
field: Map<string, any>;
metadata: Map<string, any>;
getAsset: GetAssetFunction;
entry: Map<string, any>;
fieldsMetaData: Map<string, any>;
}
export interface CmsWidgetParam<T = any> {
name: string;
controlComponent: CmsWidgetControlProps<T>;
previewComponent?: CmsWidgetPreviewProps<T>;
globalStyles?: any;
}
export interface CmsWidget<T = any> {
control: CmsWidgetControlProps<T>;
preview?: CmsWidgetPreviewProps<T>;
globalStyles?: any;
}
export type CmsWidgetValueSerializer = any; // TODO: type properly
export type CmsMediaLibraryOptions = any; // TODO: type properly
export interface CmsMediaLibrary {
name: string;
config?: CmsMediaLibraryOptions;
}
export interface CmsEventListener {
name: 'prePublish' | 'postPublish' | 'preUnpublish' | 'postUnpublish' | 'preSave' | 'postSave';
handler: ({
entry,
author,
}: {
entry: Map<string, any>;
author: { login: string; name: string };
}) => any;
}
export type CmsEventListenerOptions = any; // TODO: type properly
export type CmsLocalePhrases = any; // TODO: type properly
export type Formatter = {
fromFile(content: string): unknown;
toFile(data: object, sortedKeys?: string[], comments?: Record<string, string>): string;
};
export interface CmsRegistry {
backends: {
[name: string]: CmsRegistryBackend;
};
templates: {
[name: string]: ComponentType<any>;
};
previewStyles: PreviewStyle[];
widgets: {
[name: string]: CmsWidget;
};
editorComponents: Map<string, ComponentType<any>>;
widgetValueSerializers: {
[name: string]: CmsWidgetValueSerializer;
};
mediaLibraries: CmsMediaLibrary[];
locales: {
[name: string]: CmsLocalePhrases;
};
formats: {
[name: string]: Formatter;
};
}
type GetAssetFunction = (asset: string) => {
url: string;
path: string;
field?: any;
fileObj: File;
};
export type PreviewTemplateComponentProps = {
entry: Map<string, any>;
collection: Map<string, any>;
getCollection: (collectionName: string, slug?: string) => Promise<Map<string, any>[]>;
widgetFor: (name: any, fields?: any, values?: any, fieldsMetaData?: any) => JSX.Element | null;
widgetsFor: (name: any) => any;
getAsset: GetAssetFunction;
boundGetAsset: (collection: any, path: any) => GetAssetFunction;
fieldsMetaData: Map<string, any>;
config: Map<string, any>;
fields: List<Map<string, any>>;
isLoadingAsset: boolean;
window: Window;
document: Document;
};
export interface CMS {
getBackend: (name: string) => CmsRegistryBackend | undefined;
getEditorComponents: () => Map<string, ComponentType<any>>;
getRemarkPlugins: () => Array<Pluggable>;
getLocale: (locale: string) => CmsLocalePhrases | undefined;
getMediaLibrary: (name: string) => CmsMediaLibrary | undefined;
getPreviewStyles: () => PreviewStyle[];
getPreviewTemplate: (name: string) => ComponentType<PreviewTemplateComponentProps> | undefined;
getWidget: (name: string) => CmsWidget | undefined;
getWidgetValueSerializer: (widgetName: string) => CmsWidgetValueSerializer | undefined;
init: (options?: InitOptions) => void;
registerBackend: (name: string, backendClass: CmsBackendClass) => void;
registerEditorComponent: (options: EditorComponentOptions) => void;
registerRemarkPlugin: (plugin: Pluggable) => void;
registerEventListener: (
eventListener: CmsEventListener,
options?: CmsEventListenerOptions,
) => void;
registerLocale: (locale: string, phrases: CmsLocalePhrases) => void;
registerMediaLibrary: (mediaLibrary: CmsMediaLibrary, options?: CmsMediaLibraryOptions) => void;
registerPreviewStyle: (filePath: string, options?: PreviewStyleOptions) => void;
registerPreviewTemplate: (
name: string,
component: ComponentType<PreviewTemplateComponentProps>,
) => void;
registerWidget: (
widget: string | CmsWidgetParam,
control?: ComponentType<CmsWidgetControlProps> | string,
preview?: ComponentType<CmsWidgetPreviewProps>,
) => void;
registerWidgetValueSerializer: (
widgetName: string,
serializer: CmsWidgetValueSerializer,
) => void;
resolveWidget: (name: string) => CmsWidget | undefined;
registerCustomFormat: (name: string, extension: string, formatter: Formatter) => void;
}
export const DecapCmsCore: CMS;
export default DecapCmsCore;
}

View File

@@ -0,0 +1,103 @@
{
"name": "decap-cms-core",
"description": "Decap CMS core application, see decap-cms package for the main distribution.",
"version": "3.8.1",
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-core",
"bugs": "https://github.com/decaporg/decap-cms/issues",
"module": "dist/esm/index.js",
"main": "dist/decap-cms-core.js",
"files": [
"src/",
"dist/",
"index.d.ts"
],
"types": "index.d.ts",
"scripts": {
"develop": "npm run build:esm -- --watch",
"webpack": "node --max_old_space_size=4096 ../../node_modules/webpack/bin/webpack.js",
"build": "cross-env NODE_ENV=production run-s webpack",
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
},
"keywords": [
"cms",
"core"
],
"license": "MIT",
"dependencies": {
"@iarna/toml": "2.2.5",
"@reduxjs/toolkit": "^1.9.1",
"@vercel/stega": "^0.1.2",
"buffer": "^6.0.3",
"clean-stack": "^5.2.0",
"copy-text-to-clipboard": "^3.0.0",
"dayjs": "^1.11.10",
"deepmerge": "^4.2.2",
"diacritics": "^1.3.0",
"fuzzy": "^0.1.1",
"gotrue-js": "^0.9.24",
"gray-matter": "^4.0.2",
"history": "^4.7.2",
"immer": "^9.0.0",
"js-base64": "^3.0.0",
"jwt-decode": "^3.0.0",
"node-polyglot": "^2.3.0",
"path-browserify": "^1.0.1",
"prop-types": "^15.7.2",
"react": "^19.1.0",
"react-dnd": "^14.0.0",
"react-dnd-html5-backend": "^14.0.0",
"react-dom": "^19.1.0",
"react-frame-component": "^5.2.1",
"react-immutable-proptypes": "^2.1.0",
"react-is": "16.13.1",
"react-markdown": "^6.0.2",
"react-modal": "^3.8.1",
"react-polyglot": "^0.7.0",
"react-redux": "^7.2.0",
"react-router-dom": "^5.2.0",
"react-scroll-sync": "^0.11.2",
"react-split-pane": "^0.1.85",
"react-toastify": "^9.1.1",
"react-topbar-progress-indicator": "^4.0.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-waypoint": "^10.0.0",
"react-window": "^1.8.5",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.8",
"redux-notifications": "^4.0.1",
"redux-thunk": "^2.3.0",
"remark-gfm": "1.0.0",
"sanitize-filename": "^1.6.1",
"semaphore": "^1.0.5",
"tomlify-j0.4": "^3.0.0-alpha.0",
"url": "^0.11.0",
"url-join": "^4.0.1",
"what-input": "^5.1.4",
"yaml": "^1.8.3"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"decap-cms-editor-component-image": "^3.0.0",
"decap-cms-lib-auth": "^3.0.0",
"decap-cms-lib-util": "^3.0.0",
"decap-cms-lib-widgets": "^3.0.0",
"decap-cms-ui-default": "^3.0.0",
"immutable": "^3.7.6",
"lodash": "^4.17.11",
"prop-types": "^15.7.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-immutable-proptypes": "^2.1.0"
},
"devDependencies": {
"@types/history": "^4.7.8",
"@types/iarna__toml": "^2.0.5",
"@types/redux-mock-store": "^1.0.2",
"@types/url-join": "^4.0.0",
"redux-mock-store": "^1.5.3"
},
"browser": {
"path": "path-browserify"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fromJS } from 'immutable';
import { addAssets } from '../media';
import * as actions from '../editorialWorkflow';
jest.mock('../../backend');
jest.mock('../../valueObjects/AssetProxy');
jest.mock('decap-cms-lib-util');
jest.mock('uuid', () => {
return { v4: jest.fn().mockReturnValue('000000000000000000000') };
});
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('editorialWorkflow actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('loadUnpublishedEntry', () => {
it('should load unpublished entry', () => {
const { currentBackend } = require('../../backend');
const { createAssetProxy } = require('../../valueObjects/AssetProxy');
const assetProxy = { name: 'name', path: 'path' };
const entry = { mediaFiles: [{ file: { name: 'name' }, id: '1', draft: true }] };
const backend = {
unpublishedEntry: jest.fn().mockResolvedValue(entry),
};
const store = mockStore({
config: fromJS({}),
collections: fromJS({
posts: { name: 'posts' },
}),
mediaLibrary: fromJS({
isLoading: false,
}),
editorialWorkflow: fromJS({
pages: { ids: [] },
}),
});
currentBackend.mockReturnValue(backend);
createAssetProxy.mockResolvedValue(assetProxy);
const slug = 'slug';
const collection = store.getState().collections.get('posts');
return store.dispatch(actions.loadUnpublishedEntry(collection, slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(4);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_REQUEST',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[1]).toEqual(addAssets([assetProxy]));
expect(actions[2]).toEqual({
type: 'UNPUBLISHED_ENTRY_SUCCESS',
payload: {
collection: 'posts',
entry: { ...entry, mediaFiles: [{ file: { name: 'name' }, id: '1', draft: true }] },
},
});
expect(actions[3]).toEqual({
type: 'DRAFT_CREATE_FROM_ENTRY',
payload: {
entry,
},
});
});
});
});
describe('publishUnpublishedEntry', () => {
it('should publish unpublished entry and report success', () => {
const { currentBackend } = require('../../backend');
const entry = {};
const backend = {
publishUnpublishedEntry: jest.fn().mockResolvedValue(),
getEntry: jest.fn().mockResolvedValue(entry),
getMedia: jest.fn().mockResolvedValue([]),
};
const store = mockStore({
config: fromJS({}),
integrations: fromJS([]),
mediaLibrary: fromJS({
isLoading: false,
}),
collections: fromJS({
posts: { name: 'posts' },
}),
});
currentBackend.mockReturnValue(backend);
const slug = 'slug';
return store.dispatch(actions.publishUnpublishedEntry('posts', slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(8);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[1]).toEqual({
type: 'MEDIA_LOAD_REQUEST',
payload: {
page: 1,
},
});
expect(actions[2]).toEqual({
type: 'NOTIFICATION_SEND',
payload: {
message: { key: 'ui.toast.entryPublished' },
type: 'success',
dismissAfter: 4000,
},
});
expect(actions[3]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[4]).toEqual({
type: 'MEDIA_LOAD_SUCCESS',
payload: {
files: [],
},
});
expect(actions[5]).toEqual({
type: 'ENTRY_REQUEST',
payload: {
slug,
collection: 'posts',
},
});
expect(actions[6]).toEqual({
type: 'ENTRY_SUCCESS',
payload: {
entry,
collection: 'posts',
},
});
expect(actions[7]).toEqual({
type: 'DRAFT_CREATE_FROM_ENTRY',
payload: {
entry,
},
});
});
});
it('should publish unpublished entry and report error', () => {
const { currentBackend } = require('../../backend');
const error = new Error('failed to publish entry');
const backend = {
publishUnpublishedEntry: jest.fn().mockRejectedValue(error),
};
const store = mockStore({
config: fromJS({}),
collections: fromJS({
posts: { name: 'posts' },
}),
});
currentBackend.mockReturnValue(backend);
const slug = 'slug';
return store.dispatch(actions.publishUnpublishedEntry('posts', slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST',
payload: {
collection: 'posts',
slug,
},
});
expect(actions[1]).toEqual({
type: 'NOTIFICATION_SEND',
payload: {
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
type: 'error',
dismissAfter: 8000,
},
});
expect(actions[2]).toEqual({
type: 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE',
payload: {
collection: 'posts',
slug,
},
});
});
});
});
});

View File

@@ -0,0 +1,575 @@
import { fromJS, Map } from 'immutable';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {
createEmptyDraft,
createEmptyDraftData,
retrieveLocalBackup,
persistLocalBackup,
getMediaAssets,
validateMetaField,
} from '../entries';
import AssetProxy from '../../valueObjects/AssetProxy';
jest.mock('../../backend');
jest.mock('decap-cms-lib-util');
jest.mock('../mediaLibrary');
jest.mock('../../reducers/entries');
jest.mock('../../reducers/entryDraft');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('entries', () => {
describe('createEmptyDraft', () => {
const { currentBackend } = require('../../backend');
const backend = {
processEntry: jest.fn((_state, _collection, entry) => Promise.resolve(entry)),
};
currentBackend.mockReturnValue(backend);
beforeEach(() => {
jest.clearAllMocks();
});
it('should dispatch draft created action', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }],
});
return store.dispatch(createEmptyDraft(collection, '')).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: {},
meta: {},
i18n: {},
isModification: null,
label: null,
mediaFiles: [],
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
it('should populate draft entry from URL param', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }, { name: 'boolean' }],
});
return store.dispatch(createEmptyDraft(collection, '?title=title&boolean=True')).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: { title: 'title', boolean: true },
meta: {},
i18n: {},
isModification: null,
label: null,
mediaFiles: [],
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
it('should html escape URL params', () => {
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
const collection = fromJS({
fields: [{ name: 'title' }],
});
return store
.dispatch(createEmptyDraft(collection, "?title=<script>alert('hello')</script>"))
.then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
payload: {
author: '',
collection: undefined,
data: { title: '&lt;script&gt;alert(&#039;hello&#039;)&lt;/script&gt;' },
meta: {},
i18n: {},
isModification: null,
label: null,
mediaFiles: [],
partial: false,
path: '',
raw: '',
slug: '',
status: '',
updatedOn: '',
},
type: 'DRAFT_CREATE_EMPTY',
});
});
});
});
describe('createEmptyDraftData', () => {
it('should allow an empty array as list default for a single field list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [],
field: { name: 'url', widget: 'text' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({ images: fromJS([]) });
});
it('should allow a complex array as list default for a single field list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [
{
url: 'https://image.png',
},
],
field: { name: 'url', widget: 'text' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({
images: fromJS([
{
url: 'https://image.png',
},
]),
});
});
it('should allow an empty array as list default for a fields list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [],
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({ images: fromJS([]) });
});
it('should allow a complex array as list default for a fields list', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
default: [
{
title: 'default image',
url: 'https://image.png',
},
],
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({
images: fromJS([
{
title: 'default image',
url: 'https://image.png',
},
]),
});
});
it('should use field default when no list default is provided', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
field: { name: 'url', widget: 'text', default: 'https://image.png' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({ images: [{ url: 'https://image.png' }] });
});
it('should use fields default when no list default is provided', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
fields: [
{ name: 'title', widget: 'text', default: 'default image' },
{ name: 'url', widget: 'text', default: 'https://image.png' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({
images: [{ title: 'default image', url: 'https://image.png' }],
});
});
it('should not set empty value for list fields widget', () => {
const fields = fromJS([
{
name: 'images',
widget: 'list',
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({});
});
it('should set default value for object field widget', () => {
const fields = fromJS([
{
name: 'post',
widget: 'object',
field: { name: 'image', widget: 'text', default: 'https://image.png' },
},
]);
expect(createEmptyDraftData(fields)).toEqual({ post: { image: 'https://image.png' } });
});
it('should set default values for object fields widget', () => {
const fields = fromJS([
{
name: 'post',
widget: 'object',
fields: [
{ name: 'title', widget: 'text', default: 'default title' },
{ name: 'url', widget: 'text', default: 'https://image.png' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({
post: { title: 'default title', url: 'https://image.png' },
});
});
it('should not set empty value for object fields widget', () => {
const fields = fromJS([
{
name: 'post',
widget: 'object',
fields: [
{ name: 'title', widget: 'text' },
{ name: 'url', widget: 'text' },
],
},
]);
expect(createEmptyDraftData(fields)).toEqual({});
});
it('should populate nested fields', () => {
const fields = fromJS([
{
name: 'names',
widget: 'list',
field: {
name: 'object',
widget: 'object',
fields: [
{ name: 'first', widget: 'string', default: 'first' },
{ name: 'second', widget: 'string', default: 'second' },
],
},
},
]);
expect(createEmptyDraftData(fields)).toEqual({
names: [{ object: { first: 'first', second: 'second' } }],
});
});
});
describe('persistLocalBackup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should persist local backup with media files', () => {
const { currentBackend } = require('../../backend');
const backend = {
persistLocalDraftBackup: jest.fn(() => Promise.resolve()),
};
const store = mockStore({
config: Map(),
});
currentBackend.mockReturnValue(backend);
const collection = Map();
const mediaFiles = [{ path: 'static/media/image.png' }];
const entry = fromJS({ mediaFiles });
return store.dispatch(persistLocalBackup(entry, collection)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(0);
expect(backend.persistLocalDraftBackup).toHaveBeenCalledTimes(1);
expect(backend.persistLocalDraftBackup).toHaveBeenCalledWith(entry, collection);
});
});
});
describe('retrieveLocalBackup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should retrieve media files with local backup', () => {
const { currentBackend } = require('../../backend');
const { createAssetProxy } = require('../../valueObjects/AssetProxy');
const backend = {
getLocalDraftBackup: jest.fn((...args) => args),
};
const store = mockStore({
config: Map(),
});
currentBackend.mockReturnValue(backend);
const collection = Map({
name: 'collection',
});
const slug = 'slug';
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 });
return store.dispatch(retrieveLocalBackup(collection, slug)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'ADD_ASSETS',
payload: [asset],
});
expect(actions[1]).toEqual({
type: 'DRAFT_LOCAL_BACKUP_RETRIEVED',
payload: { entry },
});
});
});
});
describe('getMediaAssets', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should map mediaFiles to assets', () => {
const mediaFiles = fromJS([{ path: 'path1' }, { path: 'path2', draft: true }]);
const entry = Map({ mediaFiles });
expect(getMediaAssets({ entry })).toEqual([new AssetProxy({ path: 'path2' })]);
});
});
describe('validateMetaField', () => {
const state = {
config: {
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
},
entries: fromJS([]),
};
const collection = fromJS({
folder: 'folder',
type: 'folder_based_collection',
name: 'name',
});
const t = jest.fn((key, args) => ({ key, args }));
const { selectCustomPath } = require('../../reducers/entryDraft');
const { selectEntryByPath } = require('../../reducers/entries');
beforeEach(() => {
jest.clearAllMocks();
});
it('should not return error on non meta field', () => {
expect(validateMetaField(null, null, fromJS({}), null, t)).toEqual({ error: false });
});
it('should not return error on meta path field', () => {
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'other' }), null, t)).toEqual(
{ error: false },
);
});
it('should return error on empty path', () => {
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), null, t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: null },
},
type: 'CUSTOM',
},
});
expect(
validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), undefined, t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: undefined },
},
type: 'CUSTOM',
},
});
expect(validateMetaField(null, null, fromJS({ meta: true, name: 'path' }), '', t)).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: '' },
},
type: 'CUSTOM',
},
});
});
it('should return error on invalid path', () => {
expect(
validateMetaField(state, null, fromJS({ meta: true, name: 'path' }), 'invalid path', t),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.invalidPath',
args: { path: 'invalid path' },
},
type: 'CUSTOM',
},
});
});
it('should return error on existing path', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: {
message: {
key: 'editor.editorControlPane.widget.pathExists',
args: { path: 'existing-path' },
},
type: 'CUSTOM',
},
});
expect(selectCustomPath).toHaveBeenCalledTimes(1);
expect(selectCustomPath).toHaveBeenCalledWith(
collection,
fromJS({ entry: { meta: { path: 'existing-path' } } }),
);
expect(selectEntryByPath).toHaveBeenCalledTimes(1);
expect(selectEntryByPath).toHaveBeenCalledWith(
state.entries,
collection.get('name'),
'existing-path',
);
});
it('should not return error on non existing path for new entry', () => {
selectCustomPath.mockReturnValue('non-existing-path');
selectEntryByPath.mockReturnValue(undefined);
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: {},
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'non-existing-path',
t,
),
).toEqual({
error: false,
});
});
it('should not return error when for existing entry', () => {
selectCustomPath.mockReturnValue('existing-path');
selectEntryByPath.mockReturnValue(fromJS({ path: 'existing-path' }));
expect(
validateMetaField(
{
...state,
entryDraft: fromJS({
entry: { path: 'existing-path' },
}),
},
collection,
fromJS({ meta: true, name: 'path' }),
'existing-path',
t,
),
).toEqual({
error: false,
});
});
});
});

View File

@@ -0,0 +1,171 @@
import { Map } from 'immutable';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { mocked } from 'jest-mock';
import { getAsset, ADD_ASSET, LOAD_ASSET_REQUEST } from '../media';
import { selectMediaFilePath } from '../../reducers/entries';
import AssetProxy from '../../valueObjects/AssetProxy';
import type { State } from '../../types/redux';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
const middlewares = [thunk];
const mockStore = configureMockStore<Partial<State>, ThunkDispatch<State, {}, AnyAction>>(
middlewares,
);
const mockedSelectMediaFilePath = mocked(selectMediaFilePath);
jest.mock('../../reducers/entries');
jest.mock('../mediaLibrary');
describe('media', () => {
const emptyAsset = new AssetProxy({
path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
describe('getAsset', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.URL = { createObjectURL: jest.fn() };
beforeEach(() => {
jest.resetAllMocks();
});
it('should return empty asset for null path', () => {
const store = mockStore({});
const payload = { collection: null, entryPath: null, entry: null, path: null };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(0);
expect(result).toEqual(emptyAsset);
});
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({
// TODO change to proper store data when immutable is removed
// from 'config' state slice
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
config: Map(),
medias: {
[path]: { asset, isLoading: false, error: null },
},
});
mockedSelectMediaFilePath.mockReturnValue(path);
const payload = { collection: Map(), entry: Map({ path: 'entryPath' }), path };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(0);
expect(result).toBe(asset);
expect(mockedSelectMediaFilePath).toHaveBeenCalledTimes(1);
expect(mockedSelectMediaFilePath).toHaveBeenCalledWith(
store.getState().config,
payload.collection,
payload.entry,
path,
undefined,
);
});
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: {},
});
mockedSelectMediaFilePath.mockReturnValue(path);
const payload = { collection: null, entryPath: null, path };
// TODO change to proper payload when immutable is removed
// from 'collections' state slice
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: ADD_ASSET,
payload: asset,
});
expect(result).toEqual(asset);
});
it('should return empty asset and initiate load when not in medias state', () => {
const path = 'static/media/image.png';
const store = mockStore({
medias: {},
});
mockedSelectMediaFilePath.mockReturnValue(path);
const payload = { path };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: LOAD_ASSET_REQUEST,
payload: { path },
});
expect(result).toEqual(emptyAsset);
});
it('should return asset with original path on load error', () => {
const path = 'static/media/image.png';
const resolvePath = 'resolvePath';
const store = mockStore({
medias: {
[resolvePath]: {
asset: undefined,
error: new Error('test'),
isLoading: false,
},
},
});
mockedSelectMediaFilePath.mockReturnValue(resolvePath);
const payload = { path };
// TODO change to proper payload when immutable is removed
// from 'collections' and 'entries' state slices
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = store.dispatch(getAsset(payload));
const actions = store.getActions();
const asset = new AssetProxy({ url: path, path: resolvePath });
expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
type: ADD_ASSET,
payload: asset,
});
expect(result).toEqual(asset);
});
});
});

View File

@@ -0,0 +1,327 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { List, Map } from 'immutable';
import { insertMedia, persistMedia, deleteMedia } from '../mediaLibrary';
jest.mock('../../backend');
jest.mock('../waitUntil');
jest.mock('decap-cms-lib-util', () => {
const lib = jest.requireActual('decap-cms-lib-util');
return {
...lib,
getBlobSHA: jest.fn(),
};
});
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('mediaLibrary', () => {
describe('insertMedia', () => {
it('should return mediaPath as string when string is given', () => {
const store = mockStore({
config: {
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: '/media/foo.png' },
});
});
it('should return mediaPath as array of strings when array of strings is given', () => {
const store = mockStore({
config: {
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: ['/media/foo.png'] },
});
});
});
const { currentBackend } = require('../../backend');
const backend = {
persistMedia: jest.fn(() => ({ id: 'id' })),
deleteMedia: jest.fn(),
};
currentBackend.mockReturnValue(backend);
describe('persistMedia', () => {
global.URL = { createObjectURL: jest.fn().mockReturnValue('displayURL') };
beforeEach(() => {
jest.clearAllMocks();
});
it('should not persist media when editing draft', () => {
const { getBlobSHA } = require('decap-cms-lib-util');
getBlobSHA.mockReturnValue('000000000000000');
const store = mockStore({
config: {
media_folder: 'static/media',
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false, collection: 'posts' }),
}),
});
const file = new File([''], 'name.png');
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0].type).toEqual('ADD_ASSET');
expect(actions[0].payload).toEqual(
expect.objectContaining({
path: 'static/media/name.png',
}),
);
expect(actions[1].type).toEqual('ADD_DRAFT_ENTRY_MEDIA_FILE');
expect(actions[1].payload).toEqual(
expect.objectContaining({
draft: true,
id: '000000000000000',
path: 'static/media/name.png',
size: file.size,
name: file.name,
}),
);
expect(getBlobSHA).toHaveBeenCalledTimes(1);
expect(getBlobSHA).toHaveBeenCalledWith(file);
expect(backend.persistMedia).toHaveBeenCalledTimes(0);
});
});
it('should persist media when not editing draft', () => {
const store = mockStore({
config: {
media_folder: 'static/media',
slug: {
encoding: 'unicode',
clean_accents: false,
sanitize_replacement: '-',
},
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map(),
}),
});
const file = new File([''], 'name.png');
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].type).toEqual('ADD_ASSET');
expect(actions[1].payload).toEqual(
expect.objectContaining({
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,
expect.objectContaining({
path: 'static/media/name.png',
}),
);
});
});
it('should sanitize media name if needed when persisting', () => {
const store = mockStore({
config: {
media_folder: 'static/media',
slug: {
encoding: 'ascii',
clean_accents: true,
sanitize_replacement: '_',
},
},
collections: Map({
posts: Map({ name: 'posts' }),
}),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map(),
}),
});
const file = new File([''], 'abc DEF éâçÖ $;, .png');
return store.dispatch(persistMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(3);
expect(actions[0]).toEqual({ type: 'MEDIA_PERSIST_REQUEST' });
expect(actions[1].type).toEqual('ADD_ASSET');
expect(actions[1].payload).toEqual(
expect.objectContaining({
path: 'static/media/abc_def_eaco_.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,
expect.objectContaining({
path: 'static/media/abc_def_eaco_.png',
}),
);
});
});
});
describe('deleteMedia', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should delete non draft file', () => {
const store = mockStore({
config: {
publish_mode: 'editorial_workflow',
},
collections: Map(),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: false };
return store.dispatch(deleteMedia(file)).then(() => {
const actions = store.getActions();
expect(actions).toHaveLength(4);
expect(actions[0]).toEqual({ type: 'MEDIA_DELETE_REQUEST' });
expect(actions[1]).toEqual({
type: 'REMOVE_ASSET',
payload: 'static/media/name.png',
});
expect(actions[2]).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(
store.getState().config,
'static/media/name.png',
);
});
});
it('should not delete a draft file', () => {
const store = mockStore({
config: {
publish_mode: 'editorial_workflow',
},
collections: Map(),
integrations: Map(),
mediaLibrary: Map({
files: List(),
}),
entryDraft: Map({
entry: Map({ isPersisting: false }),
}),
});
const file = { name: 'name.png', id: 'id', path: 'static/media/name.png', draft: true };
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);
});
});
});
});

View File

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

View File

@@ -0,0 +1,127 @@
import { currentBackend } from '../backend';
import { addNotification, clearNotifications } from './notifications';
import type { Credentials, User } from 'decap-cms-lib-util';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { State } from '../types/redux';
export const AUTH_REQUEST = 'AUTH_REQUEST';
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
export const AUTH_FAILURE = 'AUTH_FAILURE';
export const AUTH_REQUEST_DONE = 'AUTH_REQUEST_DONE';
export const USE_OPEN_AUTHORING = 'USE_OPEN_AUTHORING';
export const LOGOUT = 'LOGOUT';
export function authenticating() {
return {
type: AUTH_REQUEST,
} as const;
}
export function authenticate(userData: User) {
return {
type: AUTH_SUCCESS,
payload: userData,
} as const;
}
export function authError(error: Error) {
return {
type: AUTH_FAILURE,
error: 'Failed to authenticate',
payload: error,
} as const;
}
export function doneAuthenticating() {
return {
type: AUTH_REQUEST_DONE,
} as const;
}
export function useOpenAuthoring() {
return {
type: USE_OPEN_AUTHORING,
} as const;
}
export function logout() {
return {
type: LOGOUT,
} as const;
}
// Check if user data token is cached and is valid
export function authenticateUser() {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(authenticating());
return Promise.resolve(backend.currentUser())
.then(user => {
if (user) {
if (user.useOpenAuthoring) {
dispatch(useOpenAuthoring());
}
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
}
})
.catch((error: Error) => {
dispatch(authError(error));
dispatch(logoutUser());
});
};
}
export function loginUser(credentials: Credentials) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(authenticating());
return backend
.authenticate(credentials)
.then(user => {
if (user.useOpenAuthoring) {
dispatch(useOpenAuthoring());
}
dispatch(authenticate(user));
})
.catch((error: Error) => {
console.error(error);
dispatch(
addNotification({
message: {
details: error.message,
key: 'ui.toast.onFailToAuth',
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(authError(error));
});
};
}
export function logoutUser() {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
Promise.resolve(backend.logout()).then(() => {
dispatch(logout());
dispatch(clearNotifications());
});
};
}
export type AuthAction = ReturnType<
| typeof authenticating
| typeof authenticate
| typeof authError
| typeof doneAuthenticating
| typeof logout
>;

View File

@@ -0,0 +1,18 @@
import { history } from '../routing/history';
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
export function searchCollections(query: string, collection: string) {
if (collection) {
history.push(`/collections/${collection}/search/${query}`);
} else {
history.push(`/search/${query}`);
}
}
export function showCollection(collectionName: string) {
history.push(getCollectionUrl(collectionName));
}
export function createNewEntry(collectionName: string) {
history.push(getNewEntryUrl(collectionName));
}

View File

@@ -0,0 +1,540 @@
import yaml from 'yaml';
import { fromJS } from 'immutable';
import deepmerge from 'deepmerge';
import { produce } from 'immer';
import trimStart from 'lodash/trimStart';
import trim from 'lodash/trim';
import isEmpty from 'lodash/isEmpty';
import { SIMPLE as SIMPLE_PUBLISH_MODE } from '../constants/publishModes';
import { validateConfig } from '../constants/configSchema';
import { selectDefaultSortableFields } from '../reducers/collections';
import { getIntegrations, selectIntegration } from '../reducers/integrations';
import { resolveBackend } from '../backend';
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
import { FILES, FOLDER } from '../constants/collectionTypes';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type {
CmsCollection,
CmsConfig,
CmsField,
CmsFieldBase,
CmsFieldObject,
CmsFieldList,
CmsI18nConfig,
CmsPublishMode,
CmsLocalBackend,
State,
} from '../types/redux';
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
function isObjectField(field: CmsField): field is CmsFieldBase & CmsFieldObject {
return 'fields' in (field as CmsFieldObject);
}
function isFieldList(field: CmsField): field is CmsFieldBase & CmsFieldList {
return 'types' in (field as CmsFieldList) || 'field' in (field as CmsFieldList);
}
function traverseFieldsJS<Field extends CmsField>(
fields: Field[],
updater: <T extends CmsField>(field: T) => T,
): Field[] {
return fields.map(field => {
const newField = updater(field);
if (isObjectField(newField)) {
return { ...newField, fields: traverseFieldsJS(newField.fields, updater) };
} else if (isFieldList(newField) && newField.field) {
return { ...newField, field: traverseFieldsJS([newField.field], updater)[0] };
} else if (isFieldList(newField) && newField.types) {
return { ...newField, types: traverseFieldsJS(newField.types, updater) };
}
return newField;
});
}
function getConfigUrl() {
const validTypes: { [type: string]: string } = {
'text/yaml': 'yaml',
'application/x-yaml': 'yaml',
};
const configLinkEl = document.querySelector<HTMLLinkElement>('link[rel="cms-config-url"]');
if (configLinkEl && validTypes[configLinkEl.type] && configLinkEl.href) {
console.log(`Using config file path: "${configLinkEl.href}"`);
return configLinkEl.href;
}
return 'config.yml';
}
function setDefaultPublicFolderForField<T extends CmsField>(field: T) {
if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder };
}
return field;
}
// Mapping between existing camelCase and its snake_case counterpart
const WIDGET_KEY_MAP = {
dateFormat: 'date_format',
timeFormat: 'time_format',
pickerUtc: 'picker_utc',
editorComponents: 'editor_components',
valueType: 'value_type',
valueField: 'value_field',
searchFields: 'search_fields',
displayFields: 'display_fields',
optionsLength: 'options_length',
} as const;
function setSnakeCaseConfig<T extends CmsField>(field: T) {
const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter(
camel => camel in field,
) as ReadonlyArray<keyof typeof WIDGET_KEY_MAP>;
const snakeValues = deprecatedKeys.map(camel => {
const snake = WIDGET_KEY_MAP[camel];
console.warn(
`Field ${field.name} is using a deprecated configuration '${camel}'. Please use '${snake}'`,
);
return { [snake]: (field as unknown as Record<string, unknown>)[camel] };
});
return Object.assign({}, field, ...snakeValues) as T;
}
function setI18nField<T extends CmsField>(field: T) {
if (field[I18N] === true) {
return { ...field, [I18N]: I18N_FIELD.TRANSLATE };
} else if (field[I18N] === false || !field[I18N]) {
return { ...field, [I18N]: I18N_FIELD.NONE };
}
return field;
}
function getI18nDefaults(
collectionOrFileI18n: boolean | CmsI18nConfig,
defaultI18n: CmsI18nConfig,
) {
if (typeof collectionOrFileI18n === 'boolean') {
return defaultI18n;
} else {
const locales = collectionOrFileI18n.locales || defaultI18n.locales;
const defaultLocale = collectionOrFileI18n.default_locale || locales[0];
const mergedI18n: CmsI18nConfig = deepmerge(defaultI18n, collectionOrFileI18n);
mergedI18n.locales = locales;
mergedI18n.default_locale = defaultLocale;
throwOnMissingDefaultLocale(mergedI18n);
return mergedI18n;
}
}
function setI18nDefaultsForFields(collectionOrFileFields: CmsField[], hasI18n: boolean) {
if (hasI18n) {
return traverseFieldsJS(collectionOrFileFields, setI18nField);
} else {
return traverseFieldsJS(collectionOrFileFields, field => {
const newField = { ...field };
delete newField[I18N];
return newField;
});
}
}
function throwOnInvalidFileCollectionStructure(i18n?: CmsI18nConfig) {
if (i18n && i18n.structure !== I18N_STRUCTURE.SINGLE_FILE) {
throw new Error(
`i18n configuration for files collections is limited to ${I18N_STRUCTURE.SINGLE_FILE} structure`,
);
}
}
function throwOnMissingDefaultLocale(i18n?: CmsI18nConfig) {
if (i18n && i18n.default_locale && !i18n.locales.includes(i18n.default_locale)) {
throw new Error(
`i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${
i18n.default_locale
}`,
);
}
}
function hasIntegration(config: CmsConfig, collection: CmsCollection) {
// TODO remove fromJS when Immutable is removed from the integrations state slice
const integrations = getIntegrations(fromJS(config));
const integration = selectIntegration(integrations, collection.name, 'listEntries');
return !!integration;
}
export function normalizeConfig(config: CmsConfig) {
const { collections = [] } = config;
const normalizedCollections = collections.map(collection => {
const { fields, files } = collection;
let normalizedCollection = collection;
if (fields) {
const normalizedFields = traverseFieldsJS(fields, setSnakeCaseConfig);
normalizedCollection = { ...normalizedCollection, fields: normalizedFields };
}
if (files) {
const normalizedFiles = files.map(file => {
const normalizedFileFields = traverseFieldsJS(file.fields, setSnakeCaseConfig);
return { ...file, fields: normalizedFileFields };
});
normalizedCollection = { ...normalizedCollection, files: normalizedFiles };
}
if (normalizedCollection.sortableFields) {
const { sortableFields, ...rest } = normalizedCollection;
normalizedCollection = { ...rest, sortable_fields: sortableFields };
console.warn(
`Collection ${collection.name} is using a deprecated configuration 'sortableFields'. Please use 'sortable_fields'`,
);
}
return normalizedCollection;
});
return { ...config, collections: normalizedCollections };
}
export function applyDefaults(originalConfig: CmsConfig) {
return produce(originalConfig, config => {
config.publish_mode = config.publish_mode || SIMPLE_PUBLISH_MODE;
config.slug = config.slug || {};
config.collections = config.collections || [];
// Use `site_url` as default `display_url`.
if (!config.display_url && config.site_url) {
config.display_url = config.site_url;
}
// Use media_folder as default public_folder.
const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`;
if (!('public_folder' in config)) {
config.public_folder = defaultPublicFolder;
}
// default values for the slug config
if (!('encoding' in config.slug)) {
config.slug.encoding = 'unicode';
}
if (!('clean_accents' in config.slug)) {
config.slug.clean_accents = false;
}
if (!('sanitize_replacement' in config.slug)) {
config.slug.sanitize_replacement = '-';
}
const i18n = config[I18N];
if (i18n) {
i18n.default_locale = i18n.default_locale || i18n.locales[0];
}
throwOnMissingDefaultLocale(i18n);
const backend = resolveBackend(config);
for (const collection of config.collections) {
if (!('publish' in collection)) {
collection.publish = true;
}
let collectionI18n = collection[I18N];
if (i18n && collectionI18n) {
collectionI18n = getI18nDefaults(collectionI18n, i18n);
collection[I18N] = collectionI18n;
} else {
collectionI18n = undefined;
delete collection[I18N];
}
if (collection.fields) {
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
}
const { folder, files, view_filters, view_groups, meta } = collection;
if (folder) {
collection.type = FOLDER;
if (collection.path && !collection.media_folder) {
// default value for media folder when using the path config
collection.media_folder = '';
}
if ('media_folder' in collection && !('public_folder' in collection)) {
collection.public_folder = collection.media_folder;
}
if (collection.fields) {
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
}
collection.folder = trim(folder, '/');
if (meta && meta.path) {
const metaField = {
name: 'path',
meta: true,
required: true,
...meta.path,
};
collection.fields = [metaField, ...(collection.fields || [])];
}
}
if (files) {
collection.type = FILES;
throwOnInvalidFileCollectionStructure(collectionI18n);
delete collection.nested;
delete collection.meta;
for (const file of files) {
file.file = trimStart(file.file, '/');
if ('media_folder' in file && !('public_folder' in file)) {
file.public_folder = file.media_folder;
}
if (file.fields) {
file.fields = traverseFieldsJS(file.fields, setDefaultPublicFolderForField);
}
let fileI18n = file[I18N];
if (fileI18n && collectionI18n) {
fileI18n = getI18nDefaults(fileI18n, collectionI18n);
file[I18N] = fileI18n;
} else {
fileI18n = undefined;
delete file[I18N];
}
throwOnInvalidFileCollectionStructure(fileI18n);
if (file.fields) {
file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n));
}
}
}
if (!collection.sortable_fields) {
collection.sortable_fields = selectDefaultSortableFields(
// TODO remove fromJS when Immutable is removed from the collections state slice
fromJS(collection),
backend,
hasIntegration(config, collection),
);
}
collection.view_filters = (view_filters || []).map(filter => {
return {
...filter,
id: `${filter.field}__${filter.pattern}`,
};
});
collection.view_groups = (view_groups || []).map(group => {
return {
...group,
id: `${group.field}__${group.pattern}`,
};
});
if (config.editor && !collection.editor) {
collection.editor = { preview: config.editor.preview };
}
}
});
}
export function parseConfig(data: string) {
const config = yaml.parse(data, { maxAliasCount: -1, prettyErrors: true, merge: true });
if (
typeof window !== 'undefined' &&
typeof window.CMS_ENV === 'string' &&
config[window.CMS_ENV]
) {
const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray<keyof CmsConfig>;
for (const key of configKeys) {
config[key] = config[window.CMS_ENV][key] as CmsConfig[keyof CmsConfig];
}
}
return config as Partial<CmsConfig>;
}
async function getConfigYaml(file: string, hasManualConfig: boolean) {
const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error);
if (response instanceof Error || response.status !== 200) {
if (hasManualConfig) {
return {};
}
const message = response instanceof Error ? response.message : response.status;
throw new Error(`Failed to load config.yml (${message})`);
}
const contentType = response.headers.get('Content-Type') || 'Not-Found';
const isYaml = contentType.indexOf('yaml') !== -1;
if (!isYaml) {
console.log(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
if (hasManualConfig) {
return {};
}
}
return parseConfig(await response.text());
}
export function configLoaded(config: CmsConfig) {
return {
type: CONFIG_SUCCESS,
payload: config,
} as const;
}
export function configLoading() {
return {
type: CONFIG_REQUEST,
} as const;
}
export function configFailed(err: Error) {
return {
type: CONFIG_FAILURE,
error: 'Error loading config',
payload: err,
} as const;
}
export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend) {
const allowedHosts = [
'localhost',
'127.0.0.1',
...(typeof localBackend === 'boolean' ? [] : localBackend?.allowed_hosts || []),
];
if (!allowedHosts.includes(location.hostname) || !localBackend) {
return {};
}
const defaultUrl = 'http://localhost:8081/api/v1';
const proxyUrl =
localBackend === true
? defaultUrl
: localBackend.url || defaultUrl.replace('localhost', location.hostname);
try {
console.log(`Looking for Decap CMS Proxy Server at '${proxyUrl}'`);
const res = await fetch(`${proxyUrl}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'info' }),
});
const { repo, publish_modes, type } = (await res.json()) as {
repo?: string;
publish_modes?: CmsPublishMode[];
type?: string;
};
if (typeof repo === 'string' && Array.isArray(publish_modes) && typeof type === 'string') {
console.log(`Detected Decap CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`);
return { proxyUrl, publish_modes, type };
} else {
console.log(`Decap CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
} catch {
console.log(`Decap CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
}
}
function getPublishMode(config: CmsConfig, publishModes?: CmsPublishMode[], backendType?: string) {
if (config.publish_mode && publishModes && !publishModes.includes(config.publish_mode)) {
const newPublishMode = publishModes[0];
console.log(
`'${config.publish_mode}' is not supported by '${backendType}' backend, switching to '${newPublishMode}'`,
);
return newPublishMode;
}
return config.publish_mode;
}
export async function handleLocalBackend(originalConfig: CmsConfig) {
if (!originalConfig.local_backend) {
return originalConfig;
}
const {
proxyUrl,
publish_modes: publishModes,
type: backendType,
} = await detectProxyServer(originalConfig.local_backend);
if (!proxyUrl) {
return originalConfig;
}
return produce(originalConfig, config => {
config.backend.name = 'proxy';
config.backend.proxy_url = proxyUrl;
if (config.publish_mode) {
config.publish_mode = getPublishMode(config, publishModes, backendType);
}
});
}
export function loadConfig(manualConfig: Partial<CmsConfig> = {}, onLoad: () => unknown) {
if (window.CMS_CONFIG) {
return configLoaded(window.CMS_CONFIG);
}
return async (dispatch: ThunkDispatch<State, {}, AnyAction>) => {
dispatch(configLoading());
try {
const configUrl = getConfigUrl();
const hasManualConfig = !isEmpty(manualConfig);
const configYaml =
manualConfig.load_config_file === false
? {}
: await getConfigYaml(configUrl, hasManualConfig);
// Merge manual config into the config.yml one
const mergedConfig = deepmerge(configYaml, manualConfig);
validateConfig(mergedConfig);
const withLocalBackend = await handleLocalBackend(mergedConfig);
const normalizedConfig = normalizeConfig(withLocalBackend);
const config = applyDefaults(normalizedConfig);
dispatch(configLoaded(config));
if (typeof onLoad === 'function') {
onLoad();
}
} catch (err) {
dispatch(configFailed(err));
throw err;
}
};
}
export type ConfigAction = ReturnType<
typeof configLoading | typeof configLoaded | typeof configFailed
>;

View File

@@ -0,0 +1,104 @@
import { currentBackend } from '../backend';
import { selectDeployPreview } from '../reducers';
import { addNotification } from './notifications';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { Collection, Entry, State } from '../types/redux';
export const DEPLOY_PREVIEW_REQUEST = 'DEPLOY_PREVIEW_REQUEST';
export const DEPLOY_PREVIEW_SUCCESS = 'DEPLOY_PREVIEW_SUCCESS';
export const DEPLOY_PREVIEW_FAILURE = 'DEPLOY_PREVIEW_FAILURE';
function deployPreviewLoading(collection: string, slug: string) {
return {
type: DEPLOY_PREVIEW_REQUEST,
payload: {
collection,
slug,
},
} as const;
}
function deployPreviewLoaded(
collection: string,
slug: string,
deploy: { url: string | undefined; status: string },
) {
const { url, status } = deploy;
return {
type: DEPLOY_PREVIEW_SUCCESS,
payload: {
collection,
slug,
url,
status,
},
} as const;
}
function deployPreviewError(collection: string, slug: string) {
return {
type: DEPLOY_PREVIEW_FAILURE,
payload: {
collection,
slug,
},
} as const;
}
/**
* Requests a deploy preview object from the registered backend.
*/
export function loadDeployPreview(
collection: Collection,
slug: string,
entry: Entry,
published: boolean,
opts?: { maxAttempts?: number; interval?: number },
) {
return async (dispatch: ThunkDispatch<State, undefined, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const collectionName = collection.get('name');
// Exit if currently fetching
const deployState = selectDeployPreview(state, collectionName, slug);
if (deployState && deployState.isFetching) {
return;
}
dispatch(deployPreviewLoading(collectionName, slug));
try {
/**
* `getDeploy` is for published entries, while `getDeployPreview` is for
* unpublished entries.
*/
const deploy = published
? backend.getDeploy(collection, slug, entry)
: await backend.getDeployPreview(collection, slug, entry, opts);
if (deploy) {
return dispatch(deployPreviewLoaded(collectionName, slug, deploy));
}
return dispatch(deployPreviewError(collectionName, slug));
} catch (error) {
console.error(error);
dispatch(
addNotification({
message: {
details: error.message,
key: 'ui.toast.onFailToLoadDeployPreview',
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(deployPreviewError(collectionName, slug));
}
};
}
export type DeploysAction = ReturnType<
typeof deployPreviewLoading | typeof deployPreviewLoaded | typeof deployPreviewError
>;

View File

@@ -0,0 +1,567 @@
import get from 'lodash/get';
import { Map, List } from 'immutable';
import { EDITORIAL_WORKFLOW_ERROR } from 'decap-cms-lib-util';
import { currentBackend, slugFromCustomPath } from '../backend';
import {
selectPublishedSlugs,
selectUnpublishedSlugs,
selectEntry,
selectUnpublishedEntry,
} from '../reducers';
import { selectEditingDraft } from '../reducers/entries';
import { EDITORIAL_WORKFLOW, status } from '../constants/publishModes';
import {
loadEntry,
entryDeleted,
getMediaAssets,
createDraftFromEntry,
loadEntries,
getSerializedEntry,
} from './entries';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { addAssets } from './media';
import { loadMedia } from './mediaLibrary';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import { navigateToEntry } from '../routing/history';
import { addNotification } from './notifications';
import type {
Collection,
EntryMap,
State,
Collections,
EntryDraft,
MediaFile,
} from '../types/redux';
import type { AnyAction } from 'redux';
import type { EntryValue } from '../valueObjects/Entry';
import type { Status } from '../constants/publishModes';
import type { ThunkDispatch } from 'redux-thunk';
/*
* Constant Declarations
*/
export const UNPUBLISHED_ENTRY_REQUEST = 'UNPUBLISHED_ENTRY_REQUEST';
export const UNPUBLISHED_ENTRY_SUCCESS = 'UNPUBLISHED_ENTRY_SUCCESS';
export const UNPUBLISHED_ENTRY_REDIRECT = 'UNPUBLISHED_ENTRY_REDIRECT';
export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST';
export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS';
export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE';
export const UNPUBLISHED_ENTRY_PERSIST_REQUEST = 'UNPUBLISHED_ENTRY_PERSIST_REQUEST';
export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCCESS';
export const UNPUBLISHED_ENTRY_PERSIST_FAILURE = 'UNPUBLISHED_ENTRY_PERSIST_FAILURE';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE';
export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST';
export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS';
export const UNPUBLISHED_ENTRY_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE';
export const UNPUBLISHED_ENTRY_DELETE_REQUEST = 'UNPUBLISHED_ENTRY_DELETE_REQUEST';
export const UNPUBLISHED_ENTRY_DELETE_SUCCESS = 'UNPUBLISHED_ENTRY_DELETE_SUCCESS';
export const UNPUBLISHED_ENTRY_DELETE_FAILURE = 'UNPUBLISHED_ENTRY_DELETE_FAILURE';
/*
* Simple Action Creators (Internal)
*/
function unpublishedEntryLoading(collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_REQUEST,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntryLoaded(
collection: Collection,
entry: EntryValue & { mediaFiles: MediaFile[] },
) {
return {
type: UNPUBLISHED_ENTRY_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
},
};
}
function unpublishedEntryRedirected(collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_REDIRECT,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntriesLoading() {
return {
type: UNPUBLISHED_ENTRIES_REQUEST,
};
}
function unpublishedEntriesLoaded(entries: EntryValue[], pagination: number) {
return {
type: UNPUBLISHED_ENTRIES_SUCCESS,
payload: {
entries,
pages: pagination,
},
};
}
function unpublishedEntriesFailed(error: Error) {
return {
type: UNPUBLISHED_ENTRIES_FAILURE,
error: 'Failed to load entries',
payload: error,
};
}
function unpublishedEntryPersisting(collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
payload: {
collection: collection.get('name'),
slug,
},
};
}
function unpublishedEntryPersisted(collection: Collection, entry: EntryMap) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
payload: {
collection: collection.get('name'),
entry,
},
};
}
function unpublishedEntryPersistedFail(error: Error, collection: Collection, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
payload: {
error,
collection: collection.get('name'),
slug,
},
error,
};
}
function unpublishedEntryStatusChangeRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
payload: {
collection,
slug,
},
};
}
function unpublishedEntryStatusChangePersisted(
collection: string,
slug: string,
newStatus: Status,
) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
payload: {
collection,
slug,
newStatus,
},
};
}
function unpublishedEntryStatusChangeError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
payload: { collection, slug },
};
}
function unpublishedEntryPublishRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
payload: { collection, slug },
};
}
function unpublishedEntryPublished(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
payload: { collection, slug },
};
}
function unpublishedEntryPublishError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
payload: { collection, slug },
};
}
function unpublishedEntryDeleteRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_REQUEST,
payload: { collection, slug },
};
}
function unpublishedEntryDeleted(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_SUCCESS,
payload: { collection, slug },
};
}
function unpublishedEntryDeleteError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_FAILURE,
payload: { collection, slug },
};
}
/*
* Exported Thunk Action Creators
*/
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) {
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(state, collection, slug)) as EntryValue;
const assetProxies = await Promise.all(
entry.mediaFiles
.filter(file => file.draft)
.map(({ url, file, path }) =>
createAssetProxy({
path,
url,
file,
}),
),
);
dispatch(addAssets(assetProxies));
dispatch(unpublishedEntryLoaded(collection, entry));
dispatch(createDraftFromEntry(entry));
} catch (error) {
if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToLoadEntries',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
}
}
};
}
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);
if (state.config.publish_mode !== EDITORIAL_WORKFLOW || entriesLoaded) {
return;
}
dispatch(unpublishedEntriesLoading());
backend
.unpublishedEntries(collections)
.then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)))
.catch((error: Error) => {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToLoadEntries',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntriesFailed(error));
Promise.reject(error);
});
};
}
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) as List<string>;
const entriesLoaded = get(state.editorialWorkflow.toJS(), 'pages.ids', false);
//load unpublishedEntries
!entriesLoaded && dispatch(loadUnpublishedEntries(state.collections));
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
const hasPresenceErrors = fieldsErrors.some(errors =>
errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE),
);
if (hasPresenceErrors) {
dispatch(
addNotification({
message: {
key: 'ui.toast.missingRequiredField',
},
type: 'error',
dismissAfter: 8000,
}),
);
}
return Promise.reject();
}
const backend = currentBackend(state.config);
const entry = entryDraft.get('entry');
const assetProxies = getMediaAssets({
entry,
});
const serializedEntry = getSerializedEntry(collection, entry);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
dispatch(unpublishedEntryPersisting(collection, entry.get('slug')));
const persistAction = existingUnpublishedEntry
? backend.persistUnpublishedEntry
: backend.persistEntry;
try {
const newSlug = await persistAction.call(backend, {
config: state.config,
collection,
entryDraft: serializedEntryDraft,
assetProxies,
usedSlugs,
});
dispatch(
addNotification({
message: {
key: 'ui.toast.entrySaved',
},
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPersisted(collection, serializedEntry));
if (entry.get('slug') !== newSlug) {
await dispatch(loadUnpublishedEntry(collection, newSlug));
navigateToEntry(collection.get('name'), newSlug);
}
} catch (error) {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToPersist',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
return Promise.reject(
dispatch(unpublishedEntryPersistedFail(error, collection, entry.get('slug'))),
);
}
};
}
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);
dispatch(unpublishedEntryStatusChangeRequest(collection, slug));
backend
.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
dispatch(
addNotification({
message: {
key: 'ui.toast.entryUpdated',
},
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, newStatus));
})
.catch((error: Error) => {
dispatch(
addNotification({
message: {
key: 'ui.toast.onFailToUpdateStatus',
details: error,
},
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryStatusChangeError(collection, slug));
});
};
}
export function deleteUnpublishedEntry(collection: string, slug: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryDeleteRequest(collection, slug));
return backend
.deleteUnpublishedEntry(collection, slug)
.then(() => {
dispatch(
addNotification({
message: { key: 'ui.toast.onDeleteUnpublishedChanges' },
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryDeleted(collection, slug));
})
.catch((error: Error) => {
dispatch(
addNotification({
message: { key: 'ui.toast.onDeleteUnpublishedChanges', details: error },
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryDeleteError(collection, slug));
});
};
}
export function publishUnpublishedEntry(collectionName: string, slug: string) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const collections = state.collections;
const backend = currentBackend(state.config);
const entry = selectUnpublishedEntry(state, collectionName, slug);
dispatch(unpublishedEntryPublishRequest(collectionName, slug));
try {
await backend.publishUnpublishedEntry(entry);
// re-load media after entry was published
dispatch(loadMedia());
dispatch(
addNotification({
message: { key: 'ui.toast.entryPublished' },
type: 'success',
dismissAfter: 4000,
}),
);
dispatch(unpublishedEntryPublished(collectionName, slug));
const collection = collections.get(collectionName);
if (collection.has('nested')) {
dispatch(loadEntries(collection));
const newSlug = slugFromCustomPath(collection, entry.get('path'));
loadEntry(collection, newSlug);
if (slug !== newSlug && selectEditingDraft(state.entryDraft)) {
navigateToEntry(collection.get('name'), newSlug);
}
} else {
return dispatch(loadEntry(collection, slug));
}
} catch (error) {
dispatch(
addNotification({
message: { key: 'ui.toast.onFailToPublishEntry', details: error },
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPublishError(collectionName, slug));
}
};
}
export function unpublishPublishedEntry(collection: Collection, slug: string) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const entry = selectEntry(state, collection.get('name'), slug);
const entryDraft = Map().set('entry', entry) as unknown as EntryDraft;
dispatch(unpublishedEntryPersisting(collection, slug));
return backend
.deleteEntry(state, collection, slug)
.then(() =>
backend.persistEntry({
config: state.config,
collection,
entryDraft,
assetProxies: [],
usedSlugs: List(),
status: status.get('PENDING_PUBLISH'),
}),
)
.then(() => {
dispatch(unpublishedEntryPersisted(collection, entry));
dispatch(entryDeleted(collection, slug));
dispatch(loadUnpublishedEntry(collection, slug));
dispatch(
addNotification({
message: { key: 'ui.toast.entryUnpublished' },
type: 'success',
dismissAfter: 4000,
}),
);
})
.catch((error: Error) => {
dispatch(
addNotification({
message: { key: 'ui.toast.onFailToUnpublishEntry', details: error },
type: 'error',
dismissAfter: 8000,
}),
);
dispatch(unpublishedEntryPersistedFail(error, collection, entry.get('slug')));
});
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
import { isAbsolutePath } from 'decap-cms-lib-util';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { selectMediaFilePath } from '../reducers/entries';
import { selectMediaFileByPath } from '../reducers/mediaLibrary';
import { getMediaFile, waitForMediaLibraryToLoad, getMediaDisplayURL } from './mediaLibrary';
import type AssetProxy from '../valueObjects/AssetProxy';
import type { Collection, State, EntryMap, EntryField } from '../types/redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
export const ADD_ASSETS = 'ADD_ASSETS';
export const ADD_ASSET = 'ADD_ASSET';
export const REMOVE_ASSET = 'REMOVE_ASSET';
export const LOAD_ASSET_REQUEST = 'LOAD_ASSET_REQUEST';
export const LOAD_ASSET_SUCCESS = 'LOAD_ASSET_SUCCESS';
export const LOAD_ASSET_FAILURE = 'LOAD_ASSET_FAILURE';
export function addAssets(assets: AssetProxy[]) {
return { type: ADD_ASSETS, payload: assets } as const;
}
export function addAsset(assetProxy: AssetProxy) {
return { type: ADD_ASSET, payload: assetProxy } as const;
}
export function removeAsset(path: string) {
return { type: REMOVE_ASSET, payload: path } as const;
}
export function loadAssetRequest(path: string) {
return { type: LOAD_ASSET_REQUEST, payload: { path } } as const;
}
export function loadAssetSuccess(path: string) {
return { type: LOAD_ASSET_SUCCESS, payload: { path } } as const;
}
export function loadAssetFailure(path: string, error: Error) {
return { type: LOAD_ASSET_FAILURE, payload: { path, error } } as const;
}
export function loadAsset(resolvedPath: string) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
try {
dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath);
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
const asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
const asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
dispatch(loadAssetSuccess(resolvedPath));
} catch (e) {
dispatch(loadAssetFailure(resolvedPath, e));
}
};
}
interface GetAssetArgs {
collection: Collection;
entry: EntryMap;
path: string;
field?: EntryField;
}
const emptyAsset = createAssetProxy({
path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
type: 'image/svg+xml',
}),
});
export function boundGetAsset(
dispatch: ThunkDispatch<State, {}, AnyAction>,
collection: Collection,
entry: EntryMap,
) {
function bound(path: string, field: EntryField) {
const asset = dispatch(getAsset({ collection, entry, path, field }));
return asset;
}
return bound;
}
export function getAsset({ collection, entry, path, field }: GetAssetArgs) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
if (!path) return emptyAsset;
const state = getState();
const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, field);
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
if (isLoading) {
return emptyAsset;
}
if (asset) {
// There is already an AssetProxy in memory for this path. Use it.
return asset;
}
if (isAbsolutePath(resolvedPath)) {
// asset path is a public url so we can just use it as is
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
} else {
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
} else {
dispatch(loadAsset(resolvedPath));
asset = emptyAsset;
}
}
return asset;
};
}
export type MediasAction = ReturnType<
| typeof addAssets
| typeof addAsset
| typeof removeAsset
| typeof loadAssetRequest
| typeof loadAssetSuccess
| typeof loadAssetFailure
>;

View File

@@ -0,0 +1,574 @@
import { Map } from 'immutable';
import { basename, getBlobSHA } from 'decap-cms-lib-util';
import { currentBackend } from '../backend';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import { selectIntegration } from '../reducers';
import {
selectMediaFilePath,
selectMediaFilePublicPath,
selectEditingDraft,
} 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 { waitUntilWithTimeout } from './waitUntil';
import { addNotification } from './notifications';
import type {
State,
MediaFile,
DisplayURLState,
MediaLibraryInstance,
EntryField,
} from '../types/redux';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type AssetProxy from '../valueObjects/AssetProxy';
import type { ImplementationMediaFile } from 'decap-cms-lib-util';
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 } as const;
}
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;
forImage?: boolean;
privateUpload?: boolean;
value?: string;
allowMultiple?: boolean;
config?: Map<string, unknown>;
field?: EntryField;
} = {},
) {
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(mediaLibraryOpened(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(mediaLibraryClosed());
};
}
export function insertMedia(mediaPath: string | string[], field: EntryField | undefined) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const config = state.config;
const entry = state.entryDraft.get('entry');
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, entry, field),
);
} else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
}
dispatch(mediaInserted(mediaPath));
};
}
export function removeInsertedMedia(controlID: string) {
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
}
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));
function loadFunction() {
return backend
.getMedia()
.then(files => 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());
}
});
}
if (delay > 0) {
return new Promise(resolve => {
setTimeout(() => resolve(loadFunction()), delay);
});
} else {
return loadFunction();
}
};
}
function createMediaFileFromAsset({
id,
file,
assetProxy,
draft,
}: {
id: string;
file: File;
assetProxy: AssetProxy;
draft: boolean;
}): ImplementationMediaFile {
const mediaFile = {
id,
name: basename(assetProxy.path),
displayURL: assetProxy.url,
draft,
file,
size: file.size,
url: assetProxy.url,
path: assetProxy.path,
field: assetProxy.field,
};
return mediaFile;
}
export function persistMedia(file: File, opts: MediaOptions = {}) {
const { privateUpload, field } = 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, field);
const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.slug);
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
const editingDraft = selectEditingDraft(state.entryDraft);
/**
* 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: fileName,
});
}
} 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 collection = state.collections.get(entry?.get('collection'));
const path = selectMediaFilePath(state.config, collection, entry, fileName, field);
assetProxy = createAssetProxy({
file,
path,
field,
});
}
dispatch(addAsset(assetProxy));
let mediaFile: ImplementationMediaFile;
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(
addNotification({
message: `Failed to persist media: ${error}`,
type: 'error',
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(
addNotification({
message: `Failed to delete media: ${error.message}`,
type: 'error',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed({ privateUpload }));
}
}
try {
if (file.draft) {
dispatch(removeAsset(file.path));
dispatch(removeDraftEntryMediaFile({ id: file.id }));
} else {
const editingDraft = selectEditingDraft(state.entryDraft);
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(
addNotification({
message: `Failed to delete media: ${error.message}`,
type: 'error',
dismissAfter: 8000,
}),
);
return dispatch(mediaDeleteFailed());
}
};
}
export async function getMediaFile(state: State, path: string) {
const backend = currentBackend(state.config);
const { url } = await backend.getMediaFile(path);
return { url };
}
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) {
console.error(err);
dispatch(mediaDisplayURLFailure(id, err));
}
};
}
function mediaLibraryOpened(payload: {
controlID?: string;
forImage?: boolean;
privateUpload?: boolean;
value?: string;
replaceIndex?: number;
allowMultiple?: boolean;
config?: Map<string, unknown>;
field?: EntryField;
}) {
return { type: MEDIA_LIBRARY_OPEN, payload } as const;
}
function mediaLibraryClosed() {
return { type: MEDIA_LIBRARY_CLOSE } as const;
}
function mediaInserted(mediaPath: string | string[]) {
return { type: MEDIA_INSERT, payload: { mediaPath } } as const;
}
export function mediaLoading(page: number) {
return {
type: MEDIA_LOAD_REQUEST,
payload: { page },
} as const;
}
interface MediaOptions {
privateUpload?: boolean;
field?: EntryField;
page?: number;
canPaginate?: boolean;
dynamicSearch?: boolean;
dynamicSearchQuery?: string;
}
export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions = {}) {
return {
type: MEDIA_LOAD_SUCCESS,
payload: { files, ...opts },
} as const;
}
export function mediaLoadFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } } as const;
}
export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST } as const;
}
export function mediaPersisted(file: ImplementationMediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_PERSIST_SUCCESS,
payload: { file, privateUpload },
} as const;
}
export function mediaPersistFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } } as const;
}
export function mediaDeleting() {
return { type: MEDIA_DELETE_REQUEST } as const;
}
export function mediaDeleted(file: MediaFile, opts: MediaOptions = {}) {
const { privateUpload } = opts;
return {
type: MEDIA_DELETE_SUCCESS,
payload: { file, privateUpload },
} as const;
}
export function mediaDeleteFailed(opts: MediaOptions = {}) {
const { privateUpload } = opts;
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } } as const;
}
export function mediaDisplayURLRequest(key: string) {
return { type: MEDIA_DISPLAY_URL_REQUEST, payload: { key } } as const;
}
export function mediaDisplayURLSuccess(key: string, url: string) {
return {
type: MEDIA_DISPLAY_URL_SUCCESS,
payload: { key, url },
} as const;
}
export function mediaDisplayURLFailure(key: string, err: Error) {
return {
type: MEDIA_DISPLAY_URL_FAILURE,
payload: { key, err },
} as const;
}
export async function waitForMediaLibraryToLoad(
dispatch: ThunkDispatch<State, {}, AnyAction>,
state: State,
) {
if (state.mediaLibrary.get('isLoading') !== false && !state.mediaLibrary.get('externalLibrary')) {
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 {
const key = file.id;
const promise = 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),
}));
if (!displayURLState.get('isFetching')) {
// load display url
dispatch(loadMediaDisplayURL(file));
}
url = (await promise) ?? null;
}
return url;
}
export type MediaLibraryAction = ReturnType<
| typeof createMediaLibrary
| typeof mediaLibraryOpened
| typeof mediaLibraryClosed
| typeof mediaInserted
| typeof removeInsertedMedia
| typeof mediaLoading
| typeof mediaLoaded
| typeof mediaLoadFailed
| typeof mediaPersisting
| typeof mediaPersisted
| typeof mediaPersistFailed
| typeof mediaDeleting
| typeof mediaDeleted
| typeof mediaDeleteFailed
| typeof mediaDisplayURLRequest
| typeof mediaDisplayURLSuccess
| typeof mediaDisplayURLFailure
>;

View File

@@ -0,0 +1,36 @@
import type { TypeOptions } from 'react-toastify';
export interface NotificationMessage {
details?: unknown;
key: string;
}
export interface NotificationPayload {
message: string | NotificationMessage;
dismissAfter?: number;
type: TypeOptions | undefined;
}
export const NOTIFICATION_SEND = 'NOTIFICATION_SEND';
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATION_CLEAR';
function addNotification(notification: NotificationPayload) {
return { type: NOTIFICATION_SEND, payload: notification };
}
function dismissNotification(id: string) {
return { type: NOTIFICATION_DISMISS, id };
}
function clearNotifications() {
return { type: NOTIFICATIONS_CLEAR };
}
export type NotificationsAction = {
type: typeof NOTIFICATION_DISMISS | typeof NOTIFICATION_SEND | typeof NOTIFICATIONS_CLEAR;
payload?: NotificationPayload;
id?: string;
};
export { addNotification, dismissNotification, clearNotifications };

View File

@@ -0,0 +1,221 @@
import isEqual from 'lodash/isEqual';
import { currentBackend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
import type { QueryRequest } from '../reducers/search';
import type { State } from '../types/redux';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { EntryValue } from '../valueObjects/Entry';
/*
* Constant Declarations
*/
export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST';
export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS';
export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE';
export const QUERY_REQUEST = 'QUERY_REQUEST';
export const QUERY_SUCCESS = 'QUERY_SUCCESS';
export const QUERY_FAILURE = 'QUERY_FAILURE';
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
export const CLEAR_REQUESTS = 'CLEAR_REQUESTS';
/*
* Simple Action Creators (Internal)
* We still need to export them for tests
*/
export function searchingEntries(searchTerm: string, searchCollections: string[], page: number) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm, searchCollections, page },
} as const;
}
export function searchSuccess(entries: EntryValue[], page: number) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
entries,
page,
},
} as const;
}
export function searchFailure(error: Error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: { error },
} as const;
}
export function querying(searchTerm: string, request?: QueryRequest) {
return {
type: QUERY_REQUEST,
payload: {
searchTerm,
request,
},
} as const;
}
type SearchResponse = {
entries: EntryValue[];
pagination: number;
};
type QueryResponse = {
hits: EntryValue[];
query: string;
};
export function querySuccess(namespace: string, hits: EntryValue[]) {
return {
type: QUERY_SUCCESS,
payload: {
namespace,
hits,
},
} as const;
}
export function queryFailure(error: Error) {
return {
type: QUERY_FAILURE,
payload: { error },
} as const;
}
/*
* Exported simple Action Creators
*/
export function clearSearch() {
return { type: SEARCH_CLEAR } as const;
}
export function clearRequests() {
return { type: CLEAR_REQUESTS } as const;
}
/*
* Exported Thunk Action Creators
*/
// SearchEntries will search for complete entries in all collections.
export function searchEntries(searchTerm: string, searchCollections: string[], page = 0) {
return async (dispatch: ThunkDispatch<State, undefined, AnyAction>, getState: () => State) => {
const state = getState();
const { search } = state;
const backend = currentBackend(state.config);
const allCollections = searchCollections || state.collections.keySeq().toArray();
const collections = allCollections.filter(collection =>
selectIntegration(state, collection, 'search'),
);
const integration = selectIntegration(state, collections[0], 'search');
// avoid duplicate searches
if (
search.isFetching &&
search.term === searchTerm &&
isEqual(allCollections, search.collections) &&
// if an integration doesn't exist, 'page' is not used
(search.page === page || !integration)
) {
return;
}
dispatch(searchingEntries(searchTerm, allCollections, page));
const searchPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
collections,
searchTerm,
page,
)
: backend.search(
state.collections
.filter((_, key: string) => allCollections.indexOf(key) !== -1)
.valueSeq()
.toArray(),
searchTerm,
);
try {
const response: SearchResponse = await searchPromise;
return dispatch(searchSuccess(response.entries, response.pagination));
} catch (error) {
return dispatch(searchFailure(error));
}
};
}
// Instead of searching for complete entries, query will search for specific fields
// in specific collections and return raw data (no entries).
export function query(
namespace: string,
collectionName: string,
searchFields: string[],
searchTerm: string,
file?: string,
limit?: number,
) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collectionName, 'search');
const collection = state.collections.find(
collection => collection.get('name') === collectionName,
);
dispatch(clearRequests());
const queryIdentifier = `${collectionName}-${searchFields.join()}-${searchTerm}-${file}-${limit}`;
const queuedQueryPromise = state.search.requests.find(({ id }) => id == queryIdentifier);
const queryPromise = queuedQueryPromise
? queuedQueryPromise.queryResponse
: integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).searchBy(
searchFields.map(f => `data.${f}`),
collectionName,
searchTerm,
)
: backend.query(collection, searchFields, searchTerm, file, limit);
dispatch(
querying(
searchTerm,
queuedQueryPromise
? undefined
: {
id: queryIdentifier,
expires: new Date(new Date().getTime() + 10 * 1000),
queryResponse: queryPromise,
},
),
);
try {
const response: QueryResponse = await queryPromise;
return dispatch(querySuccess(namespace, response.hits));
} catch (error) {
return dispatch(queryFailure(error));
}
};
}
export type SearchAction = ReturnType<
| typeof searchingEntries
| typeof searchSuccess
| typeof searchFailure
| typeof querying
| typeof querySuccess
| typeof queryFailure
| typeof clearSearch
| typeof clearRequests
>;

View File

@@ -0,0 +1,99 @@
import { currentBackend } from '../backend';
import { addNotification, dismissNotification } from './notifications';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { State } from '../types/redux';
export const STATUS_REQUEST = 'STATUS_REQUEST';
export const STATUS_SUCCESS = 'STATUS_SUCCESS';
export const STATUS_FAILURE = 'STATUS_FAILURE';
export function statusRequest() {
return {
type: STATUS_REQUEST,
} as const;
}
export function statusSuccess(status: {
auth: { status: boolean };
api: { status: boolean; statusPage: string };
}) {
return {
type: STATUS_SUCCESS,
payload: { status },
} as const;
}
export function statusFailure(error: Error) {
return {
type: STATUS_FAILURE,
payload: { error },
} as const;
}
export function checkBackendStatus() {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
try {
const state = getState();
if (state.status.isFetching) {
return;
}
dispatch(statusRequest());
const backend = currentBackend(state.config);
const status = await backend.status();
const backendDownKey = 'ui.toast.onBackendDown';
const previousBackendDownNotifications = state.notifications.notifications.filter(
n => typeof n.message != 'string' && n.message?.key === backendDownKey,
);
if (status.api.status === false) {
if (previousBackendDownNotifications.length === 0) {
dispatch(
addNotification({
message: {
details: status.api.statusPage,
key: 'ui.toast.onBackendDown',
},
type: 'error',
}),
);
}
return dispatch(statusSuccess(status));
} else if (status.api.status === true && previousBackendDownNotifications.length > 0) {
// If backend is up, clear all the danger messages
previousBackendDownNotifications.forEach(notification => {
dispatch(dismissNotification(notification.id));
});
}
const authError = status.auth.status === false;
if (authError) {
const key = 'ui.toast.onLoggedOut';
const existingNotification = state.notifications.notifications.find(
n => typeof n.message != 'string' && n.message?.key === key,
);
if (!existingNotification) {
dispatch(
addNotification({
message: {
key: 'ui.toast.onLoggedOut',
},
type: 'error',
}),
);
}
}
dispatch(statusSuccess(status));
} catch (error) {
dispatch(statusFailure(error));
}
};
}
export type StatusAction = ReturnType<
typeof statusRequest | typeof statusSuccess | typeof statusFailure
>;

View File

@@ -0,0 +1,49 @@
import { WAIT_UNTIL_ACTION } from '../redux/middleware/waitUntilAction';
import type { WaitActionArgs } from '../redux/middleware/waitUntilAction';
import type { ThunkDispatch } from 'redux-thunk';
import type { AnyAction } from 'redux';
import type { State } from '../types/redux';
export function waitUntil({ predicate, run }: WaitActionArgs) {
return {
type: WAIT_UNTIL_ACTION,
predicate,
run,
};
}
export async function waitUntilWithTimeout<T>(
dispatch: ThunkDispatch<State, {}, AnyAction>,
waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs,
timeout = 30000,
): Promise<T | null | void> {
let waitDone = false;
const waitPromise = new Promise<T>(resolve => {
dispatch(waitUntil(waitActionArgs(resolve as (value?: T | undefined) => void)));
});
const timeoutPromise = new Promise<T | null | void>(resolve => {
setTimeout(() => {
if (waitDone) {
resolve();
} else {
console.warn('Wait Action timed out');
resolve(null);
}
}, timeout);
});
const result = await Promise.race([
waitPromise
.then(result => {
waitDone = true;
return result;
})
.catch(null),
timeoutPromise,
]);
return result;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
import './lib/polyfill';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider, connect } from 'react-redux';
import { Route, Router } from 'react-router-dom';
import { GlobalStyles } from 'decap-cms-ui-default';
import { I18n } from 'react-polyglot';
import { store } from './redux';
import { history } from './routing/history';
import { loadConfig } from './actions/config';
import { authenticateUser } from './actions/auth';
import { getPhrases } from './lib/phrases';
import { selectLocale } from './reducers/config';
import { ErrorBoundary } from './components/UI';
import App from './components/App/App';
import './components/EditorWidgets';
import './mediaLibrary';
import 'what-input';
const ROOT_ID = 'nc-root';
function TranslatedApp({ locale, config }) {
return (
<I18n locale={locale} messages={getPhrases(locale)}>
<ErrorBoundary showBackup config={config}>
<Router history={history}>
<Route component={App} />
</Router>
</ErrorBoundary>
</I18n>
);
}
function mapDispatchToProps(state) {
return { locale: selectLocale(state.config), config: state.config };
}
const ConnectedTranslatedApp = connect(mapDispatchToProps)(TranslatedApp);
function bootstrap(opts = {}) {
const { config } = opts;
/**
* Log the version number.
*/
if (typeof DECAP_CMS_CORE_VERSION === 'string') {
console.log(`decap-cms-core ${DECAP_CMS_CORE_VERSION}`);
}
/**
* Get DOM element where app will mount.
*/
function getRoot() {
/**
* Return existing root if found.
*/
const existingRoot = document.getElementById(ROOT_ID);
if (existingRoot) {
return existingRoot;
}
/**
* If no existing root, create and return a new root.
*/
const newRoot = document.createElement('div');
newRoot.id = ROOT_ID;
document.body.appendChild(newRoot);
return newRoot;
}
/**
* Dispatch config to store if received. This config will be merged into
* config.yml if it exists, and any portion that produces a conflict will be
* overwritten.
*/
store.dispatch(
loadConfig(config, function onLoad() {
store.dispatch(authenticateUser());
}),
);
/**
* Create connected root component.
*/
function Root() {
return (
<>
<GlobalStyles />
<Provider store={store}>
<ConnectedTranslatedApp />
</Provider>
</>
);
}
/**
* Render application root.
*/
const root = createRoot(getRoot());
root.render(<Root />);
}
export default bootstrap;

View File

@@ -0,0 +1,286 @@
import PropTypes from 'prop-types';
import React from 'react';
import { translate } from 'react-polyglot';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { Route, Switch, Redirect } from 'react-router-dom';
import TopBarProgress from 'react-topbar-progress-indicator';
import { Loader, colors } from 'decap-cms-ui-default';
import { loginUser, logoutUser } from '../../actions/auth';
import { currentBackend } from '../../backend';
import { createNewEntry } from '../../actions/collections';
import { openMediaLibrary } from '../../actions/mediaLibrary';
import MediaLibrary from '../MediaLibrary/MediaLibrary';
import { Notifications } from '../UI';
import { history } from '../../routing/history';
import { SIMPLE, EDITORIAL_WORKFLOW } from '../../constants/publishModes';
import Collection from '../Collection/Collection';
import Workflow from '../Workflow/Workflow';
import Editor from '../Editor/Editor';
import NotFoundPage from './NotFoundPage';
import Header from './Header';
TopBarProgress.config({
barColors: {
0: colors.active,
'1.0': colors.active,
},
shadowBlur: 0,
barThickness: 2,
});
const AppMainContainer = styled.div`
min-width: 800px;
max-width: 1440px;
margin: 0 auto;
`;
const ErrorContainer = styled.div`
margin: 20px;
`;
const ErrorCodeBlock = styled.pre`
margin-left: 20px;
font-size: 15px;
line-height: 1.5;
`;
function getDefaultPath(collections) {
const first = collections.filter(collection => collection.get('hide') !== true).first();
if (first) {
return `/collections/${first.get('name')}`;
} else {
throw new Error('Could not find a non hidden collection');
}
}
function RouteInCollection({ collections, render, ...props }) {
const defaultPath = getDefaultPath(collections);
return (
<Route
{...props}
render={routeProps => {
const collectionExists = collections.get(routeProps.match.params.name);
return collectionExists ? render(routeProps) : <Redirect to={defaultPath} />;
}}
/>
);
}
class App extends React.Component {
static propTypes = {
auth: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
collections: ImmutablePropTypes.map.isRequired,
loginUser: PropTypes.func.isRequired,
logoutUser: PropTypes.func.isRequired,
user: PropTypes.object,
isFetching: PropTypes.bool.isRequired,
publishMode: PropTypes.oneOf([SIMPLE, EDITORIAL_WORKFLOW]),
siteId: PropTypes.string,
useMediaLibrary: PropTypes.bool,
openMediaLibrary: PropTypes.func.isRequired,
showMediaButton: PropTypes.bool,
t: PropTypes.func.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(App.propTypes, this.props, 'prop', 'App');
}
configError(config) {
const t = this.props.t;
return (
<ErrorContainer>
<h1>{t('app.app.errorHeader')}</h1>
<div>
<strong>{t('app.app.configErrors')}:</strong>
<ErrorCodeBlock>{config.error}</ErrorCodeBlock>
<span>{t('app.app.checkConfigYml')}</span>
</div>
</ErrorContainer>
);
}
handleLogin(credentials) {
this.props.loginUser(credentials);
}
authenticating() {
const { auth, t } = this.props;
const backend = currentBackend(this.props.config);
if (backend == null) {
return (
<div>
<h1>{t('app.app.waitingBackend')}</h1>
</div>
);
}
return (
<div>
<Notifications />
{React.createElement(backend.authComponent(), {
onLogin: this.handleLogin.bind(this),
error: auth.error,
inProgress: auth.isFetching,
siteId: this.props.config.backend.site_domain,
base_url: this.props.config.backend.base_url,
authEndpoint: this.props.config.backend.auth_endpoint,
config: this.props.config,
clearHash: () => history.replace('/'),
t,
})}
</div>
);
}
handleLinkClick(event, handler, ...args) {
event.preventDefault();
handler(...args);
}
render() {
const {
user,
config,
collections,
logoutUser,
isFetching,
publishMode,
useMediaLibrary,
openMediaLibrary,
t,
showMediaButton,
} = this.props;
if (config === null) {
return null;
}
if (config.error) {
return this.configError(config);
}
if (config.isFetching) {
return <Loader active>{t('app.app.loadingConfig')}</Loader>;
}
if (user == null) {
return this.authenticating(t);
}
const defaultPath = getDefaultPath(collections);
const hasWorkflow = publishMode === EDITORIAL_WORKFLOW;
return (
<>
<Notifications />
<Header
user={user}
collections={collections}
onCreateEntryClick={createNewEntry}
onLogoutClick={logoutUser}
openMediaLibrary={openMediaLibrary}
hasWorkflow={hasWorkflow}
displayUrl={config.display_url}
logoUrl={config.logo_url} // Deprecated, replaced by `logo.src`
logo={config.logo}
isTestRepo={config.backend.name === 'test-repo'}
showMediaButton={showMediaButton}
/>
<AppMainContainer>
{isFetching && <TopBarProgress />}
<Switch>
<Redirect exact from="/" to={defaultPath} />
<Redirect exact from="/search/" to={defaultPath} />
<RouteInCollection
exact
collections={collections}
path="/collections/:name/search/"
render={({ match }) => <Redirect to={`/collections/${match.params.name}`} />}
/>
<Redirect
// This happens on Identity + Invite Only + External Provider email not matching
// the registered user
from="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
to={defaultPath}
/>
{hasWorkflow ? <Route path="/workflow" component={Workflow} /> : null}
<RouteInCollection
exact
collections={collections}
path="/collections/:name"
render={props => <Collection {...props} />}
/>
<RouteInCollection
path="/collections/:name/new"
collections={collections}
render={props => <Editor {...props} newRecord />}
/>
<RouteInCollection
path="/collections/:name/entries/*"
collections={collections}
render={props => <Editor {...props} />}
/>
<RouteInCollection
path="/collections/:name/search/:searchTerm"
collections={collections}
render={props => <Collection {...props} isSearchResults isSingleSearchResult />}
/>
<RouteInCollection
collections={collections}
path="/collections/:name/filter/:filterTerm*"
render={props => <Collection {...props} />}
/>
<Route
path="/search/:searchTerm"
render={props => <Collection {...props} isSearchResults />}
/>
<RouteInCollection
path="/edit/:name/:entryName"
collections={collections}
render={({ match }) => {
const { name, entryName } = match.params;
return <Redirect to={`/collections/${name}/entries/${entryName}`} />;
}}
/>
<Route component={NotFoundPage} />
</Switch>
{useMediaLibrary ? <MediaLibrary /> : null}
</AppMainContainer>
</>
);
}
}
function mapStateToProps(state) {
const { auth, config, collections, globalUI, mediaLibrary } = state;
const user = auth.user;
const isFetching = globalUI.isFetching;
const publishMode = config.publish_mode;
const useMediaLibrary = !mediaLibrary.get('externalLibrary');
const showMediaButton = mediaLibrary.get('showMediaButton');
return {
auth,
config,
collections,
user,
isFetching,
publishMode,
showMediaButton,
useMediaLibrary,
};
}
const mapDispatchToProps = {
openMediaLibrary,
loginUser,
logoutUser,
};
export default connect(mapStateToProps, mapDispatchToProps)(translate()(App));

View File

@@ -0,0 +1,266 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { css } from '@emotion/react';
import { translate } from 'react-polyglot';
import { NavLink } from 'react-router-dom';
import {
Icon,
Dropdown,
DropdownItem,
StyledDropdownButton,
colors,
lengths,
shadows,
buttons,
zIndex,
} from 'decap-cms-ui-default';
import { connect } from 'react-redux';
import { SettingsDropdown } from '../UI';
import { checkBackendStatus } from '../../actions/status';
const styles = {
buttonActive: css`
color: ${colors.active};
`,
};
function AppHeader(props) {
return (
<header
css={css`
${shadows.dropMain};
position: sticky;
width: 100%;
top: 0;
background-color: ${colors.foreground};
z-index: ${zIndex.zIndex300};
height: ${lengths.topBarHeight};
`}
{...props}
/>
);
}
const AppHeaderContent = styled.div`
display: flex;
justify-content: space-between;
min-width: 800px;
max-width: 1440px;
padding: 0 12px;
margin: 0 auto;
`;
const AppHeaderButton = styled.button`
${buttons.button};
background: none;
color: #7b8290;
font-family: inherit;
font-size: 16px;
font-weight: 500;
display: inline-flex;
padding: 16px 20px;
align-items: center;
${Icon} {
margin-right: 4px;
color: #b3b9c4;
}
&:hover,
&:active,
&:focus-visible {
${styles.buttonActive};
${Icon} {
${styles.buttonActive};
}
}
${props => css`
&.${props.activeClassName} {
${styles.buttonActive};
${Icon} {
${styles.buttonActive};
}
}
`};
`;
const AppHeaderNavLink = AppHeaderButton.withComponent(NavLink);
const AppHeaderActions = styled.div`
display: inline-flex;
align-items: center;
`;
const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
${buttons.button};
${buttons.medium};
${buttons.gray};
margin-right: 8px;
&:after {
top: 11px;
}
`;
const AppHeaderNavList = styled.ul`
display: flex;
margin: 0;
list-style: none;
`;
const AppHeaderLogo = styled.li`
display: flex;
align-items: center;
img {
padding: 12px 20px;
max-height: 56px;
max-width: 300px;
object-fit: contain;
object-position: center;
}
`;
class Header extends React.Component {
static propTypes = {
user: PropTypes.object.isRequired,
collections: ImmutablePropTypes.map.isRequired,
onCreateEntryClick: PropTypes.func.isRequired,
onLogoutClick: PropTypes.func.isRequired,
openMediaLibrary: PropTypes.func.isRequired,
hasWorkflow: PropTypes.bool.isRequired,
displayUrl: PropTypes.string,
logoUrl: PropTypes.string, // Deprecated, replaced by `logo.src`
logo: PropTypes.shape({
src: PropTypes.string.isRequired,
show_in_header: PropTypes.bool,
}),
isTestRepo: PropTypes.bool,
t: PropTypes.func.isRequired,
checkBackendStatus: PropTypes.func.isRequired,
};
intervalId;
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Header.propTypes, this.props, 'prop', 'Header');
this.intervalId = setInterval(() => {
this.props.checkBackendStatus();
}, 5 * 60 * 1000);
}
componentWillUnmount() {
clearInterval(this.intervalId);
}
handleCreatePostClick = collectionName => {
const { onCreateEntryClick } = this.props;
if (onCreateEntryClick) {
onCreateEntryClick(collectionName);
}
};
render() {
const {
user,
collections,
onLogoutClick,
openMediaLibrary,
hasWorkflow,
displayUrl,
logoUrl, // Deprecated, replaced by `logo.src`
logo,
isTestRepo,
t,
showMediaButton,
} = this.props;
const creatableCollections = collections
.filter(collection => collection.get('create'))
.toList();
const shouldShowLogo = logo?.show_in_header && logo?.src;
return (
<AppHeader>
<AppHeaderContent>
<nav>
<AppHeaderNavList>
{shouldShowLogo && (
<AppHeaderLogo>
<img src={logo?.src || logoUrl} alt="Logo" />
</AppHeaderLogo>
)}
<li>
<AppHeaderNavLink
to="/"
activeClassName="header-link-active"
isActive={(match, location) => location.pathname.startsWith('/collections/')}
>
<Icon type="page" />
{t('app.header.content')}
</AppHeaderNavLink>
</li>
{hasWorkflow && (
<li>
<AppHeaderNavLink to="/workflow" activeClassName="header-link-active">
<Icon type="workflow" />
{t('app.header.workflow')}
</AppHeaderNavLink>
</li>
)}
{showMediaButton && (
<li>
<AppHeaderButton onClick={openMediaLibrary}>
<Icon type="media-alt" />
{t('app.header.media')}
</AppHeaderButton>
</li>
)}
</AppHeaderNavList>
</nav>
<AppHeaderActions>
{creatableCollections.size > 0 && (
<Dropdown
renderButton={() => (
<AppHeaderQuickNewButton> {t('app.header.quickAdd')}</AppHeaderQuickNewButton>
)}
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{creatableCollections.map(collection => (
<DropdownItem
key={collection.get('name')}
label={collection.get('label_singular') || collection.get('label')}
onClick={() => this.handleCreatePostClick(collection.get('name'))}
/>
))}
</Dropdown>
)}
<SettingsDropdown
displayUrl={displayUrl}
isTestRepo={isTestRepo}
imageUrl={user?.avatar_url}
onLogoutClick={onLogoutClick}
/>
</AppHeaderActions>
</AppHeaderContent>
</AppHeader>
);
}
}
const mapDispatchToProps = {
checkBackendStatus,
};
export default connect(null, mapDispatchToProps)(translate()(Header));

View File

@@ -0,0 +1,23 @@
import React from 'react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { lengths } from 'decap-cms-ui-default';
import PropTypes from 'prop-types';
const NotFoundContainer = styled.div`
margin: ${lengths.pageMargin};
`;
function NotFoundPage({ t }) {
return (
<NotFoundContainer>
<h2>{t('app.notFoundPage.header')}</h2>
</NotFoundContainer>
);
}
NotFoundPage.propTypes = {
t: PropTypes.func.isRequired,
};
export default translate()(NotFoundPage);

View File

@@ -0,0 +1,210 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { translate } from 'react-polyglot';
import { lengths, components } from 'decap-cms-ui-default';
import { getNewEntryUrl } from '../../lib/urlHelper';
import Sidebar from './Sidebar';
import CollectionTop from './CollectionTop';
import EntriesCollection from './Entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch';
import CollectionControls from './CollectionControls';
import { sortByField, filterByField, changeViewStyle, groupByField } from '../../actions/entries';
import {
selectSortableFields,
selectViewFilters,
selectViewGroups,
} from '../../reducers/collections';
import {
selectEntriesSort,
selectEntriesFilter,
selectEntriesGroup,
selectViewStyle,
} from '../../reducers/entries';
const CollectionContainer = styled.div`
margin: ${lengths.pageMargin};
`;
const CollectionMain = styled.main`
padding-left: 280px;
`;
const SearchResultContainer = styled.div`
${components.cardTop};
margin-bottom: 22px;
`;
const SearchResultHeading = styled.h1`
${components.cardTopHeading};
`;
export class Collection extends React.Component {
static propTypes = {
searchTerm: PropTypes.string,
collectionName: PropTypes.string,
isSearchResults: PropTypes.bool,
isSingleSearchResult: PropTypes.bool,
collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.map.isRequired,
sortableFields: PropTypes.array,
sort: ImmutablePropTypes.orderedMap,
onSortClick: PropTypes.func.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Collection.propTypes, this.props, 'prop', 'Collection');
}
renderEntriesCollection = () => {
const { collection, filterTerm, viewStyle } = this.props;
return (
<EntriesCollection collection={collection} viewStyle={viewStyle} filterTerm={filterTerm} />
);
};
renderEntriesSearch = () => {
const { searchTerm, collections, collection, isSingleSearchResult } = this.props;
return (
<EntriesSearch
collections={isSingleSearchResult ? collections.filter(c => c === collection) : collections}
searchTerm={searchTerm}
/>
);
};
render() {
const {
collection,
collections,
collectionName,
isSearchEnabled,
isSearchResults,
isSingleSearchResult,
searchTerm,
sortableFields,
onSortClick,
sort,
viewFilters,
viewGroups,
filterTerm,
t,
onFilterClick,
onGroupClick,
filter,
group,
onChangeViewStyle,
viewStyle,
} = this.props;
let newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
if (newEntryUrl && filterTerm) {
newEntryUrl = getNewEntryUrl(collectionName);
if (filterTerm) {
newEntryUrl = `${newEntryUrl}?path=${filterTerm}`;
}
}
const searchResultKey =
'collection.collectionTop.searchResults' + (isSingleSearchResult ? 'InCollection' : '');
return (
<CollectionContainer>
<Sidebar
collections={collections}
collection={(!isSearchResults || isSingleSearchResult) && collection}
isSearchEnabled={isSearchEnabled}
searchTerm={searchTerm}
filterTerm={filterTerm}
/>
<CollectionMain>
{isSearchResults ? (
<SearchResultContainer>
<SearchResultHeading>
{t(searchResultKey, { searchTerm, collection: collection.get('label') })}
</SearchResultHeading>
</SearchResultContainer>
) : (
<>
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
<CollectionControls
viewStyle={viewStyle}
onChangeViewStyle={onChangeViewStyle}
sortableFields={sortableFields}
onSortClick={onSortClick}
sort={sort}
viewFilters={viewFilters}
viewGroups={viewGroups}
t={t}
onFilterClick={onFilterClick}
onGroupClick={onGroupClick}
filter={filter}
group={group}
/>
</>
)}
{isSearchResults ? this.renderEntriesSearch() : this.renderEntriesCollection()}
</CollectionMain>
</CollectionContainer>
);
}
}
function mapStateToProps(state, ownProps) {
const { collections } = state;
const isSearchEnabled = state.config && state.config.search != false;
const { isSearchResults, match, t } = ownProps;
const { name, searchTerm = '', filterTerm = '' } = match.params;
const collection = name ? collections.get(name) : collections.first();
const sort = selectEntriesSort(state.entries, collection.get('name'));
const sortableFields = selectSortableFields(collection, t);
const viewFilters = selectViewFilters(collection);
const viewGroups = selectViewGroups(collection);
const filter = selectEntriesFilter(state.entries, collection.get('name'));
const group = selectEntriesGroup(state.entries, collection.get('name'));
const viewStyle = selectViewStyle(state.entries);
return {
collection,
collections,
collectionName: name,
isSearchEnabled,
isSearchResults,
searchTerm,
filterTerm,
sort,
sortableFields,
viewFilters,
viewGroups,
filter,
group,
viewStyle,
};
}
const mapDispatchToProps = {
sortByField,
filterByField,
changeViewStyle,
groupByField,
};
function mergeProps(stateProps, dispatchProps, ownProps) {
return {
...stateProps,
...ownProps,
onSortClick: (key, direction) =>
dispatchProps.sortByField(stateProps.collection, key, direction),
onFilterClick: filter => dispatchProps.filterByField(stateProps.collection, filter),
onGroupClick: group => dispatchProps.groupByField(stateProps.collection, group),
onChangeViewStyle: viewStyle => dispatchProps.changeViewStyle(viewStyle),
};
}
const ConnectedCollection = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Collection);
export default translate()(ConnectedCollection);

View File

@@ -0,0 +1,58 @@
import React from 'react';
import styled from '@emotion/styled';
import { lengths } from 'decap-cms-ui-default';
import ViewStyleControl from './ViewStyleControl';
import SortControl from './SortControl';
import FilterControl from './FilterControl';
import GroupControl from './GroupControl';
const CollectionControlsContainer = styled.div`
display: flex;
align-items: center;
flex-direction: row-reverse;
margin-top: 22px;
width: ${lengths.topCardWidth};
max-width: 100%;
& > div {
margin-left: 6px;
}
`;
function CollectionControls({
viewStyle,
onChangeViewStyle,
sortableFields,
onSortClick,
sort,
viewFilters,
viewGroups,
onFilterClick,
onGroupClick,
t,
filter,
group,
}) {
return (
<CollectionControlsContainer>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{viewGroups.length > 0 && (
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
)}
{viewFilters.length > 0 && (
<FilterControl
viewFilters={viewFilters}
onFilterClick={onFilterClick}
t={t}
filter={filter}
/>
)}
{sortableFields.length > 0 && (
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
)}
</CollectionControlsContainer>
);
}
export default CollectionControls;

View File

@@ -0,0 +1,243 @@
import React from 'react';
import styled from '@emotion/styled';
import { colorsRaw, colors, Icon, lengths, zIndex } from 'decap-cms-ui-default';
import { translate } from 'react-polyglot';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
const SearchContainer = styled.div`
margin: 0 12px;
position: relative;
${Icon} {
position: absolute;
top: 0;
left: 6px;
z-index: ${zIndex.zIndex2};
height: 100%;
display: flex;
align-items: center;
pointer-events: none;
}
`;
const InputContainer = styled.div`
display: flex;
align-items: center;
position: relative;
`;
const SearchInput = styled.input`
background-color: #eff0f4;
border-radius: ${lengths.borderRadius};
font-size: 14px;
padding: 10px 6px 10px 34px;
width: 100%;
position: relative;
z-index: ${zIndex.zIndex1};
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px ${colorsRaw.blue};
}
`;
const SuggestionsContainer = styled.div`
position: relative;
width: 100%;
`;
const Suggestions = styled.ul`
position: absolute;
top: 6px;
left: 0;
right: 0;
padding: 10px 0;
margin: 0;
list-style: none;
background-color: #fff;
border-radius: ${lengths.borderRadius};
border: 1px solid ${colors.textFieldBorder};
z-index: ${zIndex.zIndex1};
`;
const SuggestionHeader = styled.li`
padding: 0 6px 6px 34px;
font-size: 12px;
color: ${colors.text};
`;
const SuggestionItem = styled.li(
({ isActive }) => `
color: ${isActive ? colors.active : colorsRaw.grayDark};
background-color: ${isActive ? colors.activeBackground : 'inherit'};
padding: 6px 6px 6px 34px;
cursor: pointer;
position: relative;
&:hover {
color: ${colors.active};
background-color: ${colors.activeBackground};
}
`,
);
const SuggestionDivider = styled.div`
width: 100%;
`;
class CollectionSearch extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map,
searchTerm: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
state = {
query: this.props.searchTerm,
suggestionsVisible: false,
// default to the currently selected
selectedCollectionIdx: this.getSelectedSelectionBasedOnProps(),
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(CollectionSearch.propTypes, this.props, 'prop', 'CollectionSearch');
}
componentDidUpdate(prevProps) {
if (prevProps.collection !== this.props.collection) {
const selectedCollectionIdx = this.getSelectedSelectionBasedOnProps();
this.setState({ selectedCollectionIdx });
}
}
getSelectedSelectionBasedOnProps() {
const { collection, collections } = this.props;
return collection ? collections.keySeq().indexOf(collection.get('name')) : -1;
}
toggleSuggestions(visible) {
this.setState({ suggestionsVisible: visible });
}
selectNextSuggestion() {
const { collections } = this.props;
const { selectedCollectionIdx } = this.state;
this.setState({
selectedCollectionIdx: Math.min(selectedCollectionIdx + 1, collections.size - 1),
});
}
selectPreviousSuggestion() {
const { selectedCollectionIdx } = this.state;
this.setState({
selectedCollectionIdx: Math.max(selectedCollectionIdx - 1, -1),
});
}
resetSelectedSuggestion() {
this.setState({
selectedCollectionIdx: -1,
});
}
submitSearch = () => {
const { onSubmit, collections } = this.props;
const { selectedCollectionIdx, query } = this.state;
this.toggleSuggestions(false);
if (selectedCollectionIdx !== -1) {
onSubmit(query, collections.toIndexedSeq().getIn([selectedCollectionIdx, 'name']));
} else {
onSubmit(query);
}
};
handleKeyDown = event => {
const { suggestionsVisible } = this.state;
if (event.key === 'Enter') {
this.submitSearch();
}
if (suggestionsVisible) {
// allow closing of suggestions with escape key
if (event.key === 'Escape') {
this.toggleSuggestions(false);
}
if (event.key === 'ArrowDown') {
this.selectNextSuggestion();
event.preventDefault();
} else if (event.key === 'ArrowUp') {
this.selectPreviousSuggestion();
event.preventDefault();
}
}
};
handleQueryChange = query => {
this.setState({ query });
this.toggleSuggestions(query !== '');
if (query === '') {
this.resetSelectedSuggestion();
}
};
handleSuggestionClick = (event, idx) => {
this.setState({ selectedCollectionIdx: idx }, this.submitSearch);
event.preventDefault();
};
render() {
const { collections, t } = this.props;
const { suggestionsVisible, selectedCollectionIdx, query } = this.state;
return (
<SearchContainer
onBlur={() => this.toggleSuggestions(false)}
onFocus={() => this.toggleSuggestions(query !== '')}
>
<InputContainer>
<Icon type="search" />
<SearchInput
onChange={e => this.handleQueryChange(e.target.value)}
onKeyDown={this.handleKeyDown}
onClick={() => this.toggleSuggestions(true)}
placeholder={t('collection.sidebar.searchAll')}
value={query}
/>
</InputContainer>
{suggestionsVisible && (
<SuggestionsContainer>
<Suggestions>
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
<SuggestionItem
isActive={selectedCollectionIdx === -1}
onClick={e => this.handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()}
>
{t('collection.sidebar.allCollections')}
</SuggestionItem>
<SuggestionDivider />
{collections.toIndexedSeq().map((collection, idx) => (
<SuggestionItem
key={idx}
isActive={idx === selectedCollectionIdx}
onClick={e => this.handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()}
>
{collection.get('label')}
</SuggestionItem>
))}
</Suggestions>
</SuggestionsContainer>
)}
</SearchContainer>
);
}
}
export default translate()(CollectionSearch);

View File

@@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import React from 'react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { Link } from 'react-router-dom';
import { components, buttons, shadows } from 'decap-cms-ui-default';
const CollectionTopContainer = styled.div`
${components.cardTop};
margin-bottom: 22px;
`;
const CollectionTopRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const CollectionTopHeading = styled.h1`
${components.cardTopHeading};
`;
const CollectionTopNewButton = styled(Link)`
${buttons.button};
${shadows.dropDeep};
${buttons.default};
${buttons.gray};
padding: 0 30px;
`;
const CollectionTopDescription = styled.p`
${components.cardTopDescription};
margin-bottom: 0;
`;
function getCollectionProps(collection) {
const collectionLabel = collection.get('label');
const collectionLabelSingular = collection.get('label_singular');
const collectionDescription = collection.get('description');
return {
collectionLabel,
collectionLabelSingular,
collectionDescription,
};
}
function CollectionTop({ collection, newEntryUrl, t }) {
const { collectionLabel, collectionLabelSingular, collectionDescription } = getCollectionProps(
collection,
t,
);
return (
<CollectionTopContainer>
<CollectionTopRow>
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
{newEntryUrl ? (
<CollectionTopNewButton to={newEntryUrl}>
{t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || collectionLabel,
})}
</CollectionTopNewButton>
) : null}
</CollectionTopRow>
{collectionDescription ? (
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
) : null}
</CollectionTopContainer>
);
}
CollectionTop.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
newEntryUrl: PropTypes.string,
t: PropTypes.func.isRequired,
};
export default translate()(CollectionTop);

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { buttons, StyledDropdownButton, colors } from 'decap-cms-ui-default';
const Button = styled(StyledDropdownButton)`
${buttons.button};
${buttons.medium};
${buttons.grayText};
font-size: 14px;
&:after {
top: 11px;
}
`;
export function ControlButton({ active, title }) {
return (
<Button
css={css`
color: ${active ? colors.active : undefined};
`}
>
{title}
</Button>
);
}

View File

@@ -0,0 +1,82 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from '@emotion/styled';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { Loader, lengths } from 'decap-cms-ui-default';
import EntryListing from './EntryListing';
const PaginationMessage = styled.div`
width: ${lengths.topCardWidth};
padding: 16px;
text-align: center;
`;
const NoEntriesMessage = styled(PaginationMessage)`
margin-top: 16px;
`;
function Entries({
collections,
entries,
isFetching,
viewStyle,
cursor,
handleCursorActions,
t,
page,
getWorkflowStatus,
getUnpublishedEntries,
filterTerm,
}) {
const loadingMessages = [
t('collection.entries.loadingEntries'),
t('collection.entries.cachingEntries'),
t('collection.entries.longerLoading'),
];
if (isFetching && page === undefined) {
return <Loader active>{loadingMessages}</Loader>;
}
const hasEntries = (entries && entries.size > 0) || cursor?.actions?.has('append_next');
if (hasEntries) {
return (
<>
<EntryListing
collections={collections}
entries={entries}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
page={page}
getWorkflowStatus={getWorkflowStatus}
getUnpublishedEntries={getUnpublishedEntries}
filterTerm={filterTerm}
/>
{isFetching && page !== undefined && entries.size > 0 ? (
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
) : null}
</>
);
}
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
}
Entries.propTypes = {
collections: ImmutablePropTypes.iterable.isRequired,
entries: ImmutablePropTypes.list,
page: PropTypes.number,
isFetching: PropTypes.bool,
viewStyle: PropTypes.string,
cursor: PropTypes.any.isRequired,
handleCursorActions: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
getWorkflowStatus: PropTypes.func,
getUnpublishedEntries: PropTypes.func,
filterTerm: PropTypes.string,
};
export default translate()(Entries);

View File

@@ -0,0 +1,277 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import partial from 'lodash/partial';
import { Cursor } from 'decap-cms-lib-util';
import { colors } from 'decap-cms-ui-default';
import {
loadEntries as actionLoadEntries,
traverseCollectionCursor as actionTraverseCollectionCursor,
} from '../../../actions/entries';
import { loadUnpublishedEntries } from '../../../actions/editorialWorkflow';
import {
selectEntries,
selectEntriesLoaded,
selectIsFetching,
selectGroups,
} from '../../../reducers/entries';
import { selectUnpublishedEntry, selectUnpublishedEntriesByStatus } from '../../../reducers';
import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
import Entries from './Entries';
const GroupHeading = styled.h2`
font-size: 22px;
font-weight: 600;
line-height: 37px;
padding-inline-start: 20px;
color: ${colors.textLead};
`;
const GroupContainer = styled.div``;
function getGroupEntries(entries, paths) {
return entries.filter(entry => paths.has(entry.get('path')));
}
function getGroupTitle(group, t) {
const { label, value } = group;
if (value === undefined) {
return t('collection.groups.other');
}
if (typeof value === 'boolean') {
return value ? label : t('collection.groups.negateLabel', { label });
}
return `${label} ${value}`.trim();
}
function withGroups(groups, entries, EntriesToRender, t) {
return groups.map(group => {
const title = getGroupTitle(group, t);
return (
<GroupContainer key={group.id} id={group.id}>
<GroupHeading>{title}</GroupHeading>
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
</GroupContainer>
);
});
}
export class EntriesCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.iterable,
page: PropTypes.number,
entries: ImmutablePropTypes.list,
groups: PropTypes.array,
isFetching: PropTypes.bool.isRequired,
viewStyle: PropTypes.string,
cursor: PropTypes.object.isRequired,
loadEntries: PropTypes.func.isRequired,
traverseCollectionCursor: PropTypes.func.isRequired,
entriesLoaded: PropTypes.bool,
loadUnpublishedEntries: PropTypes.func.isRequired,
unpublishedEntriesLoaded: PropTypes.bool,
isEditorialWorkflowEnabled: PropTypes.bool,
getWorkflowStatus: PropTypes.func.isRequired,
getUnpublishedEntries: PropTypes.func.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(EntriesCollection.propTypes, this.props, 'prop', 'EntriesCollection');
const {
collection,
collections,
entriesLoaded,
loadEntries,
unpublishedEntriesLoaded,
loadUnpublishedEntries,
isEditorialWorkflowEnabled,
} = this.props;
if (collection && !entriesLoaded) {
loadEntries(collection);
}
if (isEditorialWorkflowEnabled && !unpublishedEntriesLoaded) {
loadUnpublishedEntries(collections);
}
}
componentDidUpdate(prevProps) {
const {
collection,
collections,
entriesLoaded,
loadEntries,
unpublishedEntriesLoaded,
loadUnpublishedEntries,
isEditorialWorkflowEnabled,
} = this.props;
if (collection !== prevProps.collection && !entriesLoaded) {
loadEntries(collection);
}
if (
isEditorialWorkflowEnabled &&
(!unpublishedEntriesLoaded || collection !== prevProps.collection)
) {
loadUnpublishedEntries(collections);
}
}
handleCursorActions = (cursor, action) => {
const { collection, traverseCollectionCursor } = this.props;
traverseCollectionCursor(collection, action);
};
render() {
const {
collection,
entries,
groups,
isFetching,
viewStyle,
cursor,
page,
t,
getWorkflowStatus,
getUnpublishedEntries,
filterTerm,
} = this.props;
const EntriesToRender = ({ entries }) => {
return (
<Entries
collections={collection}
entries={entries}
isFetching={isFetching}
collectionName={collection.get('label')}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={partial(this.handleCursorActions, cursor)}
page={page}
getWorkflowStatus={getWorkflowStatus}
getUnpublishedEntries={getUnpublishedEntries}
filterTerm={filterTerm}
/>
);
};
if (groups && groups.length > 0) {
return withGroups(groups, entries, EntriesToRender, t);
}
return <EntriesToRender entries={entries} />;
}
}
export function filterNestedEntries(path, collectionFolder, entries, subfolders) {
const filtered = entries.filter(e => {
let entryPath = e.get('path').slice(collectionFolder.length + 1);
if (!entryPath.startsWith(path)) {
return false;
}
// for subdirectories, trim off the parent folder corresponding to
// this nested collection entry
if (path) {
entryPath = entryPath.slice(path.length + 1);
}
// if subfolders legacy mode is enabled, show only immediate subfolders
// also show index file in root folder
if (subfolders) {
const depth = entryPath.split('/').length;
return path ? depth === 2 : depth <= 2;
}
// only show immediate children
return !entryPath.includes('/');
});
return filtered;
}
function mapStateToProps(state, ownProps) {
const { collection, viewStyle, filterTerm } = ownProps;
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
const collections = state.collections;
let entries = selectEntries(state.entries, collection);
const groups = selectGroups(state.entries, collection);
if (collection.has('nested')) {
const collectionFolder = collection.get('folder');
entries = filterNestedEntries(
filterTerm || '',
collectionFolder,
entries,
collection.get('nested').get('subfolders') !== false,
);
}
const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name'));
const isFetching = selectIsFetching(state.entries, collection.get('name'));
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
const cursor = Cursor.create(rawCursor).clearData();
const isEditorialWorkflowEnabled = state.config?.publish_mode === 'editorial_workflow';
const unpublishedEntriesLoaded = isEditorialWorkflowEnabled
? !!state.editorialWorkflow?.getIn(['pages', 'ids'], false)
: true;
return {
collection,
collections,
page,
entries,
groups,
entriesLoaded,
isFetching,
viewStyle,
cursor,
unpublishedEntriesLoaded,
isEditorialWorkflowEnabled,
getWorkflowStatus: (collectionName, slug) => {
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
return unpublishedEntry ? unpublishedEntry.get('status') : null;
},
getUnpublishedEntries: collectionName => {
if (!isEditorialWorkflowEnabled) return [];
const allStatuses = ['draft', 'pending_review', 'pending_publish'];
const unpublishedEntries = [];
allStatuses.forEach(statusKey => {
const entriesForStatus = selectUnpublishedEntriesByStatus(state, statusKey);
if (entriesForStatus) {
entriesForStatus.forEach(entry => {
if (entry.get('collection') === collectionName) {
const entryWithCollection = entry.set('collection', collectionName);
unpublishedEntries.push(entryWithCollection);
}
});
}
});
return unpublishedEntries;
},
};
}
const mapDispatchToProps = {
loadEntries: actionLoadEntries,
traverseCollectionCursor: actionTraverseCollectionCursor,
loadUnpublishedEntries: collections => loadUnpublishedEntries(collections),
};
const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection);
export default translate()(ConnectedEntriesCollection);

View File

@@ -0,0 +1,102 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import isEqual from 'lodash/isEqual';
import { Cursor } from 'decap-cms-lib-util';
import { selectSearchedEntries, selectUnpublishedEntry } from '../../../reducers';
import {
searchEntries as actionSearchEntries,
clearSearch as actionClearSearch,
} from '../../../actions/search';
import Entries from './Entries';
class EntriesSearch extends React.Component {
static propTypes = {
isFetching: PropTypes.bool,
searchEntries: PropTypes.func.isRequired,
clearSearch: PropTypes.func.isRequired,
searchTerm: PropTypes.string.isRequired,
collections: ImmutablePropTypes.seq,
collectionNames: PropTypes.array,
entries: ImmutablePropTypes.list,
page: PropTypes.number,
getWorkflowStatus: PropTypes.func,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(EntriesSearch.propTypes, this.props, 'prop', 'EntriesSearch');
const { searchTerm, searchEntries, collectionNames } = this.props;
searchEntries(searchTerm, collectionNames);
}
componentDidUpdate(prevProps) {
const { searchTerm, collectionNames } = this.props;
// check if the search parameters are the same
if (prevProps.searchTerm === searchTerm && isEqual(prevProps.collectionNames, collectionNames))
return;
const { searchEntries } = prevProps;
searchEntries(searchTerm, collectionNames);
}
componentWillUnmount() {
this.props.clearSearch();
}
getCursor = () => {
const { page } = this.props;
return Cursor.create({
actions: isNaN(page) ? [] : ['append_next'],
});
};
handleCursorActions = action => {
const { page, searchTerm, searchEntries, collectionNames } = this.props;
if (action === 'append_next') {
const nextPage = page + 1;
searchEntries(searchTerm, collectionNames, nextPage);
}
};
render() {
const { collections, entries, isFetching, getWorkflowStatus } = this.props;
return (
<Entries
cursor={this.getCursor()}
handleCursorActions={this.handleCursorActions}
collections={collections}
entries={entries}
isFetching={isFetching}
getWorkflowStatus={getWorkflowStatus}
/>
);
}
}
function mapStateToProps(state, ownProps) {
const { searchTerm } = ownProps;
const collections = ownProps.collections.toIndexedSeq();
const collectionNames = ownProps.collections.keySeq().toArray();
const isFetching = state.search.isFetching;
const page = state.search.page;
const entries = selectSearchedEntries(state, collectionNames);
function getWorkflowStatus(collectionName, slug) {
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
return unpublishedEntry ? unpublishedEntry.get('status') : null;
}
return { isFetching, page, collections, collectionNames, entries, searchTerm, getWorkflowStatus };
}
const mapDispatchToProps = {
searchEntries: actionSearchEntries,
clearSearch: actionClearSearch,
};
export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch);

View File

@@ -0,0 +1,246 @@
import React from 'react';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { colors, colorsRaw, components, lengths, zIndex } from 'decap-cms-ui-default';
import { translate } from 'react-polyglot';
import { boundGetAsset } from '../../../actions/media';
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from '../../../constants/collectionViews';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import { selectEntryCollectionTitle } from '../../../reducers/collections';
const ListCard = styled.li`
${components.card};
width: ${lengths.topCardWidth};
margin-left: 12px;
margin-bottom: 10px;
overflow: hidden;
`;
const ListCardLink = styled(Link)`
display: block;
max-width: 100%;
padding: 16px 20px;
&:hover {
background-color: ${colors.foreground};
}
`;
const GridCard = styled.li`
${components.card};
flex: 0 0 335px;
height: 240px;
overflow: hidden;
margin-left: 12px;
margin-bottom: 16px;
`;
const GridCardLink = styled(Link)`
display: block;
height: 100%;
outline-offset: -2px;
&,
&:hover {
background-color: ${colors.foreground};
color: ${colors.text};
}
`;
const CollectionLabel = styled.h2`
font-size: 12px;
color: ${colors.textLead};
text-transform: uppercase;
`;
const ListCardTitle = styled.h2`
margin-bottom: 0;
display: flex;
justify-content: space-between;
`;
const CardHeading = styled.h2`
margin: 0 0 2px;
display: flex;
justify-content: space-between;
`;
const CardBody = styled.div`
padding: 16px 20px;
height: 90px;
position: relative;
margin-bottom: ${props => props.hasImage && 0};
&:after {
content: '';
position: absolute;
display: block;
z-index: ${zIndex.zIndex1};
bottom: 0;
left: -20%;
height: 140%;
width: 140%;
box-shadow: inset 0 -15px 24px ${colorsRaw.white};
}
`;
const CardImage = styled.div`
background-image: url(${props => props.src});
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
height: 150px;
`;
const TitleIcons = styled.div`
display: flex;
align-items: center;
gap: 8px;
`;
const WorkflowBadge = styled.span`
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
text-transform: uppercase;
background-color: ${props => {
switch (props.status) {
case 'draft':
return colors.statusDraftBackground;
case 'pending_review':
return colors.statusReviewBackground;
case 'pending_publish':
return colors.statusReadyBackground;
default:
return colors.background;
}
}};
color: ${props => {
switch (props.status) {
case 'draft':
return colors.statusDraftText;
case 'pending_review':
return colors.statusReviewText;
case 'pending_publish':
return colors.statusReadyText;
default:
return colors.text;
}
}};
`;
function EntryCard({
path,
summary,
image,
imageField,
collectionLabel,
viewStyle = VIEW_STYLE_LIST,
workflowStatus,
getAsset,
t,
}) {
function getStatusLabel(status) {
switch (status) {
case 'pending_review':
return t('editor.editorToolbar.inReview');
case 'pending_publish':
return t('editor.editorToolbar.ready');
case 'draft':
return t('editor.editorToolbar.draft');
default:
return status;
}
}
if (viewStyle === VIEW_STYLE_LIST) {
return (
<ListCard>
<ListCardLink to={path}>
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<ListCardTitle>
{summary}
<TitleIcons>
{workflowStatus && (
<WorkflowBadge status={workflowStatus}>
{getStatusLabel(workflowStatus)}
</WorkflowBadge>
)}
</TitleIcons>
</ListCardTitle>
</ListCardLink>
</ListCard>
);
}
if (viewStyle === VIEW_STYLE_GRID) {
return (
<GridCard>
<GridCardLink to={path}>
<CardBody hasImage={image}>
{collectionLabel ? <CollectionLabel>{collectionLabel}</CollectionLabel> : null}
<CardHeading>
{summary}
<TitleIcons>
{workflowStatus && (
<WorkflowBadge status={workflowStatus}>
{getStatusLabel(workflowStatus)}
</WorkflowBadge>
)}
</TitleIcons>
</CardHeading>
</CardBody>
{image ? <CardImage src={getAsset(image, imageField).toString()} /> : null}
</GridCardLink>
</GridCard>
);
}
}
function mapStateToProps(state, ownProps) {
const { entry, inferredFields, collection } = ownProps;
const entryData = entry.get('data');
const summary = selectEntryCollectionTitle(collection, entry);
let image = entryData.get(inferredFields.imageField);
if (image) {
image = encodeURI(image);
}
const isLoadingAsset = selectIsLoadingAsset(state.medias);
return {
summary,
path: `/collections/${collection.get('name')}/entries/${entry.get('slug')}`,
image,
imageFolder: collection
.get('fields')
?.find(f => f.get('name') === inferredFields.imageField && f.get('widget') === 'image'),
isLoadingAsset,
};
}
function mapDispatchToProps(dispatch) {
return {
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
};
}
function mergeProps(stateProps, dispatchProps, ownProps) {
return {
...stateProps,
...dispatchProps,
...ownProps,
getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry),
};
}
const ConnectedEntryCard = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
)(translate()(EntryCard));
export default ConnectedEntryCard;

View File

@@ -0,0 +1,151 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { Waypoint } from 'react-waypoint';
import { Map, List } from 'immutable';
import { selectFields, selectInferredField } from '../../../reducers/collections';
import { filterNestedEntries } from './EntriesCollection';
import EntryCard from './EntryCard';
const CardsGrid = styled.ul`
display: flex;
flex-flow: row wrap;
list-style-type: none;
margin-left: -12px;
margin-top: 16px;
margin-bottom: 16px;
`;
class EntryListing extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.iterable.isRequired,
entries: ImmutablePropTypes.list,
viewStyle: PropTypes.string,
cursor: PropTypes.any.isRequired,
handleCursorActions: PropTypes.func.isRequired,
page: PropTypes.number,
getUnpublishedEntries: PropTypes.func.isRequired,
getWorkflowStatus: PropTypes.func.isRequired,
filterTerm: PropTypes.string,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(EntryListing.propTypes, this.props, 'prop', 'EntryListing');
}
hasMore = () => {
const hasMore = this.props.cursor?.actions?.has('append_next');
return hasMore;
};
handleLoadMore = () => {
if (this.hasMore()) {
this.props.handleCursorActions('append_next');
}
};
inferFields = collection => {
const titleField = selectInferredField(collection, 'title');
const descriptionField = selectInferredField(collection, 'description');
const imageField = selectInferredField(collection, 'image');
const fields = selectFields(collection);
const inferredFields = [titleField, descriptionField, imageField];
const remainingFields =
fields && fields.filter(f => inferredFields.indexOf(f.get('name')) === -1);
return { titleField, descriptionField, imageField, remainingFields };
};
getAllEntries = () => {
const { entries, collections, filterTerm } = this.props;
const collectionName = Map.isMap(collections) ? collections.get('name') : null;
if (!collectionName) {
return entries;
}
const unpublishedEntries = this.props.getUnpublishedEntries(collectionName);
if (!unpublishedEntries || unpublishedEntries.length === 0) {
return entries;
}
let unpublishedList = List(unpublishedEntries.map(entry => entry));
if (collections.has('nested') && filterTerm) {
const collectionFolder = collections.get('folder');
const subfolders = collections.get('nested').get('subfolders') !== false;
unpublishedList = filterNestedEntries(
filterTerm,
collectionFolder,
unpublishedList,
subfolders,
);
}
const publishedSlugs = entries.map(entry => entry.get('slug')).toSet();
const uniqueUnpublished = unpublishedList.filterNot(entry =>
publishedSlugs.has(entry.get('slug')),
);
return entries.concat(uniqueUnpublished);
};
renderCardsForSingleCollection = () => {
const { collections, viewStyle } = this.props;
const allEntries = this.getAllEntries();
const inferredFields = this.inferFields(collections);
const entryCardProps = { collection: collections, inferredFields, viewStyle };
return allEntries.map((entry, idx) => {
const workflowStatus = this.props.getWorkflowStatus(
collections.get('name'),
entry.get('slug'),
);
return (
<EntryCard {...entryCardProps} entry={entry} workflowStatus={workflowStatus} key={idx} />
);
});
};
renderCardsForMultipleCollections = () => {
const { collections, entries } = this.props;
const isSingleCollectionInList = collections.size === 1;
return entries.map((entry, idx) => {
const collectionName = entry.get('collection');
const collection = collections.find(coll => coll.get('name') === collectionName);
const collectionLabel = !isSingleCollectionInList && collection.get('label');
const inferredFields = this.inferFields(collection);
const workflowStatus = this.props.getWorkflowStatus(collectionName, entry.get('slug'));
const entryCardProps = {
collection,
entry,
inferredFields,
collectionLabel,
workflowStatus,
};
return <EntryCard {...entryCardProps} key={idx} />;
});
};
render() {
const { collections, page } = this.props;
return (
<div>
<CardsGrid>
{Map.isMap(collections)
? this.renderCardsForSingleCollection()
: this.renderCardsForMultipleCollections()}
{this.hasMore() && <Waypoint key={page} onEnter={this.handleLoadMore} />}
</CardsGrid>
</div>
);
}
}
export default EntryListing;

View File

@@ -0,0 +1,163 @@
import React from 'react';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import ConnectedEntriesCollection, {
EntriesCollection,
filterNestedEntries,
} from '../EntriesCollection';
jest.mock('../Entries', () => 'mock-entries');
const middlewares = [];
const mockStore = configureStore(middlewares);
function createMockStore(collection, entriesArray, additionalState = {}) {
return mockStore({
entries: toEntriesState(collection, entriesArray),
cursors: fromJS({}),
config: fromJS({ publish_mode: 'simple' }),
collections: fromJS({ [collection.get('name')]: collection }),
editorialWorkflow: fromJS({
pages: { ids: [] },
}),
...additionalState,
});
}
function renderWithRedux(component, { store } = {}) {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
}
function toEntriesState(collection, entriesArray) {
const entries = entriesArray.reduce(
(acc, entry) => {
acc.entities[`${collection.get('name')}.${entry.slug}`] = entry;
acc.pages[collection.get('name')].ids.push(entry.slug);
return acc;
},
{ pages: { [collection.get('name')]: { ids: [] } }, entities: {} },
);
return fromJS(entries);
}
describe('filterNestedEntries', () => {
it('should return only immediate children for non root path', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const entries = fromJS(entriesArray);
expect(filterNestedEntries('dir3', 'src/pages', entries).toJS()).toEqual([
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
]);
});
it('should return only immediate children for root path', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const entries = fromJS(entriesArray);
expect(filterNestedEntries('', 'src/pages', entries).toJS()).toEqual([
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
]);
});
});
describe('EntriesCollection', () => {
const collection = fromJS({ name: 'pages', label: 'Pages', folder: 'src/pages' });
const props = {
t: jest.fn(),
loadEntries: jest.fn(),
traverseCollectionCursor: jest.fn(),
loadUnpublishedEntries: jest.fn(),
isFetching: false,
cursor: {},
collection,
collections: fromJS({ pages: collection }),
entriesLoaded: true,
unpublishedEntriesLoaded: true,
isEditorialWorkflowEnabled: false,
getWorkflowStatus: jest.fn(),
getUnpublishedEntries: jest.fn(() => []),
};
it('should render with entries', () => {
const entries = fromJS([{ slug: 'index' }]);
const { asFragment } = render(<EntriesCollection {...props} entries={entries} />);
expect(asFragment()).toMatchSnapshot();
});
it('should render connected component', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir2/index', path: 'src/pages/dir2/index.md', data: { title: 'File 2' } },
];
const store = createMockStore(collection, entriesArray);
const { asFragment } = renderWithRedux(<ConnectedEntriesCollection collection={collection} />, {
store,
});
expect(asFragment()).toMatchSnapshot();
});
it('should render show only immediate children for nested collection', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const store = createMockStore(collection, entriesArray);
const { asFragment } = renderWithRedux(
<ConnectedEntriesCollection
collection={collection.set('nested', fromJS({ depth: 10, subfolders: false }))}
/>,
{ store },
);
expect(asFragment()).toMatchSnapshot();
});
it('should render with applied filter term for nested collections', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'dir1/index', path: 'src/pages/dir1/index.md', data: { title: 'File 1' } },
{ slug: 'dir1/dir2/index', path: 'src/pages/dir1/dir2/index.md', data: { title: 'File 2' } },
{ slug: 'dir3/index', path: 'src/pages/dir3/index.md', data: { title: 'File 3' } },
{ slug: 'dir3/dir4/index', path: 'src/pages/dir3/dir4/index.md', data: { title: 'File 4' } },
];
const store = createMockStore(collection, entriesArray);
const { asFragment } = renderWithRedux(
<ConnectedEntriesCollection
collection={collection.set('nested', fromJS({ depth: 10, subfolders: false }))}
filterTerm="dir3/dir4"
/>,
{ store },
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EntriesCollection should render connected component 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\" }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } }, Map { \\"slug\\": \\"dir1/index\\", \\"path\\": \\"src/pages/dir1/index.md\\", \\"data\\": Map { \\"title\\": \\"File 1\\" } }, Map { \\"slug\\": \\"dir2/index\\", \\"path\\": \\"src/pages/dir2/index.md\\", \\"data\\": Map { \\"title\\": \\"File 2\\" } } ]"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render show only immediate children for nested collection 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10, \\"subfolders\\": false } }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\", \\"path\\": \\"src/pages/index.md\\", \\"data\\": Map { \\"title\\": \\"Root\\" } } ]"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render with applied filter term for nested collections 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\", \\"nested\\": Map { \\"depth\\": 10, \\"subfolders\\": false } }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"dir3/dir4/index\\", \\"path\\": \\"src/pages/dir3/dir4/index.md\\", \\"data\\": Map { \\"title\\": \\"File 4\\" } } ]"
filterterm="dir3/dir4"
/>
</DocumentFragment>
`;
exports[`EntriesCollection should render with entries 1`] = `
<DocumentFragment>
<mock-entries
collectionname="Pages"
collections="Map { \\"name\\": \\"pages\\", \\"label\\": \\"Pages\\", \\"folder\\": \\"src/pages\\" }"
cursor="[object Object]"
entries="List [ Map { \\"slug\\": \\"index\\" } ]"
/>
</DocumentFragment>
`;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { translate } from 'react-polyglot';
import { Dropdown, DropdownCheckedItem } from 'decap-cms-ui-default';
import { ControlButton } from './ControlButton';
function FilterControl({ viewFilters, t, onFilterClick, filter }) {
const hasActiveFilter = filter
?.valueSeq()
.toJS()
.some(f => f.active === true);
return (
<Dropdown
renderButton={() => {
return (
<ControlButton active={hasActiveFilter} title={t('collection.collectionTop.filterBy')} />
);
}}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownPosition="left"
>
{viewFilters.map(viewFilter => {
return (
<DropdownCheckedItem
key={viewFilter.id}
label={viewFilter.label}
id={viewFilter.id}
checked={filter.getIn([viewFilter.id, 'active'], false)}
onClick={() => onFilterClick(viewFilter)}
/>
);
})}
</Dropdown>
);
}
export default translate()(FilterControl);

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { translate } from 'react-polyglot';
import { Dropdown, DropdownItem } from 'decap-cms-ui-default';
import { ControlButton } from './ControlButton';
function GroupControl({ viewGroups, t, onGroupClick, group }) {
const hasActiveGroup = group
?.valueSeq()
.toJS()
.some(f => f.active === true);
return (
<Dropdown
renderButton={() => {
return (
<ControlButton active={hasActiveGroup} title={t('collection.collectionTop.groupBy')} />
);
}}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{viewGroups.map(viewGroup => {
return (
<DropdownItem
key={viewGroup.id}
label={viewGroup.label}
onClick={() => onGroupClick(viewGroup)}
isActive={group.getIn([viewGroup.id, 'active'], false)}
/>
);
})}
</Dropdown>
);
}
export default translate()(GroupControl);

View File

@@ -0,0 +1,323 @@
import React from 'react';
import { List } from 'immutable';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
import { dirname, sep } from 'path';
import { stringTemplate } from 'decap-cms-lib-widgets';
import { Icon, colors, components } from 'decap-cms-ui-default';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import sortBy from 'lodash/sortBy';
import { selectEntries } from '../../reducers/entries';
import { selectEntryCollectionTitle } from '../../reducers/collections';
const { addFileTemplateFields } = stringTemplate;
const NodeTitleContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const NodeTitle = styled.div`
margin-right: 4px;
`;
const Caret = styled.div`
position: relative;
top: 2px;
`;
const CaretDown = styled(Caret)`
${components.caretDown};
color: currentColor;
`;
const CaretRight = styled(Caret)`
${components.caretRight};
color: currentColor;
left: 2px;
`;
const TreeNavLink = styled(NavLink)`
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px;
padding-left: ${props => props.depth * 16 + 18}px;
border-left: 2px solid #fff;
${Icon} {
margin-right: 4px;
flex-shrink: 0;
}
${props => css`
&:hover,
&:active,
&.${props.activeClassName} {
color: ${colors.active};
background-color: ${colors.activeBackground};
border-left-color: #4863c6;
}
`};
`;
function getNodeTitle(node) {
const title = node.isRoot
? node.title
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
return title;
}
function TreeNode(props) {
const { collection, treeData, depth = 0, onToggle } = props;
const collectionName = collection.get('name');
const sortedData = sortBy(treeData, getNodeTitle);
const subfolders = collection.get('nested')?.get('subfolders') !== false;
return sortedData.map(node => {
const leaf =
depth > 0 &&
(subfolders
? node.children.length <= 1 && !node.children[0]?.isDir
: node.children.length === 0);
if (leaf) {
return null;
}
let to = `/collections/${collectionName}`;
if (depth > 0) {
to = `${to}/filter${node.path}`;
}
const title = getNodeTitle(node);
const hasChildren =
depth === 0 ||
(subfolders
? node.children.some(c => c.children.some(c => c.isDir))
: node.children.some(c => c.isDir));
return (
<React.Fragment key={node.path}>
<TreeNavLink
exact
to={to}
activeClassName="sidebar-active"
onClick={() => onToggle({ node, expanded: !node.expanded })}
depth={depth}
data-testid={node.path}
>
<Icon type="write" />
<NodeTitleContainer>
<NodeTitle>{title}</NodeTitle>
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
</NodeTitleContainer>
</TreeNavLink>
{node.expanded && (
<TreeNode
collection={collection}
depth={depth + 1}
treeData={node.children}
onToggle={onToggle}
/>
)}
</React.Fragment>
);
});
}
TreeNode.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
depth: PropTypes.number,
treeData: PropTypes.array.isRequired,
onToggle: PropTypes.func.isRequired,
};
export function walk(treeData, callback) {
function traverse(children) {
for (const child of children) {
callback(child);
traverse(child.children);
}
}
return traverse(treeData);
}
export function getTreeData(collection, entries) {
const collectionFolder = collection.get('folder');
const rootFolder = '/';
const entriesObj = entries
.toJS()
.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));
const dirs = entriesObj.reduce((acc, entry) => {
let dir = dirname(entry.path);
while (!acc[dir] && dir && dir !== rootFolder) {
const parts = dir.split(sep);
acc[dir] = parts.pop();
dir = parts.length && parts.join(sep);
}
return acc;
}, {});
if (collection.getIn(['nested', 'summary'])) {
collection = collection.set('summary', collection.getIn(['nested', 'summary']));
} else {
collection = collection.delete('summary');
}
const flatData = [
{
title: collection.get('label'),
path: rootFolder,
isDir: true,
isRoot: true,
},
...Object.entries(dirs).map(([key, value]) => ({
title: value,
path: key,
isDir: true,
isRoot: false,
})),
...entriesObj.map((e, index) => {
let entryMap = entries.get(index);
entryMap = entryMap.set(
'data',
addFileTemplateFields(entryMap.get('path'), entryMap.get('data')),
);
const title = selectEntryCollectionTitle(collection, entryMap);
return {
...e,
title,
isDir: false,
isRoot: false,
};
}),
];
const parentsToChildren = flatData.reduce((acc, node) => {
const parent = node.path === rootFolder ? '' : dirname(node.path);
if (acc[parent]) {
acc[parent].push(node);
} else {
acc[parent] = [node];
}
return acc;
}, {});
function reducer(acc, value) {
const node = value;
let children = [];
if (parentsToChildren[node.path]) {
children = parentsToChildren[node.path].reduce(reducer, []);
}
acc.push({ ...node, children });
return acc;
}
const treeData = parentsToChildren[''].reduce(reducer, []);
return treeData;
}
export function updateNode(treeData, node, callback) {
let stop = false;
function updater(nodes) {
if (stop) {
return nodes;
}
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].path === node.path) {
nodes[i] = callback(node);
stop = true;
return nodes;
}
}
nodes.forEach(node => updater(node.children));
return nodes;
}
return updater([...treeData]);
}
export class NestedCollection extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entries: ImmutablePropTypes.list.isRequired,
filterTerm: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
treeData: getTreeData(this.props.collection, this.props.entries),
selected: null,
useFilter: true,
};
}
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(NestedCollection.propTypes, this.props, 'prop', 'NestedCollection');
}
componentDidUpdate(prevProps) {
const { collection, entries, filterTerm } = this.props;
if (
collection !== prevProps.collection ||
entries !== prevProps.entries ||
filterTerm !== prevProps.filterTerm
) {
const expanded = {};
walk(this.state.treeData, node => {
if (node.expanded) {
expanded[node.path] = true;
}
});
const treeData = getTreeData(collection, entries);
const path = `/${filterTerm}`;
walk(treeData, node => {
if (expanded[node.path] || (this.state.useFilter && path.startsWith(node.path))) {
node.expanded = true;
}
});
this.setState({ treeData });
}
}
onToggle = ({ node, expanded }) => {
if (!this.state.selected || this.state.selected.path === node.path || expanded) {
const treeData = updateNode(this.state.treeData, node, node => ({
...node,
expanded,
}));
this.setState({ treeData, selected: node, useFilter: false });
} else {
// don't collapse non selected nodes when clicked
this.setState({ selected: node, useFilter: false });
}
};
render() {
const { treeData } = this.state;
const { collection } = this.props;
return <TreeNode collection={collection} treeData={treeData} onToggle={this.onToggle} />;
}
}
function mapStateToProps(state, ownProps) {
const { collection } = ownProps;
const entries = selectEntries(state.entries, collection) || List();
return { entries };
}
export default connect(mapStateToProps, null)(NestedCollection);

View File

@@ -0,0 +1,136 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { css } from '@emotion/react';
import { translate } from 'react-polyglot';
import { NavLink } from 'react-router-dom';
import { Icon, components, colors } from 'decap-cms-ui-default';
import { searchCollections } from '../../actions/collections';
import CollectionSearch from './CollectionSearch';
import NestedCollection from './NestedCollection';
const styles = {
sidebarNavLinkActive: css`
color: ${colors.active};
background-color: ${colors.activeBackground};
border-left-color: #4863c6;
`,
};
const SidebarContainer = styled.aside`
${components.card};
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: flex;
flex-direction: column;
`;
const SidebarHeading = styled.h2`
font-size: 22px;
font-weight: 600;
line-height: 37px;
padding: 0;
margin: 10px 20px;
color: ${colors.textLead};
`;
const SidebarNavList = styled.ul`
margin: 12px 0 0;
list-style: none;
overflow: auto;
`;
const SidebarNavLink = styled(NavLink)`
display: flex;
font-size: 14px;
font-weight: 500;
align-items: center;
padding: 8px 18px;
border-left: 2px solid #fff;
z-index: -1;
${Icon} {
margin-right: 4px;
flex-shrink: 0;
}
${props => css`
&:hover,
&:active,
&.${props.activeClassName} {
${styles.sidebarNavLinkActive};
}
`};
`;
export class Sidebar extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map,
isSearchEnabled: PropTypes.bool,
searchTerm: PropTypes.string,
filterTerm: PropTypes.string,
t: PropTypes.func.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Sidebar.propTypes, this.props, 'prop', 'Sidebar');
}
renderLink = (collection, filterTerm) => {
const collectionName = collection.get('name');
if (collection.has('nested')) {
return (
<li key={collectionName}>
<NestedCollection
collection={collection}
filterTerm={filterTerm}
data-testid={collectionName}
/>
</li>
);
}
return (
<li key={collectionName}>
<SidebarNavLink
to={`/collections/${collectionName}`}
activeClassName="sidebar-active"
data-testid={collectionName}
>
<Icon type="write" />
{collection.get('label')}
</SidebarNavLink>
</li>
);
};
render() {
const { collections, collection, isSearchEnabled, searchTerm, t, filterTerm } = this.props;
return (
<SidebarContainer>
<SidebarHeading>{t('collection.sidebar.collections')}</SidebarHeading>
{isSearchEnabled && (
<CollectionSearch
searchTerm={searchTerm}
collections={collections}
collection={collection}
onSubmit={(query, collection) => searchCollections(query, collection)}
/>
)}
<SidebarNavList>
{collections
.toList()
.filter(collection => collection.get('hide') !== true)
.map(collection => this.renderLink(collection, filterTerm))}
</SidebarNavList>
</SidebarContainer>
);
}
}
export default translate()(Sidebar);

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { translate } from 'react-polyglot';
import { Dropdown, DropdownItem } from 'decap-cms-ui-default';
import { SortDirection } from '../../types/redux';
import { ControlButton } from './ControlButton';
function nextSortDirection(direction) {
switch (direction) {
case SortDirection.Ascending:
return SortDirection.Descending;
case SortDirection.Descending:
return SortDirection.None;
default:
return SortDirection.Ascending;
}
}
function sortIconProps(sortDir) {
return {
icon: 'chevron',
iconDirection: sortIconDirections[sortDir],
iconSmall: true,
};
}
const sortIconDirections = {
[SortDirection.Ascending]: 'up',
[SortDirection.Descending]: 'down',
};
function SortControl({ t, fields, onSortClick, sort }) {
const hasActiveSort = sort
?.valueSeq()
.toJS()
.some(s => s.direction !== SortDirection.None);
return (
<Dropdown
renderButton={() => {
return (
<ControlButton active={hasActiveSort} title={t('collection.collectionTop.sortBy')} />
);
}}
closeOnSelection={false}
dropdownTopOverlap="30px"
dropdownWidth="160px"
dropdownPosition="left"
>
{fields.map(field => {
const sortDir = sort?.getIn([field.key, 'direction']);
const isActive = sortDir && sortDir !== SortDirection.None;
const nextSortDir = nextSortDirection(sortDir);
return (
<DropdownItem
key={field.key}
label={field.label}
onClick={() => onSortClick(field.key, nextSortDir)}
isActive={isActive}
{...(isActive && sortIconProps(sortDir))}
/>
);
})}
</Dropdown>
);
}
export default translate()(SortControl);

View File

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

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import ConnectedCollection, { Collection } from '../Collection';
jest.mock('../Entries/EntriesCollection', () => 'mock-entries-collection');
jest.mock('../CollectionTop', () => 'mock-collection-top');
jest.mock('../CollectionControls', () => 'mock-collection-controls');
jest.mock('../Sidebar', () => 'mock-sidebar');
const middlewares = [];
const mockStore = configureStore(middlewares);
function renderWithRedux(component, { store } = {}) {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
}
describe('Collection', () => {
const collection = fromJS({
name: 'pages',
sortable_fields: [],
view_filters: [],
view_groups: [],
});
const props = {
collections: fromJS([collection]).toOrderedMap(),
collection,
collectionName: collection.get('name'),
t: jest.fn(key => key),
onSortClick: jest.fn(),
};
it('should render with collection without create url', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', false)} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render with collection with create url', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render with collection with create url and path', () => {
const { asFragment } = render(
<Collection {...props} collection={collection.set('create', true)} filterTerm="dir1/dir2" />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render connected component', () => {
const store = mockStore({
collections: props.collections,
entries: fromJS({}),
});
const { asFragment } = renderWithRedux(<ConnectedCollection match={{ params: {} }} />, {
store,
});
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,445 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, fireEvent } from '@testing-library/react';
import { fromJS } from 'immutable';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import ConnectedNestedCollection, {
NestedCollection,
getTreeData,
walk,
updateNode,
} from '../NestedCollection';
jest.mock('decap-cms-ui-default', () => {
const actual = jest.requireActual('decap-cms-ui-default');
return {
...actual,
Icon: 'mocked-icon',
};
});
const middlewares = [];
const mockStore = configureStore(middlewares);
function renderWithRedux(component, { store } = {}) {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return render(component, { wrapper: Wrapper });
}
describe('NestedCollection', () => {
const collection = fromJS({
name: 'pages',
label: 'Pages',
folder: 'src/pages',
fields: [{ name: 'title', widget: 'string' }],
nested: {
subfolders: false,
},
});
it('should render correctly with no entries', () => {
const entries = fromJS([]);
const { asFragment, getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
expect(getByTestId('/')).toHaveTextContent('Pages');
expect(getByTestId('/')).toHaveAttribute('href', '/collections/pages');
expect(asFragment()).toMatchSnapshot();
});
it('should render correctly with nested entries', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
]);
const { asFragment, getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
// expand the tree
fireEvent.click(getByTestId('/'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
expect(getByTestId('/a')).toHaveAttribute('href', '/collections/pages/filter/a');
expect(getByTestId('/b')).toHaveTextContent('File 2');
expect(getByTestId('/b')).toHaveAttribute('href', '/collections/pages/filter/b');
expect(asFragment()).toMatchSnapshot();
});
it('should keep expanded nodes on re-render', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
const newEntries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
{ path: 'src/pages/c/index.md', data: { title: 'File 5' } },
{ path: 'src/pages/c/a/index.md', data: { title: 'File 6' } },
]);
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={newEntries} />
</MemoryRouter>,
);
expect(getByTestId('/a')).toHaveTextContent('File 1');
});
it('should expand nodes based on filterTerm', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
]);
const { getByTestId, queryByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
expect(queryByTestId('/a/a')).toBeNull();
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} filterTerm={'a/a'} />
</MemoryRouter>,
);
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
});
it('should ignore filterTerm once a user toggles an node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
]);
const { getByTestId, queryByTestId, rerender } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
rerender(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} filterTerm={'a/a'} />
</MemoryRouter>,
);
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
fireEvent.click(getByTestId('/a'));
rerender(
<MemoryRouter>
<NestedCollection
collection={collection}
entries={fromJS(entries.toJS())}
filterTerm={'a/a'}
/>
</MemoryRouter>,
);
expect(queryByTestId('/a/a')).toBeNull();
});
it('should not collapse an unselected node when clicked', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/a/a/a/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
fireEvent.click(getByTestId('/a/a'));
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
fireEvent.click(getByTestId('/a'));
expect(getByTestId('/a/a')).toHaveTextContent('File 2');
});
it('should collapse a selected node when clicked', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ path: 'src/pages/a/a/index.md', data: { title: 'File 2' } },
{ path: 'src/pages/a/a/a/index.md', data: { title: 'File 3' } },
{ path: 'src/pages/a/a/a/a/index.md', data: { title: 'File 4' } },
]);
const { getByTestId, queryByTestId } = render(
<MemoryRouter>
<NestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
);
fireEvent.click(getByTestId('/'));
fireEvent.click(getByTestId('/a'));
fireEvent.click(getByTestId('/a/a'));
expect(getByTestId('/a/a/a')).toHaveTextContent('File 3');
fireEvent.click(getByTestId('/a/a'));
expect(queryByTestId('/a/a/a')).toBeNull();
});
it('should render connected component', () => {
const entriesArray = [
{ slug: 'index', path: 'src/pages/index.md', data: { title: 'Root' } },
{ slug: 'a/index', path: 'src/pages/a/index.md', data: { title: 'File 1' } },
{ slug: 'b/index', path: 'src/pages/b/index.md', data: { title: 'File 2' } },
{ slug: 'a/a/index', path: 'src/pages/a/a/index.md', data: { title: 'File 3' } },
{ slug: 'b/a/index', path: 'src/pages/b/a/index.md', data: { title: 'File 4' } },
];
const entries = entriesArray.reduce(
(acc, entry) => {
acc.entities[`${collection.get('name')}.${entry.slug}`] = entry;
acc.pages[collection.get('name')].ids.push(entry.slug);
return acc;
},
{ pages: { [collection.get('name')]: { ids: [] } }, entities: {} },
);
const store = mockStore({ entries: fromJS(entries) });
const { asFragment, getByTestId } = renderWithRedux(
<MemoryRouter>
<ConnectedNestedCollection collection={collection} entries={entries} />
</MemoryRouter>,
{ store },
);
// expand the root
fireEvent.click(getByTestId('/'));
expect(getByTestId('/a')).toHaveTextContent('File 1');
expect(getByTestId('/a')).toHaveAttribute('href', '/collections/pages/filter/a');
expect(getByTestId('/b')).toHaveTextContent('File 2');
expect(getByTestId('/b')).toHaveAttribute('href', '/collections/pages/filter/b');
expect(asFragment()).toMatchSnapshot();
});
describe('getTreeData', () => {
it('should return nested tree data from entries', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/intro/index.md', data: { title: 'intro index' } },
{ path: 'src/pages/intro/category/index.md', data: { title: 'intro category index' } },
{ path: 'src/pages/compliance/index.md', data: { title: 'compliance index' } },
]);
const treeData = getTreeData(collection, entries);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
title: 'intro',
path: '/intro',
isDir: true,
isRoot: false,
children: [
{
title: 'category',
path: '/intro/category',
isDir: true,
isRoot: false,
children: [
{
path: '/intro/category/index.md',
data: { title: 'intro category index' },
title: 'intro category index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
path: '/intro/index.md',
data: { title: 'intro index' },
title: 'intro index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
title: 'compliance',
path: '/compliance',
isDir: true,
isRoot: false,
children: [
{
path: '/compliance/index.md',
data: { title: 'compliance index' },
title: 'compliance index',
isDir: false,
isRoot: false,
children: [],
},
],
},
{
path: '/index.md',
data: { title: 'Root' },
title: 'Root',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
it('should ignore collection summary', () => {
const entries = fromJS([{ path: 'src/pages/index.md', data: { title: 'Root' } }]);
const treeData = getTreeData(collection, entries);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
path: '/index.md',
data: { title: 'Root' },
title: 'Root',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
it('should use nested collection summary for title', () => {
const entries = fromJS([{ path: 'src/pages/index.md', data: { title: 'Root' } }]);
const treeData = getTreeData(
collection.setIn(['nested', 'summary'], '{{filename}}'),
entries,
);
expect(treeData).toEqual([
{
title: 'Pages',
path: '/',
isDir: true,
isRoot: true,
children: [
{
path: '/index.md',
data: { title: 'Root' },
title: 'index',
isDir: false,
isRoot: false,
children: [],
},
],
},
]);
});
});
describe('walk', () => {
it('should visit every tree node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/dir1/index.md', data: { title: 'Dir1 File' } },
{ path: 'src/pages/dir2/index.md', data: { title: 'Dir2 File' } },
]);
const treeData = getTreeData(collection, entries);
const callback = jest.fn();
walk(treeData, callback);
expect(callback).toHaveBeenCalledTimes(6);
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/index.md' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir1' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir2' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir1/index.md' }));
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ path: '/dir2/index.md' }));
});
});
describe('updateNode', () => {
it('should update node', () => {
const entries = fromJS([
{ path: 'src/pages/index.md', data: { title: 'Root' } },
{ path: 'src/pages/dir1/index.md', data: { title: 'Dir1 File' } },
{ path: 'src/pages/dir2/index.md', data: { title: 'Dir2 File' } },
]);
const treeData = getTreeData(collection, entries);
expect(treeData[0].children[0].children[0].expanded).toBeUndefined();
const callback = jest.fn(node => ({ ...node, expanded: true }));
const node = { path: '/dir1/index.md' };
updateNode(treeData, node, callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(node);
expect(treeData[0].children[0].children[0].expanded).toEqual(true);
});
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import { Sidebar } from '../Sidebar';
jest.mock('decap-cms-ui-default', () => {
const actual = jest.requireActual('decap-cms-ui-default');
return {
...actual,
Icon: 'mocked-icon',
};
});
jest.mock('../NestedCollection', () => 'nested-collection');
jest.mock('../CollectionSearch', () => 'collection-search');
jest.mock('../../../actions/collections');
describe('Sidebar', () => {
const props = {
searchTerm: '',
isSearchEnabled: true,
t: jest.fn(key => key),
};
it('should render sidebar with a simple collection', () => {
const collections = fromJS([{ name: 'posts', label: 'Posts' }]).toOrderedMap();
const { asFragment, getByTestId } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(getByTestId('posts')).toHaveTextContent('Posts');
expect(getByTestId('posts')).toHaveAttribute('href', '/collections/posts');
expect(asFragment()).toMatchSnapshot();
});
it('should not render a hidden collection', () => {
const collections = fromJS([{ name: 'posts', label: 'Posts', hide: true }]).toOrderedMap();
const { queryByTestId } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(queryByTestId('posts')).toBeNull();
});
it('should render sidebar with a nested collection', () => {
const collections = fromJS([
{ name: 'posts', label: 'Posts', nested: { depth: 10 } },
]).toOrderedMap();
const { asFragment } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} />
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render nested collection with filterTerm', () => {
const collections = fromJS([
{ name: 'posts', label: 'Posts', nested: { depth: 10 } },
]).toOrderedMap();
const { asFragment } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} filterTerm="dir1/dir2" />
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render sidebar without search', () => {
const collections = fromJS([{ name: 'posts', label: 'Posts' }]).toOrderedMap();
const { asFragment } = render(
<MemoryRouter>
<Sidebar {...props} collections={collections} isSearchEnabled={false} />
</MemoryRouter>,
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Collection should render connected component 1`] = `
<DocumentFragment>
.emotion-0 {
margin: 28px 18px;
}
.emotion-2 {
padding-left: 280px;
}
<div
class="emotion-0 emotion-1"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
filterterm=""
searchterm=""
/>
<main
class="emotion-2 emotion-3"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] }"
newentryurl=""
/>
<mock-collection-controls
filter="Map {}"
group="Map {}"
sortablefields=""
viewfilters=""
viewgroups=""
/>
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] }"
filterterm=""
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection with create url 1`] = `
<DocumentFragment>
.emotion-0 {
margin: 28px 18px;
}
.emotion-2 {
padding-left: 280px;
}
<div
class="emotion-0 emotion-1"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
/>
<main
class="emotion-2 emotion-3"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new"
/>
<mock-collection-controls />
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection with create url and path 1`] = `
<DocumentFragment>
.emotion-0 {
margin: 28px 18px;
}
.emotion-2 {
padding-left: 280px;
}
<div
class="emotion-0 emotion-1"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
filterterm="dir1/dir2"
/>
<main
class="emotion-2 emotion-3"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
newentryurl="/collections/pages/new?path=dir1/dir2"
/>
<mock-collection-controls />
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": true }"
filterterm="dir1/dir2"
/>
</main>
</div>
</DocumentFragment>
`;
exports[`Collection should render with collection without create url 1`] = `
<DocumentFragment>
.emotion-0 {
margin: 28px 18px;
}
.emotion-2 {
padding-left: 280px;
}
<div
class="emotion-0 emotion-1"
>
<mock-sidebar
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": false }"
collections="OrderedMap { 0: Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [] } }"
/>
<main
class="emotion-2 emotion-3"
>
<mock-collection-top
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": false }"
newentryurl=""
/>
<mock-collection-controls />
<mock-entries-collection
collection="Map { \\"name\\": \\"pages\\", \\"sortable_fields\\": List [], \\"view_filters\\": List [], \\"view_groups\\": List [], \\"create\\": false }"
/>
</main>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,618 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NestedCollection should render connected component 1`] = `
<DocumentFragment>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 18px;
border-left: 2px solid #fff;
}
.emotion-0 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-0:hover,
.emotion-0:active,
.emotion-0.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-4 {
margin-right: 4px;
}
.emotion-6 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-top: 6px solid currentColor;
border-bottom: 0;
color: currentColor;
}
<a
aria-current="page"
class="emotion-0 emotion-1 sidebar-active"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
>
Pages
</div>
<div
class="emotion-6 emotion-7"
/>
</div>
</a>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 34px;
border-left: 2px solid #fff;
}
.emotion-0 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-0:hover,
.emotion-0:active,
.emotion-0.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-4 {
margin-right: 4px;
}
.emotion-6 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-left: 6px solid currentColor;
border-right: 0;
color: currentColor;
left: 2px;
}
<a
class="emotion-0 emotion-1"
data-testid="/a"
depth="1"
href="/collections/pages/filter/a"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
>
File 1
</div>
<div
class="emotion-6 emotion-7"
/>
</div>
</a>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 34px;
border-left: 2px solid #fff;
}
.emotion-0 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-0:hover,
.emotion-0:active,
.emotion-0.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-4 {
margin-right: 4px;
}
.emotion-6 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-left: 6px solid currentColor;
border-right: 0;
color: currentColor;
left: 2px;
}
<a
class="emotion-0 emotion-1"
data-testid="/b"
depth="1"
href="/collections/pages/filter/b"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
>
File 2
</div>
<div
class="emotion-6 emotion-7"
/>
</div>
</a>
</DocumentFragment>
`;
exports[`NestedCollection should render correctly with nested entries 1`] = `
<DocumentFragment>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 18px;
border-left: 2px solid #fff;
}
.emotion-0 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-0:hover,
.emotion-0:active,
.emotion-0.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-4 {
margin-right: 4px;
}
.emotion-6 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-top: 6px solid currentColor;
border-bottom: 0;
color: currentColor;
}
<a
aria-current="page"
class="emotion-0 emotion-1 sidebar-active"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
>
Pages
</div>
<div
class="emotion-6 emotion-7"
/>
</div>
</a>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 34px;
border-left: 2px solid #fff;
}
.emotion-0 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-0:hover,
.emotion-0:active,
.emotion-0.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-4 {
margin-right: 4px;
}
.emotion-6 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-left: 6px solid currentColor;
border-right: 0;
color: currentColor;
left: 2px;
}
<a
class="emotion-0 emotion-1"
data-testid="/a"
depth="1"
href="/collections/pages/filter/a"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
>
File 1
</div>
<div
class="emotion-6 emotion-7"
/>
</div>
</a>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 34px;
border-left: 2px solid #fff;
}
.emotion-0 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-0:hover,
.emotion-0:active,
.emotion-0.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-4 {
margin-right: 4px;
}
.emotion-6 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-left: 6px solid currentColor;
border-right: 0;
color: currentColor;
left: 2px;
}
<a
class="emotion-0 emotion-1"
data-testid="/b"
depth="1"
href="/collections/pages/filter/b"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
>
File 2
</div>
<div
class="emotion-6 emotion-7"
/>
</div>
</a>
</DocumentFragment>
`;
exports[`NestedCollection should render correctly with no entries 1`] = `
<DocumentFragment>
.emotion-0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px;
padding-left: 18px;
border-left: 2px solid #fff;
}
.emotion-0 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-0:hover,
.emotion-0:active,
.emotion-0.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
.emotion-2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.emotion-4 {
margin-right: 4px;
}
.emotion-6 {
position: relative;
top: 2px;
color: #fff;
width: 0;
height: 0;
border: 5px solid transparent;
border-radius: 2px;
border-left: 6px solid currentColor;
border-right: 0;
color: currentColor;
left: 2px;
}
<a
class="emotion-0 emotion-1"
data-testid="/"
depth="0"
href="/collections/pages"
>
<mocked-icon
type="write"
/>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
>
Pages
</div>
<div
class="emotion-6 emotion-7"
/>
</div>
</a>
</DocumentFragment>
`;

View File

@@ -0,0 +1,312 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Sidebar should render nested collection with filterTerm 1`] = `
<DocumentFragment>
.emotion-0 {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),0 1px 3px 0 rgba(68, 74, 87, 0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-2 {
font-size: 22px;
font-weight: 600;
line-height: 37px;
padding: 0;
margin: 10px 20px;
color: #313d3e;
}
.emotion-4 {
margin: 12px 0 0;
list-style: none;
overflow: auto;
}
<aside
class="emotion-0 emotion-1"
>
<h2
class="emotion-2 emotion-3"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } } }"
searchterm=""
/>
<ul
class="emotion-4 emotion-5"
>
<li>
<nested-collection
collection="Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } }"
data-testid="posts"
filterterm="dir1/dir2"
/>
</li>
</ul>
</aside>
</DocumentFragment>
`;
exports[`Sidebar should render sidebar with a nested collection 1`] = `
<DocumentFragment>
.emotion-0 {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),0 1px 3px 0 rgba(68, 74, 87, 0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-2 {
font-size: 22px;
font-weight: 600;
line-height: 37px;
padding: 0;
margin: 10px 20px;
color: #313d3e;
}
.emotion-4 {
margin: 12px 0 0;
list-style: none;
overflow: auto;
}
<aside
class="emotion-0 emotion-1"
>
<h2
class="emotion-2 emotion-3"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } } }"
searchterm=""
/>
<ul
class="emotion-4 emotion-5"
>
<li>
<nested-collection
collection="Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\", \\"nested\\": Map { \\"depth\\": 10 } }"
data-testid="posts"
/>
</li>
</ul>
</aside>
</DocumentFragment>
`;
exports[`Sidebar should render sidebar with a simple collection 1`] = `
<DocumentFragment>
.emotion-0 {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),0 1px 3px 0 rgba(68, 74, 87, 0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-2 {
font-size: 22px;
font-weight: 600;
line-height: 37px;
padding: 0;
margin: 10px 20px;
color: #313d3e;
}
.emotion-4 {
margin: 12px 0 0;
list-style: none;
overflow: auto;
}
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px 18px;
border-left: 2px solid #fff;
z-index: -1;
}
.emotion-6 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<aside
class="emotion-0 emotion-1"
>
<h2
class="emotion-2 emotion-3"
>
collection.sidebar.collections
</h2>
<collection-search
collections="OrderedMap { 0: Map { \\"name\\": \\"posts\\", \\"label\\": \\"Posts\\" } }"
searchterm=""
/>
<ul
class="emotion-4 emotion-5"
>
<li>
<a
class="emotion-6 emotion-7"
data-testid="posts"
href="/collections/posts"
>
<mocked-icon
type="write"
/>
Posts
</a>
</li>
</ul>
</aside>
</DocumentFragment>
`;
exports[`Sidebar should render sidebar without search 1`] = `
<DocumentFragment>
.emotion-0 {
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05),0 1px 3px 0 rgba(68, 74, 87, 0.1);
border-radius: 5px;
background-color: #fff;
width: 250px;
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
.emotion-2 {
font-size: 22px;
font-weight: 600;
line-height: 37px;
padding: 0;
margin: 10px 20px;
color: #313d3e;
}
.emotion-4 {
margin: 12px 0 0;
list-style: none;
overflow: auto;
}
.emotion-6 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
font-size: 14px;
font-weight: 500;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 8px 18px;
border-left: 2px solid #fff;
z-index: -1;
}
.emotion-6 mocked-icon {
margin-right: 4px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.emotion-6:hover,
.emotion-6:active,
.emotion-6.sidebar-active {
color: #3a69c7;
background-color: #e8f5fe;
border-left-color: #4863c6;
}
<aside
class="emotion-0 emotion-1"
>
<h2
class="emotion-2 emotion-3"
>
collection.sidebar.collections
</h2>
<ul
class="emotion-4 emotion-5"
>
<li>
<a
class="emotion-6 emotion-7"
data-testid="posts"
href="/collections/posts"
>
<mocked-icon
type="write"
/>
Posts
</a>
</li>
</ul>
</aside>
</DocumentFragment>
`;

View File

@@ -0,0 +1,497 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { Loader } from 'decap-cms-ui-default';
import { translate } from 'react-polyglot';
import debounce from 'lodash/debounce';
import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history';
import { logoutUser } from '../../actions/auth';
import {
loadEntry,
loadEntries,
createDraftDuplicateFromEntry,
createEmptyDraft,
discardDraft,
changeDraftField,
changeDraftFieldValidation,
persistEntry,
deleteEntry,
persistLocalBackup,
loadLocalBackup,
retrieveLocalBackup,
deleteLocalBackup,
} from '../../actions/entries';
import {
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
unpublishPublishedEntry,
deleteUnpublishedEntry,
} from '../../actions/editorialWorkflow';
import { loadDeployPreview } from '../../actions/deploys';
import { selectEntry, selectUnpublishedEntry, selectDeployPreview } from '../../reducers';
import { selectFields } from '../../reducers/collections';
import { status, EDITORIAL_WORKFLOW } from '../../constants/publishModes';
import EditorInterface from './EditorInterface';
import withWorkflow from './withWorkflow';
export class Editor extends React.Component {
static propTypes = {
changeDraftField: PropTypes.func.isRequired,
changeDraftFieldValidation: PropTypes.func.isRequired,
collection: ImmutablePropTypes.map.isRequired,
createDraftDuplicateFromEntry: PropTypes.func.isRequired,
createEmptyDraft: PropTypes.func.isRequired,
discardDraft: PropTypes.func.isRequired,
entry: ImmutablePropTypes.map,
entryDraft: ImmutablePropTypes.map.isRequired,
loadEntry: PropTypes.func.isRequired,
persistEntry: PropTypes.func.isRequired,
deleteEntry: PropTypes.func.isRequired,
showDelete: PropTypes.bool.isRequired,
fields: ImmutablePropTypes.list.isRequired,
slug: PropTypes.string,
newEntry: PropTypes.bool.isRequired,
displayUrl: PropTypes.string,
hasWorkflow: PropTypes.bool,
useOpenAuthoring: PropTypes.bool,
unpublishedEntry: PropTypes.bool,
isModification: PropTypes.bool,
collectionEntriesLoaded: PropTypes.bool,
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
publishUnpublishedEntry: PropTypes.func.isRequired,
deleteUnpublishedEntry: PropTypes.func.isRequired,
logoutUser: PropTypes.func.isRequired,
loadEntries: PropTypes.func.isRequired,
deployPreview: PropTypes.object,
loadDeployPreview: PropTypes.func.isRequired,
currentStatus: PropTypes.string,
user: PropTypes.object,
location: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
}),
hasChanged: PropTypes.bool,
t: PropTypes.func.isRequired,
retrieveLocalBackup: PropTypes.func.isRequired,
localBackup: ImmutablePropTypes.map,
loadLocalBackup: PropTypes.func,
persistLocalBackup: PropTypes.func.isRequired,
deleteLocalBackup: PropTypes.func,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Editor.propTypes, this.props, 'prop', 'Editor');
const {
newEntry,
collection,
slug,
loadEntry,
createEmptyDraft,
loadEntries,
retrieveLocalBackup,
collectionEntriesLoaded,
t,
} = this.props;
retrieveLocalBackup(collection, slug);
if (newEntry) {
createEmptyDraft(collection, this.props.location.search);
} else {
loadEntry(collection, slug);
}
const leaveMessage = t('editor.editor.onLeavePage');
this.exitBlocker = event => {
if (this.props.entryDraft.get('hasChanged')) {
// This message is ignored in most browsers, but its presence
// triggers the confirmation dialog
event.returnValue = leaveMessage;
return leaveMessage;
}
};
window.addEventListener('beforeunload', this.exitBlocker);
const navigationBlocker = (location, action) => {
/**
* New entry being saved and redirected to it's new slug based url.
*/
const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']);
const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']);
const newEntryPath = `/collections/${collection.get('name')}/new`;
if (
isPersisting &&
newRecord &&
this.props.location.pathname === newEntryPath &&
action === 'PUSH'
) {
return;
}
if (this.props.hasChanged) {
return leaveMessage;
}
};
const unblock = history.block(navigationBlocker);
/**
* This will run as soon as the location actually changes, unless creating
* a new post. The confirmation above will run first.
*/
this.unlisten = history.listen((location, action) => {
const newEntryPath = `/collections/${collection.get('name')}/new`;
const entriesPath = `/collections/${collection.get('name')}/entries/`;
const { pathname } = location;
if (
pathname.startsWith(newEntryPath) ||
(pathname.startsWith(entriesPath) && action === 'PUSH')
) {
return;
}
this.deleteBackup();
unblock();
this.unlisten();
});
if (!collectionEntriesLoaded) {
loadEntries(collection);
}
}
componentDidUpdate(prevProps) {
if (!prevProps.localBackup && this.props.localBackup) {
const confirmLoadBackup = window.confirm(this.props.t('editor.editor.confirmLoadBackup'));
if (confirmLoadBackup) {
this.props.loadLocalBackup();
} else {
this.deleteBackup();
}
}
if (this.props.hasChanged) {
this.createBackup(this.props.entryDraft.get('entry'), this.props.collection);
}
if (prevProps.entry === this.props.entry) return;
const { newEntry, collection } = this.props;
if (newEntry) {
prevProps.createEmptyDraft(collection, this.props.location.search);
}
}
componentWillUnmount() {
this.createBackup.flush();
this.props.discardDraft();
window.removeEventListener('beforeunload', this.exitBlocker);
}
createBackup = debounce(function (entry, collection) {
this.props.persistLocalBackup(entry, collection);
}, 2000);
handleChangeDraftField = (field, value, metadata, i18n) => {
const entries = [this.props.unPublishedEntry, this.props.publishedEntry].filter(Boolean);
this.props.changeDraftField({ field, value, metadata, entries, i18n });
};
handleChangeStatus = newStatusName => {
const { entryDraft, updateUnpublishedEntryStatus, collection, slug, currentStatus, t } =
this.props;
if (entryDraft.get('hasChanged')) {
window.alert(t('editor.editor.onUpdatingWithUnsavedChanges'));
return;
}
const newStatus = status.get(newStatusName);
updateUnpublishedEntryStatus(collection.get('name'), slug, currentStatus, newStatus);
};
deleteBackup() {
const { deleteLocalBackup, collection, slug, newEntry } = this.props;
this.createBackup.cancel();
deleteLocalBackup(collection, !newEntry && slug);
}
handlePersistEntry = async (opts = {}) => {
const { createNew = false, duplicate = false } = opts;
const {
persistEntry,
collection,
currentStatus,
hasWorkflow,
loadEntry,
slug,
createDraftDuplicateFromEntry,
entryDraft,
} = this.props;
await persistEntry(collection);
this.deleteBackup();
if (createNew) {
navigateToNewEntry(collection.get('name'));
duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
} else if (slug && hasWorkflow && !currentStatus) {
loadEntry(collection, slug);
}
};
handlePublishEntry = async (opts = {}) => {
const { createNew = false, duplicate = false } = opts;
const {
publishUnpublishedEntry,
createDraftDuplicateFromEntry,
entryDraft,
collection,
slug,
currentStatus,
t,
} = this.props;
if (currentStatus !== status.last()) {
window.alert(t('editor.editor.onPublishingNotReady'));
return;
} else if (entryDraft.get('hasChanged')) {
window.alert(t('editor.editor.onPublishingWithUnsavedChanges'));
return;
} else if (!window.confirm(t('editor.editor.onPublishing'))) {
return;
}
await publishUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (createNew) {
navigateToNewEntry(collection.get('name'));
}
duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry'));
};
handleUnpublishEntry = async () => {
const { unpublishPublishedEntry, collection, slug, t } = this.props;
if (!window.confirm(t('editor.editor.onUnpublishing'))) return;
await unpublishPublishedEntry(collection, slug);
return navigateToCollection(collection.get('name'));
};
handleDuplicateEntry = () => {
const { createDraftDuplicateFromEntry, collection, entryDraft } = this.props;
navigateToNewEntry(collection.get('name'));
createDraftDuplicateFromEntry(entryDraft.get('entry'));
};
handleDeleteEntry = () => {
const { entryDraft, newEntry, collection, deleteEntry, slug, t } = this.props;
if (entryDraft.get('hasChanged')) {
if (!window.confirm(t('editor.editor.onDeleteWithUnsavedChanges'))) {
return;
}
} else if (!window.confirm(t('editor.editor.onDeletePublishedEntry'))) {
return;
}
if (newEntry) {
return navigateToCollection(collection.get('name'));
}
setTimeout(async () => {
await deleteEntry(collection, slug);
this.deleteBackup();
return navigateToCollection(collection.get('name'));
}, 0);
};
handleDeleteUnpublishedChanges = async () => {
const { entryDraft, collection, slug, deleteUnpublishedEntry, loadEntry, isModification, t } =
this.props;
if (
entryDraft.get('hasChanged') &&
!window.confirm(t('editor.editor.onDeleteUnpublishedChangesWithUnsavedChanges'))
) {
return;
} else if (!window.confirm(t('editor.editor.onDeleteUnpublishedChanges'))) {
return;
}
await deleteUnpublishedEntry(collection.get('name'), slug);
this.deleteBackup();
if (isModification) {
loadEntry(collection, slug);
} else {
navigateToCollection(collection.get('name'));
}
};
render() {
const {
entry,
entryDraft,
fields,
collection,
changeDraftFieldValidation,
user,
hasChanged,
displayUrl,
hasWorkflow,
useOpenAuthoring,
unpublishedEntry,
newEntry,
isModification,
currentStatus,
logoutUser,
deployPreview,
loadDeployPreview,
draftKey,
slug,
t,
editorBackLink,
} = this.props;
const isPublished = !newEntry && !unpublishedEntry;
if (entry && entry.get('error')) {
return (
<div>
<h3>{entry.get('error')}</h3>
</div>
);
} else if (
entryDraft == null ||
entryDraft.get('entry') === undefined ||
(entry && entry.get('isFetching'))
) {
return <Loader active>{t('editor.editor.loadingEntry')}</Loader>;
}
return (
<EditorInterface
draftKey={draftKey}
entry={entryDraft.get('entry')}
collection={collection}
fields={fields}
fieldsMetaData={entryDraft.get('fieldsMetaData')}
fieldsErrors={entryDraft.get('fieldsErrors')}
onChange={this.handleChangeDraftField}
onValidate={changeDraftFieldValidation}
onPersist={this.handlePersistEntry}
onDelete={this.handleDeleteEntry}
onDeleteUnpublishedChanges={this.handleDeleteUnpublishedChanges}
onChangeStatus={this.handleChangeStatus}
onPublish={this.handlePublishEntry}
unPublish={this.handleUnpublishEntry}
onDuplicate={this.handleDuplicateEntry}
showDelete={this.props.showDelete}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
hasWorkflow={hasWorkflow}
useOpenAuthoring={useOpenAuthoring}
hasUnpublishedChanges={unpublishedEntry}
isNewEntry={newEntry}
isModification={isModification}
currentStatus={currentStatus}
onLogoutClick={logoutUser}
deployPreview={deployPreview}
loadDeployPreview={opts => loadDeployPreview(collection, slug, entry, isPublished, opts)}
editorBackLink={editorBackLink}
t={t}
/>
);
}
}
function mapStateToProps(state, ownProps) {
const { collections, entryDraft, auth, config, entries, globalUI } = state;
const slug = ownProps.match.params[0];
const collection = collections.get(ownProps.match.params.name);
const collectionName = collection.get('name');
const newEntry = ownProps.newRecord === true;
const fields = selectFields(collection, slug);
const entry = newEntry ? null : selectEntry(state, collectionName, slug);
const user = auth.user;
const hasChanged = entryDraft.get('hasChanged');
const displayUrl = config.display_url;
const hasWorkflow = config.publish_mode === EDITORIAL_WORKFLOW;
const useOpenAuthoring = globalUI.useOpenAuthoring;
const isModification = entryDraft.getIn(['entry', 'isModification']);
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
const unPublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const publishedEntry = selectEntry(state, collectionName, slug);
const currentStatus = unPublishedEntry && unPublishedEntry.get('status');
const deployPreview = selectDeployPreview(state, collectionName, slug);
const localBackup = entryDraft.get('localBackup');
const draftKey = entryDraft.get('key');
let editorBackLink = `/collections/${collectionName}`;
if (new URLSearchParams(ownProps.location.search).get('ref') === 'workflow') {
editorBackLink = `/workflow`;
}
if (collection.has('nested') && slug) {
const pathParts = slug.split('/');
if (pathParts.length > 2) {
editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`;
}
}
return {
collection,
collections,
newEntry,
entryDraft,
fields,
slug,
entry,
user,
hasChanged,
displayUrl,
hasWorkflow,
useOpenAuthoring,
isModification,
collectionEntriesLoaded,
currentStatus,
deployPreview,
localBackup,
draftKey,
publishedEntry,
unPublishedEntry,
editorBackLink,
};
}
const mapDispatchToProps = {
changeDraftField,
changeDraftFieldValidation,
loadEntry,
loadEntries,
loadDeployPreview,
loadLocalBackup,
retrieveLocalBackup,
persistLocalBackup,
deleteLocalBackup,
createDraftDuplicateFromEntry,
createEmptyDraft,
discardDraft,
persistEntry,
deleteEntry,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
unpublishPublishedEntry,
deleteUnpublishedEntry,
logoutUser,
};
export default connect(mapStateToProps, mapDispatchToProps)(withWorkflow(translate()(Editor)));

View File

@@ -0,0 +1,452 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { translate } from 'react-polyglot';
import { ClassNames, Global, css as coreCss } from '@emotion/react';
import styled from '@emotion/styled';
import partial from 'lodash/partial';
import uniqueId from 'lodash/uniqueId';
import { connect } from 'react-redux';
import { FieldLabel, colors, transitions, lengths, borders } from 'decap-cms-ui-default';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm';
import { resolveWidget, getEditorComponents } from '../../../lib/registry';
import { clearFieldErrors, tryLoadEntry, validateMetaField } from '../../../actions/entries';
import { addAsset, boundGetAsset } from '../../../actions/media';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import { query, clearSearch } from '../../../actions/search';
import {
openMediaLibrary,
removeInsertedMedia,
clearMediaControl,
removeMediaControl,
persistMedia,
} from '../../../actions/mediaLibrary';
import Widget from './Widget';
/**
* This is a necessary bridge as we are still passing classnames to widgets
* for styling. Once that changes we can stop storing raw style strings like
* this.
*/
const styleStrings = {
widget: `
display: block;
width: 100%;
padding: ${lengths.inputPadding};
margin: 0;
border: ${borders.textField};
border-radius: ${lengths.borderRadius};
border-top-left-radius: 0;
outline: 0;
box-shadow: none;
background-color: ${colors.inputBackground};
color: #444a57;
transition: border-color ${transitions.main};
position: relative;
font-size: 15px;
line-height: 1.5;
select& {
text-indent: 14px;
height: 58px;
}
`,
widgetActive: `
border-color: ${colors.active};
`,
widgetError: `
border-color: ${colors.errorText};
`,
disabled: `
pointer-events: none;
opacity: 0.5;
`,
hidden: `
visibility: hidden;
`,
};
const ControlContainer = styled.div`
margin-top: 16px;
&:first-of-type {
margin-top: 36px;
}
`;
const ControlTopbar = styled.div`
display: flex;
justify-content: space-between;
gap: 20px;
align-items: end;
`;
const ControlErrorsList = styled.ul`
list-style-type: none;
font-size: 12px;
color: ${colors.errorText};
text-align: right;
text-transform: uppercase;
font-weight: 600;
margin: 0;
padding: 2px 0 3px;
`;
export const ControlHint = styled.p`
margin-bottom: 0;
padding: 6px 0 0;
font-size: 12px;
color: ${props =>
props.error ? colors.errorText : props.active ? colors.active : colors.controlLabel};
transition: color ${transitions.main};
`;
function LabelComponent({ field, isActive, hasErrors, uniqueFieldId, isFieldOptional, t }) {
const label = `${field.get('label', field.get('name'))}`;
const labelComponent = (
<FieldLabel isActive={isActive} hasErrors={hasErrors} htmlFor={uniqueFieldId}>
{isFieldOptional ? (
<>
{label}
<span>{` (${t('editor.editorControl.field.optional')})`}</span>
</>
) : (
label
)}
</FieldLabel>
);
return labelComponent;
}
class EditorControl extends React.Component {
static propTypes = {
value: PropTypes.oneOfType([
PropTypes.node,
PropTypes.object,
PropTypes.string,
PropTypes.bool,
]),
field: ImmutablePropTypes.map.isRequired,
fieldsMetaData: ImmutablePropTypes.map,
fieldsErrors: ImmutablePropTypes.map,
mediaPaths: ImmutablePropTypes.map.isRequired,
boundGetAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
openMediaLibrary: PropTypes.func.isRequired,
addAsset: PropTypes.func.isRequired,
removeInsertedMedia: PropTypes.func.isRequired,
persistMedia: PropTypes.func.isRequired,
onValidate: PropTypes.func,
controlRef: PropTypes.func,
query: PropTypes.func.isRequired,
queryHits: PropTypes.object,
isFetching: PropTypes.bool,
clearSearch: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
parentIds: PropTypes.arrayOf(PropTypes.string),
entry: ImmutablePropTypes.map.isRequired,
collection: ImmutablePropTypes.map.isRequired,
isDisabled: PropTypes.bool,
isHidden: PropTypes.bool,
isFieldDuplicate: PropTypes.func,
isFieldHidden: PropTypes.func,
locale: PropTypes.string,
isParentListCollapsed: PropTypes.bool,
};
static defaultProps = {
parentIds: [],
};
state = {
activeLabel: false,
};
uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`);
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(EditorControl.propTypes, this.props, 'prop', 'EditorControl');
}
isAncestorOfFieldError = () => {
const { fieldsErrors } = this.props;
if (fieldsErrors && fieldsErrors.size > 0) {
return Object.values(fieldsErrors.toJS()).some(arr =>
arr.some(err => err.parentIds && err.parentIds.includes(this.uniqueFieldId)),
);
}
return false;
};
render() {
const {
value,
entry,
collection,
config,
field,
fieldsMetaData,
fieldsErrors,
mediaPaths,
boundGetAsset,
onChange,
openMediaLibrary,
clearMediaControl,
removeMediaControl,
addAsset,
removeInsertedMedia,
persistMedia,
onValidate,
controlRef,
query,
queryHits,
isFetching,
clearSearch,
clearFieldErrors,
loadEntry,
className,
isSelected,
isEditorComponent,
isNewEditorComponent,
parentIds,
t,
validateMetaField,
isLoadingAsset,
isDisabled,
isHidden,
isFieldDuplicate,
isFieldHidden,
locale,
isParentListCollapsed,
} = this.props;
const widgetName = field.get('widget');
const widget = resolveWidget(widgetName);
const fieldName = field.get('name');
const fieldHint = field.get('hint');
const isFieldOptional = field.get('required') === false;
const onValidateObject = onValidate;
const metadata = fieldsMetaData && fieldsMetaData.get(fieldName);
const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId);
const childErrors = this.isAncestorOfFieldError();
const hasErrors = !!errors || childErrors;
return (
<ClassNames>
{({ css, cx }) => (
<ControlContainer
className={className}
css={css`
${isHidden && styleStrings.hidden};
`}
>
<ControlTopbar>
{widget.globalStyles && <Global styles={coreCss`${widget.globalStyles}`} />}
<LabelComponent
field={field}
isActive={isSelected || this.state.styleActive}
hasErrors={hasErrors}
uniqueFieldId={this.uniqueFieldId}
isFieldOptional={isFieldOptional}
t={t}
/>
{errors && (
<ControlErrorsList>
{errors.map(
error =>
error.message &&
typeof error.message === 'string' && (
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>
{error.message}
</li>
),
)}
</ControlErrorsList>
)}
</ControlTopbar>
<Widget
classNameWrapper={cx(
css`
${styleStrings.widget};
`,
{
[css`
${styleStrings.widgetActive};
`]: isSelected || this.state.styleActive,
},
{
[css`
${styleStrings.widgetError};
`]: hasErrors,
},
{
[css`
${styleStrings.disabled}
`]: isDisabled,
},
)}
classNameWidget={css`
${styleStrings.widget};
`}
classNameWidgetActive={css`
${styleStrings.widgetActive};
`}
classNameLabel={css`
${styleStrings.label};
`}
classNameLabelActive={css`
${styleStrings.labelActive};
`}
controlComponent={widget.control}
entry={entry}
collection={collection}
config={config}
field={field}
uniqueFieldId={this.uniqueFieldId}
value={value}
mediaPaths={mediaPaths}
metadata={metadata}
onChange={(newValue, newMetadata) => {
onChange(field, newValue, newMetadata);
clearFieldErrors(this.uniqueFieldId); // Видаляємо помилки лише для цього поля
}}
onValidate={onValidate && partial(onValidate, this.uniqueFieldId)}
onOpenMediaLibrary={openMediaLibrary}
onClearMediaControl={clearMediaControl}
onRemoveMediaControl={removeMediaControl}
onRemoveInsertedMedia={removeInsertedMedia}
onPersistMedia={persistMedia}
onAddAsset={addAsset}
getAsset={boundGetAsset}
hasActiveStyle={isSelected || this.state.styleActive}
setActiveStyle={() => this.setState({ styleActive: true })}
setInactiveStyle={() => this.setState({ styleActive: false })}
resolveWidget={resolveWidget}
widget={widget}
getEditorComponents={getEditorComponents}
controlRef={controlRef}
editorControl={ConnectedEditorControl}
query={query}
loadEntry={loadEntry}
queryHits={queryHits[this.uniqueFieldId] || []}
clearSearch={clearSearch}
clearFieldErrors={clearFieldErrors}
isFetching={isFetching}
fieldsErrors={fieldsErrors}
onValidateObject={onValidateObject}
isEditorComponent={isEditorComponent}
isNewEditorComponent={isNewEditorComponent}
parentIds={parentIds}
t={t}
validateMetaField={validateMetaField}
isDisabled={isDisabled}
isFieldDuplicate={isFieldDuplicate}
isFieldHidden={isFieldHidden}
isLoadingAsset={isLoadingAsset}
locale={locale}
isParentListCollapsed={isParentListCollapsed}
/>
{fieldHint && (
<ControlHint active={isSelected || this.state.styleActive} error={hasErrors}>
<ReactMarkdown
remarkPlugins={[gfm]}
allowedElements={['a', 'strong', 'em', 'del']}
unwrapDisallowed={true}
components={{
// eslint-disable-next-line no-unused-vars
a: ({ node, ...props }) => (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit' }}
/>
),
}}
>
{fieldHint}
</ReactMarkdown>
</ControlHint>
)}
</ControlContainer>
)}
</ClassNames>
);
}
}
function mapStateToProps(state) {
const { collections, entryDraft } = state;
const entry = entryDraft.get('entry');
const collection = collections.get(entryDraft.getIn(['entry', 'collection']));
const isLoadingAsset = selectIsLoadingAsset(state.medias);
async function loadEntry(collectionName, slug) {
const targetCollection = collections.get(collectionName);
if (targetCollection) {
const loadedEntry = await tryLoadEntry(state, targetCollection, slug);
return loadedEntry;
} else {
throw new Error(`Can't find collection '${collectionName}'`);
}
}
return {
mediaPaths: state.mediaLibrary.get('controlMedia'),
isFetching: state.search.isFetching,
queryHits: state.search.queryHits,
config: state.config,
entry,
collection,
isLoadingAsset,
loadEntry,
validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t),
};
}
function mapDispatchToProps(dispatch) {
const creators = bindActionCreators(
{
openMediaLibrary,
clearMediaControl,
removeMediaControl,
removeInsertedMedia,
persistMedia,
addAsset,
query,
clearSearch,
clearFieldErrors,
},
dispatch,
);
return {
...creators,
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
};
}
function mergeProps(stateProps, dispatchProps, ownProps) {
return {
...stateProps,
...dispatchProps,
...ownProps,
boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entry),
};
}
const ConnectedEditorControl = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
)(translate()(EditorControl));
export default ConnectedEditorControl;

View File

@@ -0,0 +1,269 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import {
buttons,
colors,
Dropdown,
DropdownItem,
StyledDropdownButton,
text,
} from 'decap-cms-ui-default';
import EditorControl from './EditorControl';
import {
getI18nInfo,
getLocaleDataPath,
hasI18n,
isFieldDuplicate,
isFieldHidden,
isFieldTranslatable,
} from '../../../lib/i18n';
const ControlPaneContainer = styled.div`
max-width: 800px;
margin: 0 auto;
padding-bottom: 16px;
font-size: 16px;
`;
const LocaleButton = styled(StyledDropdownButton)`
${buttons.button};
${buttons.medium};
color: ${colors.controlLabel};
background: ${colors.textFieldBorder};
height: 100%;
&:after {
top: 11px;
}
`;
const LocaleButtonWrapper = styled.div`
display: flex;
`;
const LocaleRowWrapper = styled.div`
display: flex;
`;
const StyledDropdown = styled(Dropdown)`
width: max-content;
margin-top: 20px;
margin-bottom: 20px;
margin-right: 20px;
`;
function LocaleDropdown({ locales, dropdownText, onLocaleChange }) {
return (
<StyledDropdown
renderButton={() => {
return (
<LocaleButtonWrapper>
<LocaleButton>{dropdownText}</LocaleButton>
</LocaleButtonWrapper>
);
}}
>
{locales.map(l => (
<DropdownItem
css={css`
${text.fieldLabel}
`}
key={l}
label={l}
onClick={() => onLocaleChange(l)}
/>
))}
</StyledDropdown>
);
}
function getFieldValue({ field, entry, isTranslatable, locale }) {
if (field.get('meta')) {
return entry.getIn(['meta', field.get('name')]);
}
if (isTranslatable) {
const dataPath = getLocaleDataPath(locale);
return entry.getIn([...dataPath, field.get('name')]);
}
return entry.getIn(['data', field.get('name')]);
}
export default class ControlPane extends React.Component {
state = {
selectedLocale: this.props.locale,
};
childRefs = {};
controlRef = (field, wrappedControl) => {
if (!wrappedControl) return;
const name = field.get('name');
this.childRefs[name] = wrappedControl;
};
getControlRef = field => wrappedControl => {
this.controlRef(field, wrappedControl);
};
handleLocaleChange = val => {
this.setState({ selectedLocale: val });
this.props.onLocaleChange(val);
};
copyFromOtherLocale =
({ targetLocale, t }) =>
sourceLocale => {
if (
!window.confirm(
t('editor.editorControlPane.i18n.copyFromLocaleConfirm', {
locale: sourceLocale.toUpperCase(),
}),
)
) {
return;
}
const { entry, collection } = this.props;
const { locales, defaultLocale } = getI18nInfo(collection);
const locale = this.state.selectedLocale;
const i18n = locales && {
currentLocale: locale,
locales,
defaultLocale,
};
this.props.fields.forEach(field => {
if (isFieldTranslatable(field, targetLocale, sourceLocale)) {
const copyValue = getFieldValue({
field,
entry,
locale: sourceLocale,
isTranslatable: sourceLocale !== defaultLocale,
});
if (copyValue) this.props.onChange(field, copyValue, undefined, i18n);
}
});
};
validate = async () => {
this.props.fields.forEach(field => {
if (field.get('widget') === 'hidden') return;
const control = this.childRefs[field.get('name')];
const validateFn = control?.innerWrappedControl?.validate ?? control?.validate;
if (validateFn) {
validateFn();
}
});
};
switchToDefaultLocale = () => {
if (hasI18n(this.props.collection)) {
const { defaultLocale } = getI18nInfo(this.props.collection);
return new Promise(resolve => this.setState({ selectedLocale: defaultLocale }, resolve));
} else {
return Promise.resolve();
}
};
focus(path) {
const [fieldName, ...remainingPath] = path.split('.');
const control = this.childRefs[fieldName];
if (control?.focus) {
control.focus(remainingPath.join('.'));
}
}
render() {
const { collection, entry, fields, fieldsMetaData, fieldsErrors, onChange, onValidate, t } =
this.props;
if (!collection || !fields) {
return null;
}
if (entry.size === 0 || entry.get('partial') === true) {
return null;
}
const { locales, defaultLocale } = getI18nInfo(collection);
const locale = this.state.selectedLocale;
const i18n = locales && {
currentLocale: locale,
locales,
defaultLocale,
};
return (
<ControlPaneContainer>
{locales && (
<LocaleRowWrapper>
<LocaleDropdown
locales={locales}
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
locale: locale.toUpperCase(),
})}
onLocaleChange={this.handleLocaleChange}
/>
<LocaleDropdown
locales={locales.filter(l => l !== locale)}
dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')}
onLocaleChange={this.copyFromOtherLocale({ targetLocale: locale, t })}
/>
</LocaleRowWrapper>
)}
{fields
.filter(f => f.get('widget') !== 'hidden')
.map((field, i) => {
const isTranslatable = isFieldTranslatable(field, locale, defaultLocale);
const isDuplicate = isFieldDuplicate(field, locale, defaultLocale);
const isHidden = isFieldHidden(field, locale, defaultLocale);
const key = i18n ? `${locale}_${i}` : i;
return (
<EditorControl
key={key}
field={field}
value={getFieldValue({
field,
entry,
locale,
isTranslatable,
})}
fieldsMetaData={fieldsMetaData}
fieldsErrors={fieldsErrors}
onChange={(field, newValue, newMetadata) => {
onChange(field, newValue, newMetadata, i18n);
}}
onValidate={onValidate}
controlRef={this.getControlRef(field)}
entry={entry}
collection={collection}
isDisabled={isDuplicate}
isHidden={isHidden}
isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)}
isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)}
locale={locale}
/>
);
})}
</ControlPaneContainer>
);
}
}
ControlPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
locale: PropTypes.string,
};

View File

@@ -0,0 +1,384 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map, List } from 'immutable';
import { oneLine } from 'common-tags';
import { getRemarkPlugins } from '../../../lib/registry';
import ValidationErrorTypes from '../../../constants/validationErrorTypes';
function truthy() {
return { error: false };
}
function isEmpty(value) {
return (
value === null ||
value === undefined ||
(Object.prototype.hasOwnProperty.call(value, 'length') && value.length === 0) ||
(value.constructor === Object && Object.keys(value).length === 0) ||
(List.isList(value) && value.size === 0)
);
}
export default class Widget extends Component {
static propTypes = {
controlComponent: PropTypes.func.isRequired,
field: ImmutablePropTypes.map.isRequired,
hasActiveStyle: PropTypes.bool,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
classNameWidget: PropTypes.string.isRequired,
classNameWidgetActive: PropTypes.string.isRequired,
classNameLabel: PropTypes.string.isRequired,
classNameLabelActive: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.node,
PropTypes.object,
PropTypes.string,
PropTypes.bool,
]),
mediaPaths: ImmutablePropTypes.map.isRequired,
metadata: ImmutablePropTypes.map,
fieldsErrors: ImmutablePropTypes.map,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func,
controlRef: PropTypes.func,
onOpenMediaLibrary: PropTypes.func.isRequired,
onClearMediaControl: PropTypes.func.isRequired,
onRemoveMediaControl: PropTypes.func.isRequired,
onPersistMedia: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
onRemoveInsertedMedia: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
widget: PropTypes.object.isRequired,
getEditorComponents: PropTypes.func.isRequired,
isFetching: PropTypes.bool,
query: PropTypes.func.isRequired,
clearSearch: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
editorControl: PropTypes.elementType.isRequired,
uniqueFieldId: PropTypes.string.isRequired,
loadEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
onValidateObject: PropTypes.func,
isEditorComponent: PropTypes.bool,
isNewEditorComponent: PropTypes.bool,
entry: ImmutablePropTypes.map.isRequired,
isDisabled: PropTypes.bool,
isFieldDuplicate: PropTypes.func,
isFieldHidden: PropTypes.func,
locale: PropTypes.string,
isParentListCollapsed: PropTypes.bool,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Widget.propTypes, this.props, 'prop', 'Widget');
}
shouldComponentUpdate(nextProps) {
/**
* Avoid unnecessary rerenders while loading assets.
*/
if (this.props.isLoadingAsset && nextProps.isLoadingAsset) return false;
/**
* Allow widgets to provide their own `shouldComponentUpdate` method.
*/
if (this.wrappedControlShouldComponentUpdate) {
return this.wrappedControlShouldComponentUpdate(nextProps);
}
return (
this.props.value !== nextProps.value ||
this.props.classNameWrapper !== nextProps.classNameWrapper ||
this.props.hasActiveStyle !== nextProps.hasActiveStyle
);
}
processInnerControlRef = ref => {
if (!ref) return;
/**
* If the widget is a container that receives state updates from the store,
* we'll need to get the ref of the actual control via the `react-redux`
* `getWrappedInstance` method. Note that connected widgets must pass
* `withRef: true` to `connect` in the options object.
*/
this.innerWrappedControl = ref.getWrappedInstance ? ref.getWrappedInstance() : ref;
this.wrappedControlValid = this.innerWrappedControl.isValid || truthy;
/**
* Get the `shouldComponentUpdate` method from the wrapped control, and
* provide the control instance is the `this` binding.
*/
const { shouldComponentUpdate: scu } = this.innerWrappedControl;
this.wrappedControlShouldComponentUpdate = scu && scu.bind(this.innerWrappedControl);
// Call the control ref if provided, passing this Widget instance
if (this.props.controlRef) {
this.props.controlRef(this);
}
};
focus(path) {
// Try widget's custom focus method first
if (this.innerWrappedControl?.focus) {
this.innerWrappedControl.focus(path);
} else {
// Fall back to focusing by ID for simple widgets
const element = document.getElementById(this.props.uniqueFieldId);
element?.focus();
}
// After focusing, ensure the element is visible
const label = document.querySelector(`label[for="${this.props.uniqueFieldId}"]`);
if (label) {
label.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
getValidateValue = () => {
let value = this.innerWrappedControl?.getValidateValue?.() || this.props.value;
// Convert list input widget value to string for validation test
List.isList(value) && (value = value.join(','));
return value;
};
validate = (skipWrapped = false) => {
const value = this.getValidateValue();
const field = this.props.field;
const errors = [];
const validations = [this.validatePresence, this.validatePattern];
if (field.get('meta')) {
validations.push(this.props.validateMetaField);
}
validations.forEach(func => {
const response = func(field, value, this.props.t);
if (response.error) errors.push(response.error);
});
if (skipWrapped) {
if (skipWrapped.error) errors.push(skipWrapped.error);
} else {
const wrappedError = this.validateWrappedControl(field);
if (wrappedError.error) errors.push(wrappedError.error);
}
this.props.onValidate(errors);
};
validatePresence = (field, value) => {
const { t, parentIds } = this.props;
const isRequired = field.get('required', true);
if (isRequired && isEmpty(value)) {
const error = {
type: ValidationErrorTypes.PRESENCE,
parentIds,
message: t('editor.editorControlPane.widget.required', {
fieldLabel: field.get('label', field.get('name')),
}),
};
return { error };
}
return { error: false };
};
validatePattern = (field, value) => {
const { t, parentIds } = this.props;
const pattern = field.get('pattern', false);
if (isEmpty(value)) {
return { error: false };
}
if (pattern && !RegExp(pattern.first()).test(value)) {
const error = {
type: ValidationErrorTypes.PATTERN,
parentIds,
message: t('editor.editorControlPane.widget.regexPattern', {
fieldLabel: field.get('label', field.get('name')),
pattern: pattern.last(),
}),
};
return { error };
}
return { error: false };
};
validateWrappedControl = field => {
const { t, parentIds } = this.props;
if (typeof this.wrappedControlValid !== 'function') {
throw new Error(oneLine`
this.wrappedControlValid is not a function. Are you sure widget
"${field.get('widget')}" is registered?
`);
}
const response = this.wrappedControlValid();
if (typeof response === 'boolean') {
const isValid = response;
return { error: !isValid };
} else if (Object.prototype.hasOwnProperty.call(response, 'error')) {
return response;
} else if (response instanceof Promise) {
response.then(
() => {
this.validate({ error: false });
},
err => {
const error = {
type: ValidationErrorTypes.CUSTOM,
message: `${field.get('label', field.get('name'))} - ${err}.`,
};
this.validate({ error });
},
);
const error = {
type: ValidationErrorTypes.CUSTOM,
parentIds,
message: t('editor.editorControlPane.widget.processing', {
fieldLabel: field.get('label', field.get('name')),
}),
};
return { error };
}
return { error: false };
};
/**
* In case the `onChangeObject` function is frozen by a child widget implementation,
* e.g. when debounced, always get the latest object value instead of using
* `this.props.value` directly.
*/
getObjectValue = () => this.props.value || Map();
/**
* Change handler for fields that are nested within another field.
*/
onChangeObject = (field, newValue, newMetadata) => {
const newObjectValue = this.getObjectValue().set(field.get('name'), newValue);
return this.props.onChange(
newObjectValue,
newMetadata && { [this.props.field.get('name')]: newMetadata },
);
};
setInactiveStyle = () => {
this.props.setInactiveStyle();
if (this.props.field.has('pattern') && !isEmpty(this.getValidateValue())) {
this.validate();
}
};
render() {
const {
controlComponent,
entry,
collection,
config,
field,
value,
mediaPaths,
metadata,
onChange,
onValidateObject,
onOpenMediaLibrary,
onRemoveMediaControl,
onPersistMedia,
onClearMediaControl,
onAddAsset,
onRemoveInsertedMedia,
getAsset,
classNameWrapper,
classNameWidget,
classNameWidgetActive,
classNameLabel,
classNameLabelActive,
setActiveStyle,
hasActiveStyle,
editorControl,
uniqueFieldId,
resolveWidget,
widget,
getEditorComponents,
query,
queryHits,
clearSearch,
clearFieldErrors,
isFetching,
loadEntry,
fieldsErrors,
controlRef,
isEditorComponent,
isNewEditorComponent,
parentIds,
t,
isDisabled,
isFieldDuplicate,
isFieldHidden,
locale,
isParentListCollapsed,
} = this.props;
return React.createElement(controlComponent, {
entry,
collection,
config,
field,
value,
mediaPaths,
metadata,
onChange,
onChangeObject: this.onChangeObject,
onValidateObject,
onOpenMediaLibrary,
onClearMediaControl,
onRemoveMediaControl,
onPersistMedia,
onAddAsset,
onRemoveInsertedMedia,
getAsset,
forID: uniqueFieldId,
ref: this.processInnerControlRef,
validate: this.validate,
classNameWrapper,
classNameWidget,
classNameWidgetActive,
classNameLabel,
classNameLabelActive,
setActiveStyle,
setInactiveStyle: () => this.setInactiveStyle(),
hasActiveStyle,
editorControl,
resolveWidget,
widget,
getEditorComponents,
getRemarkPlugins,
query,
queryHits,
clearSearch,
clearFieldErrors,
isFetching,
loadEntry,
isEditorComponent,
isNewEditorComponent,
fieldsErrors,
controlRef,
parentIds,
t,
isDisabled,
isFieldDuplicate,
isFieldHidden,
locale,
isParentListCollapsed,
});
}
}

View File

@@ -0,0 +1,444 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { css, Global } from '@emotion/react';
import styled from '@emotion/styled';
import SplitPane from 'react-split-pane';
import {
colors,
colorsRaw,
components,
transitions,
IconButton,
zIndex,
} from 'decap-cms-ui-default';
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
import EditorControlPane from './EditorControlPane/EditorControlPane';
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
import EditorToolbar from './EditorToolbar';
import { hasI18n, getI18nInfo, getPreviewEntry } from '../../lib/i18n';
import { FILES } from '../../constants/collectionTypes';
import { getFileFromSlug } from '../../reducers/collections';
const PREVIEW_VISIBLE = 'cms.preview-visible';
const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
const SPLIT_PANE_POSITION = 'cms.split-pane-position';
const I18N_VISIBLE = 'cms.i18n-visible';
const styles = {
splitPane: css`
${components.card};
border-radius: 0;
height: 100%;
`,
pane: css`
height: 100%;
overflow-y: auto;
`,
};
const EditorToggle = styled(IconButton)`
margin-bottom: 12px;
`;
function ReactSplitPaneGlobalStyles() {
return (
<Global
styles={css`
.Resizer.vertical {
width: 2px;
cursor: col-resize;
position: relative;
background: none;
&:before {
content: '';
width: 2px;
height: 100%;
position: relative;
background-color: ${colors.textFieldBorder};
display: block;
z-index: 10;
transition: background-color ${transitions.main};
}
&:hover,
&:active {
&:before {
width: 4px;
left: -1px;
background-color: ${colorsRaw.blue};
}
}
}
`}
/>
);
}
const StyledSplitPane = styled(SplitPane)`
${styles.splitPane};
/**
* Quick fix for preview pane not fully displaying in Safari
*/
.Pane {
height: 100%;
}
`;
const NoPreviewContainer = styled.div`
${styles.splitPane};
`;
const EditorContainer = styled.div`
width: 100%;
min-width: 800px;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
padding-top: 66px;
background-color: ${colors.background};
`;
const Editor = styled.div`
height: 100%;
margin: 0 auto;
position: relative;
`;
const PreviewPaneContainer = styled.div`
height: 100%;
pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')};
overflow-y: ${props => (props.overFlow ? 'auto' : 'hidden')};
`;
const ControlPaneContainer = styled(PreviewPaneContainer)`
padding: 0 16px;
position: relative;
overflow-x: hidden;
`;
const ViewControls = styled.div`
position: absolute;
top: 10px;
right: 10px;
z-index: ${zIndex.zIndex299};
`;
function EditorContent({
i18nVisible,
previewVisible,
editor,
editorWithEditor,
editorWithPreview,
}) {
if (i18nVisible) {
return editorWithEditor;
} else if (previewVisible) {
return editorWithPreview;
} else {
return <NoPreviewContainer>{editor}</NoPreviewContainer>;
}
}
function isPreviewEnabled(collection, entry) {
if (collection.get('type') === FILES) {
const file = getFileFromSlug(collection, entry.get('slug'));
const previewEnabled = file?.getIn(['editor', 'preview']);
if (previewEnabled != null) return previewEnabled;
}
return collection.getIn(['editor', 'preview'], true);
}
class EditorInterface extends Component {
state = {
showEventBlocker: false,
previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
scrollSyncEnabled: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false',
};
handleFieldClick = path => {
this.controlPaneRef?.focus(path);
};
handleSplitPaneDragStart = () => {
this.setState({ showEventBlocker: true });
};
handleSplitPaneDragFinished = () => {
this.setState({ showEventBlocker: false });
};
handleOnPersist = async (opts = {}) => {
const { createNew = false, duplicate = false } = opts;
await this.controlPaneRef.switchToDefaultLocale();
this.controlPaneRef.validate();
this.props.onPersist({ createNew, duplicate });
};
handleOnPublish = async (opts = {}) => {
const { createNew = false, duplicate = false } = opts;
await this.controlPaneRef.switchToDefaultLocale();
this.controlPaneRef.validate();
this.props.onPublish({ createNew, duplicate });
};
handleTogglePreview = () => {
const newPreviewVisible = !this.state.previewVisible;
this.setState({ previewVisible: newPreviewVisible });
localStorage.setItem(PREVIEW_VISIBLE, newPreviewVisible);
};
handleToggleScrollSync = () => {
const newScrollSyncEnabled = !this.state.scrollSyncEnabled;
this.setState({ scrollSyncEnabled: newScrollSyncEnabled });
localStorage.setItem(SCROLL_SYNC_ENABLED, newScrollSyncEnabled);
};
handleToggleI18n = () => {
const newI18nVisible = !this.state.i18nVisible;
this.setState({ i18nVisible: newI18nVisible });
localStorage.setItem(I18N_VISIBLE, newI18nVisible);
};
handleLeftPanelLocaleChange = locale => {
this.setState({ leftPanelLocale: locale });
};
render() {
const {
collection,
entry,
fields,
fieldsMetaData,
fieldsErrors,
onChange,
showDelete,
onDelete,
onDeleteUnpublishedChanges,
onChangeStatus,
onPublish,
unPublish,
onDuplicate,
onValidate,
user,
hasChanged,
displayUrl,
hasWorkflow,
useOpenAuthoring,
hasUnpublishedChanges,
isNewEntry,
isModification,
currentStatus,
onLogoutClick,
loadDeployPreview,
deployPreview,
draftKey,
editorBackLink,
t,
} = this.props;
const { scrollSyncEnabled, showEventBlocker } = this.state;
const previewEnabled = isPreviewEnabled(collection, entry);
const { locales, defaultLocale } = getI18nInfo(this.props.collection);
const collectionI18nEnabled = hasI18n(collection) && locales.length > 1;
const editorProps = {
collection,
entry,
fields,
fieldsMetaData,
fieldsErrors,
onChange,
onValidate,
};
const leftPanelLocale = this.state.leftPanelLocale || locales?.[0];
const editor = (
<ControlPaneContainer overFlow blockEntry={showEventBlocker}>
<EditorControlPane
{...editorProps}
ref={c => (this.controlPaneRef = c)}
locale={leftPanelLocale}
t={t}
onLocaleChange={this.handleLeftPanelLocaleChange}
/>
</ControlPaneContainer>
);
const editor2 = (
<ControlPaneContainer overFlow={!this.state.scrollSyncEnabled} blockEntry={showEventBlocker}>
<EditorControlPane {...editorProps} locale={locales?.[1]} t={t} />
</ControlPaneContainer>
);
const previewEntry = collectionI18nEnabled
? getPreviewEntry(entry, leftPanelLocale, defaultLocale)
: entry;
const editorWithPreview = (
<ScrollSync enabled={this.state.scrollSyncEnabled}>
<div>
<ReactSplitPaneGlobalStyles />
<StyledSplitPane
maxSize={-100}
minSize={400}
defaultSize={parseInt(localStorage.getItem(SPLIT_PANE_POSITION), 10) || '50%'}
onChange={size => localStorage.setItem(SPLIT_PANE_POSITION, size)}
onDragStarted={this.handleSplitPaneDragStart}
onDragFinished={this.handleSplitPaneDragFinished}
>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<PreviewPaneContainer blockEntry={showEventBlocker}>
<EditorPreviewPane
collection={collection}
entry={previewEntry}
fields={fields}
fieldsMetaData={fieldsMetaData}
locale={leftPanelLocale}
onFieldClick={this.handleFieldClick}
/>
</PreviewPaneContainer>
</StyledSplitPane>
</div>
</ScrollSync>
);
const editorWithEditor = (
<ScrollSync enabled={this.state.scrollSyncEnabled}>
<div>
<StyledSplitPane
maxSize={-100}
defaultSize={parseInt(localStorage.getItem(SPLIT_PANE_POSITION), 10) || '50%'}
onChange={size => localStorage.setItem(SPLIT_PANE_POSITION, size)}
onDragStarted={this.handleSplitPaneDragStart}
onDragFinished={this.handleSplitPaneDragFinished}
>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<ScrollSyncPane>{editor2}</ScrollSyncPane>
</StyledSplitPane>
</div>
</ScrollSync>
);
const i18nVisible = collectionI18nEnabled && this.state.i18nVisible;
const previewVisible = previewEnabled && this.state.previewVisible;
const scrollSyncVisible = i18nVisible || previewVisible;
return (
<EditorContainer>
<EditorToolbar
isPersisting={entry.get('isPersisting')}
isPublishing={entry.get('isPublishing')}
isUpdatingStatus={entry.get('isUpdatingStatus')}
isDeleting={entry.get('isDeleting')}
onPersist={this.handleOnPersist}
onPersistAndNew={() => this.handleOnPersist({ createNew: true })}
onPersistAndDuplicate={() => this.handleOnPersist({ createNew: true, duplicate: true })}
onDelete={onDelete}
onDeleteUnpublishedChanges={onDeleteUnpublishedChanges}
onChangeStatus={onChangeStatus}
showDelete={showDelete}
onPublish={onPublish}
unPublish={unPublish}
onDuplicate={onDuplicate}
onPublishAndNew={() => this.handleOnPublish({ createNew: true })}
onPublishAndDuplicate={() => this.handleOnPublish({ createNew: true, duplicate: true })}
user={user}
hasChanged={hasChanged}
displayUrl={displayUrl}
collection={collection}
hasWorkflow={hasWorkflow}
useOpenAuthoring={useOpenAuthoring}
hasUnpublishedChanges={hasUnpublishedChanges}
isNewEntry={isNewEntry}
isModification={isModification}
currentStatus={currentStatus}
onLogoutClick={onLogoutClick}
loadDeployPreview={loadDeployPreview}
deployPreview={deployPreview}
editorBackLink={editorBackLink}
/>
<Editor key={draftKey}>
<ViewControls>
{collectionI18nEnabled && (
<EditorToggle
isActive={i18nVisible}
onClick={this.handleToggleI18n}
size="large"
type="page"
title={t('editor.editorInterface.toggleI18n')}
marginTop="70px"
/>
)}
{previewEnabled && (
<EditorToggle
isActive={previewVisible}
onClick={this.handleTogglePreview}
size="large"
type="eye"
title={t('editor.editorInterface.togglePreview')}
/>
)}
{scrollSyncVisible && !collection.getIn(['editor', 'visualEditing']) && (
<EditorToggle
isActive={scrollSyncEnabled}
onClick={this.handleToggleScrollSync}
size="large"
type="scroll"
title={t('editor.editorInterface.toggleScrollSync')}
/>
)}
</ViewControls>
<EditorContent
i18nVisible={i18nVisible}
previewVisible={previewVisible}
editor={editor}
editorWithEditor={editorWithEditor}
editorWithPreview={editorWithPreview}
/>
</Editor>
</EditorContainer>
);
}
}
EditorInterface.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
fieldsErrors: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onValidate: PropTypes.func.isRequired,
onPersist: PropTypes.func.isRequired,
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
unPublish: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired,
user: PropTypes.object,
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,
hasWorkflow: PropTypes.bool,
useOpenAuthoring: PropTypes.bool,
hasUnpublishedChanges: PropTypes.bool,
isNewEntry: PropTypes.bool,
isModification: PropTypes.bool,
currentStatus: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
deployPreview: PropTypes.object,
loadDeployPreview: PropTypes.func.isRequired,
draftKey: PropTypes.string.isRequired,
t: PropTypes.func.isRequired,
};
export default EditorInterface;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
function isVisible(field) {
return field.get('widget') !== 'hidden';
}
const PreviewContainer = styled.div`
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
`;
/**
* Use a stateful component so that child components can effectively utilize
* `shouldComponentUpdate`.
*/
export default class Preview extends React.Component {
render() {
const { collection, fields, widgetFor } = this.props;
if (!collection || !fields) {
return null;
}
return (
<PreviewContainer>
{fields.filter(isVisible).map(field => (
<div key={field.get('name')}>{widgetFor(field.get('name'))}</div>
))}
</PreviewContainer>
);
}
}
Preview.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
getAsset: PropTypes.func.isRequired,
widgetFor: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,74 @@
import PropTypes from 'prop-types';
import React from 'react';
import { isElement } from 'react-is';
import { ScrollSyncPane } from 'react-scroll-sync';
import { FrameContextConsumer } from 'react-frame-component';
import { vercelStegaDecode } from '@vercel/stega';
/**
* PreviewContent renders the preview component and optionally handles visual editing interactions.
* By default it uses scroll sync, but can be configured to use visual editing instead.
*/
class PreviewContent extends React.Component {
handleClick = e => {
const { previewProps, onFieldClick } = this.props;
const visualEditing = previewProps?.collection?.getIn(['editor', 'visualEditing'], false);
if (!visualEditing) {
return;
}
try {
const text = e.target.textContent;
const decoded = vercelStegaDecode(text);
if (decoded?.decap) {
if (onFieldClick) {
onFieldClick(decoded.decap);
}
}
} catch (err) {
console.log('Visual editing error:', err);
}
};
renderPreview() {
const { previewComponent, previewProps } = this.props;
return (
<div onClick={this.handleClick}>
{isElement(previewComponent)
? React.cloneElement(previewComponent, previewProps)
: React.createElement(previewComponent, previewProps)}
</div>
);
}
render() {
const { previewProps } = this.props;
const visualEditing = previewProps?.collection?.getIn(['editor', 'visualEditing'], false);
const showScrollSync = !visualEditing;
return (
<FrameContextConsumer>
{context => {
const preview = this.renderPreview();
if (showScrollSync) {
return (
<ScrollSyncPane attachTo={context.document.scrollingElement}>
{preview}
</ScrollSyncPane>
);
}
return preview;
}}
</FrameContextConsumer>
);
}
}
PreviewContent.propTypes = {
previewComponent: PropTypes.func.isRequired,
previewProps: PropTypes.object,
onFieldClick: PropTypes.func,
};
export default PreviewContent;

View File

@@ -0,0 +1,333 @@
import PropTypes from 'prop-types';
import React from 'react';
import styled from '@emotion/styled';
import { List, Map } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Frame, { FrameContextConsumer } from 'react-frame-component';
import { lengths } from 'decap-cms-ui-default';
import { connect } from 'react-redux';
import { encodeEntry } from '../../../lib/stega';
import {
resolveWidget,
getPreviewTemplate,
getPreviewStyles,
getRemarkPlugins,
} from '../../../lib/registry';
import { getAllEntries, tryLoadEntry } from '../../../actions/entries';
import { ErrorBoundary } from '../../UI';
import {
selectTemplateName,
selectInferredField,
selectField,
} from '../../../reducers/collections';
import { boundGetAsset } from '../../../actions/media';
import { selectIsLoadingAsset } from '../../../reducers/medias';
import { INFERABLE_FIELDS } from '../../../constants/fieldInference';
import EditorPreviewContent from './EditorPreviewContent.js';
import PreviewHOC from './PreviewHOC';
import EditorPreview from './EditorPreview';
const PreviewPaneFrame = styled(Frame)`
width: 100%;
height: 100%;
border: none;
background: #fff;
border-radius: ${lengths.borderRadius};
`;
export class PreviewPane extends React.Component {
getWidget = (field, value, metadata, props, idx = null) => {
const { getAsset, entry } = props;
const widget = resolveWidget(field.get('widget'));
const key = idx ? field.get('name') + '_' + idx : field.get('name');
const valueIsInMap = value && !widget.allowMapValue && Map.isMap(value);
/**
* Use an HOC to provide conditional updates for all previews.
*/
return !widget.preview ? null : (
<PreviewHOC
previewComponent={widget.preview}
key={key}
field={field}
getAsset={getAsset}
value={valueIsInMap ? value.get(field.get('name')) : value}
entry={entry}
fieldsMetaData={metadata}
resolveWidget={resolveWidget}
getRemarkPlugins={getRemarkPlugins}
/>
);
};
inferredFields = {};
inferFields() {
const titleField = selectInferredField(this.props.collection, 'title');
const shortTitleField = selectInferredField(this.props.collection, 'shortTitle');
const authorField = selectInferredField(this.props.collection, 'author');
this.inferredFields = {};
if (titleField) this.inferredFields[titleField] = INFERABLE_FIELDS.title;
if (shortTitleField) this.inferredFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
if (authorField) this.inferredFields[authorField] = INFERABLE_FIELDS.author;
}
/**
* Returns the widget component for a named field, and makes recursive calls
* to retrieve components for nested and deeply nested fields, which occur in
* object and list type fields. Used internally to retrieve widgets, and also
* exposed for use in custom preview templates.
*/
widgetFor = (
name,
fields = this.props.fields,
values = this.props.entry.get('data'),
fieldsMetaData = this.props.fieldsMetaData,
) => {
// We retrieve the field by name so that this function can also be used in
// custom preview templates, where the field object can't be passed in.
let field = fields && fields.find(f => f.get('name') === name);
let value = Map.isMap(values) && values.get(field.get('name'));
if (field.get('meta')) {
value = this.props.entry.getIn(['meta', field.get('name')]);
}
const nestedFields = field.get('fields');
const singleField = field.get('field');
const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map());
if (nestedFields) {
field = field.set('fields', this.getNestedWidgets(nestedFields, value, metadata));
}
if (singleField) {
field = field.set('field', this.getSingleNested(singleField, value, metadata));
}
const labelledWidgets = ['string', 'text', 'number'];
const inferredField = Object.entries(this.inferredFields)
.filter(([key]) => {
const fieldToMatch = selectField(this.props.collection, key);
return fieldToMatch === field;
})
.map(([, value]) => value)[0];
if (inferredField) {
value = inferredField.defaultPreview(value);
} else if (
value &&
labelledWidgets.indexOf(field.get('widget')) !== -1 &&
value.toString().length < 50
) {
value = (
<div>
<strong>{field.get('label', field.get('name'))}:</strong> {value}
</div>
);
}
return value ? this.getWidget(field, value, metadata, this.props) : null;
};
/**
* Retrieves widgets for nested fields (children of object/list fields)
*/
getNestedWidgets = (fields, values, fieldsMetaData) => {
// Fields nested within a list field will be paired with a List of value Maps.
if (List.isList(values)) {
return values.map(value => this.widgetsForNestedFields(fields, value, fieldsMetaData));
}
// Fields nested within an object field will be paired with a single Map of values.
return this.widgetsForNestedFields(fields, values, fieldsMetaData);
};
getSingleNested = (field, values, fieldsMetaData) => {
if (List.isList(values)) {
return values.map((value, idx) =>
this.getWidget(field, value, fieldsMetaData.get(field.get('name')), this.props, idx),
);
}
return this.getWidget(field, values, fieldsMetaData.get(field.get('name')), this.props);
};
/**
* Use widgetFor as a mapping function for recursive widget retrieval
*/
widgetsForNestedFields = (fields, values, fieldsMetaData) => {
return fields.map(field => this.widgetFor(field.get('name'), fields, values, fieldsMetaData));
};
/**
* This function exists entirely to expose nested widgets for object and list
* fields to custom preview templates.
*
* TODO: see if widgetFor can now provide this functionality for preview templates
*/
widgetsFor = name => {
const { fields, entry, fieldsMetaData } = this.props;
const field = fields.find(f => f.get('name') === name);
const nestedFields = field && field.get('fields');
const variableTypes = field && field.get('types');
const value = entry.getIn(['data', field.get('name')]);
const metadata = fieldsMetaData.get(field.get('name'), Map());
// Variable Type lists
if (List.isList(value) && variableTypes) {
return value.map(val => {
const valueType = variableTypes.find(t => t.get('name') === val.get('type'));
const typeFields = valueType && valueType.get('fields');
const widgets =
typeFields &&
Map(
typeFields.map((f, i) => [
f.get('name'),
<div key={i}>{this.getWidget(f, val, metadata.get(f.get('name')), this.props)}</div>,
]),
);
return Map({ data: val, widgets });
});
}
// List widgets
if (List.isList(value)) {
return value.map(val => {
const widgets =
nestedFields &&
Map(
nestedFields.map((f, i) => [
f.get('name'),
<div key={i}>{this.getWidget(f, val, metadata.get(f.get('name')), this.props)}</div>,
]),
);
return Map({ data: val, widgets });
});
}
return Map({
data: value,
widgets:
nestedFields &&
Map(
nestedFields.map(f => [
f.get('name'),
this.getWidget(f, value, metadata.get(f.get('name')), this.props),
]),
),
});
};
/**
* This function exists entirely to expose collections from outside of this entry
*
*/
getCollection = async (collectionName, slug) => {
const { state } = this.props;
const selectedCollection = state.collections.get(collectionName);
if (typeof slug === 'undefined') {
const entries = await getAllEntries(state, selectedCollection);
return entries.map(entry => Map().set('data', entry.data));
}
const entry = await tryLoadEntry(state, selectedCollection, slug);
return Map().set('data', entry.data);
};
render() {
const { entry, collection, config } = this.props;
if (!entry || !entry.get('data')) {
return null;
}
const previewComponent =
getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || EditorPreview;
this.inferFields();
const visualEditing = collection.getIn(['editor', 'visualEditing'], false);
// Only encode entry data if visual editing is enabled
const previewEntry = visualEditing
? entry.set('data', encodeEntry(entry.get('data'), this.props.fields))
: entry;
const previewProps = {
...this.props,
entry: previewEntry,
widgetFor: (name, fields, values = previewEntry.get('data'), fieldsMetaData) =>
this.widgetFor(name, fields, values, fieldsMetaData),
widgetsFor: this.widgetsFor,
getCollection: this.getCollection,
};
const styleEls = getPreviewStyles().map((style, i) => {
if (style.raw) {
return <style key={i}>{style.value}</style>;
}
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
});
if (!collection) {
<PreviewPaneFrame id="preview-pane" head={styleEls} />;
}
const initialContent = `
<!DOCTYPE html>
<html>
<head><base target="_blank"/></head>
<body><div></div></body>
</html>
`;
return (
<ErrorBoundary config={config}>
<PreviewPaneFrame id="preview-pane" head={styleEls} initialContent={initialContent}>
<FrameContextConsumer>
{({ document, window }) => {
return (
<EditorPreviewContent
{...{ previewComponent, previewProps: { ...previewProps, document, window } }}
onFieldClick={this.props.onFieldClick}
/>
);
}}
</FrameContextConsumer>
</PreviewPaneFrame>
</ErrorBoundary>
);
}
}
PreviewPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
fields: ImmutablePropTypes.list.isRequired,
entry: ImmutablePropTypes.map.isRequired,
fieldsMetaData: ImmutablePropTypes.map.isRequired,
getAsset: PropTypes.func.isRequired,
onFieldClick: PropTypes.func,
};
function mapStateToProps(state) {
const isLoadingAsset = selectIsLoadingAsset(state.medias);
return { isLoadingAsset, config: state.config, state };
}
function mapDispatchToProps(dispatch) {
return {
boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry),
};
}
function mergeProps(stateProps, dispatchProps, ownProps) {
return {
...stateProps,
...dispatchProps,
...ownProps,
getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry),
};
}
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(PreviewPane);

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
class PreviewHOC extends React.Component {
/**
* Only re-render on value change, but always re-render objects and lists.
* Their child widgets will each also be wrapped with this component, and
* will only be updated on value change.
*/
shouldComponentUpdate(nextProps) {
const isWidgetContainer = ['object', 'list'].includes(nextProps.field.get('widget'));
return (
isWidgetContainer ||
this.props.value !== nextProps.value ||
this.props.fieldsMetaData !== nextProps.fieldsMetaData ||
this.props.getAsset !== nextProps.getAsset
);
}
render() {
const { previewComponent, ...props } = this.props;
return React.createElement(previewComponent, props);
}
}
PreviewHOC.propTypes = {
previewComponent: PropTypes.func.isRequired,
field: ImmutablePropTypes.map.isRequired,
value: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.string, PropTypes.bool]),
};
export default PreviewHOC;

View File

@@ -0,0 +1,691 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { Link } from 'react-router-dom';
import {
Icon,
Dropdown,
DropdownItem,
StyledDropdownButton,
colorsRaw,
colors,
components,
buttons,
zIndex,
} from 'decap-cms-ui-default';
import { status } from '../../constants/publishModes';
import { SettingsDropdown } from '../UI';
const styles = {
noOverflow: css`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`,
buttonMargin: css`
margin: 0 10px;
`,
toolbarSection: css`
height: 100%;
display: flex;
align-items: center;
border: 0 solid ${colors.textFieldBorder};
`,
publishedButton: css`
background-color: ${colorsRaw.tealLight};
color: ${colorsRaw.tealDark};
`,
};
const TooltipText = styled.div`
visibility: hidden;
width: 321px;
background-color: #555;
color: #fff;
text-align: unset;
border-radius: 6px;
padding: 5px;
/* Position the tooltip text */
position: absolute;
z-index: 1;
top: 145%;
left: 50%;
margin-left: -320px;
/* Fade in tooltip */
opacity: 0;
transition: opacity 0.3s;
`;
const Tooltip = styled.div`
position: relative;
display: inline-block;
&:hover + ${TooltipText} {
visibility: visible;
opacity: 0.9;
}
`;
const TooltipContainer = styled.div`
position: relative;
`;
const DropdownButton = styled(StyledDropdownButton)`
${styles.noOverflow}
@media (max-width: 1200px) {
padding-left: 10px;
}
`;
const ToolbarContainer = styled.div`
box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05), 0 1px 3px 0 rgba(68, 74, 87, 0.1),
0 2px 54px rgba(0, 0, 0, 0.1);
position: absolute;
top: 0;
left: 0;
width: 100%;
min-width: 800px;
z-index: ${zIndex.zIndex300};
background-color: #fff;
height: 66px;
display: flex;
justify-content: space-between;
`;
const ToolbarSectionMain = styled.div`
${styles.toolbarSection};
flex: 10;
display: flex;
justify-content: space-between;
padding: 0 10px;
`;
const ToolbarSubSectionFirst = styled.div`
display: flex;
align-items: center;
`;
const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
justify-content: flex-end;
`;
const ToolbarSectionBackLink = styled(Link)`
${styles.toolbarSection};
border-right-width: 1px;
font-weight: normal;
padding: 0 20px;
&:hover,
&:focus {
background-color: #f1f2f4;
}
`;
const ToolbarSectionMeta = styled.div`
${styles.toolbarSection};
border-left-width: 1px;
padding: 0 7px;
`;
const ToolbarDropdown = styled(Dropdown)`
${styles.buttonMargin};
${Icon} {
color: ${colorsRaw.teal};
}
`;
const BackArrow = styled.div`
color: ${colors.textLead};
font-size: 21px;
font-weight: 600;
margin-right: 16px;
`;
const BackCollection = styled.div`
color: ${colors.textLead};
font-size: 14px;
`;
const BackStatus = styled.div`
margin-top: 6px;
`;
const BackStatusUnchanged = styled(BackStatus)`
${components.textBadgeSuccess};
`;
const BackStatusChanged = styled(BackStatus)`
${components.textBadgeDanger};
`;
const ToolbarButton = styled.button`
${buttons.button};
${buttons.default};
${styles.buttonMargin};
${styles.noOverflow};
display: block;
@media (max-width: 1200px) {
padding: 0 10px;
}
`;
const DeleteButton = styled(ToolbarButton)`
${buttons.lightRed};
`;
const SaveButton = styled(ToolbarButton)`
${buttons.lightBlue};
&[disabled] {
${buttons.disabled};
}
`;
const PublishedToolbarButton = styled(DropdownButton)`
${styles.publishedButton}
`;
const PublishedButton = styled(ToolbarButton)`
${styles.publishedButton}
`;
const PublishButton = styled(DropdownButton)`
background-color: ${colorsRaw.teal};
`;
const StatusButton = styled(DropdownButton)`
background-color: ${colorsRaw.tealLight};
color: ${colorsRaw.teal};
`;
const PreviewButtonContainer = styled.div`
margin-right: 12px;
color: ${colorsRaw.blue};
display: flex;
align-items: center;
a,
${Icon} {
color: ${colorsRaw.blue};
}
${Icon} {
position: relative;
top: 1px;
}
`;
const RefreshPreviewButton = styled.button`
background: none;
border: 0;
cursor: pointer;
color: ${colorsRaw.blue};
span {
margin-right: 6px;
}
`;
const PreviewLink = RefreshPreviewButton.withComponent('a');
const PublishDropDownItem = styled(DropdownItem)`
min-width: initial;
`;
const StatusDropdownItem = styled(DropdownItem)`
${Icon} {
color: ${colors.infoText};
}
`;
export class EditorToolbar extends React.Component {
static propTypes = {
isPersisting: PropTypes.bool,
isPublishing: PropTypes.bool,
isUpdatingStatus: PropTypes.bool,
isDeleting: PropTypes.bool,
onPersist: PropTypes.func.isRequired,
onPersistAndNew: PropTypes.func.isRequired,
onPersistAndDuplicate: PropTypes.func.isRequired,
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onDeleteUnpublishedChanges: PropTypes.func.isRequired,
onChangeStatus: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
unPublish: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onPublishAndNew: PropTypes.func.isRequired,
onPublishAndDuplicate: PropTypes.func.isRequired,
user: PropTypes.object,
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,
collection: ImmutablePropTypes.map.isRequired,
hasWorkflow: PropTypes.bool,
useOpenAuthoring: PropTypes.bool,
hasUnpublishedChanges: PropTypes.bool,
isNewEntry: PropTypes.bool,
isModification: PropTypes.bool,
currentStatus: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
deployPreview: PropTypes.object,
loadDeployPreview: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
editorBackLink: PropTypes.string.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(EditorToolbar.propTypes, this.props, 'prop', 'EditorToolbar');
const { isNewEntry, loadDeployPreview } = this.props;
if (!isNewEntry) {
loadDeployPreview({ maxAttempts: 3 });
}
}
componentDidUpdate(prevProps) {
const { isNewEntry, isPersisting, loadDeployPreview } = this.props;
if (!isNewEntry && prevProps.isPersisting && !isPersisting) {
loadDeployPreview({ maxAttempts: 3 });
}
}
renderSimpleControls = () => {
const { collection, hasChanged, isNewEntry, showDelete, onDelete, t } = this.props;
const canCreate = collection.get('create');
return (
<>
{!isNewEntry && !hasChanged
? this.renderExistingEntrySimplePublishControls({ canCreate })
: this.renderNewEntrySimplePublishControls({ canCreate })}
<div>
{showDelete ? (
<DeleteButton onClick={onDelete}>{t('editor.editorToolbar.deleteEntry')}</DeleteButton>
) : null}
</div>
</>
);
};
renderDeployPreviewControls = label => {
const { deployPreview = {}, loadDeployPreview, t } = this.props;
const { url, status, isFetching } = deployPreview;
if (!status) {
return;
}
const deployPreviewReady = status === 'SUCCESS' && !isFetching;
return (
<PreviewButtonContainer>
{deployPreviewReady ? (
<PreviewLink rel="noopener noreferrer" target="_blank" href={url}>
<span>{label}</span>
<Icon type="new-tab" size="xsmall" />
</PreviewLink>
) : (
<RefreshPreviewButton onClick={loadDeployPreview}>
<span>{t('editor.editorToolbar.deployPreviewPendingButtonLabel')}</span>
<Icon type="refresh" size="xsmall" />
</RefreshPreviewButton>
)}
</PreviewButtonContainer>
);
};
renderStatusInfoTooltip = () => {
const { t, currentStatus } = this.props;
const statusToLocaleKey = {
[status.get('DRAFT')]: 'statusInfoTooltipDraft',
[status.get('PENDING_REVIEW')]: 'statusInfoTooltipInReview',
};
const statusKey = Object.keys(statusToLocaleKey).find(key => key === currentStatus);
return (
<TooltipContainer>
<Tooltip>
<Icon type="info-circle" size="small" className="tooltip" />
</Tooltip>
{statusKey && (
<TooltipText>{t(`editor.editorToolbar.${statusToLocaleKey[statusKey]}`)}</TooltipText>
)}
</TooltipContainer>
);
};
renderWorkflowStatusControls = () => {
const { isUpdatingStatus, onChangeStatus, currentStatus, t, useOpenAuthoring } = this.props;
const statusToTranslation = {
[status.get('DRAFT')]: t('editor.editorToolbar.draft'),
[status.get('PENDING_REVIEW')]: t('editor.editorToolbar.inReview'),
[status.get('PENDING_PUBLISH')]: t('editor.editorToolbar.ready'),
};
const buttonText = isUpdatingStatus
? t('editor.editorToolbar.updating')
: t('editor.editorToolbar.status', { status: statusToTranslation[currentStatus] });
return (
<>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="120px"
renderButton={() => <StatusButton>{buttonText}</StatusButton>}
>
<StatusDropdownItem
label={t('editor.editorToolbar.draft')}
onClick={() => onChangeStatus('DRAFT')}
icon={currentStatus === status.get('DRAFT') ? 'check' : null}
/>
<StatusDropdownItem
label={t('editor.editorToolbar.inReview')}
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') ? 'check' : null}
/>
{useOpenAuthoring ? (
''
) : (
<StatusDropdownItem
label={t('editor.editorToolbar.ready')}
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') ? 'check' : null}
/>
)}
</ToolbarDropdown>
{useOpenAuthoring && this.renderStatusInfoTooltip()}
</>
);
};
renderNewEntryWorkflowPublishControls = ({ canCreate, canPublish }) => {
const { isPublishing, onPublish, onPublishAndNew, onPublishAndDuplicate, t } = this.props;
return canPublish ? (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="200px"
renderButton={() => (
<PublishButton>
{isPublishing
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</PublishButton>
)}
>
<PublishDropDownItem
label={t('editor.editorToolbar.publishNow')}
icon="arrow"
iconDirection="right"
onClick={onPublish}
/>
{canCreate ? (
<>
<PublishDropDownItem
label={t('editor.editorToolbar.publishAndCreateNew')}
icon="add"
onClick={onPublishAndNew}
/>
<PublishDropDownItem
label={t('editor.editorToolbar.publishAndDuplicate')}
icon="add"
onClick={onPublishAndDuplicate}
/>
</>
) : null}
</ToolbarDropdown>
) : (
''
);
};
renderExistingEntryWorkflowPublishControls = ({ canCreate, canPublish, canDelete }) => {
const { unPublish, onDuplicate, isPersisting, t } = this.props;
return canPublish || canCreate ? (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="max-content"
key="td-publish-create"
renderButton={() => (
<PublishedToolbarButton>
{isPersisting
? t('editor.editorToolbar.unpublishing')
: t('editor.editorToolbar.published')}
</PublishedToolbarButton>
)}
>
{canDelete && canPublish && (
<DropdownItem
label={t('editor.editorToolbar.unpublish')}
icon="arrow"
iconDirection="right"
onClick={unPublish}
/>
)}
{canCreate && (
<DropdownItem
label={t('editor.editorToolbar.duplicate')}
icon="add"
onClick={onDuplicate}
/>
)}
</ToolbarDropdown>
) : (
''
);
};
renderExistingEntrySimplePublishControls = ({ canCreate }) => {
const { onDuplicate, t } = this.props;
return canCreate ? (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="max-content"
renderButton={() => (
<PublishedToolbarButton>{t('editor.editorToolbar.published')}</PublishedToolbarButton>
)}
>
{
<DropdownItem
label={t('editor.editorToolbar.duplicate')}
icon="add"
onClick={onDuplicate}
/>
}
</ToolbarDropdown>
) : (
<PublishedButton>{t('editor.editorToolbar.published')}</PublishedButton>
);
};
renderNewEntrySimplePublishControls = ({ canCreate }) => {
const { onPersist, onPersistAndNew, onPersistAndDuplicate, isPersisting, t } = this.props;
return (
<div>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="max-content"
renderButton={() => (
<PublishButton>
{isPersisting
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</PublishButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.publishNow')}
icon="arrow"
iconDirection="right"
onClick={onPersist}
/>
{canCreate ? (
<>
<DropdownItem
label={t('editor.editorToolbar.publishAndCreateNew')}
icon="add"
onClick={onPersistAndNew}
/>
<DropdownItem
label={t('editor.editorToolbar.publishAndDuplicate')}
icon="add"
onClick={onPersistAndDuplicate}
/>
</>
) : null}
</ToolbarDropdown>
</div>
);
};
renderSimpleDeployPreviewControls = () => {
const { hasChanged, isNewEntry, t } = this.props;
if (!isNewEntry && !hasChanged) {
return this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'));
}
};
renderWorkflowControls = () => {
const {
onPersist,
onDelete,
onDeleteUnpublishedChanges,
showDelete,
hasChanged,
hasUnpublishedChanges,
useOpenAuthoring,
isPersisting,
isDeleting,
isNewEntry,
isModification,
currentStatus,
collection,
t,
} = this.props;
const canCreate = collection.get('create');
const canPublish = collection.get('publish') && !useOpenAuthoring;
const canDelete = collection.get('delete', true);
const deleteLabel =
(hasUnpublishedChanges &&
isModification &&
t('editor.editorToolbar.deleteUnpublishedChanges')) ||
(hasUnpublishedChanges &&
(isNewEntry || !isModification) &&
t('editor.editorToolbar.deleteUnpublishedEntry')) ||
(!hasUnpublishedChanges && !isModification && t('editor.editorToolbar.deletePublishedEntry'));
return [
<SaveButton
disabled={!hasChanged}
key="save-button"
onClick={() => hasChanged && onPersist()}
>
{isPersisting ? t('editor.editorToolbar.saving') : t('editor.editorToolbar.save')}
</SaveButton>,
currentStatus
? [
<React.Fragment key="workflow-status-controls">
{this.renderWorkflowStatusControls()}
{!hasChanged && this.renderNewEntryWorkflowPublishControls({ canCreate, canPublish })}
</React.Fragment>,
]
: !isNewEntry && (
<React.Fragment key="existing-entry-workflow-publish-controls">
{this.renderExistingEntryWorkflowPublishControls({
canCreate,
canPublish,
canDelete,
})}
</React.Fragment>
),
(!showDelete || useOpenAuthoring) && !hasUnpublishedChanges && !isModification ? null : (
<DeleteButton
key="delete-button"
onClick={hasUnpublishedChanges ? onDeleteUnpublishedChanges : onDelete}
>
{isDeleting ? t('editor.editorToolbar.deleting') : deleteLabel}
</DeleteButton>
),
];
};
renderWorkflowDeployPreviewControls = () => {
const { currentStatus, isNewEntry, t } = this.props;
if (currentStatus) {
return this.renderDeployPreviewControls(t('editor.editorToolbar.deployPreviewButtonLabel'));
}
/**
* Publish control for published workflow entry.
*/
if (!isNewEntry) {
return this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'));
}
};
render() {
const {
user,
hasChanged,
displayUrl,
collection,
hasWorkflow,
onLogoutClick,
t,
editorBackLink,
} = this.props;
return (
<ToolbarContainer>
<ToolbarSectionBackLink to={editorBackLink}>
<BackArrow></BackArrow>
<div>
<BackCollection>
{t('editor.editorToolbar.backCollection', {
collectionLabel: collection.get('label'),
})}
</BackCollection>
{hasChanged ? (
<BackStatusChanged>{t('editor.editorToolbar.unsavedChanges')}</BackStatusChanged>
) : (
<BackStatusUnchanged>{t('editor.editorToolbar.changesSaved')}</BackStatusUnchanged>
)}
</div>
</ToolbarSectionBackLink>
<ToolbarSectionMain>
<ToolbarSubSectionFirst>
{hasWorkflow ? this.renderWorkflowControls() : this.renderSimpleControls()}
</ToolbarSubSectionFirst>
<ToolbarSubSectionLast>
{hasWorkflow
? this.renderWorkflowDeployPreviewControls()
: this.renderSimpleDeployPreviewControls()}
</ToolbarSubSectionLast>
</ToolbarSectionMain>
<ToolbarSectionMeta>
<SettingsDropdown
displayUrl={displayUrl}
imageUrl={user?.avatar_url}
onLogoutClick={onLogoutClick}
/>
</ToolbarSectionMeta>
</ToolbarContainer>
);
}
}
export default translate()(EditorToolbar);

View File

@@ -0,0 +1,221 @@
import React from 'react';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import { Editor } from '../Editor';
jest.mock('lodash/debounce', () => {
const flush = jest.fn();
return func => {
func.flush = flush;
return func;
};
});
// eslint-disable-next-line react/display-name
jest.mock('../EditorInterface', () => props => <mock-editor-interface {...props} />);
jest.mock('decap-cms-ui-default', () => {
return {
// eslint-disable-next-line react/display-name
Loader: props => <mock-loader {...props} />,
};
});
jest.mock('../../../routing/history');
describe('Editor', () => {
const props = {
boundGetAsset: jest.fn(),
changeDraftField: jest.fn(),
changeDraftFieldValidation: jest.fn(),
collection: fromJS({ name: 'posts' }),
createDraftDuplicateFromEntry: jest.fn(),
createEmptyDraft: jest.fn(),
discardDraft: jest.fn(),
entry: fromJS({}),
entryDraft: fromJS({}),
loadEntry: jest.fn(),
persistEntry: jest.fn(),
deleteEntry: jest.fn(),
showDelete: true,
fields: fromJS([]),
slug: 'slug',
newEntry: true,
updateUnpublishedEntryStatus: jest.fn(),
publishUnpublishedEntry: jest.fn(),
deleteUnpublishedEntry: jest.fn(),
logoutUser: jest.fn(),
loadEntries: jest.fn(),
deployPreview: fromJS({}),
loadDeployPreview: jest.fn(),
user: fromJS({}),
t: jest.fn(key => key),
localBackup: fromJS({}),
retrieveLocalBackup: jest.fn(),
persistLocalBackup: jest.fn(),
location: { search: '?title=title' },
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render loader when entryDraft is null', () => {
// suppress prop type error
jest.spyOn(console, 'error').mockImplementation(() => {});
const { asFragment } = render(<Editor {...props} entryDraft={null} />);
expect(asFragment()).toMatchSnapshot();
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(
'Warning: Failed prop type: Required prop `entryDraft` was not specified in `Editor`.',
),
);
});
it('should render loader when entryDraft entry is undefined', () => {
const { asFragment } = render(<Editor {...props} entryDraft={fromJS({})} />);
expect(asFragment()).toMatchSnapshot();
});
it('should render loader when entry is fetching', () => {
const { asFragment } = render(
<Editor {...props} entryDraft={fromJS({ entry: {} })} entry={fromJS({ isFetching: true })} />,
);
expect(asFragment()).toMatchSnapshot();
});
it('should render editor interface when entry is not fetching', () => {
const { asFragment } = render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
/>,
);
expect(asFragment()).toMatchSnapshot();
});
it('should call retrieveLocalBackup on mount', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
/>,
);
expect(props.retrieveLocalBackup).toHaveBeenCalledTimes(1);
expect(props.retrieveLocalBackup).toHaveBeenCalledWith(props.collection, props.slug);
});
it('should create new draft on new entry when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
newEntry={true}
/>,
);
expect(props.createEmptyDraft).toHaveBeenCalledTimes(1);
expect(props.createEmptyDraft).toHaveBeenCalledWith(props.collection, '?title=title');
expect(props.loadEntry).toHaveBeenCalledTimes(0);
});
it('should load entry on existing entry when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
newEntry={false}
/>,
);
expect(props.createEmptyDraft).toHaveBeenCalledTimes(0);
expect(props.loadEntry).toHaveBeenCalledTimes(1);
expect(props.loadEntry).toHaveBeenCalledWith(props.collection, 'slug');
});
it('should load entries when entries are not loaded when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
collectionEntriesLoaded={false}
/>,
);
expect(props.loadEntries).toHaveBeenCalledTimes(1);
expect(props.loadEntries).toHaveBeenCalledWith(props.collection);
});
it('should not load entries when entries are loaded when mounting', () => {
render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' } })}
entry={fromJS({ isFetching: false })}
collectionEntriesLoaded={true}
/>,
);
expect(props.loadEntries).toHaveBeenCalledTimes(0);
});
it('should flush debounce createBackup, discard draft and remove exit blocker on umount', () => {
window.removeEventListener = jest.fn();
const debounce = require('lodash/debounce');
const flush = debounce({}).flush;
const { unmount } = render(
<Editor
{...props}
entryDraft={fromJS({ entry: { slug: 'slug' }, hasChanged: true })}
entry={fromJS({ isFetching: false })}
/>,
);
jest.clearAllMocks();
unmount();
expect(flush).toHaveBeenCalledTimes(1);
expect(props.discardDraft).toHaveBeenCalledTimes(1);
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
const callback = window.removeEventListener.mock.calls.find(
call => call[0] === 'beforeunload',
)[1];
const event = {};
callback(event);
expect(event).toEqual({ returnValue: 'editor.editor.onLeavePage' });
});
it('should persist backup when changed', () => {
const { rerender } = render(
<Editor
{...props}
entryDraft={fromJS({ entry: {} })}
entry={fromJS({ isFetching: false })}
/>,
);
jest.clearAllMocks();
rerender(
<Editor
{...props}
entryDraft={fromJS({ entry: { mediaFiles: [{ id: '1' }] } })}
entry={fromJS({ isFetching: false, data: {} })}
hasChanged={true}
/>,
);
expect(props.persistLocalBackup).toHaveBeenCalledTimes(1);
expect(props.persistLocalBackup).toHaveBeenCalledWith(
fromJS({ mediaFiles: [{ id: '1' }] }),
props.collection,
);
});
});

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { render } from '@testing-library/react';
import { fromJS } from 'immutable';
import { EditorToolbar } from '../EditorToolbar';
jest.mock('../../UI', () => ({
// eslint-disable-next-line react/display-name
SettingsDropdown: props => <mock-settings-dropdown {...props} />,
}));
jest.mock('react-router-dom', () => {
return {
// eslint-disable-next-line react/display-name
Link: props => <mock-link {...props} />,
};
});
describe('EditorToolbar', () => {
const props = {
isPersisting: false,
isPublishing: false,
isUpdatingStatus: false,
isDeleting: false,
onPersist: jest.fn(),
onPersistAndNew: jest.fn(),
onPersistAndDuplicate: jest.fn(),
showDelete: true,
onDelete: jest.fn(),
onDeleteUnpublishedChanges: jest.fn(),
onChangeStatus: jest.fn(),
onPublish: jest.fn(),
unPublish: jest.fn(),
onDuplicate: jest.fn(),
onPublishAndNew: jest.fn(),
onPublishAndDuplicate: jest.fn(),
hasChanged: false,
collection: fromJS({ name: 'posts' }),
hasWorkflow: false,
useOpenAuthoring: false,
hasUnpublishedChanges: false,
isNewEntry: false,
isModification: false,
onLogoutClick: jest.fn(),
loadDeployPreview: jest.fn(),
t: jest.fn(key => key),
editorBackLink: '',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with default props', () => {
const { asFragment } = render(<EditorToolbar {...props} />);
expect(asFragment()).toMatchSnapshot();
});
[false, true].forEach(useOpenAuthoring => {
it(`should render with workflow controls hasUnpublishedChanges=true,isNewEntry=false,isModification=true,useOpenAuthoring=${useOpenAuthoring}`, () => {
const { asFragment } = render(
<EditorToolbar
{...props}
hasWorkflow={true}
hasUnpublishedChanges={true}
isNewEntry={false}
isModification={true}
useOpenAuthoring={useOpenAuthoring}
/>,
);
expect(asFragment()).toMatchSnapshot();
});
it(`should render with workflow controls hasUnpublishedChanges=true,isNewEntry=false,isModification=false,useOpenAuthoring=${useOpenAuthoring}`, () => {
const { asFragment } = render(
<EditorToolbar
{...props}
hasWorkflow={true}
hasUnpublishedChanges={true}
isNewEntry={false}
isModification={false}
useOpenAuthoring={useOpenAuthoring}
/>,
);
expect(asFragment()).toMatchSnapshot();
});
it(`should render with workflow controls hasUnpublishedChanges=false,isNewEntry=false,isModification=false,useOpenAuthoring=${useOpenAuthoring}`, () => {
const { asFragment } = render(
<EditorToolbar
{...props}
hasWorkflow={true}
hasUnpublishedChanges={false}
isNewEntry={false}
isModification={false}
useOpenAuthoring={useOpenAuthoring}
/>,
);
expect(asFragment()).toMatchSnapshot();
});
['draft', 'pending_review', 'pending_publish'].forEach(status => {
it(`should render with status=${status},useOpenAuthoring=${useOpenAuthoring}`, () => {
const { asFragment } = render(
<EditorToolbar
{...props}
hasWorkflow={true}
currentStatus={status}
useOpenAuthoring={useOpenAuthoring}
/>,
);
expect(asFragment()).toMatchSnapshot();
});
});
it(`should render normal save button`, () => {
const { asFragment } = render(<EditorToolbar {...props} hasChanged={true} />);
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Editor should render editor interface when entry is not fetching 1`] = `
<DocumentFragment>
<mock-editor-interface
collection="Map { \\"name\\": \\"posts\\" }"
deploypreview="Map {}"
entry="Map { \\"slug\\": \\"slug\\" }"
fields="List []"
isnewentry=""
showdelete=""
user="Map {}"
/>
</DocumentFragment>
`;
exports[`Editor should render loader when entry is fetching 1`] = `
<DocumentFragment>
<mock-loader
active=""
>
editor.editor.loadingEntry
</mock-loader>
</DocumentFragment>
`;
exports[`Editor should render loader when entryDraft entry is undefined 1`] = `
<DocumentFragment>
<mock-loader
active=""
>
editor.editor.loadingEntry
</mock-loader>
</DocumentFragment>
`;
exports[`Editor should render loader when entryDraft is null 1`] = `
<DocumentFragment>
<mock-loader
active=""
>
editor.editor.loadingEntry
</mock-loader>
</DocumentFragment>
`;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { connect } from 'react-redux';
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
import { selectUnpublishedEntry } from '../../reducers';
import { selectAllowDeletion } from '../../reducers/collections';
import { loadUnpublishedEntry, persistUnpublishedEntry } from '../../actions/editorialWorkflow';
function mapStateToProps(state, ownProps) {
const { collections } = state;
const isEditorialWorkflow = state.config.publish_mode === EDITORIAL_WORKFLOW;
const collection = collections.get(ownProps.match.params.name);
const returnObj = {
isEditorialWorkflow,
showDelete: !ownProps.newEntry && selectAllowDeletion(collection),
};
if (isEditorialWorkflow) {
const slug = ownProps.match.params[0];
const unpublishedEntry = selectUnpublishedEntry(state, collection.get('name'), slug);
if (unpublishedEntry) {
returnObj.unpublishedEntry = true;
returnObj.entry = unpublishedEntry;
}
}
return returnObj;
}
function mergeProps(stateProps, dispatchProps, ownProps) {
const { isEditorialWorkflow, unpublishedEntry } = stateProps;
const { dispatch } = dispatchProps;
const returnObj = {};
if (isEditorialWorkflow) {
// Overwrite loadEntry to loadUnpublishedEntry
returnObj.loadEntry = (collection, slug) => dispatch(loadUnpublishedEntry(collection, slug));
// Overwrite persistEntry to persistUnpublishedEntry
returnObj.persistEntry = collection =>
dispatch(persistUnpublishedEntry(collection, unpublishedEntry));
}
return {
...ownProps,
...stateProps,
...returnObj,
};
}
export default function withWorkflow(Editor) {
return connect(
mapStateToProps,
null,
mergeProps,
)(
class WorkflowEditor extends React.Component {
render() {
return <Editor {...this.props} />;
}
},
);
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { translate } from 'react-polyglot';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
function UnknownControl({ field, t }) {
return (
<div>{t('editor.editorWidgets.unknownControl.noControl', { widget: field.get('widget') })}</div>
);
}
UnknownControl.propTypes = {
field: ImmutablePropTypes.map,
t: PropTypes.func.isRequired,
};
export default translate()(UnknownControl);

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { translate } from 'react-polyglot';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
function UnknownPreview({ field, t }) {
return (
<div className="nc-widgetPreview">
{t('editor.editorWidgets.unknownPreview.noPreview', { widget: field.get('widget') })}
</div>
);
}
UnknownPreview.propTypes = {
field: ImmutablePropTypes.map,
t: PropTypes.func.isRequired,
};
export default translate()(UnknownPreview);

View File

@@ -0,0 +1,5 @@
import { registerWidget } from '../../lib/registry';
import UnknownControl from './Unknown/UnknownControl';
import UnknownPreview from './Unknown/UnknownPreview';
registerWidget('unknown', UnknownControl, UnknownPreview);

View File

@@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { colors } from 'decap-cms-ui-default';
const EmptyMessageContainer = styled.div`
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
color: ${props => props.isPrivate && colors.textFieldBorder};
`;
function EmptyMessage({ content, isPrivate }) {
return (
<EmptyMessageContainer isPrivate={isPrivate}>
<h1>{content}</h1>
</EmptyMessageContainer>
);
}
EmptyMessage.propTypes = {
content: PropTypes.string.isRequired,
isPrivate: PropTypes.bool,
};
export default EmptyMessage;

View File

@@ -0,0 +1,411 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import orderBy from 'lodash/orderBy';
import map from 'lodash/map';
import { translate } from 'react-polyglot';
import fuzzy from 'fuzzy';
import { fileExtension } from 'decap-cms-lib-util';
import {
loadMedia as loadMediaAction,
persistMedia as persistMediaAction,
deleteMedia as deleteMediaAction,
insertMedia as insertMediaAction,
loadMediaDisplayURL as loadMediaDisplayURLAction,
closeMediaLibrary as closeMediaLibraryAction,
} from '../../actions/mediaLibrary';
import { selectMediaFiles } from '../../reducers/mediaLibrary';
import MediaLibraryModal, { fileShape } from './MediaLibraryModal';
/**
* Extensions used to determine which files to show when the media library is
* accessed from an image insertion field.
*/
const IMAGE_EXTENSIONS_VIEWABLE = [
'jpg',
'jpeg',
'webp',
'gif',
'png',
'bmp',
'tiff',
'svg',
'avif',
];
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
class MediaLibrary extends React.Component {
static propTypes = {
isVisible: PropTypes.bool,
loadMediaDisplayURL: PropTypes.func,
displayURLs: ImmutablePropTypes.map,
canInsert: PropTypes.bool,
files: PropTypes.arrayOf(PropTypes.shape(fileShape)).isRequired,
dynamicSearch: PropTypes.bool,
dynamicSearchActive: PropTypes.bool,
forImage: PropTypes.bool,
isLoading: PropTypes.bool,
isPersisting: PropTypes.bool,
isDeleting: PropTypes.bool,
hasNextPage: PropTypes.bool,
isPaginating: PropTypes.bool,
privateUpload: PropTypes.bool,
config: ImmutablePropTypes.map,
loadMedia: PropTypes.func.isRequired,
dynamicSearchQuery: PropTypes.string,
page: PropTypes.number,
persistMedia: PropTypes.func.isRequired,
deleteMedia: PropTypes.func.isRequired,
insertMedia: PropTypes.func.isRequired,
closeMediaLibrary: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
static defaultProps = {
files: [],
};
/**
* The currently selected file and query are tracked in component state as
* they do not impact the rest of the application.
*/
state = {
selectedFile: {},
query: '',
isPersisted: false,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(MediaLibrary.propTypes, this.props, 'prop', 'MediaLibrary');
this.props.loadMedia();
}
UNSAFE_componentWillReceiveProps(nextProps) {
/**
* We clear old state from the media library when it's being re-opened
* because, when doing so on close, the state is cleared while the media
* library is still fading away.
*/
const isOpening = !this.props.isVisible && nextProps.isVisible;
if (isOpening) {
this.setState({ selectedFile: {}, query: '' });
}
if (this.state.isPersisted) {
this.setState({
selectedFile: nextProps.files[0],
isPersisted: false,
});
}
}
componentDidUpdate(prevProps) {
const isOpening = !prevProps.isVisible && this.props.isVisible;
if (isOpening && prevProps.privateUpload !== this.props.privateUpload) {
this.props.loadMedia({ privateUpload: this.props.privateUpload });
}
if (this.state.isPersisted) {
this.setState({
selectedFile: this.props.files[0],
isPersisted: false,
});
}
}
loadDisplayURL = file => {
const { loadMediaDisplayURL } = this.props;
loadMediaDisplayURL(file);
};
/**
* Filter an array of file data to include only images.
*/
filterImages = files => {
return files.filter(file => {
const ext = fileExtension(file.name).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext);
});
};
/**
* Transform file data for table display.
*/
toTableData = files => {
const tableData =
files &&
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,
displayURL,
draft,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
};
});
/**
* Get the sort order for use with `lodash.orderBy`, and always add the
* `queryOrder` sort as the lowest priority sort order.
*/
const { sortFields } = this.state;
const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
const directions = map(sortFields, 'direction').concat('asc');
return orderBy(tableData, fieldNames, directions);
};
handleClose = () => {
this.props.closeMediaLibrary();
};
/**
* Toggle asset selection on click.
*/
handleAssetClick = asset => {
const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset;
this.setState({ selectedFile });
};
/**
* Upload a file.
*/
handlePersist = async event => {
/**
* Stop the browser from automatically handling the file input click, and
* get the file for upload, and retain the synthetic event for access after
* the asynchronous persist operation.
*/
event.persist();
event.stopPropagation();
event.preventDefault();
const { persistMedia, privateUpload, config, t, field } = this.props;
const { files: fileList } = event.dataTransfer || event.target;
const files = [...fileList];
const file = files[0];
const maxFileSize = config.get('max_file_size');
if (maxFileSize && file.size > maxFileSize) {
window.alert(
t('mediaLibrary.mediaLibrary.fileTooLarge', {
size: Math.floor(maxFileSize / 1000),
}),
);
} else {
await persistMedia(file, { privateUpload, field });
this.setState({ isPersisted: true });
this.scrollToTop();
}
event.target.value = null;
};
/**
* Stores the public path of the file in the application store, where the
* editor field that launched the media library can retrieve it.
*/
handleInsert = () => {
const { selectedFile } = this.state;
const { path } = selectedFile;
const { insertMedia, field } = this.props;
insertMedia(path, field);
this.handleClose();
};
/**
* Removes the selected file from the backend.
*/
handleDelete = () => {
const { selectedFile } = this.state;
const { files, deleteMedia, privateUpload, t } = this.props;
if (!window.confirm(t('mediaLibrary.mediaLibrary.onDelete'))) {
return;
}
const file = files.find(file => selectedFile.key === file.key);
deleteMedia(file, { privateUpload }).then(() => {
this.setState({ selectedFile: {} });
});
};
/**
* Downloads the selected file.
*/
handleDownload = () => {
const { selectedFile } = this.state;
const { displayURLs } = this.props;
const url = displayURLs.getIn([selectedFile.id, 'url']) || selectedFile.url;
if (!url) {
return;
}
const filename = selectedFile.name;
const element = document.createElement('a');
element.setAttribute('href', url);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
this.setState({ selectedFile: {} });
};
/**
*
*/
handleLoadMore = () => {
const { loadMedia, dynamicSearchQuery, page, privateUpload } = this.props;
loadMedia({ query: dynamicSearchQuery, page: page + 1, privateUpload });
};
/**
* Executes media library search for implementations that support dynamic
* search via request. For these implementations, the Enter key must be
* pressed to execute search. If assets are being stored directly through
* the GitHub backend, search is in-memory and occurs as the query is typed,
* so this handler has no impact.
*/
handleSearchKeyDown = async event => {
const { dynamicSearch, loadMedia, privateUpload } = this.props;
if (event.key === 'Enter' && dynamicSearch) {
await loadMedia({ query: this.state.query, privateUpload });
this.scrollToTop();
}
};
scrollToTop = () => {
this.scrollContainerRef.scrollTop = 0;
};
/**
* Updates query state as the user types in the search field.
*/
handleSearchChange = event => {
this.setState({ query: event.target.value });
};
/**
* Filters files that do not match the query. Not used for dynamic search.
*/
queryFilter = (query, files) => {
/**
* Because file names don't have spaces, typing a space eliminates all
* potential matches, so we strip them all out internally before running the
* query.
*/
const strippedQuery = query.replace(/ /g, '');
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
const matchFiles = matches.map((match, queryIndex) => {
const file = files[match.index];
return { ...file, queryIndex };
});
return matchFiles;
};
render() {
const {
isVisible,
canInsert,
files,
dynamicSearch,
dynamicSearchActive,
forImage,
isLoading,
isPersisting,
isDeleting,
hasNextPage,
isPaginating,
privateUpload,
displayURLs,
t,
} = this.props;
return (
<MediaLibraryModal
isVisible={isVisible}
canInsert={canInsert}
files={files}
dynamicSearch={dynamicSearch}
dynamicSearchActive={dynamicSearchActive}
forImage={forImage}
isLoading={isLoading}
isPersisting={isPersisting}
isDeleting={isDeleting}
hasNextPage={hasNextPage}
isPaginating={isPaginating}
privateUpload={privateUpload}
query={this.state.query}
selectedFile={this.state.selectedFile}
handleFilter={this.filterImages}
handleQuery={this.queryFilter}
toTableData={this.toTableData}
handleClose={this.handleClose}
handleSearchChange={this.handleSearchChange}
handleSearchKeyDown={this.handleSearchKeyDown}
handlePersist={this.handlePersist}
handleDelete={this.handleDelete}
handleInsert={this.handleInsert}
handleDownload={this.handleDownload}
setScrollContainerRef={ref => (this.scrollContainerRef = ref)}
handleAssetClick={this.handleAssetClick}
handleLoadMore={this.handleLoadMore}
displayURLs={displayURLs}
loadDisplayURL={this.loadDisplayURL}
t={t}
/>
);
}
}
function mapStateToProps(state) {
const { mediaLibrary } = state;
const field = mediaLibrary.get('field');
const mediaLibraryProps = {
isVisible: mediaLibrary.get('isVisible'),
canInsert: mediaLibrary.get('canInsert'),
files: selectMediaFiles(state, field),
displayURLs: mediaLibrary.get('displayURLs'),
dynamicSearch: mediaLibrary.get('dynamicSearch'),
dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'),
dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'),
forImage: mediaLibrary.get('forImage'),
isLoading: mediaLibrary.get('isLoading'),
isPersisting: mediaLibrary.get('isPersisting'),
isDeleting: mediaLibrary.get('isDeleting'),
privateUpload: mediaLibrary.get('privateUpload'),
config: mediaLibrary.get('config'),
page: mediaLibrary.get('page'),
hasNextPage: mediaLibrary.get('hasNextPage'),
isPaginating: mediaLibrary.get('isPaginating'),
field,
};
return { ...mediaLibraryProps };
}
const mapDispatchToProps = {
loadMedia: loadMediaAction,
persistMedia: persistMediaAction,
deleteMedia: deleteMediaAction,
insertMedia: insertMediaAction,
loadMediaDisplayURL: loadMediaDisplayURLAction,
closeMediaLibrary: closeMediaLibraryAction,
};
export default connect(mapStateToProps, mapDispatchToProps)(translate()(MediaLibrary));

View File

@@ -0,0 +1,136 @@
import React from 'react';
import PropTypes from 'prop-types';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import copyToClipboard from 'copy-text-to-clipboard';
import { isAbsolutePath } from 'decap-cms-lib-util';
import { buttons, shadows, zIndex } from 'decap-cms-ui-default';
import { FileUploadButton } from '../UI';
const styles = {
button: css`
${buttons.button};
${buttons.default};
display: inline-block;
margin-left: 15px;
margin-right: 2px;
&[disabled] {
${buttons.disabled};
cursor: default;
}
`,
};
export const UploadButton = styled(FileUploadButton)`
${styles.button};
${buttons.gray};
${shadows.dropMain};
margin-bottom: 0;
span {
font-size: 14px;
font-weight: 500;
display: flex;
justify-content: center;
align-items: center;
}
input {
height: 0.1px;
width: 0.1px;
margin: 0;
padding: 0;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: ${zIndex.zIndex0};
outline: none;
}
`;
export const DeleteButton = styled.button`
${styles.button};
${buttons.lightRed};
`;
export const InsertButton = styled.button`
${styles.button};
${buttons.green};
`;
const ActionButton = styled.button`
${styles.button};
${props =>
!props.disabled &&
css`
${buttons.gray}
`}
`;
export const DownloadButton = ActionButton;
export class CopyToClipBoardButton extends React.Component {
mounted = false;
timeout;
state = {
copied: false,
};
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
handleCopy = () => {
clearTimeout(this.timeout);
const { path, draft, name } = this.props;
copyToClipboard(isAbsolutePath(path) || !draft ? path : name);
this.setState({ copied: true });
this.timeout = setTimeout(() => this.mounted && this.setState({ copied: false }), 1500);
};
getTitle = () => {
const { t, path, draft } = this.props;
if (this.state.copied) {
return t('mediaLibrary.mediaLibraryCard.copied');
}
if (!path) {
return t('mediaLibrary.mediaLibraryCard.copy');
}
if (isAbsolutePath(path)) {
return t('mediaLibrary.mediaLibraryCard.copyUrl');
}
if (draft) {
return t('mediaLibrary.mediaLibraryCard.copyName');
}
return t('mediaLibrary.mediaLibraryCard.copyPath');
};
render() {
const { disabled } = this.props;
return (
<ActionButton disabled={disabled} onClick={this.handleCopy}>
{this.getTitle()}
</ActionButton>
);
}
}
CopyToClipBoardButton.propTypes = {
disabled: PropTypes.bool.isRequired,
draft: PropTypes.bool,
path: PropTypes.string,
name: PropTypes.string,
t: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,128 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { colors, borders, lengths, shadows, effects } from 'decap-cms-ui-default';
const IMAGE_HEIGHT = 160;
const Card = styled.div`
width: ${props => props.width};
height: ${props => props.height};
margin: ${props => props.margin};
border: ${borders.textField};
border-color: ${props => props.isSelected && colors.active};
border-radius: ${lengths.borderRadius};
cursor: pointer;
overflow: hidden;
background-color: ${props => props.isPrivate && colors.textFieldBorder};
&:focus {
outline: none;
}
`;
const CardImageWrapper = styled.div`
height: ${IMAGE_HEIGHT + 2}px;
${effects.checkerboard};
${shadows.inset};
border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder};
position: relative;
`;
const CardImage = styled.img`
width: 100%;
height: ${IMAGE_HEIGHT}px;
object-fit: contain;
border-radius: 2px 2px 0 0;
`;
const CardFileIcon = styled.div`
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 2px 2px 0 0;
padding: 1em;
font-size: 3em;
`;
const CardText = styled.p`
color: ${colors.text};
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3 !important;
`;
const DraftText = styled.p`
color: ${colors.mediaDraftText};
background-color: ${colors.mediaDraftBackground};
position: absolute;
padding: 8px;
border-radius: ${lengths.borderRadius} 0 ${lengths.borderRadius} 0;
`;
class MediaLibraryCard extends React.Component {
render() {
const {
isSelected,
displayURL,
text,
onClick,
draftText,
width,
height,
margin,
isPrivate,
type,
isViewableImage,
isDraft,
} = this.props;
const url = displayURL.get('url');
return (
<Card
isSelected={isSelected}
onClick={onClick}
width={width}
height={height}
margin={margin}
tabIndex="-1"
isPrivate={isPrivate}
>
<CardImageWrapper>
{isDraft ? <DraftText data-testid="draft-text">{draftText}</DraftText> : null}
{url && isViewableImage ? (
<CardImage loading="lazy" src={url} />
) : (
<CardFileIcon data-testid="card-file-icon">{type}</CardFileIcon>
)}
</CardImageWrapper>
<CardText>{text}</CardText>
</Card>
);
}
componentDidMount() {
const { displayURL, loadDisplayURL } = this.props;
if (!displayURL.get('url')) {
loadDisplayURL();
}
}
}
MediaLibraryCard.propTypes = {
isSelected: PropTypes.bool,
displayURL: ImmutablePropTypes.map.isRequired,
text: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
draftText: PropTypes.string.isRequired,
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
margin: PropTypes.string.isRequired,
isPrivate: PropTypes.bool,
type: PropTypes.string,
isViewableImage: PropTypes.bool.isRequired,
loadDisplayURL: PropTypes.func.isRequired,
isDraft: PropTypes.bool,
};
export default MediaLibraryCard;

View File

@@ -0,0 +1,199 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Waypoint } from 'react-waypoint';
import { Map } from 'immutable';
import { colors } from 'decap-cms-ui-default';
import { FixedSizeGrid as Grid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import MediaLibraryCard from './MediaLibraryCard';
function CardWrapper(props) {
const {
rowIndex,
columnIndex,
style,
data: {
mediaItems,
isSelectedFile,
onAssetClick,
cardDraftText,
cardWidth,
cardHeight,
isPrivate,
displayURLs,
loadDisplayURL,
columnCount,
gutter,
},
} = props;
const index = rowIndex * columnCount + columnIndex;
if (index >= mediaItems.length) {
return null;
}
const file = mediaItems[index];
return (
<div
tabIndex="0"
style={{
...style,
left: style.left + gutter * columnIndex,
top: style.top + gutter,
width: style.width - gutter,
height: style.height - gutter,
}}
>
<MediaLibraryCard
key={file.key}
isSelected={isSelectedFile(file)}
text={file.name}
onClick={() => onAssetClick(file)}
isDraft={file.draft}
draftText={cardDraftText}
width={cardWidth}
height={cardHeight}
margin={'0px'}
isPrivate={isPrivate}
displayURL={displayURLs.get(file.id, file.url ? Map({ url: file.url }) : Map())}
loadDisplayURL={() => loadDisplayURL(file)}
type={file.type}
isViewableImage={file.isViewableImage}
/>
</div>
);
}
function VirtualizedGrid(props) {
const { mediaItems, setScrollContainerRef } = props;
return (
<CardGridContainer ref={setScrollContainerRef}>
<AutoSizer>
{({ height, width }) => {
const cardWidth = parseInt(props.cardWidth, 10);
const cardHeight = parseInt(props.cardHeight, 10);
const gutter = parseInt(props.cardMargin, 10);
const columnWidth = cardWidth + gutter;
const rowHeight = cardHeight + gutter;
const columnCount = Math.floor(width / columnWidth);
const rowCount = Math.ceil(mediaItems.length / columnCount);
return (
<Grid
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
height={height}
itemData={{ ...props, gutter, columnCount }}
>
{CardWrapper}
</Grid>
);
}}
</AutoSizer>
</CardGridContainer>
);
}
function PaginatedGrid({
setScrollContainerRef,
mediaItems,
isSelectedFile,
onAssetClick,
cardDraftText,
cardWidth,
cardHeight,
cardMargin,
isPrivate,
displayURLs,
loadDisplayURL,
canLoadMore,
onLoadMore,
isPaginating,
paginatingMessage,
}) {
return (
<CardGridContainer ref={setScrollContainerRef}>
<CardGrid>
{mediaItems.map(file => (
<MediaLibraryCard
key={file.key}
isSelected={isSelectedFile(file)}
text={file.name}
onClick={() => onAssetClick(file)}
isDraft={file.draft}
draftText={cardDraftText}
width={cardWidth}
height={cardHeight}
margin={cardMargin}
isPrivate={isPrivate}
displayURL={displayURLs.get(file.id, file.url ? Map({ url: file.url }) : Map())}
loadDisplayURL={() => loadDisplayURL(file)}
type={file.type}
isViewableImage={file.isViewableImage}
/>
))}
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
</CardGrid>
{!isPaginating ? null : (
<PaginatingMessage isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
)}
</CardGridContainer>
);
}
const CardGridContainer = styled.div`
overflow-y: auto;
overflow-x: hidden;
`;
const CardGrid = styled.div`
display: flex;
flex-wrap: wrap;
margin-left: -10px;
margin-right: -10px;
`;
const PaginatingMessage = styled.h1`
color: ${props => props.isPrivate && colors.textFieldBorder};
`;
function MediaLibraryCardGrid(props) {
const { canLoadMore, isPaginating } = props;
if (canLoadMore || isPaginating) {
return <PaginatedGrid {...props} />;
}
return <VirtualizedGrid {...props} />;
}
MediaLibraryCardGrid.propTypes = {
setScrollContainerRef: PropTypes.func.isRequired,
mediaItems: PropTypes.arrayOf(
PropTypes.shape({
displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
id: PropTypes.string.isRequired,
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
draft: PropTypes.bool,
}),
).isRequired,
isSelectedFile: PropTypes.func.isRequired,
onAssetClick: PropTypes.func.isRequired,
canLoadMore: PropTypes.bool,
onLoadMore: PropTypes.func.isRequired,
isPaginating: PropTypes.bool,
paginatingMessage: PropTypes.string,
cardDraftText: PropTypes.string.isRequired,
cardWidth: PropTypes.string.isRequired,
cardMargin: PropTypes.string.isRequired,
loadDisplayURL: PropTypes.func.isRequired,
isPrivate: PropTypes.bool,
displayURLs: PropTypes.instanceOf(Map).isRequired,
};
export default MediaLibraryCardGrid;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Icon, shadows, colors, buttons } from 'decap-cms-ui-default';
const CloseButton = styled.button`
${buttons.button};
${shadows.dropMiddle};
position: absolute;
margin-right: -40px;
left: -40px;
top: -40px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: white;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
`;
const LibraryTitle = styled.h1`
line-height: 36px;
font-size: 22px;
text-align: left;
margin-bottom: 25px;
color: ${props => props.isPrivate && colors.textFieldBorder};
`;
function MediaLibraryHeader({ onClose, title, isPrivate }) {
return (
<div>
<CloseButton onClick={onClose}>
<Icon type="close" />
</CloseButton>
<LibraryTitle isPrivate={isPrivate}>{title}</LibraryTitle>
</div>
);
}
MediaLibraryHeader.propTypes = {
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
isPrivate: PropTypes.bool,
};
export default MediaLibraryHeader;

View File

@@ -0,0 +1,200 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Map } from 'immutable';
import isEmpty from 'lodash/isEmpty';
import { translate } from 'react-polyglot';
import { colors } from 'decap-cms-ui-default';
import { Modal } from '../UI';
import MediaLibraryTop from './MediaLibraryTop';
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
import EmptyMessage from './EmptyMessage';
/**
* Responsive styling needs to be overhauled. Current setup requires specifying
* widths per breakpoint.
*/
const cardWidth = `280px`;
const cardHeight = `240px`;
const cardMargin = `10px`;
/**
* cardWidth + cardMargin * 2 = cardOutsideWidth
* (not using calc because this will be nested in other calcs)
*/
const cardOutsideWidth = `300px`;
const StyledModal = styled(Modal)`
display: grid;
grid-template-rows: 120px auto;
width: calc(${cardOutsideWidth} + 20px);
background-color: ${props => props.isPrivate && colors.grayDark};
@media (min-width: 800px) {
width: calc(${cardOutsideWidth} * 2 + 20px);
}
@media (min-width: 1120px) {
width: calc(${cardOutsideWidth} * 3 + 20px);
}
@media (min-width: 1440px) {
width: calc(${cardOutsideWidth} * 4 + 20px);
}
@media (min-width: 1760px) {
width: calc(${cardOutsideWidth} * 5 + 20px);
}
@media (min-width: 2080px) {
width: calc(${cardOutsideWidth} * 6 + 20px);
}
h1 {
color: ${props => props.isPrivate && colors.textFieldBorder};
}
button:disabled,
label[disabled] {
background-color: ${props => props.isPrivate && `rgba(217, 217, 217, 0.15)`};
}
`;
function MediaLibraryModal({
isVisible,
canInsert,
files,
dynamicSearch,
dynamicSearchActive,
forImage,
isLoading,
isPersisting,
isDeleting,
hasNextPage,
isPaginating,
privateUpload,
query,
selectedFile,
handleFilter,
handleQuery,
toTableData,
handleClose,
handleSearchChange,
handleSearchKeyDown,
handlePersist,
handleDelete,
handleInsert,
handleDownload,
setScrollContainerRef,
handleAssetClick,
handleLoadMore,
loadDisplayURL,
displayURLs,
t,
}) {
const filteredFiles = forImage ? handleFilter(files) : files;
const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles;
const tableData = toTableData(queriedFiles);
const hasFiles = files && !!files.length;
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
const hasSearchResults = queriedFiles && !!queriedFiles.length;
const hasMedia = hasSearchResults;
const shouldShowEmptyMessage = !hasMedia;
const emptyMessage =
(isLoading && !hasMedia && t('mediaLibrary.mediaLibraryModal.loading')) ||
(dynamicSearchActive && t('mediaLibrary.mediaLibraryModal.noResults')) ||
(!hasFiles && t('mediaLibrary.mediaLibraryModal.noAssetsFound')) ||
(!hasFilteredFiles && t('mediaLibrary.mediaLibraryModal.noImagesFound')) ||
(!hasSearchResults && t('mediaLibrary.mediaLibraryModal.noResults'));
const hasSelection = hasMedia && !isEmpty(selectedFile);
return (
<StyledModal isOpen={isVisible} onClose={handleClose} isPrivate={privateUpload}>
<MediaLibraryTop
t={t}
onClose={handleClose}
privateUpload={privateUpload}
forImage={forImage}
onDownload={handleDownload}
onUpload={handlePersist}
query={query}
onSearchChange={handleSearchChange}
onSearchKeyDown={handleSearchKeyDown}
searchDisabled={!dynamicSearchActive && !hasFilteredFiles}
onDelete={handleDelete}
canInsert={canInsert}
onInsert={handleInsert}
hasSelection={hasSelection}
isPersisting={isPersisting}
isDeleting={isDeleting}
selectedFile={selectedFile}
/>
{!shouldShowEmptyMessage ? null : (
<EmptyMessage content={emptyMessage} isPrivate={privateUpload} />
)}
<MediaLibraryCardGrid
setScrollContainerRef={setScrollContainerRef}
mediaItems={tableData}
isSelectedFile={file => selectedFile.key === file.key}
onAssetClick={handleAssetClick}
canLoadMore={hasNextPage}
onLoadMore={handleLoadMore}
isPaginating={isPaginating}
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
cardWidth={cardWidth}
cardHeight={cardHeight}
cardMargin={cardMargin}
isPrivate={privateUpload}
loadDisplayURL={loadDisplayURL}
displayURLs={displayURLs}
/>
</StyledModal>
);
}
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,
path: PropTypes.string.isRequired,
};
MediaLibraryModal.propTypes = {
isVisible: PropTypes.bool,
canInsert: PropTypes.bool,
files: PropTypes.arrayOf(PropTypes.shape(fileShape)).isRequired,
dynamicSearch: PropTypes.bool,
dynamicSearchActive: PropTypes.bool,
forImage: PropTypes.bool,
isLoading: PropTypes.bool,
isPersisting: PropTypes.bool,
isDeleting: PropTypes.bool,
hasNextPage: PropTypes.bool,
isPaginating: PropTypes.bool,
privateUpload: PropTypes.bool,
query: PropTypes.string,
selectedFile: PropTypes.oneOfType([PropTypes.shape(fileShape), PropTypes.shape({})]),
handleFilter: PropTypes.func.isRequired,
handleQuery: PropTypes.func.isRequired,
toTableData: PropTypes.func.isRequired,
handleClose: PropTypes.func.isRequired,
handleSearchChange: PropTypes.func.isRequired,
handleSearchKeyDown: PropTypes.func.isRequired,
handlePersist: PropTypes.func.isRequired,
handleDelete: PropTypes.func.isRequired,
handleInsert: PropTypes.func.isRequired,
setScrollContainerRef: PropTypes.func.isRequired,
handleAssetClick: PropTypes.func.isRequired,
handleLoadMore: PropTypes.func.isRequired,
loadDisplayURL: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
displayURLs: PropTypes.instanceOf(Map).isRequired,
};
export default translate()(MediaLibraryModal);

View File

@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Icon, lengths, colors, zIndex } from 'decap-cms-ui-default';
const SearchContainer = styled.div`
height: 37px;
display: flex;
align-items: center;
position: relative;
width: 400px;
`;
const SearchInput = styled.input`
background-color: #eff0f4;
border-radius: ${lengths.borderRadius};
font-size: 14px;
padding: 10px 6px 10px 32px;
width: 100%;
position: relative;
z-index: ${zIndex.zIndex1};
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px ${colors.active};
}
`;
const SearchIcon = styled(Icon)`
position: absolute;
top: 50%;
left: 6px;
z-index: ${zIndex.zIndex2};
transform: translate(0, -50%);
`;
function MediaLibrarySearch({ value, onChange, onKeyDown, placeholder, disabled }) {
return (
<SearchContainer>
<SearchIcon type="search" size="small" />
<SearchInput
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
/>
</SearchContainer>
);
}
MediaLibrarySearch.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
onKeyDown: PropTypes.func.isRequired,
placeholder: PropTypes.string.isRequired,
disabled: PropTypes.bool,
};
export default MediaLibrarySearch;

View File

@@ -0,0 +1,143 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import MediaLibrarySearch from './MediaLibrarySearch';
import MediaLibraryHeader from './MediaLibraryHeader';
import {
UploadButton,
DeleteButton,
DownloadButton,
CopyToClipBoardButton,
InsertButton,
} from './MediaLibraryButtons';
const LibraryTop = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;
const RowContainer = styled.div`
display: flex;
justify-content: space-between;
`;
const ButtonsContainer = styled.div`
flex-shrink: 0;
`;
function MediaLibraryTop({
t,
onClose,
privateUpload,
forImage,
onDownload,
onUpload,
query,
onSearchChange,
onSearchKeyDown,
searchDisabled,
onDelete,
canInsert,
onInsert,
hasSelection,
isPersisting,
isDeleting,
selectedFile,
}) {
const shouldShowButtonLoader = isPersisting || isDeleting;
const uploadEnabled = !shouldShowButtonLoader;
const deleteEnabled = !shouldShowButtonLoader && hasSelection;
const uploadButtonLabel = isPersisting
? t('mediaLibrary.mediaLibraryModal.uploading')
: t('mediaLibrary.mediaLibraryModal.upload');
const deleteButtonLabel = isDeleting
? t('mediaLibrary.mediaLibraryModal.deleting')
: t('mediaLibrary.mediaLibraryModal.deleteSelected');
const downloadButtonLabel = t('mediaLibrary.mediaLibraryModal.download');
const insertButtonLabel = t('mediaLibrary.mediaLibraryModal.chooseSelected');
return (
<LibraryTop>
<RowContainer>
<MediaLibraryHeader
onClose={onClose}
title={`${privateUpload ? t('mediaLibrary.mediaLibraryModal.private') : ''}${
forImage
? t('mediaLibrary.mediaLibraryModal.images')
: t('mediaLibrary.mediaLibraryModal.mediaAssets')
}`}
isPrivate={privateUpload}
/>
<ButtonsContainer>
<CopyToClipBoardButton
disabled={!hasSelection}
path={selectedFile.path}
name={selectedFile.name}
draft={selectedFile.draft}
t={t}
/>
<DownloadButton onClick={onDownload} disabled={!hasSelection}>
{downloadButtonLabel}
</DownloadButton>
<UploadButton
label={uploadButtonLabel}
imagesOnly={forImage}
onChange={onUpload}
disabled={!uploadEnabled}
/>
</ButtonsContainer>
</RowContainer>
<RowContainer>
<MediaLibrarySearch
value={query}
onChange={onSearchChange}
onKeyDown={onSearchKeyDown}
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
disabled={searchDisabled}
/>
<ButtonsContainer>
<DeleteButton onClick={onDelete} disabled={!deleteEnabled}>
{deleteButtonLabel}
</DeleteButton>
{!canInsert ? null : (
<InsertButton onClick={onInsert} disabled={!hasSelection}>
{insertButtonLabel}
</InsertButton>
)}
</ButtonsContainer>
</RowContainer>
</LibraryTop>
);
}
MediaLibraryTop.propTypes = {
t: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
privateUpload: PropTypes.bool,
forImage: PropTypes.bool,
onDownload: PropTypes.func.isRequired,
onUpload: PropTypes.func.isRequired,
query: PropTypes.string,
onSearchChange: PropTypes.func.isRequired,
onSearchKeyDown: PropTypes.func.isRequired,
searchDisabled: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
canInsert: PropTypes.bool,
onInsert: PropTypes.func.isRequired,
hasSelection: PropTypes.bool.isRequired,
isPersisting: PropTypes.bool,
isDeleting: PropTypes.bool,
selectedFile: PropTypes.oneOfType([
PropTypes.shape({
path: PropTypes.string.isRequired,
draft: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
}),
PropTypes.shape({}),
]),
};
export default MediaLibraryTop;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { render } from '@testing-library/react';
import { CopyToClipBoardButton } from '../MediaLibraryButtons';
describe('CopyToClipBoardButton', () => {
const props = {
disabled: false,
t: jest.fn(key => key),
};
it('should use copy text when no path is defined', () => {
const { container } = render(<CopyToClipBoardButton {...props} />);
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copy');
});
it('should use copyUrl text when path is absolute and is draft', () => {
const { container } = render(
<CopyToClipBoardButton {...props} path="https://www.images.com/image.png" draft />,
);
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyUrl');
});
it('should use copyUrl text when path is absolute and is not draft', () => {
const { container } = render(
<CopyToClipBoardButton {...props} path="https://www.images.com/image.png" />,
);
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyUrl');
});
it('should use copyName when path is not absolute and is draft', () => {
const { container } = render(<CopyToClipBoardButton {...props} path="image.png" draft />);
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyName');
});
it('should use copyPath when path is not absolute and is not draft', () => {
const { container } = render(<CopyToClipBoardButton {...props} path="image.png" />);
expect(container).toHaveTextContent('mediaLibrary.mediaLibraryCard.copyPath');
});
});

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Map } from 'immutable';
import { render } from '@testing-library/react';
import MediaLibraryCard from '../MediaLibraryCard';
describe('MediaLibraryCard', () => {
const props = {
displayURL: Map({ url: 'url' }),
text: 'image.png',
onClick: jest.fn(),
draftText: 'Draft',
width: '100px',
height: '240px',
margin: '10px',
isViewableImage: true,
loadDisplayURL: jest.fn(),
};
it('should match snapshot for non draft image', () => {
const { asFragment, queryByTestId } = render(<MediaLibraryCard {...props} />);
expect(queryByTestId('draft-text')).toBeNull();
expect(asFragment()).toMatchSnapshot();
});
it('should match snapshot for draft image', () => {
const { asFragment, getByTestId } = render(<MediaLibraryCard {...props} isDraft={true} />);
expect(getByTestId('draft-text')).toHaveTextContent('Draft');
expect(asFragment()).toMatchSnapshot();
});
it('should match snapshot for non viewable image', () => {
const { asFragment, getByTestId } = render(
<MediaLibraryCard {...props} isViewableImage={false} type="Not Viewable" />,
);
expect(getByTestId('card-file-icon')).toHaveTextContent('Not Viewable');
expect(asFragment()).toMatchSnapshot();
});
it('should call loadDisplayURL on mount when url is empty', () => {
const loadDisplayURL = jest.fn();
render(
<MediaLibraryCard {...props} loadDisplayURL={loadDisplayURL} displayURL={Map({ url: '' })} />,
);
expect(loadDisplayURL).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,264 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MediaLibraryCard should match snapshot for draft image 1`] = `
<DocumentFragment>
.emotion-0 {
width: 100px;
height: 240px;
margin: 10px;
border: solid 2px #dfdfe3;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
}
.emotion-0:focus {
outline: none;
}
.emotion-2 {
height: 162px;
background-color: #f2f2f2;
-webkit-background-size: 16px 16px;
background-size: 16px 16px;
-webkit-background-position: 0 0,8px 8px;
background-position: 0 0,8px 8px;
background-image: linear-gradient(
45deg,
#e6e6e6 25%,
transparent 25%,
transparent 75%,
#e6e6e6 75%,
#e6e6e6
),linear-gradient(
45deg,
#e6e6e6 25%,
transparent 25%,
transparent 75%,
#e6e6e6 75%,
#e6e6e6
);
box-shadow: inset 0 0 4px rgba(68, 74, 87, 0.3);
border-bottom: solid 2px #dfdfe3;
position: relative;
}
.emotion-4 {
color: #70399f;
background-color: #f6d8ff;
position: absolute;
padding: 8px;
border-radius: 5px 0 5px 0;
}
.emotion-6 {
width: 100%;
height: 160px;
object-fit: contain;
border-radius: 2px 2px 0 0;
}
.emotion-8 {
color: #798291;
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3!important;
}
<div
class="emotion-0 emotion-1"
height="240px"
tabindex="-1"
width="100px"
>
<div
class="emotion-2 emotion-3"
>
<p
class="emotion-4 emotion-5"
data-testid="draft-text"
>
Draft
</p>
<img
class="emotion-6 emotion-7"
loading="lazy"
src="url"
/>
</div>
<p
class="emotion-8 emotion-9"
>
image.png
</p>
</div>
</DocumentFragment>
`;
exports[`MediaLibraryCard should match snapshot for non draft image 1`] = `
<DocumentFragment>
.emotion-0 {
width: 100px;
height: 240px;
margin: 10px;
border: solid 2px #dfdfe3;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
}
.emotion-0:focus {
outline: none;
}
.emotion-2 {
height: 162px;
background-color: #f2f2f2;
-webkit-background-size: 16px 16px;
background-size: 16px 16px;
-webkit-background-position: 0 0,8px 8px;
background-position: 0 0,8px 8px;
background-image: linear-gradient(
45deg,
#e6e6e6 25%,
transparent 25%,
transparent 75%,
#e6e6e6 75%,
#e6e6e6
),linear-gradient(
45deg,
#e6e6e6 25%,
transparent 25%,
transparent 75%,
#e6e6e6 75%,
#e6e6e6
);
box-shadow: inset 0 0 4px rgba(68, 74, 87, 0.3);
border-bottom: solid 2px #dfdfe3;
position: relative;
}
.emotion-4 {
width: 100%;
height: 160px;
object-fit: contain;
border-radius: 2px 2px 0 0;
}
.emotion-6 {
color: #798291;
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3!important;
}
<div
class="emotion-0 emotion-1"
height="240px"
tabindex="-1"
width="100px"
>
<div
class="emotion-2 emotion-3"
>
<img
class="emotion-4 emotion-5"
loading="lazy"
src="url"
/>
</div>
<p
class="emotion-6 emotion-7"
>
image.png
</p>
</div>
</DocumentFragment>
`;
exports[`MediaLibraryCard should match snapshot for non viewable image 1`] = `
<DocumentFragment>
.emotion-0 {
width: 100px;
height: 240px;
margin: 10px;
border: solid 2px #dfdfe3;
border-radius: 5px;
cursor: pointer;
overflow: hidden;
}
.emotion-0:focus {
outline: none;
}
.emotion-2 {
height: 162px;
background-color: #f2f2f2;
-webkit-background-size: 16px 16px;
background-size: 16px 16px;
-webkit-background-position: 0 0,8px 8px;
background-position: 0 0,8px 8px;
background-image: linear-gradient(
45deg,
#e6e6e6 25%,
transparent 25%,
transparent 75%,
#e6e6e6 75%,
#e6e6e6
),linear-gradient(
45deg,
#e6e6e6 25%,
transparent 25%,
transparent 75%,
#e6e6e6 75%,
#e6e6e6
);
box-shadow: inset 0 0 4px rgba(68, 74, 87, 0.3);
border-bottom: solid 2px #dfdfe3;
position: relative;
}
.emotion-4 {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 2px 2px 0 0;
padding: 1em;
font-size: 3em;
}
.emotion-6 {
color: #798291;
padding: 8px;
margin-top: 20px;
overflow-wrap: break-word;
line-height: 1.3!important;
}
<div
class="emotion-0 emotion-1"
height="240px"
tabindex="-1"
width="100px"
>
<div
class="emotion-2 emotion-3"
>
<div
class="emotion-4 emotion-5"
data-testid="card-file-icon"
>
Not Viewable
</div>
</div>
<p
class="emotion-6 emotion-7"
>
image.png
</p>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,66 @@
import { HTML5Backend as ReactDNDHTML5Backend } from 'react-dnd-html5-backend';
import {
DndProvider as ReactDNDProvider,
DragSource as ReactDNDDragSource,
DropTarget as ReactDNDDropTarget,
} from 'react-dnd';
import React from 'react';
import PropTypes from 'prop-types';
export function DragSource({ namespace, ...props }) {
const DragComponent = ReactDNDDragSource(
namespace,
{
// eslint-disable-next-line no-unused-vars
beginDrag({ children, isDragging, connectDragComponent, ...ownProps }) {
// We return the rest of the props as the ID of the element being dragged.
return ownProps;
},
},
connect => ({
connectDragComponent: connect.dragSource(),
}),
)(({ children, connectDragComponent }) => children(connectDragComponent));
return React.createElement(DragComponent, props, props.children);
}
DragSource.propTypes = {
namespace: PropTypes.any.isRequired,
children: PropTypes.func.isRequired,
};
export function DropTarget({ onDrop, namespace, ...props }) {
const DropComponent = ReactDNDDropTarget(
namespace,
{
drop(ownProps, monitor) {
onDrop(monitor.getItem());
},
},
(connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
isHovered: monitor.isOver(),
}),
)(({ children, connectDropTarget, isHovered }) => children(connectDropTarget, { isHovered }));
return React.createElement(DropComponent, props, props.children);
}
DropTarget.propTypes = {
onDrop: PropTypes.func.isRequired,
namespace: PropTypes.any.isRequired,
children: PropTypes.func.isRequired,
};
export function HTML5DragDrop(WrappedComponent) {
return class HTML5DragDrop extends React.Component {
render() {
return (
<ReactDNDProvider backend={ReactDNDHTML5Backend}>
<WrappedComponent {...this.props} />
</ReactDNDProvider>
);
}
};
}

View File

@@ -0,0 +1,214 @@
import React from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-polyglot';
import styled from '@emotion/styled';
import yaml from 'yaml';
import truncate from 'lodash/truncate';
import copyToClipboard from 'copy-text-to-clipboard';
import { localForage } from 'decap-cms-lib-util';
import { buttons, colors } from 'decap-cms-ui-default';
import cleanStack from 'clean-stack';
const ISSUE_URL = 'https://github.com/decaporg/decap-cms/issues/new?';
function getIssueTemplate({ version, provider, browser, config }) {
return `
**Describe the bug**
**To Reproduce**
**Expected behavior**
**Screenshots**
**Applicable Versions:**
- Decap CMS version: \`${version}\`
- Git provider: \`${provider}\`
- Browser version: \`${browser}\`
**CMS configuration**
\`\`\`
${config}
\`\`\`
**Additional context**
`;
}
function buildIssueTemplate({ config }) {
let version = '';
if (typeof DECAP_CMS_VERSION === 'string') {
version = `decap-cms@${DECAP_CMS_VERSION}`;
} else if (typeof DECAP_CMS_APP_VERSION === 'string') {
version = `decap-cms-app@${DECAP_CMS_APP_VERSION}`;
}
const template = getIssueTemplate({
version,
provider: config.backend.name,
browser: navigator.userAgent,
config: yaml.stringify(config),
});
return template;
}
function buildIssueUrl({ title, config }) {
try {
const body = buildIssueTemplate({ config });
const params = new URLSearchParams();
params.append('title', truncate(title, { length: 100 }));
params.append('body', truncate(body, { length: 4000, omission: '\n...' }));
params.append('labels', 'type: bug');
return `${ISSUE_URL}${params.toString()}`;
} catch (e) {
console.log(e);
return `${ISSUE_URL}template=bug_report.md`;
}
}
const ErrorBoundaryContainer = styled.div`
padding: 40px;
h1 {
font-size: 28px;
color: ${colors.text};
}
h2 {
font-size: 20px;
}
strong {
color: ${colors.textLead};
font-weight: 500;
}
hr {
width: 200px;
margin: 30px 0;
border: 0;
height: 1px;
background-color: ${colors.text};
}
a {
color: ${colors.active};
}
`;
const PrivacyWarning = styled.span`
color: ${colors.text};
`;
const CopyButton = styled.button`
${buttons.button};
${buttons.default};
${buttons.gray};
display: block;
margin: 12px 0;
`;
function RecoveredEntry({ entry, t }) {
console.log(entry);
return (
<>
<hr />
<h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
<strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
<CopyButton onClick={() => copyToClipboard(entry)}>
{t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
</CopyButton>
<pre>
<code>{entry}</code>
</pre>
</>
);
}
export class ErrorBoundary extends React.Component {
static propTypes = {
children: PropTypes.node,
t: PropTypes.func.isRequired,
config: PropTypes.object.isRequired,
};
state = {
hasError: false,
errorMessage: '',
errorTitle: '',
backup: '',
};
static getDerivedStateFromError(error) {
console.error(error);
return {
hasError: true,
errorMessage: cleanStack(error.stack, { basePath: window.location.origin || '' }),
errorTitle: error.toString(),
};
}
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(ErrorBoundary.propTypes, this.props, 'prop', 'ErrorBoundary');
}
shouldComponentUpdate(nextProps, nextState) {
if (this.props.showBackup) {
return (
this.state.errorMessage !== nextState.errorMessage || this.state.backup !== nextState.backup
);
}
return true;
}
async componentDidUpdate() {
if (this.props.showBackup) {
const backup = await localForage.getItem('backup');
backup && console.log(backup);
this.setState({ backup });
}
}
render() {
const { hasError, errorMessage, backup, errorTitle } = this.state;
const { showBackup, t } = this.props;
if (!hasError) {
return this.props.children;
}
return (
<ErrorBoundaryContainer>
<h1>{t('ui.errorBoundary.title')}</h1>
<p>
<span>{t('ui.errorBoundary.details')}</span>
<a
href={buildIssueUrl({ title: errorTitle, config: this.props.config })}
target="_blank"
rel="noopener noreferrer"
data-testid="issue-url"
>
{t('ui.errorBoundary.reportIt')}
</a>
</p>
<p>
{t('ui.errorBoundary.privacyWarning')
.split('\n')
.map((item, index) => (
<>
<PrivacyWarning key={index}>{item}</PrivacyWarning>
<br />
</>
))}
</p>
<hr />
<h2>{t('ui.errorBoundary.detailsHeading')}</h2>
<p>{errorMessage}</p>
{backup && showBackup && <RecoveredEntry entry={backup} t={t} />}
</ErrorBoundaryContainer>
);
}
}
export default translate()(ErrorBoundary);

View File

@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
export function FileUploadButton({ label, imagesOnly, onChange, disabled, className }) {
return (
<label tabIndex={'0'} className={`nc-fileUploadButton ${className || ''}`}>
<span>{label}</span>
<input
type="file"
accept={imagesOnly ? 'image/*' : '*/*'}
onChange={onChange}
disabled={disabled}
/>
</label>
);
}
FileUploadButton.propTypes = {
className: PropTypes.string,
label: PropTypes.string.isRequired,
imagesOnly: PropTypes.bool,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
};

View File

@@ -0,0 +1,112 @@
import React from 'react';
import PropTypes from 'prop-types';
import { css, Global, ClassNames } from '@emotion/react';
import ReactModal from 'react-modal';
import { transitions, shadows, lengths, zIndex } from 'decap-cms-ui-default';
function ReactModalGlobalStyles() {
return (
<Global
styles={css`
.ReactModal__Body--open {
overflow: hidden;
}
`}
/>
);
}
const styleStrings = {
modalBody: `
${shadows.dropDeep};
background-color: #fff;
border-radius: ${lengths.borderRadius};
height: 80%;
text-align: center;
max-width: 2200px;
padding: 20px;
&:focus {
outline: none;
}
`,
overlay: `
z-index: ${zIndex.zIndex99999};
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
background-color: rgba(0, 0, 0, 0);
transition: background-color ${transitions.main}, opacity ${transitions.main};
`,
overlayAfterOpen: `
background-color: rgba(0, 0, 0, 0.6);
opacity: 1;
`,
overlayBeforeClose: `
background-color: rgba(0, 0, 0, 0);
opacity: 0;
`,
};
export class Modal extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
isOpen: PropTypes.bool.isRequired,
className: PropTypes.string,
onClose: PropTypes.func.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Modal.propTypes, this.props, 'prop', 'Modal');
ReactModal.setAppElement('#nc-root');
}
render() {
const { isOpen, children, className, onClose } = this.props;
return (
<>
<ReactModalGlobalStyles />
<ClassNames>
{({ css, cx }) => (
<ReactModal
isOpen={isOpen}
onRequestClose={onClose}
closeTimeoutMS={300}
className={{
base: cx(
css`
${styleStrings.modalBody};
`,
className,
),
afterOpen: '',
beforeClose: '',
}}
overlayClassName={{
base: css`
${styleStrings.overlay};
`,
afterOpen: css`
${styleStrings.overlayAfterOpen};
`,
beforeClose: css`
${styleStrings.overlayBeforeClose};
`,
}}
>
{children}
</ReactModal>
)}
</ClassNames>
</>
);
}
}

View File

@@ -0,0 +1,83 @@
// eslint-disable-next-line no-unused-vars
import React, { useEffect } from 'react';
// import { translate } from 'react-polyglot';
import { injectStyle } from 'react-toastify/dist/inject-style';
import { toast, ToastContainer } from 'react-toastify';
import { connect, useDispatch } from 'react-redux';
import { useTranslate } from 'react-polyglot';
import { dismissNotification } from '../../actions/notifications';
import type { Id, ToastItem } from 'react-toastify';
import type { State } from '../../types/redux';
import type { Notification } from '../../reducers/notifications';
injectStyle();
interface Props {
notifications: Notification[];
}
type IdMap = {
[id: string]: Id;
};
function Notifications({ notifications }: Props) {
const t = useTranslate();
const dispatch = useDispatch();
const [idMap, setIdMap] = React.useState<IdMap>({});
useEffect(() => {
notifications
.filter(notification => !idMap[notification.id])
.forEach(notification => {
const toastId = toast(
typeof notification.message == 'string'
? notification.message
: t(notification.message.key, { ...notification.message }),
{
autoClose: notification.dismissAfter,
type: notification.type,
},
);
idMap[notification.id] = toastId;
setIdMap(idMap);
if (notification.dismissAfter) {
setTimeout(() => {
dispatch(dismissNotification(notification.id));
}, notification.dismissAfter);
}
});
Object.entries(idMap).forEach(([id, toastId]) => {
if (!notifications.find(notification => notification.id === id)) {
toast.dismiss(toastId);
delete idMap[id];
setIdMap(idMap);
}
});
}, [notifications]);
toast.onChange((payload: ToastItem) => {
if (payload.status == 'removed') {
const id = Object.entries(idMap).find(([, toastId]) => toastId === payload.id)?.[0];
if (id) {
dispatch(dismissNotification(id));
}
}
});
return (
<>
<ToastContainer position="top-right" theme="colored" className="notif__container" />
</>
);
}
function mapStateToProps({ notifications }: State): Props {
return { notifications: notifications.notifications };
}
export default connect(mapStateToProps)(Notifications);

View File

@@ -0,0 +1,103 @@
import React from 'react';
import PropTypes from 'prop-types';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { Icon, Dropdown, DropdownItem, DropdownButton, colors } from 'decap-cms-ui-default';
import { stripProtocol } from '../../lib/urlHelper';
const styles = {
avatarImage: css`
width: 32px;
border-radius: 32px;
`,
};
const AvatarDropdownButton = styled(DropdownButton)`
display: inline-block;
padding: 8px;
cursor: pointer;
color: #1e2532;
background-color: transparent;
`;
const AvatarImage = styled.img`
${styles.avatarImage};
`;
const AvatarPlaceholderIcon = styled(Icon)`
${styles.avatarImage};
height: 32px;
color: #1e2532;
background-color: ${colors.textFieldBorder};
`;
const AppHeaderSiteLink = styled.a`
font-size: 14px;
font-weight: 400;
color: #7b8290;
padding: 10px 16px;
`;
const AppHeaderTestRepoIndicator = styled.a`
font-size: 14px;
font-weight: 400;
color: #7b8290;
padding: 10px 16px;
`;
function Avatar({ imageUrl }) {
return imageUrl ? (
<AvatarImage src={imageUrl} />
) : (
<AvatarPlaceholderIcon type="user" size="large" />
);
}
Avatar.propTypes = {
imageUrl: PropTypes.string,
};
function SettingsDropdown({ displayUrl, isTestRepo, imageUrl, onLogoutClick, t }) {
return (
<React.Fragment>
{isTestRepo && (
<AppHeaderTestRepoIndicator
href="https://www.decapcms.org/docs/test-backend"
target="_blank"
rel="noopener noreferrer"
>
Test Backend
</AppHeaderTestRepoIndicator>
)}
{displayUrl ? (
<AppHeaderSiteLink href={displayUrl} target="_blank">
{stripProtocol(displayUrl)}
</AppHeaderSiteLink>
) : null}
<Dropdown
dropdownTopOverlap="50px"
dropdownWidth="100px"
dropdownPosition="right"
renderButton={() => (
<AvatarDropdownButton>
<Avatar imageUrl={imageUrl} />
</AvatarDropdownButton>
)}
>
<DropdownItem label={t('ui.settingsDropdown.logOut')} onClick={onLogoutClick} />
</Dropdown>
</React.Fragment>
);
}
SettingsDropdown.propTypes = {
displayUrl: PropTypes.string,
isTestRepo: PropTypes.bool,
imageUrl: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
export default translate()(SettingsDropdown);

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { render } from '@testing-library/react';
import { oneLineTrim } from 'common-tags';
import { ErrorBoundary } from '../ErrorBoundary';
function WithError() {
throw new Error('Some unknown error');
}
jest.spyOn(console, 'error').mockImplementation(() => ({}));
Object.defineProperty(
window.navigator,
'userAgent',
(value => ({
get() {
return value;
},
set(v) {
value = v;
},
}))(window.navigator['userAgent']),
);
describe('Editor', () => {
const config = { backend: { name: 'github' } };
const props = { t: jest.fn(key => key), config };
beforeEach(() => {
jest.clearAllMocks();
});
it('should match snapshot with issue URL', () => {
global.navigator.userAgent = 'Test User Agent';
const { getByTestId } = render(
<ErrorBoundary {...props}>
<WithError />
</ErrorBoundary>,
);
expect(console.error).toHaveBeenCalledWith(new Error('Some unknown error'));
expect(getByTestId('issue-url').getAttribute('href')).toEqual(
oneLineTrim`https://github.com/decaporg/decap-cms/issues/new?
title=Error%3A+Some+unknown+error&
body=%0A**Describe+the+bug**%0A%0A**To+Reproduce**%0A%0A**Expected+behavior**%0A%0A**
Screenshots**%0A%0A**Applicable+Versions%3A**%0A+-+
Decap+CMS+version%3A+%60%60%0A+-+
Git+provider%3A+%60github%60%0A+-+
Browser+version%3A+%60Test+User+Agent%60%0A%0A**
CMS+configuration**%0A%60%60%60%0Abackend%3A%0A++name%3A+github%0A%0A%60%60%60%0A%0A**
Additional+context**%0A&labels=type%3A+bug
`,
);
});
});

View File

@@ -0,0 +1,6 @@
export { DragSource, DropTarget, HTML5DragDrop } from './DragDrop';
export { default as ErrorBoundary } from './ErrorBoundary';
export { FileUploadButton } from './FileUploadButton';
export { Modal } from './Modal';
export { default as Notifications } from './Notifications';
export { default as SettingsDropdown } from './SettingsDropdown';

View File

@@ -0,0 +1,169 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { OrderedMap } from 'immutable';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
Dropdown,
DropdownItem,
StyledDropdownButton,
Loader,
lengths,
components,
shadows,
} from 'decap-cms-ui-default';
import { createNewEntry } from '../../actions/collections';
import {
loadUnpublishedEntries,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry,
} from '../../actions/editorialWorkflow';
import { selectUnpublishedEntriesByStatus } from '../../reducers';
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
import WorkflowList from './WorkflowList';
const WorkflowContainer = styled.div`
padding: ${lengths.pageMargin} 0;
height: 100vh;
`;
const WorkflowTop = styled.div`
${components.cardTop};
`;
const WorkflowTopRow = styled.div`
display: flex;
justify-content: space-between;
span[role='button'] {
${shadows.dropDeep};
}
`;
const WorkflowTopHeading = styled.h1`
${components.cardTopHeading};
`;
const WorkflowTopDescription = styled.p`
${components.cardTopDescription};
`;
class Workflow extends Component {
static propTypes = {
collections: ImmutablePropTypes.map.isRequired,
isEditorialWorkflow: PropTypes.bool.isRequired,
isOpenAuthoring: PropTypes.bool,
isFetching: PropTypes.bool,
unpublishedEntries: ImmutablePropTypes.map,
loadUnpublishedEntries: PropTypes.func.isRequired,
updateUnpublishedEntryStatus: PropTypes.func.isRequired,
publishUnpublishedEntry: PropTypes.func.isRequired,
deleteUnpublishedEntry: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Workflow.propTypes, this.props, 'prop', 'Workflow');
const { loadUnpublishedEntries, isEditorialWorkflow, collections } = this.props;
if (isEditorialWorkflow) {
loadUnpublishedEntries(collections);
}
}
render() {
const {
isEditorialWorkflow,
isOpenAuthoring,
isFetching,
unpublishedEntries,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry,
collections,
t,
} = this.props;
if (!isEditorialWorkflow) return null;
if (isFetching) return <Loader active>{t('workflow.workflow.loading')}</Loader>;
const reviewCount = unpublishedEntries.get('pending_review').size;
const readyCount = unpublishedEntries.get('pending_publish').size;
return (
<WorkflowContainer>
<WorkflowTop>
<WorkflowTopRow>
<WorkflowTopHeading>{t('workflow.workflow.workflowHeading')}</WorkflowTopHeading>
<Dropdown
dropdownWidth="160px"
dropdownPosition="left"
dropdownTopOverlap="40px"
renderButton={() => (
<StyledDropdownButton>{t('workflow.workflow.newPost')}</StyledDropdownButton>
)}
>
{collections
.filter(collection => collection.get('create'))
.toList()
.map(collection => (
<DropdownItem
key={collection.get('name')}
label={collection.get('label')}
onClick={() => createNewEntry(collection.get('name'))}
/>
))}
</Dropdown>
</WorkflowTopRow>
<WorkflowTopDescription>
{t('workflow.workflow.description', {
smart_count: reviewCount,
readyCount,
})}
</WorkflowTopDescription>
</WorkflowTop>
<WorkflowList
entries={unpublishedEntries}
handleChangeStatus={updateUnpublishedEntryStatus}
handlePublish={publishUnpublishedEntry}
handleDelete={deleteUnpublishedEntry}
isOpenAuthoring={isOpenAuthoring}
collections={collections}
/>
</WorkflowContainer>
);
}
}
function mapStateToProps(state) {
const { collections, config, globalUI } = state;
const isEditorialWorkflow = config.publish_mode === EDITORIAL_WORKFLOW;
const isOpenAuthoring = globalUI.useOpenAuthoring;
const returnObj = { collections, isEditorialWorkflow, isOpenAuthoring };
if (isEditorialWorkflow) {
returnObj.isFetching = state.editorialWorkflow.getIn(['pages', 'isFetching'], false);
/*
* Generates an ordered Map of the available status as keys.
* Each key containing a Sequence of available unpubhlished entries
* Eg.: OrderedMap{'draft':Seq(), 'pending_review':Seq(), 'pending_publish':Seq()}
*/
returnObj.unpublishedEntries = status.reduce((acc, currStatus) => {
const entries = selectUnpublishedEntriesByStatus(state, currStatus);
return acc.set(currStatus, entries);
}, OrderedMap());
}
return returnObj;
}
export default connect(mapStateToProps, {
loadUnpublishedEntries,
updateUnpublishedEntryStatus,
publishUnpublishedEntry,
deleteUnpublishedEntry,
})(translate()(Workflow));

View File

@@ -0,0 +1,177 @@
import React from 'react';
import PropTypes from 'prop-types';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { translate } from 'react-polyglot';
import { Link } from 'react-router-dom';
import { components, colors, colorsRaw, transitions, buttons } from 'decap-cms-ui-default';
const styles = {
text: css`
font-size: 13px;
font-weight: normal;
margin-top: 4px;
`,
button: css`
${buttons.button};
width: auto;
flex: 1 0 0;
font-size: 13px;
padding: 6px 0;
`,
};
const WorkflowLink = styled(Link)`
display: block;
padding: 0 18px 18px;
height: 200px;
overflow: hidden;
`;
const CardCollection = styled.div`
font-size: 14px;
color: ${colors.textLead};
text-transform: uppercase;
margin-top: 12px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
`;
const CardTitle = styled.h2`
margin: 28px 0 0;
color: ${colors.textLead};
`;
const CardDateContainer = styled.div`
${styles.text};
`;
const CardBody = styled.p`
${styles.text};
color: ${colors.text};
margin: 24px 0 0;
overflow-wrap: break-word;
word-break: break-word;
hyphens: auto;
`;
const CardButtonContainer = styled.div`
background-color: ${colors.foreground};
position: absolute;
bottom: 0;
width: 100%;
padding: 12px 18px;
display: flex;
opacity: 0;
transition: opacity ${transitions.main};
cursor: pointer;
`;
const DeleteButton = styled.button`
${styles.button};
background-color: ${colorsRaw.redLight};
color: ${colorsRaw.red};
margin-right: 6px;
`;
const PublishButton = styled.button`
${styles.button};
background-color: ${colorsRaw.teal};
color: ${colors.textLight};
margin-left: 6px;
&[disabled] {
${buttons.disabled};
}
`;
const WorkflowCardContainer = styled.div`
${components.card};
margin-bottom: 24px;
position: relative;
overflow: hidden;
&:hover ${CardButtonContainer} {
opacity: 1;
}
`;
function lastChangePhraseKey(date, author) {
if (date && author) {
return 'lastChange';
} else if (date) {
return 'lastChangeNoAuthor';
} else if (author) {
return 'lastChangeNoDate';
}
}
const CardDate = translate()(({ t, date, author }) => {
const key = lastChangePhraseKey(date, author);
if (key) {
return (
<CardDateContainer>{t(`workflow.workflowCard.${key}`, { date, author })}</CardDateContainer>
);
}
});
function WorkflowCard({
collectionLabel,
title,
authorLastChange,
body,
isModification,
editLink,
timestamp,
onDelete,
allowPublish,
canPublish,
onPublish,
postAuthor,
t,
}) {
return (
<WorkflowCardContainer>
<WorkflowLink to={editLink}>
<CardCollection>{collectionLabel}</CardCollection>
{postAuthor}
<CardTitle>{title}</CardTitle>
{(timestamp || authorLastChange) && <CardDate date={timestamp} author={authorLastChange} />}
<CardBody>{body}</CardBody>
</WorkflowLink>
<CardButtonContainer>
<DeleteButton onClick={onDelete}>
{isModification
? t('workflow.workflowCard.deleteChanges')
: t('workflow.workflowCard.deleteNewEntry')}
</DeleteButton>
{allowPublish && (
<PublishButton disabled={!canPublish} onClick={onPublish}>
{isModification
? t('workflow.workflowCard.publishChanges')
: t('workflow.workflowCard.publishNewEntry')}
</PublishButton>
)}
</CardButtonContainer>
</WorkflowCardContainer>
);
}
WorkflowCard.propTypes = {
collectionLabel: PropTypes.string.isRequired,
title: PropTypes.string,
authorLastChange: PropTypes.string,
body: PropTypes.string,
isModification: PropTypes.bool,
editLink: PropTypes.string.isRequired,
timestamp: PropTypes.string.isRequired,
onDelete: PropTypes.func.isRequired,
allowPublish: PropTypes.bool.isRequired,
canPublish: PropTypes.bool.isRequired,
onPublish: PropTypes.func.isRequired,
postAuthor: PropTypes.string,
t: PropTypes.func.isRequired,
};
export default translate()(WorkflowCard);

View File

@@ -0,0 +1,272 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import dayjs from 'dayjs';
import { translate } from 'react-polyglot';
import { colors, lengths } from 'decap-cms-ui-default';
import { status } from '../../constants/publishModes';
import { DragSource, DropTarget, HTML5DragDrop } from '../UI';
import WorkflowCard from './WorkflowCard';
import { selectEntryCollectionTitle } from '../../reducers/collections';
const WorkflowListContainer = styled.div`
min-height: 60%;
display: grid;
grid-template-columns: 33.3% 33.3% 33.3%;
`;
const WorkflowListContainerOpenAuthoring = styled.div`
min-height: 60%;
display: grid;
grid-template-columns: 50% 50% 0%;
`;
const styles = {
columnPosition: idx =>
(idx === 0 &&
css`
margin-left: 0;
`) ||
(idx === 2 &&
css`
margin-right: 0;
`) ||
css`
&:before,
&:after {
content: '';
display: block;
position: absolute;
width: 2px;
height: 80%;
top: 76px;
background-color: ${colors.textFieldBorder};
}
&:before {
left: -23px;
}
&:after {
right: -23px;
}
`,
column: css`
margin: 0 20px;
transition: background-color 0.5s ease;
border: 2px dashed transparent;
border-radius: 4px;
position: relative;
height: 100%;
`,
columnHovered: css`
border-color: ${colors.active};
`,
hiddenColumn: css`
display: none;
`,
hiddenRightBorder: css`
&:not(:first-child):not(:last-child) {
&:after {
display: none;
}
}
`,
};
const ColumnHeader = styled.h2`
font-size: 20px;
font-weight: normal;
padding: 4px 14px;
border-radius: ${lengths.borderRadius};
margin-bottom: 28px;
${props =>
props.name === 'draft' &&
css`
background-color: ${colors.statusDraftBackground};
color: ${colors.statusDraftText};
`}
${props =>
props.name === 'pending_review' &&
css`
background-color: ${colors.statusReviewBackground};
color: ${colors.statusReviewText};
`}
${props =>
props.name === 'pending_publish' &&
css`
background-color: ${colors.statusReadyBackground};
color: ${colors.statusReadyText};
`}
`;
const ColumnCount = styled.p`
font-size: 13px;
font-weight: 500;
color: ${colors.text};
text-transform: uppercase;
margin-bottom: 6px;
`;
// This is a namespace so that we can only drop these elements on a DropTarget with the same
const DNDNamespace = 'cms-workflow';
function getColumnHeaderText(columnName, t) {
switch (columnName) {
case 'draft':
return t('workflow.workflowList.draftHeader');
case 'pending_review':
return t('workflow.workflowList.inReviewHeader');
case 'pending_publish':
return t('workflow.workflowList.readyHeader');
}
}
class WorkflowList extends React.Component {
static propTypes = {
entries: ImmutablePropTypes.orderedMap,
handleChangeStatus: PropTypes.func.isRequired,
handlePublish: PropTypes.func.isRequired,
handleDelete: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
isOpenAuthoring: PropTypes.bool,
collections: ImmutablePropTypes.map.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(WorkflowList.propTypes, this.props, 'prop', 'WorkflowList');
}
handleChangeStatus = (newStatus, dragProps) => {
const slug = dragProps.slug;
const collection = dragProps.collection;
const oldStatus = dragProps.ownStatus;
this.props.handleChangeStatus(collection, slug, oldStatus, newStatus);
};
requestDelete = (collection, slug, ownStatus) => {
if (window.confirm(this.props.t('workflow.workflowList.onDeleteEntry'))) {
this.props.handleDelete(collection, slug, ownStatus);
}
};
requestPublish = (collection, slug, ownStatus) => {
if (ownStatus !== status.last()) {
window.alert(this.props.t('workflow.workflowList.onPublishingNotReadyEntry'));
return;
} else if (!window.confirm(this.props.t('workflow.workflowList.onPublishEntry'))) {
return;
}
this.props.handlePublish(collection, slug);
};
// eslint-disable-next-line react/display-name
renderColumns = (entries, column) => {
const { isOpenAuthoring, collections, t } = this.props;
if (!entries) return null;
if (!column) {
return entries.entrySeq().map(([currColumn, currEntries], idx) => (
<DropTarget
namespace={DNDNamespace}
key={currColumn}
onDrop={this.handleChangeStatus.bind(this, currColumn)}
>
{(connect, { isHovered }) =>
connect(
<div style={{ height: '100%' }}>
<div
css={[
styles.column,
styles.columnPosition(idx),
isHovered && styles.columnHovered,
isOpenAuthoring && currColumn === 'pending_publish' && styles.hiddenColumn,
isOpenAuthoring && currColumn === 'pending_review' && styles.hiddenRightBorder,
]}
>
<ColumnHeader name={currColumn}>
{getColumnHeaderText(currColumn, this.props.t)}
</ColumnHeader>
<ColumnCount>
{this.props.t('workflow.workflowList.currentEntries', {
smart_count: currEntries.size,
})}
</ColumnCount>
{this.renderColumns(currEntries, currColumn)}
</div>
</div>,
)
}
</DropTarget>
));
}
return (
<div>
{entries.map(entry => {
const timestamp = dayjs(entry.get('updatedOn')).format(t('workflow.workflow.dateFormat'));
const slug = entry.get('slug');
const collectionName = entry.get('collection');
const editLink = `collections/${collectionName}/entries/${slug}?ref=workflow`;
const ownStatus = entry.get('status');
const collection = collections.find(
collection => collection.get('name') === collectionName,
);
const collectionLabel = collection?.get('label');
const isModification = entry.get('isModification');
const allowPublish = collection?.get('publish');
const canPublish = ownStatus === status.last() && !entry.get('isPersisting', false);
const postAuthor = entry.get('author');
return (
<DragSource
namespace={DNDNamespace}
key={`${collectionName}-${slug}`}
slug={slug}
collection={collectionName}
ownStatus={ownStatus}
>
{connect =>
connect(
<div>
<WorkflowCard
collectionLabel={collectionLabel || collectionName}
title={selectEntryCollectionTitle(collection, entry)}
authorLastChange={entry.getIn(['metaData', 'user'])}
body={entry.getIn(['data', 'body'])}
isModification={isModification}
editLink={editLink}
timestamp={timestamp}
onDelete={this.requestDelete.bind(this, collectionName, slug, ownStatus)}
allowPublish={allowPublish}
canPublish={canPublish}
onPublish={this.requestPublish.bind(this, collectionName, slug, ownStatus)}
postAuthor={postAuthor}
/>
</div>,
)
}
</DragSource>
);
})}
</div>
);
};
render() {
const columns = this.renderColumns(this.props.entries);
const ListContainer = this.props.isOpenAuthoring
? WorkflowListContainerOpenAuthoring
: WorkflowListContainer;
return <ListContainer>{columns}</ListContainer>;
}
}
export default HTML5DragDrop(translate()(WorkflowList));

View File

@@ -0,0 +1,524 @@
import merge from 'lodash/merge';
import { validateConfig } from '../configSchema';
jest.mock('../../lib/registry');
describe('config', () => {
/**
* Suppress error logging to reduce noise during testing. Jest will still
* log test failures and associated errors as expected.
*/
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
const { getWidgets } = require('../../lib/registry');
getWidgets.mockImplementation(() => [{}]);
describe('validateConfig', () => {
const validConfig = {
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: [
{
name: 'posts',
label: 'Posts',
folder: '_posts',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
},
],
};
it('should not throw if no errors', () => {
expect(() => {
validateConfig(validConfig);
}).not.toThrowError();
});
it('should throw if backend is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar' });
}).toThrowError("config must have required property 'backend'");
});
it('should throw if backend name is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: {} });
}).toThrowError("'backend' must have required property 'name'");
});
it('should throw if backend name is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: {} } });
}).toThrowError("'backend.name' must be string");
});
it('should throw if backend.open_authoring is not a boolean in config', () => {
expect(() => {
validateConfig(merge(validConfig, { backend: { open_authoring: 'true' } }));
}).toThrowError("'backend.open_authoring' must be boolean");
});
it('should not throw if backend.open_authoring is boolean in config', () => {
expect(() => {
validateConfig(merge(validConfig, { backend: { open_authoring: true } }));
}).not.toThrowError();
});
it('should throw if backend.auth_scope is not "repo" or "public_repo" in config', () => {
expect(() => {
validateConfig(merge(validConfig, { backend: { auth_scope: 'user' } }));
}).toThrowError("'backend.auth_scope' must be equal to one of the allowed values");
});
it('should not throw if backend.auth_scope is one of "repo" or "public_repo" in config', () => {
expect(() => {
validateConfig(merge(validConfig, { backend: { auth_scope: 'repo' } }));
}).not.toThrowError();
expect(() => {
validateConfig(merge(validConfig, { backend: { auth_scope: 'public_repo' } }));
}).not.toThrowError();
});
it('should throw if media_folder is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' } });
}).toThrowError("config must have required property 'media_folder'");
});
it('should throw if media_folder is not a string in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: {} });
}).toThrowError("'media_folder' must be string");
});
it('should throw if collections is not defined in config', () => {
expect(() => {
validateConfig({ foo: 'bar', backend: { name: 'bar' }, media_folder: 'baz' });
}).toThrowError("config must have required property 'collections'");
});
it('should throw if collections not an array in config', () => {
expect(() => {
validateConfig({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: {},
});
}).toThrowError("'collections' must be array");
});
it('should throw if collections is an empty array in config', () => {
expect(() => {
validateConfig({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: [],
});
}).toThrowError("'collections' must NOT have fewer than 1 items");
});
it('should throw if collections is an array with a single null element in config', () => {
expect(() => {
validateConfig({
foo: 'bar',
backend: { name: 'bar' },
media_folder: 'baz',
collections: [null],
});
}).toThrowError("'collections[0]' must be object");
});
it('should throw if local_backend is not a boolean or plain object', () => {
expect(() => {
validateConfig({ ...validConfig, local_backend: [] });
}).toThrowError("'local_backend' must be boolean");
});
it('should throw if local_backend url is not a string', () => {
expect(() => {
validateConfig({ ...validConfig, local_backend: { url: [] } });
}).toThrowError("'local_backend.url' must be string");
});
it('should throw if local_backend allowed_hosts is not a string array', () => {
expect(() => {
validateConfig({ ...validConfig, local_backend: { allowed_hosts: [true] } });
}).toThrowError("'local_backend.allowed_hosts[0]' must be string");
});
it('should not throw if local_backend is a boolean', () => {
expect(() => {
validateConfig({ ...validConfig, local_backend: true });
}).not.toThrowError();
});
it('should not throw if local_backend is a plain object with url string property', () => {
expect(() => {
validateConfig({ ...validConfig, local_backend: { url: 'http://localhost:8081/api/v1' } });
}).not.toThrowError();
});
it('should not throw if local_backend is a plain object with allowed_hosts string array property', () => {
expect(() => {
validateConfig({
...validConfig,
local_backend: { allowed_hosts: ['192.168.0.1'] },
});
}).not.toThrowError();
});
it('should throw if collection publish is not a boolean', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ publish: 'false' }] }));
}).toThrowError("'collections[0].publish' must be boolean");
});
it('should not throw if collection publish is a boolean', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ publish: false }] }));
}).not.toThrowError();
});
it('should throw if collections sortable_fields is not a boolean or a string array', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ sortable_fields: 'title' }] }));
}).toThrowError("'collections[0].sortable_fields' must be array");
});
it('should allow sortable_fields to be a string array', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ sortable_fields: ['title'] }] }));
}).not.toThrow();
});
it('should allow sortable_fields to be a an empty array', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ sortable_fields: [] }] }));
}).not.toThrow();
});
it('should allow sortableFields instead of sortable_fields', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ sortableFields: [] }] }));
}).not.toThrow();
});
it('should throw if both sortable_fields and sortableFields exist', () => {
expect(() => {
validateConfig(
merge({}, validConfig, { collections: [{ sortable_fields: [], sortableFields: [] }] }),
);
}).toThrowError("'collections[0]' must NOT be valid");
});
it('should throw if collection names are not unique', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [validConfig.collections[0], validConfig.collections[0]],
}),
);
}).toThrowError("'collections' collections names must be unique");
});
it('should throw if collection file names are not unique', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{},
{
files: [
{
name: 'a',
label: 'a',
file: 'a.md',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
},
{
name: 'a',
label: 'b',
file: 'b.md',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
},
],
},
],
}),
);
}).toThrowError("'collections[1].files' files names must be unique");
});
it('should throw if collection fields names are not unique', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{
fields: [
{ name: 'title', label: 'title', widget: 'string' },
{ name: 'title', label: 'other title', widget: 'string' },
],
},
],
}),
);
}).toThrowError("'collections[0].fields' fields names must be unique");
});
it('should not throw if collection fields are unique across nesting levels', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{
fields: [
{ name: 'title', label: 'title', widget: 'string' },
{
name: 'object',
label: 'Object',
widget: 'object',
fields: [{ name: 'title', label: 'title', widget: 'string' }],
},
],
},
],
}),
);
}).not.toThrow();
});
describe('nested validation', () => {
const { getWidgets } = require('../../lib/registry');
getWidgets.mockImplementation(() => [
{
name: 'relation',
schema: {
properties: {
search_fields: { type: 'array', items: { type: 'string' } },
display_fields: { type: 'array', items: { type: 'string' } },
},
},
},
]);
it('should throw if nested relation display_fields and search_fields are not arrays', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{
fields: [
{ name: 'title', label: 'title', widget: 'string' },
{
name: 'object',
label: 'Object',
widget: 'object',
fields: [
{ name: 'title', label: 'title', widget: 'string' },
{
name: 'relation',
label: 'relation',
widget: 'relation',
display_fields: 'title',
search_fields: 'title',
},
],
},
],
},
],
}),
);
}).toThrowError(
"'collections[0].fields[1].fields[1].search_fields' must be array\n'collections[0].fields[1].fields[1].display_fields' must be array",
);
});
it('should not throw if nested relation display_fields and search_fields are arrays', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{
fields: [
{ name: 'title', label: 'title', widget: 'string' },
{
name: 'object',
label: 'Object',
widget: 'object',
fields: [
{ name: 'title', label: 'title', widget: 'string' },
{
name: 'relation',
label: 'relation',
widget: 'relation',
display_fields: ['title'],
search_fields: ['title'],
},
],
},
],
},
],
}),
);
}).not.toThrow();
});
});
it('should throw if collection meta is not a plain object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: [] }] }));
}).toThrowError("'collections[0].meta' must be object");
});
it('should throw if collection meta is an empty object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: {} }] }));
}).toThrowError("'collections[0].meta' must NOT have fewer than 1 properties");
});
it('should throw if collection meta is an empty object', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ meta: { path: {} } }] }));
}).toThrowError("'collections[0].meta.path' must have required property 'label'");
expect(() => {
validateConfig(
merge({}, validConfig, { collections: [{ meta: { path: { label: 'Label' } } }] }),
);
}).toThrowError("'collections[0].meta.path' must have required property 'widget'");
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [{ meta: { path: { label: 'Label', widget: 'widget' } } }],
}),
);
}).toThrowError("'collections[0].meta.path' must have required property 'index_file'");
});
it('should allow collection meta to have a path configuration', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [
{ meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } } },
],
}),
);
}).not.toThrow();
});
it('should throw if collection field pattern is not an array', () => {
expect(() => {
validateConfig(merge({}, validConfig, { collections: [{ fields: [{ pattern: '' }] }] }));
}).toThrowError("'collections[0].fields[0].pattern' must be array");
});
it('should throw if collection field pattern is not an array of [string|regex, string]', () => {
expect(() => {
validateConfig(
merge({}, validConfig, { collections: [{ fields: [{ pattern: [1, ''] }] }] }),
);
}).toThrowError(
"'collections[0].fields[0].pattern[0]' must be string\n'collections[0].fields[0].pattern[0]' must be a regular expression",
);
expect(() => {
validateConfig(
merge({}, validConfig, { collections: [{ fields: [{ pattern: ['', 1] }] }] }),
);
}).toThrowError("'collections[0].fields[0].pattern[1]' must be string");
});
it('should allow collection field pattern to be an array of [string|regex, string]', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [{ fields: [{ pattern: ['pattern', 'error'] }] }],
}),
);
}).not.toThrow();
expect(() => {
validateConfig(
merge({}, validConfig, {
collections: [{ fields: [{ pattern: [/pattern/, 'error'] }] }],
}),
);
}).not.toThrow();
});
describe('i18n', () => {
it('should throw error when locale has invalid characters', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 'tr.TR'],
},
}),
);
}).toThrowError(`'i18n.locales[1]' must match pattern "^[a-zA-Z-_]+$"`);
});
it('should throw error when locale is less than 2 characters', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 't'],
},
}),
);
}).toThrowError(`'i18n.locales[1]' must NOT have fewer than 2 characters`);
});
it('should throw error when locale is more than 10 characters', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 'a_very_long_locale'],
},
}),
);
}).toThrowError(`'i18n.locales[1]' must NOT have more than 10 characters`);
});
it('should throw error when locales is less than 1 items', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: [],
},
}),
);
}).toThrowError(`'i18n.locales' must NOT have fewer than 1 items`);
});
it('should allow valid locales strings', () => {
expect(() => {
validateConfig(
merge({}, validConfig, {
i18n: {
structure: 'multiple_folders',
locales: ['en', 'tr-TR', 'zh_CHS'],
},
}),
);
}).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,2 @@
export const FILES = 'file_based_collection';
export const FOLDER = 'folder_based_collection';

View File

@@ -0,0 +1,2 @@
export const VIEW_STYLE_LIST = 'VIEW_STYLE_LIST';
export const VIEW_STYLE_GRID = 'VIEW_STYLE_GRID';

View File

@@ -0,0 +1,2 @@
export const COMMIT_AUTHOR = 'commit_author';
export const COMMIT_DATE = 'commit_date';

View File

@@ -0,0 +1,408 @@
import AJV from 'ajv';
import {
select,
uniqueItemProperties,
instanceof as instanceOf,
prohibited,
} from 'ajv-keywords/dist/keywords';
import ajvErrors from 'ajv-errors';
import { v4 as uuid } from 'uuid';
import { frontmatterFormats, extensionFormatters } from '../formats/formats';
import { getWidgets } from '../lib/registry';
import { I18N_STRUCTURE, I18N_FIELD } from '../lib/i18n';
const localeType = { type: 'string', minLength: 2, maxLength: 10, pattern: '^[a-zA-Z-_]+$' };
const i18n = {
type: 'object',
properties: {
structure: { type: 'string', enum: Object.values(I18N_STRUCTURE) },
locales: {
type: 'array',
minItems: 1,
items: localeType,
uniqueItems: true,
},
default_locale: localeType,
},
};
const i18nRoot = {
...i18n,
required: ['structure', 'locales'],
};
const i18nCollection = {
oneOf: [{ type: 'boolean' }, i18n],
};
const i18nField = {
oneOf: [{ type: 'boolean' }, { type: 'string', enum: Object.values(I18N_FIELD) }],
};
/**
* Config for fields in both file and folder collections.
*/
function fieldsConfig() {
const id = uuid();
return {
$id: `fields_${id}`,
type: 'array',
minItems: 1,
items: {
// ------- Each field: -------
$id: `field_${id}`,
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
widget: { type: 'string' },
required: { type: 'boolean' },
i18n: i18nField,
hint: { type: 'string' },
pattern: {
type: 'array',
minItems: 2,
items: [{ oneOf: [{ type: 'string' }, { instanceof: 'RegExp' }] }, { type: 'string' }],
},
field: { $ref: `field_${id}` },
fields: { $ref: `fields_${id}` },
types: { $ref: `fields_${id}` },
},
select: { $data: '0/widget' },
selectCases: {
...getWidgetSchemas(),
},
required: ['name'],
},
uniqueItemProperties: ['name'],
};
}
const viewFilters = {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
label: { type: 'string' },
field: { type: 'string' },
pattern: {
oneOf: [
{ type: 'boolean' },
{
type: 'string',
},
],
},
},
additionalProperties: false,
required: ['label', 'field', 'pattern'],
},
};
const viewGroups = {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
label: { type: 'string' },
field: { type: 'string' },
pattern: { type: 'string' },
},
additionalProperties: false,
required: ['label', 'field'],
},
};
/**
* The schema had to be wrapped in a function to
* fix a circular dependency problem for WebPack,
* where the imports get resolved asynchronously.
*/
function getConfigSchema() {
return {
type: 'object',
properties: {
backend: {
type: 'object',
properties: {
name: { type: 'string', examples: ['test-repo'] },
auth_scope: {
type: 'string',
examples: ['repo', 'public_repo'],
enum: ['repo', 'public_repo'],
},
cms_label_prefix: { type: 'string', minLength: 1 },
open_authoring: { type: 'boolean', examples: [true] },
},
required: ['name'],
},
local_backend: {
oneOf: [
{ type: 'boolean' },
{
type: 'object',
properties: {
url: { type: 'string', examples: ['http://localhost:8081/api/v1'] },
allowed_hosts: {
type: 'array',
items: { type: 'string' },
},
},
additionalProperties: false,
},
],
},
locale: { type: 'string', examples: ['en', 'fr', 'de'] },
i18n: i18nRoot,
site_url: { type: 'string', examples: ['https://example.com'] },
display_url: { type: 'string', examples: ['https://example.com'] },
logo_url: { type: 'string', examples: ['https://example.com/images/logo.svg'] }, // Deprecated, replaced by `logo.src`
logo: {
type: 'object',
properties: {
src: { type: 'string', examples: ['https://example.com/images/logo.svg'] },
show_in_header: { type: 'boolean' },
},
required: ['src'],
},
show_preview_links: { type: 'boolean' },
media_folder: { type: 'string', examples: ['assets/uploads'] },
public_folder: { type: 'string', examples: ['/uploads'] },
media_folder_relative: { type: 'boolean' },
media_library: {
type: 'object',
properties: {
name: { type: 'string', examples: ['uploadcare'] },
config: { type: 'object' },
},
required: ['name'],
},
publish_mode: {
type: 'string',
enum: ['simple', 'editorial_workflow', ''],
examples: ['editorial_workflow'],
},
slug: {
type: 'object',
properties: {
encoding: { type: 'string', enum: ['unicode', 'ascii'] },
clean_accents: { type: 'boolean' },
},
},
collections: {
type: 'array',
minItems: 1,
items: {
// ------- Each collection: -------
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
label_singular: { type: 'string' },
description: { type: 'string' },
folder: { type: 'string' },
files: {
type: 'array',
items: {
// ------- Each file: -------
type: 'object',
properties: {
name: { type: 'string' },
label: { type: 'string' },
label_singular: { type: 'string' },
description: { type: 'string' },
file: { type: 'string' },
preview_path: { type: 'string' },
preview_path_date_field: { type: 'string' },
fields: fieldsConfig(),
},
required: ['name', 'label', 'file', 'fields'],
},
uniqueItemProperties: ['name'],
},
identifier_field: { type: 'string' },
summary: { type: 'string' },
slug: { type: 'string' },
path: { type: 'string' },
preview_path: { type: 'string' },
preview_path_date_field: { type: 'string' },
create: { type: 'boolean' },
publish: { type: 'boolean' },
hide: { type: 'boolean' },
editor: {
type: 'object',
properties: {
preview: { type: 'boolean' },
},
},
format: { type: 'string' },
extension: { type: 'string' },
frontmatter_delimiter: {
type: ['string', 'array'],
minItems: 2,
maxItems: 2,
items: {
type: 'string',
},
},
fields: fieldsConfig(),
sortable_fields: {
type: 'array',
items: {
type: 'string',
},
},
sortableFields: {
type: 'array',
items: {
type: 'string',
},
},
view_filters: viewFilters,
view_groups: viewGroups,
nested: {
type: 'object',
properties: {
depth: { type: 'number', minimum: 1, maximum: 1000 },
subfolders: { type: 'boolean' },
summary: { type: 'string' },
},
required: ['depth'],
},
meta: {
type: 'object',
properties: {
path: {
type: 'object',
properties: {
label: { type: 'string' },
widget: { type: 'string' },
index_file: { type: 'string' },
},
required: ['label', 'widget', 'index_file'],
},
},
additionalProperties: false,
minProperties: 1,
},
i18n: i18nCollection,
},
required: ['name', 'label'],
oneOf: [{ required: ['files'] }, { required: ['folder', 'fields'] }],
not: {
required: ['sortable_fields', 'sortableFields'],
},
if: { required: ['extension'] },
then: {
// Cannot infer format from extension.
if: {
properties: {
extension: { enum: Object.keys(extensionFormatters) },
},
},
else: { required: ['format'] },
},
dependencies: {
frontmatter_delimiter: {
properties: {
format: { enum: frontmatterFormats },
},
required: ['format'],
},
},
},
uniqueItemProperties: ['name'],
},
editor: {
type: 'object',
properties: {
preview: { type: 'boolean' },
},
},
},
required: ['backend', 'collections'],
anyOf: [{ required: ['media_folder'] }, { required: ['media_library'] }],
};
}
function getWidgetSchemas() {
const schemas = getWidgets().map(widget => ({ [widget.name]: widget.schema }));
return Object.assign(...schemas);
}
class ConfigError extends Error {
constructor(errors, ...args) {
const message = errors
.map(({ message, instancePath }) => {
const dotPath = instancePath
.slice(1)
.split('/')
.map(seg => (seg.match(/^\d+$/) ? `[${seg}]` : `.${seg}`))
.join('')
.slice(1);
return `${dotPath ? `'${dotPath}'` : 'config'} ${message}`;
})
.join('\n');
super(message, ...args);
this.errors = errors;
this.message = message;
}
toString() {
return this.message;
}
}
/**
* `validateConfig` is a pure function. It does not mutate
* the config that is passed in.
*/
export function validateConfig(config) {
const ajv = new AJV({ allErrors: true, $data: true, strict: false });
uniqueItemProperties(ajv);
select(ajv);
instanceOf(ajv);
prohibited(ajv);
ajvErrors(ajv);
const valid = ajv.validate(getConfigSchema(), config);
if (!valid) {
const errors = ajv.errors.map(e => {
switch (e.keyword) {
// TODO: remove after https://github.com/ajv-validator/ajv-keywords/pull/123 is merged
case 'uniqueItemProperties': {
const path = e.instancePath || '';
let newError = e;
if (path.endsWith('/fields')) {
newError = { ...e, message: 'fields names must be unique' };
} else if (path.endsWith('/files')) {
newError = { ...e, message: 'files names must be unique' };
} else if (path.endsWith('/collections')) {
newError = { ...e, message: 'collections names must be unique' };
}
return newError;
}
case 'instanceof': {
const path = e.instancePath || '';
let newError = e;
if (/fields\/\d+\/pattern\/\d+/.test(path)) {
newError = {
...e,
message: 'must be a regular expression',
};
}
return newError;
}
default:
return e;
}
});
console.error('Config Errors', errors);
throw new ConfigError(errors);
}
}

View File

@@ -0,0 +1,78 @@
import React from 'react';
export const IDENTIFIER_FIELDS = ['title', 'path'] as const;
export const SORTABLE_FIELDS = ['title', 'date', 'author', 'description'] as const;
export const INFERABLE_FIELDS = {
title: {
type: 'string',
secondaryTypes: [],
synonyms: ['title', 'name', 'label', 'headline', 'header'],
defaultPreview: (value: React.ReactNode) => <h1>{value}</h1>, // eslint-disable-line react/display-name
fallbackToFirstField: true,
showError: true,
},
shortTitle: {
type: 'string',
secondaryTypes: [],
synonyms: ['short_title', 'shortTitle', 'short'],
defaultPreview: (value: React.ReactNode) => <h2>{value}</h2>, // eslint-disable-line react/display-name
fallbackToFirstField: false,
showError: false,
},
author: {
type: 'string',
secondaryTypes: [],
synonyms: ['author', 'name', 'by', 'byline', 'owner'],
defaultPreview: (value: React.ReactNode) => <strong>{value}</strong>, // eslint-disable-line react/display-name
fallbackToFirstField: false,
showError: false,
},
date: {
type: 'datetime',
secondaryTypes: ['date'],
synonyms: ['date', 'publishDate', 'publish_date'],
defaultPreview: (value: React.ReactNode) => value,
fallbackToFirstField: false,
showError: false,
},
description: {
type: 'string',
secondaryTypes: ['text', 'markdown'],
synonyms: [
'shortDescription',
'short_description',
'shortdescription',
'description',
'intro',
'introduction',
'brief',
'content',
'biography',
'bio',
'summary',
],
defaultPreview: (value: React.ReactNode) => value,
fallbackToFirstField: false,
showError: false,
},
image: {
type: 'image',
secondaryTypes: [],
synonyms: [
'image',
'thumbnail',
'thumb',
'picture',
'avatar',
'photo',
'cover',
'hero',
'logo',
],
defaultPreview: (value: React.ReactNode) => value,
fallbackToFirstField: false,
showError: false,
},
};

View File

@@ -0,0 +1,22 @@
import { Map, OrderedMap } from 'immutable';
// Create/edit workflow modes
export const SIMPLE = 'simple';
export const EDITORIAL_WORKFLOW = 'editorial_workflow';
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;

Some files were not shown because too many files have changed in this diff Show More