add-cms
This commit is contained in:
1583
source/admin/packages/decap-cms-core/CHANGELOG.md
Normal file
1583
source/admin/packages/decap-cms-core/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
9
source/admin/packages/decap-cms-core/README.md
Normal file
9
source/admin/packages/decap-cms-core/README.md
Normal 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)!
|
||||
610
source/admin/packages/decap-cms-core/index.d.ts
vendored
Normal file
610
source/admin/packages/decap-cms-core/index.d.ts
vendored
Normal 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;
|
||||
}
|
||||
103
source/admin/packages/decap-cms-core/package.json
Normal file
103
source/admin/packages/decap-cms-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1009
source/admin/packages/decap-cms-core/src/__tests__/backend.spec.js
Normal file
1009
source/admin/packages/decap-cms-core/src/__tests__/backend.spec.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: '<script>alert('hello')</script>' },
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
source/admin/packages/decap-cms-core/src/actions/auth.ts
Normal file
127
source/admin/packages/decap-cms-core/src/actions/auth.ts
Normal 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
|
||||
>;
|
||||
@@ -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));
|
||||
}
|
||||
540
source/admin/packages/decap-cms-core/src/actions/config.ts
Normal file
540
source/admin/packages/decap-cms-core/src/actions/config.ts
Normal 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
|
||||
>;
|
||||
104
source/admin/packages/decap-cms-core/src/actions/deploys.ts
Normal file
104
source/admin/packages/decap-cms-core/src/actions/deploys.ts
Normal 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
|
||||
>;
|
||||
@@ -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')));
|
||||
});
|
||||
};
|
||||
}
|
||||
1045
source/admin/packages/decap-cms-core/src/actions/entries.ts
Normal file
1045
source/admin/packages/decap-cms-core/src/actions/entries.ts
Normal file
File diff suppressed because it is too large
Load Diff
139
source/admin/packages/decap-cms-core/src/actions/media.ts
Normal file
139
source/admin/packages/decap-cms-core/src/actions/media.ts
Normal 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
|
||||
>;
|
||||
574
source/admin/packages/decap-cms-core/src/actions/mediaLibrary.ts
Normal file
574
source/admin/packages/decap-cms-core/src/actions/mediaLibrary.ts
Normal 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
|
||||
>;
|
||||
@@ -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 };
|
||||
221
source/admin/packages/decap-cms-core/src/actions/search.ts
Normal file
221
source/admin/packages/decap-cms-core/src/actions/search.ts
Normal 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
|
||||
>;
|
||||
99
source/admin/packages/decap-cms-core/src/actions/status.ts
Normal file
99
source/admin/packages/decap-cms-core/src/actions/status.ts
Normal 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
|
||||
>;
|
||||
@@ -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;
|
||||
}
|
||||
1398
source/admin/packages/decap-cms-core/src/backend.ts
Normal file
1398
source/admin/packages/decap-cms-core/src/backend.ts
Normal file
File diff suppressed because it is too large
Load Diff
104
source/admin/packages/decap-cms-core/src/bootstrap.js
vendored
Normal file
104
source/admin/packages/decap-cms-core/src/bootstrap.js
vendored
Normal 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;
|
||||
286
source/admin/packages/decap-cms-core/src/components/App/App.js
Normal file
286
source/admin/packages/decap-cms-core/src/components/App/App.js
Normal 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));
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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)));
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { registerWidget } from '../../lib/registry';
|
||||
import UnknownControl from './Unknown/UnknownControl';
|
||||
import UnknownPreview from './Unknown/UnknownPreview';
|
||||
|
||||
registerWidget('unknown', UnknownControl, UnknownPreview);
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
112
source/admin/packages/decap-cms-core/src/components/UI/Modal.js
Normal file
112
source/admin/packages/decap-cms-core/src/components/UI/Modal.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const FILES = 'file_based_collection';
|
||||
export const FOLDER = 'folder_based_collection';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const VIEW_STYLE_LIST = 'VIEW_STYLE_LIST';
|
||||
export const VIEW_STYLE_GRID = 'VIEW_STYLE_GRID';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const COMMIT_AUTHOR = 'commit_author';
|
||||
export const COMMIT_DATE = 'commit_date';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user